@fusion.dev/helix-headless 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/custom-elements.json +234 -234
- package/dist/accordion/item/accordion-item.js +0 -108
- package/dist/accordion/root/accordion-root.js +0 -33
- package/dist/announcement/root/announcement-root.js +1 -76
- package/dist/app-header/root/app-header-root.js +0 -84
- package/dist/avatar/group/avatar-group.js +0 -77
- package/dist/avatar/root/avatar-root.js +0 -72
- package/dist/badge/root/badge-root.js +0 -67
- package/dist/bar-chart/root/bar-chart-root.js +3 -263
- package/dist/blockquote/root/blockquote-root.js +0 -46
- package/dist/breadcrumbs/breadcrumb/breadcrumb.js +0 -72
- package/dist/breadcrumbs/root/breadcrumbs-root.js +0 -52
- package/dist/button/root/button-root.js +0 -215
- package/dist/button-group/root/button-group-root.js +0 -30
- package/dist/callout/root/callout-root.js +0 -52
- package/dist/card/root/card-root.js +0 -41
- package/dist/carousel/root/carousel-root.d.ts +2 -2
- package/dist/carousel/root/carousel-root.js +2 -362
- package/dist/chat/composer/chat-composer.js +0 -131
- package/dist/chat/message/chat-message.js +0 -137
- package/dist/chat/root/chat-root.js +0 -193
- package/dist/checkbox/root/checkbox-root.js +0 -113
- package/dist/checkbox-group/root/checkbox-group-root.js +1 -157
- package/dist/code/root/code-root.js +0 -26
- package/dist/code-block/root/code-block-root.js +0 -100
- package/dist/color-input/root/color-input-root.js +0 -217
- package/dist/color-picker/color.js +0 -69
- package/dist/color-picker/root/color-picker-root.js +0 -272
- package/dist/combobox/root/combobox-root.js +0 -428
- package/dist/command/root/command-root.js +0 -177
- package/dist/copy-button/root/copy-button-root.js +0 -134
- package/dist/data-table/root/data-table-root.js +0 -336
- package/dist/data-table/root/order.js +0 -100
- package/dist/date-input/root/date-input-root.js +1 -398
- package/dist/date-picker/root/date-picker-root.js +0 -358
- package/dist/dialog/root/dialog-root.js +0 -189
- package/dist/divider/root/divider-root.js +0 -71
- package/dist/donut-chart/root/donut-chart-root.js +0 -214
- package/dist/drawer/root/drawer-root.js +0 -116
- package/dist/drilldown/item/drilldown-item.js +0 -82
- package/dist/drilldown/root/drilldown-root.js +0 -91
- package/dist/element-events.generated.js +0 -5
- package/dist/empty/root/empty-root.js +0 -35
- package/dist/file-input/root/file-input-root.js +0 -232
- package/dist/format-date/root/format-date-root.js +0 -95
- package/dist/heatmap/root/buckets.js +0 -57
- package/dist/heatmap/root/heatmap-root.js +0 -313
- package/dist/icon/root/icon-root.js +0 -48
- package/dist/icon-button/root/icon-button-root.js +0 -30
- package/dist/image/root/image-root.js +0 -174
- package/dist/index.js +0 -9
- package/dist/input/root/input-root.js +0 -228
- package/dist/kbd/root/kbd-root.js +0 -117
- package/dist/legend/root/legend-root.js +0 -73
- package/dist/line-chart/root/line-chart-root.js +0 -172
- package/dist/list/item/list-item.js +0 -92
- package/dist/list/root/list-root.js +0 -26
- package/dist/listbox/root/listbox-root.js +2 -429
- package/dist/loader/root/loader-root.js +0 -40
- package/dist/megamenu/root/megamenu-root.js +0 -284
- package/dist/menu/item/menu-item.js +1 -221
- package/dist/menu/root/menu-root.js +1 -171
- package/dist/meter/root/meter-root.js +1 -152
- package/dist/navigation/root/navigation-root.js +0 -56
- package/dist/notifications/notification/notification.js +0 -159
- package/dist/notifications/root/notifications-root.js +0 -148
- package/dist/number-input/root/number-input-root.js +1 -263
- package/dist/option/group/option-group.js +0 -26
- package/dist/option/root/option-root.js +0 -157
- package/dist/outline/root/outline-root.js +0 -292
- package/dist/pagination/root/pagination-root.js +0 -361
- package/dist/pagination/size/pagination-size.js +0 -104
- package/dist/pagination/summary/pagination-summary.js +0 -90
- package/dist/pin-input/root/pin-input-root.js +0 -276
- package/dist/popover/root/popover-root.js +0 -103
- package/dist/progress/root/progress-root.js +1 -133
- package/dist/qr-code/root/qr-code-root.js +0 -96
- package/dist/quote/root/quote-root.js +0 -25
- package/dist/radio/group/radio-group.js +0 -81
- package/dist/radio/root/radio-root.js +0 -85
- package/dist/rating/root/rating-root.js +0 -220
- package/dist/scroll-area/root/scroll-area-root.js +0 -100
- package/dist/select/root/select-root.js +2 -218
- package/dist/skeleton/root/skeleton-root.js +0 -20
- package/dist/slider/root/slider-root.js +1 -267
- package/dist/sparkline/root/sparkline-root.js +3 -164
- package/dist/speed-dial/item/speed-dial-item.js +0 -52
- package/dist/speed-dial/root/speed-dial-root.js +1 -116
- package/dist/splitter/root/splitter-root.js +0 -489
- package/dist/stat/group/stat-group.js +0 -18
- package/dist/stat/root/stat-root.js +0 -23
- package/dist/steps/root/steps-root.js +0 -62
- package/dist/steps/step/step.js +0 -131
- package/dist/switch/root/switch-root.js +0 -80
- package/dist/table/cell/table-cell.js +0 -23
- package/dist/table/column/table-column.js +0 -158
- package/dist/table/root/table-root.js +0 -82
- package/dist/table/row/table-row.js +0 -21
- package/dist/tabs/panel/tab-panel.js +0 -49
- package/dist/tabs/root/tabs-root.js +0 -98
- package/dist/tabs/tab/tab.js +0 -59
- package/dist/tag/root/tag-root.js +0 -31
- package/dist/textarea/root/textarea-root.js +0 -162
- package/dist/time-input/root/time-input-root.js +0 -283
- package/dist/time-picker/root/time-picker-root.js +0 -326
- package/dist/timeline/item/timeline-item.js +0 -66
- package/dist/timeline/root/timeline-root.js +0 -41
- package/dist/toast/item/toast-item.js +0 -165
- package/dist/toast/root/toast-root.js +0 -51
- package/dist/toggle/group/toggle-group.js +0 -150
- package/dist/toggle/root/toggle-root.js +0 -78
- package/dist/toolbar/root/toolbar-root.js +0 -116
- package/dist/tooltip/root/tooltip-root.js +0 -427
- package/dist/tour/root/tour-root.js +0 -130
- package/dist/tour/stop/tour-stop.js +1 -127
- package/dist/tree/item/tree-item.js +0 -198
- package/dist/tree/root/tree-root.js +0 -97
- package/dist/trend/root/trend-root.js +0 -53
- package/dist/video/root/video-root.d.ts +8 -12
- package/dist/video/root/video-root.js +5 -756
- package/dist/video-playlist/item/video-playlist-item.js +1 -90
- package/dist/video-playlist/menu/video-playlist-menu.js +1 -132
- package/dist/video-playlist/root/video-playlist-root.js +0 -113
- package/dist/virtual-list/root/virtual-list-root.js +0 -105
- package/jsx.d.ts +1408 -2576
- package/package.json +6 -6
- 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() {
|