@fusion.dev/helix-headless 0.151.0 → 0.152.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 (41) hide show
  1. package/custom-elements.json +648 -28
  2. package/dist/app-header/root/app-header-root.d.ts +75 -0
  3. package/dist/app-header/root/app-header-root.js +140 -5
  4. package/dist/breadcrumbs/breadcrumb/breadcrumb.d.ts +11 -2
  5. package/dist/breadcrumbs/breadcrumb/breadcrumb.js +11 -2
  6. package/dist/breadcrumbs/root/breadcrumbs-root.d.ts +14 -2
  7. package/dist/breadcrumbs/root/breadcrumbs-root.js +16 -3
  8. package/dist/dialog/description.d.ts +10 -0
  9. package/dist/dialog/description.js +22 -8
  10. package/dist/dialog/root/dialog-root.d.ts +17 -1
  11. package/dist/dialog/root/dialog-root.js +2 -2
  12. package/dist/drawer/root/drawer-root.d.ts +38 -2
  13. package/dist/drawer/root/drawer-root.js +33 -6
  14. package/dist/lightbox/root/lightbox-root.d.ts +44 -6
  15. package/dist/lightbox/root/lightbox-root.js +59 -11
  16. package/dist/lightbox/zoom-controller.d.ts +5 -0
  17. package/dist/lightbox/zoom-controller.js +8 -0
  18. package/dist/menu/root/menu-root.js +2 -0
  19. package/dist/navigation/root/navigation-root.d.ts +11 -1
  20. package/dist/navigation/root/navigation-root.js +10 -5
  21. package/dist/notifications/root/notifications-root.d.ts +17 -0
  22. package/dist/notifications/root/notifications-root.js +21 -1
  23. package/dist/popover/root/popover-root.d.ts +18 -7
  24. package/dist/popover/root/popover-root.js +8 -1
  25. package/dist/splitter/root/splitter-root.d.ts +41 -0
  26. package/dist/splitter/root/splitter-root.js +77 -0
  27. package/dist/tabs/root/tabs-root.d.ts +25 -0
  28. package/dist/tabs/root/tabs-root.js +28 -1
  29. package/dist/tour/naming.d.ts +9 -3
  30. package/dist/tour/naming.js +9 -3
  31. package/dist/tour/root/tour-root.d.ts +29 -5
  32. package/dist/tour/root/tour-root.js +13 -1
  33. package/dist/tour/stop/tour-stop.d.ts +2 -1
  34. package/dist/tour/stop/tour-stop.js +4 -1
  35. package/dist/tree/root/tree-root.d.ts +13 -0
  36. package/dist/tree/root/tree-root.js +42 -2
  37. package/lint.json +1 -1
  38. package/package.json +5 -5
  39. package/types/jsx.d.ts +450 -30
  40. package/types/vue.d.ts +53 -16
  41. package/types/words.d.ts +8 -0
@@ -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 { AriaForwardController, anchorElement, arrowReach, CommandController, customElement, deepActiveElement, FocusScopeController, HelixElement, headingIn, MixinControllerOpen, OpenPhaseController, PopoverController, reflectElements, sideOf, tellDisclosure, words, } from '@fusion.dev/helix-core';
7
+ import { AriaForwardController, anchorElement, arrowReach, auditName, CommandController, customElement, deepActiveElement, FocusScopeController, HelixElement, headingIn, MixinControllerOpen, OpenPhaseController, PopoverController, reflectElements, sideOf, tellDisclosure, words, } from '@fusion.dev/helix-core';
8
8
  import { html, nothing } from 'lit';
9
9
  import { property, state } from 'lit/decorators.js';
10
10
  import { hideOutside } from '../hide-outside.js';
@@ -144,6 +144,10 @@ let HelixHeadlessPopover = class HelixHeadlessPopover extends MixinControllerOpe
144
144
  super.connectedCallback();
145
145
  this.addEventListener('focusout', this.handleFocusOut);
146
146
  this.addEventListener('click', this.notePress, { capture: true });
147
+ void this.whenSettled.then(() => {
148
+ this.pressed = true;
149
+ this.tellTrigger();
150
+ });
147
151
  }
148
152
  disconnectedCallback() {
149
153
  super.disconnectedCallback();
@@ -180,6 +184,9 @@ let HelixHeadlessPopover = class HelixHeadlessPopover extends MixinControllerOpe
180
184
  super.updated(changed);
181
185
  this.tellTrigger();
182
186
  this.nameByHeading();
187
+ if (changed.has('open') && this.open) {
188
+ auditName(this, () => null, 'Give it a `label`, or a heading in the panel.', () => this.label !== '' || this.namedByHeading || headingIn(this.slotted) != null);
189
+ }
183
190
  if (this.settled) {
184
191
  this.hideAround();
185
192
  }
@@ -30,6 +30,21 @@ import { type PropertyValues, type TemplateResult } from 'lit';
30
30
  * `collapsed-size` keeps a sliver of the pane visible, such as a tab strip.
31
31
  * Collapsing overrides `min` and `max`.
32
32
  *
33
+ * Set `primary` to the pane that keeps its size when the splitter's own size
34
+ * changes, such as a sidebar beside a window being resized. The other pane
35
+ * takes all of the change, as Web Awesome's split panel does. Without it,
36
+ * both panes keep their share.
37
+ *
38
+ * **Keyboard.** The divider is one tab stop.
39
+ *
40
+ * | Key | What it does |
41
+ * |---|---|
42
+ * | Left, Right | Moves a side-by-side divider by `step`. On a right-to-left page, Left moves it toward the end. |
43
+ * | Up, Down | Moves a stacked divider by `step`. |
44
+ * | Home, End | Moves the divider to its bounds. |
45
+ * | Enter | Collapses the start pane, and opens it again. |
46
+ * | F6 | Moves focus from one pane to the first control in the other, and from the divider to the start pane. |
47
+ *
33
48
  * @since 0.1.0
34
49
  * @status stable
35
50
  * @category layout
@@ -191,6 +206,32 @@ export declare class HelixHeadlessSplitter extends HelixElement {
191
206
  */
192
207
  get position(): number;
193
208
  set position(next: number);
209
+ /**
210
+ * The pane that keeps its size when the splitter's own size changes. The
211
+ * other pane takes all of the change. Unset, both keep their share.
212
+ */
213
+ primary?: 'end' | 'start';
214
+ /** Watches this element's length along its axis while `primary` is set. */
215
+ private lengths;
216
+ /** The length along the axis last seen, in pixels. */
217
+ private length;
218
+ /**
219
+ * Starts or stops watching the length, according to `primary`, and holds
220
+ * the primary pane's size in pixels as the length changes.
221
+ */
222
+ private watchLength;
223
+ /**
224
+ * Moves the divider so the primary pane keeps the pixels it had, as the
225
+ * splitter's length goes from the one last seen to `length`. Not while a
226
+ * pane is shut or a drag holds the divider.
227
+ */
228
+ private holdPrimary;
229
+ /**
230
+ * F6, which moves focus between the panes, as the window splitter pattern
231
+ * allows: from the start pane to the end pane, and from the end pane or the
232
+ * divider to the start pane. Only a pane that holds a control takes focus.
233
+ */
234
+ private readonly handlePaneKeys;
194
235
  /**
195
236
  * Where a dragged divider snaps, comma separated, as Web Awesome's split
196
237
  * panel does: a bare number is a percentage and a length such as `240px` is
@@ -11,6 +11,7 @@ import { property, state } from 'lit/decorators.js';
11
11
  import { snapTo } from '../snap.js';
12
12
  const STACK_BELOW = 512;
13
13
  const channel = new WeakMap();
14
+ const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
14
15
  let HelixHeadlessSplitter = class HelixHeadlessSplitter extends HelixElement {
15
16
  static { HelixHeadlessSplitter_1 = this; }
16
17
  static { this.words = 'helix-splitter'; }
@@ -69,6 +70,41 @@ let HelixHeadlessSplitter = class HelixHeadlessSplitter extends HelixElement {
69
70
  this.reached = undefined;
70
71
  this.requestUpdate('position', previous);
71
72
  }
73
+ watchLength() {
74
+ if (!this.primary || typeof ResizeObserver === 'undefined') {
75
+ this.lengths?.disconnect();
76
+ this.lengths = undefined;
77
+ this.length = 0;
78
+ return;
79
+ }
80
+ if (this.lengths) {
81
+ return;
82
+ }
83
+ this.lengths = new ResizeObserver(([entry]) => {
84
+ const box = entry?.borderBoxSize?.[0];
85
+ const length = (this.vertical
86
+ ? (box?.blockSize ?? entry?.contentRect.height)
87
+ : (box?.inlineSize ?? entry?.contentRect.width)) ?? 0;
88
+ this.holdPrimary(length);
89
+ });
90
+ this.lengths.observe(this);
91
+ }
92
+ holdPrimary(length) {
93
+ const before = this.length;
94
+ this.length = length;
95
+ if (!this.primary || before <= 0 || length <= 0 || length === before) {
96
+ return;
97
+ }
98
+ if (this.collapsed || this.dragging) {
99
+ return;
100
+ }
101
+ const position = this.position;
102
+ const kept = ((this.primary === 'start' ? position : 100 - position) / 100) * before;
103
+ const share = (kept / length) * 100;
104
+ const previous = position;
105
+ this.positionState = this.clamp(this.primary === 'start' ? share : 100 - share);
106
+ this.requestUpdate('position', previous);
107
+ }
72
108
  get showing() {
73
109
  return this.reached === undefined ? this.position : this.clamp(this.reached);
74
110
  }
@@ -225,6 +261,34 @@ let HelixHeadlessSplitter = class HelixHeadlessSplitter extends HelixElement {
225
261
  });
226
262
  this.max = 100;
227
263
  this.min = 0;
264
+ this.length = 0;
265
+ this.handlePaneKeys = (event) => {
266
+ if (event.key !== 'F6' ||
267
+ event.altKey ||
268
+ event.ctrlKey ||
269
+ event.metaKey ||
270
+ event.defaultPrevented) {
271
+ return;
272
+ }
273
+ const path = event.composedPath();
274
+ const pane = (name) => this.renderRoot
275
+ ?.querySelector(`slot[name="${name}"]`)
276
+ ?.assignedElements({ flatten: true }) ?? [];
277
+ const inner = path.find((node) => node instanceof HelixHeadlessSplitter_1);
278
+ if (inner !== this) {
279
+ return;
280
+ }
281
+ const fromStart = pane('start').some((one) => path.includes(one));
282
+ const to = pane(fromStart ? 'end' : 'start');
283
+ const first = to.flatMap((one) => [
284
+ ...(one instanceof HTMLElement && one.matches(FOCUSABLE) ? [one] : []),
285
+ ...one.querySelectorAll(FOCUSABLE),
286
+ ])[0];
287
+ if (first) {
288
+ event.preventDefault();
289
+ first.focus();
290
+ }
291
+ };
228
292
  this.snap = '';
229
293
  this.snapThreshold = 12;
230
294
  this.step = 2;
@@ -435,13 +499,19 @@ let HelixHeadlessSplitter = class HelixHeadlessSplitter extends HelixElement {
435
499
  super.connectedCallback();
436
500
  if (this.hasUpdated) {
437
501
  this.watchWidth();
502
+ this.watchLength();
438
503
  this.findOuter();
439
504
  }
505
+ this.addEventListener('keydown', this.handlePaneKeys);
440
506
  }
441
507
  disconnectedCallback() {
442
508
  super.disconnectedCallback();
509
+ this.removeEventListener('keydown', this.handlePaneKeys);
443
510
  this.sizes?.disconnect();
444
511
  this.sizes = undefined;
512
+ this.lengths?.disconnect();
513
+ this.lengths = undefined;
514
+ this.length = 0;
445
515
  if (this.outer) {
446
516
  channel.get(this.outer)?.uncross(this);
447
517
  }
@@ -451,6 +521,7 @@ let HelixHeadlessSplitter = class HelixHeadlessSplitter extends HelixElement {
451
521
  firstUpdated(changed) {
452
522
  super.firstUpdated(changed);
453
523
  this.watchWidth();
524
+ this.watchLength();
454
525
  this.findOuter();
455
526
  this.finishHydration();
456
527
  }
@@ -459,6 +530,9 @@ let HelixHeadlessSplitter = class HelixHeadlessSplitter extends HelixElement {
459
530
  if (changed.has('orientation')) {
460
531
  this.watchWidth();
461
532
  }
533
+ if (changed.has('primary')) {
534
+ this.watchLength();
535
+ }
462
536
  if (this.collapsed) {
463
537
  this.dataset.collapsed = this.collapsed;
464
538
  }
@@ -578,6 +652,9 @@ __decorate([
578
652
  __decorate([
579
653
  property({ type: Number })
580
654
  ], HelixHeadlessSplitter.prototype, "position", null);
655
+ __decorate([
656
+ property()
657
+ ], HelixHeadlessSplitter.prototype, "primary", void 0);
581
658
  __decorate([
582
659
  property()
583
660
  ], HelixHeadlessSplitter.prototype, "snap", void 0);
@@ -36,6 +36,20 @@ declare const HelixHeadlessTabs_base: (new (...args: any[]) => import("@fusion.d
36
36
  * Disabled tabs are passed over. Tab leaves the list for the selected panel,
37
37
  * which is a tab stop only while it holds nothing focusable.
38
38
  *
39
+ * **Keyboard.**
40
+ *
41
+ * | Key | What it does |
42
+ * |---|---|
43
+ * | Right, Down | Moves to the next tab, from the last round to the first. Selects it, unless `manual` is set or a tab is a link. |
44
+ * | Left, Up | Moves to the tab before, from the first round to the last. |
45
+ * | Home, End | Moves to the first or the last tab. |
46
+ * | Enter, Space | Selects the tab with focus, or follows its link. |
47
+ * | Tab | Leaves the list for the selected panel. |
48
+ *
49
+ * Removing the selected tab selects the first enabled tab that is left, and
50
+ * `active` says so, with `helix-activate`. Where the removed tab had focus,
51
+ * focus moves to the tab now selected.
52
+ *
39
53
  * The `indicator` part moves to the selected tab and carries its place and
40
54
  * size, so a mark of your own can be drawn there: style the part, or slot an
41
55
  * element into `indicator`, which sits inside the part and reads the same
@@ -125,8 +139,19 @@ export declare class HelixHeadlessTabs extends HelixHeadlessTabs_base {
125
139
  private get linked();
126
140
  private get panels();
127
141
  private readonly handleSelection;
142
+ /** The values of the tabs the last pairing read, to tell a removed tab from one not yet here. */
143
+ private held;
128
144
  /** Pushes the current value down to whichever tab and panel own it. */
129
145
  private sync;
146
+ /** The tab that last had focus, so its removal can hand focus on. */
147
+ private focused;
148
+ private readonly handleFocusIn;
149
+ /**
150
+ * Puts focus on the selected tab where the tab that had it was removed and
151
+ * focus fell to the page, which is where removing a focused element leaves
152
+ * it.
153
+ */
154
+ private refocus;
130
155
  connectedCallback(): void;
131
156
  disconnectedCallback(): void;
132
157
  protected firstUpdated(changed: PropertyValues): void;
@@ -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 { AriaForwardController, customElement, HelixElement, IndicatorController, KeyboardNavigationController, MixinControllerActive, whenHydrated, words, } from '@fusion.dev/helix-core';
7
+ import { AriaForwardController, customElement, deepActiveElement, HelixActivateEvent, HelixElement, IndicatorController, KeyboardNavigationController, MixinControllerActive, whenHydrated, words, } from '@fusion.dev/helix-core';
8
8
  import { html, nothing } from 'lit';
9
9
  import { property } from 'lit/decorators.js';
10
10
  import { HelixHeadlessTabPanel } from '../panel/tab-panel.js';
@@ -35,9 +35,15 @@ let HelixHeadlessTabs = class HelixHeadlessTabs extends MixinControllerActive(He
35
35
  }
36
36
  this.requestActive(event.detail.value);
37
37
  };
38
+ this.held = [];
39
+ this.handleFocusIn = (event) => {
40
+ const path = event.composedPath();
41
+ this.focused = this.tabs.find((tab) => path.includes(tab));
42
+ };
38
43
  this.handleSlotChange = () => {
39
44
  this.sync();
40
45
  this.indicator.place();
46
+ this.refocus();
41
47
  };
42
48
  }
43
49
  static { this.words = 'helix-tabs'; }
@@ -54,6 +60,13 @@ let HelixHeadlessTabs = class HelixHeadlessTabs extends MixinControllerActive(He
54
60
  const tabs = this.tabs;
55
61
  const declared = this.active === '' ? tabs.find((tab) => tab.selected) : undefined;
56
62
  const value = (declared ?? this.resolveActive(tabs))?.value ?? '';
63
+ const previous = this.active;
64
+ const gone = this.held.includes(previous) && !tabs.some((tab) => tab.value === previous);
65
+ this.held = tabs.map((tab) => tab.value);
66
+ if (gone && value !== '') {
67
+ this.active = value;
68
+ this.dispatchEvent(new HelixActivateEvent({ previous, value }));
69
+ }
57
70
  for (const tab of tabs) {
58
71
  const selected = tab.value === value;
59
72
  whenHydrated(tab, () => {
@@ -77,13 +90,27 @@ let HelixHeadlessTabs = class HelixHeadlessTabs extends MixinControllerActive(He
77
90
  });
78
91
  }
79
92
  }
93
+ refocus() {
94
+ const gone = this.focused;
95
+ if (!gone || this.tabs.includes(gone)) {
96
+ return;
97
+ }
98
+ this.focused = undefined;
99
+ const active = deepActiveElement();
100
+ if (active !== null && active !== document.body) {
101
+ return;
102
+ }
103
+ void this.updateComplete.then(() => this.tabs.find((tab) => tab.selected)?.focus());
104
+ }
80
105
  connectedCallback() {
81
106
  super.connectedCallback();
82
107
  this.addEventListener('helix-select', this.handleSelection);
108
+ this.addEventListener('focusin', this.handleFocusIn);
83
109
  }
84
110
  disconnectedCallback() {
85
111
  super.disconnectedCallback();
86
112
  this.removeEventListener('helix-select', this.handleSelection);
113
+ this.removeEventListener('focusin', this.handleFocusIn);
87
114
  }
88
115
  firstUpdated(changed) {
89
116
  super.firstUpdated(changed);
@@ -15,6 +15,11 @@ export interface StopWords {
15
15
  heading: readonly Element[];
16
16
  /** Whether the stop has a `label` of its own. */
17
17
  labeled: boolean;
18
+ /**
19
+ * The region that holds the content, given where the content holds words
20
+ * with no element around them, which then describes the panel.
21
+ */
22
+ loose?: Element | null;
18
23
  /** The element that says the stop's place in the tour, such as "2 of 5". */
19
24
  progress: Element | null;
20
25
  }
@@ -24,7 +29,8 @@ export interface StopWords {
24
29
  * Named by the `heading` slot, then by the first heading in the content, then
25
30
  * by the stop's own `label`, and where there is none of those, by its place in
26
31
  * the tour, so no stop is an unnamed group. Described by the `description`
27
- * slot and by its place, which is how a screen reader hears where it is as
28
- * focus moves from one stop to the next.
32
+ * slot, or where it is empty by the words at the start of the content, as a
33
+ * `Dialog` is, and then by its place, which is how a screen reader hears where
34
+ * it is as focus moves from one stop to the next.
29
35
  */
30
- export declare const stopNaming: ({ content, description, heading, labeled, progress, }: StopWords) => StopNaming;
36
+ export declare const stopNaming: ({ content, description, heading, labeled, loose, progress, }: StopWords) => StopNaming;
@@ -1,10 +1,16 @@
1
1
  import { headingIn } from '@fusion.dev/helix-core';
2
- export const stopNaming = ({ content, description, heading, labeled, progress, }) => {
2
+ import { wordsAmong } from '../dialog/description.js';
3
+ export const stopNaming = ({ content, description, heading, labeled, loose = null, progress, }) => {
3
4
  const first = heading.length > 0 ? null : headingIn(content);
4
5
  const found = heading.length > 0 ? [...heading] : first ? [first] : [];
5
6
  const place = progress ? [progress] : [];
7
+ const said = description.length > 0
8
+ ? [...description]
9
+ : content.length === 0 && loose
10
+ ? [loose]
11
+ : wordsAmong(content, first);
6
12
  if (found.length === 0 && !labeled) {
7
- return { describedBy: [...description], labelledBy: place };
13
+ return { describedBy: said, labelledBy: place };
8
14
  }
9
- return { describedBy: [...description, ...place], labelledBy: found };
15
+ return { describedBy: [...said, ...place], labelledBy: found };
10
16
  };
@@ -40,11 +40,27 @@ declare const HelixHeadlessTour_base: (new (...args: any[]) => import("@fusion.d
40
40
  * you have moved it somewhere else on the page. A stop does not trap focus, so
41
41
  * you can still `Tab` out to the element it describes.
42
42
  *
43
- * **The arrow keys move between stops** while focus is in one: the arrow that
44
- * points the way the line runs goes on, and the other goes back, so on a
45
- * right-to-left page `ArrowLeft` goes on. Neither finishes the tour at the last
46
- * stop, and a text field in a stop keeps its own arrows. `Escape` skips the
47
- * tour.
43
+ * **The target is scrolled into view** before its stop is shown, by the
44
+ * least distance that brings it on screen, so the stop that points at it is
45
+ * on screen too.
46
+ *
47
+ * **Keyboard.** Focus is on the current stop's panel, which is not in the
48
+ * `Tab` order itself.
49
+ *
50
+ * | Key | What it does |
51
+ * |---|---|
52
+ * | Right | Goes to the next stop. On the last stop it does nothing, and Next finishes the tour. |
53
+ * | Left | Goes back to the previous stop. |
54
+ * | Escape | Skips the tour, and puts focus back where it was when the tour opened. |
55
+ * | Tab, Shift+Tab | Moves through the controls in the stop, and on out to the page. A stop does not trap focus. |
56
+ * | Enter, Space | On Back, Next, or Skip, presses it. |
57
+ *
58
+ * The arrow that points the way the line runs goes on, and the other goes
59
+ * back, so on a right-to-left page Left goes on and Right goes back. A text field in a stop keeps
60
+ * its own arrows.
61
+ *
62
+ * The tour is a region named by `label` while it runs. A development build
63
+ * warns when a tour opens with no `label`.
48
64
  *
49
65
  * @since 0.1.0
50
66
  * @status stable
@@ -186,6 +202,14 @@ export declare class HelixHeadlessTour extends HelixHeadlessTour_base {
186
202
  disconnectedCallback(): void;
187
203
  protected willUpdate(changed: PropertyValues): void;
188
204
  protected updated(changed: PropertyValues): void;
205
+ /** The target last scrolled into view, so a render that moved nothing scrolls nothing. */
206
+ private revealed;
207
+ /**
208
+ * Scrolls the current target into view, by the least distance that brings
209
+ * it on screen, before its stop is shown beside it. Zag's tour does the same.
210
+ * A target already on screen does not move.
211
+ */
212
+ private reveal;
189
213
  /** Where focus was when the tour opened, to give it back when it ends. */
190
214
  private returnFocus;
191
215
  /** Whether `follow` has seen the first update. */
@@ -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 { controlsIn, customElement, deepActiveElement, deepContains, formatWords, HelixActivateEvent, HelixBeforeActivateEvent, HelixCompleteEvent, HelixElement, HelixTourStatusEvent, hostAria, inlineStep, keyClaimed, MixinControllerOpen, requestChange, whenHydrated, words, } from '@fusion.dev/helix-core';
7
+ import { auditName, controlsIn, customElement, deepActiveElement, deepContains, formatWords, HelixActivateEvent, HelixBeforeActivateEvent, HelixCompleteEvent, HelixElement, HelixTourStatusEvent, hostAria, inlineStep, keyClaimed, MixinControllerOpen, requestChange, whenHydrated, words, } from '@fusion.dev/helix-core';
8
8
  import { html } from 'lit';
9
9
  import { property, queryAssignedElements, state } from 'lit/decorators.js';
10
10
  import { stopBefore } from '../back.js';
@@ -151,11 +151,23 @@ let HelixHeadlessTour = class HelixHeadlessTour extends MixinControllerOpen(Heli
151
151
  }
152
152
  updated(changed) {
153
153
  super.updated(changed);
154
+ this.reveal();
154
155
  this.settle();
155
156
  this.light();
157
+ if (changed.has('open') && this.open) {
158
+ auditName(this, () => null, 'Give it a `label`, such as "Getting started".', () => this.label !== '');
159
+ }
156
160
  this.follow(changed);
157
161
  this.wait();
158
162
  }
163
+ reveal() {
164
+ const target = this.lit ?? undefined;
165
+ if (target === this.revealed) {
166
+ return;
167
+ }
168
+ this.revealed = target;
169
+ target?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
170
+ }
159
171
  follow(changed) {
160
172
  if (!this.followed) {
161
173
  this.followed = true;
@@ -37,7 +37,8 @@ import '../../button/index.js';
37
37
  * The panel is a `group`, and a `dialog` for a dialog stop, only while the
38
38
  * stop is shown. It is named by the `heading` slot, then by the first heading
39
39
  * in the content, then by `label`, and otherwise by its place in the tour. It
40
- * is described by the `description` slot and by its place, such as "2 of 5",
40
+ * is described by the `description` slot, or where that is empty by the words
41
+ * at the start of the content, and by its place, such as "2 of 5",
41
42
  * so a screen reader says where the reader is at every stop. `focus()` moves
42
43
  * focus to the panel, which is what the tour does when it opens or moves on.
43
44
  *
@@ -68,6 +68,9 @@ let HelixHeadlessTourStop = class HelixHeadlessTourStop extends HelixElement {
68
68
  description: slotted('description'),
69
69
  heading: slotted('heading'),
70
70
  labeled: this.label !== '',
71
+ loose: [...this.childNodes].some((one) => one.nodeType === Node.TEXT_NODE && (one.textContent ?? '').trim() !== '')
72
+ ? this.partNamed('content')
73
+ : null,
71
74
  progress: this.position === '' ? null : this.partNamed('progress'),
72
75
  });
73
76
  reflectElements(bubble, 'ariaLabelledByElements', labelledBy, true);
@@ -127,7 +130,7 @@ let HelixHeadlessTourStop = class HelixHeadlessTourStop extends HelixElement {
127
130
  ></div>
128
131
  <slot name="heading" part="heading" @slotchange=${this.nameByHeading}></slot>
129
132
  <slot name="description" part="description" @slotchange=${this.nameByHeading}></slot>
130
- <div part="content"><slot></slot></div>
133
+ <div part="content"><slot @slotchange=${this.nameByHeading}></slot></div>
131
134
  ${this.position === ''
132
135
  ? ''
133
136
  : html `<span part="progress" ?hidden=${!this.progress}>${this.position}</span>`}
@@ -36,6 +36,9 @@ declare const HelixHeadlessTree_base: (new (...args: any[]) => import("@fusion.d
36
36
  * tree of check marks, Control+A (Command+A on an Apple keyboard) checks every
37
37
  * item or clears them all, and Shift with Up or Down Arrow moves and checks.
38
38
  *
39
+ * Removing the item that has focus moves focus to the item after it among its
40
+ * siblings, or the one before, or its parent, so focus stays in the tree.
41
+ *
39
42
  * **`reorderable` lets you move items among their siblings.** Alt with Up or
40
43
  * Down moves the focused item a place, and Alt with Home or End to the first
41
44
  * or the last place among its siblings, as the APG's rearrangeable listbox
@@ -183,6 +186,16 @@ export declare class HelixHeadlessTree extends HelixHeadlessTree_base {
183
186
  /** Whether many items can be checked. */
184
187
  private get multiple();
185
188
  private watcher;
189
+ /** The item that last had focus, so its removal can hand focus on. */
190
+ private focused;
191
+ private readonly handleFocusIn;
192
+ /**
193
+ * Puts focus back in the tree where the item that had it was removed and
194
+ * focus fell to the page: on the item after it among its siblings, or the
195
+ * one before, or its parent. Read from the removal itself, which knows where
196
+ * the item stood.
197
+ */
198
+ private refocus;
186
199
  /**
187
200
  * Every item, in the order a reader moving down would meet them.
188
201
  *
@@ -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 { AriaForwardController, closestInstance, commaList, customElement, HelixElement, inlineStep, keyClaimed, MixinCollection, MixinControllerActive, ReorderController, scrollItemIntoView, selectsAll, slottedWords, Typeahead, treeArrow, typesCharacter, whenHydrated, words, } from '@fusion.dev/helix-core';
7
+ import { AriaForwardController, closestInstance, commaList, customElement, deepActiveElement, HelixElement, inlineStep, keyClaimed, MixinCollection, MixinControllerActive, ReorderController, scrollItemIntoView, selectsAll, slottedWords, Typeahead, treeArrow, typesCharacter, whenHydrated, words, } from '@fusion.dev/helix-core';
8
8
  import { html, nothing } from 'lit';
9
9
  import { property } from 'lit/decorators.js';
10
10
  import { fromGrip } from '../grip.js';
@@ -52,6 +52,11 @@ let HelixHeadlessTree = class HelixHeadlessTree extends MixinCollection(MixinCon
52
52
  });
53
53
  this.pickedUpLabel = 'Picked up {name}';
54
54
  this.reorderable = false;
55
+ this.handleFocusIn = (event) => {
56
+ this.focused = event
57
+ .composedPath()
58
+ .find((node) => node instanceof HelixHeadlessTreeItem);
59
+ };
55
60
  this.handleSelection = (event) => {
56
61
  const origin = event.target;
57
62
  if (!(origin instanceof HelixHeadlessTreeItem) || !this.items.includes(origin)) {
@@ -162,6 +167,36 @@ let HelixHeadlessTree = class HelixHeadlessTree extends MixinCollection(MixinCon
162
167
  get multiple() {
163
168
  return this.selectionMode === 'multiple';
164
169
  }
170
+ refocus(records) {
171
+ const gone = this.focused;
172
+ if (!gone || this.items.includes(gone)) {
173
+ return;
174
+ }
175
+ this.focused = undefined;
176
+ const active = deepActiveElement();
177
+ if (active !== null && active !== document.body) {
178
+ return;
179
+ }
180
+ const record = records.find((one) => [...one.removedNodes].some((node) => node === gone || node.contains(gone)));
181
+ const near = (from, forward) => {
182
+ let node = from ?? null;
183
+ while (node) {
184
+ if (node instanceof HelixHeadlessTreeItem && !node.disabled && node.isConnected) {
185
+ return node;
186
+ }
187
+ node = forward ? node.nextSibling : node.previousSibling;
188
+ }
189
+ return undefined;
190
+ };
191
+ const parent = record ? closestInstance(record.target, HelixHeadlessTreeItem) : null;
192
+ const next = near(record?.nextSibling, true) ??
193
+ near(record?.previousSibling, false) ??
194
+ (parent instanceof HelixHeadlessTreeItem && this.items.includes(parent)
195
+ ? parent
196
+ : undefined) ??
197
+ this.visibleItems.find((item) => !item.disabled);
198
+ this.focusOn(next);
199
+ }
165
200
  get items() {
166
201
  return [...this.querySelectorAll('*')].filter((node) => node instanceof HelixHeadlessTreeItem);
167
202
  }
@@ -305,7 +340,11 @@ let HelixHeadlessTree = class HelixHeadlessTree extends MixinCollection(MixinCon
305
340
  this.addEventListener('helix-select', this.handleSelection);
306
341
  this.addEventListener('toggle', this.handleBranch, true);
307
342
  this.addEventListener('keydown', this.handleKeydown);
308
- this.watcher = new MutationObserver(() => this.syncItems());
343
+ this.addEventListener('focusin', this.handleFocusIn);
344
+ this.watcher = new MutationObserver((records) => {
345
+ this.syncItems();
346
+ this.refocus(records);
347
+ });
309
348
  this.watcher.observe(this, { childList: true, subtree: true });
310
349
  }
311
350
  disconnectedCallback() {
@@ -313,6 +352,7 @@ let HelixHeadlessTree = class HelixHeadlessTree extends MixinCollection(MixinCon
313
352
  this.removeEventListener('helix-select', this.handleSelection);
314
353
  this.removeEventListener('toggle', this.handleBranch, true);
315
354
  this.removeEventListener('keydown', this.handleKeydown);
355
+ this.removeEventListener('focusin', this.handleFocusIn);
316
356
  this.watcher?.disconnect();
317
357
  this.watcher = undefined;
318
358
  }