diff --git a/docs/reference/values.md b/docs/reference/values.md index 8a1aa5de..8638d2f8 100644 --- a/docs/reference/values.md +++ b/docs/reference/values.md @@ -52,16 +52,35 @@ export default class extends Controller { ## Types -A value's type is one of `Array`, `Boolean`, `Number`, `Object`, or `String`. The type determines how the value is transcoded between JavaScript and HTML. +A value's type is one of `Array`, `Boolean`, `DOMTokenList`, `Number`, `Object`, or `String`. The type determines how the value is transcoded between JavaScript and HTML. | Type | Encoded as… | Decoded as… | | ------- | ------------------------ | --------------------------------------- | | Array | `JSON.stringify(array)` | `JSON.parse(value)` | | Boolean | `boolean.toString()` | `!(value == "0" \|\| value == "false")` | +| DOMTokenList | `tokens.join(" ")` | Space-separated tokens, deduplicated | | Number | `number.toString()` | `Number(value.replace(/_/g, ""))` | | Object | `JSON.stringify(object)` | `JSON.parse(value)` | | String | Itself | Itself | +### List Values + +Declare a list value with the `DOMTokenList` constructor to store a simple array of string tokens as a space-separated attribute, the same format the platform uses for the `class` attribute and `DOMTokenList` properties like `classList`: + +```js +export default class extends Controller { + static values = { + permittedTypes: DOMTokenList + } +} +``` + +```html +
+``` + +Reading a list value returns an array of strings. Like `DOMTokenList`, repeated tokens are deduplicated, keeping the first occurrence. Writing joins the tokens with single spaces; assigning a value that is not an array, or a token that is empty or contains whitespace, throws a `TypeError`. Use the `Array` type instead when your values are arbitrary strings rather than simple tokens. Default values declared for a list value must themselves be valid tokens. + ## Properties and Attributes Stimulus automatically generates getter, setter, and existential properties for each value defined in a controller. These properties are linked to data attributes on the controller's element: @@ -82,6 +101,7 @@ Type | Default value ---- | ------------- Array | `[]` Boolean | `false` +DOMTokenList | `[]` Number | `0` Object | `{}` String | `""` diff --git a/src/core/value_observer.ts b/src/core/value_observer.ts index 2d12c9cd..abc39260 100644 --- a/src/core/value_observer.ts +++ b/src/core/value_observer.ts @@ -90,7 +90,7 @@ export class ValueObserver implements StringMapObserverDelegate { const value = descriptor.reader(rawValue) let oldValue = rawOldValue - if (rawOldValue) { + if (rawOldValue !== undefined) { oldValue = descriptor.reader(rawOldValue) } diff --git a/src/core/value_properties.ts b/src/core/value_properties.ts index c15e2d9a..0038cd5f 100644 --- a/src/core/value_properties.ts +++ b/src/core/value_properties.ts @@ -74,7 +74,13 @@ export type ValueDefinitionMap = { [token: string]: ValueTypeDefinition } export type ValueDefinitionPair = [string, ValueTypeDefinition] -export type ValueTypeConstant = typeof Array | typeof Boolean | typeof Number | typeof Object | typeof String +export type ValueTypeConstant = + | typeof Array + | typeof Boolean + | typeof DOMTokenList + | typeof Number + | typeof Object + | typeof String export type ValueTypeDefault = Array | boolean | number | Object | string @@ -82,7 +88,7 @@ export type ValueTypeObject = Partial<{ type: ValueTypeConstant; default: ValueT export type ValueTypeDefinition = ValueTypeConstant | ValueTypeDefault | ValueTypeObject -export type ValueType = "array" | "boolean" | "number" | "object" | "string" +export type ValueType = "array" | "boolean" | "list" | "number" | "object" | "string" function parseValueDefinitionPair([token, typeDefinition]: ValueDefinitionPair, controller?: string): ValueDescriptor { return valueDescriptorForTokenAndTypeDefinition({ @@ -93,6 +99,8 @@ function parseValueDefinitionPair([token, typeDefinition]: ValueDefinitionPair, } export function parseValueTypeConstant(constant?: ValueTypeConstant) { + if (typeof DOMTokenList !== "undefined" && constant === DOMTokenList) return "list" + switch (constant) { case Array: return "array" @@ -143,14 +151,28 @@ export function parseValueTypeObject(payload: ValueTypeObjectPayload) { if (onlyType) return typeFromObject if (onlyDefault) return typeFromDefaultValue - if (typeFromObject !== typeFromDefaultValue) { - const propertyPath = controller ? `${controller}.${token}` : token + const propertyPath = controller ? `${controller}.${token}` : token + + const defaultValueMatchesType = + typeFromObject === typeFromDefaultValue || (typeFromObject === "list" && typeFromDefaultValue === "array") + if (!defaultValueMatchesType) { throw new Error( `The specified default value for the Stimulus Value "${propertyPath}" must match the defined type "${typeFromObject}". The provided default value of "${typeObject.default}" is of type "${typeFromDefaultValue}".` ) } + if (typeFromObject === "list" && Array.isArray(typeObject.default)) { + for (const item of typeObject.default) { + const listToken = `${item}` + if (listToken.length == 0 || /\s/.test(listToken)) { + throw new Error( + `The specified default value for the Stimulus Value "${propertyPath}" contains the invalid list token "${listToken}". List tokens must be non-empty and free of whitespace.` + ) + } + } + } + if (fullObject) return typeFromObject } @@ -223,6 +245,9 @@ const defaultValuesByType = { return [] }, boolean: false, + get list() { + return [] + }, number: 0, get object() { return {} @@ -247,6 +272,14 @@ const readers: { [type: string]: Reader } = { return !(value == "0" || String(value).toLowerCase() == "false") }, + list(value: string): string[] { + const tokens = value + .trim() + .split(/\s+/) + .filter((token) => token.length > 0) + return Array.from(new Set(tokens)) + }, + number(value: string): number { return Number(value.replace(/_/g, "")) }, @@ -271,6 +304,7 @@ type Writer = (value: any) => string const writers: { [type: string]: Writer } = { default: writeString, array: writeJSON, + list: writeList, object: writeJSON, } @@ -278,6 +312,26 @@ function writeJSON(value: any) { return JSON.stringify(value) } +function writeList(value: any) { + if (!Array.isArray(value)) { + throw new TypeError( + `expected value of type "list" but instead got value "${value}" of type "${parseValueTypeDefault(value)}"` + ) + } + + const tokens = value.map((item) => `${item}`) + + for (const token of tokens) { + if (token.length == 0 || /\s/.test(token)) { + throw new TypeError( + `expected token of a "list" value to be non-empty and free of whitespace but got "${token}". Use the Array type for values containing arbitrary strings.` + ) + } + } + + return tokens.join(" ") +} + function writeString(value: any) { return `${value}` } diff --git a/src/tests/controllers/default_value_controller.ts b/src/tests/controllers/default_value_controller.ts index 603842c5..88d8bf3f 100644 --- a/src/tests/controllers/default_value_controller.ts +++ b/src/tests/controllers/default_value_controller.ts @@ -21,6 +21,10 @@ export class DefaultValueController extends Controller { defaultArrayFilled: { type: Array, default: [1, 2, 3] }, defaultArrayOverride: [9, 9, 9], + defaultList: { type: DOMTokenList, default: [] }, + defaultListFilled: { type: DOMTokenList, default: ["one", "two"] }, + defaultListOverride: { type: DOMTokenList, default: ["will", "be", "overridden"] }, + defaultObject: {}, defaultObjectPerson: { type: Object, default: { name: "David" } }, defaultObjectOverride: { override: "me" }, @@ -60,6 +64,13 @@ export class DefaultValueController extends Controller { defaultArrayOverrideValue!: { [key: string]: any } hasDefaultArrayOverrideValue!: boolean + defaultListValue!: string[] + hasDefaultListValue!: boolean + defaultListFilledValue!: string[] + hasDefaultListFilledValue!: boolean + defaultListOverrideValue!: string[] + hasDefaultListOverrideValue!: boolean + defaultObjectValue!: object hasDefaultObjectValue!: boolean defaultObjectPersonValue!: object diff --git a/src/tests/controllers/value_controller.ts b/src/tests/controllers/value_controller.ts index 50bde761..da90668f 100644 --- a/src/tests/controllers/value_controller.ts +++ b/src/tests/controllers/value_controller.ts @@ -19,6 +19,7 @@ export class ValueController extends BaseValueController { missingString: String, ids: Array, options: Object, + tokens: DOMTokenList, "time-24hr": Boolean, } @@ -26,6 +27,7 @@ export class ValueController extends BaseValueController { missingStringValue!: string idsValue!: any[] optionsValue!: { [key: string]: any } + tokensValue!: string[] time24hrValue!: boolean loggedNumericValues: number[] = [] @@ -48,4 +50,11 @@ export class ValueController extends BaseValueController { this.optionsValues.push(value) this.oldOptionsValues.push(oldValue) } + + loggedTokensValues: string[][] = [] + oldLoggedTokensValues: any[] = [] + tokensValueChanged(value: string[], oldValue: any) { + this.loggedTokensValues.push(value) + this.oldLoggedTokensValues.push(oldValue) + } } diff --git a/src/tests/modules/core/default_value_tests.ts b/src/tests/modules/core/default_value_tests.ts index 924d85f3..dce3f7b0 100644 --- a/src/tests/modules/core/default_value_tests.ts +++ b/src/tests/modules/core/default_value_tests.ts @@ -8,6 +8,7 @@ export default class DefaultValueTests extends ControllerTestCase(DefaultValueCo data-${this.identifier}-default-boolean-override-value="false" data-${this.identifier}-default-number-override-value="42" data-${this.identifier}-default-array-override-value="[9,8,7]" + data-${this.identifier}-default-list-override-value="expected value" data-${this.identifier}-default-object-override-value='{"expected":"value"}' ` @@ -140,6 +141,36 @@ export default class DefaultValueTests extends ControllerTestCase(DefaultValueCo this.assert.ok(this.controller.hasDefaultArrayOverrideValue) } + // Lists + + "test custom default list values"() { + this.assert.deepEqual(this.controller.defaultListValue, []) + this.assert.ok(this.controller.hasDefaultListValue) + this.assert.deepEqual(this.get("default-list-value"), null) + + this.assert.deepEqual(this.controller.defaultListFilledValue, ["one", "two"]) + this.assert.ok(this.controller.hasDefaultListFilledValue) + this.assert.deepEqual(this.get("default-list-filled-value"), null) + } + + "test should be able to set a new value for custom default list values"() { + this.assert.deepEqual(this.get("default-list-value"), null) + this.assert.deepEqual(this.controller.defaultListValue, []) + this.assert.ok(this.controller.hasDefaultListValue) + + this.controller.defaultListValue = ["new", "value"] + + this.assert.deepEqual(this.get("default-list-value"), "new value") + this.assert.deepEqual(this.controller.defaultListValue, ["new", "value"]) + this.assert.ok(this.controller.hasDefaultListValue) + } + + "test should override custom default list value with given data-attribute"() { + this.assert.deepEqual(this.get("default-list-override-value"), "expected value") + this.assert.deepEqual(this.controller.defaultListOverrideValue, ["expected", "value"]) + this.assert.ok(this.controller.hasDefaultListOverrideValue) + } + // Objects "test custom default object values"() { diff --git a/src/tests/modules/core/value_properties_tests.ts b/src/tests/modules/core/value_properties_tests.ts index 0c8a583d..d4b8d79a 100644 --- a/src/tests/modules/core/value_properties_tests.ts +++ b/src/tests/modules/core/value_properties_tests.ts @@ -16,6 +16,7 @@ export default class ValuePropertiesTests extends ControllerTestCase(ValueContro this.assert.equal(parseValueTypeConstant(Array), "array") this.assert.equal(parseValueTypeConstant(Object), "object") this.assert.equal(parseValueTypeConstant(Number), "number") + this.assert.equal(parseValueTypeConstant(DOMTokenList), "list") this.assert.equal(parseValueTypeConstant("" as any), undefined) this.assert.equal(parseValueTypeConstant({} as any), undefined) @@ -94,6 +95,11 @@ export default class ValuePropertiesTests extends ControllerTestCase(ValueContro this.assert.equal(typeObject({ type: Boolean }), "boolean") this.assert.equal(typeObject({ default: false }), "boolean") + this.assert.equal(typeObject({ type: DOMTokenList }), "list") + this.assert.equal(typeObject({ type: DOMTokenList, default: [] }), "list") + this.assert.equal(typeObject({ type: DOMTokenList, default: ["a", "b"] }), "list") + this.assert.equal(typeObject({ default: ["a", "b"] }), "array") + this.assert.throws(() => typeObject({ type: Boolean, default: "something else" }), { name: "Error", message: `The specified default value for the Stimulus Value "test.url" must match the defined type "boolean". The provided default value of "something else" is of type "string".`, @@ -103,6 +109,21 @@ export default class ValuePropertiesTests extends ControllerTestCase(ValueContro name: "Error", message: `The specified default value for the Stimulus Value "test.url" must match the defined type "boolean". The provided default value of "true" is of type "string".`, }) + + this.assert.throws(() => typeObject({ type: DOMTokenList, default: "a b" }), { + name: "Error", + message: `The specified default value for the Stimulus Value "test.url" must match the defined type "list". The provided default value of "a b" is of type "string".`, + }) + + this.assert.throws(() => typeObject({ type: DOMTokenList, default: ["foo bar"] }), { + name: "Error", + message: `The specified default value for the Stimulus Value "test.url" contains the invalid list token "foo bar". List tokens must be non-empty and free of whitespace.`, + }) + + this.assert.throws(() => typeObject({ type: DOMTokenList, default: [""] }), { + name: "Error", + message: `The specified default value for the Stimulus Value "test.url" contains the invalid list token "". List tokens must be non-empty and free of whitespace.`, + }) } "test parseValueTypeDefinition booleans"() { @@ -128,6 +149,7 @@ export default class ValuePropertiesTests extends ControllerTestCase(ValueContro this.assert.equal(typeDefinition({}), "object") this.assert.equal(typeDefinition(""), "string") this.assert.equal(typeDefinition([]), "array") + this.assert.equal(typeDefinition(DOMTokenList), "list") this.assert.throws(() => typeDefinition(null)) this.assert.throws(() => typeDefinition(undefined)) @@ -139,12 +161,15 @@ export default class ValuePropertiesTests extends ControllerTestCase(ValueContro this.assert.deepEqual(defaultValueForDefinition(Object), {}) this.assert.deepEqual(defaultValueForDefinition(Array), []) this.assert.deepEqual(defaultValueForDefinition(Number), 0) + this.assert.deepEqual(defaultValueForDefinition(DOMTokenList), []) this.assert.deepEqual(defaultValueForDefinition({ type: String }), "") this.assert.deepEqual(defaultValueForDefinition({ type: Boolean }), false) this.assert.deepEqual(defaultValueForDefinition({ type: Object }), {}) this.assert.deepEqual(defaultValueForDefinition({ type: Array }), []) this.assert.deepEqual(defaultValueForDefinition({ type: Number }), 0) + this.assert.deepEqual(defaultValueForDefinition({ type: DOMTokenList }), []) + this.assert.deepEqual(defaultValueForDefinition({ type: DOMTokenList, default: ["a", "b"] }), ["a", "b"]) this.assert.deepEqual(defaultValueForDefinition({ type: String, default: null }), null) this.assert.deepEqual(defaultValueForDefinition({ type: Boolean, default: null }), null) diff --git a/src/tests/modules/core/value_tests.ts b/src/tests/modules/core/value_tests.ts index fd60ee1a..9c95f7e1 100644 --- a/src/tests/modules/core/value_tests.ts +++ b/src/tests/modules/core/value_tests.ts @@ -9,6 +9,7 @@ export default class ValueTests extends ControllerTestCase(ValueController) { data-${this.identifier}-string-value="ok" data-${this.identifier}-ids-value="[1,2,3]" data-${this.identifier}-options-value='{"one":[2,3]}' + data-${this.identifier}-tokens-value="one two three" data-${this.identifier}-time-24hr-value="true"> ` @@ -114,6 +115,46 @@ export default class ValueTests extends ControllerTestCase(ValueController) { this.assert.throws(() => this.controller.optionsValue) } + "test list values"() { + this.assert.deepEqual(this.controller.tokensValue, ["one", "two", "three"]) + + this.controller.tokensValue = ["a", "b"] + this.assert.deepEqual(this.controller.tokensValue, ["a", "b"]) + this.assert.deepEqual(this.get("tokens-value"), "a b") + + this.controller.tokensValue.push("c") + this.assert.deepEqual(this.controller.tokensValue, ["a", "b"]) + + this.set("tokens-value", "b a b c a") + this.assert.deepEqual(this.controller.tokensValue, ["b", "a", "c"]) + + this.set("tokens-value", " one\t two\n three ") + this.assert.deepEqual(this.controller.tokensValue, ["one", "two", "three"]) + + this.set("tokens-value", "") + this.assert.deepEqual(this.controller.tokensValue, []) + + this.set("tokens-value", " ") + this.assert.deepEqual(this.controller.tokensValue, []) + + this.controller.tokensValue = ["x", "y", "x"] + this.assert.deepEqual(this.get("tokens-value"), "x y x") + this.assert.deepEqual(this.controller.tokensValue, ["x", "y"]) + + this.controller.tokensValue = [1, 2] as any + this.assert.deepEqual(this.get("tokens-value"), "1 2") + this.assert.deepEqual(this.controller.tokensValue, ["1", "2"]) + + this.controller.tokensValue = [] + this.assert.deepEqual(this.get("tokens-value"), "") + this.assert.deepEqual(this.controller.tokensValue, []) + + this.assert.throws(() => (this.controller.tokensValue = "not an array" as any)) + this.assert.throws(() => (this.controller.tokensValue = null as any)) + this.assert.throws(() => (this.controller.tokensValue = ["foo bar"])) + this.assert.throws(() => (this.controller.tokensValue = [""])) + } + "test accessing a string value returns the empty string when the attribute is missing"() { this.controller.stringValue = undefined as any this.assert.notOk(this.has("string-value")) @@ -150,6 +191,15 @@ export default class ValueTests extends ControllerTestCase(ValueController) { this.assert.deepEqual(this.controller.optionsValue, {}) } + "test accessing a list value returns an empty array when the attribute is missing"() { + this.controller.tokensValue = undefined as any + this.assert.notOk(this.has("tokens-value")) + this.assert.deepEqual(this.controller.tokensValue, []) + + this.controller.tokensValue.push("x") + this.assert.deepEqual(this.controller.tokensValue, []) + } + async "test changed callbacks"() { this.assert.deepEqual(this.controller.loggedNumericValues, [123]) this.assert.deepEqual(this.controller.oldLoggedNumericValues, [0]) @@ -163,6 +213,16 @@ export default class ValueTests extends ControllerTestCase(ValueController) { await this.nextFrame this.assert.deepEqual(this.controller.loggedNumericValues, [123, 0, 1]) this.assert.deepEqual(this.controller.oldLoggedNumericValues, [0, 123, 0]) + + this.set("numeric-value", "") + await this.nextFrame + this.assert.deepEqual(this.controller.loggedNumericValues, [123, 0, 1, 0]) + this.assert.deepEqual(this.controller.oldLoggedNumericValues, [0, 123, 0, 1]) + + this.set("numeric-value", "5") + await this.nextFrame + this.assert.deepEqual(this.controller.loggedNumericValues, [123, 0, 1, 0, 5]) + this.assert.deepEqual(this.controller.oldLoggedNumericValues, [0, 123, 0, 1, 0]) } async "test changed callbacks for object"() { @@ -191,6 +251,21 @@ export default class ValueTests extends ControllerTestCase(ValueController) { ]) } + async "test changed callbacks for list"() { + this.assert.deepEqual(this.controller.loggedTokensValues, [["one", "two", "three"]]) + this.assert.deepEqual(this.controller.oldLoggedTokensValues, [[]]) + + this.controller.tokensValue = ["four"] + await this.nextFrame + this.assert.deepEqual(this.controller.loggedTokensValues, [["one", "two", "three"], ["four"]]) + this.assert.deepEqual(this.controller.oldLoggedTokensValues, [[], ["one", "two", "three"]]) + + this.set("tokens-value", "five six") + await this.nextFrame + this.assert.deepEqual(this.controller.loggedTokensValues, [["one", "two", "three"], ["four"], ["five", "six"]]) + this.assert.deepEqual(this.controller.oldLoggedTokensValues, [[], ["one", "two", "three"], ["four"]]) + } + async "test default values trigger changed callbacks"() { this.assert.deepEqual(this.controller.loggedMissingStringValues, [""]) this.assert.deepEqual(this.controller.oldLoggedMissingStringValues, [undefined])