Options
An option is a value a component reads from its element. It is declared in config.options and written in the markup as data-option-<name>.
Two rules carry everything else on this page:
An option is an input, never a store.
Every option is responsive. There is nothing to declare for it.
Declaring options
import { Base } from '@studiometa/js-toolkit';
class Slider extends Base {
static config = {
name: 'Slider',
options: {
// The short form: just the type.
label: String,
// The long form: a type and a default.
speed: { type: Number, default: 1 },
loop: { type: Boolean, default: true },
// Array and Object defaults must be factory functions.
tween: { type: Object, default: () => ({ ease: 'linear' }) },
},
};
mounted() {
console.log(this.$options.speed);
}
}<div data-component="Slider" data-option-speed="2" data-option-label="Gallery"></div>The attribute name is the option name in kebab case: dragThreshold is data-option-drag-threshold.
$options is a read-only view
buildOptions() defines every property with a getter and no setter. The value is derived from the element and the viewport on each access, and nothing is written in. An assignment throws, because a module is strict code:
this.$options.open = true;
// TypeError: Cannot set property open of #<Object> which has only a getterTwo idioms replace the write, and which one you want depends on what the value is.
The DOM is meant to change — write the attribute. It is the same statement the markup makes:
import { Base } from '@studiometa/js-toolkit';
class Panel extends Base {
static config = { name: 'Panel', options: { disabled: Boolean } };
enable() {
this.$el.removeAttribute('data-option-disabled');
}
disable() {
this.$el.setAttribute('data-option-disabled', '');
}
}The next read gives the new value, and option<Name>Changed() announces it to anyone listening.
The value was never an input — keep a private field seeded from the option. The attribute says where the component starts; the field carries where it has got to:
import { Base } from '@studiometa/js-toolkit';
class Carousel extends Base {
static config = { name: 'Carousel', options: { start: { type: Number, default: 0 } } };
#index = this.$options.start;
next() {
this.#index += 1;
}
}An option a component only reads keeps neither: it reads this.$options.x where it needs it.
Defaults
A primitive can be a default. Every other data type needs a factory function.
options: {
speed: { type: Number, default: 1 }, // fine
tween: { type: Object, default: () => ({ ease: 'linear' }) }, // required form
items: { type: Array, default: () => [] }, // required form
}Functionis not an option type, so adefaultthat is a function is always a factory.- A default is built once per instance and then kept. Two instances never share one default.
- A factory is lazy. Nothing is built for an option nobody reads, and a component whose attribute is present never runs its factory.
ArrayandObjectwith no declared default get an empty value per instance.- A literal object or array default gives one
option.literal-defaultwarning, naming the component, the option and the correction. The value is then used as declared and shared between instances.
A default is the one option value that lives on the instance, so mutating the object a factory built sticks — this.$options.tween.ease = 'ease-out' is kept, for that instance. Replacing the option itself does not: this.$options.tween = {} throws, like every other write to the view.
Boolean options read presence
data-option-open is on, an absent attribute is the declared default, and the string the attribute carries is never read — the way disabled and checked work on the platform:
<div data-component="Panel" data-option-open></div>
<!-- true -->
<div data-component="Panel" data-option-open="false"></div>
<!-- true — the value says nothing -->
<div data-component="Panel"></div>
<!-- the declared default -->Update one from code by adding or removing the attribute:
flag ? el.setAttribute('data-option-open', '') : el.removeAttribute('data-option-open');A template must therefore write the attribute conditionally rather than interpolate a boolean into it:
{# Correct #}
<div data-component="Panel" {% if isOpen %}data-option-open{% endif %}></div>
{# Wrong — always true #}
<div data-component="Panel" data-option-open="{{ isOpen }}"></div>Turning one off — data-option-no-<name>
A boolean option is turned off by an attribute that only has to be there:
<div data-component="Dialog" data-option-no-trap-focus></div>- It is how an option declared
default: trueis turned off, since removing an attribute that is not there says nothing. - Only an option that can hold
falsehas one — a declaredBoolean, or a union containing it. AStringoption has nothing to turn off, sodata-option-no-labelis not an attribute and the observer never watches for it. - Presence is the whole statement.
data-option-no-x="false"is not a double negative; it is the same flag. - It is responsive like every other spelling.
- An option whose own name starts with
nonegates independently:noSortownsdata-option-no-sort, and its off spelling doubles the prefix,data-option-no-no-sort.
Several types
An option can accept several types. Declare the constructors in order:
import { Base } from '@studiometa/js-toolkit';
class Sticky extends Base {
static config = {
name: 'Sticky',
options: {
// "10" reads as a number; "[10, 20]" reads as an array.
offset: [Number, Array],
},
};
}Each parser must give a value of its declared type before the next parser runs. An absent union option uses its declared default, or the empty value of its first type.
Responsive options
Every option takes a breakpoint suffix, with no flag to declare:
<div
data-component="Grid"
data-option-columns="1"
data-option-columns:s="2"
data-option-columns:l="4"></div>- The suffix names one breakpoint and it cascades upwards.
$options.columnswalks from the active breakpoint down to the base value and gives the first attribute present. v3 spelled a set (:xs:s); v4 does not. - The separator is a colon, because an option name in kebab case can contain a dash.
- The value is derived on read. Nothing is stored and nothing is written.
- A suffix naming no configured breakpoint gives one
responsive.unknown-breakpointwarning per mount. - A
matchMediasubscription opens only for a component that declaresoption<Name>Changed(). A page that only reads options holds no listener.
See setBreakpoints() to replace the named set.
Live effects
A declared method named option<Name>Changed() makes that option a live effect. It is how an option that chooses a resource stays correct:
import { Base } from '@studiometa/js-toolkit';
function connect(url) {
return { dispose() {} };
}
class Feed extends Base {
static config = { name: 'Feed', options: { source: String } };
optionSourceChanged({ value, previousValue, initial }) {
const connection = connect(value);
// The returned function is the cleanup for *this* value.
return () => connection.dispose();
}
}- The hook runs before
mounted()on each mount cycle, withinitial: true. - Several writes in one mutation batch give one change, from the first old raw value to the final DOM value. A write that ends where it started is not a change.
- The previous cleanup runs before an update. Every active cleanup runs on
$unmount(). - Removal of the attribute applies the declared default.
- A breakpoint crossing reports through the same hook. A crossing to the same resolved value announces nothing.
- A component without the convention pays no setup cost.
The payload is an OptionChange:
import { Base, type OptionChange } from '@studiometa/js-toolkit';
class Feed extends Base {
static config = { name: 'Feed', options: { source: String } };
optionSourceChanged({ value, previousValue, rawValue, initial }: OptionChange<string>) {
console.log(value, previousValue, rawValue, initial);
}
}