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.
- package/dist/components/autocomplete/autocomplete-clear-button.js +1 -1
- package/dist/components/autocomplete/autocomplete-root.js +3 -0
- package/dist/components/checkbox/checkbox-root.js +4 -1
- package/dist/components/radio/radio-root.js +4 -1
- package/dist/components/scroll-shadow/scroll-shadow-root.js +2 -10
- package/dist/components/scroll-shadow/use-scroll-shadow.d.ts +10 -0
- package/dist/components/scroll-shadow/use-scroll-shadow.js +34 -2
- package/dist/components/select/index.d.ts +3 -2
- package/dist/components/select/index.js +2 -1
- package/dist/components/select/select-clear-button.d.ts +21 -0
- package/dist/components/select/select-clear-button.js +66 -0
- package/dist/components/select/select-root.d.ts +2 -1
- package/dist/components/select/select-root.js +15 -1
- package/dist/components/select/select.context.d.ts +11 -0
- package/dist/components/select/select.types.d.ts +3 -0
- package/dist/components/switch/switch-root.js +4 -1
- package/dist/components/table/table-row.js +3 -2
- package/dist/components/table/use-grid-keyboard.js +2 -3
- package/dist/components/table/use-table-collection.d.ts +0 -11
- package/dist/components/table/use-table-collection.js +1 -17
- package/dist/components/tabs/tabs-root.js +5 -1
- package/dist/components/tabs/tabs.types.d.ts +6 -0
- package/dist/components/toast/index.d.ts +1 -1
- package/dist/components/toast/index.js +3 -3
- package/dist/components/toast/toast-default-content.js +26 -4
- package/dist/components/toast/toast-indicator.js +8 -2
- package/dist/components/toast/toast-provider.js +53 -5
- package/dist/components/toast/toast-queue.d.ts +60 -5
- package/dist/components/toast/toast-queue.js +85 -27
- package/dist/components/toast/toast-root.js +67 -14
- package/dist/components/toast/toast.constants.d.ts +17 -1
- package/dist/components/toast/toast.constants.js +18 -2
- package/dist/components/toast/toast.context.d.ts +6 -0
- package/dist/components/toast/toast.types.d.ts +19 -0
- package/dist/composables/use-list-keyboard.d.ts +1 -1
- package/dist/composables/use-list-keyboard.js +4 -5
- package/dist/composables/use-measured-height.d.ts +18 -10
- package/dist/composables/use-measured-height.js +53 -15
- package/dist/composables/use-overlay-position.js +7 -4
- package/dist/composables/use-select-state.d.ts +8 -0
- package/dist/composables/use-select-state.js +5 -0
- package/dist/composables/use-select.d.ts +7 -0
- package/dist/composables/use-select.js +6 -0
- package/dist/composables/use-toast-region.d.ts +18 -4
- package/dist/composables/use-toast-region.js +128 -6
- package/dist/composables/use-toast.d.ts +1 -1
- package/dist/composables/use-toast.js +4 -3
- package/dist/composables/use-typeahead.js +3 -0
- package/dist/index.js +5 -4
- package/dist/ropav.min.css +1 -1
- package/dist/utils/focus.d.ts +13 -0
- package/dist/utils/focus.js +18 -1
- package/dist/version.js +1 -1
- 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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
|
32
|
+
let resizeObserver;
|
|
33
|
+
let contentObserver;
|
|
34
|
+
let frame;
|
|
25
35
|
const detach = () => {
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
/**
|
|
5
|
-
|
|
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":
|
|
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":
|
|
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
|
-
*
|
|
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:
|
|
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:
|
|
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 {
|
|
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
|
-
*
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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.
|
|
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()) {
|