@fusion.dev/helix-core 0.144.0 → 0.145.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.
@@ -17,6 +17,7 @@ const RELATIONSHIPS = [
17
17
  ['aria-labelledby', 'ariaLabelledByElements'],
18
18
  ];
19
19
  const STATES = [
20
+ 'aria-current',
20
21
  'aria-expanded',
21
22
  'aria-haspopup',
22
23
  'aria-controls',
@@ -0,0 +1,55 @@
1
+ import type { ReactiveController, ReactiveControllerHost } from 'lit';
2
+ /** What a `CountdownController` does when its time runs out, and when it stops and starts. */
3
+ export interface CountdownOptions {
4
+ /** Runs once the time is spent. */
5
+ done: () => void;
6
+ /** Runs with `true` when the clock stops partway and `false` when it runs again. */
7
+ paused?: (paused: boolean) => void;
8
+ }
9
+ /**
10
+ * A lifetime that pauses: a message that dismisses itself after a time, and
11
+ * holds that time while a reader is on it.
12
+ *
13
+ * Toast's items and Announcement each dismiss themselves after `duration`,
14
+ * and each stops the clock while the pointer or focus is on the message, so a
15
+ * reader partway through is not cut off. Resuming continues from what was
16
+ * left rather than starting over.
17
+ *
18
+ * **Holds are counted, not flagged.** Hover and focus stop the same clock and
19
+ * are independent: a pointer can leave while focus stays. Uncounted, a reader
20
+ * who tabs into a message and then moves the pointer off it has the clock
21
+ * restarted underneath them. Only the last one out starts it again.
22
+ *
23
+ * **Leaving the page pauses it**, keeping the time already spent, and drops
24
+ * the holds, since a pointer or focus on a detached element never leaves it.
25
+ * Coming back does not resume on its own: the host says whether it should,
26
+ * such as only while it is still showing. `DelayController` is the other wait
27
+ * in core, for a wait that starts over and forgets itself on leaving.
28
+ */
29
+ export declare class CountdownController implements ReactiveController {
30
+ private readonly options;
31
+ /** When the current run began, or nothing while the clock is stopped. */
32
+ private startedAt;
33
+ /** What is left of the time, so resuming continues rather than restarts. */
34
+ private remaining;
35
+ private timer;
36
+ /** How many reasons there are to hold the clock. */
37
+ private holds;
38
+ constructor(host: ReactiveControllerHost, options: CountdownOptions);
39
+ /** Whether the clock is running now. */
40
+ get running(): boolean;
41
+ hostConnected(): void;
42
+ hostDisconnected(): void;
43
+ /** Starts the clock at `ms`. Nothing, zero or less starts nothing. */
44
+ start(ms: number | undefined): void;
45
+ /** Stops the clock and keeps the time that is left. */
46
+ pause(): void;
47
+ /** Runs the clock again from where it stopped. */
48
+ resume(): void;
49
+ /** Stops the clock where it is, as a dismissal does. */
50
+ cancel(): void;
51
+ /** One more reason to hold the clock, such as a pointer arriving. */
52
+ hold(): void;
53
+ /** One reason fewer, and the clock runs again once none is left. */
54
+ release(): void;
55
+ }
@@ -0,0 +1,57 @@
1
+ export class CountdownController {
2
+ constructor(host, options) {
3
+ this.options = options;
4
+ this.holds = 0;
5
+ host.addController(this);
6
+ }
7
+ get running() {
8
+ return this.timer !== undefined;
9
+ }
10
+ hostConnected() { }
11
+ hostDisconnected() {
12
+ this.pause();
13
+ this.holds = 0;
14
+ }
15
+ start(ms) {
16
+ if (ms === undefined || !(ms > 0)) {
17
+ return;
18
+ }
19
+ this.remaining = ms;
20
+ this.resume();
21
+ }
22
+ pause() {
23
+ if (this.timer === undefined || this.startedAt === undefined) {
24
+ return;
25
+ }
26
+ clearTimeout(this.timer);
27
+ this.timer = undefined;
28
+ this.remaining = Math.max((this.remaining ?? 0) - (Date.now() - this.startedAt), 0);
29
+ this.startedAt = undefined;
30
+ this.options.paused?.(true);
31
+ }
32
+ resume() {
33
+ if (this.timer !== undefined || this.remaining === undefined || this.remaining <= 0) {
34
+ return;
35
+ }
36
+ this.startedAt = Date.now();
37
+ this.options.paused?.(false);
38
+ this.timer = setTimeout(() => {
39
+ this.timer = undefined;
40
+ this.options.done();
41
+ }, this.remaining);
42
+ }
43
+ cancel() {
44
+ clearTimeout(this.timer);
45
+ this.timer = undefined;
46
+ }
47
+ hold() {
48
+ this.holds += 1;
49
+ this.pause();
50
+ }
51
+ release() {
52
+ this.holds = Math.max(this.holds - 1, 0);
53
+ if (this.holds === 0) {
54
+ this.resume();
55
+ }
56
+ }
57
+ }
@@ -19,6 +19,8 @@ export interface NavigableItem {
19
19
  /** Whether the item is the current one, which is where movement starts. */
20
20
  selected: boolean;
21
21
  }
22
+ /** Where a move starts from: the item that has focus, or the chosen item. */
23
+ export type KeyboardOrigin = 'focus' | 'selection';
22
24
  /** How the controller is told what it is moving through. */
23
25
  export interface KeyboardNavigationOptions {
24
26
  /** The items, in the order a reader moves through them. */
@@ -35,8 +37,13 @@ export interface KeyboardNavigationOptions {
35
37
  * every time. It is also the only reading that can tell a press on a tab from
36
38
  * a press somewhere else in the component, which matters wherever the host
37
39
  * holds more than the set (a tab panel is a tab stop of its own).
40
+ *
41
+ * A function where the answer changes with the element's state, read on every
42
+ * press. A read-only radio group is the case: while it is read-only the arrows
43
+ * move focus between radios without choosing, so the move has to start where
44
+ * focus is, and once it is editable again the selection is the start.
38
45
  */
39
- origin?: 'focus' | 'selection';
46
+ origin?: KeyboardOrigin | (() => KeyboardOrigin);
40
47
  /**
41
48
  * Whether arriving also chooses.
42
49
  *
@@ -176,13 +183,15 @@ export declare class Typeahead {
176
183
  export declare class KeyboardNavigationController implements ReactiveController {
177
184
  private readonly host;
178
185
  private readonly items;
179
- private readonly origin;
186
+ private readonly originOf;
180
187
  private readonly selects;
181
188
  private readonly reserved;
182
189
  private readonly wrap;
183
190
  private readonly words;
184
191
  private readonly typeahead;
185
192
  constructor(host: ReactiveControllerHost & HTMLElement, { items, origin, reserved, selects, typeahead, wrap, }: KeyboardNavigationOptions);
193
+ /** Where this press starts from, read again on every press. */
194
+ private get origin();
186
195
  /** Moves to an item, and chooses it where arriving is choosing. */
187
196
  private arrive;
188
197
  /**
@@ -81,13 +81,16 @@ export class KeyboardNavigationController {
81
81
  };
82
82
  this.host = host;
83
83
  this.items = items;
84
- this.origin = origin;
84
+ this.originOf = typeof origin === 'function' ? origin : () => origin;
85
85
  this.reserved = reserved;
86
86
  this.selects = selects;
87
87
  this.words = typeahead;
88
88
  this.wrap = wrap;
89
89
  host.addController(this);
90
90
  }
91
+ get origin() {
92
+ return this.originOf();
93
+ }
91
94
  arrive(target) {
92
95
  target?.focusItem();
93
96
  if (this.selects()) {
@@ -63,6 +63,16 @@ export interface Window {
63
63
  /** Index one past the last item rendered. */
64
64
  last: number;
65
65
  }
66
+ /** Where `reveal` puts an item, and how it gets there. */
67
+ export interface RevealOptions {
68
+ /**
69
+ * Where the item lands: the least distance that shows it whole (`nearest`,
70
+ * the default), or at the box's `start`, `center` or `end`.
71
+ */
72
+ align?: 'center' | 'end' | 'nearest' | 'start';
73
+ /** The platform's scroll behavior. Instant when left out. */
74
+ behavior?: ScrollBehavior;
75
+ }
66
76
  /**
67
77
  * Works out which slice of a long set is worth rendering.
68
78
  *
@@ -86,35 +96,14 @@ export interface Window {
86
96
  * track, so the room the absent rows would have taken is left as padding before
87
97
  * and after. A reader dragging that thumb is dragging against the real length.
88
98
  *
89
- * **What it does not do, and the reason is narrower than it first looks.** True
90
- * virtualization means never creating the rows that are not on screen, and that
91
- * needs the set as *data*. Two channels are shut: a consumer's items arrive as
92
- * light-DOM children, which exist by the time this could decide not to create
93
- * them, and an `items` property cannot survive React hydration, which skips
94
- * every property it would have to apply imperatively (see `bindReactHandler`,
95
- * and the note in the rules that objects and functions have that bug and are
96
- * not fixed).
97
- *
98
- * **A third channel is open.** Data as *text*
99
- * in the markup, a `<script type="application/json">` child or an attribute, is
100
- * neither a property nor a node per row: React's hydration bug cannot touch text
101
- * it renders as a child, a server emits it, and the node count is one. The
102
- * charts already read data out of the document this way, `for="<id>"` at a real
103
- * `<table>`, so the pattern is in the library rather than being invented for
104
- * this.
105
- *
106
- * So the thing actually blocked is the *row template*. Turning an item into
107
- * markup wants either a render function, which is a function property and dies
108
- * on the same bug, or a `<template>` with named holes, which survives because it
109
- * is markup and which no component here asks anyone to write. That is a decision
110
- * about what this library is willing to look like, not a platform limit, and it
111
- * is open at #71.
112
- *
113
- * So what this buys is the expensive half rather than all of it: the rows
114
- * outside the window are not laid out and not painted, which is where the time
115
- * goes in a long list, and the node count stays. That is worth having and it is
116
- * not worth calling something it is not. An element that windows children it
117
- * was handed is not a virtual list, whatever it is named.
99
+ * **The set arrives as data**, which is what lets a host create only the rows
100
+ * in the window. A host takes it through a property (an `items` array, which
101
+ * `applyReactProperties` carries across React hydration with every other object
102
+ * property) or as text in the markup (a `<script type="application/json">`
103
+ * named by `for`, which a server emits and which is one node whatever the
104
+ * row count). A host that is handed its rows as light-DOM children still gets
105
+ * the expensive half from this: the rows outside the window are not laid out
106
+ * and not painted, and the node count stays.
118
107
  *
119
108
  * **One estimate, not per-row measurement.** Measuring every row to place it is
120
109
  * how a virtual list ends up reading the layout it is trying to avoid, and it
@@ -166,6 +155,39 @@ export declare class VirtualizeController implements ReactiveController {
166
155
  * on screen plus the overscan, whatever the set's length.
167
156
  */
168
157
  private placed;
158
+ /**
159
+ * The items on screen, with no overscan: `first` is the item at the top of
160
+ * the box and `last` is one past the item at its bottom, so
161
+ * `items.slice(first, last)` is what a reader sees.
162
+ *
163
+ * For a host that reports what is visible, where `window` is what is drawn.
164
+ * An empty span (`first === last`) while the box has no height or the set
165
+ * no items.
166
+ */
167
+ visible(): {
168
+ first: number;
169
+ last: number;
170
+ };
171
+ /**
172
+ * How much of the set lies below the bottom of the box, in pixels.
173
+ *
174
+ * For a host that asks for more items as a reader nears the end. Read from
175
+ * the arithmetic and not from `scrollHeight`, which also counts whatever the
176
+ * host draws around the rows.
177
+ */
178
+ remaining(): number;
179
+ /**
180
+ * The two spacers' heights for the window, with item `kept` drawn too.
181
+ *
182
+ * For a host that keeps an item outside the window drawn, the row holding
183
+ * focus say. The item takes its own room out of the spacer on its side, so
184
+ * the scrollbar still measures the set. Every host that keeps a focused row
185
+ * asks this, so the arithmetic is written once.
186
+ */
187
+ spacers(kept?: number): {
188
+ after: number;
189
+ before: number;
190
+ };
169
191
  /**
170
192
  * Measures what was just drawn, and holds the reader still if it moved.
171
193
  *
@@ -206,14 +228,25 @@ export declare class VirtualizeController implements ReactiveController {
206
228
  */
207
229
  sizeOf(index: number): number;
208
230
  /**
209
- * Scrolls the box the least distance that shows item `index` whole.
231
+ * Scrolls the box to show item `index`.
232
+ *
233
+ * By default the least distance that shows it whole, for a host moving focus
234
+ * to an item outside the window, whose own element cannot be scrolled to
235
+ * because it is not drawn where it belongs. `inset` is how far into the box
236
+ * the items start, for a heading that sticks at its top and covers that much
237
+ * of every item scrolled under it.
210
238
  *
211
- * For a host moving focus to an item outside the window, whose own element
212
- * cannot be scrolled to because it is not drawn where it belongs. `inset` is
213
- * how far into the box the items start, for a heading that sticks at its
214
- * top and covers that much of every item scrolled under it.
239
+ * `align` puts the item at the box's `start`, `center` or `end` instead, and
240
+ * `behavior` is the platform's, for a host whose consumer asked to be taken
241
+ * to an item. A smooth scroll moves the window through the box's own
242
+ * `scroll` events, the same as a reader scrolling.
243
+ */
244
+ reveal(index: number, inset?: number, { align, behavior }?: RevealOptions): void;
245
+ /**
246
+ * Where item `index` starts along the scroll axis, in pixels from the top of
247
+ * the set: what was measured above it, or the estimate.
215
248
  */
216
- reveal(index: number, inset?: number): void;
249
+ offsetOf(index: number): number;
217
250
  /**
218
251
  * Drops every measurement, for a host that swapped its data.
219
252
  *
@@ -39,6 +39,7 @@ export class VirtualizeController {
39
39
  }
40
40
  this.observer.observe(box);
41
41
  box.addEventListener('scroll', this.handleScroll, { passive: true });
42
+ box.setAttribute('data-virtual', '');
42
43
  this.watching = true;
43
44
  }
44
45
  measure() {
@@ -97,6 +98,48 @@ export class VirtualizeController {
97
98
  last,
98
99
  };
99
100
  }
101
+ visible() {
102
+ const box = this.options.container();
103
+ const total = Math.max(0, this.options.count());
104
+ if (!box || total === 0) {
105
+ return { first: 0, last: 0 };
106
+ }
107
+ const top = Math.max(0, box.scrollTop);
108
+ const bottom = top + box.clientHeight;
109
+ if (!this.measuring()) {
110
+ const size = Math.max(1, this.options.estimate());
111
+ const first = Math.min(Math.floor(top / size), total);
112
+ return { first, last: Math.max(first, Math.min(total, Math.ceil(bottom / size))) };
113
+ }
114
+ const first = this.heights.indexAt(top, total);
115
+ let last = first;
116
+ while (last < total && this.heights.before(last, total) < bottom) {
117
+ last += 1;
118
+ }
119
+ return { first, last };
120
+ }
121
+ remaining() {
122
+ const box = this.options.container();
123
+ const total = Math.max(0, this.options.count());
124
+ if (!box) {
125
+ return 0;
126
+ }
127
+ const length = this.measuring()
128
+ ? this.heights.total(total)
129
+ : total * Math.max(1, this.options.estimate());
130
+ return Math.max(0, length - Math.max(0, box.scrollTop) - box.clientHeight);
131
+ }
132
+ spacers(kept) {
133
+ const { after, before, first, last } = this.window;
134
+ if (kept === undefined || (kept >= first && kept < last)) {
135
+ return { after, before };
136
+ }
137
+ const room = this.sizeOf(kept);
138
+ return {
139
+ after: kept >= last ? Math.max(0, after - room) : after,
140
+ before: kept < first ? Math.max(0, before - room) : before,
141
+ };
142
+ }
100
143
  hostUpdated() {
101
144
  if (this.owed !== 0) {
102
145
  const box = this.options.container();
@@ -149,22 +192,46 @@ export class VirtualizeController {
149
192
  sizeOf(index) {
150
193
  return this.measuring() ? this.heights.at(index) : Math.max(1, this.options.estimate());
151
194
  }
152
- reveal(index, inset = 0) {
195
+ reveal(index, inset = 0, { align = 'nearest', behavior } = {}) {
153
196
  const box = this.options.container();
154
197
  if (!box) {
155
198
  return;
156
199
  }
157
- const total = Math.max(0, this.options.count());
158
- const top = this.measuring()
159
- ? this.heights.before(index, total)
160
- : index * Math.max(1, this.options.estimate());
161
- const bottom = top + this.sizeOf(index) + inset;
162
- if (top < box.scrollTop) {
163
- box.scrollTop = top;
200
+ const top = this.offsetOf(index);
201
+ const size = this.sizeOf(index);
202
+ const room = box.clientHeight - inset;
203
+ let to;
204
+ if (align === 'start') {
205
+ to = top;
206
+ }
207
+ else if (align === 'center') {
208
+ to = top - inset - (room - size) / 2;
209
+ }
210
+ else if (align === 'end') {
211
+ to = top + size + inset - box.clientHeight;
212
+ }
213
+ else if (top < box.scrollTop) {
214
+ to = top;
164
215
  }
165
- else if (bottom > box.scrollTop + box.clientHeight) {
166
- box.scrollTop = bottom - box.clientHeight;
216
+ else if (top + size + inset > box.scrollTop + box.clientHeight) {
217
+ to = top + size + inset - box.clientHeight;
167
218
  }
219
+ else {
220
+ return;
221
+ }
222
+ const next = Math.max(0, to);
223
+ if (behavior && typeof box.scrollTo === 'function') {
224
+ box.scrollTo({ behavior, top: next });
225
+ }
226
+ else {
227
+ box.scrollTop = next;
228
+ }
229
+ }
230
+ offsetOf(index) {
231
+ const total = Math.max(0, this.options.count());
232
+ return this.measuring()
233
+ ? this.heights.before(index, total)
234
+ : Math.max(0, Math.min(index, total)) * Math.max(1, this.options.estimate());
168
235
  }
169
236
  forget() {
170
237
  this.heights.forget();