@fusion.dev/helix-core 0.182.0 → 0.182.2
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.
- package/custom-elements.json +1109 -163
- package/dist/controllers/aria-forward-controller/aria-forward-controller.d.ts +4 -0
- package/dist/helix-element/helix-element.d.ts +11 -1
- package/dist/helix-element/helix-element.js +7 -6
- package/dist/helix-element/mark-rules.d.ts +119 -0
- package/dist/helix-element/mark-rules.js +4 -0
- package/dist/helix-form-element/helix-form-element-base.d.ts +264 -0
- package/dist/helix-form-element/helix-form-element-base.js +173 -0
- package/dist/helix-form-element/helix-form-element.d.ts +8 -226
- package/dist/helix-form-element/helix-form-element.js +2 -157
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/mixins/mixin-chart-source/mixin-chart-source.js +1 -6
- package/dist/mixins/mixin-chart-status/mixin-chart-status.js +1 -6
- package/dist/mixins/mixin-collection/mixin-collection.js +1 -6
- package/dist/mixins/mixin-controller-active/mixin-controller-active.js +1 -6
- package/dist/mixins/mixin-controller-checked/mixin-controller-checked.js +1 -6
- package/dist/mixins/mixin-controller-open/mixin-controller-open.js +1 -6
- package/dist/mixins/mixin-controller-value/mixin-controller-value.js +1 -6
- package/dist/mixins/mixin-dismissible/mixin-dismissible.js +1 -6
- package/dist/mixins/mixin-field/mixin-field.js +1 -6
- package/dist/mixins/mixin-picker-editing/mixin-picker-editing.js +1 -6
- package/dist/mixins/mixin-picker-feedback/mixin-picker-feedback.js +1 -6
- package/dist/mixins/mixin-picker-field/mixin-picker-field.js +1 -6
- package/dist/mixins/mixin-segmented-field/mixin-segmented-field.js +1 -6
- package/dist/mixins/mixin-selectable-item/mixin-selectable-item.js +1 -6
- package/dist/mixins/mixin-tokens/mixin-tokens.js +1 -6
- package/dist/mixins/mixin-viewport/mixin-viewport.js +1 -6
- package/dist/utils/audit-name/audit-name.d.ts +3 -3
- package/dist/utils/audit-name/audit-name.js +7 -2
- package/dist/utils/implicit-submit/implicit-submit.js +4 -4
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +5 -4
|
@@ -35,6 +35,10 @@ export interface AriaForwardOptions {
|
|
|
35
35
|
* How to name the control, said in a warning when it has no accessible
|
|
36
36
|
* name once it has rendered. Absent, the name is not checked.
|
|
37
37
|
*
|
|
38
|
+
* A sentence, or the key of one `audit-name.ts` keeps, as `'button'` is. A
|
|
39
|
+
* key keeps the sentence out of a production build, where the check does
|
|
40
|
+
* nothing.
|
|
41
|
+
*
|
|
38
42
|
* For a control named by its content or a host attribute, as a button is. A
|
|
39
43
|
* field is checked by `MixinField`, which knows its label.
|
|
40
44
|
*/
|
|
@@ -278,9 +278,19 @@ export declare class HelixElement extends LitElement {
|
|
|
278
278
|
* where you do.
|
|
279
279
|
*/
|
|
280
280
|
protected relayNativeEvent(event: Event, init?: EventInit): void;
|
|
281
|
+
/**
|
|
282
|
+
* The rules from `mark-rules.ts` for the marks this element writes in its
|
|
283
|
+
* own shadow root: `data-scroller`, `data-virtual`, `data-spoken` and
|
|
284
|
+
* `data-mirror`.
|
|
285
|
+
*
|
|
286
|
+
* A component that writes a mark names its rule here, and a subclass
|
|
287
|
+
* inherits the list. One that writes none takes only the three rules every
|
|
288
|
+
* shadow root gets, which is what keeps the other four out of a Button.
|
|
289
|
+
*/
|
|
290
|
+
protected static markRules: readonly string[];
|
|
281
291
|
/**
|
|
282
292
|
* Prepends the shadow-root reset to every subclass's styles, so no component
|
|
283
|
-
* has to remember to include it.
|
|
293
|
+
* has to remember to include it, with the rules for the marks it writes.
|
|
284
294
|
*/
|
|
285
295
|
protected static finalizeStyles(styles?: CSSResultOrNative | CSSResultOrNative[]): CSSResultOrNative[];
|
|
286
296
|
/**
|
|
@@ -5,12 +5,8 @@ import { noteVersion, reportUsage } from '../utils/report-usage/report-usage.js'
|
|
|
5
5
|
import { applyReactProperties, bindReactHandler, reactPropsOf } from './react-bridge.js';
|
|
6
6
|
const BOX_SIZING = '*,::before,::after{box-sizing:border-box}';
|
|
7
7
|
const HIDDEN = ":host([hidden]:not([hidden='until-found'])){display:none}";
|
|
8
|
-
const SCROLLER = '[data-scroller]{overflow:auto}';
|
|
9
|
-
const VIRTUAL = '[data-virtual]{overflow-anchor:none}';
|
|
10
|
-
const SPOKEN = '[data-spoken]{position:absolute;inline-size:1px;block-size:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}';
|
|
11
|
-
const MIRROR = ':host(:dir(rtl)) [data-mirror]{scale:-1 1}';
|
|
12
8
|
const SETTLED = ':host(:not(:state(settled))),:host(:not(:state(settled))) *{transition:none!important}';
|
|
13
|
-
const BASE = unsafeCSS(BOX_SIZING + HIDDEN +
|
|
9
|
+
const BASE = unsafeCSS(BOX_SIZING + HIDDEN + SETTLED);
|
|
14
10
|
const LOOK_EVERY = 16;
|
|
15
11
|
const LOOK_FOR = 96;
|
|
16
12
|
export class HelixElement extends LitElement {
|
|
@@ -94,8 +90,13 @@ export class HelixElement extends LitElement {
|
|
|
94
90
|
...init,
|
|
95
91
|
}));
|
|
96
92
|
}
|
|
93
|
+
static { this.markRules = []; }
|
|
97
94
|
static finalizeStyles(styles) {
|
|
98
|
-
|
|
95
|
+
const marks = this.markRules;
|
|
96
|
+
return [
|
|
97
|
+
marks.length === 0 ? BASE : unsafeCSS(BOX_SIZING + HIDDEN + marks.join('') + SETTLED),
|
|
98
|
+
...LitElement.finalizeStyles(styles),
|
|
99
|
+
];
|
|
99
100
|
}
|
|
100
101
|
performUpdate() {
|
|
101
102
|
if (this.hasUpdated || !measuring()) {
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Makes an element a scroll container, where a component says it is one.
|
|
3
|
+
*
|
|
4
|
+
* **A box that scrolls is the headless tier's, and the headless tier had no way to say
|
|
5
|
+
* so.** `overflow` is what creates a scrollport: without it `scrollTop` is
|
|
6
|
+
* permanently zero and every scroll API on the element is inert. So a component
|
|
7
|
+
* that reads `scrollTop` and cannot declare `overflow` is one whose behavior
|
|
8
|
+
* lives in somebody else's stylesheet.
|
|
9
|
+
*
|
|
10
|
+
* Measured before deciding: `helix-headless-scroll-area`, the component whose
|
|
11
|
+
* entire purpose is scrolling, computes `overflow-y: visible` on its viewport
|
|
12
|
+
* with `scrollHeight` equal to `clientHeight`. Standing alone it is a div, and
|
|
13
|
+
* the virtual list is worse because it *acts* on the reading.
|
|
14
|
+
*
|
|
15
|
+
* **It goes in `core` rather than in a stylesheet**, with `BOX_SIZING`,
|
|
16
|
+
* `HIDDEN` and `SETTLED`, which are the token-free structural rules `core`
|
|
17
|
+
* ships into every shadow root. "The headless tier has no CSS" has never meant
|
|
18
|
+
* `core` has none; it means `helix/headless` must not depend on
|
|
19
|
+
* `@fusion.dev/helix-styles`, and a rule with no token in it does not.
|
|
20
|
+
*
|
|
21
|
+
* **And a rule rather than an inline style, which was the first idea and is
|
|
22
|
+
* wrong.** An inline `style` on a rendered part outranks every author rule
|
|
23
|
+
* short of `!important`, so a consumer wanting `overflow: hidden` on that part
|
|
24
|
+
* would have to fight it and would need `!important` of their own. A shadow rule
|
|
25
|
+
* loses to a document rule, so `::part(viewport) { overflow: … }` from a
|
|
26
|
+
* consumer simply wins, which is the right way round.
|
|
27
|
+
*
|
|
28
|
+
* Both axes, and nothing else. Which *kind* of scrolling, how much room,
|
|
29
|
+
* `overscroll-behavior`, `scroll-snap`, a gutter, a scrollbar treatment: those
|
|
30
|
+
* are decisions and stay in `helix/styled`. This is only the mechanism, and the
|
|
31
|
+
* styled sheet is prepended-after so it overrides at equal specificity anyway.
|
|
32
|
+
*
|
|
33
|
+
* **A component marks a box whose scroll state it reads, and no other**, which
|
|
34
|
+
* is narrower than "every box that ends up scrolling". `Megamenu`'s panel
|
|
35
|
+
* scrolls, and only because the styled tier gives it a `max-block-size`: that is
|
|
36
|
+
* a decision, the `overflow` that serves it belongs with it, and the headless
|
|
37
|
+
* tier never asks the panel where it is scrolled to. Marking it anyway moved a
|
|
38
|
+
* heading four pixels, because `overflow` establishes a block formatting context
|
|
39
|
+
* and margins that had been collapsing stopped. A mechanism with a layout
|
|
40
|
+
* consequence is one to apply exactly where it is needed.
|
|
41
|
+
*
|
|
42
|
+
* `ScrollArea`, `VirtualList`, `Carousel`, `ObjectInspector`, `Chat` and a
|
|
43
|
+
* `windowed` `DataTable` mark theirs, each reading `scrollTop` or `scrollLeft`
|
|
44
|
+
* and acting on the answer. The last two took their `overflow` from their
|
|
45
|
+
* styled sheet until they were marked, so the window each drew was inert on a
|
|
46
|
+
* page that loads only `helix/headless`; marking them moved nothing on the
|
|
47
|
+
* styled tier, where both boxes already scrolled.
|
|
48
|
+
*/
|
|
49
|
+
export declare const SCROLLER_RULE = "[data-scroller]{overflow:auto}";
|
|
50
|
+
/**
|
|
51
|
+
* A box whose rows are drawn and taken away as it scrolls, which
|
|
52
|
+
* `VirtualizeController` marks `data-virtual` once it watches it.
|
|
53
|
+
*
|
|
54
|
+
* **The browser's scroll anchoring fights the window.** Chromium keeps the
|
|
55
|
+
* content a reader was looking at in place when what is above it changes, and
|
|
56
|
+
* a virtual window changes exactly that on every move: the spacer above grows
|
|
57
|
+
* and the rows are swapped. Measured on a 500 row list scrolled to 330, the
|
|
58
|
+
* offset went to 138, then 522, then back to 330 before it settled, and the
|
|
59
|
+
* list reported four ranges for one scroll. The window already keeps the rows
|
|
60
|
+
* where they belong, so anchoring has nothing left to do here but move them.
|
|
61
|
+
*/
|
|
62
|
+
export declare const VIRTUAL_RULE = "[data-virtual]{overflow-anchor:none}";
|
|
63
|
+
/**
|
|
64
|
+
* Words a screen reader reads where the eye reads something else, which a
|
|
65
|
+
* component marks `data-spoken`: `⇧⌘P` in Kbd is read as `Shift Command P`.
|
|
66
|
+
*
|
|
67
|
+
* The drawn text is `aria-hidden` and the words sit beside it, in no box the
|
|
68
|
+
* eye sees. The rule takes the words out of the layout and paints none of
|
|
69
|
+
* them, and names no token, so it belongs with the mechanisms here. Without
|
|
70
|
+
* it a page that loads only `helix/headless` showed both.
|
|
71
|
+
*/
|
|
72
|
+
export declare const SPOKEN_RULE = "[data-spoken]{position:absolute;inline-size:1px;block-size:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}";
|
|
73
|
+
/**
|
|
74
|
+
* Turns a drawing that runs along the line of text, or a glyph that points
|
|
75
|
+
* along it, round with it on a right-to-left page.
|
|
76
|
+
*
|
|
77
|
+
* **A chart computes its geometry from the left, because an SVG's coordinates
|
|
78
|
+
* do.** `x` grows rightward whatever `direction` says, while the words around a
|
|
79
|
+
* drawing are HTML written in logical properties and turn on their own. Before
|
|
80
|
+
* this, a bar chart on an Arabic page wrote Q4 to Q1 from the left under
|
|
81
|
+
* columns still drawn Q1 to Q4, so each name stood under its mirror's column.
|
|
82
|
+
*
|
|
83
|
+
* **A rule rather than a second set of coordinates, and three things decide
|
|
84
|
+
* it.** A server renders the same markup whichever way the page reads, so the
|
|
85
|
+
* drawing it sends is right before a script runs. Everything drawn on a mark
|
|
86
|
+
* turns with it: a column rounded at its far end, a gradient toward its base,
|
|
87
|
+
* the hole a ring terminus cuts, each of which arithmetic would have to mirror
|
|
88
|
+
* again in every stylesheet that draws one. And it follows `dir` when it
|
|
89
|
+
* changes, which a render would only do on its next update.
|
|
90
|
+
*
|
|
91
|
+
* **About the vertical axis, never the horizontal.** Up is more in every
|
|
92
|
+
* script, so a value keeps its height and only the run along the line turns.
|
|
93
|
+
* The numbers are text beside the drawing rather than inside it, so they read
|
|
94
|
+
* the right way round.
|
|
95
|
+
*
|
|
96
|
+
* **Keyed on `:dir()`, which is what every sheet here mirrors a caret with.**
|
|
97
|
+
* The pointer arithmetic that has to agree with it asks the computed
|
|
98
|
+
* `direction`, the question the table and the splitter already ask, and the two
|
|
99
|
+
* part only where a sheet sets `direction` without `dir`.
|
|
100
|
+
*
|
|
101
|
+
* A component marks the drawing with `data-mirror`, and a drawing with no line
|
|
102
|
+
* of text to follow is left unmarked: a whole ring's slices run clockwise from
|
|
103
|
+
* twelve, which is a clock's convention and not a script's. `scale` rather than
|
|
104
|
+
* `transform`, so a consumer's `transform` on the same part composes with it,
|
|
105
|
+
* and a consumer's `scale` there wins, a shadow rule losing to a document one.
|
|
106
|
+
*
|
|
107
|
+
* **A glyph that points along the line is the same case, marked the same
|
|
108
|
+
* way**: a caret meaning next or previous, the one between two crumbs, a shut
|
|
109
|
+
* disclosure. The component drawing it knows what it means, so the headless
|
|
110
|
+
* tier points it the right way with no stylesheet at all. Before the mark each
|
|
111
|
+
* styled sheet turned its own and a headless page had to know to, which the
|
|
112
|
+
* carousel's and the pagination's did not. A glyph a *sheet* stands on its side
|
|
113
|
+
* is that sheet's to turn: a tree draws its chevron pointing down, and a mark
|
|
114
|
+
* would come out right under a `transform: rotate()`, which applies before
|
|
115
|
+
* `scale`, and do nothing under the `rotate` property, which applies after it.
|
|
116
|
+
* A glyph for playing media is left unmarked too, a tape running one way
|
|
117
|
+
* whatever the script, and so is a picture of a key.
|
|
118
|
+
*/
|
|
119
|
+
export declare const MIRROR_RULE = ":host(:dir(rtl)) [data-mirror]{scale:-1 1}";
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export const SCROLLER_RULE = '[data-scroller]{overflow:auto}';
|
|
2
|
+
export const VIRTUAL_RULE = '[data-virtual]{overflow-anchor:none}';
|
|
3
|
+
export const SPOKEN_RULE = '[data-spoken]{position:absolute;inline-size:1px;block-size:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}';
|
|
4
|
+
export const MIRROR_RULE = ':host(:dir(rtl)) [data-mirror]{scale:-1 1}';
|
|
@@ -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);
|