@fusion.dev/helix-headless 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 (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
@@ -7,156 +7,24 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
7
7
  import { customElement, HelixElement, HelixSelectEvent } from '@fusion.dev/helix-core';
8
8
  import { html, nothing } from 'lit';
9
9
  import { property } from 'lit/decorators.js';
10
- /**
11
- * @summary One choice in a listbox.
12
- *
13
- * @description
14
- * **Its own family rather than a part of Select, and the reason is what it
15
- * belongs to.** `role="option"` requires an owning `role="listbox"`, which is
16
- * the ARIA contract: an option cannot stand alone. But a Select is *a listbox
17
- * with a trigger*, a Combobox is *a listbox with a text input*, and a command
18
- * palette is the same listbox again. The thing an option belongs to is the
19
- * listbox, not any one control that happens to contain one.
20
- *
21
- * That is the difference from `Radio`, which is a part of its group: a radio
22
- * has exactly one kind of owner, and an option has several.
23
- *
24
- * Selection is announced rather than applied, the way a radio's is. The option
25
- * says it was chosen, the listbox decides whether that stands, and the listbox
26
- * writes `selected` back. An option that selected itself would be a second
27
- * answer to which one is chosen.
28
- *
29
- * **Nothing here knows about filtering.** A hidden option is hidden by whatever
30
- * owns it, through `hidden`, because what counts as a match is the listbox's
31
- * question and differs between a select and a command palette.
32
- *
33
- * @since 0.1.0
34
- * @status experimental
35
- * @category forms
36
- * @aliases Item, Choice
37
- * @documentation https://helix.fusion.dev/components/option
38
- *
39
- * @slot - The words naming this choice.
40
- * @slot end - After them, for a shortcut or a count.
41
- * @slot start - Before the words, for an icon or an avatar.
42
- *
43
- * @event {HelixSelectEvent} helix-select - Fired when this choice is made. Cancelable: refusing it takes the decision and nothing moves.
44
- *
45
- * @csspart description - The caption under the words, when there is one.
46
- * @csspart item - The row.
47
- * @csspart label - The words.
48
- * @csspart text - The words and the caption under them, as one column.
49
- */
50
10
  let HelixHeadlessOption = class HelixHeadlessOption extends HelixElement {
51
11
  constructor() {
52
12
  super(...arguments);
53
- /**
54
- * Whether this is the option the keyboard is on.
55
- *
56
- * Written by the listbox.
57
- */
58
13
  this.active = false;
59
- /**
60
- * A second destination for this row, taken with the modifier held.
61
- *
62
- * **An attribute rather than a second row, which is the whole point.** A
63
- * palette of eighty-eight components that offered each of them a reference
64
- * page and a sandbox as separate entries would be a palette of a hundred and
65
- * seventy-six, and one earns its keep by getting a reader to one thing in
66
- * three keystrokes. The row already carries the first destination, so the
67
- * second belongs beside it.
68
- *
69
- * Declared here and acted on by whatever owns the list, the same division
70
- * selection already uses: an option says what it is and the listbox decides
71
- * what happens. `Command` is the only owner that reads it today, because a
72
- * select has one destination by definition.
73
- *
74
- * Absent is a row with one destination, which is nearly every row.
75
- */
76
14
  this.alternate = '';
77
- /**
78
- * What the second destination is, for a reader who cannot see the footer.
79
- *
80
- * A row with two destinations needs both said, or a screen reader is told
81
- * about one of them and the other is a key nobody mentioned. It goes to
82
- * `aria-description` rather than into the name: the name is the row's own
83
- * words and stays what a reader searched for, and the second destination is a
84
- * fact about what pressing a key would do, which is what a description is.
85
- */
86
15
  this.alternateLabel = '';
87
- /** What the markup said, read once at construction for the reason above. */
88
16
  this.seeded = this.hasAttribute('selected');
89
- /**
90
- * Whether this choice can be made.
91
- */
92
17
  this.disabled = false;
93
- /**
94
- * A shorter name for this choice, for where the row does not fit.
95
- *
96
- * `<option label>`'s own meaning: the text a chooser shows when it has less
97
- * room than the row does, which is what a select's closed trigger has. Empty
98
- * means the row's own content, which is the common case and is why this is not
99
- * simply the accessible name.
100
- */
101
18
  this.label = '';
102
- /**
103
- * Whether this choice is currently chosen.
104
- *
105
- * Written by the listbox.
106
- */
107
19
  this.selected = false;
108
- /**
109
- * Identifies this choice, and is what a form submits.
110
- */
111
20
  this.value = '';
112
21
  }
113
- /**
114
- * Whether the markup declared this one chosen, which is what a reset returns to.
115
- *
116
- * `<option selected>` reflects to `defaultSelected` and `option.selected` is
117
- * live state that does not reflect, and those are two different facts. This
118
- * element has one `selected` doing both jobs, so what is captured here is what
119
- * the markup said at upgrade, before anything could overwrite it: read the
120
- * attribute later and the listbox's own writes answer instead.
121
- *
122
- * **The deeper half is 302 and is not this.** State reflecting under the
123
- * declaration's name is the fault `TreeItem` already avoids by mirroring to
124
- * `data-selected`, and four families here still write `selected` back into a
125
- * consumer's markup. Splitting them means moving what every stylesheet selects
126
- * on, which is a change of its own rather than a property being added.
127
- */
128
22
  get defaultSelected() {
129
23
  return this.seeded;
130
24
  }
131
25
  set defaultSelected(next) {
132
26
  this.seeded = next;
133
27
  }
134
- /**
135
- * The words, for a listbox that has to match text against them.
136
- *
137
- * Read from the rendered content rather than required as a property, because
138
- * the content is where a consumer naturally puts it and asking for both is
139
- * asking for two things to disagree.
140
- *
141
- * **The default slot's content, never `this.textContent`.** An option holds
142
- * three slots: the words, plus `start` for an icon and `end` for a shortcut
143
- * or a count. Neither of the latter two is what a reader searched for, and
144
- * `textContent` runs both onto the end of it. Measured on a font picker whose
145
- * options carry a two-letter specimen in `end`: the field read
146
- * `Noto Sans Linear BAg`, and typing `ag` matched 1,892 of 1,893 options,
147
- * every row containing the sample. An option with a keyboard shortcut in its
148
- * end slot has the same fault and a worse one, every row carrying one matching
149
- * every letter of it.
150
- *
151
- * Six things read this: the collapsed field of Select and of Combobox, the
152
- * listbox's filter, Combobox's match against typed text, its inline
153
- * completion, and the label on a chosen tag.
154
- *
155
- * A node goes to the default slot when it names no other, which is the rule
156
- * the platform applies at assignment. Asking the same question here rather
157
- * than a second one is what stops the two disagreeing. Text nodes are
158
- * always the default slot's and cannot say otherwise.
159
- */
160
28
  get text() {
161
29
  return [...this.childNodes]
162
30
  .filter((node) => !(node instanceof Element) || node.slot === '')
@@ -164,47 +32,22 @@ let HelixHeadlessOption = class HelixHeadlessOption extends HelixElement {
164
32
  .join('')
165
33
  .trim();
166
34
  }
167
- /** Makes this choice and announces it. */
168
35
  select() {
169
36
  if (this.disabled) {
170
37
  return;
171
38
  }
172
39
  this.dispatchEvent(new HelixSelectEvent({ value: this.value }));
173
40
  }
174
- /**
175
- * Mirrors the chosen state where a stylesheet can select on it.
176
- *
177
- * **A custom state, and never the `selected` attribute.** The parent writes
178
- * this: a listbox choosing a row, a tab strip moving the tab, a radio group
179
- * answering for its set. Reflected, that puts the element's own bookkeeping
180
- * onto markup a consumer wrote and a consumer's server never sent, which React
181
- * reconciles and reports as a mismatch, and which a serialized element pasted
182
- * back declares as a choice nobody made. `ElementInternals` holds it instead,
183
- * where it is not in the DOM at all.
184
- *
185
- * Reading `selected` is unaffected and so is writing it: the attribute still
186
- * seeds the property, which is what `<helix-option selected>` in a consumer's
187
- * markup means. What stops is the write back.
188
- */
189
41
  willUpdate(changed) {
190
42
  super.willUpdate(changed);
191
43
  this.toggleState('selected', this.selected);
192
44
  }
193
45
  render() {
194
- /* `role` and `aria-selected` on the host through internals, because an
195
- option's semantics have to be visible to the listbox that owns it and a
196
- wrapper in here would put a box between them. */
197
46
  this.internals.role = 'option';
198
47
  this.internals.ariaSelected = String(this.selected);
199
48
  this.internals.ariaDisabled = this.disabled ? 'true' : null;
200
49
  this.internals.ariaDescription =
201
50
  this.alternate !== '' && this.alternateLabel !== '' ? this.alternateLabel : null;
202
- /* A pointer reaches `select()` the way every other choosable row in this
203
- library does, from a `@click` in its own template: `Tabs.Tab`,
204
- `Tree.Item` and `Menu.Item` all bind one. Nothing else listens for a click
205
- on an option, so removing this leaves a select the keyboard can use and a
206
- pointer cannot. A spec that chooses by calling `select()` or by pressing a
207
- key cannot tell the difference. */
208
51
  return html `<div part="item" @click=${() => this.select()}>
209
52
  <slot name="start"></slot>
210
53
  <span part="text">
@@ -7,129 +7,19 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
7
7
  import { customElement, HelixBeforeActivateEvent, HelixElement, IndicatorController, } from '@fusion.dev/helix-core';
8
8
  import { html } from 'lit';
9
9
  import { property, state } from 'lit/decorators.js';
10
- /**
11
- * @summary Links to the headings on this page, marking the one being read.
12
- *
13
- * @description
14
- * A table of contents, named for the job. It sits beside `Navigation`, which is
15
- * the same job at the other scale: one navigates a site, the other navigates a
16
- * document. `Contents` and `TableOfContents` are both taken by something else,
17
- * `display: contents` and the `content` part in one case and the `Table` family
18
- * in the other.
19
- *
20
- * **The links are the consumer's own anchors**, the same as `Navigation`. A router
21
- * renders its own `<a>` and cannot render one inside this shadow root, and the
22
- * hrefs are fragments the page already knows.
23
- *
24
- * **What is current is decided by position, not by intersection.** The obvious
25
- * build is an `IntersectionObserver` with a thin band near the top of the
26
- * window, and it fails in both directions: with headings far apart nothing is
27
- * in the band and the marker goes out entirely, and with headings close
28
- * together several are and it flickers between them. So the observer is only
29
- * the *trigger*, and the answer is computed from where the headings actually
30
- * are: the current section is the last one whose top has passed the reading
31
- * line, or the first heading before any of them has.
32
- *
33
- * That rule is what a reader means. Two headings on screen at once are not
34
- * ambiguous to a person: they are reading under the upper one.
35
- *
36
- * **`aria-current="location"`, and it is written onto the consumer's anchor.**
37
- * This library does not normally write to light-DOM children, because an
38
- * attribute added after upgrade is one a server's markup never had. It is right
39
- * here for the reason `Carousel` names its slides: what is current is derived
40
- * from scroll position, so no server could have rendered it and there is
41
- * nothing for hydration to disagree with. `location` rather than `page`, which
42
- * is what `Navigation` uses: `page` means this is the page, and every one of these
43
- * is the same page.
44
- *
45
- * @since 0.1.0
46
- * @status experimental
47
- * @category navigation
48
- * @aliases Table of contents, On this page, Outline
49
- * @documentation https://helix.fusion.dev/components/outline
50
- *
51
- * @slot - The links, as anchors with fragment hrefs.
52
- *
53
- * @event {HelixBeforeActivateEvent} helix-before-activate - The section being read has changed, carrying its id as the value. Cancelable: refusing it takes the decision and nothing moves.
54
- *
55
- * @csspart indicator - The mark that travels to the section being read.
56
- * @csspart nav - The navigation landmark.
57
- */
58
10
  let HelixHeadlessOutline = class HelixHeadlessOutline extends HelixElement {
59
11
  constructor() {
60
12
  super(...arguments);
61
- /* The mark. Vertical, which the controller needs no telling about: it writes
62
- all four values and the stylesheet uses the two it cares about. */
63
13
  this.indicator = new IndicatorController(this, {
64
14
  active: () => this.links.find((link) => link.hasAttribute('aria-current')),
65
15
  indicator: () => this.renderRoot?.querySelector('[part="indicator"]'),
66
16
  surface: () => this.renderRoot?.querySelector('[part="nav"]'),
67
17
  });
68
- /**
69
- * How a click on one of these links travels to its section.
70
- *
71
- * The platform's own vocabulary, because this is the platform's own option:
72
- * `auto` defers to whatever `scroll-behavior` the page has set on its scroll
73
- * container, which is the honest default and means an outline changes nothing
74
- * a consumer did not ask it to. `smooth` slides, `instant` jumps.
75
- *
76
- * **`auto` is also the only value that leaves the navigation to the browser.**
77
- * A fragment link does four things: it scrolls, it writes the hash, it pushes
78
- * a history entry, and it moves the sequential focus point so the next Tab
79
- * continues from the heading rather than from the link. Choosing a behavior
80
- * means intercepting the click, and an interception that does only the first
81
- * of those is a keyboard reader stranded in the contents list. So the other
82
- * three are done by hand here, and only when there is a reason to take over.
83
- *
84
- * `smooth` becomes `instant` under `prefers-reduced-motion: reduce`. That is
85
- * read at the click rather than cached, because a reader can change it while
86
- * the page is open and this is the only motion in the component that
87
- * JavaScript rather than CSS decides.
88
- */
89
18
  this.behavior = 'auto';
90
- /**
91
- * Accessible name for the navigation.
92
- */
93
19
  this.label = 'On this page';
94
- /**
95
- * How far below the top of the window a heading counts as reached, in pixels.
96
- *
97
- * The reading line. A page with a sticky masthead sets this to its height, or
98
- * a little more: without it the current section changes as a heading slides
99
- * under the chrome rather than as it reaches the top of what can be seen.
100
- */
101
20
  this.offset = 0;
102
- /**
103
- * What `current` reports, which only `mark` writes.
104
- */
105
21
  this.reading = '';
106
- /** The frame a mark is already scheduled for, so a scroll asks for one. */
107
22
  this.frame = 0;
108
- /**
109
- * The two things an `IntersectionObserver` cannot see, and one listener for
110
- * both.
111
- *
112
- * *At the bottom*, arriving at the end of a page changes no intersection: a
113
- * short last section is fully visible long before the final pixel, so its
114
- * ratio reached 1 and stopped moving. Nothing fires for the scroll that ends
115
- * the page.
116
- *
117
- * *And at the end of a smooth scroll*, which is the one that showed up in
118
- * use. Clicking a link in the contents list scrolled to the section and left
119
- * the mark where it was. Measured on the shipped example: after clicking
120
- * `#usage` the heading sat at 72px, exactly the reading line, and `current`
121
- * was still `install`. The observer fires several times during the travel and
122
- * the last of them lands a frame or two before the final position, where the
123
- * heading has not crossed yet, and no callback follows for the rest of the
124
- * way.
125
- *
126
- * Marking once per animation frame while a scroll is happening covers both,
127
- * where a listener that marks only when its own comparison changes fixes the
128
- * bottom and cannot fix the settle. The cost is what `mark()` costs, a
129
- * `getBoundingClientRect` per section on frames a reader is already scrolling
130
- * through. Passive, and coalesced to one call per frame however many events
131
- * arrive.
132
- */
133
23
  this.handleScroll = () => {
134
24
  if (this.frame !== 0) {
135
25
  return;
@@ -139,13 +29,6 @@ let HelixHeadlessOutline = class HelixHeadlessOutline extends HelixElement {
139
29
  this.mark();
140
30
  });
141
31
  };
142
- /**
143
- * Takes a link's click, but only where `auto` would not have done the job.
144
- *
145
- * Left the platform's wherever it can be: a modified click is a new tab or a
146
- * download and none of this applies, a click something else has already
147
- * handled is not ours, and `auto` is the whole point of `auto`.
148
- */
149
32
  this.handleClick = (event) => {
150
33
  if (event.defaultPrevented || event.button !== 0) {
151
34
  return;
@@ -158,23 +41,6 @@ let HelixHeadlessOutline = class HelixHeadlessOutline extends HelixElement {
158
41
  if (!section) {
159
42
  return;
160
43
  }
161
- /* The destination is marked now, and everything between here and there is
162
- * not.
163
- *
164
- * The marker follows the scroll, which is right when a reader is scrolling
165
- * and wrong when they have said where they are going: traveling to the
166
- * fifth section passes the second, third and fourth, each became current
167
- * for a frame or two on the way, and the mark stepped down the list one
168
- * item at a time instead of making the journey.
169
- *
170
- * Suppressed for any click on one of these links, not only the ones this
171
- * component scrolls itself: a consumer with `scroll-behavior: smooth` on
172
- * their own scroll container gets the same travel and the same stepping,
173
- * and `behavior="auto"` does not mean "nobody is animating".
174
- *
175
- * `landing` is cleared when the page stops moving, by the same `scrollend`
176
- * and timeout pair the mark uses elsewhere, because a scroll that is
177
- * interrupted by a reader grabbing the page has to hand the marker back. */
178
44
  this.landing = section;
179
45
  this.markTo(section);
180
46
  clearTimeout(this.landed);
@@ -195,11 +61,6 @@ let HelixHeadlessOutline = class HelixHeadlessOutline extends HelixElement {
195
61
  behavior: reduced && this.behavior === 'smooth' ? 'instant' : this.behavior,
196
62
  block: 'start',
197
63
  });
198
- /* The three things the anchor would have done and the scroll does not. The
199
- history entry keeps Back working and the address bar copyable; the focus
200
- move is what stops Tab continuing from the contents list. `preventScroll`
201
- because the scroll above is already under way and focusing would fight it,
202
- instantly, wherever `smooth` was asked for. */
203
64
  globalThis.history?.pushState(null, '', link?.getAttribute('href') ?? '');
204
65
  const target = section.target;
205
66
  if (!target.hasAttribute('tabindex')) {
@@ -207,70 +68,28 @@ let HelixHeadlessOutline = class HelixHeadlessOutline extends HelixElement {
207
68
  }
208
69
  target.focus({ preventScroll: true });
209
70
  };
210
- /**
211
- * The set changed: watch it, and measure it.
212
- *
213
- * Both, for the reason the tab strip records: the host renders before its
214
- * links have anything to point at, so the first measurement finds nothing,
215
- * and without a second one at `slotchange` the mark stays unplaced for the
216
- * life of the page.
217
- */
218
71
  this.handleSlotChange = () => {
219
72
  this.watch();
220
73
  this.indicator.place();
221
74
  };
222
75
  }
223
- /**
224
- * The id of the section being read, or empty before anything is measured.
225
- *
226
- * Read-only, and a getter rather than a property for that reason: it is
227
- * derived from where the page is scrolled to, so a consumer setting it would
228
- * be overwritten by the next thing that crossed the reading line. Every other
229
- * writable property in this library that a component also writes is state a
230
- * consumer can own; this one cannot be, and saying so with the type is better
231
- * than saying it in a sentence nobody reads.
232
- */
233
76
  get current() {
234
77
  return this.reading;
235
78
  }
236
- /** The links, in document order. Empty on the server, which has no tree. */
237
79
  get links() {
238
80
  const slot = this.renderRoot?.querySelector('slot');
239
81
  return (slot?.assignedElements({ flatten: true }) ?? []).filter((element) => element instanceof HTMLAnchorElement &&
240
82
  element.getAttribute('href')?.startsWith('#') === true);
241
83
  }
242
- /** What each link points at, paired with the link, skipping anything missing. */
243
84
  get sections() {
244
85
  const root = this.getRootNode();
245
86
  return this.links.flatMap((link) => {
246
87
  const id = decodeURIComponent(link.getAttribute('href')?.slice(1) ?? '');
247
88
  const target = id ? root.getElementById?.(id) : null;
248
- /* A section that is not rendered is not one that can be read.
249
- *
250
- * `display: none` reports a zero rect, and zero is above the reading line,
251
- * so an unrendered section counts as passed on every measurement and the
252
- * marker lands on the last one of them. Found on a reference page that
253
- * shows one API tier at a time and hides the other: the rail marked a
254
- * heading the reader could not see, and no scrolling could move it,
255
- * because the hidden sections always won the comparison.
256
- *
257
- * `getClientRects()` rather than a size check, because it is empty for
258
- * exactly the case in question and non-empty for a section that is merely
259
- * scrolled out of view, which is very much readable. */
260
89
  return target && target.getClientRects().length > 0 ? [{ link, target }] : [];
261
90
  });
262
91
  }
263
- /**
264
- * Works out which section is being read, and marks its link.
265
- *
266
- * Positional rather than a reading of what intersects, for the reason above.
267
- * The comparison is against `offset` and not zero, so the reading line is
268
- * wherever the page says the top of the readable area is.
269
- */
270
92
  mark() {
271
- /* A destination beats a measurement. See `handleClick`: while the page is
272
- traveling to somewhere a reader asked for, what it passes on the way is
273
- not what they are reading. */
274
93
  if (this.landing) {
275
94
  return;
276
95
  }
@@ -279,31 +98,9 @@ let HelixHeadlessOutline = class HelixHeadlessOutline extends HelixElement {
279
98
  return;
280
99
  }
281
100
  const passed = sections.filter(({ target }) => target.getBoundingClientRect().top - this.offset <= 1);
282
- /* At the end of the page the last section is what is being read, whether or
283
- not its top ever reached the reading line.
284
- *
285
- * The positional rule has one blind spot and it is at the bottom: a section
286
- * is current once its top passes the line, and a section near the end of the
287
- * document may not have enough page beneath it to get there. Measured on
288
- * three sections with a short last one, scrolled fully to the end, 910 of
289
- * 910: the third heading sat at 530px, plainly on screen and plainly the
290
- * thing being read, and the marker stayed on the second. The last item in a
291
- * contents list was unreachable by scrolling.
292
- *
293
- * There is no mirror of it at the top: `passed` is empty there and the first
294
- * section is already the fallback, and the first heading's top starts *at*
295
- * the line rather than below it, so there is nothing for it to fail to
296
- * reach.
297
- *
298
- * What this cannot do is tell trailing sections apart. If the last three
299
- * share the final screenful, none of them can reach the line and the page
300
- * has no scroll left to distinguish them, so all three collapse into "you
301
- * are at the end". That is a fact about the page rather than a choice made
302
- * here: the component cannot invent room to scroll. */
303
101
  const active = this.atEnd ? sections.at(-1) : (passed.at(-1) ?? sections[0]);
304
102
  this.markTo(active);
305
103
  }
306
- /** Where the destination is set, whether measured or asked for. */
307
104
  markTo(active) {
308
105
  for (const { link, target } of this.sections) {
309
106
  if (target === active?.target) {
@@ -313,41 +110,12 @@ let HelixHeadlessOutline = class HelixHeadlessOutline extends HelixElement {
313
110
  link.removeAttribute('aria-current');
314
111
  }
315
112
  }
316
- /* And say so. `reading` is derived from scroll position, which is the one
317
- * fact this component computes that nothing else on the page can, so a
318
- * consumer syncing a URL hash to it has no other way in short of a
319
- * `MutationObserver` on the anchors.
320
- *
321
- * Only on a change, because this runs from a scroll frame and an event per
322
- * frame is not a signal, it is a firehose. */
323
113
  const next = active?.target.id ?? '';
324
114
  if (next !== this.reading) {
325
115
  this.reading = next;
326
116
  this.dispatchEvent(new HelixBeforeActivateEvent({ value: next }));
327
117
  }
328
118
  }
329
- /**
330
- * Whether the page has no more scroll left.
331
- *
332
- * `Math.ceil` and a pixel of slack, because a fractional device pixel ratio
333
- * leaves the position a hair under the height at the very bottom and an exact
334
- * comparison never fires.
335
- *
336
- * **All three numbers off the same element.** Adding the window's `scrollY`
337
- * and `innerHeight` and comparing them against
338
- * `documentElement.scrollHeight` mixes two coordinate systems, which agree
339
- * only while the viewport is what scrolls, and the body takes that job the
340
- * moment it becomes a scroll container. Measured on a page whose body is
341
- * `overflow: clip visible`, which is a pair CSS resolves by making the
342
- * `visible` axis `auto`: `documentElement.scrollTop` read 1126 at the bottom
343
- * of the page and `window.scrollY` read 240, so a contents rail never learned
344
- * it had reached the end and its last item stayed unreachable. This library's
345
- * own base layer produces exactly that page.
346
- *
347
- * `clientHeight` rather than `innerHeight` for the same reason, and because it
348
- * is the one that excludes a scrollbar, which is what `scrollHeight` is
349
- * measured against.
350
- */
351
119
  get atEnd() {
352
120
  const doc = globalThis.document?.scrollingElement ?? globalThis.document?.documentElement;
353
121
  if (!doc) {
@@ -355,49 +123,14 @@ let HelixHeadlessOutline = class HelixHeadlessOutline extends HelixElement {
355
123
  }
356
124
  return Math.ceil(doc.scrollTop + doc.clientHeight) >= doc.scrollHeight - 1;
357
125
  }
358
- /**
359
- * Gives every section the same reading line the marker uses.
360
- *
361
- * `offset` says where the top of the readable area is, so it has to govern
362
- * where a click lands as much as what the marker calls current. Read by the
363
- * marker alone, a click puts the heading against the very top of the window
364
- * and underneath the sticky masthead the consumer measured the offset from,
365
- * which is the same number meaning two things.
366
- *
367
- * `scroll-margin-block-start` rather than an interception, because it is the
368
- * property the platform provides for exactly this and the UA honors it on
369
- * ordinary fragment navigation, on `scrollIntoView`, and on a browser
370
- * restoring a position. Nothing has to be taken over to get it.
371
- *
372
- * Written onto the consumer's element, like `aria-current` above and for the
373
- * same reason: it is derived from a property describing the page's chrome,
374
- * so no server rendered it and there is nothing to disagree with. Cleared
375
- * rather than set to `0px` when there is no offset, so the common case leaves
376
- * no attribute at all.
377
- */
378
126
  reach() {
379
127
  for (const { target } of this.sections) {
380
128
  const element = target;
381
129
  element.style.scrollMarginBlockStart = this.offset > 0 ? `${this.offset}px` : '';
382
130
  }
383
131
  }
384
- /**
385
- * Watches every section, and re-marks whenever any of them crosses anything.
386
- *
387
- * The thresholds are what make the trigger frequent enough: an observer fires
388
- * on entering and leaving, and a long section scrolled slowly crosses neither
389
- * for a long time. Asking for a callback at each quarter of visibility is
390
- * cheap. It is still only a trigger: see `handleScroll` for the two cases no
391
- * intersection can see.
392
- */
393
132
  watch() {
394
133
  this.observer?.disconnect();
395
- /* Nothing to watch yet is not nothing to watch ever. A page whose sections
396
- * arrive after the nav does, which is every page rendering its content
397
- * asynchronously, resolves no href at first update, and returning here
398
- * without leaving a way back kills the component for the life of the page.
399
- * `updated` re-runs this while `observer` is undefined, which is why the
400
- * field is cleared rather than left holding a disconnected observer. */
401
134
  if (this.sections.length === 0) {
402
135
  this.observer = undefined;
403
136
  return;
@@ -423,9 +156,6 @@ let HelixHeadlessOutline = class HelixHeadlessOutline extends HelixElement {
423
156
  this.observer = undefined;
424
157
  this.resize?.disconnect();
425
158
  this.resize = undefined;
426
- /* The landing timer holds a closure over this element for 1.2 seconds after
427
- it leaves the document and then touches it, which on a route change is a
428
- write to a tree nobody is looking at. */
429
159
  clearTimeout(this.landed);
430
160
  this.landed = undefined;
431
161
  }
@@ -433,13 +163,6 @@ let HelixHeadlessOutline = class HelixHeadlessOutline extends HelixElement {
433
163
  super.connectedCallback();
434
164
  this.addEventListener('click', this.handleClick);
435
165
  globalThis.addEventListener?.('scroll', this.handleScroll, { passive: true });
436
- /* Both observers are rebuilt on reconnection. `firstUpdated` runs once for
437
- * the life of the element and `disconnectedCallback` runs every time it
438
- * moves, so without this an outline moved in the DOM, which a framework does
439
- * on any reorder, comes back permanently frozen: `aria-current`, `current`
440
- * and the traveling mark all stop for the rest of the page's life with
441
- * nothing to say why. Guarded on `hasUpdated`, because the first connection
442
- * happens before the first render and there is nothing to watch yet. */
443
166
  if (this.hasUpdated) {
444
167
  this.watch();
445
168
  this.resize = new ResizeObserver(() => this.indicator.place());
@@ -449,33 +172,18 @@ let HelixHeadlessOutline = class HelixHeadlessOutline extends HelixElement {
449
172
  firstUpdated(changed) {
450
173
  super.firstUpdated(changed);
451
174
  this.watch();
452
- /* The links are text in a column, so anything that changes the column's
453
- width rewraps them and moves every one below. Nothing else would notice:
454
- the intersection observer watches the sections, and a resize moves the
455
- links without moving the page. */
456
175
  this.resize = new ResizeObserver(() => this.indicator.place());
457
176
  this.resize.observe(this);
458
177
  }
459
178
  updated(changed) {
460
179
  super.updated(changed);
461
- /* The reading line moved, so where the reader is may have changed without
462
- the page having moved at all. Nothing else would notice: the observer
463
- fires on sections crossing the viewport, and this changes what crossing
464
- means rather than whether anything did. */
465
180
  if (changed.has('offset')) {
466
181
  this.reach();
467
182
  this.mark();
468
183
  }
469
- /* Try again while there is nothing being watched. Cheap: `watch` returns on
470
- the first line once it has something, and this is the only path back for
471
- a nav whose sections had not rendered when it first looked. */
472
184
  if (!this.observer) {
473
185
  this.watch();
474
186
  }
475
- /* After the render that applied the new `aria-current`, not inside `mark()`
476
- beside the attribute write. The current link is styled differently, and
477
- on a narrow column the extra weight is enough to rewrap it, so measuring
478
- before the style lands measures the wrong height. */
479
187
  this.indicator.place();
480
188
  }
481
189
  render() {