@fusion.dev/helix-headless 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.
Files changed (127) hide show
  1. package/custom-elements.json +234 -234
  2. package/dist/accordion/item/accordion-item.js +0 -108
  3. package/dist/accordion/root/accordion-root.js +0 -33
  4. package/dist/announcement/root/announcement-root.js +1 -76
  5. package/dist/app-header/root/app-header-root.js +0 -84
  6. package/dist/avatar/group/avatar-group.js +0 -77
  7. package/dist/avatar/root/avatar-root.js +0 -72
  8. package/dist/badge/root/badge-root.js +0 -67
  9. package/dist/bar-chart/root/bar-chart-root.js +3 -263
  10. package/dist/blockquote/root/blockquote-root.js +0 -46
  11. package/dist/breadcrumbs/breadcrumb/breadcrumb.js +0 -72
  12. package/dist/breadcrumbs/root/breadcrumbs-root.js +0 -52
  13. package/dist/button/root/button-root.js +0 -215
  14. package/dist/button-group/root/button-group-root.js +0 -30
  15. package/dist/callout/root/callout-root.js +0 -52
  16. package/dist/card/root/card-root.js +0 -41
  17. package/dist/carousel/root/carousel-root.d.ts +2 -2
  18. package/dist/carousel/root/carousel-root.js +2 -362
  19. package/dist/chat/composer/chat-composer.js +0 -131
  20. package/dist/chat/message/chat-message.js +0 -137
  21. package/dist/chat/root/chat-root.js +0 -193
  22. package/dist/checkbox/root/checkbox-root.js +0 -113
  23. package/dist/checkbox-group/root/checkbox-group-root.js +1 -157
  24. package/dist/code/root/code-root.js +0 -26
  25. package/dist/code-block/root/code-block-root.js +0 -100
  26. package/dist/color-input/root/color-input-root.js +0 -217
  27. package/dist/color-picker/color.js +0 -69
  28. package/dist/color-picker/root/color-picker-root.js +0 -272
  29. package/dist/combobox/root/combobox-root.js +0 -428
  30. package/dist/command/root/command-root.js +0 -177
  31. package/dist/copy-button/root/copy-button-root.js +0 -134
  32. package/dist/data-table/root/data-table-root.js +0 -336
  33. package/dist/data-table/root/order.js +0 -100
  34. package/dist/date-input/root/date-input-root.js +1 -398
  35. package/dist/date-picker/root/date-picker-root.js +0 -358
  36. package/dist/dialog/root/dialog-root.js +0 -189
  37. package/dist/divider/root/divider-root.js +0 -71
  38. package/dist/donut-chart/root/donut-chart-root.js +0 -214
  39. package/dist/drawer/root/drawer-root.js +0 -116
  40. package/dist/drilldown/item/drilldown-item.js +0 -82
  41. package/dist/drilldown/root/drilldown-root.js +0 -91
  42. package/dist/element-events.generated.js +0 -5
  43. package/dist/empty/root/empty-root.js +0 -35
  44. package/dist/file-input/root/file-input-root.js +0 -232
  45. package/dist/format-date/root/format-date-root.js +0 -95
  46. package/dist/heatmap/root/buckets.js +0 -57
  47. package/dist/heatmap/root/heatmap-root.js +0 -313
  48. package/dist/icon/root/icon-root.js +0 -48
  49. package/dist/icon-button/root/icon-button-root.js +0 -30
  50. package/dist/image/root/image-root.js +0 -174
  51. package/dist/index.js +0 -9
  52. package/dist/input/root/input-root.js +0 -228
  53. package/dist/kbd/root/kbd-root.js +0 -117
  54. package/dist/legend/root/legend-root.js +0 -73
  55. package/dist/line-chart/root/line-chart-root.js +0 -172
  56. package/dist/list/item/list-item.js +0 -92
  57. package/dist/list/root/list-root.js +0 -26
  58. package/dist/listbox/root/listbox-root.js +2 -429
  59. package/dist/loader/root/loader-root.js +0 -40
  60. package/dist/megamenu/root/megamenu-root.js +0 -284
  61. package/dist/menu/item/menu-item.js +1 -221
  62. package/dist/menu/root/menu-root.js +1 -171
  63. package/dist/meter/root/meter-root.js +1 -152
  64. package/dist/navigation/root/navigation-root.js +0 -56
  65. package/dist/notifications/notification/notification.js +0 -159
  66. package/dist/notifications/root/notifications-root.js +0 -148
  67. package/dist/number-input/root/number-input-root.js +1 -263
  68. package/dist/option/group/option-group.js +0 -26
  69. package/dist/option/root/option-root.js +0 -157
  70. package/dist/outline/root/outline-root.js +0 -292
  71. package/dist/pagination/root/pagination-root.js +0 -361
  72. package/dist/pagination/size/pagination-size.js +0 -104
  73. package/dist/pagination/summary/pagination-summary.js +0 -90
  74. package/dist/pin-input/root/pin-input-root.js +0 -276
  75. package/dist/popover/root/popover-root.js +0 -103
  76. package/dist/progress/root/progress-root.js +1 -133
  77. package/dist/qr-code/root/qr-code-root.js +0 -96
  78. package/dist/quote/root/quote-root.js +0 -25
  79. package/dist/radio/group/radio-group.js +0 -81
  80. package/dist/radio/root/radio-root.js +0 -85
  81. package/dist/rating/root/rating-root.js +0 -220
  82. package/dist/scroll-area/root/scroll-area-root.js +0 -100
  83. package/dist/select/root/select-root.js +2 -218
  84. package/dist/skeleton/root/skeleton-root.js +0 -20
  85. package/dist/slider/root/slider-root.js +1 -267
  86. package/dist/sparkline/root/sparkline-root.js +3 -164
  87. package/dist/speed-dial/item/speed-dial-item.js +0 -52
  88. package/dist/speed-dial/root/speed-dial-root.js +1 -116
  89. package/dist/splitter/root/splitter-root.js +0 -489
  90. package/dist/stat/group/stat-group.js +0 -18
  91. package/dist/stat/root/stat-root.js +0 -23
  92. package/dist/steps/root/steps-root.js +0 -62
  93. package/dist/steps/step/step.js +0 -131
  94. package/dist/switch/root/switch-root.js +0 -80
  95. package/dist/table/cell/table-cell.js +0 -23
  96. package/dist/table/column/table-column.js +0 -158
  97. package/dist/table/root/table-root.js +0 -82
  98. package/dist/table/row/table-row.js +0 -21
  99. package/dist/tabs/panel/tab-panel.js +0 -49
  100. package/dist/tabs/root/tabs-root.js +0 -98
  101. package/dist/tabs/tab/tab.js +0 -59
  102. package/dist/tag/root/tag-root.js +0 -31
  103. package/dist/textarea/root/textarea-root.js +0 -162
  104. package/dist/time-input/root/time-input-root.js +0 -283
  105. package/dist/time-picker/root/time-picker-root.js +0 -326
  106. package/dist/timeline/item/timeline-item.js +0 -66
  107. package/dist/timeline/root/timeline-root.js +0 -41
  108. package/dist/toast/item/toast-item.js +0 -165
  109. package/dist/toast/root/toast-root.js +0 -51
  110. package/dist/toggle/group/toggle-group.js +0 -150
  111. package/dist/toggle/root/toggle-root.js +0 -78
  112. package/dist/toolbar/root/toolbar-root.js +0 -116
  113. package/dist/tooltip/root/tooltip-root.js +0 -427
  114. package/dist/tour/root/tour-root.js +0 -130
  115. package/dist/tour/stop/tour-stop.js +1 -127
  116. package/dist/tree/item/tree-item.js +0 -198
  117. package/dist/tree/root/tree-root.js +0 -97
  118. package/dist/trend/root/trend-root.js +0 -53
  119. package/dist/video/root/video-root.d.ts +8 -12
  120. package/dist/video/root/video-root.js +5 -756
  121. package/dist/video-playlist/item/video-playlist-item.js +1 -90
  122. package/dist/video-playlist/menu/video-playlist-menu.js +1 -132
  123. package/dist/video-playlist/root/video-playlist-root.js +0 -113
  124. package/dist/virtual-list/root/virtual-list-root.js +0 -105
  125. package/jsx.d.ts +1408 -2576
  126. package/package.json +6 -6
  127. package/vue.d.ts +1 -1
@@ -9,87 +9,16 @@ import { caretLeft, caretRight, play, stop } from '@fusion.dev/helix-icons';
9
9
  import { html, nothing } from 'lit';
10
10
  import { property, state } from 'lit/decorators.js';
11
11
  import { unsafeSVG } from 'lit/directives/unsafe-svg.js';
12
- /**
13
- * @summary Shows one slide of several, with controls to move between them.
14
- *
15
- * @description
16
- * The track is a scroller with scroll snapping, so a drag, a trackpad swipe,
17
- * and the browser's own momentum all work without being reimplemented. The
18
- * controls scroll it; they do not drive a transform.
19
- *
20
- * Slides are the consumer's own elements, and nothing is written onto them. The
21
- * component renders a pane per slide carrying `role="group"`,
22
- * `aria-roledescription="slide"` and a name reading its position in the set,
23
- * and assigns the slide into it: a screen-reader user hears where they are, and
24
- * a slide that is itself a link is still a link. See `assign`.
25
- *
26
- * **Rotation stops on focus and on hover, and once stopped it stays stopped.**
27
- * The APG is explicit: rotation must not resume unless the reader asks for it,
28
- * so hovering away does not start it again. The control names the action it
29
- * performs, and it is first in the tab order.
30
- *
31
- * @since 0.1.0
32
- * @status experimental
33
- * @category layout
34
- * @aliases Slider, Gallery, Slideshow
35
- * @documentation https://helix.fusion.dev/components/carousel
36
- *
37
- * @slot - The slides.
38
- *
39
- * @event {HelixSelectEvent} helix-select - Fired when the slide in view changes, with its index. Cancelable: refusing it takes the decision and nothing moves.
40
- *
41
- * @csspart controls - The row of controls.
42
- * @csspart indicator - One of them, and a way to reach that slide.
43
- * @csspart indicators - The row of controls saying where the reader is.
44
- * @csspart next - The control that moves to the next slide.
45
- * @csspart next-mark - The glyph on the next control.
46
- * @csspart previous - The control that moves to the previous slide.
47
- * @csspart previous-mark - The glyph on the previous control.
48
- * @csspart rotation - The control that stops and starts rotation.
49
- * @csspart rotation-mark - The glyph on the play and pause control.
50
- * @csspart slide - The pane one slide is shown in, and the box the track snaps to.
51
- * @csspart track - The scroller the slides sit in.
52
- */
53
12
  let HelixHeadlessCarousel = class HelixHeadlessCarousel extends HelixElement {
54
13
  constructor() {
55
14
  super(...arguments);
56
- /**
57
- * Whether the carousel advances on its own.
58
- */
59
15
  this.autoplay = false;
60
- /**
61
- * Slide to start on, where nothing declared one.
62
- */
63
16
  this.defaultIndex = 0;
64
- /**
65
- * How long each slide is held, in milliseconds.
66
- */
67
17
  this.interval = 5000;
68
- /** The slide in view, whoever set it, and `undefined` until somebody has. */
69
18
  this.indexState = undefined;
70
- /**
71
- * Accessible name for the carousel.
72
- */
73
19
  this.label = 'Carousel';
74
- /**
75
- * Whether moving past either end wraps to the other.
76
- */
77
20
  this.loop = false;
78
- /**
79
- * Whether rotation is running.
80
- *
81
- * Absent until autoplay starts it.
82
- */
83
21
  this.rotating = false;
84
- /**
85
- * The arrows move between indicators, which is the tablist pattern.
86
- *
87
- * One tab stop for the set and the arrows within it, so a reader does not
88
- * have to press Tab once per slide to get past a carousel of twelve. Moving
89
- * changes the slide, because an indicator that could be focused without
90
- * selecting would leave the highlight and the slide disagreeing with nothing
91
- * on screen saying which is which.
92
- */
93
22
  this.handleIndicatorKeydown = (event) => {
94
23
  const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;
95
24
  if (step === 0) {
@@ -101,46 +30,10 @@ let HelixHeadlessCarousel = class HelixHeadlessCarousel extends HelixElement {
101
30
  this.renderRoot.querySelector('[part="indicator"][data-current]')?.focus();
102
31
  });
103
32
  };
104
- /**
105
- * Whether the scroll now settling is one this component asked for.
106
- *
107
- * Cleared by the first `scrollend` after it is set, and by a reader taking
108
- * hold of the track. The second half is not belt and braces: a `scrollTo`
109
- * whose target is already the scroll position fires no `scrollend` at all,
110
- * and the clamped end of a `per-view` track is exactly that case. Measured:
111
- * clicking past the last reachable slide moves `index` and produces no
112
- * `scrollend`, so a flag that only a `scrollend` could clear would stay set
113
- * and swallow the reader's next drag.
114
- */
115
33
  this.settling = false;
116
- /* A reader's own input on the track ends our claim on the next `scrollend`,
117
- whatever became of the scroll we issued. */
118
34
  this.handleTake = () => {
119
35
  this.settling = false;
120
36
  };
121
- /**
122
- * Follows the track while a reader is still dragging it.
123
- *
124
- * **`scrollend` is the only honest place to commit an index and much too late
125
- * to show one.** It fires once momentum has fully settled, which on iOS is
126
- * most of a second after the finger has left the glass, so the dots sit on
127
- * the old slide for the whole of the coast and then jump.
128
- *
129
- * So showing and committing are separated. This moves the marker and nothing
130
- * else: no state is written and no event is dispatched, because neither is
131
- * true yet. A drag in flight is not a reader having chosen, `handleScroll`
132
- * still decides that, and a consumer holding `index` still hears once.
133
- *
134
- * **The marker only, and not `aria-current` or the roving `tabindex`.** Those
135
- * describe where the reader *is*, which is a question with one answer per
136
- * settled position; moving them every frame of a drag would churn the
137
- * accessibility tree to report a place nobody has arrived at.
138
- *
139
- * Coalesced to a frame, a scroll firing far more often than a screen
140
- * refreshes, and skipped while we are settling a scroll of our own: a click
141
- * has already moved the index and the track is following it, which is the
142
- * precedence `handleScroll` documents at length.
143
- */
144
37
  this.handleTrackScroll = () => {
145
38
  if (this.settling || this.scrollFrame) {
146
39
  return;
@@ -153,42 +46,16 @@ let HelixHeadlessCarousel = class HelixHeadlessCarousel extends HelixElement {
153
46
  }
154
47
  });
155
48
  };
156
- /**
157
- * Reads where a reader's drag settled, and only a reader's.
158
- *
159
- * **The scroll we issued ourselves ends with a `scrollend` too, and taking it
160
- * as a reading is what makes the dots jump back.** Run on every one, the
161
- * index a reader just chose is overwritten by whichever slide happened to be
162
- * nearest the leading edge when the animation finished. Any disagreement
163
- * there is enough: a snap settling a pixel short of a midpoint, a target
164
- * clamped by the scroller's own maximum, a fractional layout, or a smooth
165
- * scroll cut off by the next click. The component asked for slide four, the
166
- * pixels said three, and the pixels won.
167
- *
168
- * Which is the wrong precedence in one direction only. A drag is the reader
169
- * moving the track and the index has to follow it; a click is the reader
170
- * moving the index and the track has to follow *that*. One `scrollend` cannot
171
- * tell those apart, so the flag does.
172
- */
173
49
  this.handleScroll = () => {
174
50
  if (this.settling) {
175
51
  this.settling = false;
176
52
  return;
177
53
  }
178
- /* The marker goes back to following the committed index, whatever this
179
- reading turns out to be: the drag is over, so there is no longer a
180
- position of its own to show. */
181
54
  this.showing = undefined;
182
55
  const nearest = this.nearest();
183
56
  if (nearest === undefined) {
184
57
  return;
185
58
  }
186
- /* Announced after the fact, and deliberately, where `goTo` announces
187
- before. The reader has already dragged the track here: there is nothing
188
- to refuse, and `preventDefault()` could not un-drag it. This is the case
189
- the controlled-change helper's own documentation distinguishes, a native
190
- control that has already moved, and putting it on that path would offer a
191
- refusal that cannot be honored. */
192
59
  if (nearest === this.index) {
193
60
  return;
194
61
  }
@@ -196,67 +63,19 @@ let HelixHeadlessCarousel = class HelixHeadlessCarousel extends HelixElement {
196
63
  this.requestUpdate('index');
197
64
  this.dispatchEvent(new HelixSelectEvent({ value: String(nearest) }));
198
65
  };
199
- /** The pending frame for the above, so a burst of scrolls costs one reading. */
200
66
  this.scrollFrame = 0;
201
- /**
202
- * Whether the reader has stopped it, as opposed to something pausing it.
203
- *
204
- * The two were one flag and the difference matters: `focusin` bubbles, so
205
- * every focus move inside the carousel re-ran `stop()`, and a reader who
206
- * pressed Start had it undone by their very next Tab. Pausing on hover and on
207
- * focus is right; forgetting that the reader asked it to stop is not, and the
208
- * APG is explicit that rotation resumes only when the reader asks.
209
- */
210
67
  this.stopped = false;
211
- /**
212
- * Watches the light DOM for slides arriving and leaving.
213
- *
214
- * **A manually assigned slot does not announce them.** `slotchange` fires when
215
- * an assignment changes, and under `slotAssignment: 'manual'` the only thing
216
- * that changes an assignment is this component calling `assign`, so a slide
217
- * appended by a consumer would reach no slot, render nowhere and be counted by
218
- * nothing. The default slot used to carry that duty and could, because it took
219
- * whatever was there. `Chat` watches for the same reason.
220
- */
221
68
  this.watcher = undefined;
222
- /** Pauses without forgetting that the reader wanted it running. */
223
69
  this.pause = () => {
224
70
  if (!this.stopped) {
225
71
  this.rotating = false;
226
72
  }
227
73
  };
228
74
  }
229
- /**
230
- * Manual, so each slide can be wrapped without its markup being touched.
231
- *
232
- * A slide has to be a `group` carrying `aria-roledescription="slide"` and a
233
- * position in the run, and those are the component's facts rather than the
234
- * consumer's element's. Writing them on is what a React consumer's hydration
235
- * reports and does not patch up, and on a slide that is itself a link it also
236
- * takes the link's role away. Assigning into a pane the component rendered
237
- * says the same thing to a screen reader and leaves the consumer's element
238
- * exactly as it was served. `Chat` and `DataTable` wrap their children the
239
- * same way and for the first of those two reasons.
240
- */
241
75
  static { this.shadowRootOptions = {
242
76
  ...HelixElement.shadowRootOptions,
243
77
  slotAssignment: 'manual',
244
78
  }; }
245
- /**
246
- * The slide in view, counted from zero.
247
- *
248
- * Reading gives the real slide, in either mode. Writing sets it, the way a
249
- * script assigning to a native control does. Which mode the carousel is in is
250
- * read once, at the first update, from whether anything had declared an
251
- * `index` by then: declared, and the consumer owns it and the carousel only
252
- * announces where it would have gone; not declared, and the carousel owns it
253
- * and `default-index` says where it starts.
254
- *
255
- * The same contract the four mixins carry, written out here rather than
256
- * extracted, because a slide is addressed by position and every one of those
257
- * mixins is keyed by a name. One consumer is not a mixin: if a second numeric
258
- * state turns up, this is what it is extracted from.
259
- */
260
79
  get index() {
261
80
  return this.indexState ?? this.defaultIndex;
262
81
  }
@@ -265,50 +84,20 @@ let HelixHeadlessCarousel = class HelixHeadlessCarousel extends HelixElement {
265
84
  this.indexState = next;
266
85
  this.requestUpdate('index', previous);
267
86
  }
268
- /**
269
- * How many distinct views the track holds, which is what the indicators count.
270
- *
271
- * A window of `perView` slides sliding one at a time over `n` of them stops
272
- * `n - perView + 1` times, so six slides four across is three: 0 to 3, 1 to 4,
273
- * 2 to 5. One at minimum, since a carousel showing everything it has still
274
- * has the one place to be.
275
- */
276
87
  get positions() {
277
88
  const across = Math.max(1, Math.floor(this.perView ?? 1));
278
89
  return Math.max(1, this.slides.length - across + 1);
279
90
  }
280
- /**
281
- * The panes rendered for the slides, in order. Empty until the first render.
282
- *
283
- * One per slide, and the box the track actually lays out: the slide itself is
284
- * assigned into it and takes its size from it, so every offset the scroller
285
- * is asked about is a pane's. Read fresh rather than cached, because a slide
286
- * arriving or leaving re-renders the run.
287
- */
288
91
  get panes() {
289
92
  return [...(this.renderRoot?.querySelectorAll?.('[part="slide"]') ?? [])];
290
93
  }
291
- /** The slides, in light-DOM order. Empty on the server, which has no tree. */
292
94
  get slides() {
293
- /* `?? []` is the server, and a note saying "client only" is not a guard:
294
- `render` reads this to count the indicators, so an unhandled spread of
295
- `undefined` kills every carousel SSR pass.
296
-
297
- Empty is also the honest answer rather than damage control. The
298
- indicators are one per position and a position is counted from the
299
- children, which the server cannot see, so they arrive with the upgrade.
300
- Nothing moves when they do: the tags are hidden until they are
301
- defined. */
302
95
  return [...(this.children ?? [])].filter((child) => child instanceof HTMLElement);
303
96
  }
304
- /** Moves to a slide, wrapping only where `loop` says to. */
305
97
  goTo(index) {
306
98
  if (this.slides.length === 0) {
307
99
  return;
308
100
  }
309
- /* Positions, not slides. The last place a four-across track of six can stop
310
- is the third, and clamping to the sixth gave three indices that all
311
- scrolled to the same clamped offset. */
312
101
  const count = this.positions;
313
102
  const next = this.loop ? (index + count) % count : Math.min(Math.max(index, 0), count - 1);
314
103
  if (next === this.index) {
@@ -325,58 +114,17 @@ let HelixHeadlessCarousel = class HelixHeadlessCarousel extends HelixElement {
325
114
  next,
326
115
  });
327
116
  }
328
- /**
329
- * The leading padding the track keeps for a slide to draw its own edge in.
330
- *
331
- * A styled track carries one so a slide's shadow and focus ring have
332
- * somewhere to go: a scroll container clips at its padding box, and a slide
333
- * flush against that edge loses whatever it paints outside itself. The
334
- * headless tier has no stylesheet and so no padding, and this reads zero
335
- * there, which is why both tiers can share the arithmetic below.
336
- *
337
- * Physical sides rather than logical, to match the physical `offsetLeft` and
338
- * `offsetTop` it is subtracted from. The bleed is one length on all four
339
- * sides, so the two agree whatever the writing mode; if that stops being
340
- * true, this is the method that has to know.
341
- */
342
117
  bleed(track) {
343
118
  const style = getComputedStyle(track);
344
119
  const read = Number.parseFloat(this.orientation === 'vertical' ? style.paddingTop : style.paddingLeft);
345
- /* Zero where there is no number to read, which is a harness rather than a
346
- browser. happy-dom resolves nothing out of an adopted stylesheet and
347
- returns `''` for every side of this, so `parseFloat` gives `NaN`, and a
348
- `NaN` distance loses every comparison it is in: the nearest-slide search
349
- below picked nothing and a reader's drag stopped moving the index at all.
350
- Three specs caught it. A browser always answers with a used length, so
351
- this arm is only ever the harness, and zero is the right answer there,
352
- nothing being laid out or clipped in the first place. */
353
120
  return Number.isNaN(read) ? 0 : read;
354
121
  }
355
- /**
356
- * Where a slide's leading edge sits, in the scroller's own coordinates.
357
- *
358
- * **Both directions go through here, because they have to agree.** The
359
- * offsets are measured to the track's *border* box and `scrollTop` and
360
- * `scrollLeft` are measured from its content box, so the two differ by the
361
- * bleed: sending the carousel to a slide and reading back which slide it is
362
- * on used the same expression, and correcting one of them alone put a drag
363
- * one position off what the arrows did.
364
- *
365
- * Unsubtracted, the leading slide's own offset *is* the bleed, so scrolling
366
- * to index 0 put its edge back on the clip edge. Measured on the insights
367
- * carousel: `scrollLeft` resting at 4 against a 4px bleed, the first card
368
- * shaved, and no scrolling done to cause it.
369
- */
370
122
  edgeOf(slide, track, bleed) {
371
123
  return this.orientation === 'vertical'
372
124
  ? slide.offsetTop - track.offsetTop - bleed
373
125
  : slide.offsetLeft - track.offsetLeft - bleed;
374
126
  }
375
127
  scrollToIndex() {
376
- /* The pane, not the slide inside it. The pane is the flex item the track
377
- lays out and the box carrying `scroll-snap-align`, so it is what the
378
- scroller's offsets are about; the slide is its content and, before the
379
- assignment lands, is not in the track's layout at all. */
380
128
  const slide = this.panes[this.index];
381
129
  const track = this.renderRoot.querySelector('[part="track"]');
382
130
  if (!slide || !track) {
@@ -384,39 +132,16 @@ let HelixHeadlessCarousel = class HelixHeadlessCarousel extends HelixElement {
384
132
  }
385
133
  const vertical = this.orientation === 'vertical';
386
134
  const target = this.edgeOf(slide, track, this.bleed(track));
387
- /* Already there, and saying so rather than asking anyway.
388
- *
389
- * This is every index change that came *from* the track: a drag settles,
390
- * the index follows it, and this would then send the scroller where it
391
- * already is. No scroll runs, so no `scrollend` arrives, and a claim staked
392
- * here would be cleared by the reader's *next* drag rather than by its own
393
- * scroll, swallowing it. A pixel of slack, because a snapped offset is
394
- * fractional and an exact comparison never holds. */
395
135
  if (Math.abs(target - (vertical ? track.scrollTop : track.scrollLeft)) < 1) {
396
136
  return;
397
137
  }
398
- /* The preference is read here, because this is where the motion is.
399
- *
400
- * `scroll-behavior: auto` in a reduce block does nothing: it restates the
401
- * initial value, and the scroll that actually animates is this call, which
402
- * names `smooth` outright and overrides the property either way. */
403
138
  const still = globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
404
- /* The scroll about to run is ours, so the `scrollend` it produces is not a
405
- reader saying where they went. See `handleScroll`. */
406
139
  this.settling = true;
407
140
  track.scrollTo({
408
141
  behavior: still ? 'instant' : 'smooth',
409
142
  ...(vertical ? { top: target } : { left: target }),
410
143
  });
411
144
  }
412
- /**
413
- * Stops rotation for good.
414
- *
415
- * Hovering away or moving focus out does not start it again. The APG is
416
- * explicit that rotation resumes only when the reader asks for it, because a
417
- * carousel that starts moving again on its own is the thing the control
418
- * exists to escape.
419
- */
420
145
  stop() {
421
146
  this.stopped = true;
422
147
  this.rotating = false;
@@ -425,15 +150,6 @@ let HelixHeadlessCarousel = class HelixHeadlessCarousel extends HelixElement {
425
150
  this.stopped = false;
426
151
  this.rotating = true;
427
152
  }
428
- /**
429
- * Owns the timer, from the state rather than from the call site.
430
- *
431
- * Never hold the interval in `start()` and `stop()` and start one from
432
- * `connectedCallback`: a DOM move re-runs connect and restarts rotation the
433
- * reader stopped, and the server, which never runs connect at all, renders
434
- * the control saying the opposite of what it will do. The state is what is
435
- * declared and the timer follows it, which also means one place clears it.
436
- */
437
153
  runTimer() {
438
154
  clearInterval(this.timer);
439
155
  this.timer = undefined;
@@ -442,19 +158,6 @@ let HelixHeadlessCarousel = class HelixHeadlessCarousel extends HelixElement {
442
158
  }
443
159
  this.timer = setInterval(() => this.goTo(this.index + 1), this.interval);
444
160
  }
445
- /**
446
- * The slide the track is nearest to, or undefined when there is no track.
447
- *
448
- * Shared by the two readers of the scroll position, which want the same
449
- * arithmetic and disagree about what to do with it: one commits where a drag
450
- * settled, the other follows it while it is still moving.
451
- *
452
- * Only the leading edges a window can actually rest on. Past the last
453
- * position every remaining slide is on screen already, and taking the nearest
454
- * of *those* to the scroller's edge reported a place the reader could not get
455
- * back to: dragged fully right, a four-across six reported the sixth slide
456
- * and the dots jumped to an indicator that no longer existed.
457
- */
458
161
  nearest() {
459
162
  const track = this.renderRoot.querySelector('[part="track"]');
460
163
  if (!track) {
@@ -489,76 +192,29 @@ let HelixHeadlessCarousel = class HelixHeadlessCarousel extends HelixElement {
489
192
  }
490
193
  willUpdate(changed) {
491
194
  super.willUpdate(changed);
492
- /* Seeded from the markup rather than started on connect, so the server and
493
- the client agree about whether it is running. A reader's stop survives a
494
- reconnection because `stopped` outlives it. */
495
195
  if (!this.hasUpdated) {
496
196
  this.rotating = this.autoplay;
497
197
  }
498
198
  hostAria(this.internals, { label: this.label, role: 'group' });
499
- // Announced as a carousel rather than as a bare group, so a reader knows
500
- // what the controls beside it are for.
501
199
  this.internals.ariaRoleDescription = 'carousel';
502
200
  }
503
201
  updated(changed) {
504
202
  super.updated(changed);
505
- /* The track follows `index`, whoever moved it.
506
- *
507
- * Not from inside `goTo`'s apply callback: that returns early in controlled
508
- * mode, so the dots update and the slides sit still, and it is not the only
509
- * path in either, a plain `carousel.index = 2` reaching no apply at all. */
510
203
  if (changed.has('index')) {
511
204
  this.scrollToIndex();
512
205
  }
513
206
  this.runTimer();
514
- /* Back inside the set when the set shrinks.
515
- *
516
- * The indicators carry a roving tab stop keyed on `index`, so a carousel
517
- * whose slides are removed under it is left with no tab stop, no current
518
- * dot and a focus restore pointing at nothing. In `updated` rather than
519
- * `willUpdate` because `slides` is empty on the server, and through `goTo`
520
- * so a controlled consumer is told rather than overwritten. */
521
207
  const count = this.slides.length;
522
208
  if (count > 0 && this.index > count - 1) {
523
209
  this.goTo(count - 1);
524
210
  }
525
211
  this.assign();
526
212
  }
527
- /**
528
- * Puts each slide into the pane rendered for it.
529
- *
530
- * **The panes carry the ARIA and the consumer's element carries none**, which
531
- * is the whole reason this is a manual assignment rather than a plain slot.
532
- * The APG wants each slide to be a `group` with `aria-roledescription="slide"`
533
- * and a name saying where it sits in the run, and writing those onto the
534
- * elements a consumer wrote fails twice, only one of them about frameworks.
535
- *
536
- * **A React consumer's slides are nodes React is in the middle of hydrating.**
537
- * Attributes landing before React reaches them are a DOM it did not render,
538
- * which it reports as a mismatch and will not patch up: the ARIA survives and
539
- * the announcement is correct, so the console is the only tell. Deferring the
540
- * write does not help, a frame being a race rather than an ordering: measured
541
- * in Chromium on the landing page, a tab opened in the foreground still
542
- * reported the mismatch and a tab opened hidden did not, only because
543
- * `requestAnimationFrame` had not run there yet. `Chat`'s comment on
544
- * `shadowRootOptions` reaches the same conclusion.
545
- *
546
- * **And `role="group"` on a consumer's `<a href>` is not additive.** It
547
- * replaces the implicit link role, so six insight cards on the landing page
548
- * announced as groups and left the links list entirely. No amount of
549
- * deferring helps, because the write is the bug. A pane around the slide is
550
- * what the pattern actually asks for: a group holding a link, rather than a
551
- * link relabeled as a group.
552
- */
553
213
  assign() {
554
214
  const panes = this.renderRoot?.querySelectorAll?.('[part="slide"] slot');
555
215
  if (!panes) {
556
216
  return;
557
217
  }
558
- /* Paired by position, and short-circuited by whichever ran out: a render
559
- and a child list can disagree for one update when children arrive after
560
- the template was built, and the mutation observer schedules the update
561
- that reconciles them. */
562
218
  this.slides.forEach((slide, index) => {
563
219
  panes[index]?.assign?.(slide);
564
220
  });
@@ -582,12 +238,7 @@ let HelixHeadlessCarousel = class HelixHeadlessCarousel extends HelixElement {
582
238
  </svg>
583
239
  </button>`
584
240
  : nothing}
585
- ${
586
- /* `none` takes them out of the tree rather than hiding them, so they
587
- leave the tab order with them. `auto` renders them and lets the
588
- stylesheet answer for the pointer, a question CSS can ask directly
589
- and this cannot without watching a media query from script. */
590
- this.controls === 'none'
241
+ ${this.controls === 'none'
591
242
  ? nothing
592
243
  : html `<button
593
244
  part="previous"
@@ -630,18 +281,7 @@ let HelixHeadlessCarousel = class HelixHeadlessCarousel extends HelixElement {
630
281
  </div>
631
282
  ${this.slides.length > 1
632
283
  ? html `<div part="indicators" role="group" aria-label=${`${this.label} slides`}>
633
- ${
634
- /* Plain buttons, not a half-built tablist.
635
- *
636
- * `role="tab"` in a `role="tablist"` promises tabpanels, and
637
- * the slides are `role="group"`: the APG has two carousel
638
- * patterns and taking the announcement of one over the
639
- * structure of the other tells a reader to expect panels that
640
- * do not exist. This is the basic pattern, buttons that move
641
- * the carousel with the current one marked, and `aria-current`
642
- * is the right word for "the one you are on" where nothing is
643
- * being selected. */
644
- ''}
284
+ ${''}
645
285
  ${Array.from({ length: this.positions }).map((_place, at) => html `<button
646
286
  part="indicator"
647
287
  type="button"