@fusion.dev/helix-headless 0.173.0 → 0.175.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.
@@ -16,7 +16,9 @@ declare const HelixHeadlessLightbox_base: (new (...args: any[]) => import("@fusi
16
16
  *
17
17
  * A lightbox shows one item at a time and has no scrolling track, so `active`
18
18
  * holds the current item, as in `Tabs`. The arrow keys change the item while
19
- * focus stays in the dialog.
19
+ * focus stays in the dialog. `active` names an item by its `value`, and an
20
+ * item with no `value` by its place, counted from 1, as in `Steps`, so a set
21
+ * written without values moves and `helix-activate` says `1`, `2` and so on.
20
22
  *
21
23
  * Set `controls` for the viewer's own previous, next and close controls, named
22
24
  * by `previous-label`, `next-label` and `dismiss-label`, whose glyphs you can
@@ -33,9 +35,9 @@ declare const HelixHeadlessLightbox_base: (new (...args: any[]) => import("@fusi
33
35
  * `--lightbox-pan-x` and `--lightbox-pan-y`, written only while zoomed, and a
34
36
  * swipe moves to the next item only while the item is at its own size. With
35
37
  * `controls`, the viewer also draws zoom-out and zoom-in controls, for a
36
- * pointer with no wheel and no second finger. Once a reader stops zooming, the
37
- * live region says the new scale in the words of `zoom-status-label`, such as
38
- * "Zoomed to 150%".
38
+ * pointer with no wheel and no second finger. Once a reader stops zooming, and
39
+ * at once for a key or a zoom control, the live region says the new scale in
40
+ * the words of `zoom-status-label`, such as "Zoomed to 150%".
39
41
  *
40
42
  * Each item is a slide: a `group` with the role description in
41
43
  * `slide-role-label`, named by its `label` or by `slide-label`. A touch or a pen
@@ -232,7 +234,14 @@ export declare class HelixHeadlessLightbox extends HelixHeadlessLightbox_base {
232
234
  private readonly zoomer;
233
235
  /** Writes the zoom on the viewport, and nothing at the item's own size. */
234
236
  private drawZoom;
235
- /** Says the new scale in the live region, a moment after the last change. */
237
+ /** Whether the zoom is changing by one discrete step, a key or a control, which is said at once. */
238
+ private zoomStep;
239
+ /**
240
+ * Says the new scale in the live region: a moment after the last change of
241
+ * a pinch, a drag or the wheel, and at once for a key or a zoom control,
242
+ * which is one change a reader is waiting to hear. Said only after the
243
+ * pause, a reader pressing `+` heard nothing until it ended.
244
+ */
236
245
  private sayZoom;
237
246
  /**
238
247
  * Zooms the shown item to `scale`, held between `1` and `max-zoom`, about
@@ -346,12 +355,23 @@ export declare class HelixHeadlessLightbox extends HelixHeadlessLightbox_base {
346
355
  private zoomIn;
347
356
  /** Zooms out by as much, as `-` and the zoom-out control do. */
348
357
  private zoomOut;
358
+ /** Changes the zoom by one discrete step, which `sayZoom` says at once. */
359
+ private stepZoom;
349
360
  /** Home and End, which are an index rather than a step. */
350
361
  private toEnd;
351
362
  /** The item shown at the last update, whose zoom a different item does not keep. */
352
363
  private zoomedItem;
353
364
  /** The item on screen: the one `active` names, or the first where it names none. */
354
365
  private get shown();
366
+ /** The item `active` names, by `value` or by place, or the first where it names none. */
367
+ private named;
368
+ /**
369
+ * Requests a new shown item, and returns whether it moved.
370
+ *
371
+ * Where `active` names nothing, it is first written with the item on
372
+ * screen, so `helix-activate` names the item you saw as `previous`.
373
+ */
374
+ requestActive(next: string, cause?: string): boolean;
355
375
  /**
356
376
  * A press on the backdrop or on the empty area around the item.
357
377
  *
@@ -12,6 +12,7 @@ import { unsafeSVG } from 'lit/directives/unsafe-svg.js';
12
12
  import { HelixHeadlessLightboxItem } from '../item/lightbox-item.js';
13
13
  import { percentOf } from '../zoom.js';
14
14
  import { ZoomController } from '../zoom-controller.js';
15
+ const keyOf = (item, index) => item.value || String(index + 1);
15
16
  let HelixHeadlessLightbox = class HelixHeadlessLightbox extends MixinControllerActive(MixinControllerOpen(HelixElement)) {
16
17
  constructor() {
17
18
  super(...arguments);
@@ -76,6 +77,7 @@ let HelixHeadlessLightbox = class HelixHeadlessLightbox extends MixinControllerA
76
77
  onChange: (scale, pan) => this.drawZoom(scale, pan),
77
78
  surface: () => this.partNamed('viewport'),
78
79
  });
80
+ this.zoomStep = false;
79
81
  this.fullscreen = new FullscreenController(this, (full) => {
80
82
  this.filled = full;
81
83
  });
@@ -164,7 +166,7 @@ let HelixHeadlessLightbox = class HelixHeadlessLightbox extends MixinControllerA
164
166
  this.zoomSaid.cancel();
165
167
  return;
166
168
  }
167
- this.zoomSaid.start(400, () => {
169
+ this.zoomSaid.start(this.zoomStep ? 0 : 400, () => {
168
170
  this.announcement = formatWords(this, 'zoomStatusLabel', {
169
171
  percent: percentOf(this.zoomScale),
170
172
  scale: this.zoomScale,
@@ -225,7 +227,7 @@ let HelixHeadlessLightbox = class HelixHeadlessLightbox extends MixinControllerA
225
227
  : Math.min(Math.max(from + by, 0), last);
226
228
  const target = items[next];
227
229
  if (target && target !== shown) {
228
- this.requestActive(target.value);
230
+ this.requestActive(keyOf(target, next));
229
231
  }
230
232
  }
231
233
  zoomKey(key) {
@@ -239,7 +241,7 @@ let HelixHeadlessLightbox = class HelixHeadlessLightbox extends MixinControllerA
239
241
  this.zoomOut();
240
242
  }
241
243
  else if (key === '0') {
242
- this.zoomer.reset();
244
+ this.stepZoom(() => this.zoomer.reset());
243
245
  }
244
246
  else {
245
247
  return false;
@@ -266,19 +268,40 @@ let HelixHeadlessLightbox = class HelixHeadlessLightbox extends MixinControllerA
266
268
  return true;
267
269
  }
268
270
  zoomIn() {
269
- this.zoomer.zoomTo(this.zoomer.scale * 1.5);
271
+ this.stepZoom(() => this.zoomer.zoomTo(this.zoomer.scale * 1.5));
270
272
  }
271
273
  zoomOut() {
272
- this.zoomer.zoomTo(this.zoomer.scale / 1.5);
274
+ this.stepZoom(() => this.zoomer.zoomTo(this.zoomer.scale / 1.5));
275
+ }
276
+ stepZoom(change) {
277
+ this.zoomStep = true;
278
+ try {
279
+ change();
280
+ }
281
+ finally {
282
+ this.zoomStep = false;
283
+ }
273
284
  }
274
285
  toEnd(at) {
275
286
  const target = this.items[at];
276
287
  if (target && target !== this.shown) {
277
- this.requestActive(target.value);
288
+ this.requestActive(keyOf(target, at));
278
289
  }
279
290
  }
280
291
  get shown() {
281
- return resolveActiveItem(this.active ?? '', this.items);
292
+ return this.named(this.items);
293
+ }
294
+ named(items) {
295
+ const keyed = items.map((item, index) => ({ item, value: keyOf(item, index) }));
296
+ return resolveActiveItem(this.active ?? '', keyed)?.item;
297
+ }
298
+ requestActive(next, cause) {
299
+ const items = this.items;
300
+ const shown = this.active === '' ? this.shown : undefined;
301
+ if (shown) {
302
+ this.active = keyOf(shown, items.indexOf(shown));
303
+ }
304
+ return super.requestActive(next, cause);
282
305
  }
283
306
  dragged(event) {
284
307
  const from = this.pressedAt;
@@ -348,7 +371,7 @@ let HelixHeadlessLightbox = class HelixHeadlessLightbox extends MixinControllerA
348
371
  }
349
372
  mark() {
350
373
  const items = this.items;
351
- const current = resolveActiveItem(this.active, items);
374
+ const current = this.named(items);
352
375
  items.forEach((item, at) => {
353
376
  item.current = item === current;
354
377
  whenHydrated(item, () => {
@@ -376,7 +399,7 @@ let HelixHeadlessLightbox = class HelixHeadlessLightbox extends MixinControllerA
376
399
  return;
377
400
  }
378
401
  const items = this.items;
379
- const current = resolveActiveItem(this.active, items);
402
+ const current = this.named(items);
380
403
  const at = current ? items.indexOf(current) : -1;
381
404
  this.announcement =
382
405
  at === -1 || !current
@@ -389,7 +412,7 @@ let HelixHeadlessLightbox = class HelixHeadlessLightbox extends MixinControllerA
389
412
  }
390
413
  place() {
391
414
  const items = this.items;
392
- const current = resolveActiveItem(this.active, items);
415
+ const current = this.named(items);
393
416
  return current
394
417
  ? formatWords(this, 'statusLabel', {
395
418
  count: items.length,
@@ -41,7 +41,7 @@ let HelixHeadlessNotificationGroup = class HelixHeadlessNotificationGroup extend
41
41
  }
42
42
  willUpdate(changed) {
43
43
  super.willUpdate(changed);
44
- hostAria(this.internals, { role: 'listitem' });
44
+ hostAria(this.internals, { label: this.label, role: 'article' });
45
45
  }
46
46
  firstUpdated(changed) {
47
47
  super.firstUpdated(changed);
@@ -92,7 +92,7 @@ let HelixHeadlessNotificationGroup = class HelixHeadlessNotificationGroup extend
92
92
  ${count}`}
93
93
  <span part="actions"><slot name="actions"></slot></span>
94
94
  </div>
95
- <div id=${ITEMS_ID} part="items" role="list" aria-labelledby=${LABEL_ID}>
95
+ <div id=${ITEMS_ID} part="items" role="feed" aria-labelledby=${LABEL_ID}>
96
96
  <slot></slot>
97
97
  </div>`;
98
98
  }
@@ -4,7 +4,7 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
4
4
  else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
5
  return c > 3 && r && Object.defineProperty(target, key, r), r;
6
6
  };
7
- import { customElement, deepActiveElement, deepContains, focusableElements, HelixElement, HelixReadEvent, hostAria, MixinDismissible, requestChange, slotFilled, words, } from '@fusion.dev/helix-core';
7
+ import { customElement, deepActiveElement, deepContains, focusableElements, HelixElement, HelixReadEvent, MixinDismissible, requestChange, slotFilled, words, } from '@fusion.dev/helix-core';
8
8
  import { x } from '@fusion.dev/helix-icons';
9
9
  import { nothing } from 'lit';
10
10
  import { property } from 'lit/decorators.js';
@@ -61,9 +61,6 @@ let HelixHeadlessNotification = class HelixHeadlessNotification extends MixinDis
61
61
  }
62
62
  willUpdate(changed) {
63
63
  super.willUpdate(changed);
64
- hostAria(this.internals, { role: 'listitem' });
65
- this.internals.ariaPosInSet = this.position > 0 ? String(this.position) : null;
66
- this.internals.ariaSetSize = this.setSize === 0 ? null : String(this.setSize);
67
64
  this.toggleState('read', this.read);
68
65
  }
69
66
  updated(changed) {
@@ -116,8 +113,10 @@ let HelixHeadlessNotification = class HelixHeadlessNotification extends MixinDis
116
113
  render() {
117
114
  return html `<div
118
115
  part="item"
119
- role="group"
116
+ role="article"
120
117
  aria-labelledby="label"
118
+ aria-posinset=${this.position > 0 ? String(this.position) : nothing}
119
+ aria-setsize=${this.setSize === 0 ? nothing : String(this.setSize)}
121
120
  tabindex=${this.stop ? 0 : -1}
122
121
  >
123
122
  <span part="media"><slot name="media"></slot></span>
@@ -21,8 +21,9 @@ declare const HelixHeadlessNotifications_base: (new (...args: any[]) => import("
21
21
  * the items and written to an attribute, so a stylesheet and a badge can both
22
22
  * use it without counting.
23
23
  *
24
- * **The feed is one `Tab` stop for its rows.** Each row takes focus itself and
25
- * is read by its label. `ArrowDown` and `ArrowUp` move to the next and the
24
+ * **The feed is one `Tab` stop for its rows.** It is an APG feed: the list is
25
+ * a `feed`, busy while `loading` is set, and each row is an `article` that
26
+ * takes focus itself and is read by its label. `ArrowDown` and `ArrowUp` move to the next and the
26
27
  * previous row, `Home` and `End` to the first and the last, and `PageDown` and
27
28
  * `PageUp` move a row on or back from anywhere inside one, as in the APG feed
28
29
  * pattern. The controls inside a row stay in the `Tab` order. Set `total` when
@@ -77,7 +78,7 @@ declare const HelixHeadlessNotifications_base: (new (...args: any[]) => import("
77
78
  * @csspart error - Shown while `load-error` is set, under any rows there are, in place of the empty and loading lines.
78
79
  * @csspart footer - The region below the list.
79
80
  * @csspart header - The region above the list.
80
- * @csspart list - The list itself.
81
+ * @csspart list - The list itself, a `feed` whose rows are its articles.
81
82
  * @csspart live - A copy of the empty, loading and error messages, in a live region that screen readers announce when they change. Visually hidden in the styled tier.
82
83
  * @csspart loading - Shown while the feed is loading.
83
84
  * @csspart mark-all - The control that marks everything read.
@@ -291,7 +291,7 @@ let HelixHeadlessNotifications = class HelixHeadlessNotifications extends MixinC
291
291
  </div>
292
292
  <div
293
293
  part="list"
294
- role="list"
294
+ role="feed"
295
295
  aria-busy=${this.loading ? 'true' : nothing}
296
296
  aria-label=${this.label || nothing}
297
297
  >
@@ -1,5 +1,5 @@
1
1
  import { HelixElement } from '@fusion.dev/helix-core';
2
- import { type PropertyValues, type TemplateResult } from 'lit';
2
+ import { type CSSResultGroup, type PropertyValues, type TemplateResult } from 'lit';
3
3
  /**
4
4
  * @summary Controls for moving between the pages of a long set of results.
5
5
  *
@@ -63,6 +63,7 @@ export declare class HelixHeadlessPagination extends HelixElement {
63
63
  * `registerWords('de', { 'helix-pagination': { nextLabel: 'Nächste Seite' } })`.
64
64
  */
65
65
  static words: string;
66
+ static styles: CSSResultGroup;
66
67
  /**
67
68
  * How many pages to show at each end, whatever the current one is.
68
69
  */
@@ -6,7 +6,7 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
6
6
  };
7
7
  import { customElement, deepActiveElement, fillTemplate, formatNumbers, formatWords, HelixActivateEvent, HelixElement, HelixNavigateEvent, hostAria, requestChange, words, wordsLocale, } from '@fusion.dev/helix-core';
8
8
  import { caretDoubleLeft, caretDoubleRight, caretLeft, caretRight, } from '@fusion.dev/helix-icons';
9
- import { html, nothing } from 'lit';
9
+ import { css, html, nothing, } from 'lit';
10
10
  import { property, state } from 'lit/decorators.js';
11
11
  import { repeat } from 'lit/directives/repeat.js';
12
12
  import { unsafeSVG } from 'lit/directives/unsafe-svg.js';
@@ -48,6 +48,11 @@ let HelixHeadlessPagination = class HelixHeadlessPagination extends HelixElement
48
48
  };
49
49
  }
50
50
  static { this.words = 'helix-pagination'; }
51
+ static { this.styles = css `
52
+ [part='list'] {
53
+ list-style: none;
54
+ }
55
+ `; }
51
56
  get page() {
52
57
  const asked = this.pageState ?? this.defaultPage;
53
58
  return Math.min(this.count, Math.max(1, Math.floor(asked) || 1));
@@ -28,8 +28,11 @@ declare const HelixHeadlessTooltip_base: (new (...args: any[]) => import("@fusio
28
28
  * markup is described when you first point at it or focus it, so a screen
29
29
  * reader in reading mode meets it with no description until then. Where the
30
30
  * browser has no element references, nothing links the two, and the tooltip
31
- * warns once on first open. The tooltip can also wrap its trigger, which
32
- * describes a Helix component and leaves a plain element undescribed. In the
31
+ * warns once on first open. The tooltip can also wrap its trigger. A Helix
32
+ * component inside is described at once, and a plain element as in the `for`
33
+ * form: when you first point at it or focus it, or at rest where it names the
34
+ * tooltip's `id` in its own `aria-describedby`, which a reference to the
35
+ * element around it allows. In the
33
36
  * `for` form the tooltip itself is out of the accessibility tree while its
34
37
  * bubble is down, so you meet its words once, as the trigger's description.
35
38
  * A trigger whose name is already the tooltip's words, such as an icon button
@@ -251,21 +251,27 @@ let HelixHeadlessTooltip = class HelixHeadlessTooltip extends MixinControllerOpe
251
251
  if (this.linked) {
252
252
  return;
253
253
  }
254
+ const trigger = this.for === '' ? this.trigger : this.named;
255
+ const names = `${trigger?.getAttribute('aria-describedby') ?? ''} ${trigger?.getAttribute('aria-labelledby') ?? ''}`
256
+ .split(/\s+/)
257
+ .filter(Boolean);
258
+ if (this.id !== '' && names.includes(this.id)) {
259
+ return;
260
+ }
254
261
  if (this.for === '') {
255
262
  if (this.triggerSays(this.describedText)) {
256
263
  return;
257
264
  }
258
265
  reportRuntime({
259
266
  kind: 'wrapped-trigger',
260
- message: `<${this.localName}> is wrapping its trigger rather than naming one, so its text ` +
261
- "reaches no screen reader: a description in this element's shadow root cannot be " +
262
- 'referenced from a trigger in the light DOM. Give this tooltip an `id`, set `for` to ' +
263
- 'the trigger\'s id, and add `aria-describedby="<that id>"` to the trigger.',
267
+ message: `<${this.localName}> opened before its trigger was described, so a screen reader ` +
268
+ 'reading the page meets the trigger without its text. Give this tooltip an `id` and ' +
269
+ 'add `aria-describedby="<that id>"` to the trigger inside it. Written in your own ' +
270
+ 'markup it describes the trigger at rest and survives server rendering.',
264
271
  tag: this.localName,
265
272
  });
266
273
  return;
267
274
  }
268
- const trigger = this.named;
269
275
  if (!trigger) {
270
276
  reportRuntime({
271
277
  kind: 'missing-trigger',
@@ -276,12 +282,6 @@ let HelixHeadlessTooltip = class HelixHeadlessTooltip extends MixinControllerOpe
276
282
  });
277
283
  return;
278
284
  }
279
- const names = `${trigger.getAttribute('aria-describedby') ?? ''} ${trigger.getAttribute('aria-labelledby') ?? ''}`
280
- .split(/\s+/)
281
- .filter(Boolean);
282
- if (this.id !== '' && names.includes(this.id)) {
283
- return;
284
- }
285
285
  reportRuntime({
286
286
  kind: 'unlinked-trigger',
287
287
  message: `<${this.localName} for="${this.for}"> is not read out with its trigger. The link is ` +