Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion docs/reference/values.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<div data-controller="upload" data-upload-permitted-types-value="image video audio"></div>
```

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:
Expand All @@ -82,6 +101,7 @@ Type | Default value
---- | -------------
Array | `[]`
Boolean | `false`
DOMTokenList | `[]`
Number | `0`
Object | `{}`
String | `""`
Expand Down
2 changes: 1 addition & 1 deletion src/core/value_observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
62 changes: 58 additions & 4 deletions src/core/value_properties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,15 +74,21 @@ 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<any> | boolean | number | Object | string

export type ValueTypeObject = Partial<{ type: ValueTypeConstant; default: ValueTypeDefault }>

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({
Expand All @@ -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"
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -223,6 +245,9 @@ const defaultValuesByType = {
return []
},
boolean: false,
get list() {
return []
},
number: 0,
get object() {
return {}
Expand All @@ -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, ""))
},
Expand All @@ -271,13 +304,34 @@ type Writer = (value: any) => string
const writers: { [type: string]: Writer } = {
default: writeString,
array: writeJSON,
list: writeList,
object: writeJSON,
}

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}`
}
11 changes: 11 additions & 0 deletions src/tests/controllers/default_value_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions src/tests/controllers/value_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@ export class ValueController extends BaseValueController {
missingString: String,
ids: Array,
options: Object,
tokens: DOMTokenList,
"time-24hr": Boolean,
}

shadowedBooleanValue!: boolean
missingStringValue!: string
idsValue!: any[]
optionsValue!: { [key: string]: any }
tokensValue!: string[]
time24hrValue!: boolean

loggedNumericValues: number[] = []
Expand All @@ -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)
}
}
31 changes: 31 additions & 0 deletions src/tests/modules/core/default_value_tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"}'
</div>
`
Expand Down Expand Up @@ -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"() {
Expand Down
25 changes: 25 additions & 0 deletions src/tests/modules/core/value_properties_tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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".`,
Expand All @@ -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"() {
Expand All @@ -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))
Expand All @@ -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)
Expand Down
Loading
Loading