@crisp-ui-kit/crisp 0.24.0 → 0.26.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/cjs/components/fileupload/fileupload.js +14 -1
- package/dist/cjs/components/radiogroup/radiogroup-keyboard.js +68 -2
- package/dist/cjs/components/radiogroup/radiogroup.js +2 -2
- package/dist/cjs/components/segmented/segmented.js +1 -1
- package/dist/cjs/components/tabletoolbar/tabletoolbar.js +13 -3
- package/dist/cjs/components/tabs/tabs-keyboard.js +10 -1
- package/dist/cjs/components/toast/toast.js +28 -1
- package/dist/cjs/core/index.js +3 -1
- package/dist/cjs/core/use-focus-after-removal.js +84 -0
- package/dist/esm/components/fileupload/fileupload.js +14 -1
- package/dist/esm/components/radiogroup/radiogroup-keyboard.js +35 -2
- package/dist/esm/components/radiogroup/radiogroup.js +2 -2
- package/dist/esm/components/segmented/segmented.js +1 -1
- package/dist/esm/components/tabletoolbar/tabletoolbar.js +13 -3
- package/dist/esm/components/tabs/tabs-keyboard.js +10 -1
- package/dist/esm/components/toast/toast.js +28 -1
- package/dist/esm/core/index.js +1 -0
- package/dist/esm/core/use-focus-after-removal.js +48 -0
- package/dist/types/components/radiogroup/radiogroup-keyboard.d.ts +9 -2
- package/dist/types/core/index.d.ts +1 -0
- package/dist/types/core/use-focus-after-removal.d.ts +32 -0
- package/package.json +1 -1
|
@@ -40,6 +40,7 @@ const jsx_runtime_1 = require("react/jsx-runtime");
|
|
|
40
40
|
const react_file_upload_1 = require("@seed-design/react-file-upload");
|
|
41
41
|
const lucide_react_1 = require("lucide-react");
|
|
42
42
|
const React = __importStar(require("react"));
|
|
43
|
+
const core_1 = require("../../core");
|
|
43
44
|
const fileupload_recipe_1 = require("./fileupload.recipe");
|
|
44
45
|
exports.fileUploadLabels = {
|
|
45
46
|
// Attio's own copy on the record Files pane.
|
|
@@ -114,5 +115,17 @@ function FileUpload({ className, labels, "aria-label": ariaLabel, accept, disabl
|
|
|
114
115
|
// instead means attaching them here; without inputRef, `openFilePicker`
|
|
115
116
|
// finds no input and returns silently, so the dropzone opens nothing.
|
|
116
117
|
inputRef, dropzoneRef, } = api;
|
|
117
|
-
|
|
118
|
+
// Removing a file destroys the button that was focused; without this, focus
|
|
119
|
+
// lands on document.body and the user starts Tabbing from the top of the
|
|
120
|
+
// page (#239). The dropzone is where the list came from, so it is where the
|
|
121
|
+
// last removal leaves you.
|
|
122
|
+
// The container is the ROOT, not the list: the list unmounts when the last
|
|
123
|
+
// file goes, and a container that disappears with the item cannot say where
|
|
124
|
+
// focus should land next.
|
|
125
|
+
const rootRef = React.useRef(null);
|
|
126
|
+
const focusAfterRemoval = (0, core_1.useFocusAfterRemoval)(rootRef, ".fileupload-remove", dropzoneRef);
|
|
127
|
+
return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, ...rest, className: (0, fileupload_recipe_1.fileuploadRecipe)({ className }), children: [(0, jsx_runtime_1.jsxs)("button", { ...dropzoneProps, ref: dropzoneRef, type: "button", className: "fileupload-zone", tabIndex: disabled ? -1 : 0, "aria-label": ariaLabel ?? t.hint, disabled: disabled, "data-dragging": dragging ? "" : undefined, "data-disabled": disabled ? "" : undefined, "data-invalid": invalid ? "" : undefined, onClick: () => openFilePicker(), children: [(0, jsx_runtime_1.jsx)("span", { className: "fileupload-title", children: t.title }), (0, jsx_runtime_1.jsx)("span", { className: "fileupload-hint", children: t.hint })] }), (0, jsx_runtime_1.jsx)("input", { ...hiddenInputProps, ref: inputRef, className: "fileupload-input" }), acceptedFileEntries.length > 0 && ((0, jsx_runtime_1.jsx)("ul", { className: "fileupload-list", children: acceptedFileEntries.map((entry, i) => ((0, jsx_runtime_1.jsxs)("li", { className: "fileupload-item", "data-status": entry.status, children: [(0, jsx_runtime_1.jsx)("span", { className: "fileupload-item-name", children: entry.file.name }), entry.status === "uploading" && entry.progress != null && ((0, jsx_runtime_1.jsx)("span", { className: "fileupload-progress", role: "progressbar", "aria-valuenow": Math.round(entry.progress), "aria-valuemin": 0, "aria-valuemax": 100, "aria-label": entry.file.name, children: (0, jsx_runtime_1.jsx)("span", { className: "fileupload-progress-fill", style: { width: `${entry.progress}%` } }) })), (0, jsx_runtime_1.jsx)("span", { className: "fileupload-item-size", children: formatSize(entry.file.size) }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "fileupload-remove", "aria-label": `${t.remove}: ${entry.file.name}`, onClick: () => {
|
|
128
|
+
focusAfterRemoval(i);
|
|
129
|
+
removeFileEntry(entry.id);
|
|
130
|
+
}, children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 13, strokeWidth: 2 }) })] }, entry.id))) })), (0, jsx_runtime_1.jsx)("ul", { className: "fileupload-errors", "aria-live": "polite", children: rejections.map((r) => ((0, jsx_runtime_1.jsx)("li", { className: "fileupload-error", children: `${r.file.name}: ${r.errors.map((e) => t.errors[e] ?? e).join(" ")}` }, r.file.name))) })] }));
|
|
118
131
|
}
|
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
2
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
36
|
exports.useRadioGroupKeyboard = useRadioGroupKeyboard;
|
|
37
|
+
const React = __importStar(require("react"));
|
|
4
38
|
/**
|
|
5
39
|
* The WAI-ARIA radio group keyboard model: ←/→/↑/↓ move focus to the next or
|
|
6
40
|
* previous enabled radio, wrapping, and check it on arrival. Home/End jump to
|
|
@@ -25,7 +59,39 @@ exports.useRadioGroupKeyboard = useRadioGroupKeyboard;
|
|
|
25
59
|
* `Segmented` already offered them, and the shared model should be the union
|
|
26
60
|
* rather than quietly dropping behaviour one surface had.
|
|
27
61
|
*/
|
|
28
|
-
function useRadioGroupKeyboard({ select, }) {
|
|
62
|
+
function useRadioGroupKeyboard({ select, groupRef: externalRef, }) {
|
|
63
|
+
/**
|
|
64
|
+
* The group's one tab stop. `tabIndex={checked ? 0 : -1}` in the markup is
|
|
65
|
+
* right for every case but the one that matters most: a group with NOTHING
|
|
66
|
+
* selected gives every radio -1, so Tab skips the whole control and an
|
|
67
|
+
* unanswered question can never be answered from the keyboard (#237). APG
|
|
68
|
+
* says the first radio is tabbable when none is checked, and RadioGroup's
|
|
69
|
+
* own comment said so too — "the selected (or first, when none) radio is
|
|
70
|
+
* tabbable" — while the code did only the first half.
|
|
71
|
+
*
|
|
72
|
+
* Owned here rather than in the markup because the group renders arbitrary
|
|
73
|
+
* children: only the DOM knows which radio comes first, which is the same
|
|
74
|
+
* reason the arrow keys read the radios out of the DOM.
|
|
75
|
+
*
|
|
76
|
+
* It runs after EVERY render, and sets both ends. React will not undo a
|
|
77
|
+
* mutation it did not make — when the selection moves, the newly checked
|
|
78
|
+
* radio changes 0 from React's side while the stale one keeps the 0 written
|
|
79
|
+
* here — so this has to own the whole row, not just add to it.
|
|
80
|
+
*/
|
|
81
|
+
const ownRef = React.useRef(null);
|
|
82
|
+
const groupRef = externalRef ?? ownRef;
|
|
83
|
+
React.useLayoutEffect(() => {
|
|
84
|
+
const root = groupRef.current;
|
|
85
|
+
if (!root)
|
|
86
|
+
return;
|
|
87
|
+
const radios = Array.from(root.querySelectorAll('[role="radio"]:not([disabled])'));
|
|
88
|
+
if (!radios.length)
|
|
89
|
+
return;
|
|
90
|
+
const checked = radios.find((r) => r.getAttribute("aria-checked") === "true");
|
|
91
|
+
const stop = checked ?? radios[0];
|
|
92
|
+
for (const r of radios)
|
|
93
|
+
r.tabIndex = r === stop ? 0 : -1;
|
|
94
|
+
});
|
|
29
95
|
const onKeyDown = (e) => {
|
|
30
96
|
const KEYS = [
|
|
31
97
|
"ArrowDown",
|
|
@@ -63,5 +129,5 @@ function useRadioGroupKeyboard({ select, }) {
|
|
|
63
129
|
if (value !== undefined)
|
|
64
130
|
select(value);
|
|
65
131
|
};
|
|
66
|
-
return { onKeyDown };
|
|
132
|
+
return { onKeyDown, groupRef };
|
|
67
133
|
}
|
|
@@ -56,8 +56,8 @@ function RadioGroup({ value, defaultValue, onValueChange, variant = "default", n
|
|
|
56
56
|
setInternal(v);
|
|
57
57
|
onValueChange?.(v);
|
|
58
58
|
}, [value, onValueChange]);
|
|
59
|
-
const { onKeyDown } = (0, radiogroup_keyboard_1.useRadioGroupKeyboard)({ select });
|
|
60
|
-
return ((0, jsx_runtime_1.jsx)(RadioContext.Provider, { value: { value: selected, select, name: groupName, variant }, children: (0, jsx_runtime_1.jsx)("div", { role: "radiogroup", "aria-label": ariaLabel, "data-variant": variant, className: (0, radiogroup_recipe_1.radiogroupRecipe)({ className }), ...rest, onKeyDown: onKeyDown, children: children }) }));
|
|
59
|
+
const { onKeyDown, groupRef } = (0, radiogroup_keyboard_1.useRadioGroupKeyboard)({ select });
|
|
60
|
+
return ((0, jsx_runtime_1.jsx)(RadioContext.Provider, { value: { value: selected, select, name: groupName, variant }, children: (0, jsx_runtime_1.jsx)("div", { ref: groupRef, role: "radiogroup", "aria-label": ariaLabel, "data-variant": variant, className: (0, radiogroup_recipe_1.radiogroupRecipe)({ className }), ...rest, onKeyDown: onKeyDown, children: children }) }));
|
|
61
61
|
}
|
|
62
62
|
RadioGroup.displayName = "RadioGroup";
|
|
63
63
|
function Radio({ value, label, children, disabled }) {
|
|
@@ -68,7 +68,7 @@ function Segmented({ options, value, defaultValue, onValueChange, "aria-label":
|
|
|
68
68
|
// crisp's shared radio group model (#200). The per-button handler this
|
|
69
69
|
// replaces closed over its own index, so it selected a neighbour without
|
|
70
70
|
// moving focus and every press after the first recomputed the same step.
|
|
71
|
-
const { onKeyDown } = (0, radiogroup_1.useRadioGroupKeyboard)({ select });
|
|
71
|
+
const { onKeyDown } = (0, radiogroup_1.useRadioGroupKeyboard)({ select, groupRef: rootRef });
|
|
72
72
|
return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, segmented_recipe_1.segmentedRecipe)({ className }), ...rest, role: "radiogroup", "aria-label": ariaLabel, onKeyDown: onKeyDown, children: [thumb && ((0, jsx_runtime_1.jsx)("span", { className: "segmented-thumb", "aria-hidden": "true", style: {
|
|
73
73
|
width: thumb.width,
|
|
74
74
|
transform: `translateX(${thumb.left}px)`,
|
|
@@ -39,6 +39,7 @@ exports.TableToolbar = TableToolbar;
|
|
|
39
39
|
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
40
40
|
const lucide_react_1 = require("lucide-react");
|
|
41
41
|
const React = __importStar(require("react"));
|
|
42
|
+
const core_1 = require("../../core");
|
|
42
43
|
const select_1 = require("../select");
|
|
43
44
|
const tabletoolbar_recipe_1 = require("./tabletoolbar.recipe");
|
|
44
45
|
const OP_LABELS = {
|
|
@@ -186,7 +187,16 @@ function TableToolbar({ columns, sorts: sortsProp, defaultSorts, onSortsChange,
|
|
|
186
187
|
};
|
|
187
188
|
const setSortField = (i, key) => putSorts(sorts.map((s, j) => (j === i ? { ...s, key } : s)));
|
|
188
189
|
const setSortDir = (i, dir) => putSorts(sorts.map((s, j) => (j === i ? { ...s, dir } : s)));
|
|
189
|
-
|
|
190
|
+
// Removing a sort row destroys the button that was focused. Without this
|
|
191
|
+
// focus falls to document.body, inside a popover the user then cannot leave
|
|
192
|
+
// with Escape because nothing in it is focused any more (#239).
|
|
193
|
+
const sortListRef = React.useRef(null);
|
|
194
|
+
const addSortRef = React.useRef(null);
|
|
195
|
+
const focusAfterSortRemoval = (0, core_1.useFocusAfterRemoval)(sortListRef, ".tabletoolbar-sort-remove", addSortRef);
|
|
196
|
+
const removeSort = (i) => {
|
|
197
|
+
focusAfterSortRemoval(i);
|
|
198
|
+
putSorts(sorts.filter((_, j) => j !== i));
|
|
199
|
+
};
|
|
190
200
|
const addFilter = (key) => {
|
|
191
201
|
const id = `f${Date.now()}${Math.random().toString(36).slice(2, 6)}`;
|
|
192
202
|
putFilters([...filters, { id, key, op: "contains", value: "" }]);
|
|
@@ -206,7 +216,7 @@ function TableToolbar({ columns, sorts: sortsProp, defaultSorts, onSortsChange,
|
|
|
206
216
|
: sorts.length
|
|
207
217
|
? `Sorted by ${columns.find((c) => c.key === sorts[0].key)?.label}${sorts.length > 1 ? ` +${sorts.length - 1}` : ""}`
|
|
208
218
|
: "Sort";
|
|
209
|
-
return ((0, jsx_runtime_1.jsx)("div", { className: (0, tabletoolbar_recipe_1.tabletoolbarRecipe)({ className }), ...rest, children: (0, jsx_runtime_1.jsxs)("div", { className: "tabletoolbar-toolbar", children: [(0, jsx_runtime_1.jsxs)("span", { className: "tabletoolbar-toolgroup", children: [(0, jsx_runtime_1.jsxs)("button", { type: "button", className: `tabletoolbar-tool${sorts.length ? " tabletoolbar-tool--active" : ""}`, onClick: () => setOpenMenu(openMenu === "sort" ? null : "sort"), "aria-haspopup": "dialog", "aria-expanded": openMenu === "sort", "data-menu-trigger": true, children: [(0, jsx_runtime_1.jsx)(lucide_react_1.ArrowUpDown, { size: 14, strokeWidth: 1.75, "aria-hidden": "true" }), sortedLabel] }), openMenu === "sort" && ((0, jsx_runtime_1.jsxs)("div", { className: "tabletoolbar-menu tabletoolbar-sortpop", role: "dialog", "aria-label": sortLabel, children: [sorts.length === 0 ? ((0, jsx_runtime_1.jsx)("div", { className: "tabletoolbar-sort-empty", children: noSortsLabel })) : (sorts.map((s, i) => ((0, jsx_runtime_1.jsxs)("div", { className: "tabletoolbar-sort-row", onDragOver: (e) => {
|
|
219
|
+
return ((0, jsx_runtime_1.jsx)("div", { className: (0, tabletoolbar_recipe_1.tabletoolbarRecipe)({ className }), ...rest, children: (0, jsx_runtime_1.jsxs)("div", { className: "tabletoolbar-toolbar", children: [(0, jsx_runtime_1.jsxs)("span", { className: "tabletoolbar-toolgroup", children: [(0, jsx_runtime_1.jsxs)("button", { type: "button", className: `tabletoolbar-tool${sorts.length ? " tabletoolbar-tool--active" : ""}`, onClick: () => setOpenMenu(openMenu === "sort" ? null : "sort"), "aria-haspopup": "dialog", "aria-expanded": openMenu === "sort", "data-menu-trigger": true, children: [(0, jsx_runtime_1.jsx)(lucide_react_1.ArrowUpDown, { size: 14, strokeWidth: 1.75, "aria-hidden": "true" }), sortedLabel] }), openMenu === "sort" && ((0, jsx_runtime_1.jsxs)("div", { ref: sortListRef, className: "tabletoolbar-menu tabletoolbar-sortpop", role: "dialog", "aria-label": sortLabel, children: [sorts.length === 0 ? ((0, jsx_runtime_1.jsx)("div", { className: "tabletoolbar-sort-empty", children: noSortsLabel })) : (sorts.map((s, i) => ((0, jsx_runtime_1.jsxs)("div", { className: "tabletoolbar-sort-row", onDragOver: (e) => {
|
|
210
220
|
if (dragSort !== null)
|
|
211
221
|
e.preventDefault();
|
|
212
222
|
}, onDrop: () => {
|
|
@@ -228,7 +238,7 @@ function TableToolbar({ columns, sorts: sortsProp, defaultSorts, onSortsChange,
|
|
|
228
238
|
label: ((0, jsx_runtime_1.jsxs)("span", { className: "tabletoolbar-sort-optlabel", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.ArrowDownWideNarrow, { size: 15, strokeWidth: 1.75 }), descendingLabel] })),
|
|
229
239
|
keywords: descendingLabel,
|
|
230
240
|
},
|
|
231
|
-
], "aria-label": sortDirectionLabel }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "tabletoolbar-sort-remove", onClick: () => removeSort(i), "aria-label": removeSortLabel, children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 15, strokeWidth: 1.75 }) })] }, s.key)))), (0, jsx_runtime_1.jsx)("div", { className: "tabletoolbar-sort-addrow", children: (0, jsx_runtime_1.jsxs)("button", { type: "button", className: "tabletoolbar-sort-add", onClick: addSort, children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Plus, { size: 15, strokeWidth: 1.75 }), addSortLabel] }) }), helpHref && ((0, jsx_runtime_1.jsx)("div", { className: "tabletoolbar-sort-foot", children: (0, jsx_runtime_1.jsxs)("a", { className: "tabletoolbar-sort-learn", href: helpHref, target: "_blank", rel: "noreferrer", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.CircleHelp, { size: 14, strokeWidth: 1.75 }), learnSortingLabel] }) }))] }))] }), (0, jsx_runtime_1.jsxs)("span", { className: "tabletoolbar-toolgroup", children: [(0, jsx_runtime_1.jsxs)("button", { type: "button", className: `tabletoolbar-tool tabletoolbar-tool--ghost${activeFilters ? " tabletoolbar-tool--active" : ""}`, onClick: () => setOpenMenu(openMenu === "filter" ? null : "filter"), "aria-haspopup": "dialog", "aria-expanded": openMenu === "filter", "data-menu-trigger": true, children: [(0, jsx_runtime_1.jsx)(lucide_react_1.ListFilter, { size: 14, strokeWidth: 1.75, "aria-hidden": "true" }), filterTriggerLabel
|
|
241
|
+
], "aria-label": sortDirectionLabel }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "tabletoolbar-sort-remove", onClick: () => removeSort(i), "aria-label": removeSortLabel, children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 15, strokeWidth: 1.75 }) })] }, s.key)))), (0, jsx_runtime_1.jsx)("div", { className: "tabletoolbar-sort-addrow", children: (0, jsx_runtime_1.jsxs)("button", { ref: addSortRef, type: "button", className: "tabletoolbar-sort-add", onClick: addSort, children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Plus, { size: 15, strokeWidth: 1.75 }), addSortLabel] }) }), helpHref && ((0, jsx_runtime_1.jsx)("div", { className: "tabletoolbar-sort-foot", children: (0, jsx_runtime_1.jsxs)("a", { className: "tabletoolbar-sort-learn", href: helpHref, target: "_blank", rel: "noreferrer", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.CircleHelp, { size: 14, strokeWidth: 1.75 }), learnSortingLabel] }) }))] }))] }), (0, jsx_runtime_1.jsxs)("span", { className: "tabletoolbar-toolgroup", children: [(0, jsx_runtime_1.jsxs)("button", { type: "button", className: `tabletoolbar-tool tabletoolbar-tool--ghost${activeFilters ? " tabletoolbar-tool--active" : ""}`, onClick: () => setOpenMenu(openMenu === "filter" ? null : "filter"), "aria-haspopup": "dialog", "aria-expanded": openMenu === "filter", "data-menu-trigger": true, children: [(0, jsx_runtime_1.jsx)(lucide_react_1.ListFilter, { size: 14, strokeWidth: 1.75, "aria-hidden": "true" }), filterTriggerLabel
|
|
232
242
|
? filterTriggerLabel(activeFilters)
|
|
233
243
|
: activeFilters
|
|
234
244
|
? `Filter · ${activeFilters}`
|
|
@@ -44,6 +44,15 @@ function useTablistKeyboard({ tabs, active, select, listRef, }) {
|
|
|
44
44
|
* arrows move within. Without this a keyboard user walks through every tab
|
|
45
45
|
* one at a time, and the number of stops grows with the tab set.
|
|
46
46
|
*/
|
|
47
|
-
const tabIndexFor = (value) =>
|
|
47
|
+
const tabIndexFor = (value) => {
|
|
48
|
+
// ...and a group with no tab stop at all is worse than one with too many:
|
|
49
|
+
// a controlled `value` that matches no tab gave every tab -1, so Tab
|
|
50
|
+
// skipped the whole strip (#237). Fall back to the first enabled tab,
|
|
51
|
+
// which is where focus would land anyway.
|
|
52
|
+
const stop = tabs.some((t) => t.value === active && !t.disabled)
|
|
53
|
+
? active
|
|
54
|
+
: tabs.find((t) => !t.disabled)?.value;
|
|
55
|
+
return value === stop ? 0 : -1;
|
|
56
|
+
};
|
|
48
57
|
return { onKeyDown, tabIndexFor };
|
|
49
58
|
}
|
|
@@ -38,6 +38,7 @@ exports.useToast = useToast;
|
|
|
38
38
|
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
39
39
|
const lucide_react_1 = require("lucide-react");
|
|
40
40
|
const React = __importStar(require("react"));
|
|
41
|
+
const core_1 = require("../../core");
|
|
41
42
|
const toast_recipe_1 = require("./toast.recipe");
|
|
42
43
|
const ToastContext = React.createContext(null);
|
|
43
44
|
const ICONS = {
|
|
@@ -62,6 +63,29 @@ function ToastProvider({ children, duration = 4000, dismissLabel = "Dismiss", re
|
|
|
62
63
|
const [items, setItems] = React.useState([]);
|
|
63
64
|
const idRef = React.useRef(0);
|
|
64
65
|
const timers = React.useRef(new Map());
|
|
66
|
+
// Dismissing a toast from the keyboard destroys the button that was focused,
|
|
67
|
+
// and focus falls to document.body — the page the user was working in is
|
|
68
|
+
// gone from under them (#239). The next toast takes the focus; when that was
|
|
69
|
+
// the last one, focus goes back to wherever it was when they stepped into
|
|
70
|
+
// the stack, which is the only sensible place left.
|
|
71
|
+
const viewportRef = React.useRef(null);
|
|
72
|
+
const cameFrom = React.useRef(null);
|
|
73
|
+
const focusAfterDismiss = (0, core_1.useFocusAfterRemoval)(viewportRef, ".toast-close", cameFrom);
|
|
74
|
+
// Tracked from the document, not from the viewport's own focus event: a
|
|
75
|
+
// focusin's relatedTarget is the element LOSING focus, which browsers fill
|
|
76
|
+
// in and happy-dom does not, and either way it is only right when focus
|
|
77
|
+
// enters the stack in one hop. What is wanted is simply the last thing
|
|
78
|
+
// focused outside the stack.
|
|
79
|
+
React.useEffect(() => {
|
|
80
|
+
const onFocusIn = (e) => {
|
|
81
|
+
const target = e.target;
|
|
82
|
+
if (!target || viewportRef.current?.contains(target))
|
|
83
|
+
return;
|
|
84
|
+
cameFrom.current = target;
|
|
85
|
+
};
|
|
86
|
+
document.addEventListener("focusin", onFocusIn);
|
|
87
|
+
return () => document.removeEventListener("focusin", onFocusIn);
|
|
88
|
+
}, []);
|
|
65
89
|
const dismiss = React.useCallback((id) => {
|
|
66
90
|
setItems((list) => list.filter((t) => t.id !== id));
|
|
67
91
|
const t = timers.current.get(id);
|
|
@@ -84,7 +108,10 @@ function ToastProvider({ children, duration = 4000, dismissLabel = "Dismiss", re
|
|
|
84
108
|
return () => map.forEach(clearTimeout);
|
|
85
109
|
}, []);
|
|
86
110
|
const api = React.useMemo(() => ({ toast, dismiss }), [toast, dismiss]);
|
|
87
|
-
return ((0, jsx_runtime_1.jsxs)(ToastContext.Provider, { value: api, children: [children, (0, jsx_runtime_1.jsx)("div", { className: (0, toast_recipe_1.toastRecipe)(), children: (0, jsx_runtime_1.jsx)("div", { className: "toast-viewport", role: "region", "aria-label": regionLabel, "aria-live": "polite", "aria-atomic": "false", children: items.map((t) => ((0, jsx_runtime_1.jsx)(ToastCard, { item: t, onClose: () =>
|
|
111
|
+
return ((0, jsx_runtime_1.jsxs)(ToastContext.Provider, { value: api, children: [children, (0, jsx_runtime_1.jsx)("div", { className: (0, toast_recipe_1.toastRecipe)(), children: (0, jsx_runtime_1.jsx)("div", { ref: viewportRef, className: "toast-viewport", role: "region", "aria-label": regionLabel, "aria-live": "polite", "aria-atomic": "false", children: items.map((t, i) => ((0, jsx_runtime_1.jsx)(ToastCard, { item: t, onClose: () => {
|
|
112
|
+
focusAfterDismiss(i);
|
|
113
|
+
dismiss(t.id);
|
|
114
|
+
}, dismissLabel: dismissLabel }, t.id))) }) })] }));
|
|
88
115
|
}
|
|
89
116
|
ToastProvider.displayName = "ToastProvider";
|
|
90
117
|
function useToast() {
|
package/dist/cjs/core/index.js
CHANGED
|
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.usePrefersReducedMotion = exports.resolveRecipe = exports.Primitive = exports.placeFloating = exports.isRtlElement = exports.withDescribedBy = exports.defineRecipe = exports.cx = void 0;
|
|
17
|
+
exports.usePrefersReducedMotion = exports.useFocusAfterRemoval = exports.resolveRecipe = exports.Primitive = exports.placeFloating = exports.isRtlElement = exports.withDescribedBy = exports.defineRecipe = exports.cx = void 0;
|
|
18
18
|
var cx_1 = require("./cx");
|
|
19
19
|
Object.defineProperty(exports, "cx", { enumerable: true, get: function () { return cx_1.cx; } });
|
|
20
20
|
var define_1 = require("./define");
|
|
@@ -30,5 +30,7 @@ Object.defineProperty(exports, "Primitive", { enumerable: true, get: function ()
|
|
|
30
30
|
var resolve_1 = require("./resolve");
|
|
31
31
|
Object.defineProperty(exports, "resolveRecipe", { enumerable: true, get: function () { return resolve_1.resolveRecipe; } });
|
|
32
32
|
__exportStar(require("./rtl"), exports);
|
|
33
|
+
var use_focus_after_removal_1 = require("./use-focus-after-removal");
|
|
34
|
+
Object.defineProperty(exports, "useFocusAfterRemoval", { enumerable: true, get: function () { return use_focus_after_removal_1.useFocusAfterRemoval; } });
|
|
33
35
|
var use_prefers_reduced_motion_1 = require("./use-prefers-reduced-motion");
|
|
34
36
|
Object.defineProperty(exports, "usePrefersReducedMotion", { enumerable: true, get: function () { return use_prefers_reduced_motion_1.usePrefersReducedMotion; } });
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.useFocusAfterRemoval = useFocusAfterRemoval;
|
|
37
|
+
const React = __importStar(require("react"));
|
|
38
|
+
/**
|
|
39
|
+
* Put focus somewhere deliberate when the control the user just pressed
|
|
40
|
+
* removes itself.
|
|
41
|
+
*
|
|
42
|
+
* Pressing "remove" on a file, a sort row or a toast deletes the button that
|
|
43
|
+
* was focused, and the browser's answer to "where does focus go now" is
|
|
44
|
+
* `document.body` — the user's place in the page is gone and Tab starts again
|
|
45
|
+
* from the top. Nothing throws, nothing is drawn differently, and a mouse
|
|
46
|
+
* never notices (#239).
|
|
47
|
+
*
|
|
48
|
+
* The target is decided by POSITION, not identity: the item that slid into the
|
|
49
|
+
* removed one's place, else the last one, else a fallback the caller names (the
|
|
50
|
+
* control that adds items, usually). Identity is no good — the element the
|
|
51
|
+
* caller would name is the one being destroyed.
|
|
52
|
+
*
|
|
53
|
+
* Returns a function to call with the index being removed. The focus move
|
|
54
|
+
* happens in a layout effect, because at the moment of the click the item is
|
|
55
|
+
* still on screen: React has not re-rendered yet, and focusing the neighbour
|
|
56
|
+
* before the removal would just be undone by it.
|
|
57
|
+
*
|
|
58
|
+
* `useLayoutEffect` rather than `useEffect` is deliberate and is the one thing
|
|
59
|
+
* here no test in this repo can hold: under `fireEvent` both flush before the
|
|
60
|
+
* assertions, so swapping them changes nothing a test can see. In a browser it
|
|
61
|
+
* is the difference between moving focus before the paint and a frame after
|
|
62
|
+
* it — a visible ring flicker, and an announcement that arrives late.
|
|
63
|
+
*/
|
|
64
|
+
function useFocusAfterRemoval(containerRef,
|
|
65
|
+
/** CSS selector matching the per-item control, in document order. */
|
|
66
|
+
selector,
|
|
67
|
+
/** Where to go when nothing is left. */
|
|
68
|
+
fallbackRef) {
|
|
69
|
+
const [pending, setPending] = React.useState(null);
|
|
70
|
+
React.useLayoutEffect(() => {
|
|
71
|
+
if (pending == null)
|
|
72
|
+
return;
|
|
73
|
+
setPending(null);
|
|
74
|
+
const root = containerRef.current;
|
|
75
|
+
if (!root)
|
|
76
|
+
return;
|
|
77
|
+
const items = Array.from(root.querySelectorAll(selector));
|
|
78
|
+
const next = items.length > 0
|
|
79
|
+
? (items[Math.min(pending, items.length - 1)] ?? null)
|
|
80
|
+
: (fallbackRef?.current ?? null);
|
|
81
|
+
next?.focus({ preventScroll: true });
|
|
82
|
+
}, [pending, containerRef, selector, fallbackRef]);
|
|
83
|
+
return setPending;
|
|
84
|
+
}
|
|
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import { useFileUpload, } from "@seed-design/react-file-upload";
|
|
4
4
|
import { X } from "lucide-react";
|
|
5
5
|
import * as React from "react";
|
|
6
|
+
import { useFocusAfterRemoval } from "../../core/index.js";
|
|
6
7
|
import { fileuploadRecipe } from "./fileupload.recipe.js";
|
|
7
8
|
export const fileUploadLabels = {
|
|
8
9
|
// Attio's own copy on the record Files pane.
|
|
@@ -77,5 +78,17 @@ export function FileUpload({ className, labels, "aria-label": ariaLabel, accept,
|
|
|
77
78
|
// instead means attaching them here; without inputRef, `openFilePicker`
|
|
78
79
|
// finds no input and returns silently, so the dropzone opens nothing.
|
|
79
80
|
inputRef, dropzoneRef, } = api;
|
|
80
|
-
|
|
81
|
+
// Removing a file destroys the button that was focused; without this, focus
|
|
82
|
+
// lands on document.body and the user starts Tabbing from the top of the
|
|
83
|
+
// page (#239). The dropzone is where the list came from, so it is where the
|
|
84
|
+
// last removal leaves you.
|
|
85
|
+
// The container is the ROOT, not the list: the list unmounts when the last
|
|
86
|
+
// file goes, and a container that disappears with the item cannot say where
|
|
87
|
+
// focus should land next.
|
|
88
|
+
const rootRef = React.useRef(null);
|
|
89
|
+
const focusAfterRemoval = useFocusAfterRemoval(rootRef, ".fileupload-remove", dropzoneRef);
|
|
90
|
+
return (_jsxs("div", { ref: rootRef, ...rest, className: fileuploadRecipe({ className }), children: [_jsxs("button", { ...dropzoneProps, ref: dropzoneRef, type: "button", className: "fileupload-zone", tabIndex: disabled ? -1 : 0, "aria-label": ariaLabel ?? t.hint, disabled: disabled, "data-dragging": dragging ? "" : undefined, "data-disabled": disabled ? "" : undefined, "data-invalid": invalid ? "" : undefined, onClick: () => openFilePicker(), children: [_jsx("span", { className: "fileupload-title", children: t.title }), _jsx("span", { className: "fileupload-hint", children: t.hint })] }), _jsx("input", { ...hiddenInputProps, ref: inputRef, className: "fileupload-input" }), acceptedFileEntries.length > 0 && (_jsx("ul", { className: "fileupload-list", children: acceptedFileEntries.map((entry, i) => (_jsxs("li", { className: "fileupload-item", "data-status": entry.status, children: [_jsx("span", { className: "fileupload-item-name", children: entry.file.name }), entry.status === "uploading" && entry.progress != null && (_jsx("span", { className: "fileupload-progress", role: "progressbar", "aria-valuenow": Math.round(entry.progress), "aria-valuemin": 0, "aria-valuemax": 100, "aria-label": entry.file.name, children: _jsx("span", { className: "fileupload-progress-fill", style: { width: `${entry.progress}%` } }) })), _jsx("span", { className: "fileupload-item-size", children: formatSize(entry.file.size) }), _jsx("button", { type: "button", className: "fileupload-remove", "aria-label": `${t.remove}: ${entry.file.name}`, onClick: () => {
|
|
91
|
+
focusAfterRemoval(i);
|
|
92
|
+
removeFileEntry(entry.id);
|
|
93
|
+
}, children: _jsx(X, { size: 13, strokeWidth: 2 }) })] }, entry.id))) })), _jsx("ul", { className: "fileupload-errors", "aria-live": "polite", children: rejections.map((r) => (_jsx("li", { className: "fileupload-error", children: `${r.file.name}: ${r.errors.map((e) => t.errors[e] ?? e).join(" ")}` }, r.file.name))) })] }));
|
|
81
94
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as React from "react";
|
|
1
2
|
/**
|
|
2
3
|
* The WAI-ARIA radio group keyboard model: ←/→/↑/↓ move focus to the next or
|
|
3
4
|
* previous enabled radio, wrapping, and check it on arrival. Home/End jump to
|
|
@@ -22,7 +23,39 @@
|
|
|
22
23
|
* `Segmented` already offered them, and the shared model should be the union
|
|
23
24
|
* rather than quietly dropping behaviour one surface had.
|
|
24
25
|
*/
|
|
25
|
-
export function useRadioGroupKeyboard({ select, }) {
|
|
26
|
+
export function useRadioGroupKeyboard({ select, groupRef: externalRef, }) {
|
|
27
|
+
/**
|
|
28
|
+
* The group's one tab stop. `tabIndex={checked ? 0 : -1}` in the markup is
|
|
29
|
+
* right for every case but the one that matters most: a group with NOTHING
|
|
30
|
+
* selected gives every radio -1, so Tab skips the whole control and an
|
|
31
|
+
* unanswered question can never be answered from the keyboard (#237). APG
|
|
32
|
+
* says the first radio is tabbable when none is checked, and RadioGroup's
|
|
33
|
+
* own comment said so too — "the selected (or first, when none) radio is
|
|
34
|
+
* tabbable" — while the code did only the first half.
|
|
35
|
+
*
|
|
36
|
+
* Owned here rather than in the markup because the group renders arbitrary
|
|
37
|
+
* children: only the DOM knows which radio comes first, which is the same
|
|
38
|
+
* reason the arrow keys read the radios out of the DOM.
|
|
39
|
+
*
|
|
40
|
+
* It runs after EVERY render, and sets both ends. React will not undo a
|
|
41
|
+
* mutation it did not make — when the selection moves, the newly checked
|
|
42
|
+
* radio changes 0 from React's side while the stale one keeps the 0 written
|
|
43
|
+
* here — so this has to own the whole row, not just add to it.
|
|
44
|
+
*/
|
|
45
|
+
const ownRef = React.useRef(null);
|
|
46
|
+
const groupRef = externalRef ?? ownRef;
|
|
47
|
+
React.useLayoutEffect(() => {
|
|
48
|
+
const root = groupRef.current;
|
|
49
|
+
if (!root)
|
|
50
|
+
return;
|
|
51
|
+
const radios = Array.from(root.querySelectorAll('[role="radio"]:not([disabled])'));
|
|
52
|
+
if (!radios.length)
|
|
53
|
+
return;
|
|
54
|
+
const checked = radios.find((r) => r.getAttribute("aria-checked") === "true");
|
|
55
|
+
const stop = checked ?? radios[0];
|
|
56
|
+
for (const r of radios)
|
|
57
|
+
r.tabIndex = r === stop ? 0 : -1;
|
|
58
|
+
});
|
|
26
59
|
const onKeyDown = (e) => {
|
|
27
60
|
const KEYS = [
|
|
28
61
|
"ArrowDown",
|
|
@@ -60,5 +93,5 @@ export function useRadioGroupKeyboard({ select, }) {
|
|
|
60
93
|
if (value !== undefined)
|
|
61
94
|
select(value);
|
|
62
95
|
};
|
|
63
|
-
return { onKeyDown };
|
|
96
|
+
return { onKeyDown, groupRef };
|
|
64
97
|
}
|
|
@@ -19,8 +19,8 @@ export function RadioGroup({ value, defaultValue, onValueChange, variant = "defa
|
|
|
19
19
|
setInternal(v);
|
|
20
20
|
onValueChange?.(v);
|
|
21
21
|
}, [value, onValueChange]);
|
|
22
|
-
const { onKeyDown } = useRadioGroupKeyboard({ select });
|
|
23
|
-
return (_jsx(RadioContext.Provider, { value: { value: selected, select, name: groupName, variant }, children: _jsx("div", { role: "radiogroup", "aria-label": ariaLabel, "data-variant": variant, className: radiogroupRecipe({ className }), ...rest, onKeyDown: onKeyDown, children: children }) }));
|
|
22
|
+
const { onKeyDown, groupRef } = useRadioGroupKeyboard({ select });
|
|
23
|
+
return (_jsx(RadioContext.Provider, { value: { value: selected, select, name: groupName, variant }, children: _jsx("div", { ref: groupRef, role: "radiogroup", "aria-label": ariaLabel, "data-variant": variant, className: radiogroupRecipe({ className }), ...rest, onKeyDown: onKeyDown, children: children }) }));
|
|
24
24
|
}
|
|
25
25
|
RadioGroup.displayName = "RadioGroup";
|
|
26
26
|
export function Radio({ value, label, children, disabled }) {
|
|
@@ -32,7 +32,7 @@ export function Segmented({ options, value, defaultValue, onValueChange, "aria-l
|
|
|
32
32
|
// crisp's shared radio group model (#200). The per-button handler this
|
|
33
33
|
// replaces closed over its own index, so it selected a neighbour without
|
|
34
34
|
// moving focus and every press after the first recomputed the same step.
|
|
35
|
-
const { onKeyDown } = useRadioGroupKeyboard({ select });
|
|
35
|
+
const { onKeyDown } = useRadioGroupKeyboard({ select, groupRef: rootRef });
|
|
36
36
|
return (_jsxs("div", { ref: rootRef, className: segmentedRecipe({ className }), ...rest, role: "radiogroup", "aria-label": ariaLabel, onKeyDown: onKeyDown, children: [thumb && (_jsx("span", { className: "segmented-thumb", "aria-hidden": "true", style: {
|
|
37
37
|
width: thumb.width,
|
|
38
38
|
transform: `translateX(${thumb.left}px)`,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { ArrowDownWideNarrow, ArrowUpDown, ArrowUpNarrowWide, Check, CircleHelp, GripVertical, ListFilter, Plus, X, } from "lucide-react";
|
|
3
3
|
import * as React from "react";
|
|
4
|
+
import { useFocusAfterRemoval } from "../../core/index.js";
|
|
4
5
|
import { Select } from "../select/index.js";
|
|
5
6
|
import { tabletoolbarRecipe } from "./tabletoolbar.recipe.js";
|
|
6
7
|
const OP_LABELS = {
|
|
@@ -148,7 +149,16 @@ export function TableToolbar({ columns, sorts: sortsProp, defaultSorts, onSortsC
|
|
|
148
149
|
};
|
|
149
150
|
const setSortField = (i, key) => putSorts(sorts.map((s, j) => (j === i ? { ...s, key } : s)));
|
|
150
151
|
const setSortDir = (i, dir) => putSorts(sorts.map((s, j) => (j === i ? { ...s, dir } : s)));
|
|
151
|
-
|
|
152
|
+
// Removing a sort row destroys the button that was focused. Without this
|
|
153
|
+
// focus falls to document.body, inside a popover the user then cannot leave
|
|
154
|
+
// with Escape because nothing in it is focused any more (#239).
|
|
155
|
+
const sortListRef = React.useRef(null);
|
|
156
|
+
const addSortRef = React.useRef(null);
|
|
157
|
+
const focusAfterSortRemoval = useFocusAfterRemoval(sortListRef, ".tabletoolbar-sort-remove", addSortRef);
|
|
158
|
+
const removeSort = (i) => {
|
|
159
|
+
focusAfterSortRemoval(i);
|
|
160
|
+
putSorts(sorts.filter((_, j) => j !== i));
|
|
161
|
+
};
|
|
152
162
|
const addFilter = (key) => {
|
|
153
163
|
const id = `f${Date.now()}${Math.random().toString(36).slice(2, 6)}`;
|
|
154
164
|
putFilters([...filters, { id, key, op: "contains", value: "" }]);
|
|
@@ -168,7 +178,7 @@ export function TableToolbar({ columns, sorts: sortsProp, defaultSorts, onSortsC
|
|
|
168
178
|
: sorts.length
|
|
169
179
|
? `Sorted by ${columns.find((c) => c.key === sorts[0].key)?.label}${sorts.length > 1 ? ` +${sorts.length - 1}` : ""}`
|
|
170
180
|
: "Sort";
|
|
171
|
-
return (_jsx("div", { className: tabletoolbarRecipe({ className }), ...rest, children: _jsxs("div", { className: "tabletoolbar-toolbar", children: [_jsxs("span", { className: "tabletoolbar-toolgroup", children: [_jsxs("button", { type: "button", className: `tabletoolbar-tool${sorts.length ? " tabletoolbar-tool--active" : ""}`, onClick: () => setOpenMenu(openMenu === "sort" ? null : "sort"), "aria-haspopup": "dialog", "aria-expanded": openMenu === "sort", "data-menu-trigger": true, children: [_jsx(ArrowUpDown, { size: 14, strokeWidth: 1.75, "aria-hidden": "true" }), sortedLabel] }), openMenu === "sort" && (_jsxs("div", { className: "tabletoolbar-menu tabletoolbar-sortpop", role: "dialog", "aria-label": sortLabel, children: [sorts.length === 0 ? (_jsx("div", { className: "tabletoolbar-sort-empty", children: noSortsLabel })) : (sorts.map((s, i) => (_jsxs("div", { className: "tabletoolbar-sort-row", onDragOver: (e) => {
|
|
181
|
+
return (_jsx("div", { className: tabletoolbarRecipe({ className }), ...rest, children: _jsxs("div", { className: "tabletoolbar-toolbar", children: [_jsxs("span", { className: "tabletoolbar-toolgroup", children: [_jsxs("button", { type: "button", className: `tabletoolbar-tool${sorts.length ? " tabletoolbar-tool--active" : ""}`, onClick: () => setOpenMenu(openMenu === "sort" ? null : "sort"), "aria-haspopup": "dialog", "aria-expanded": openMenu === "sort", "data-menu-trigger": true, children: [_jsx(ArrowUpDown, { size: 14, strokeWidth: 1.75, "aria-hidden": "true" }), sortedLabel] }), openMenu === "sort" && (_jsxs("div", { ref: sortListRef, className: "tabletoolbar-menu tabletoolbar-sortpop", role: "dialog", "aria-label": sortLabel, children: [sorts.length === 0 ? (_jsx("div", { className: "tabletoolbar-sort-empty", children: noSortsLabel })) : (sorts.map((s, i) => (_jsxs("div", { className: "tabletoolbar-sort-row", onDragOver: (e) => {
|
|
172
182
|
if (dragSort !== null)
|
|
173
183
|
e.preventDefault();
|
|
174
184
|
}, onDrop: () => {
|
|
@@ -190,7 +200,7 @@ export function TableToolbar({ columns, sorts: sortsProp, defaultSorts, onSortsC
|
|
|
190
200
|
label: (_jsxs("span", { className: "tabletoolbar-sort-optlabel", children: [_jsx(ArrowDownWideNarrow, { size: 15, strokeWidth: 1.75 }), descendingLabel] })),
|
|
191
201
|
keywords: descendingLabel,
|
|
192
202
|
},
|
|
193
|
-
], "aria-label": sortDirectionLabel }), _jsx("button", { type: "button", className: "tabletoolbar-sort-remove", onClick: () => removeSort(i), "aria-label": removeSortLabel, children: _jsx(X, { size: 15, strokeWidth: 1.75 }) })] }, s.key)))), _jsx("div", { className: "tabletoolbar-sort-addrow", children: _jsxs("button", { type: "button", className: "tabletoolbar-sort-add", onClick: addSort, children: [_jsx(Plus, { size: 15, strokeWidth: 1.75 }), addSortLabel] }) }), helpHref && (_jsx("div", { className: "tabletoolbar-sort-foot", children: _jsxs("a", { className: "tabletoolbar-sort-learn", href: helpHref, target: "_blank", rel: "noreferrer", children: [_jsx(CircleHelp, { size: 14, strokeWidth: 1.75 }), learnSortingLabel] }) }))] }))] }), _jsxs("span", { className: "tabletoolbar-toolgroup", children: [_jsxs("button", { type: "button", className: `tabletoolbar-tool tabletoolbar-tool--ghost${activeFilters ? " tabletoolbar-tool--active" : ""}`, onClick: () => setOpenMenu(openMenu === "filter" ? null : "filter"), "aria-haspopup": "dialog", "aria-expanded": openMenu === "filter", "data-menu-trigger": true, children: [_jsx(ListFilter, { size: 14, strokeWidth: 1.75, "aria-hidden": "true" }), filterTriggerLabel
|
|
203
|
+
], "aria-label": sortDirectionLabel }), _jsx("button", { type: "button", className: "tabletoolbar-sort-remove", onClick: () => removeSort(i), "aria-label": removeSortLabel, children: _jsx(X, { size: 15, strokeWidth: 1.75 }) })] }, s.key)))), _jsx("div", { className: "tabletoolbar-sort-addrow", children: _jsxs("button", { ref: addSortRef, type: "button", className: "tabletoolbar-sort-add", onClick: addSort, children: [_jsx(Plus, { size: 15, strokeWidth: 1.75 }), addSortLabel] }) }), helpHref && (_jsx("div", { className: "tabletoolbar-sort-foot", children: _jsxs("a", { className: "tabletoolbar-sort-learn", href: helpHref, target: "_blank", rel: "noreferrer", children: [_jsx(CircleHelp, { size: 14, strokeWidth: 1.75 }), learnSortingLabel] }) }))] }))] }), _jsxs("span", { className: "tabletoolbar-toolgroup", children: [_jsxs("button", { type: "button", className: `tabletoolbar-tool tabletoolbar-tool--ghost${activeFilters ? " tabletoolbar-tool--active" : ""}`, onClick: () => setOpenMenu(openMenu === "filter" ? null : "filter"), "aria-haspopup": "dialog", "aria-expanded": openMenu === "filter", "data-menu-trigger": true, children: [_jsx(ListFilter, { size: 14, strokeWidth: 1.75, "aria-hidden": "true" }), filterTriggerLabel
|
|
194
204
|
? filterTriggerLabel(activeFilters)
|
|
195
205
|
: activeFilters
|
|
196
206
|
? `Filter · ${activeFilters}`
|
|
@@ -41,6 +41,15 @@ export function useTablistKeyboard({ tabs, active, select, listRef, }) {
|
|
|
41
41
|
* arrows move within. Without this a keyboard user walks through every tab
|
|
42
42
|
* one at a time, and the number of stops grows with the tab set.
|
|
43
43
|
*/
|
|
44
|
-
const tabIndexFor = (value) =>
|
|
44
|
+
const tabIndexFor = (value) => {
|
|
45
|
+
// ...and a group with no tab stop at all is worse than one with too many:
|
|
46
|
+
// a controlled `value` that matches no tab gave every tab -1, so Tab
|
|
47
|
+
// skipped the whole strip (#237). Fall back to the first enabled tab,
|
|
48
|
+
// which is where focus would land anyway.
|
|
49
|
+
const stop = tabs.some((t) => t.value === active && !t.disabled)
|
|
50
|
+
? active
|
|
51
|
+
: tabs.find((t) => !t.disabled)?.value;
|
|
52
|
+
return value === stop ? 0 : -1;
|
|
53
|
+
};
|
|
45
54
|
return { onKeyDown, tabIndexFor };
|
|
46
55
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { CircleAlert, CircleCheck, Info, TriangleAlert, X } from "lucide-react";
|
|
3
3
|
import * as React from "react";
|
|
4
|
+
import { useFocusAfterRemoval } from "../../core/index.js";
|
|
4
5
|
import { toastRecipe } from "./toast.recipe.js";
|
|
5
6
|
const ToastContext = React.createContext(null);
|
|
6
7
|
const ICONS = {
|
|
@@ -25,6 +26,29 @@ export function ToastProvider({ children, duration = 4000, dismissLabel = "Dismi
|
|
|
25
26
|
const [items, setItems] = React.useState([]);
|
|
26
27
|
const idRef = React.useRef(0);
|
|
27
28
|
const timers = React.useRef(new Map());
|
|
29
|
+
// Dismissing a toast from the keyboard destroys the button that was focused,
|
|
30
|
+
// and focus falls to document.body — the page the user was working in is
|
|
31
|
+
// gone from under them (#239). The next toast takes the focus; when that was
|
|
32
|
+
// the last one, focus goes back to wherever it was when they stepped into
|
|
33
|
+
// the stack, which is the only sensible place left.
|
|
34
|
+
const viewportRef = React.useRef(null);
|
|
35
|
+
const cameFrom = React.useRef(null);
|
|
36
|
+
const focusAfterDismiss = useFocusAfterRemoval(viewportRef, ".toast-close", cameFrom);
|
|
37
|
+
// Tracked from the document, not from the viewport's own focus event: a
|
|
38
|
+
// focusin's relatedTarget is the element LOSING focus, which browsers fill
|
|
39
|
+
// in and happy-dom does not, and either way it is only right when focus
|
|
40
|
+
// enters the stack in one hop. What is wanted is simply the last thing
|
|
41
|
+
// focused outside the stack.
|
|
42
|
+
React.useEffect(() => {
|
|
43
|
+
const onFocusIn = (e) => {
|
|
44
|
+
const target = e.target;
|
|
45
|
+
if (!target || viewportRef.current?.contains(target))
|
|
46
|
+
return;
|
|
47
|
+
cameFrom.current = target;
|
|
48
|
+
};
|
|
49
|
+
document.addEventListener("focusin", onFocusIn);
|
|
50
|
+
return () => document.removeEventListener("focusin", onFocusIn);
|
|
51
|
+
}, []);
|
|
28
52
|
const dismiss = React.useCallback((id) => {
|
|
29
53
|
setItems((list) => list.filter((t) => t.id !== id));
|
|
30
54
|
const t = timers.current.get(id);
|
|
@@ -47,7 +71,10 @@ export function ToastProvider({ children, duration = 4000, dismissLabel = "Dismi
|
|
|
47
71
|
return () => map.forEach(clearTimeout);
|
|
48
72
|
}, []);
|
|
49
73
|
const api = React.useMemo(() => ({ toast, dismiss }), [toast, dismiss]);
|
|
50
|
-
return (_jsxs(ToastContext.Provider, { value: api, children: [children, _jsx("div", { className: toastRecipe(), children: _jsx("div", { className: "toast-viewport", role: "region", "aria-label": regionLabel, "aria-live": "polite", "aria-atomic": "false", children: items.map((t) => (_jsx(ToastCard, { item: t, onClose: () =>
|
|
74
|
+
return (_jsxs(ToastContext.Provider, { value: api, children: [children, _jsx("div", { className: toastRecipe(), children: _jsx("div", { ref: viewportRef, className: "toast-viewport", role: "region", "aria-label": regionLabel, "aria-live": "polite", "aria-atomic": "false", children: items.map((t, i) => (_jsx(ToastCard, { item: t, onClose: () => {
|
|
75
|
+
focusAfterDismiss(i);
|
|
76
|
+
dismiss(t.id);
|
|
77
|
+
}, dismissLabel: dismissLabel }, t.id))) }) })] }));
|
|
51
78
|
}
|
|
52
79
|
ToastProvider.displayName = "ToastProvider";
|
|
53
80
|
export function useToast() {
|
package/dist/esm/core/index.js
CHANGED
|
@@ -6,4 +6,5 @@ export { placeFloating, } from "./place-floating.js";
|
|
|
6
6
|
export { Primitive } from "./primitive.js";
|
|
7
7
|
export { resolveRecipe } from "./resolve.js";
|
|
8
8
|
export * from "./rtl.js";
|
|
9
|
+
export { useFocusAfterRemoval } from "./use-focus-after-removal.js";
|
|
9
10
|
export { usePrefersReducedMotion } from "./use-prefers-reduced-motion.js";
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
/**
|
|
3
|
+
* Put focus somewhere deliberate when the control the user just pressed
|
|
4
|
+
* removes itself.
|
|
5
|
+
*
|
|
6
|
+
* Pressing "remove" on a file, a sort row or a toast deletes the button that
|
|
7
|
+
* was focused, and the browser's answer to "where does focus go now" is
|
|
8
|
+
* `document.body` — the user's place in the page is gone and Tab starts again
|
|
9
|
+
* from the top. Nothing throws, nothing is drawn differently, and a mouse
|
|
10
|
+
* never notices (#239).
|
|
11
|
+
*
|
|
12
|
+
* The target is decided by POSITION, not identity: the item that slid into the
|
|
13
|
+
* removed one's place, else the last one, else a fallback the caller names (the
|
|
14
|
+
* control that adds items, usually). Identity is no good — the element the
|
|
15
|
+
* caller would name is the one being destroyed.
|
|
16
|
+
*
|
|
17
|
+
* Returns a function to call with the index being removed. The focus move
|
|
18
|
+
* happens in a layout effect, because at the moment of the click the item is
|
|
19
|
+
* still on screen: React has not re-rendered yet, and focusing the neighbour
|
|
20
|
+
* before the removal would just be undone by it.
|
|
21
|
+
*
|
|
22
|
+
* `useLayoutEffect` rather than `useEffect` is deliberate and is the one thing
|
|
23
|
+
* here no test in this repo can hold: under `fireEvent` both flush before the
|
|
24
|
+
* assertions, so swapping them changes nothing a test can see. In a browser it
|
|
25
|
+
* is the difference between moving focus before the paint and a frame after
|
|
26
|
+
* it — a visible ring flicker, and an announcement that arrives late.
|
|
27
|
+
*/
|
|
28
|
+
export function useFocusAfterRemoval(containerRef,
|
|
29
|
+
/** CSS selector matching the per-item control, in document order. */
|
|
30
|
+
selector,
|
|
31
|
+
/** Where to go when nothing is left. */
|
|
32
|
+
fallbackRef) {
|
|
33
|
+
const [pending, setPending] = React.useState(null);
|
|
34
|
+
React.useLayoutEffect(() => {
|
|
35
|
+
if (pending == null)
|
|
36
|
+
return;
|
|
37
|
+
setPending(null);
|
|
38
|
+
const root = containerRef.current;
|
|
39
|
+
if (!root)
|
|
40
|
+
return;
|
|
41
|
+
const items = Array.from(root.querySelectorAll(selector));
|
|
42
|
+
const next = items.length > 0
|
|
43
|
+
? (items[Math.min(pending, items.length - 1)] ?? null)
|
|
44
|
+
: (fallbackRef?.current ?? null);
|
|
45
|
+
next?.focus({ preventScroll: true });
|
|
46
|
+
}, [pending, containerRef, selector, fallbackRef]);
|
|
47
|
+
return setPending;
|
|
48
|
+
}
|
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
import
|
|
1
|
+
import * as React from "react";
|
|
2
2
|
export type UseRadioGroupKeyboardOptions = {
|
|
3
3
|
/** Select a radio by its `data-value`. */
|
|
4
4
|
select: (value: string) => void;
|
|
5
|
+
/**
|
|
6
|
+
* The element carrying `role="radiogroup"`, when the surface already keeps a
|
|
7
|
+
* ref to it (Segmented measures its thumb from one). Omit it and the hook
|
|
8
|
+
* hands one back to attach.
|
|
9
|
+
*/
|
|
10
|
+
groupRef?: React.RefObject<HTMLElement | null>;
|
|
5
11
|
};
|
|
6
12
|
/**
|
|
7
13
|
* The WAI-ARIA radio group keyboard model: ←/→/↑/↓ move focus to the next or
|
|
@@ -27,6 +33,7 @@ export type UseRadioGroupKeyboardOptions = {
|
|
|
27
33
|
* `Segmented` already offered them, and the shared model should be the union
|
|
28
34
|
* rather than quietly dropping behaviour one surface had.
|
|
29
35
|
*/
|
|
30
|
-
export declare function useRadioGroupKeyboard({ select, }: UseRadioGroupKeyboardOptions): {
|
|
36
|
+
export declare function useRadioGroupKeyboard({ select, groupRef: externalRef, }: UseRadioGroupKeyboardOptions): {
|
|
31
37
|
onKeyDown: (e: React.KeyboardEvent) => void;
|
|
38
|
+
groupRef: React.RefObject<HTMLElement | null>;
|
|
32
39
|
};
|
|
@@ -7,4 +7,5 @@ export { Primitive } from "./primitive";
|
|
|
7
7
|
export { resolveRecipe } from "./resolve";
|
|
8
8
|
export * from "./rtl";
|
|
9
9
|
export type * from "./types";
|
|
10
|
+
export { useFocusAfterRemoval } from "./use-focus-after-removal";
|
|
10
11
|
export { usePrefersReducedMotion } from "./use-prefers-reduced-motion";
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
/**
|
|
3
|
+
* Put focus somewhere deliberate when the control the user just pressed
|
|
4
|
+
* removes itself.
|
|
5
|
+
*
|
|
6
|
+
* Pressing "remove" on a file, a sort row or a toast deletes the button that
|
|
7
|
+
* was focused, and the browser's answer to "where does focus go now" is
|
|
8
|
+
* `document.body` — the user's place in the page is gone and Tab starts again
|
|
9
|
+
* from the top. Nothing throws, nothing is drawn differently, and a mouse
|
|
10
|
+
* never notices (#239).
|
|
11
|
+
*
|
|
12
|
+
* The target is decided by POSITION, not identity: the item that slid into the
|
|
13
|
+
* removed one's place, else the last one, else a fallback the caller names (the
|
|
14
|
+
* control that adds items, usually). Identity is no good — the element the
|
|
15
|
+
* caller would name is the one being destroyed.
|
|
16
|
+
*
|
|
17
|
+
* Returns a function to call with the index being removed. The focus move
|
|
18
|
+
* happens in a layout effect, because at the moment of the click the item is
|
|
19
|
+
* still on screen: React has not re-rendered yet, and focusing the neighbour
|
|
20
|
+
* before the removal would just be undone by it.
|
|
21
|
+
*
|
|
22
|
+
* `useLayoutEffect` rather than `useEffect` is deliberate and is the one thing
|
|
23
|
+
* here no test in this repo can hold: under `fireEvent` both flush before the
|
|
24
|
+
* assertions, so swapping them changes nothing a test can see. In a browser it
|
|
25
|
+
* is the difference between moving focus before the paint and a frame after
|
|
26
|
+
* it — a visible ring flicker, and an announcement that arrives late.
|
|
27
|
+
*/
|
|
28
|
+
export declare function useFocusAfterRemoval(containerRef: React.RefObject<HTMLElement | null>,
|
|
29
|
+
/** CSS selector matching the per-item control, in document order. */
|
|
30
|
+
selector: string,
|
|
31
|
+
/** Where to go when nothing is left. */
|
|
32
|
+
fallbackRef?: React.RefObject<HTMLElement | null>): (removedIndex: number) => void;
|