ropav 0.11.1 → 0.12.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 (54) hide show
  1. package/dist/components/autocomplete/autocomplete-clear-button.js +1 -1
  2. package/dist/components/autocomplete/autocomplete-root.js +3 -0
  3. package/dist/components/checkbox/checkbox-root.js +4 -1
  4. package/dist/components/radio/radio-root.js +4 -1
  5. package/dist/components/scroll-shadow/scroll-shadow-root.js +2 -10
  6. package/dist/components/scroll-shadow/use-scroll-shadow.d.ts +10 -0
  7. package/dist/components/scroll-shadow/use-scroll-shadow.js +34 -2
  8. package/dist/components/select/index.d.ts +3 -2
  9. package/dist/components/select/index.js +2 -1
  10. package/dist/components/select/select-clear-button.d.ts +21 -0
  11. package/dist/components/select/select-clear-button.js +66 -0
  12. package/dist/components/select/select-root.d.ts +2 -1
  13. package/dist/components/select/select-root.js +15 -1
  14. package/dist/components/select/select.context.d.ts +11 -0
  15. package/dist/components/select/select.types.d.ts +3 -0
  16. package/dist/components/switch/switch-root.js +4 -1
  17. package/dist/components/table/table-row.js +3 -2
  18. package/dist/components/table/use-grid-keyboard.js +2 -3
  19. package/dist/components/table/use-table-collection.d.ts +0 -11
  20. package/dist/components/table/use-table-collection.js +1 -17
  21. package/dist/components/tabs/tabs-root.js +5 -1
  22. package/dist/components/tabs/tabs.types.d.ts +6 -0
  23. package/dist/components/toast/index.d.ts +1 -1
  24. package/dist/components/toast/index.js +3 -3
  25. package/dist/components/toast/toast-default-content.js +26 -4
  26. package/dist/components/toast/toast-indicator.js +8 -2
  27. package/dist/components/toast/toast-provider.js +53 -5
  28. package/dist/components/toast/toast-queue.d.ts +60 -5
  29. package/dist/components/toast/toast-queue.js +85 -27
  30. package/dist/components/toast/toast-root.js +67 -14
  31. package/dist/components/toast/toast.constants.d.ts +17 -1
  32. package/dist/components/toast/toast.constants.js +18 -2
  33. package/dist/components/toast/toast.context.d.ts +6 -0
  34. package/dist/components/toast/toast.types.d.ts +19 -0
  35. package/dist/composables/use-list-keyboard.d.ts +1 -1
  36. package/dist/composables/use-list-keyboard.js +4 -5
  37. package/dist/composables/use-measured-height.d.ts +18 -10
  38. package/dist/composables/use-measured-height.js +53 -15
  39. package/dist/composables/use-overlay-position.js +7 -4
  40. package/dist/composables/use-select-state.d.ts +8 -0
  41. package/dist/composables/use-select-state.js +5 -0
  42. package/dist/composables/use-select.d.ts +7 -0
  43. package/dist/composables/use-select.js +6 -0
  44. package/dist/composables/use-toast-region.d.ts +18 -4
  45. package/dist/composables/use-toast-region.js +128 -6
  46. package/dist/composables/use-toast.d.ts +1 -1
  47. package/dist/composables/use-toast.js +4 -3
  48. package/dist/composables/use-typeahead.js +3 -0
  49. package/dist/index.js +5 -4
  50. package/dist/ropav.min.css +1 -1
  51. package/dist/utils/focus.d.ts +13 -0
  52. package/dist/utils/focus.js +18 -1
  53. package/dist/version.js +1 -1
  54. package/package.json +3 -3
@@ -82,6 +82,19 @@ export interface ToastProviderProps {
82
82
  class?: string;
83
83
  /** Pixels between stacked toasts. @default 12 */
84
84
  gap?: number;
85
+ /**
86
+ * Key combination that moves focus to the region, as `KeyboardEvent` modifier names plus an
87
+ * `event.code`. Modifiers left out have to be up, so `Alt`+`T` does not answer `Ctrl`+`Alt`+`T`.
88
+ * Pass an empty list to turn it off.
89
+ *
90
+ * @default ["altKey", "KeyT"]
91
+ */
92
+ hotkey?: readonly string[];
93
+ /**
94
+ * Holds the stack open regardless of pointer or focus. Deliberately does not pause the clocks:
95
+ * expansion asked for by the page is not someone reading the toasts.
96
+ */
97
+ isExpanded?: boolean;
85
98
  /**
86
99
  * How many toasts are drawn at once. Visual only — the rest are faded out, not dropped.
87
100
  *
@@ -118,6 +131,12 @@ export interface ToastContentProps {
118
131
  }
119
132
  export interface ToastIndicatorProps {
120
133
  class?: string;
134
+ /**
135
+ * Whether this indicator is replacing one the toast was already showing, which the stylesheet
136
+ * animates. The first indicator a toast renders is not a replacement and must not be marked as
137
+ * one, or every toast would arrive with its icon animating.
138
+ */
139
+ isSwapped?: boolean;
121
140
  /** Picks the default icon. Falls back to the toast's own variant. */
122
141
  variant?: ToastVariants["variant"];
123
142
  }
@@ -50,7 +50,7 @@ export interface UseListKeyboardOptions {
50
50
  disallowSelectAll?: MaybeRefOrGetter<boolean | undefined>;
51
51
  /** @default "clearSelection" */
52
52
  escapeKeyBehavior?: MaybeRefOrGetter<"clearSelection" | "none" | undefined>;
53
- /** Called when an item is activated rather than selected. */
53
+ /** Called when an item is activated: Enter, or a press where there is no selection to make. */
54
54
  onAction?: (key: CollectionKey) => void;
55
55
  /**
56
56
  * Whether focus over the collection is nominal rather than real.
@@ -278,14 +278,13 @@ var useListKeyboard = (options) => {
278
278
  return;
279
279
  }
280
280
  case "Enter":
281
- if (focused == null || toValue(options.disallowActivation)) return;
282
- options.onAction?.(focused);
283
- event.preventDefault();
284
- return;
285
281
  case " ":
286
282
  if (focused == null || toValue(options.disallowActivation)) return;
287
283
  if (selection.selectionMode.value === "none") options.onAction?.(focused);
288
- else selection.select(focused, { isShiftPressed: event.shiftKey });
284
+ else {
285
+ selection.select(focused, { isShiftPressed: event.shiftKey });
286
+ if (event.key === "Enter") options.onAction?.(focused);
287
+ }
289
288
  event.preventDefault();
290
289
  return;
291
290
  default: return;
@@ -10,16 +10,24 @@ export interface UseMeasuredHeightReturn {
10
10
  * the content *wants*: a toast that is not frontmost is clipped to the front one's height, so its
11
11
  * own border box has already been overwritten by the very number this feeds.
12
12
  *
13
- * Two narrowings against the usual shape, both measured rather than assumed:
13
+ * That overwriting is also why the height is unset before every reading. The value this produces
14
+ * is what decides the element's own `height`, so reading it back off a sized box would return the
15
+ * previous answer for ever — a toast whose content is replaced in place would keep the height of
16
+ * the content it no longer holds.
14
17
  *
15
- * 1. It measures directly on attach and then lets the observer take over, rather than relying on
16
- * the observer's first notification alone. jsdom *has* a `ResizeObserver` constructor but it
17
- * never notifies anything, so an observer-only reading is permanently absent there — and this
18
- * value drives an inline custom property that decides layout, which is exactly the kind of
19
- * thing a fast suite should be able to assert. In a real browser the two readings agree, and
20
- * the repeat is dropped, so the only difference is that the first one lands a frame earlier.
21
- * 2. React also watches `aria-hidden` with a `MutationObserver`. That attribute never reaches the
22
- * DOM there — `react-aria-components` filters it off the toast — so the observer is dead code,
23
- * and this package renders no `aria-hidden` for it to watch either.
18
+ * Three narrowings against the usual shape, each measured rather than assumed:
19
+ *
20
+ * 1. It measures directly on attach and then lets the observers take over, rather than relying on
21
+ * a first notification alone. jsdom *has* a `ResizeObserver` constructor but it never notifies
22
+ * anything, so an observer-only reading is permanently absent there — and this value drives an
23
+ * inline custom property that decides layout, which is exactly the kind of thing a fast suite
24
+ * should be able to assert. In a real browser the two readings agree, and the repeat is
25
+ * dropped, so the only difference is that the first one lands a frame earlier.
26
+ * 2. Only the inline axis of a resize counts. The block axis is transitioned by the caller, and a
27
+ * reading forces layout twice to unset and restore the height — so reacting to it would
28
+ * re-measure on every frame of that animation and retarget the very transition it is watching.
29
+ * 3. Content is watched separately, because a box whose height is already forced does not resize
30
+ * when what is inside it changes. Attributes are left out of that watch: the caller writes its
31
+ * state onto this element, and none of it changes what the content wants.
24
32
  */
25
33
  export declare const useMeasuredHeight: (elementRef: MaybeRefOrGetter<HTMLElement | null | undefined>) => UseMeasuredHeightReturn;
@@ -7,36 +7,74 @@ import { computed, onScopeDispose, shallowRef, toValue, watch } from "vue";
7
7
  * the content *wants*: a toast that is not frontmost is clipped to the front one's height, so its
8
8
  * own border box has already been overwritten by the very number this feeds.
9
9
  *
10
- * Two narrowings against the usual shape, both measured rather than assumed:
10
+ * That overwriting is also why the height is unset before every reading. The value this produces
11
+ * is what decides the element's own `height`, so reading it back off a sized box would return the
12
+ * previous answer for ever — a toast whose content is replaced in place would keep the height of
13
+ * the content it no longer holds.
11
14
  *
12
- * 1. It measures directly on attach and then lets the observer take over, rather than relying on
13
- * the observer's first notification alone. jsdom *has* a `ResizeObserver` constructor but it
14
- * never notifies anything, so an observer-only reading is permanently absent there — and this
15
- * value drives an inline custom property that decides layout, which is exactly the kind of
16
- * thing a fast suite should be able to assert. In a real browser the two readings agree, and
17
- * the repeat is dropped, so the only difference is that the first one lands a frame earlier.
18
- * 2. React also watches `aria-hidden` with a `MutationObserver`. That attribute never reaches the
19
- * DOM there — `react-aria-components` filters it off the toast — so the observer is dead code,
20
- * and this package renders no `aria-hidden` for it to watch either.
15
+ * Three narrowings against the usual shape, each measured rather than assumed:
16
+ *
17
+ * 1. It measures directly on attach and then lets the observers take over, rather than relying on
18
+ * a first notification alone. jsdom *has* a `ResizeObserver` constructor but it never notifies
19
+ * anything, so an observer-only reading is permanently absent there — and this value drives an
20
+ * inline custom property that decides layout, which is exactly the kind of thing a fast suite
21
+ * should be able to assert. In a real browser the two readings agree, and the repeat is
22
+ * dropped, so the only difference is that the first one lands a frame earlier.
23
+ * 2. Only the inline axis of a resize counts. The block axis is transitioned by the caller, and a
24
+ * reading forces layout twice to unset and restore the height — so reacting to it would
25
+ * re-measure on every frame of that animation and retarget the very transition it is watching.
26
+ * 3. Content is watched separately, because a box whose height is already forced does not resize
27
+ * when what is inside it changes. Attributes are left out of that watch: the caller writes its
28
+ * state onto this element, and none of it changes what the content wants.
21
29
  */
22
30
  var useMeasuredHeight = (elementRef) => {
23
31
  const height = shallowRef(void 0);
24
- let observer;
32
+ let resizeObserver;
33
+ let contentObserver;
34
+ let frame;
25
35
  const detach = () => {
26
- observer?.disconnect();
27
- observer = void 0;
36
+ resizeObserver?.disconnect();
37
+ resizeObserver = void 0;
38
+ contentObserver?.disconnect();
39
+ contentObserver = void 0;
40
+ if (frame !== void 0 && typeof cancelAnimationFrame === "function") cancelAnimationFrame(frame);
41
+ frame = void 0;
28
42
  };
29
43
  watch(() => toValue(elementRef) ?? null, (element) => {
30
44
  detach();
31
45
  if (!element) return;
32
46
  const measure = () => {
47
+ const forced = element.style.height;
48
+ element.style.height = "auto";
33
49
  const next = element.scrollHeight;
50
+ element.style.height = forced;
34
51
  if (height.value !== next) height.value = next;
35
52
  };
53
+ const scheduleMeasure = () => {
54
+ if (frame !== void 0 || typeof requestAnimationFrame !== "function") return;
55
+ frame = requestAnimationFrame(() => {
56
+ frame = void 0;
57
+ measure();
58
+ });
59
+ };
36
60
  measure();
61
+ if (typeof MutationObserver === "function") {
62
+ contentObserver = new MutationObserver(scheduleMeasure);
63
+ contentObserver.observe(element, {
64
+ characterData: true,
65
+ childList: true,
66
+ subtree: true
67
+ });
68
+ }
37
69
  if (typeof ResizeObserver === "undefined") return;
38
- observer = new ResizeObserver(measure);
39
- observer.observe(element);
70
+ let lastWidth = element.getBoundingClientRect().width;
71
+ resizeObserver = new ResizeObserver((entries) => {
72
+ const width = entries[0]?.contentRect.width;
73
+ if (width === void 0 || Math.abs(width - lastWidth) < .5) return;
74
+ lastWidth = width;
75
+ scheduleMeasure();
76
+ });
77
+ resizeObserver.observe(element);
40
78
  }, {
41
79
  flush: "post",
42
80
  immediate: true
@@ -1,8 +1,11 @@
1
1
  import { calculatePosition, getRect, translateRTL } from "../utils/position.js";
2
2
  import { computed, onScopeDispose, shallowRef, toValue, watch } from "vue";
3
3
  //#region src/composables/use-overlay-position.ts
4
- /** Overlays sit above the page; matched to what the React build renders. */
5
- var OVERLAY_Z_INDEX = 1e5;
4
+ /**
5
+ * Overlays sit above the page. The value lives in the stylesheet so the modal-level surfaces,
6
+ * which can only declare theirs in CSS, resolve to the same level as this inline one.
7
+ */
8
+ var OVERLAY_Z_INDEX = "var(--rp-z-index-overlay)";
6
9
  /** Whether an element or one of its descendants holds focus. */
7
10
  var isFocusWithin = (element) => {
8
11
  const active = document.activeElement;
@@ -212,12 +215,12 @@ var useOverlayPosition = (options) => {
212
215
  "max-height": "100vh",
213
216
  position: "fixed",
214
217
  top: "0px",
215
- "z-index": String(OVERLAY_Z_INDEX)
218
+ "z-index": OVERLAY_Z_INDEX
216
219
  };
217
220
  const style = {
218
221
  "max-height": `${resolved.maxHeight}px`,
219
222
  position: "absolute",
220
- "z-index": String(OVERLAY_Z_INDEX)
223
+ "z-index": OVERLAY_Z_INDEX
221
224
  };
222
225
  for (const [key, value] of Object.entries(resolved.position)) style[key] = `${value}px`;
223
226
  style["--trigger-anchor-point"] = `${resolved.triggerAnchorPoint.x}px ${resolved.triggerAnchorPoint.y}px`;
@@ -61,6 +61,14 @@ export interface UseSelectStateReturn<T> extends MenuTriggerState, FormValidatio
61
61
  /** The value a form reset goes back to. */
62
62
  defaultValue: ComputedRef<SelectedValue>;
63
63
  setValue: (value: SelectedValue) => void;
64
+ /**
65
+ * Empties the selection.
66
+ *
67
+ * Not `selection.clearSelection()`: a single select sets `disallowEmptySelection`, which is
68
+ * what stops a press on the chosen option from un-choosing it — and which makes that call a
69
+ * no-op in the one mode most selects are in.
70
+ */
71
+ clearValue: () => void;
64
72
  /** The first chosen key, which is the whole value when single. */
65
73
  selectedKey: ComputedRef<CollectionKey | null>;
66
74
  selectedItems: ComputedRef<SelectedItem<T>[]>;
@@ -99,6 +99,10 @@ var useSelectState = (options) => {
99
99
  return Array.isArray(current) && current.length === 0 ? null : current;
100
100
  }
101
101
  });
102
+ const clearValue = () => {
103
+ setValue(null);
104
+ validation.commitValidation();
105
+ };
102
106
  const selection = useSelectionManager({
103
107
  collection,
104
108
  disabledBehavior: options.disabledBehavior,
@@ -132,6 +136,7 @@ var useSelectState = (options) => {
132
136
  const canOpen = () => collection.size.value !== 0 || Boolean(toValue(options.allowsEmptyCollection));
133
137
  return {
134
138
  ...validation,
139
+ clearValue,
135
140
  close: trigger.close,
136
141
  collection,
137
142
  defaultValue,
@@ -10,6 +10,13 @@ export interface UseSelectOptions {
10
10
  ariaLabelledby?: MaybeRefOrGetter<string | undefined>;
11
11
  ariaDescribedby?: MaybeRefOrGetter<string | undefined>;
12
12
  onFocusChange?: (isFocused: boolean) => void;
13
+ /**
14
+ * Whether a clear button is composed into the select, which is what opens the trigger's
15
+ * Backspace/Delete shortcut. Read at event time, so registering one never costs a render.
16
+ */
17
+ hasClearButton?: () => boolean;
18
+ /** Empties the selection. Called by the shortcut above. */
19
+ onClear?: () => void;
13
20
  }
14
21
  /** Attributes the trigger element renders, beside its own class and `data-slot`. */
15
22
  export type SelectTriggerAttributes = Record<string, string | number | boolean | undefined>;
@@ -83,6 +83,12 @@ var useSelect = (options, state) => {
83
83
  return;
84
84
  }
85
85
  }
86
+ if ((event.key === "Backspace" || event.key === "Delete") && !state.isOpen.value && options.hasClearButton?.() && state.selectedItems.value.length > 0) {
87
+ event.preventDefault();
88
+ state.clearValue();
89
+ options.onClear?.();
90
+ return;
91
+ }
86
92
  typeahead.onKeydown(event);
87
93
  if (event.defaultPrevented) return;
88
94
  trigger.responder.handlers.value.onKeydown?.(event);
@@ -9,6 +9,10 @@ export interface UseToastRegionOptions {
9
9
  ariaLabel?: MaybeRefOrGetter<string | undefined>;
10
10
  /** The region element, which is also what the toasts are looked up inside. */
11
11
  elementRef: MaybeRefOrGetter<HTMLElement | null | undefined>;
12
+ /** Key combination that moves focus to the region. An empty list turns it off. */
13
+ hotkey?: MaybeRefOrGetter<readonly string[] | undefined>;
14
+ /** Forces the stack open regardless of pointer or focus. */
15
+ isExpanded?: MaybeRefOrGetter<boolean | undefined>;
12
16
  /** Stops every visible toast's clock. */
13
17
  onPauseAll: () => void;
14
18
  /** Restarts every visible toast's clock. */
@@ -22,17 +26,21 @@ export interface ToastRegionAttrs {
22
26
  tabindex: -1;
23
27
  }
24
28
  export interface UseToastRegionReturn {
29
+ /** Whether the stack should be opened out, so every toast shows at its own height. */
30
+ isExpanded: ComputedRef<boolean>;
25
31
  onFocusin: (event: FocusEvent) => void;
26
32
  onFocusout: (event: FocusEvent) => void;
27
33
  onPointerenter: (event: PointerEvent) => void;
28
34
  onPointerleave: () => void;
35
+ /** Reasserts hover when the stack moves under a cursor that has not itself moved. */
36
+ onPointermove: (event: PointerEvent) => void;
29
37
  regionAttrs: ComputedRef<ToastRegionAttrs>;
30
38
  }
31
39
  /**
32
40
  * The behaviour and accessibility wiring of the toast region, ported from react-aria's
33
41
  * `useToastRegion`.
34
42
  *
35
- * Three jobs, and they are only in one composable because they share the same state:
43
+ * Four jobs, and they are only in one composable because they share the same state:
36
44
  *
37
45
  * 1. **Naming.** A landmark region labelled with how many notifications it holds.
38
46
  * 2. **Pausing.** Hover *or* focus anywhere inside stops every visible toast's clock, so a toast
@@ -41,13 +49,19 @@ export interface UseToastRegionReturn {
41
49
  * rather than dropping it on `<body>` — except under a pointer, where focus is pushed back out
42
50
  * of the region, because a pointer user who is no longer hovering would otherwise hold every
43
51
  * remaining clock paused by the focus they did not ask for.
52
+ * 4. **Reaching it.** A key combination that moves focus to the region from anywhere, because a
53
+ * toast is announced where the user is not and tabbing to it means tabbing past everything
54
+ * between. The listener is on the document rather than the region: the region is only in the
55
+ * DOM while it holds a toast, so a listener of its own could never be the thing that reaches
56
+ * it first.
44
57
  *
45
- * One narrowing, recorded rather than hidden: upstream also registers the region with a
58
+ * One narrowing, recorded rather than hidden: react-aria also registers the region with a
46
59
  * document-level landmark manager, which is what makes F6 cycle between landmarks. Nothing else
47
60
  * in react-aria registers one, so with a single registrant F6 has nowhere to go — the rendered
48
- * DOM is identical either way, and toasts are still reachable by Tab.
61
+ * DOM is identical either way, and toasts are still reachable by Tab. That is an argument about
62
+ * cycling between landmarks and not about reaching this one, which is why job 4 stands beside it.
49
63
  *
50
- * One simplification: upstream runs focus-within and raw focus as two channels, because its
64
+ * One simplification: react-aria runs focus-within and raw focus as two channels, because its
51
65
  * focus-within fires once on entry and it needs every change. `focusin` and `focusout` bubble and
52
66
  * fire on every change already, so one pair answers both questions.
53
67
  */
@@ -1,19 +1,33 @@
1
- import { getInteractionModality, useInteractionStates } from "./use-interaction-states.js";
1
+ import { getInteractionModality, retainInteractionModality, useInteractionStates } from "./use-interaction-states.js";
2
2
  import { TOP_LAYER_ATTRIBUTE } from "../utils/top-layer.js";
3
+ import { willOpenKeyboard } from "../utils/platform.js";
3
4
  import { useLocalizedStringFormatter } from "./use-localized-string-formatter.js";
4
5
  import { toastStrings } from "../i18n/toast.js";
5
- import { computed, onScopeDispose, toValue, watch } from "vue";
6
+ import { DEFAULT_HOTKEY } from "../components/toast/toast.constants.js";
7
+ import { computed, onScopeDispose, shallowRef, toValue, watch } from "vue";
6
8
  //#region src/composables/use-toast-region.ts
7
9
  var TOAST_SELECTOR = "[role=\"alertdialog\"]";
8
10
  /** The package's spelling of react-aria's `focusWithoutScrolling`. */
9
11
  var focusQuietly = (element) => {
10
12
  element.focus({ preventScroll: true });
11
13
  };
14
+ /** The `KeyboardEvent` booleans a hotkey entry can name. Anything else is an `event.code`. */
15
+ var MODIFIERS = [
16
+ "altKey",
17
+ "ctrlKey",
18
+ "metaKey",
19
+ "shiftKey"
20
+ ];
21
+ var matchesHotkey = (event, hotkey) => {
22
+ const modifiersAgree = MODIFIERS.every((name) => event[name] === hotkey.includes(name));
23
+ const keysAgree = hotkey.every((key) => MODIFIERS.includes(key) || event.code === key);
24
+ return modifiersAgree && keysAgree;
25
+ };
12
26
  /**
13
27
  * The behaviour and accessibility wiring of the toast region, ported from react-aria's
14
28
  * `useToastRegion`.
15
29
  *
16
- * Three jobs, and they are only in one composable because they share the same state:
30
+ * Four jobs, and they are only in one composable because they share the same state:
17
31
  *
18
32
  * 1. **Naming.** A landmark region labelled with how many notifications it holds.
19
33
  * 2. **Pausing.** Hover *or* focus anywhere inside stops every visible toast's clock, so a toast
@@ -22,25 +36,56 @@ var focusQuietly = (element) => {
22
36
  * rather than dropping it on `<body>` — except under a pointer, where focus is pushed back out
23
37
  * of the region, because a pointer user who is no longer hovering would otherwise hold every
24
38
  * remaining clock paused by the focus they did not ask for.
39
+ * 4. **Reaching it.** A key combination that moves focus to the region from anywhere, because a
40
+ * toast is announced where the user is not and tabbing to it means tabbing past everything
41
+ * between. The listener is on the document rather than the region: the region is only in the
42
+ * DOM while it holds a toast, so a listener of its own could never be the thing that reaches
43
+ * it first.
25
44
  *
26
- * One narrowing, recorded rather than hidden: upstream also registers the region with a
45
+ * One narrowing, recorded rather than hidden: react-aria also registers the region with a
27
46
  * document-level landmark manager, which is what makes F6 cycle between landmarks. Nothing else
28
47
  * in react-aria registers one, so with a single registrant F6 has nowhere to go — the rendered
29
- * DOM is identical either way, and toasts are still reachable by Tab.
48
+ * DOM is identical either way, and toasts are still reachable by Tab. That is an argument about
49
+ * cycling between landmarks and not about reaching this one, which is why job 4 stands beside it.
30
50
  *
31
- * One simplification: upstream runs focus-within and raw focus as two channels, because its
51
+ * One simplification: react-aria runs focus-within and raw focus as two channels, because its
32
52
  * focus-within fires once on entry and it needs every change. `focusin` and `focusout` bubble and
33
53
  * fire on every change already, so one pair answers both questions.
34
54
  */
35
55
  var useToastRegion = (options) => {
36
56
  const strings = useLocalizedStringFormatter(toastStrings);
37
57
  const hover = useInteractionStates();
58
+ onScopeDispose(retainInteractionModality(), true);
38
59
  let isFocusWithin = false;
60
+ /** Set by Escape, and cleared by the next thing that would open the stack on purpose. */
61
+ const isDismissed = shallowRef(false);
62
+ /**
63
+ * Whether the focus inside the region should hold the stack open.
64
+ *
65
+ * Focus arriving by keyboard is someone working through the toasts and wants them all legible.
66
+ * Focus arriving from a pointer is a side effect of the press, and on a touchscreen it is the
67
+ * *only* signal there is — a tap would open the stack with nothing left to close it again,
68
+ * since a finger has no hover to lose.
69
+ */
70
+ const isFocusExpanding = shallowRef(false);
71
+ const isExpanded = computed(() => {
72
+ if (toValue(options.visibleToasts).length <= 1) return false;
73
+ if (toValue(options.isExpanded) === true) return true;
74
+ return !isDismissed.value && (hover.isHovered.value || isFocusExpanding.value);
75
+ });
39
76
  const updateTimers = () => {
40
77
  if (hover.isHovered.value || isFocusWithin) options.onPauseAll();
41
78
  else options.onResumeAll();
42
79
  };
43
80
  /**
81
+ * Re-applied whenever the set of clocks changes, and not only when hover or focus moves.
82
+ *
83
+ * A toast added or updated while the pointer is on the stack mints a timer that the last
84
+ * `onPauseAll` could not have reached, and the toast starts that timer itself — so without this
85
+ * one toast counts down under a pointer that is holding every other toast frozen.
86
+ */
87
+ watch(() => toValue(options.visibleToasts), updateTimers, { flush: "post" });
88
+ /**
44
89
  * Hover has to settle the clocks *in the handler*, not in a watcher on the state it sets.
45
90
  *
46
91
  * A watcher runs a tick later, and a tick is long enough for a toast to expire under the
@@ -50,12 +95,84 @@ var useToastRegion = (options) => {
50
95
  */
51
96
  const onPointerenter = (event) => {
52
97
  hover.onPointerenter(event);
98
+ isDismissed.value = false;
53
99
  updateTimers();
54
100
  };
55
101
  const onPointerleave = () => {
102
+ hover.onPointerleave();
103
+ isDismissed.value = false;
104
+ updateTimers();
105
+ };
106
+ /**
107
+ * A pointer that has not moved still needs the stack to notice it.
108
+ *
109
+ * The stack shifts underneath a resting cursor whenever a toast arrives or leaves, and no
110
+ * browser re-fires a boundary event for a pointer that stayed where it was — so without this
111
+ * an opened stack folds the moment the thing being hovered is replaced beneath it.
112
+ */
113
+ const onPointermove = (event) => {
114
+ if (hover.isHovered.value) return;
115
+ hover.onPointerenter(event);
116
+ updateTimers();
117
+ };
118
+ /**
119
+ * Escape folds the stack.
120
+ *
121
+ * Focus is released as well as the flag being set, because the expansion answers focus too: a
122
+ * region that kept focus would reopen on the next thing that asked it.
123
+ *
124
+ * Hover is deliberately left where it is. The flag folds the stack on its own, and clearing
125
+ * hover as well would restart every clock under a pointer that has not moved — so the toasts
126
+ * being read would expire moments after being tidied out of the way.
127
+ */
128
+ const dismiss = () => {
129
+ const element = toValue(options.elementRef);
130
+ const active = typeof document === "undefined" ? null : document.activeElement;
131
+ if (element && active instanceof HTMLElement && element.contains(active)) active.blur();
132
+ isDismissed.value = true;
133
+ isFocusExpanding.value = false;
134
+ updateTimers();
135
+ };
136
+ /**
137
+ * No browser fires `pointerleave` when the element under the pointer is removed, and a toast
138
+ * closing under the cursor is the ordinary case here rather than a corner one. The next event
139
+ * that says anything is a `pointerover` on something else, which is what this listens for.
140
+ */
141
+ const onDocumentPointerover = (event) => {
142
+ const element = toValue(options.elementRef);
143
+ if (event.pointerType === "touch") return;
144
+ if (!element || event.target instanceof Node && element.contains(event.target)) return;
145
+ if (!hover.isHovered.value) return;
56
146
  hover.onPointerleave();
57
147
  updateTimers();
58
148
  };
149
+ const onDocumentKeydown = (event) => {
150
+ if (event.key === "Escape") {
151
+ dismiss();
152
+ return;
153
+ }
154
+ const hotkey = toValue(options.hotkey) ?? DEFAULT_HOTKEY;
155
+ const element = toValue(options.elementRef);
156
+ if (hotkey.length === 0 || !element || !matchesHotkey(event, hotkey)) return;
157
+ if (event.target instanceof Element && willOpenKeyboard(event.target)) return;
158
+ if (event.defaultPrevented) return;
159
+ event.preventDefault();
160
+ focusQuietly(element);
161
+ isDismissed.value = false;
162
+ isFocusExpanding.value = true;
163
+ };
164
+ watch(() => toValue(options.elementRef) ?? null, (element, _previous, onCleanup) => {
165
+ if (!element || typeof document === "undefined") return;
166
+ document.addEventListener("keydown", onDocumentKeydown, true);
167
+ document.addEventListener("pointerover", onDocumentPointerover, true);
168
+ onCleanup(() => {
169
+ document.removeEventListener("keydown", onDocumentKeydown, true);
170
+ document.removeEventListener("pointerover", onDocumentPointerover, true);
171
+ });
172
+ }, {
173
+ flush: "post",
174
+ immediate: true
175
+ });
59
176
  /** The alertdialog elements, as of the last change to the visible toasts. */
60
177
  let toastElements = [];
61
178
  let previousToasts = toValue(options.visibleToasts);
@@ -124,6 +241,8 @@ var useToastRegion = (options) => {
124
241
  }, { flush: "post" });
125
242
  onScopeDispose(restoreLastFocused, true);
126
243
  const onFocusin = (event) => {
244
+ isDismissed.value = false;
245
+ isFocusExpanding.value = getInteractionModality() === "keyboard";
127
246
  if (!isFocusWithin) {
128
247
  isFocusWithin = true;
129
248
  lastFocused = event.relatedTarget instanceof HTMLElement ? event.relatedTarget : null;
@@ -139,15 +258,18 @@ var useToastRegion = (options) => {
139
258
  if (target instanceof Node && !target.isConnected) return;
140
259
  if (currentTarget instanceof Node && relatedTarget instanceof Node && currentTarget.contains(relatedTarget)) return;
141
260
  isFocusWithin = false;
261
+ isFocusExpanding.value = false;
142
262
  lastFocused = null;
143
263
  focusedIndex = -1;
144
264
  updateTimers();
145
265
  };
146
266
  return {
267
+ isExpanded,
147
268
  onFocusin,
148
269
  onFocusout,
149
270
  onPointerenter,
150
271
  onPointerleave,
272
+ onPointermove,
151
273
  regionAttrs: computed(() => ({
152
274
  [TOP_LAYER_ATTRIBUTE]: true,
153
275
  "aria-label": toValue(options.ariaLabel) || strings.value.format("notifications", { count: toValue(options.visibleToasts).length }),
@@ -45,7 +45,7 @@ export interface UseToastReturn {
45
45
  * region is not rendered has not already spent its life waiting to appear. Unmounting pauses it
46
46
  * rather than dropping it, so a toast that comes back has the time it had left.
47
47
  *
48
- * One narrowing against upstream: it also returns a localized `aria-label` for the close button,
48
+ * One narrowing against react-aria: it also returns a localized `aria-label` for the close button,
49
49
  * which nothing can use — `CloseButton` writes its own `aria-label="Close"`, and that hardcoded
50
50
  * label wins over the one offered through context.
51
51
  */
@@ -13,7 +13,7 @@ import { computed, onMounted, onScopeDispose, shallowRef, toValue, watch } from
13
13
  * region is not rendered has not already spent its life waiting to appear. Unmounting pauses it
14
14
  * rather than dropping it, so a toast that comes back has the time it had left.
15
15
  *
16
- * One narrowing against upstream: it also returns a localized `aria-label` for the close button,
16
+ * One narrowing against react-aria: it also returns a localized `aria-label` for the close button,
17
17
  * which nothing can use — `CloseButton` writes its own `aria-label="Close"`, and that hardcoded
18
18
  * label wins over the one offered through context.
19
19
  */
@@ -23,7 +23,7 @@ var useToast = (options) => {
23
23
  let descriptionClaims = 0;
24
24
  const hasDescription = shallowRef(false);
25
25
  /**
26
- * Upstream resolves this with `useSlotId`, which hands out the id and then withdraws it in a
26
+ * react-aria resolves this with `useSlotId`, which hands out the id and then withdraws it in a
27
27
  * layout effect when no element turns out to carry it. The claim is the same answer asked
28
28
  * directly rather than probed off the document, and it is how the rest of this package settles
29
29
  * whether an id has an owner.
@@ -40,7 +40,7 @@ var useToast = (options) => {
40
40
  * Withheld for the first tick, then dropped.
41
41
  *
42
42
  * NVDA does not announce the toast at all without this — the alert has to become visible to the
43
- * accessibility tree *after* it exists for the announcement to fire. Upstream carries the same
43
+ * accessibility tree *after* it exists for the announcement to fire. react-aria carries the same
44
44
  * flag and the same reason.
45
45
  */
46
46
  const isVisible = shallowRef(false);
@@ -55,6 +55,7 @@ var useToast = (options) => {
55
55
  timeout: toValue(options.timeout),
56
56
  timer: toValue(options.timer)
57
57
  }), (next, previous) => {
58
+ if (previous && previous.timer === next.timer && previous.timeout === next.timeout) return;
58
59
  previous?.timer?.pause();
59
60
  if (next.timer == null || next.timeout == null) return;
60
61
  next.timer.reset(next.timeout);
@@ -1,3 +1,4 @@
1
+ import { isNestedControl } from "../utils/focus.js";
1
2
  import { onScopeDispose, toValue } from "vue";
2
3
  //#region src/composables/use-typeahead.ts
3
4
  /** How long a partial search stays live before it is forgotten. */
@@ -44,6 +45,7 @@ var useTypeahead = (options) => {
44
45
  const onKeydownCapture = (event) => {
45
46
  if (toValue(options.isDisabled)) return;
46
47
  if (search.length === 0 || event.key !== " ") return;
48
+ if (isNestedControl(event.target, event.currentTarget)) return;
47
49
  event.preventDefault();
48
50
  event.stopPropagation();
49
51
  search += " ";
@@ -57,6 +59,7 @@ var useTypeahead = (options) => {
57
59
  const target = event.target;
58
60
  const currentTarget = event.currentTarget;
59
61
  if (currentTarget instanceof Node && target instanceof Node && !currentTarget.contains(target)) return;
62
+ if (isNestedControl(target, currentTarget)) return;
60
63
  if (search.length === 0 && character === " ") return;
61
64
  search += character;
62
65
  if (!runSearch()) {