@fusion.dev/helix-core 0.182.1 → 0.183.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/custom-elements.json +1677 -349
  2. package/dist/controllers/aria-forward-controller/aria-forward-controller.d.ts +4 -0
  3. package/dist/controllers/virtualize-controller/heights.d.ts +7 -0
  4. package/dist/controllers/virtualize-controller/heights.js +7 -0
  5. package/dist/controllers/virtualize-controller/virtualize-controller.d.ts +24 -0
  6. package/dist/controllers/virtualize-controller/virtualize-controller.js +40 -20
  7. package/dist/controllers/virtualize-controller/waterfall.d.ts +66 -0
  8. package/dist/controllers/virtualize-controller/waterfall.js +137 -0
  9. package/dist/helix-element/helix-element.d.ts +11 -1
  10. package/dist/helix-element/helix-element.js +7 -6
  11. package/dist/helix-element/mark-rules.d.ts +119 -0
  12. package/dist/helix-element/mark-rules.js +4 -0
  13. package/dist/helix-form-element/helix-form-element-base.d.ts +264 -0
  14. package/dist/helix-form-element/helix-form-element-base.js +173 -0
  15. package/dist/helix-form-element/helix-form-element.d.ts +8 -226
  16. package/dist/helix-form-element/helix-form-element.js +2 -157
  17. package/dist/index.d.ts +3 -0
  18. package/dist/index.js +3 -0
  19. package/dist/mixins/mixin-chart-source/mixin-chart-source.js +1 -6
  20. package/dist/mixins/mixin-chart-status/mixin-chart-status.js +1 -6
  21. package/dist/mixins/mixin-collection/mixin-collection.js +1 -6
  22. package/dist/mixins/mixin-controller-active/mixin-controller-active.js +1 -6
  23. package/dist/mixins/mixin-controller-checked/mixin-controller-checked.js +1 -6
  24. package/dist/mixins/mixin-controller-open/mixin-controller-open.js +1 -6
  25. package/dist/mixins/mixin-controller-value/mixin-controller-value.js +1 -6
  26. package/dist/mixins/mixin-dismissible/mixin-dismissible.js +1 -6
  27. package/dist/mixins/mixin-field/mixin-field.js +1 -6
  28. package/dist/mixins/mixin-picker-editing/mixin-picker-editing.js +1 -6
  29. package/dist/mixins/mixin-picker-feedback/mixin-picker-feedback.js +1 -6
  30. package/dist/mixins/mixin-picker-field/mixin-picker-field.js +1 -6
  31. package/dist/mixins/mixin-segmented-field/mixin-segmented-field.js +1 -6
  32. package/dist/mixins/mixin-selectable-item/mixin-selectable-item.js +1 -6
  33. package/dist/mixins/mixin-tokens/mixin-tokens.js +1 -6
  34. package/dist/mixins/mixin-viewport/mixin-viewport.js +1 -6
  35. package/dist/utils/audit-name/audit-name.d.ts +3 -3
  36. package/dist/utils/audit-name/audit-name.js +7 -2
  37. package/dist/utils/implicit-submit/implicit-submit.js +4 -4
  38. package/dist/version.d.ts +1 -1
  39. package/dist/version.js +1 -1
  40. package/package.json +5 -4
@@ -0,0 +1,264 @@
1
+ import type { PropertyValues } from 'lit';
2
+ import { HelixElement } from '../helix-element/helix-element.js';
3
+ /**
4
+ * @summary Base for a form element that reports no validity of its own, as a native button does.
5
+ *
6
+ * @description
7
+ * `HelixFormElementBase` carries form ownership, `disabled` and the fieldset
8
+ * that disables it, `name`, focus, and the validity API with a custom message
9
+ * as its only verdict. `HelixFormElement` extends it with what a control that
10
+ * judges its own value needs: the wrapped `setValidity`, `feedback` from a
11
+ * field, and the `input` and `change` announcements.
12
+ *
13
+ * **Two classes, because a button pays for the half it never calls.** Button
14
+ * reports nothing through `setValidity` and holds no value, so a message from
15
+ * `setCustomValidity` is the whole of its verdict, the same as on a native
16
+ * `<button>`. Measured on the styled Button bundled alone, October 7, 2026:
17
+ * the validating half was 347 of its 12,973 bytes brotli.
18
+ *
19
+ * A family extends this one only where it never calls
20
+ * `internals.setValidity` and takes no `MixinField`. Every other control
21
+ * extends `HelixFormElement`.
22
+ *
23
+ * @since 0.176.0
24
+ * @status experimental
25
+ * @documentation https://fusion.dev/helix/docs/tiers
26
+ */
27
+ export declare class HelixFormElementBase extends HelixElement {
28
+ /** Opts the element into form ownership, labels, and submission. */
29
+ static readonly formAssociated = true;
30
+ /**
31
+ * Whether the control is disabled and cannot be used.
32
+ *
33
+ * Reflected, because the styled tier selects on it, and mirrored to
34
+ * `aria-disabled` rather than removing the element from the accessibility
35
+ * tree, so a screen reader can still announce that the control exists.
36
+ *
37
+ * The mirror itself is in `willUpdate` below, here rather than in each
38
+ * control, because here is where `disabled` is.
39
+ */
40
+ disabled: boolean;
41
+ /**
42
+ * Whether an ancestor `<fieldset disabled>` has switched this control off.
43
+ *
44
+ * The platform tells a form-associated element through `formDisabledCallback`
45
+ * and nothing else does, so without this a control inside a disabled fieldset
46
+ * stays fully typeable and announces itself as enabled.
47
+ *
48
+ * The submission half is already the platform's: measured, the fieldset's
49
+ * controls are absent from the form's entry list and the host matches
50
+ * `:disabled`, so the value never goes anywhere. What is missing without this
51
+ * is everything a reader can see or touch.
52
+ *
53
+ * State rather than the `disabled` property. Writing that would reflect an
54
+ * attribute the consumer's server markup does not have, which React reports
55
+ * on hydration and does not patch up, and it would also silently overwrite
56
+ * what the consumer set.
57
+ */
58
+ protected formDisabled: boolean;
59
+ /**
60
+ * Whether the control is off, by its own attribute or by an ancestor.
61
+ *
62
+ * What a component should bind to rather than `disabled`, which is only the
63
+ * half of it the consumer wrote.
64
+ *
65
+ * @internal Read by the component itself and by nothing outside it: every
66
+ * call in the library is `this.isDisabled`. Public, it prints a row on
67
+ * twenty-four families in both tiers, forty-eight in all, on every form
68
+ * control's reference page.
69
+ *
70
+ * **A consumer loses nothing, which was measured rather than assumed.**
71
+ * `element.matches(':disabled')` answers the same question, is the platform's
72
+ * own, and works on a native control too. Checked in a browser across the
73
+ * three cases: a control disabled by its own attribute, one inside a
74
+ * `<fieldset>` before and after that fieldset is disabled, and one that is
75
+ * neither. `:disabled` agreed with `isDisabled` in every one, including the
76
+ * inheritance case that is the only reason this getter exists.
77
+ *
78
+ * Documented away rather than made `protected`, because a subclass in
79
+ * `helix/styled` binds to it and that is exactly what it is for.
80
+ */
81
+ get isDisabled(): boolean;
82
+ /**
83
+ * Called by the browser when an ancestor fieldset is disabled or enabled.
84
+ *
85
+ * **The argument is the effective state, and taking it whole is what latched
86
+ * every control in the library.** The platform calls this for the element's
87
+ * own `disabled` as well as for a fieldset, and our own arrives here at the
88
+ * worst possible moment: reflecting `disabled` to the attribute happens
89
+ * inside Lit's `update()`, the UA fires this synchronously from that write,
90
+ * and a reactive write during an update is a change-in-update Lit does not
91
+ * re-render for. So `formDisabled` ran exactly one update behind forever.
92
+ *
93
+ * Measured on Slider, logging what `render` saw:
94
+ *
95
+ * ```
96
+ * set true disabled true formDisabled false isDisabled true
97
+ * set false disabled false formDisabled true isDisabled true <- stale
98
+ * (no third render)
99
+ * ```
100
+ *
101
+ * The host came out right every time, `disabled` false, `:disabled` not
102
+ * matching, and the inner control kept the attribute, so a control disabled
103
+ * once could never be re-enabled. It only shows when something re-enables,
104
+ * which is the same shape of bug `inheritDisabled` was written for.
105
+ *
106
+ * Our own half is already in `this.disabled` and `isDisabled` is an or, so
107
+ * this records only what the fieldset adds. That also removes the write
108
+ * entirely on the own-disabled path rather than moving it.
109
+ *
110
+ * The one case this cannot see is a control that is disabled itself *and*
111
+ * inside a disabled fieldset, whose own `disabled` is then removed: the
112
+ * effective state does not change, so the platform does not call again, and
113
+ * the fieldset's hold is not recorded. There is no callback to learn it from
114
+ * and reading `:disabled` here would answer about the attribute we are in the
115
+ * middle of removing.
116
+ */
117
+ formDisabledCallback(disabled: boolean): void;
118
+ /**
119
+ * Whether an ancestor `<fieldset disabled>` holds the control, outside its
120
+ * first `<legend>`, read from the tree.
121
+ *
122
+ * For the one case the callback cannot report: a control disabled itself and
123
+ * by a fieldset, whose own `disabled` is then removed. The effective state does
124
+ * not change, so the platform does not call, and the fieldset's hold has to
125
+ * be read.
126
+ */
127
+ private get heldByFieldset();
128
+ /**
129
+ * Called by the browser when the form is reset. It does nothing here, and
130
+ * every override calls it.
131
+ *
132
+ * **A chain rather than a leaf**, because more than one layer owes a reset
133
+ * something: the value mixin its default, and the field mixin beneath it the
134
+ * forgetting of a reader's past attempt, whose message otherwise showed over
135
+ * the emptied control the moment the form was reset.
136
+ */
137
+ formResetCallback(): void;
138
+ protected willUpdate(changed: PropertyValues): void;
139
+ /**
140
+ * The name the control's value is submitted under.
141
+ *
142
+ * **Reflected, and it is the exception to "reflect only what CSS selects
143
+ * on".** The platform builds a form's entry list from the `name` *content
144
+ * attribute*, not from the property, so a control named through the property
145
+ * submitted under no name at all and was silently dropped. Measured: two
146
+ * inputs in one form, one named by attribute and one by property, and the
147
+ * form carried a single entry.
148
+ *
149
+ * Safe for a React consumer, which is what the rule is protecting: React
150
+ * renders a custom element's props as attributes on the server, so the name
151
+ * is already in the markup being hydrated and reflecting it writes what is
152
+ * there. It is only a change to the markup when a consumer assigns it
153
+ * imperatively later, and nothing is hydrating by then.
154
+ *
155
+ * Here rather than on each control.
156
+ */
157
+ name: string;
158
+ /** The message `setCustomValidity` was given. */
159
+ protected customValidity: string;
160
+ /**
161
+ * Hands the platform the control's verdict, anchored where focus can go.
162
+ *
163
+ * Here the verdict is the custom message alone. `HelixFormElement` lays the
164
+ * message over what the control itself reported.
165
+ */
166
+ protected applyValidity(): void;
167
+ /**
168
+ * Whether the control meets its constraints, and why not.
169
+ *
170
+ * @reference https://developer.mozilla.org/docs/Web/API/ElementInternals/validity ElementInternals.validity
171
+ */
172
+ get validity(): ValidityState;
173
+ /** The message the browser would show for the control, or an empty string. */
174
+ get validationMessage(): string;
175
+ /** Whether an update is running, so `runPendingUpdate` never starts one inside another. */
176
+ private updateRunning;
177
+ protected performUpdate(): void;
178
+ /**
179
+ * Runs an update that is on its way, so the verdict read next is the one
180
+ * for what the control holds now.
181
+ *
182
+ * **A control reports its verdict as it updates, and an update waits for a
183
+ * microtask**, so a script that wrote `value` and asked at once was answered
184
+ * about the value before: measured on Time Input, `value = '18:00'` past a
185
+ * `max` of 17:00 and `checkValidity()` returned true until `updateComplete`.
186
+ * A native control answers about the value it was just given, so the
187
+ * questions a script can ask run the pending update first. Lit's
188
+ * `performUpdate` is the documented way to run one synchronously.
189
+ *
190
+ * Protected for a control that submits on its own, which has to hand the
191
+ * form the verdict for what it holds now: Pin Input's `autosubmit`.
192
+ */
193
+ protected runPendingUpdate(): void;
194
+ /** Whether the control is checked when its form is submitted. */
195
+ get willValidate(): boolean;
196
+ /**
197
+ * Returns whether the control is valid, and fires `invalid` on it when it is
198
+ * not. The control shows no message for it: `reportValidity()` does that.
199
+ */
200
+ checkValidity(): boolean;
201
+ /**
202
+ * Returns whether the control is valid, and when it is not, fires `invalid`
203
+ * and shows the browser's message at the control.
204
+ */
205
+ reportValidity(): boolean;
206
+ /**
207
+ * Marks the control invalid with `message`, or clears that with an empty
208
+ * string, as on a native control. The form does not submit while it is set.
209
+ */
210
+ setCustomValidity(message: string): void;
211
+ /**
212
+ * The element focus goes to: the control's first tab stop, or `null` where
213
+ * there is none.
214
+ *
215
+ * Read through shadow roots and slots, so a group lands on the member that
216
+ * holds its tab stop, which is the chosen radio in a radio group.
217
+ */
218
+ protected get focusTarget(): HTMLElement | null;
219
+ /** Moves focus to the control, as focusing a native control does. */
220
+ focus(options?: FocusOptions): void;
221
+ /**
222
+ * Focuses the control when its `<label>` is pressed.
223
+ *
224
+ * The label sends the host a click of the browser's own after trying to
225
+ * focus it, and a host that is not focusable takes nothing from that try.
226
+ * Only a click that starts at the host and that the browser sent: one from
227
+ * inside has landed where it meant to, and `click()` from a script does not
228
+ * focus a native control either.
229
+ */
230
+ private readonly focusFromLabel;
231
+ /**
232
+ * Re-anchors the verdict as the form checks it, because the control's
233
+ * members may have rendered since it last reported, and the anchor read then
234
+ * was the best there was.
235
+ */
236
+ private readonly refreshAnchor;
237
+ /** Stops listening for the labels in this control's tree. */
238
+ private unwatchLabels;
239
+ connectedCallback(): void;
240
+ disconnectedCallback(): void;
241
+ /**
242
+ * @internal Whether this control is a field that counts against implicit
243
+ * submission: a single line of text, as `<input type="text">` is. Read by
244
+ * `submitImplicitly` in `utils/implicit-submit`, which only the controls
245
+ * that submit on Enter import.
246
+ */
247
+ protected get blocksImplicitSubmission(): boolean;
248
+ /** @internal Whether this control submits its form, for finding the form's default button. */
249
+ protected get submitsForm(): boolean;
250
+ /** @internal Activates the control as its form's default button. */
251
+ protected activate(): void;
252
+ /**
253
+ * The form the control belongs to, or `null`.
254
+ *
255
+ * @reference https://html.spec.whatwg.org/multipage/custom-elements.html#form-associated-custom-elements form-associated custom elements
256
+ */
257
+ get form(): HTMLFormElement | null;
258
+ /**
259
+ * The labels associated with the control.
260
+ *
261
+ * @reference https://html.spec.whatwg.org/multipage/custom-elements.html#form-associated-custom-elements form-associated custom elements
262
+ */
263
+ get labels(): NodeList;
264
+ }
@@ -0,0 +1,173 @@
1
+ import { __decorate } from "tslib";
2
+ import { property, state } from 'lit/decorators.js';
3
+ import { HelixElement } from '../helix-element/helix-element.js';
4
+ import { watchLabels } from '../utils/watch-labels/watch-labels.js';
5
+ const isTabStop = (element) => element instanceof HTMLElement &&
6
+ element.tabIndex >= 0 &&
7
+ !element.disabled &&
8
+ !element.hidden &&
9
+ !element.inert &&
10
+ (typeof element.checkVisibility !== 'function' || element.checkVisibility());
11
+ const firstTabStop = (node) => {
12
+ const children = node instanceof HTMLSlotElement ? node.assignedElements({ flatten: true }) : [...node.children];
13
+ for (const child of children) {
14
+ if (isTabStop(child)) {
15
+ return child;
16
+ }
17
+ const found = (child.shadowRoot ? firstTabStop(child.shadowRoot) : null) ?? firstTabStop(child);
18
+ if (found) {
19
+ return found;
20
+ }
21
+ }
22
+ return null;
23
+ };
24
+ export class HelixFormElementBase extends HelixElement {
25
+ constructor() {
26
+ super(...arguments);
27
+ this.disabled = false;
28
+ this.formDisabled = false;
29
+ this.name = '';
30
+ this.customValidity = '';
31
+ this.updateRunning = false;
32
+ this.focusFromLabel = (event) => {
33
+ if (!event.isTrusted || event.composedPath()[0] !== this || this.isDisabled) {
34
+ return;
35
+ }
36
+ if (!this.matches(':focus-within')) {
37
+ this.focus();
38
+ }
39
+ };
40
+ this.refreshAnchor = () => {
41
+ if (!this.internals.validity.valid) {
42
+ this.applyValidity();
43
+ }
44
+ };
45
+ }
46
+ static { this.formAssociated = true; }
47
+ get isDisabled() {
48
+ return this.disabled || this.formDisabled;
49
+ }
50
+ formDisabledCallback(disabled) {
51
+ this.formDisabled = disabled && !this.disabled;
52
+ }
53
+ get heldByFieldset() {
54
+ let fieldset = this.parentElement?.closest('fieldset[disabled]') ?? null;
55
+ while (fieldset) {
56
+ const legend = fieldset.querySelector(':scope > legend');
57
+ if (!legend?.contains(this)) {
58
+ return true;
59
+ }
60
+ fieldset = fieldset.parentElement?.closest('fieldset[disabled]') ?? null;
61
+ }
62
+ return false;
63
+ }
64
+ formResetCallback() { }
65
+ willUpdate(changed) {
66
+ super.willUpdate(changed);
67
+ if (changed.get('disabled') === true && !this.disabled && this.isConnected) {
68
+ this.formDisabled = this.heldByFieldset;
69
+ }
70
+ this.internals.ariaDisabled = this.isDisabled ? 'true' : null;
71
+ }
72
+ applyValidity() {
73
+ if (this.customValidity === '') {
74
+ this.internals.setValidity({});
75
+ return;
76
+ }
77
+ this.internals.setValidity({ customError: true }, this.customValidity, this.focusTarget ?? undefined);
78
+ }
79
+ get validity() {
80
+ this.runPendingUpdate();
81
+ return this.internals.validity;
82
+ }
83
+ get validationMessage() {
84
+ this.runPendingUpdate();
85
+ return this.internals.validationMessage;
86
+ }
87
+ performUpdate() {
88
+ this.updateRunning = true;
89
+ try {
90
+ super.performUpdate();
91
+ }
92
+ finally {
93
+ this.updateRunning = false;
94
+ }
95
+ }
96
+ runPendingUpdate() {
97
+ if (this.hasUpdated && this.isUpdatePending && !this.updateRunning) {
98
+ this.performUpdate();
99
+ }
100
+ }
101
+ get willValidate() {
102
+ return this.internals.willValidate;
103
+ }
104
+ checkValidity() {
105
+ this.runPendingUpdate();
106
+ return this.internals.checkValidity();
107
+ }
108
+ reportValidity() {
109
+ this.runPendingUpdate();
110
+ this.applyValidity();
111
+ return this.internals.reportValidity();
112
+ }
113
+ setCustomValidity(message) {
114
+ this.customValidity = message;
115
+ this.applyValidity();
116
+ this.requestUpdate();
117
+ }
118
+ get focusTarget() {
119
+ const root = this.shadowRoot;
120
+ return root ? firstTabStop(root) : null;
121
+ }
122
+ focus(options) {
123
+ const target = this.focusTarget;
124
+ if (target) {
125
+ target.focus(options);
126
+ return;
127
+ }
128
+ super.focus(options);
129
+ }
130
+ connectedCallback() {
131
+ super.connectedCallback();
132
+ this.addEventListener('click', this.focusFromLabel);
133
+ this.addEventListener('invalid', this.refreshAnchor);
134
+ this.unwatchLabels = watchLabels(this, () => this.requestUpdate());
135
+ }
136
+ disconnectedCallback() {
137
+ super.disconnectedCallback();
138
+ this.removeEventListener('click', this.focusFromLabel);
139
+ this.removeEventListener('invalid', this.refreshAnchor);
140
+ this.unwatchLabels?.();
141
+ this.unwatchLabels = undefined;
142
+ }
143
+ get blocksImplicitSubmission() {
144
+ return false;
145
+ }
146
+ get submitsForm() {
147
+ return false;
148
+ }
149
+ activate() {
150
+ this.click();
151
+ }
152
+ get form() {
153
+ return this.internals.form;
154
+ }
155
+ get labels() {
156
+ return this.internals.labels;
157
+ }
158
+ }
159
+ __decorate([
160
+ property({ reflect: true, type: Boolean })
161
+ ], HelixFormElementBase.prototype, "disabled", void 0);
162
+ __decorate([
163
+ state()
164
+ ], HelixFormElementBase.prototype, "formDisabled", void 0);
165
+ __decorate([
166
+ property({
167
+ converter: {
168
+ fromAttribute: (value) => value ?? '',
169
+ toAttribute: (value) => (value === '' ? null : value),
170
+ },
171
+ reflect: true,
172
+ })
173
+ ], HelixFormElementBase.prototype, "name", void 0);