@fusion.dev/helix-headless 0.158.0 → 0.159.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 (35) hide show
  1. package/custom-elements.json +674 -35
  2. package/dist/app-header/root/app-header-root.d.ts +59 -12
  3. package/dist/app-header/root/app-header-root.js +68 -29
  4. package/dist/button/root/button-root.d.ts +8 -0
  5. package/dist/button/root/button-root.js +4 -0
  6. package/dist/button-group/root/button-group-root.d.ts +20 -7
  7. package/dist/button-group/root/button-group-root.js +30 -5
  8. package/dist/element-events.generated.js +7 -1
  9. package/dist/lightbox/item/lightbox-item.d.ts +7 -1
  10. package/dist/lightbox/item/lightbox-item.js +15 -2
  11. package/dist/lightbox/root/lightbox-root.d.ts +8 -4
  12. package/dist/lightbox/root/lightbox-root.js +9 -1
  13. package/dist/lightbox/zoom-controller.d.ts +13 -4
  14. package/dist/lightbox/zoom-controller.js +17 -0
  15. package/dist/steps/root/steps-root.js +8 -6
  16. package/dist/steps/step/step.d.ts +9 -0
  17. package/dist/steps/step/step.js +19 -2
  18. package/dist/tabs/panel/tab-panel.d.ts +22 -1
  19. package/dist/tabs/panel/tab-panel.js +22 -1
  20. package/dist/tabs/root/tabs-root.d.ts +21 -5
  21. package/dist/tabs/root/tabs-root.js +24 -13
  22. package/dist/tabs/tab/tab.d.ts +4 -2
  23. package/dist/tabs/tab/tab.js +3 -1
  24. package/dist/toast/item/toast-item.d.ts +27 -8
  25. package/dist/toast/item/toast-item.js +47 -11
  26. package/dist/toast/root/toast-root.d.ts +24 -1
  27. package/dist/toast/root/toast-root.js +54 -0
  28. package/dist/tour/root/tour-root.d.ts +34 -0
  29. package/dist/tour/root/tour-root.js +92 -3
  30. package/dist/tour/stop/tour-stop.d.ts +33 -1
  31. package/dist/tour/stop/tour-stop.js +12 -1
  32. package/lint.json +1 -1
  33. package/package.json +5 -5
  34. package/types/jsx.d.ts +459 -62
  35. package/types/vue.d.ts +100 -14
@@ -1,5 +1,6 @@
1
1
  import { HelixElement } from '@fusion.dev/helix-core';
2
2
  import { type PropertyValues, type TemplateResult } from 'lit';
3
+ declare const HelixHeadlessAppHeader_base: (new (...args: any[]) => import("@fusion.dev/helix-core").MixinControllerOpenInterface) & typeof HelixElement;
3
4
  /**
4
5
  * @summary Page-level banner holding brand, navigation, and actions.
5
6
  *
@@ -26,12 +27,21 @@ import { type PropertyValues, type TemplateResult } from 'lit';
26
27
  *
27
28
  * **Set `fold-below` to fold the middle region on a narrow screen.** Below that
28
29
  * many pixels of the bar's own width, the middle region is hidden behind a
29
- * toggle at the end of the bar, named by `menu-label`. The toggle says whether
30
- * the region is open with `aria-expanded`, and the region opens below the row.
31
- * Escape closes it and puts focus back on the toggle. The custom state
32
- * `folded` is on the host while the bar is that narrow, and `open` while the
33
- * folded region is open. A server serves the bar unfolded, and the browser
34
- * folds it once it has measured it.
30
+ * toggle at the end of the bar, named by `menu-label`, or at its start with
31
+ * `toggle-placement="start"`. The toggle says whether the region is open with
32
+ * `aria-expanded`, and the region opens below the row. The region follows the
33
+ * toggle's region in focus order, so Tab from the toggle at the end goes into
34
+ * the open region. The custom state `folded` is on the host while the bar is
35
+ * that narrow, and `open` while the folded region is open. A server serves the
36
+ * bar unfolded, and the browser folds it once it has measured it.
37
+ *
38
+ * **`open` says whether the folded region is open, and setting it opens or
39
+ * closes the region.** `requestOpen(true)` and `requestOpen(false)` do the same
40
+ * and send `helix-before-open` or `helix-before-close` first, which you can
41
+ * cancel, and `toggleOpen()` asks for the opposite. `toggle` follows every
42
+ * change. The region closes on Escape, which puts focus back on the toggle,
43
+ * when focus moves to something outside the header, and on a press outside
44
+ * it. A bar that widens past `fold-below` closes its region.
35
45
  *
36
46
  * The host is the page's `banner` landmark. Placed inside `main`, `article`,
37
47
  * `aside`, `nav` or `section`, it is a plain box instead, as a native
@@ -53,11 +63,14 @@ import { type PropertyValues, type TemplateResult } from 'lit';
53
63
  * @slot menu-icon - Replaces the glyph inside the toggle that opens a folded middle region.
54
64
  * @slot start - Leading content, usually a wordmark.
55
65
  *
66
+ * @event {HelixBeforeCloseEvent} helix-before-close - The folded region is about to close. Cancelable: cancel it to keep it open.
67
+ * @event {HelixBeforeOpenEvent} helix-before-open - The folded region is about to open. Cancelable: cancel it to keep it closed.
56
68
  * @event {HelixScrolledEvent} helix-scrolled - Fired when the page crosses the
57
69
  * scroll threshold, with the new state. A custom state cannot be observed the
58
70
  * way an attribute can, so listen for this event to follow it. The event uses
59
71
  * the header's own threshold and hysteresis, so you do not need a second scroll
60
72
  * listener.
73
+ * @event {ToggleEvent} toggle - The folded region opened or closed. The event carries `newState` and `oldState`.
61
74
  *
62
75
  * @csspart content - The middle region.
63
76
  * @csspart end - The trailing region.
@@ -71,7 +84,7 @@ import { type PropertyValues, type TemplateResult } from 'lit';
71
84
  * @cssstate open - While the folded middle region is open.
72
85
  * @cssstate scrolled - While the page is scrolled down from the top, which is when the bar lifts off it.
73
86
  */
74
- export declare class HelixHeadlessAppHeader extends HelixElement {
87
+ export declare class HelixHeadlessAppHeader extends HelixHeadlessAppHeader_base {
75
88
  /**
76
89
  * The key this family's words are registered under, its styled tag:
77
90
  * `registerWords('de', { 'helix-app-header': { menuLabel: 'Menü' } })`.
@@ -103,20 +116,37 @@ export declare class HelixHeadlessAppHeader extends HelixElement {
103
116
  * page scrolls up.
104
117
  */
105
118
  hideOnScroll: boolean;
119
+ /** The name of the toggle that opens a folded middle region. */
120
+ menuLabel: string;
106
121
  /** Whether the bar is narrower than `fold-below`, which only the observer sets. */
107
122
  private folded;
108
- /** Whether the folded middle region is open. */
109
- private unfolded;
123
+ /** Whether the folded middle region is open and showing. */
124
+ private get unfolded();
110
125
  /** Watches the bar's own width while `fold-below` is set. */
111
126
  private widths;
112
127
  /** Starts or stops watching the width, according to `fold-below`. */
113
128
  private watchWidth;
114
- /** Folds the middle region where the bar is narrower than `fold-below`. */
129
+ /**
130
+ * Folds the middle region where the bar is narrower than `fold-below`. A bar
131
+ * that widens closes its region, so it starts closed when it folds again;
132
+ * one that narrows keeps an `open` you set.
133
+ */
115
134
  private fold;
116
135
  /** Opens or closes the folded middle region. */
117
136
  private readonly handleToggle;
118
137
  /** Escape in the open region closes it and puts focus back on the toggle. */
119
138
  private readonly handleKeydown;
139
+ /**
140
+ * Closes the open region when focus moves to something outside the header.
141
+ * Focus leaving the window has no `relatedTarget`, and closes nothing.
142
+ */
143
+ private readonly handleFocusOut;
144
+ /** Closes the open region on a press outside the header. */
145
+ private readonly handleOutsidePress;
146
+ /** Whether the outside press is listened for, which is while the region is open. */
147
+ private pressing;
148
+ /** Listens for a press outside while the folded region is open, and only then. */
149
+ private watchPresses;
120
150
  /** Whether the bar is out of the way, under `hide-on-scroll`. */
121
151
  private away;
122
152
  /** Where the page was last scrolled to, to tell down from up. */
@@ -132,13 +162,19 @@ export declare class HelixHeadlessAppHeader extends HelixElement {
132
162
  * or its folded region is open, which a reader is using.
133
163
  */
134
164
  private follow;
135
- /** The name of the toggle that opens a folded middle region. */
136
- menuLabel: string;
137
165
  /**
138
166
  * The id of the element whose scrolling lifts the bar, for an app shell that
139
167
  * scrolls a container instead of the window. Unset is the window.
140
168
  */
141
169
  scroller?: string;
170
+ /**
171
+ * Where the toggle that opens a folded middle region sits: at the end of the
172
+ * bar, after the trailing content, or with `start`, before the leading
173
+ * content.
174
+ *
175
+ * Optional, so the resting bar writes no attribute onto the host.
176
+ */
177
+ togglePlacement?: 'start';
142
178
  private landmark;
143
179
  private scrolled;
144
180
  /** What scroll is listened to now, so a change of `scroller` moves it. */
@@ -187,6 +223,16 @@ export declare class HelixHeadlessAppHeader extends HelixElement {
187
223
  * tier above replaces it by overriding.
188
224
  */
189
225
  protected renderMenuIcon(): TemplateResult;
226
+ /**
227
+ * The row, with the middle region between the leading and the trailing
228
+ * content while the bar is wide, and after both while it is folded.
229
+ *
230
+ * **Folded, the region comes last in the markup**, because it opens below
231
+ * the row and focus order follows the markup. Between the two, as it is
232
+ * drawn on a wide bar, Tab from the toggle at the end left the header and
233
+ * the links it had just revealed were reached only by going back. The grid
234
+ * puts it on its own row either way.
235
+ */
190
236
  protected render(): TemplateResult;
191
237
  }
192
238
  declare global {
@@ -194,3 +240,4 @@ declare global {
194
240
  'helix-headless-app-header': HelixHeadlessAppHeader;
195
241
  }
196
242
  }
243
+ export {};
@@ -4,35 +4,47 @@ 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, elementById, HelixElement, HelixScrolledEvent, hostAria, words, } from '@fusion.dev/helix-core';
7
+ import { customElement, deepActiveElement, deepContains, elementById, HelixElement, HelixScrolledEvent, hostAria, MixinControllerOpen, words, } from '@fusion.dev/helix-core';
8
8
  import { list } from '@fusion.dev/helix-icons';
9
9
  import { html, nothing } from 'lit';
10
10
  import { property, state } from 'lit/decorators.js';
11
11
  import { unsafeSVG } from 'lit/directives/unsafe-svg.js';
12
12
  import { inSectioningContent } from '../sectioning.js';
13
- let HelixHeadlessAppHeader = class HelixHeadlessAppHeader extends HelixElement {
13
+ let HelixHeadlessAppHeader = class HelixHeadlessAppHeader extends MixinControllerOpen(HelixElement) {
14
14
  constructor() {
15
15
  super(...arguments);
16
16
  this.hideOnScroll = false;
17
+ this.menuLabel = 'Menu';
17
18
  this.folded = false;
18
- this.unfolded = false;
19
19
  this.handleToggle = () => {
20
- this.unfolded = !this.unfolded;
20
+ this.toggleOpen();
21
21
  };
22
22
  this.handleKeydown = (event) => {
23
23
  if (event.key !== 'Escape' || !this.unfolded || event.defaultPrevented) {
24
24
  return;
25
25
  }
26
26
  event.preventDefault();
27
- this.unfolded = false;
28
- void this.updateComplete.then(() => this.partNamed('toggle')?.focus());
27
+ if (this.requestOpen(false)) {
28
+ void this.updateComplete.then(() => this.partNamed('toggle')?.focus());
29
+ }
30
+ };
31
+ this.handleFocusOut = (event) => {
32
+ const next = event.relatedTarget;
33
+ if (this.unfolded && next instanceof Node && !deepContains(this, next)) {
34
+ this.requestOpen(false);
35
+ }
29
36
  };
37
+ this.handleOutsidePress = (event) => {
38
+ if (this.unfolded && !event.composedPath().includes(this)) {
39
+ this.requestOpen(false);
40
+ }
41
+ };
42
+ this.pressing = false;
30
43
  this.away = false;
31
44
  this.lastY = 0;
32
45
  this.handleFocusIn = () => {
33
46
  this.hide(false);
34
47
  };
35
- this.menuLabel = 'Menu';
36
48
  this.landmark = 'banner';
37
49
  this.scrolled = false;
38
50
  this.handleScroll = () => {
@@ -49,6 +61,9 @@ let HelixHeadlessAppHeader = class HelixHeadlessAppHeader extends HelixElement {
49
61
  };
50
62
  }
51
63
  static { this.words = 'helix-app-header'; }
64
+ get unfolded() {
65
+ return this.folded && this.open;
66
+ }
52
67
  watchWidth() {
53
68
  if (!this.foldBelow || typeof ResizeObserver === 'undefined') {
54
69
  this.widths?.disconnect();
@@ -71,7 +86,22 @@ let HelixHeadlessAppHeader = class HelixHeadlessAppHeader extends HelixElement {
71
86
  const folded = this.foldBelow !== undefined && width < this.foldBelow;
72
87
  if (folded !== this.folded) {
73
88
  this.folded = folded;
74
- this.unfolded = false;
89
+ if (!folded && this.open) {
90
+ this.open = false;
91
+ }
92
+ }
93
+ }
94
+ watchPresses() {
95
+ const want = this.unfolded && this.isConnected;
96
+ if (want === this.pressing) {
97
+ return;
98
+ }
99
+ this.pressing = want;
100
+ if (want) {
101
+ document.addEventListener('pointerdown', this.handleOutsidePress, true);
102
+ }
103
+ else {
104
+ document.removeEventListener('pointerdown', this.handleOutsidePress, true);
75
105
  }
76
106
  }
77
107
  hide(away) {
@@ -119,7 +149,7 @@ let HelixHeadlessAppHeader = class HelixHeadlessAppHeader extends HelixElement {
119
149
  super.willUpdate(changed);
120
150
  hostAria(this.internals, { role: this.landmark });
121
151
  this.toggleState('folded', this.folded);
122
- this.toggleState('open', this.folded && this.unfolded);
152
+ this.toggleState('open', this.unfolded);
123
153
  }
124
154
  connectedCallback() {
125
155
  super.connectedCallback();
@@ -131,9 +161,11 @@ let HelixHeadlessAppHeader = class HelixHeadlessAppHeader extends HelixElement {
131
161
  });
132
162
  this.listen();
133
163
  this.addEventListener('focusin', this.handleFocusIn);
164
+ this.addEventListener('focusout', this.handleFocusOut);
134
165
  this.addEventListener('keydown', this.handleKeydown);
135
166
  if (this.hasUpdated) {
136
167
  this.watchWidth();
168
+ this.watchPresses();
137
169
  }
138
170
  }
139
171
  readLandmark() {
@@ -145,9 +177,11 @@ let HelixHeadlessAppHeader = class HelixHeadlessAppHeader extends HelixElement {
145
177
  this.source?.removeEventListener('scroll', this.handleScroll);
146
178
  this.source = undefined;
147
179
  this.removeEventListener('focusin', this.handleFocusIn);
180
+ this.removeEventListener('focusout', this.handleFocusOut);
148
181
  this.removeEventListener('keydown', this.handleKeydown);
149
182
  this.widths?.disconnect();
150
183
  this.widths = undefined;
184
+ this.watchPresses();
151
185
  }
152
186
  firstUpdated(changed) {
153
187
  super.firstUpdated(changed);
@@ -170,6 +204,7 @@ let HelixHeadlessAppHeader = class HelixHeadlessAppHeader extends HelixElement {
170
204
  if (changed.has('hideOnScroll') && !this.hideOnScroll) {
171
205
  this.hide(false);
172
206
  }
207
+ this.watchPresses();
173
208
  }
174
209
  renderMenuIcon() {
175
210
  return html `<slot name="menu-icon">
@@ -179,22 +214,26 @@ let HelixHeadlessAppHeader = class HelixHeadlessAppHeader extends HelixElement {
179
214
  </slot>`;
180
215
  }
181
216
  render() {
217
+ const toggle = this.folded
218
+ ? html `<button
219
+ part="toggle"
220
+ type="button"
221
+ aria-controls="content"
222
+ aria-expanded=${this.unfolded ? 'true' : 'false'}
223
+ aria-label=${this.menuLabel}
224
+ @click=${this.handleToggle}
225
+ >
226
+ ${this.renderMenuIcon()}
227
+ </button>`
228
+ : nothing;
229
+ const first = this.togglePlacement === 'start';
230
+ const content = html `<div part="content" id="content" ?hidden=${this.folded && !this.unfolded}><slot></slot></div>`;
182
231
  return html `
183
232
  <div part="row">
184
- <div part="start"><slot name="start"></slot></div>
185
- <div part="content" id="content" ?hidden=${this.folded && !this.unfolded}><slot></slot></div>
186
- <div part="end"><slot name="end"></slot>${this.folded
187
- ? html `<button
188
- part="toggle"
189
- type="button"
190
- aria-controls="content"
191
- aria-expanded=${this.unfolded ? 'true' : 'false'}
192
- aria-label=${this.menuLabel}
193
- @click=${this.handleToggle}
194
- >
195
- ${this.renderMenuIcon()}
196
- </button>`
197
- : nothing}</div>
233
+ <div part="start">${first ? toggle : nothing}<slot name="start"></slot></div>
234
+ ${this.folded ? nothing : content}
235
+ <div part="end"><slot name="end"></slot>${first ? nothing : toggle}</div>
236
+ ${this.folded ? content : nothing}
198
237
  </div>
199
238
  `;
200
239
  }
@@ -208,19 +247,19 @@ __decorate([
208
247
  __decorate([
209
248
  property({ attribute: 'hide-on-scroll', type: Boolean })
210
249
  ], HelixHeadlessAppHeader.prototype, "hideOnScroll", void 0);
211
- __decorate([
212
- state()
213
- ], HelixHeadlessAppHeader.prototype, "folded", void 0);
214
- __decorate([
215
- state()
216
- ], HelixHeadlessAppHeader.prototype, "unfolded", void 0);
217
250
  __decorate([
218
251
  words,
219
252
  property({ attribute: 'menu-label' })
220
253
  ], HelixHeadlessAppHeader.prototype, "menuLabel", void 0);
254
+ __decorate([
255
+ state()
256
+ ], HelixHeadlessAppHeader.prototype, "folded", void 0);
221
257
  __decorate([
222
258
  property()
223
259
  ], HelixHeadlessAppHeader.prototype, "scroller", void 0);
260
+ __decorate([
261
+ property({ attribute: 'toggle-placement', reflect: true })
262
+ ], HelixHeadlessAppHeader.prototype, "togglePlacement", void 0);
224
263
  HelixHeadlessAppHeader = __decorate([
225
264
  customElement('helix-headless-app-header')
226
265
  ], HelixHeadlessAppHeader);
@@ -57,6 +57,7 @@ import { nothing, type PropertyValues, type TemplateResult } from 'lit';
57
57
  * @csspart text - The button's words, which the styled button trims to their capitals and baseline so the letters sit at the middle in any font.
58
58
  *
59
59
  * @cssstate pending - The button is pending: `pending` is set.
60
+ * @cssstate pressed - A Button Group in a selection mode has this button pressed.
60
61
  */
61
62
  export declare class HelixHeadlessButton extends HelixFormElement {
62
63
  /**
@@ -206,6 +207,13 @@ export declare class HelixHeadlessButton extends HelixFormElement {
206
207
  * reported collapsed and expanded and no popup.
207
208
  */
208
209
  popup?: 'dialog' | 'grid' | 'listbox' | 'menu' | 'tree';
210
+ /**
211
+ * @internal Set by a Button Group in a selection mode: whether the group has
212
+ * this button pressed. Recorded as `:state(pressed)`, so a page can style a
213
+ * pressed button from its own element. The group says it to screen readers
214
+ * on the control.
215
+ */
216
+ pressed?: boolean | undefined;
209
217
  /**
210
218
  * The status region, rendered from the first time the button is pending, so
211
219
  * a button that never waits carries no live region.
@@ -140,6 +140,7 @@ let HelixHeadlessButton = class HelixHeadlessButton extends HelixFormElement {
140
140
  willUpdate(changed) {
141
141
  super.willUpdate(changed);
142
142
  this.toggleState('pending', this.pending);
143
+ this.toggleState('pressed', this.pressed === true);
143
144
  }
144
145
  updated(changed) {
145
146
  super.updated(changed);
@@ -238,6 +239,9 @@ __decorate([
238
239
  __decorate([
239
240
  property({ attribute: false })
240
241
  ], HelixHeadlessButton.prototype, "popup", void 0);
242
+ __decorate([
243
+ property({ attribute: false })
244
+ ], HelixHeadlessButton.prototype, "pressed", void 0);
241
245
  __decorate([
242
246
  property()
243
247
  ], HelixHeadlessButton.prototype, "type", void 0);
@@ -16,11 +16,15 @@ import { type PropertyValues, type TemplateResult } from 'lit';
16
16
  * appearance and size.
17
17
  *
18
18
  * **Buttons that stay pressed.** With `selection-mode`, each button with a
19
- * `value` is a toggle button: a press presses or releases it, and it says so
20
- * with `aria-pressed`. `single` keeps one pressed at a time, and a press on the
21
- * pressed one releases it. `multiple` lets any number be pressed. `value` is
22
- * the pressed values, comma separated. A press sends a cancelable
23
- * `helix-before-change` with the value it would leave, then `change`.
19
+ * `value` is a toggle button: a press presses or releases it. `multiple` lets
20
+ * any number be pressed, and each button says so with `aria-pressed`. `single`
21
+ * keeps one pressed at a time: the group is a `radiogroup` and each button a
22
+ * `radio` that says so with `aria-checked`, as React Aria's toggle button
23
+ * group is. A press on the pressed one releases it, unless
24
+ * `disallow-empty-selection` keeps one pressed. `value` is the pressed values,
25
+ * comma separated. A press sends a cancelable `helix-before-change` with the
26
+ * value it would leave, then `change`. A pressed Helix button has
27
+ * `:state(pressed)` on its own element.
24
28
  *
25
29
  * The pressed state is written on the control inside each button's shadow
26
30
  * root, so the markup you rendered is never changed and a framework hydrating
@@ -48,10 +52,10 @@ import { type PropertyValues, type TemplateResult } from 'lit';
48
52
  * | Space, Enter | Presses or releases the focused button. |
49
53
  *
50
54
  * Left and Right swap on a right-to-left page, and a disabled button is
51
- * passed over. In a selection mode the group is a `toolbar`, with
55
+ * passed over. Under `multiple` the group is a `toolbar`, with
52
56
  * `aria-orientation` under `orientation="vertical"`. Inside a toolbar it stays
53
57
  * a named `group`, and the toolbar's own arrow keys walk its buttons, so a
54
- * reader meets one toolbar.
58
+ * reader meets one toolbar. Under `single` it is a `radiogroup` either way.
55
59
  *
56
60
  * @since 0.1.0
57
61
  * @status stable
@@ -69,6 +73,12 @@ import { type PropertyValues, type TemplateResult } from 'lit';
69
73
  * @cssstate pressed - While a group with `selection-mode` has a button pressed.
70
74
  */
71
75
  export declare class HelixHeadlessButtonGroup extends HelixElement {
76
+ /**
77
+ * Keeps at least one button pressed once one is, as React Aria's
78
+ * `disallowEmptySelection`: a press that would leave none pressed is
79
+ * refused.
80
+ */
81
+ disallowEmptySelection: boolean;
72
82
  /**
73
83
  * The group's name, read by screen readers.
74
84
  *
@@ -172,6 +182,9 @@ export declare class HelixHeadlessButtonGroup extends HelixElement {
172
182
  * Says on a button's control whether it is pressed, or that it is no toggle
173
183
  * at all where `pressed` is undefined. A styled tier draws the look here.
174
184
  *
185
+ * Under `single` the control is a `radio` that says so with `aria-checked`,
186
+ * and under `multiple` a toggle button that says so with `aria-pressed`.
187
+ *
175
188
  * @param control The focusable control inside the button's shadow root.
176
189
  * @param pressed Whether the button is pressed, or undefined where it is not a toggle.
177
190
  */
@@ -12,6 +12,7 @@ const isGroupButton = (child) => child instanceof HTMLElement && 'focusable' in
12
12
  let HelixHeadlessButtonGroup = class HelixHeadlessButtonGroup extends HelixElement {
13
13
  constructor() {
14
14
  super(...arguments);
15
+ this.disallowEmptySelection = false;
15
16
  this.label = '';
16
17
  this.value = '';
17
18
  this.lastFocused = undefined;
@@ -21,8 +22,12 @@ let HelixHeadlessButtonGroup = class HelixHeadlessButtonGroup extends HelixEleme
21
22
  const mode = this.selectionMode;
22
23
  const button = this.buttons.find((one) => event.composedPath().includes(one));
23
24
  if (mode && button && button.value !== '') {
24
- requestValue(this, pressedValue(mode, this.value, button.value), (next) => {
25
- this.value = next;
25
+ const next = pressedValue(mode, this.value, button.value);
26
+ if (this.disallowEmptySelection && commaList(next).length === 0) {
27
+ return;
28
+ }
29
+ requestValue(this, next, (value) => {
30
+ this.value = value;
26
31
  });
27
32
  }
28
33
  };
@@ -107,17 +112,30 @@ let HelixHeadlessButtonGroup = class HelixHeadlessButtonGroup extends HelixEleme
107
112
  const control = button.shadowRoot?.querySelector('button');
108
113
  if (control && !button.hasAttribute('aria-pressed')) {
109
114
  const toggle = this.told.has(button) && button.value !== '';
110
- this.markPressed(control, toggle ? this.pressed(button) : undefined);
115
+ const pressed = toggle ? this.pressed(button) : undefined;
116
+ if ('pressed' in button && button.pressed !== pressed) {
117
+ button.pressed = pressed;
118
+ }
119
+ this.markPressed(control, pressed);
111
120
  }
112
121
  });
113
122
  }
114
123
  markPressed(control, pressed) {
115
- if (pressed === undefined) {
124
+ const radio = pressed !== undefined && this.selectionMode === 'single';
125
+ if (pressed === undefined || radio) {
116
126
  control.removeAttribute('aria-pressed');
117
127
  }
118
128
  else {
119
129
  control.setAttribute('aria-pressed', String(pressed));
120
130
  }
131
+ if (radio) {
132
+ control.setAttribute('role', 'radio');
133
+ control.setAttribute('aria-checked', String(pressed));
134
+ }
135
+ else {
136
+ control.removeAttribute('role');
137
+ control.removeAttribute('aria-checked');
138
+ }
121
139
  control.style.setProperty('--button-group-pressed', pressed === undefined ? '' : pressed ? '1' : '0');
122
140
  }
123
141
  connectedCallback() {
@@ -143,7 +161,11 @@ let HelixHeadlessButtonGroup = class HelixHeadlessButtonGroup extends HelixEleme
143
161
  willUpdate(changed) {
144
162
  super.willUpdate(changed);
145
163
  const toolbar = this.roving;
146
- hostAria(this.internals, { label: this.label, role: toolbar ? 'toolbar' : 'group' });
164
+ const radios = this.selectionMode === 'single';
165
+ hostAria(this.internals, {
166
+ label: this.label,
167
+ role: radios ? 'radiogroup' : toolbar ? 'toolbar' : 'group',
168
+ });
147
169
  this.internals.ariaOrientation = toolbar ? (this.orientation ?? null) : null;
148
170
  this.toggleState('pressed', Boolean(this.selectionMode) && commaList(this.value).length > 0);
149
171
  }
@@ -159,6 +181,9 @@ let HelixHeadlessButtonGroup = class HelixHeadlessButtonGroup extends HelixEleme
159
181
  return html `<slot></slot>`;
160
182
  }
161
183
  };
184
+ __decorate([
185
+ property({ attribute: 'disallow-empty-selection', type: Boolean })
186
+ ], HelixHeadlessButtonGroup.prototype, "disallowEmptySelection", void 0);
162
187
  __decorate([
163
188
  property()
164
189
  ], HelixHeadlessButtonGroup.prototype, "label", void 0);
@@ -2,7 +2,12 @@ import { registerElementEvents } from '@fusion.dev/helix-core';
2
2
  registerElementEvents({
3
3
  'helix-headless-accordion-item': ['helix-before-close', 'helix-before-open', 'toggle'],
4
4
  'helix-headless-announcement': ['helix-dismiss'],
5
- 'helix-headless-app-header': ['helix-scrolled'],
5
+ 'helix-headless-app-header': [
6
+ 'helix-before-close',
7
+ 'helix-before-open',
8
+ 'helix-scrolled',
9
+ 'toggle',
10
+ ],
6
11
  'helix-headless-area-chart': ['helix-reading-change', 'helix-reading-press'],
7
12
  'helix-headless-avatar': ['error', 'load'],
8
13
  'helix-headless-bar-chart': ['helix-reading-change', 'helix-reading-press'],
@@ -244,6 +249,7 @@ registerElementEvents({
244
249
  'helix-headless-steps': ['helix-activate', 'helix-before-activate', 'helix-select'],
245
250
  'helix-headless-switch': ['change', 'helix-before-toggle', 'input'],
246
251
  'helix-headless-tab': ['helix-dismiss', 'helix-select'],
252
+ 'helix-headless-tab-panel': ['helix-load'],
247
253
  'helix-headless-table': [
248
254
  'helix-before-reorder',
249
255
  'helix-reorder',
@@ -11,7 +11,8 @@ import { type PropertyValues, type TemplateResult } from 'lit';
11
11
  * Each item is a `group` with the role description `slide`, named by its
12
12
  * `label` or, without one, by its place in the set, such as "2 of 5", as the
13
13
  * APG carousel pattern names a slide. The lightbox supplies both words, so they
14
- * are translated on the lightbox.
14
+ * are translated on the lightbox. A `description`, while there is one,
15
+ * describes the group, so a screen reader says it with the name.
15
16
  *
16
17
  * An item that is not current is hidden and stays in the DOM, as a `TabPanel`
17
18
  * does. Crawlers can read it, it is in the server-rendered markup, and an
@@ -56,6 +57,11 @@ export declare class HelixHeadlessLightboxItem extends HelixElement {
56
57
  * The item's identifier. The root compares its `active` value against it.
57
58
  */
58
59
  value: string;
60
+ /** Whether anything is slotted into `description`, which then describes the group. */
61
+ private described;
62
+ /** Reads whether the `description` slot holds anything. */
63
+ private readonly readDescription;
64
+ protected firstUpdated(changed: PropertyValues): void;
59
65
  /**
60
66
  * Mirrors the shown state where a stylesheet can select on it.
61
67
  *
@@ -6,7 +6,7 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
6
6
  };
7
7
  import { customElement, HelixElement } from '@fusion.dev/helix-core';
8
8
  import { html, nothing } from 'lit';
9
- import { property } from 'lit/decorators.js';
9
+ import { property, state } from 'lit/decorators.js';
10
10
  let HelixHeadlessLightboxItem = class HelixHeadlessLightboxItem extends HelixElement {
11
11
  constructor() {
12
12
  super(...arguments);
@@ -15,6 +15,15 @@ let HelixHeadlessLightboxItem = class HelixHeadlessLightboxItem extends HelixEle
15
15
  this.position = '';
16
16
  this.roleLabel = '';
17
17
  this.value = '';
18
+ this.described = false;
19
+ this.readDescription = () => {
20
+ const slot = this.renderRoot.querySelector('slot[name="description"]');
21
+ this.described = (slot?.assignedNodes() ?? []).some((node) => node.nodeType === Node.ELEMENT_NODE || (node.textContent ?? '').trim() !== '');
22
+ };
23
+ }
24
+ firstUpdated(changed) {
25
+ super.firstUpdated(changed);
26
+ this.readDescription();
18
27
  }
19
28
  willUpdate(changed) {
20
29
  super.willUpdate(changed);
@@ -26,10 +35,11 @@ let HelixHeadlessLightboxItem = class HelixHeadlessLightboxItem extends HelixEle
26
35
  role="group"
27
36
  aria-roledescription=${this.roleLabel || nothing}
28
37
  aria-label=${this.label || this.position || nothing}
38
+ aria-describedby=${this.described ? 'description' : nothing}
29
39
  ?hidden=${!this.current}
30
40
  >
31
41
  <slot></slot>
32
- <slot name="description"></slot>
42
+ <slot id="description" name="description" @slotchange=${this.readDescription}></slot>
33
43
  </div>`;
34
44
  }
35
45
  };
@@ -48,6 +58,9 @@ __decorate([
48
58
  __decorate([
49
59
  property()
50
60
  ], HelixHeadlessLightboxItem.prototype, "value", void 0);
61
+ __decorate([
62
+ state()
63
+ ], HelixHeadlessLightboxItem.prototype, "described", void 0);
51
64
  HelixHeadlessLightboxItem = __decorate([
52
65
  customElement('helix-headless-lightbox-item')
53
66
  ], HelixHeadlessLightboxItem);
@@ -1,4 +1,4 @@
1
- import { CommandController, FocusScopeController, HelixElement, SwipeController } from '@fusion.dev/helix-core';
1
+ import { CommandController, FocusScopeController, HelixElement, OpenPhaseController, SwipeController } from '@fusion.dev/helix-core';
2
2
  import { type PropertyValues, type TemplateResult } from 'lit';
3
3
  declare const HelixHeadlessLightbox_base: (new (...args: any[]) => import("@fusion.dev/helix-core").MixinControllerActiveInterface) & (new (...args: any[]) => import("@fusion.dev/helix-core").MixinControllerOpenInterface) & typeof HelixElement;
4
4
  /**
@@ -25,9 +25,10 @@ declare const HelixHeadlessLightbox_base: (new (...args: any[]) => import("@fusi
25
25
  * buttons that send `--previous`, `--next` and `--close`.
26
26
  *
27
27
  * **Set `max-zoom` to let a reader zoom into the shown item**, such as `3`.
28
- * Two fingers pinch it, a double click or a double tap zooms all the way in
29
- * about the point pressed and back out, and once zoomed one pointer drags it
30
- * about. `+` and `-` zoom in and out from the keyboard, and `0` puts it back.
28
+ * Two fingers pinch it, and so does a trackpad pinch or the wheel with Ctrl
29
+ * held, about the point under the pointer. A double click or a double tap
30
+ * zooms all the way in about the point pressed and back out, and once zoomed
31
+ * one pointer drags it about and the wheel moves it. `+` and `-` zoom in and out from the keyboard, and `0` puts it back.
31
32
  * The scale and the offset are on the `viewport` part as `--lightbox-zoom`,
32
33
  * `--lightbox-pan-x` and `--lightbox-pan-y`, written only while zoomed, and a
33
34
  * swipe moves to the next item only while the item is at its own size. With
@@ -109,6 +110,8 @@ declare const HelixHeadlessLightbox_base: (new (...args: any[]) => import("@fusi
109
110
  * @cssproperty --lightbox-pan-y - How far the zoomed item is moved down from the middle of the viewport, in pixels. Written only while zoomed.
110
111
  * @cssproperty --lightbox-zoom - The scale the shown item is zoomed to, such as `2`. Written only while zoomed.
111
112
  *
113
+ * @cssstate entering - From the moment the viewer opens until its entrance has finished.
114
+ * @cssstate exiting - From the moment the viewer closes until its exit has finished.
112
115
  * @cssstate fullscreen - While the viewer fills the screen.
113
116
  * @cssstate idle - The user has been still long enough for the controls to hide.
114
117
  * @cssstate open - While the viewer is open.
@@ -225,6 +228,7 @@ export declare class HelixHeadlessLightbox extends HelixHeadlessLightbox_base {
225
228
  private quietZoom;
226
229
  protected readonly swipes: SwipeController;
227
230
  protected readonly scope: FocusScopeController;
231
+ protected readonly phases: OpenPhaseController;
228
232
  private readonly zoomer;
229
233
  /** Writes the zoom on the viewport, and nothing at the item's own size. */
230
234
  private drawZoom;