@fusion.dev/helix-core 0.2.0 → 0.3.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.
- package/custom-elements.json +4 -4
- package/dist/controllers/aria-forward-controller/aria-forward-controller.js +0 -105
- package/dist/controllers/chart-pointer-controller/chart-pointer-controller.js +0 -71
- package/dist/controllers/command-controller/command-controller.js +0 -62
- package/dist/controllers/copy-controller/copy-controller.js +0 -63
- package/dist/controllers/disabled-cascade-controller/disabled-cascade-controller.js +0 -40
- package/dist/controllers/dismiss-controller/dismiss-controller.js +0 -124
- package/dist/controllers/drilldown-controller/drilldown-controller.js +0 -87
- package/dist/controllers/floating-controller/floating-controller.js +0 -152
- package/dist/controllers/focus-trap-controller/focus-trap-controller.js +0 -35
- package/dist/controllers/indicator-controller/indicator-controller.js +0 -82
- package/dist/controllers/keyboard-navigation-controller/keyboard-navigation-controller.js +0 -46
- package/dist/controllers/popover-controller/popover-controller.js +0 -72
- package/dist/controllers/source-controller/source-controller.js +0 -38
- package/dist/controllers/token-color-controller/token-color-controller.js +0 -51
- package/dist/controllers/virtualize-controller/virtualize-controller.js +0 -128
- package/dist/events/element-events.js +0 -22
- package/dist/events/events.js +0 -386
- package/dist/helix-element/helix-element.d.ts +10 -17
- package/dist/helix-element/helix-element.js +0 -505
- package/dist/helix-form-element/helix-form-element.js +0 -140
- package/dist/mixins/mixin-chart-source/mixin-chart-source.js +0 -77
- package/dist/mixins/mixin-collection/mixin-collection.js +0 -83
- package/dist/mixins/mixin-controller-active/mixin-controller-active.js +0 -82
- package/dist/mixins/mixin-controller-checked/mixin-controller-checked.js +0 -78
- package/dist/mixins/mixin-controller-open/mixin-controller-open.js +0 -93
- package/dist/mixins/mixin-controller-value/mixin-controller-value.js +0 -87
- package/dist/mixins/mixin-dismissible/mixin-dismissible.js +0 -38
- package/dist/mixins/mixin-field/mixin-field.js +0 -277
- package/dist/utils/anchor-element/anchor-element.js +0 -23
- package/dist/utils/capture-pointer/capture-pointer.js +0 -18
- package/dist/utils/closest-instance/closest-instance.js +0 -20
- package/dist/utils/create-sheet/create-sheet.js +0 -14
- package/dist/utils/custom-element/custom-element.js +0 -83
- package/dist/utils/draw-icon/draw-icon.js +0 -28
- package/dist/utils/draw-tooltip/draw-tooltip.js +1 -50
- package/dist/utils/elapsed-time/elapsed-time.js +0 -51
- package/dist/utils/focusable-elements/focusable-elements.js +1 -59
- package/dist/utils/format-numbers/format-numbers.js +0 -16
- package/dist/utils/host-aria/host-aria.js +0 -26
- package/dist/utils/inherit-disabled/inherit-disabled.js +0 -36
- package/dist/utils/line-path/line-path.js +0 -36
- package/dist/utils/on-unless-false/on-unless-false.js +0 -23
- package/dist/utils/read-series/read-series.js +0 -71
- package/dist/utils/relative-time/relative-time.js +0 -43
- package/dist/utils/resolve-active-item/resolve-active-item.js +0 -20
- package/dist/utils/restore-value/restore-value.js +0 -54
- package/dist/utils/scale/scale.js +0 -19
- package/dist/utils/scale/ticks.js +0 -36
- package/dist/utils/scroll-item-into-view/scroll-item-into-view.js +0 -53
- package/dist/utils/slot-filled/slot-filled.js +0 -23
- package/dist/utils/tell-disclosure/tell-disclosure.js +0 -53
- package/dist/utils/ticker/ticker.js +0 -22
- package/dist/utils/tone-marks/tone-marks.js +0 -24
- package/dist/utils/value-as/value-as.js +0 -56
- package/package.json +5 -5
|
@@ -7,161 +7,22 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
|
|
|
7
7
|
import { html, nothing } from 'lit';
|
|
8
8
|
import { property, state } from 'lit/decorators.js';
|
|
9
9
|
import { hostAria } from '../../utils/host-aria/host-aria.js';
|
|
10
|
-
/** Fixed ids, scoped to the shadow root that holds them. See the mixin's note. */
|
|
11
10
|
const HINT_ID = 'helix-hint';
|
|
12
11
|
const FEEDBACK_ID = 'helix-feedback';
|
|
13
|
-
/**
|
|
14
|
-
* Grafts a label, help text, and a validation message onto a control.
|
|
15
|
-
*
|
|
16
|
-
* **Over `HelixElement` rather than `HelixFormElement`, because not every
|
|
17
|
-
* control that wants a label is form-associated.** A checkbox group is the
|
|
18
|
-
* case: each box in it submits itself already, so the group deliberately
|
|
19
|
-
* reports no value of its own, and every form-related member of
|
|
20
|
-
* `ElementInternals` throws `NotSupportedError` on an element that is not
|
|
21
|
-
* form-associated. Measured, and the ARIA members are fine on the same
|
|
22
|
-
* element, which is why the naming and describing work there and only the
|
|
23
|
-
* validity has to ask first.
|
|
24
|
-
*
|
|
25
|
-
* Every form control wants the same three things around it and wants them in a
|
|
26
|
-
* different arrangement: a label above an input, beside a checkbox, and over a
|
|
27
|
-
* group. So this owns the properties, the parts, and the wiring, and each
|
|
28
|
-
* control places the nodes where its own geometry needs them. Same part names
|
|
29
|
-
* either way, so one stylesheet in the styled tier dresses all of them.
|
|
30
|
-
*
|
|
31
|
-
* **Not a wrapping `Field` component, and that is not a style preference.** A
|
|
32
|
-
* wrapper cannot name what it wraps. Its label would live in its own shadow
|
|
33
|
-
* root and the control in another, and measured in Chrome 148: an
|
|
34
|
-
* `aria-labelledby` IDREF cannot see across, and
|
|
35
|
-
* `control.ariaLabelledByElements = [labelInTheWrapper]` reads back as zero
|
|
36
|
-
* elements, the reference dropped. The same assignment with a label in the
|
|
37
|
-
* control's own tree is kept. That pattern works in React libraries because
|
|
38
|
-
* they have no shadow boundary; bringing it here brings the ergonomics and none
|
|
39
|
-
* of the conditions that made it work.
|
|
40
|
-
*
|
|
41
|
-
* **A control whose label is its content renders its own.** A checkbox is named
|
|
42
|
-
* by the words beside it, inside the `<label>` that forwards the press, so it
|
|
43
|
-
* uses its default slot and nesting `renderLabel`'s `<label>` in there would be
|
|
44
|
-
* invalid markup. `label` is that slot's fallback either way.
|
|
45
|
-
*
|
|
46
|
-
* Worth knowing, because it costs a label and gives no warning: **a
|
|
47
|
-
* whitespace-only text node is assigned to the default slot and defeats its
|
|
48
|
-
* fallback.** Measured in Chrome 148, and a newline between the tags is enough,
|
|
49
|
-
* so `<helix-checkbox label="Accept">` closed on the next line renders no label
|
|
50
|
-
* at all. A named slot does not have this problem, because `slot="label"` is an
|
|
51
|
-
* attribute and a text node has none. It is the platform's behavior rather
|
|
52
|
-
* than ours, and the same is already true of every slot fallback here.
|
|
53
|
-
*
|
|
54
|
-
* **The ids are fixed rather than generated.** IDREFs resolve within the tree
|
|
55
|
-
* that holds them, so two controls on a page each carrying `helix-hint` in
|
|
56
|
-
* their own shadow root do not collide. That also removes the usual hydration
|
|
57
|
-
* hazard, since there is no generated value for a server and a client to
|
|
58
|
-
* disagree about.
|
|
59
|
-
*
|
|
60
|
-
* **A group has to say it differently.** A control's `<input>` and its hint
|
|
61
|
-
* are both in the shadow root, so a plain `aria-describedby` resolves. A
|
|
62
|
-
* group's host is in the light DOM and its hint is not, so the IDREF cannot
|
|
63
|
-
* reach and `element.ariaDescribedByElements` drops the reference. Only
|
|
64
|
-
* `internals.ariaDescribedByElements` keeps it, which is what `describeHost`
|
|
65
|
-
* uses. Measured the same way, and the failure is silent either way: the
|
|
66
|
-
* description simply never reaches the accessibility tree.
|
|
67
|
-
*
|
|
68
|
-
* **The label, the hint and the feedback are documented on each component that
|
|
69
|
-
* applies this, rather than here.** Declaring them once on this mixin is the
|
|
70
|
-
* obvious fix and does not work: the analyzer reads `@csspart` off a custom
|
|
71
|
-
* element class, so a class-level tag here reaches the manifest as nothing at
|
|
72
|
-
* all, on this JSDoc and on the inner class alike. Measured both ways, and the
|
|
73
|
-
* inner-class version is the dangerous one, since it satisfies the rule while
|
|
74
|
-
* every API page stays exactly as wrong as before. Members are different and
|
|
75
|
-
* do inherit, which is what the note about the inner class in the area rules
|
|
76
|
-
* is about.
|
|
77
|
-
*/
|
|
78
12
|
export const MixinField = (Base) => {
|
|
79
|
-
// JSDoc is duplicated from MixinFieldInterface deliberately. The interface is
|
|
80
|
-
// what TypeScript consumers see; this class is what the manifest analyzer
|
|
81
|
-
// reads when resolving inherited members into dependent packages.
|
|
82
13
|
class FieldElement extends Base {
|
|
83
14
|
constructor() {
|
|
84
15
|
super(...arguments);
|
|
85
|
-
/**
|
|
86
|
-
* What is wrong, in the reader's words rather than the browser's.
|
|
87
|
-
*/
|
|
88
16
|
this.feedback = '';
|
|
89
|
-
/**
|
|
90
|
-
* Help under the control, shown while there is nothing wrong.
|
|
91
|
-
*/
|
|
92
17
|
this.hint = '';
|
|
93
|
-
/**
|
|
94
|
-
* The accessible name, and the fallback for the `label` slot.
|
|
95
|
-
*/
|
|
96
18
|
this.label = '';
|
|
97
|
-
/**
|
|
98
|
-
* Whether the form refuses to submit without it.
|
|
99
|
-
*/
|
|
100
19
|
this.required = false;
|
|
101
|
-
/**
|
|
102
|
-
* Whether the reader has had a go at the field yet.
|
|
103
|
-
*
|
|
104
|
-
* **Not `:user-invalid`**, which is the platform's own answer to "is this
|
|
105
|
-
* field wrong *yet*" and cannot work here, for a structural reason rather
|
|
106
|
-
* than a browser bug: it tracks interaction with the element it is on, and
|
|
107
|
-
* nobody interacts with this element. The reader types into an `<input>`
|
|
108
|
-
* inside its shadow root, so the host's interaction flag is never set and
|
|
109
|
-
* the selector never matches. Measured after submitting an invalid required
|
|
110
|
-
* field and again after editing one and leaving it: false both times, while
|
|
111
|
-
* `:invalid` matched both times.
|
|
112
|
-
*
|
|
113
|
-
* So the moments are listened for instead, and both of them demonstrably
|
|
114
|
-
* reach the host: `invalid` when a form is submitted with this control
|
|
115
|
-
* failing, and `change` when the reader has edited and committed. Focus
|
|
116
|
-
* alone is deliberately not one of them. Tabbing past an empty required
|
|
117
|
-
* field should not be answered with an error.
|
|
118
|
-
*
|
|
119
|
-
* **A composite control has to construct its own `change`, and this mixin
|
|
120
|
-
* deliberately does not do it for them.** A native `change` is
|
|
121
|
-
* `composed: false`, so a control built from several inner inputs never
|
|
122
|
-
* relays one to its host: `Input` gets this free from its one control,
|
|
123
|
-
* while a `PinInput` of four boxes has no inner `change` that means the
|
|
124
|
-
* code is entered, so a half-entered code is never `touched` and can never
|
|
125
|
-
* say what is wrong with it.
|
|
126
|
-
*
|
|
127
|
-
* **The obvious shared fix is for `touched` to take a `focusout`, and it is
|
|
128
|
-
* the wrong one**, because it is the paragraph above undone: a `focusout`
|
|
129
|
-
* fires on the reader who tabbed past and typed nothing, which is exactly
|
|
130
|
-
* the case this refuses to call an error. What a composite needs is not a
|
|
131
|
-
* focus moment, it is the native meaning of `change`, "edited, then left",
|
|
132
|
-
* and the editing half is the part a mixin up here cannot see.
|
|
133
|
-
*
|
|
134
|
-
* **Nor is there one shape to extract yet.** The two composites solve it
|
|
135
|
-
* differently and both are right: `PinInput` is focus-driven, taking a
|
|
136
|
-
* baseline when focus arrives from outside the row and announcing on the
|
|
137
|
-
* way out if it moved, and `DateInput` is value-driven, stopping its inner
|
|
138
|
-
* `change` and announcing once the date itself moves. Only one of them has
|
|
139
|
-
* a leaving moment at all, so generalizing from the pair is generalizing
|
|
140
|
-
* from one. The trigger for lifting it is a third control that needs the
|
|
141
|
-
* focus-driven shape, at which point what moves up is the
|
|
142
|
-
* baseline-and-commit pair rather than `touched`.
|
|
143
|
-
*/
|
|
144
20
|
this.touched = false;
|
|
145
|
-
/** What the last render put on screen, so a change can be noticed. */
|
|
146
21
|
this.shownFeedback = '';
|
|
147
22
|
}
|
|
148
|
-
/**
|
|
149
|
-
* Whether this element may be asked about its own validity at all.
|
|
150
|
-
*
|
|
151
|
-
* Reading the static the platform itself reads to decide, rather than
|
|
152
|
-
* probing: `validity` and `validationMessage` throw `NotSupportedError` on
|
|
153
|
-
* an element that is not form-associated, and a group whose members each
|
|
154
|
-
* submit themselves is not. Such a control still shows a message, it just
|
|
155
|
-
* has to be given one.
|
|
156
|
-
*/
|
|
157
23
|
get validates() {
|
|
158
24
|
return this.constructor.formAssociated === true;
|
|
159
25
|
}
|
|
160
|
-
/**
|
|
161
|
-
* @internal The message actually shown: the consumer's, or the platform's,
|
|
162
|
-
* or none. Wiring a field does to itself, read by its own template. A
|
|
163
|
-
* consumer sets `feedback` and reads validity from the platform.
|
|
164
|
-
*/
|
|
165
26
|
get resolvedFeedback() {
|
|
166
27
|
if (this.feedback !== '') {
|
|
167
28
|
return this.feedback;
|
|
@@ -171,32 +32,13 @@ export const MixinField = (Base) => {
|
|
|
171
32
|
}
|
|
172
33
|
return this.internals.validationMessage;
|
|
173
34
|
}
|
|
174
|
-
/**
|
|
175
|
-
* @internal Id of the description node, for `aria-describedby` on the
|
|
176
|
-
* control. Written by the field's own template into its own shadow root.
|
|
177
|
-
*
|
|
178
|
-
* One at a time, because only one of the two is ever rendered. Naming both
|
|
179
|
-
* would point a screen reader at an id that resolves to nothing, which it
|
|
180
|
-
* reads as a description it cannot find.
|
|
181
|
-
*/
|
|
182
35
|
get describedBy() {
|
|
183
36
|
if (this.resolvedFeedback !== '') {
|
|
184
37
|
return FEEDBACK_ID;
|
|
185
38
|
}
|
|
186
39
|
return this.hint === '' ? undefined : HINT_ID;
|
|
187
40
|
}
|
|
188
|
-
/**
|
|
189
|
-
* A label over a set rather than beside a control.
|
|
190
|
-
*
|
|
191
|
-
* A plain element and not a `<label for>`, because a group is not a single
|
|
192
|
-
* control and there is nothing for `for` to point at. What names the group
|
|
193
|
-
* is `linkHost`, pointing the host's internals at this element.
|
|
194
|
-
*/
|
|
195
41
|
renderGroupLabel() {
|
|
196
|
-
/* A set names itself through `hostAria`, so an `aria-label` here already
|
|
197
|
-
reaches the group. The label is still rendered and clipped, because
|
|
198
|
-
`renderGroupLabel` is what the styled tier lays out and a group that
|
|
199
|
-
drew nothing would collapse its own first row. */
|
|
200
42
|
const hidden = this.label === '' ? this.getAttribute('aria-label') : null;
|
|
201
43
|
if (this.label === '' && !hidden) {
|
|
202
44
|
return nothing;
|
|
@@ -205,31 +47,6 @@ export const MixinField = (Base) => {
|
|
|
205
47
|
><slot name="label">${this.label || hidden}</slot></span
|
|
206
48
|
>`;
|
|
207
49
|
}
|
|
208
|
-
/**
|
|
209
|
-
* A label above the control, pointing at it.
|
|
210
|
-
*
|
|
211
|
-
* A real `<label for>`, so pressing the words focuses the field and the
|
|
212
|
-
* browser does the forwarding. It resolves because `for` looks within the
|
|
213
|
-
* same tree, and the control it names is in this shadow root.
|
|
214
|
-
*
|
|
215
|
-
* **Nothing at all when there is no label**, which is the same rule the
|
|
216
|
-
* hint and the feedback follow: none of the three is always used, and an
|
|
217
|
-
* element rendered empty is not free. It still generates a box, so it still
|
|
218
|
-
* takes a row of the grid and a gap on either side of it, which is a field
|
|
219
|
-
* that has moved down the page for a label nobody wrote. `nothing` leaves
|
|
220
|
-
* comment markers rather than a node, so the gap has nothing to sit between.
|
|
221
|
-
*
|
|
222
|
-
* **`aria-label` names without showing**, and it is forwarded rather than
|
|
223
|
-
* left where the consumer put it. A custom element has no way to delegate a
|
|
224
|
-
* label to the control inside it, so an `aria-label` on the host reaches
|
|
225
|
-
* nothing: measured, the inner input came back with no label and no name of
|
|
226
|
-
* its own while the attribute sat on the wrapper. What happens instead is
|
|
227
|
-
* that the label is rendered and clipped, so the name arrives through the
|
|
228
|
-
* same `<label for>` a visible one does.
|
|
229
|
-
*
|
|
230
|
-
* The rule is that `label` means a label, `aria-label` means a name, and
|
|
231
|
-
* neither means an unnamed field a screen reader announces as "edit text".
|
|
232
|
-
*/
|
|
233
50
|
renderLabel() {
|
|
234
51
|
const hidden = this.label === '' ? this.getAttribute('aria-label') : null;
|
|
235
52
|
if (this.label === '' && !hidden) {
|
|
@@ -239,27 +56,12 @@ export const MixinField = (Base) => {
|
|
|
239
56
|
><slot name="label">${this.label || hidden}</slot></label
|
|
240
57
|
>`;
|
|
241
58
|
}
|
|
242
|
-
/**
|
|
243
|
-
* Help under the control, unless something has gone wrong.
|
|
244
|
-
*
|
|
245
|
-
* The two share a place rather than stacking, so a field is one line of
|
|
246
|
-
* small text either way and nothing below it moves when the message
|
|
247
|
-
* changes. What the reader needs at that moment is what is wrong, and a
|
|
248
|
-
* hint that survives the error it describes is a second line to read before
|
|
249
|
-
* getting to the one that matters.
|
|
250
|
-
*/
|
|
251
59
|
renderHint() {
|
|
252
60
|
if (this.hint === '' || this.resolvedFeedback !== '') {
|
|
253
61
|
return nothing;
|
|
254
62
|
}
|
|
255
63
|
return html `<p part="hint" id=${HINT_ID}><slot name="hint">${this.hint}</slot></p>`;
|
|
256
64
|
}
|
|
257
|
-
/**
|
|
258
|
-
* The validation message, in the hint's place.
|
|
259
|
-
*
|
|
260
|
-
* `role="alert"` rather than a live region on the whole field, so what is
|
|
261
|
-
* announced is the message and not the label and help along with it.
|
|
262
|
-
*/
|
|
263
65
|
renderFeedback() {
|
|
264
66
|
const message = this.resolvedFeedback;
|
|
265
67
|
if (message === '') {
|
|
@@ -269,23 +71,6 @@ export const MixinField = (Base) => {
|
|
|
269
71
|
<slot name="feedback">${message}</slot>
|
|
270
72
|
</p>`;
|
|
271
73
|
}
|
|
272
|
-
/**
|
|
273
|
-
* Points the host at its own label and description nodes, for a group.
|
|
274
|
-
*
|
|
275
|
-
* A control's `<input>` and its hint are both in the shadow root, so a
|
|
276
|
-
* plain `aria-describedby` resolves between them. A group's host is in the
|
|
277
|
-
* light DOM and its hint is not, so the IDREF cannot reach and
|
|
278
|
-
* `element.ariaDescribedByElements` drops the reference outright. Only the
|
|
279
|
-
* `ElementInternals` form keeps it, measured in Chrome 148: two of two
|
|
280
|
-
* kept through internals, zero of one through the element.
|
|
281
|
-
*
|
|
282
|
-
* Client only, and deliberately, because element references need the nodes
|
|
283
|
-
* to exist and on the server they do not. The name is therefore also set as
|
|
284
|
-
* a plain string by the component, which a server can write, and the
|
|
285
|
-
* reference here overrides it wherever a consumer slotted something richer
|
|
286
|
-
* than a string. A group with only a property for a label reads the same
|
|
287
|
-
* either way.
|
|
288
|
-
*/
|
|
289
74
|
linkHost() {
|
|
290
75
|
const label = this.renderRoot.querySelector('[part="label"]');
|
|
291
76
|
hostAria(this.internals, {
|
|
@@ -295,19 +80,6 @@ export const MixinField = (Base) => {
|
|
|
295
80
|
labelledBy: label ? [label] : [],
|
|
296
81
|
});
|
|
297
82
|
}
|
|
298
|
-
/**
|
|
299
|
-
* Re-reads the platform's verdict, for a control that has just reported one.
|
|
300
|
-
*
|
|
301
|
-
* Validity changes through `ElementInternals` rather than through a
|
|
302
|
-
* property, so nothing schedules an update for it. Worse, the order is
|
|
303
|
-
* against us: this mixin sits above the control in the prototype chain, so
|
|
304
|
-
* its `updated` runs before the control's own, which is where `setValidity`
|
|
305
|
-
* is called. Reading the verdict here would always read the previous one,
|
|
306
|
-
* which is what left a corrected field still showing the error it had
|
|
307
|
-
* before. So the control says when it has reported, and this schedules the
|
|
308
|
-
* one further update that needs. It converges because the second pass finds
|
|
309
|
-
* nothing changed.
|
|
310
|
-
*/
|
|
311
83
|
refreshFeedback() {
|
|
312
84
|
const next = this.resolvedFeedback;
|
|
313
85
|
if (next !== this.shownFeedback) {
|
|
@@ -315,19 +87,6 @@ export const MixinField = (Base) => {
|
|
|
315
87
|
this.requestUpdate();
|
|
316
88
|
}
|
|
317
89
|
}
|
|
318
|
-
/**
|
|
319
|
-
* Reports "required and empty" and refreshes the message, or clears both.
|
|
320
|
-
*
|
|
321
|
-
* Two things differ between the controls that call this, so those are the
|
|
322
|
-
* arguments: whether the control is empty, which only it can say, and the
|
|
323
|
-
* sentence to say about it, which is different for a box, a switch and a
|
|
324
|
-
* set. The anchor is looked up here because every one of them anchors on a
|
|
325
|
-
* rendered part and the browser needs somewhere to point the bubble.
|
|
326
|
-
*
|
|
327
|
-
* The message is not derived from the control's kind, deliberately. "Tick
|
|
328
|
-
* this box" and "Choose one" are not the same instruction and a lookup
|
|
329
|
-
* table would be one more thing to keep in step with the components.
|
|
330
|
-
*/
|
|
331
90
|
reportRequired(empty, message, anchor) {
|
|
332
91
|
if (this.required && empty) {
|
|
333
92
|
this.internals.setValidity({ valueMissing: true }, message, this.renderRoot?.querySelector(`[part="${anchor}"]`) ?? undefined);
|
|
@@ -335,40 +94,14 @@ export const MixinField = (Base) => {
|
|
|
335
94
|
else {
|
|
336
95
|
this.internals.setValidity({});
|
|
337
96
|
}
|
|
338
|
-
// Straight after, because validity moved without any property moving.
|
|
339
97
|
this.refreshFeedback();
|
|
340
98
|
}
|
|
341
|
-
/**
|
|
342
|
-
* Records "wrong, and the reader knows it" as the custom state `invalid`.
|
|
343
|
-
*
|
|
344
|
-
* The styled tier selects on this rather than on `:host(:user-invalid)`,
|
|
345
|
-
* which cannot match for the reason above. One state, and the message and
|
|
346
|
-
* the border agree by construction.
|
|
347
|
-
*
|
|
348
|
-
* **A state and not `data-invalid`.** A field served with a `feedback`
|
|
349
|
-
* message already failing is written invalid on its first update, which
|
|
350
|
-
* would be an attribute the consumer's server never sent.
|
|
351
|
-
* `HelixElement.toggleState` carries the argument.
|
|
352
|
-
*
|
|
353
|
-
* In `willUpdate`, which is where the resolved message is first known.
|
|
354
|
-
*/
|
|
355
99
|
willUpdate(changed) {
|
|
356
100
|
super.willUpdate(changed);
|
|
357
101
|
this.shownFeedback = this.resolvedFeedback;
|
|
358
102
|
this.toggleState('invalid', this.shownFeedback !== '');
|
|
359
|
-
/* And say it to the accessibility tree. Describing a field with its
|
|
360
|
-
* `feedback` message through `aria-describedby` never announces it as
|
|
361
|
-
* being *wrong*: a reader hears the message as though it were help text.
|
|
362
|
-
* `aria-invalid` is the one word for it, and this is the one place that
|
|
363
|
-
* knows, so every field in the library gets it from here. */
|
|
364
103
|
this.internals.ariaInvalid = this.shownFeedback === '' ? null : 'true';
|
|
365
104
|
}
|
|
366
|
-
/**
|
|
367
|
-
* The two moments a field becomes wrong, neither of which is a property
|
|
368
|
-
* change, so nothing else would schedule an update for them.
|
|
369
|
-
*
|
|
370
|
-
* In `firstUpdated` rather than `connectedCallback`, which SSR never calls.
|
|
371
|
-
*/
|
|
372
105
|
firstUpdated(changed) {
|
|
373
106
|
super.firstUpdated(changed);
|
|
374
107
|
const touch = () => {
|
|
@@ -393,15 +126,5 @@ export const MixinField = (Base) => {
|
|
|
393
126
|
__decorate([
|
|
394
127
|
state()
|
|
395
128
|
], FieldElement.prototype, "touched", void 0);
|
|
396
|
-
/* Through `unknown`, which the protected members make necessary: TypeScript
|
|
397
|
-
treats two protected members as compatible only when they come from the
|
|
398
|
-
same declaration, and these come from two, the class here and the declared
|
|
399
|
-
interface above. Making them public would type-check without a word and put
|
|
400
|
-
five render helpers on the public API page of every form control, which is
|
|
401
|
-
what the interface exists to prevent.
|
|
402
|
-
|
|
403
|
-
The contract is enforced by use rather than by the cast. Every helper is
|
|
404
|
-
called from `Input` or `Checkbox`, so an interface that drifts from the
|
|
405
|
-
class fails their builds. */
|
|
406
129
|
return FieldElement;
|
|
407
130
|
};
|
|
@@ -1,27 +1,4 @@
|
|
|
1
|
-
/** How far to descend before giving up on finding a box. */
|
|
2
1
|
const DEPTH = 4;
|
|
3
|
-
/**
|
|
4
|
-
* The element a floating surface should open against, given the slot it wraps.
|
|
5
|
-
*
|
|
6
|
-
* **A wrapper with no box is not an anchor.** `display: contents` puts an
|
|
7
|
-
* element's children into the layout and leaves the element itself without a
|
|
8
|
-
* box, so its `getBoundingClientRect()` is `0 0 0 0`. Measured: a tooltip host
|
|
9
|
-
* reports exactly that while the button inside it reports 32 by 32. Anchoring
|
|
10
|
-
* to it opens the surface against the top left of the window, and focusing it
|
|
11
|
-
* on close puts focus nowhere.
|
|
12
|
-
*
|
|
13
|
-
* That is not an edge case, because wrapping is how a tooltip attaches to the
|
|
14
|
-
* thing it describes. Without this, an icon button used as a trigger is the one
|
|
15
|
-
* button on a toolbar that cannot have a tooltip.
|
|
16
|
-
*
|
|
17
|
-
* So this descends past anything that draws nothing, and the first child with a
|
|
18
|
-
* box is the anchor. The wrapper is transparent by construction, so the thing
|
|
19
|
-
* with the box is the thing the reader is pointing at.
|
|
20
|
-
*
|
|
21
|
-
* Bounded rather than a walk to the leaves: a chain of box-less wrappers is
|
|
22
|
-
* already unusual, and descending without a limit through a tree this library
|
|
23
|
-
* does not own is worse than stopping and anchoring to something imperfect.
|
|
24
|
-
*/
|
|
25
2
|
export const anchorElement = (slot, fallback) => {
|
|
26
3
|
const assigned = slot?.assignedElements({ flatten: true })[0];
|
|
27
4
|
if (!assigned) {
|
|
@@ -1,21 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Takes the pointer for a drag, and carries on if the platform refuses.
|
|
3
|
-
*
|
|
4
|
-
* **Capture is an enhancement, and the drag has to survive losing it.** What it
|
|
5
|
-
* buys is events continuing to arrive once the pointer leaves the target, which
|
|
6
|
-
* matters because a thin divider is easy to outrun. What it costs, called bare,
|
|
7
|
-
* is the rest of the handler: `setPointerCapture` throws `NotFoundError` when
|
|
8
|
-
* the id names no active pointer, and every one of these calls sits above the
|
|
9
|
-
* lines that set the dragging flag and call `preventDefault`. So a refusal did
|
|
10
|
-
* not degrade the drag, it canceled it.
|
|
11
|
-
*
|
|
12
|
-
* That is reachable outside a test: a pointer released between the event being
|
|
13
|
-
* queued and the handler running is gone by the time this is called, and a
|
|
14
|
-
* synthetic `pointerdown` has no active pointer at all.
|
|
15
|
-
*
|
|
16
|
-
* Returns whether it took, for the callers that then ask `hasPointerCapture`
|
|
17
|
-
* before believing a move belongs to them.
|
|
18
|
-
*/
|
|
19
1
|
export const capturePointer = (target, pointerId) => {
|
|
20
2
|
try {
|
|
21
3
|
target.setPointerCapture(pointerId);
|
|
@@ -1,23 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The nearest ancestor that is an instance of a class, crossing shadow roots.
|
|
3
|
-
*
|
|
4
|
-
* **Identity rather than tag name.** Matching `helix-accordion-item` ties the
|
|
5
|
-
* headless tier to the styled one by string, and only `@fusion.dev/helix`
|
|
6
|
-
* registers a `helix-*` tag.
|
|
7
|
-
*
|
|
8
|
-
* The consequence is silent rather than loud. Subclassing a headless element under
|
|
9
|
-
* a different tag is exactly what the headless tier exists to allow, and it is
|
|
10
|
-
* what `helix/styled` itself does. Under a third tag a lookup by tag returns
|
|
11
|
-
* `null`, so an accordion's `mode` is ignored and every section behaves as
|
|
12
|
-
* `multiple` with no error anywhere.
|
|
13
|
-
*
|
|
14
|
-
* **`closest()` alone is not enough**, which is why this walks rather than
|
|
15
|
-
* delegating. `closest()` stops at a shadow boundary: a component slotted into
|
|
16
|
-
* another component's shadow root has a `parentNode` chain that ends at a
|
|
17
|
-
* `ShadowRoot`, and `closest` will not step across it. Following `host` is what
|
|
18
|
-
* makes a nested arrangement resolve, and nesting through a slot is the
|
|
19
|
-
* ordinary case for anything composable.
|
|
20
|
-
*/
|
|
21
1
|
export const closestInstance = (from, Ctor) => {
|
|
22
2
|
let node = from ?? null;
|
|
23
3
|
while (node) {
|
|
@@ -1,16 +1,2 @@
|
|
|
1
1
|
import { unsafeCSS } from 'lit';
|
|
2
|
-
/**
|
|
3
|
-
* Builds a stylesheet from CSS authored in a `.css` file.
|
|
4
|
-
*
|
|
5
|
-
* Component styles live in real `.css` files rather than `css` tagged
|
|
6
|
-
* templates, so they get CSS tooling: syntax highlighting, formatters, the
|
|
7
|
-
* token linter, and a diff that reads as CSS. `turbo run styles` compiles each
|
|
8
|
-
* one into a sibling `.css.ts` that calls this.
|
|
9
|
-
*
|
|
10
|
-
* The `unsafe` in `unsafeCSS` warns about interpolating untrusted input into a
|
|
11
|
-
* stylesheet. That is not what happens here: the argument is a build-time
|
|
12
|
-
* constant read from a file in this repo, never a runtime value. Wrapping it
|
|
13
|
-
* keeps the warning where it belongs, on `unsafeCSS` itself, and gives one seam
|
|
14
|
-
* to change if constructable stylesheets become the better primitive.
|
|
15
|
-
*/
|
|
16
2
|
export const createSheet = (cssText) => unsafeCSS(cssText);
|
|
@@ -1,90 +1,8 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Registers a custom element, once.
|
|
3
|
-
*
|
|
4
|
-
* A drop-in replacement for Lit's `@customElement` that does not throw when the
|
|
5
|
-
* tag is already registered.
|
|
6
|
-
*
|
|
7
|
-
* The duplicate is not hypothetical. Next.js evaluates a module in both the
|
|
8
|
-
* server and client graphs, so a component imported by a server component and a
|
|
9
|
-
* client component runs its registration twice against one registry, and
|
|
10
|
-
* `customElements.define` throws:
|
|
11
|
-
*
|
|
12
|
-
* > Failed to execute 'define': the name "helix-card" has already been used
|
|
13
|
-
*
|
|
14
|
-
* That failure takes down the whole prerender, and it appears only once an app
|
|
15
|
-
* mixes server and client usage of the same component, which is exactly what a
|
|
16
|
-
* real app does.
|
|
17
|
-
*
|
|
18
|
-
* The analyzer detects `@customElement` **by decorator name**, not by where it
|
|
19
|
-
* is imported from, so the manifest still records `tagName` and nothing
|
|
20
|
-
* downstream changes.
|
|
21
|
-
*/
|
|
22
|
-
/**
|
|
23
|
-
* Tags already reported, so a page holding duplicates says each one once rather
|
|
24
|
-
* than once per module that tried.
|
|
25
|
-
*/
|
|
26
1
|
const reported = new Set();
|
|
27
|
-
/**
|
|
28
|
-
* The tags this page registered twice, for a reader that is not a person.
|
|
29
|
-
*
|
|
30
|
-
* **The warning below is the right thing and it is not a readable output.** It
|
|
31
|
-
* fires at the moment of the second registration, which is the best possible
|
|
32
|
-
* position and better than anything a later pass can do, and it goes to a
|
|
33
|
-
* console: a CI job cannot see it, and a developer whose console already has
|
|
34
|
-
* forty lines in it will not. `@fusion.dev/helix-diagnostics` reads this instead, so the
|
|
35
|
-
* same fault reaches a structured report without a second detector guessing at
|
|
36
|
-
* it from the outside. Detect once, at the source; surface twice.
|
|
37
|
-
*/
|
|
38
2
|
const duplicates = new Set();
|
|
39
|
-
/** Every tag registered twice by different classes, in registration order. */
|
|
40
3
|
export const duplicateRegistrations = () => [...duplicates];
|
|
41
|
-
/**
|
|
42
|
-
* Every tag this decorator has defined, in definition order.
|
|
43
|
-
*
|
|
44
|
-
* **Recorded here because here is the only place that sees all of them once.** A
|
|
45
|
-
* spec wanting "every element this package defines" reached for patching
|
|
46
|
-
* `customElements.define` around an import of the barrel, and that answers
|
|
47
|
-
* correctly only for the first importer in the process: a second import is a
|
|
48
|
-
* module-cache hit and defines nothing, so any spec that touched a component
|
|
49
|
-
* first silently shrank the list. Measured when one did, and what it reported
|
|
50
|
-
* was a floor count, 164 against 250, which names nothing about the cause.
|
|
51
|
-
*
|
|
52
|
-
* Not `registerElementEvents`, which was tried and is a different set: the
|
|
53
|
-
* generated map holds the tags that *dispatch* something, so it came out 68
|
|
54
|
-
* against 90. Definition is the question, so the definition site is the answer.
|
|
55
|
-
*/
|
|
56
4
|
const defined = [];
|
|
57
|
-
/** Every tag registered through `customElement`, whoever imported it first. */
|
|
58
5
|
export const definedTags = () => [...defined];
|
|
59
|
-
/**
|
|
60
|
-
* Says so when a second, different class asks for a tag that is already taken.
|
|
61
|
-
*
|
|
62
|
-
* **Returning quietly is right and saying nothing is not.** The registry keeps
|
|
63
|
-
* the first constructor, so on a page carrying two copies of Helix every
|
|
64
|
-
* `<helix-button>` is the first copy's class whatever the second copy exports.
|
|
65
|
-
* Nothing fails: the element renders, behaves, and answers its own API. What
|
|
66
|
-
* breaks is identity, `el instanceof HelixButton` returning `false` against an
|
|
67
|
-
* element that is visibly a button, and a version's fixes being absent from
|
|
68
|
-
* elements the consumer believes are on that version. Both are hours of
|
|
69
|
-
* debugging with no error to search for, which is the worst shape a library
|
|
70
|
-
* failure can take.
|
|
71
|
-
*
|
|
72
|
-
* **Only in a browser, which is the line that keeps this quiet where the guard
|
|
73
|
-
* was written for.** The prerender case above is a server one: Lit's SSR shim
|
|
74
|
-
* provides `customElements` and `document` and leaves `window` undefined, so
|
|
75
|
-
* `typeof window` separates a real page from a render pass exactly. Measured
|
|
76
|
-
* against `@lit-labs/ssr`'s global DOM shim rather than assumed, because a
|
|
77
|
-
* diagnostic that fires during a build is a diagnostic somebody turns off.
|
|
78
|
-
*
|
|
79
|
-
* **Different constructors, not merely a second call.** A module evaluated
|
|
80
|
-
* twice in one graph produces two class objects and is a genuine duplicate; the
|
|
81
|
-
* same class arriving twice through two import specifiers is not, and has
|
|
82
|
-
* nothing to report.
|
|
83
|
-
*
|
|
84
|
-
* No `throw`. The first definition is already live and elements are already
|
|
85
|
-
* upgraded against it, so there is nothing left to refuse and a lot to take
|
|
86
|
-
* down.
|
|
87
|
-
*/
|
|
88
6
|
const warnDuplicate = (tagName) => {
|
|
89
7
|
if (typeof window === 'undefined' || reported.has(tagName)) {
|
|
90
8
|
return;
|
|
@@ -98,7 +16,6 @@ const warnDuplicate = (tagName) => {
|
|
|
98
16
|
'bundler shipping the same version twice is the other. Deduplicate the dependency.');
|
|
99
17
|
};
|
|
100
18
|
export const customElement = (tagName) => (target) => {
|
|
101
|
-
// Lit SSR shims the registry, but a bare Node context may have none.
|
|
102
19
|
if (typeof customElements === 'undefined') {
|
|
103
20
|
return;
|
|
104
21
|
}
|
|
@@ -1,33 +1,5 @@
|
|
|
1
1
|
import { html, nothing } from 'lit';
|
|
2
2
|
import { unsafeSVG } from 'lit/directives/unsafe-svg.js';
|
|
3
|
-
/**
|
|
4
|
-
* Draws an icon that was imported by name, rather than looked up by string.
|
|
5
|
-
*
|
|
6
|
-
* **`<helix-icon name="play">` indexes the whole icon set at runtime.** Its
|
|
7
|
-
* render is `icons[this.name]`, and no bundler can shake a dynamic index, so
|
|
8
|
-
* every consumer of any component that renders one ships all of the generated
|
|
9
|
-
* path data: measured at 50,814 bytes raw and 15,010 gzipped for 102 icons, and
|
|
10
|
-
* growing about 147 gzipped bytes per icon added. A named import ships one path
|
|
11
|
-
* and is checked when the package compiles rather than by a name lookup later.
|
|
12
|
-
*
|
|
13
|
-
* That element still earns its place as *public* API, where a consumer writes a
|
|
14
|
-
* name in markup and a lookup is the only thing that can work. What it should
|
|
15
|
-
* not be is how this library draws its own marks.
|
|
16
|
-
*
|
|
17
|
-
* **There is no `part` option, and there cannot be.** A part name behind an
|
|
18
|
-
* interpolation is a part the manifest cannot see, so the component's own
|
|
19
|
-
* stylesheet matches nothing and the part stops being
|
|
20
|
-
* API. `Trend` needs one and therefore writes its `<svg>` out with a literal
|
|
21
|
-
* `part="icon"`, which is the honest cost: a helper can carry everything except
|
|
22
|
-
* the thing static analysis has to read.
|
|
23
|
-
*
|
|
24
|
-
* `data-glyph` rather than a class, because the sizing lives in
|
|
25
|
-
* `helix/styled/src/icon/glyph.css` and a component's own `--icon-size`
|
|
26
|
-
* declarations have to keep working untouched: the variable was read by
|
|
27
|
-
* `helix-icon`, and now it is read by the rule that sheet carries. Without it
|
|
28
|
-
* every one of those declarations would go quietly dead and every mark in six
|
|
29
|
-
* families would change size.
|
|
30
|
-
*/
|
|
31
3
|
export const drawIcon = (icon, options = {}) => html `<svg
|
|
32
4
|
aria-hidden="true"
|
|
33
5
|
data-glyph
|