@fusion.dev/helix-core 0.2.0 → 0.4.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 (56) hide show
  1. package/custom-elements.json +4 -4
  2. package/dist/controllers/aria-forward-controller/aria-forward-controller.js +0 -105
  3. package/dist/controllers/chart-pointer-controller/chart-pointer-controller.js +0 -71
  4. package/dist/controllers/command-controller/command-controller.js +0 -62
  5. package/dist/controllers/copy-controller/copy-controller.js +0 -63
  6. package/dist/controllers/disabled-cascade-controller/disabled-cascade-controller.js +0 -40
  7. package/dist/controllers/dismiss-controller/dismiss-controller.js +0 -124
  8. package/dist/controllers/drilldown-controller/drilldown-controller.js +0 -87
  9. package/dist/controllers/floating-controller/floating-controller.js +0 -152
  10. package/dist/controllers/focus-trap-controller/focus-trap-controller.js +0 -35
  11. package/dist/controllers/indicator-controller/indicator-controller.js +0 -82
  12. package/dist/controllers/keyboard-navigation-controller/keyboard-navigation-controller.js +0 -46
  13. package/dist/controllers/popover-controller/popover-controller.js +0 -72
  14. package/dist/controllers/source-controller/source-controller.js +0 -38
  15. package/dist/controllers/token-color-controller/token-color-controller.js +0 -51
  16. package/dist/controllers/virtualize-controller/virtualize-controller.js +0 -128
  17. package/dist/events/element-events.js +0 -22
  18. package/dist/events/events.js +0 -386
  19. package/dist/helix-element/helix-element.d.ts +10 -17
  20. package/dist/helix-element/helix-element.js +0 -505
  21. package/dist/helix-form-element/helix-form-element.js +0 -140
  22. package/dist/mixins/mixin-chart-source/mixin-chart-source.js +0 -77
  23. package/dist/mixins/mixin-collection/mixin-collection.js +0 -83
  24. package/dist/mixins/mixin-controller-active/mixin-controller-active.js +0 -82
  25. package/dist/mixins/mixin-controller-checked/mixin-controller-checked.js +0 -78
  26. package/dist/mixins/mixin-controller-open/mixin-controller-open.js +0 -93
  27. package/dist/mixins/mixin-controller-value/mixin-controller-value.js +0 -87
  28. package/dist/mixins/mixin-dismissible/mixin-dismissible.js +0 -38
  29. package/dist/mixins/mixin-field/mixin-field.js +0 -277
  30. package/dist/utils/anchor-element/anchor-element.js +0 -23
  31. package/dist/utils/capture-pointer/capture-pointer.js +0 -18
  32. package/dist/utils/closest-instance/closest-instance.js +0 -20
  33. package/dist/utils/create-sheet/create-sheet.js +0 -14
  34. package/dist/utils/custom-element/custom-element.js +0 -83
  35. package/dist/utils/draw-icon/draw-icon.js +0 -28
  36. package/dist/utils/draw-tooltip/draw-tooltip.js +1 -50
  37. package/dist/utils/elapsed-time/elapsed-time.js +0 -51
  38. package/dist/utils/focusable-elements/focusable-elements.js +1 -59
  39. package/dist/utils/format-numbers/format-numbers.js +0 -16
  40. package/dist/utils/host-aria/host-aria.js +0 -26
  41. package/dist/utils/inherit-disabled/inherit-disabled.js +0 -36
  42. package/dist/utils/line-path/line-path.js +0 -36
  43. package/dist/utils/on-unless-false/on-unless-false.js +0 -23
  44. package/dist/utils/read-series/read-series.js +0 -71
  45. package/dist/utils/relative-time/relative-time.js +0 -43
  46. package/dist/utils/resolve-active-item/resolve-active-item.js +0 -20
  47. package/dist/utils/restore-value/restore-value.js +0 -54
  48. package/dist/utils/scale/scale.js +0 -19
  49. package/dist/utils/scale/ticks.js +0 -36
  50. package/dist/utils/scroll-item-into-view/scroll-item-into-view.js +0 -53
  51. package/dist/utils/slot-filled/slot-filled.js +0 -23
  52. package/dist/utils/tell-disclosure/tell-disclosure.js +0 -53
  53. package/dist/utils/ticker/ticker.js +0 -22
  54. package/dist/utils/tone-marks/tone-marks.js +0 -24
  55. package/dist/utils/value-as/value-as.js +0 -56
  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