@eifi1/ui-kit 0.8.1 → 0.10.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/README.md +25 -11
- package/dist/components/alert-banner.d.ts +32 -5
- package/dist/components/alert-banner.js +57 -14
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.d.ts +6 -0
- package/dist/components/breadcrumbs.d.ts +60 -0
- package/dist/components/breadcrumbs.js +88 -0
- package/dist/components/breadcrumbs.js.map +1 -0
- package/dist/components/bulk-action-bar.d.ts +66 -0
- package/dist/components/bulk-action-bar.js +149 -0
- package/dist/components/bulk-action-bar.js.map +1 -0
- package/dist/components/calculator.d.ts +6 -0
- package/dist/components/chip.d.ts +58 -10
- package/dist/components/chip.js +91 -7
- package/dist/components/chip.js.map +1 -1
- package/dist/components/copy-button.d.ts +13 -4
- package/dist/components/copy-button.js +17 -4
- package/dist/components/copy-button.js.map +1 -1
- package/dist/components/data-table-filter-popover.d.ts +1 -1
- package/dist/components/data-table-filters.d.ts +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +122 -91
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.d.ts +89 -3
- package/dist/components/date-picker.js +163 -68
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/description-list.d.ts +43 -6
- package/dist/components/description-list.js +91 -11
- package/dist/components/description-list.js.map +1 -1
- package/dist/components/dialog-frame.d.ts +7 -0
- package/dist/components/dialog-frame.js.map +1 -1
- package/dist/components/disclosure.d.ts +68 -4
- package/dist/components/disclosure.js +33 -15
- package/dist/components/disclosure.js.map +1 -1
- package/dist/components/file-dropzone.d.ts +3 -4
- package/dist/components/file-dropzone.js +2 -1
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/floating-panel.d.ts +29 -2
- package/dist/components/floating-panel.js +16 -2
- package/dist/components/floating-panel.js.map +1 -1
- package/dist/components/full-bleed-dialog.d.ts +23 -4
- package/dist/components/full-bleed-dialog.js +11 -2
- package/dist/components/full-bleed-dialog.js.map +1 -1
- package/dist/components/list.d.ts +174 -0
- package/dist/components/list.js +215 -0
- package/dist/components/list.js.map +1 -0
- package/dist/components/menu-item.d.ts +99 -0
- package/dist/components/menu-item.js +83 -0
- package/dist/components/menu-item.js.map +1 -0
- package/dist/components/modal.d.ts +23 -1
- package/dist/components/modal.js +36 -10
- package/dist/components/modal.js.map +1 -1
- package/dist/components/number-field.d.ts +6 -0
- package/dist/components/number-input.d.ts +6 -0
- package/dist/components/numpad-sheet.d.ts +6 -0
- package/dist/components/page-contents.js +1 -2
- package/dist/components/page-contents.js.map +1 -1
- package/dist/components/page-header.d.ts +37 -0
- package/dist/components/page-header.js +36 -0
- package/dist/components/page-header.js.map +1 -0
- package/dist/components/progress-bar.d.ts +16 -2
- package/dist/components/progress-bar.js +4 -2
- package/dist/components/progress-bar.js.map +1 -1
- package/dist/components/series-chart-labels.d.ts +3 -0
- package/dist/components/series-chart-labels.js +2 -1
- package/dist/components/series-chart-labels.js.map +1 -1
- package/dist/components/series-chart.d.ts +16 -0
- package/dist/components/series-chart.js +137 -3
- package/dist/components/series-chart.js.map +1 -1
- package/dist/components/status-dot.d.ts +49 -0
- package/dist/components/status-dot.js +52 -0
- package/dist/components/status-dot.js.map +1 -0
- package/dist/components/table.d.ts +39 -6
- package/dist/components/table.js +43 -4
- package/dist/components/table.js.map +1 -1
- package/dist/components/text.d.ts +63 -0
- package/dist/components/text.js +23 -0
- package/dist/components/text.js.map +1 -0
- package/dist/components/toast.d.ts +167 -0
- package/dist/components/toast.js +226 -0
- package/dist/components/toast.js.map +1 -0
- package/dist/components/toggle-group.d.ts +29 -1
- package/dist/components/toggle-group.js +52 -3
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/tooltip.d.ts +38 -15
- package/dist/components/tooltip.js +85 -47
- package/dist/components/tooltip.js.map +1 -1
- package/dist/components/ui.d.ts +139 -16
- package/dist/components/ui.js +136 -26
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -1
- package/dist/{data-table-filters-CF1PXqjQ.d.ts → data-table-filters-Dh9uF_S-.d.ts} +33 -2
- package/dist/data-table.d.ts +1 -1
- package/dist/hooks/use-close-transition.d.ts +7 -1
- package/dist/hooks/use-close-transition.js +5 -2
- package/dist/hooks/use-close-transition.js.map +1 -1
- package/dist/hooks/use-copy-to-clipboard.js +1 -1
- package/dist/hooks/use-copy-to-clipboard.js.map +1 -1
- package/dist/hooks/use-overlay-history.js +84 -4
- package/dist/hooks/use-overlay-history.js.map +1 -1
- package/dist/i18n/defaults.d.ts +6 -0
- package/dist/i18n/defaults.js +11 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +11 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/de-CH-informal.d.ts +6 -0
- package/dist/i18n/locales/de-CH.d.ts +6 -0
- package/dist/i18n/locales/de-informal.d.ts +6 -0
- package/dist/i18n/locales/de.d.ts +6 -0
- package/dist/i18n/locales/de.js +31 -1
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +6 -0
- package/dist/i18n/locales/es.js +31 -1
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +6 -0
- package/dist/i18n/locales/fr.js +31 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +6 -0
- package/dist/i18n/locales/hu.js +31 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +6 -0
- package/dist/i18n/locales/it.js +31 -1
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +6 -0
- package/dist/i18n/locales/zh.js +31 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +18 -8
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/clipping.d.ts +9 -0
- package/dist/lib/clipping.js +14 -0
- package/dist/lib/clipping.js.map +1 -0
- package/dist/search/command-palette.d.ts +67 -2
- package/dist/search/command-palette.js +147 -33
- package/dist/search/command-palette.js.map +1 -1
- package/dist/search/global-search.d.ts +166 -0
- package/dist/search/global-search.js +293 -0
- package/dist/search/global-search.js.map +1 -0
- package/dist/search/search-index.d.ts +105 -0
- package/dist/search/search-index.js +142 -0
- package/dist/search/search-index.js.map +1 -0
- package/dist/search.d.ts +3 -1
- package/dist/search.js +2 -0
- package/dist/search.js.map +1 -1
- package/dist/wizard/stepper-nav.d.ts +10 -1
- package/dist/wizard/stepper-nav.js +3 -2
- package/dist/wizard/stepper-nav.js.map +1 -1
- package/dist/wizard/types.d.ts +2 -2
- package/dist/wizard/types.js.map +1 -1
- package/dist/wizard/use-wizard.js +1 -1
- package/dist/wizard/use-wizard.js.map +1 -1
- package/dist/wizard/wizard-summary.js +3 -3
- package/dist/wizard/wizard-summary.js.map +1 -1
- package/package.json +25 -12
- package/src/components/alert-banner.tsx +117 -18
- package/src/components/breadcrumbs.tsx +168 -0
- package/src/components/bulk-action-bar.tsx +256 -0
- package/src/components/chip.tsx +164 -13
- package/src/components/copy-button.tsx +29 -7
- package/src/components/data-table.tsx +81 -5
- package/src/components/date-picker.tsx +377 -118
- package/src/components/description-list.tsx +168 -15
- package/src/components/dialog-frame.tsx +7 -0
- package/src/components/disclosure.tsx +116 -28
- package/src/components/file-dropzone.tsx +9 -10
- package/src/components/floating-panel.tsx +46 -2
- package/src/components/full-bleed-dialog.tsx +42 -5
- package/src/components/list.tsx +417 -0
- package/src/components/menu-item.tsx +214 -0
- package/src/components/modal.tsx +80 -17
- package/src/components/page-contents.tsx +4 -4
- package/src/components/page-header.tsx +68 -0
- package/src/components/progress-bar.tsx +27 -3
- package/src/components/series-chart-labels.ts +4 -0
- package/src/components/series-chart.tsx +253 -3
- package/src/components/status-dot.tsx +99 -0
- package/src/components/table.tsx +91 -6
- package/src/components/text.tsx +89 -0
- package/src/components/toast.tsx +441 -0
- package/src/components/toggle-group.tsx +82 -3
- package/src/components/tooltip.tsx +186 -100
- package/src/components/ui.tsx +321 -41
- package/src/hooks/use-close-transition.ts +14 -5
- package/src/hooks/use-copy-to-clipboard.ts +1 -1
- package/src/hooks/use-overlay-history.ts +181 -4
- package/src/i18n/defaults.ts +10 -0
- package/src/i18n/kit-labels.tsx +10 -0
- package/src/i18n/locales/de.ts +30 -0
- package/src/i18n/locales/es.ts +30 -0
- package/src/i18n/locales/fr.ts +30 -0
- package/src/i18n/locales/hu.ts +30 -0
- package/src/i18n/locales/it.ts +30 -0
- package/src/i18n/locales/zh.ts +30 -0
- package/src/index.ts +30 -0
- package/src/lib/clipping.ts +16 -0
- package/src/search/command-palette.tsx +241 -31
- package/src/search/global-search.tsx +525 -0
- package/src/search/search-index.ts +295 -0
- package/src/search.ts +2 -0
- package/src/wizard/stepper-nav.tsx +13 -2
- package/src/wizard/types.ts +2 -2
- package/src/wizard/use-wizard.ts +3 -3
- package/src/wizard/wizard-summary.tsx +14 -10
- package/tokens.css +97 -0
package/src/i18n/locales/zh.ts
CHANGED
|
@@ -231,6 +231,11 @@ export function uiKitLabelsZh(numberLocale = "zh-CN"): UiKitLabels {
|
|
|
231
231
|
dialogFrame: {
|
|
232
232
|
close: "关闭",
|
|
233
233
|
},
|
|
234
|
+
bulkActionBar: {
|
|
235
|
+
selected: (count) => `已选择 ${n(count)} 项`,
|
|
236
|
+
clear: "清除选择",
|
|
237
|
+
cleared: "已清除选择",
|
|
238
|
+
},
|
|
234
239
|
swipeableRow: {
|
|
235
240
|
actions: "行操作",
|
|
236
241
|
},
|
|
@@ -287,12 +292,22 @@ export function uiKitLabelsZh(numberLocale = "zh-CN"): UiKitLabels {
|
|
|
287
292
|
step: (current, total) => `${n(current)} / ${n(total)}`,
|
|
288
293
|
},
|
|
289
294
|
commandPalette: {
|
|
295
|
+
clear: "清除搜索",
|
|
296
|
+
submit: "搜索",
|
|
297
|
+
close: "关闭",
|
|
290
298
|
placeholder: "搜索…",
|
|
291
299
|
empty: "无结果",
|
|
292
300
|
loading: "搜索中…",
|
|
293
301
|
dialog: "搜索",
|
|
294
302
|
error: "搜索失败,请重试。",
|
|
295
303
|
},
|
|
304
|
+
globalSearch: {
|
|
305
|
+
trigger: "搜索",
|
|
306
|
+
placeholder: "搜索或跳转到…",
|
|
307
|
+
shortcut: (keys) => `搜索(${keys})`,
|
|
308
|
+
suggestions: "试试",
|
|
309
|
+
results: "结果",
|
|
310
|
+
},
|
|
296
311
|
sparkline: {
|
|
297
312
|
rising: (first, last) => `从 ${first} 上升到 ${last}`,
|
|
298
313
|
falling: (first, last) => `从 ${first} 下降到 ${last}`,
|
|
@@ -352,6 +367,7 @@ export function uiKitLabelsZh(numberLocale = "zh-CN"): UiKitLabels {
|
|
|
352
367
|
"拖动以缩放:近似正方形的选区同时缩放两条坐标轴,细长的选区只缩放其所沿的那条轴。双击可重置。",
|
|
353
368
|
empty: "无数据",
|
|
354
369
|
legend: "数据系列",
|
|
370
|
+
points: "图表数值",
|
|
355
371
|
},
|
|
356
372
|
confirmDialog: {
|
|
357
373
|
confirm: "确认",
|
|
@@ -367,6 +383,20 @@ export function uiKitLabelsZh(numberLocale = "zh-CN"): UiKitLabels {
|
|
|
367
383
|
copiedAnnouncement: "已复制到剪贴板",
|
|
368
384
|
failedAnnouncement: "无法复制到剪贴板",
|
|
369
385
|
},
|
|
386
|
+
list: {
|
|
387
|
+
unread: "未读",
|
|
388
|
+
opensInNewTab: "在新标签页中打开",
|
|
389
|
+
},
|
|
390
|
+
breadcrumbs: {
|
|
391
|
+
label: "面包屑导航",
|
|
392
|
+
showAll: "显示完整路径",
|
|
393
|
+
},
|
|
394
|
+
toast: {
|
|
395
|
+
close: "关闭通知",
|
|
396
|
+
notifications: "通知",
|
|
397
|
+
undo: "撤销",
|
|
398
|
+
redo: "重做",
|
|
399
|
+
},
|
|
370
400
|
};
|
|
371
401
|
}
|
|
372
402
|
|
package/src/index.ts
CHANGED
|
@@ -96,6 +96,8 @@ export * from "./components/danger-confirm";
|
|
|
96
96
|
export * from "./components/confirm-dialog";
|
|
97
97
|
// A non-modal corner panel and its round trigger (keksdose's assistant launcher).
|
|
98
98
|
export * from "./components/floating-panel";
|
|
99
|
+
// The count / clear / actions bar a selection brings up (keksdose's three copies).
|
|
100
|
+
export * from "./components/bulk-action-bar";
|
|
99
101
|
export * from "./components/swatch-picker";
|
|
100
102
|
export * from "./components/icon-picker";
|
|
101
103
|
export * from "./components/choice-card";
|
|
@@ -151,6 +153,30 @@ export * from "./components/separator";
|
|
|
151
153
|
// Named: scroll-area.tsx also holds the overflow hook and scrollbar class Table shares.
|
|
152
154
|
export { ScrollArea } from "./components/scroll-area";
|
|
153
155
|
export type { ScrollAreaProps } from "./components/scroll-area";
|
|
156
|
+
// 0.10.0 rows, menu rows, text primitives and page chrome the apps drew by hand.
|
|
157
|
+
export * from "./components/list";
|
|
158
|
+
export * from "./components/menu-item";
|
|
159
|
+
export * from "./components/text";
|
|
160
|
+
export * from "./components/status-dot";
|
|
161
|
+
export * from "./components/page-header";
|
|
162
|
+
export * from "./components/breadcrumbs";
|
|
163
|
+
// The toast layer over sonner (an OPTIONAL peer, loaded lazily): `toast` mirrors sonner's
|
|
164
|
+
// API so apps migrate by swapping the import; `<Toaster>` carries the placement, theme,
|
|
165
|
+
// tones and z-index both apps had wired by hand.
|
|
166
|
+
export { toast, Toaster, DEFAULT_TOAST_LABELS, TOAST_ACTION_DURATION } from "./components/toast";
|
|
167
|
+
export type {
|
|
168
|
+
ToastId,
|
|
169
|
+
ToastAction,
|
|
170
|
+
ToastOptions,
|
|
171
|
+
ToastPromiseOptions,
|
|
172
|
+
ToastUndoOptions,
|
|
173
|
+
ToastRedoOptions,
|
|
174
|
+
ToastLabels,
|
|
175
|
+
ToastPosition,
|
|
176
|
+
ToastSwipeDirection,
|
|
177
|
+
ToasterOffset,
|
|
178
|
+
ToasterProps,
|
|
179
|
+
} from "./components/toast";
|
|
154
180
|
|
|
155
181
|
// ── data-table suite ─────────────────────────────────────────────────────────
|
|
156
182
|
export * from "./components/data-table-labels";
|
|
@@ -163,6 +189,8 @@ export { FilterPopover } from "./components/data-table-filter-popover";
|
|
|
163
189
|
export { DataTable } from "./components/data-table";
|
|
164
190
|
export type {
|
|
165
191
|
DataTableColumn,
|
|
192
|
+
DataTableCellProps,
|
|
193
|
+
DataTableHeadProps,
|
|
166
194
|
DataTableProps,
|
|
167
195
|
ServerPagination,
|
|
168
196
|
FilterState,
|
|
@@ -217,6 +245,8 @@ export * from "./tour/tour";
|
|
|
217
245
|
|
|
218
246
|
// ── command palette / global search ──────────────────────────────────────────
|
|
219
247
|
export * from "./search/command-palette";
|
|
248
|
+
export * from "./search/search-index";
|
|
249
|
+
export * from "./search/global-search";
|
|
220
250
|
|
|
221
251
|
// ── i18n: one label tree, one optional provider ──────────────────────────────
|
|
222
252
|
// `UiKitLabels` names every string the kit renders; `<UiKitProvider labels locale>`
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Internal (not exported from the package): the Tooltip's auto-portal check. */
|
|
2
|
+
|
|
3
|
+
/** Whether anything between `el` and `<body>` clips or scrolls its content — the
|
|
4
|
+
* containers an absolutely positioned bubble would be cut off by, or would widen.
|
|
5
|
+
* `<body>` and `<html>` are not counted: their `overflow` belongs to the viewport (a
|
|
6
|
+
* dialog's scroll lock sets it), and the viewport clips a portalled bubble just the
|
|
7
|
+
* same. */
|
|
8
|
+
export function hasClippingAncestor(el: Element | null): boolean {
|
|
9
|
+
if (typeof window === "undefined" || !el) return false;
|
|
10
|
+
for (let node = el.parentElement; node; node = node.parentElement) {
|
|
11
|
+
if (node === document.body || node === document.documentElement) return false;
|
|
12
|
+
const style = window.getComputedStyle(node);
|
|
13
|
+
if (style.overflowX !== "visible" || style.overflowY !== "visible") return true;
|
|
14
|
+
}
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
3
|
import { createPortal } from "react-dom";
|
|
4
|
-
import { Search } from "lucide-react";
|
|
4
|
+
import { CornerDownLeft, Search, X } from "lucide-react";
|
|
5
5
|
import { cn } from "../lib/cn";
|
|
6
|
+
import { PHONE_QUERY } from "../components/ui";
|
|
7
|
+
import { useMediaQuery } from "../hooks/use-media-query";
|
|
6
8
|
import { useOverlayHistory } from "../hooks/use-overlay-history";
|
|
7
9
|
import { useFocusTrap } from "../hooks/use-focus-trap";
|
|
8
10
|
import { useBodyScrollLock } from "../hooks/use-body-scroll-lock";
|
|
@@ -27,6 +29,35 @@ export interface CommandItem {
|
|
|
27
29
|
* with a link and JavaScript cannot fake — the rejected alternative, an
|
|
28
30
|
* `onAuxClick` calling `window.open`, gets the middle click and nothing else. */
|
|
29
31
|
href?: string;
|
|
32
|
+
/** Marks this row's label and hint `data-private`, so session replay and screenshot
|
|
33
|
+
* tooling masks them — a payee, an account name, an amount. Overrides the palette's
|
|
34
|
+
* {@link CommandPaletteProps.redactLabels} either way, so `false` un-masks one
|
|
35
|
+
* harmless row ("Settings") in a palette that masks by default. */
|
|
36
|
+
redact?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* `"status"` makes the row a line of text rather than a choice — a group's
|
|
39
|
+
* "Searching…" while its source is still out, or its error. It is skipped by the
|
|
40
|
+
* arrow keys and ↵, has no hover highlight, and its `onSelect` is never called.
|
|
41
|
+
* Default `"option"`.
|
|
42
|
+
*/
|
|
43
|
+
kind?: "option" | "status";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Group items preserving first-seen group order; `flat` is the navigable rows only. */
|
|
47
|
+
function groupItems(items: CommandItem[]) {
|
|
48
|
+
const order: string[] = [];
|
|
49
|
+
const byGroup = new Map<string, CommandItem[]>();
|
|
50
|
+
for (const item of items) {
|
|
51
|
+
if (!byGroup.has(item.group)) {
|
|
52
|
+
byGroup.set(item.group, []);
|
|
53
|
+
order.push(item.group);
|
|
54
|
+
}
|
|
55
|
+
byGroup.get(item.group)!.push(item);
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
groups: order.map((g) => ({ group: g, items: byGroup.get(g)! })),
|
|
59
|
+
flat: order.flatMap((g) => byGroup.get(g)!).filter((item) => item.kind !== "status"),
|
|
60
|
+
};
|
|
30
61
|
}
|
|
31
62
|
|
|
32
63
|
export interface CommandPaletteLabels {
|
|
@@ -49,6 +80,13 @@ export interface CommandPaletteLabels {
|
|
|
49
80
|
* rejects. Optional for the same reason as `dialog`; the default always carries it.
|
|
50
81
|
*/
|
|
51
82
|
error?: string;
|
|
83
|
+
/** Names the "×" that empties the field. Optional like `dialog`. */
|
|
84
|
+
clear?: string;
|
|
85
|
+
/** Names the button that commits the typed text when `searchOn="submit"`. */
|
|
86
|
+
submit?: string;
|
|
87
|
+
/** Names the close button of the full-screen phone presentation, which has no
|
|
88
|
+
* backdrop to tap and, on a phone, no Escape key. */
|
|
89
|
+
close?: string;
|
|
52
90
|
}
|
|
53
91
|
|
|
54
92
|
export const DEFAULT_COMMAND_PALETTE_LABELS: CommandPaletteLabels = {
|
|
@@ -57,8 +95,23 @@ export const DEFAULT_COMMAND_PALETTE_LABELS: CommandPaletteLabels = {
|
|
|
57
95
|
loading: "Searching…",
|
|
58
96
|
dialog: "Search",
|
|
59
97
|
error: "Search failed. Try again.",
|
|
98
|
+
clear: "Clear search",
|
|
99
|
+
submit: "Search",
|
|
100
|
+
close: "Close",
|
|
60
101
|
};
|
|
61
102
|
|
|
103
|
+
/**
|
|
104
|
+
* How tall the palette's rows are. `"compact"` (the default) is the desktop list the
|
|
105
|
+
* palette always drew, 36px a row; `"comfortable"` gives every row the kit's 44px touch
|
|
106
|
+
* target (`min-h-11`) and body-size text — keksdose's phone search, where a 36px row
|
|
107
|
+
* under a thumb picks its neighbour.
|
|
108
|
+
*/
|
|
109
|
+
export type CommandPaletteDensity = "compact" | "comfortable";
|
|
110
|
+
|
|
111
|
+
/** A provider's answer is a promise — the only case with anything to wait for. */
|
|
112
|
+
const isThenable = (value: unknown): value is PromiseLike<unknown> =>
|
|
113
|
+
typeof (value as PromiseLike<unknown> | null)?.then === "function";
|
|
114
|
+
|
|
62
115
|
/** The defaults minus `dialog`, so a resolved `dialog` means someone SUPPLIED one —
|
|
63
116
|
* see the dialog's `aria-label` for why that distinction matters. */
|
|
64
117
|
const { dialog: DEFAULT_DIALOG_NAME, ...DEFAULTS_WITHOUT_DIALOG } = DEFAULT_COMMAND_PALETTE_LABELS;
|
|
@@ -71,6 +124,14 @@ interface CommandPaletteProps {
|
|
|
71
124
|
* query is allowed — return defaults like pages/recent). Sync or async; stale
|
|
72
125
|
* async responses are ignored.
|
|
73
126
|
*
|
|
127
|
+
* A SYNCHRONOUS provider (one returning an array, not a promise) never shows the
|
|
128
|
+
* "Searching…" hint: its answer is there the moment it is asked, and the hint only
|
|
129
|
+
* flashed through the debounce before results that were never late — keksdose's
|
|
130
|
+
* transaction search filters a list already in memory. The palette tells the two
|
|
131
|
+
* apart by what the provider RETURNS, call by call, so there is no prop to keep in
|
|
132
|
+
* step with the implementation: an async provider shows the hint from the first
|
|
133
|
+
* promise on (and during the debounce of every later query), a sync one never.
|
|
134
|
+
*
|
|
74
135
|
* Its IDENTITY is not a re-run signal — it is read through a ref, so the palette
|
|
75
136
|
* never re-searches merely because the caller rebuilt the callback. That is
|
|
76
137
|
* deliberate: a provider closing over a list rendered from a query allocates a new
|
|
@@ -105,6 +166,37 @@ interface CommandPaletteProps {
|
|
|
105
166
|
/** Every edit of the field's text. Fires in both modes — uncontrolled, it only
|
|
106
167
|
* observes. */
|
|
107
168
|
onQueryChange?: (query: string) => void;
|
|
169
|
+
/**
|
|
170
|
+
* When the typed text becomes THE query — the one `search` runs on and
|
|
171
|
+
* `onQueryChange` reports.
|
|
172
|
+
*
|
|
173
|
+
* - `"input"` (default): every keystroke, debounced for the search, as before.
|
|
174
|
+
* - `"submit"`: typing edits a draft; the query is committed by ↵ or the submit
|
|
175
|
+
* button beside the field. Until then the results stay on the last committed query
|
|
176
|
+
* and `onQueryChange` stays quiet — which is what an owner binding `query` to the
|
|
177
|
+
* URL's `q` needs, or every keystroke re-filters the page behind and pushes history.
|
|
178
|
+
* ↵ with a draft that differs from the committed query COMMITS; ↵ with nothing new
|
|
179
|
+
* typed chooses the highlighted row, as in `"input"` mode. The draft follows any
|
|
180
|
+
* outside change of the committed query (Back through the URL), and an uncommitted
|
|
181
|
+
* draft is dropped when the palette is closed.
|
|
182
|
+
*
|
|
183
|
+
* The clear "×" commits the empty query in both modes: an emptied box over results
|
|
184
|
+
* (or a page) still filtered by the old text is the stuck state Keksdose #423 was.
|
|
185
|
+
*/
|
|
186
|
+
searchOn?: "input" | "submit";
|
|
187
|
+
/** Mark every row's label and hint `data-private` (see {@link CommandItem.redact},
|
|
188
|
+
* which overrides it per row). Off by default. */
|
|
189
|
+
redactLabels?: boolean;
|
|
190
|
+
/**
|
|
191
|
+
* Below the phone breakpoint ({@link PHONE_QUERY}) the palette fills the screen:
|
|
192
|
+
* no inset, no rounded panel, the field pinned at the top inside the safe areas and
|
|
193
|
+
* the list scrolling under it, with a close button since there is no backdrop to tap.
|
|
194
|
+
* On by default — a top-centred card at 70vh leaves little room once the keyboard is
|
|
195
|
+
* up. `false` keeps the card on every viewport.
|
|
196
|
+
*/
|
|
197
|
+
fullScreenOnPhone?: boolean;
|
|
198
|
+
/** Row height — see {@link CommandPaletteDensity}. Default `"compact"`. */
|
|
199
|
+
density?: CommandPaletteDensity;
|
|
108
200
|
labels?: Partial<CommandPaletteLabels>;
|
|
109
201
|
}
|
|
110
202
|
|
|
@@ -135,6 +227,10 @@ export function CommandPalette({
|
|
|
135
227
|
revision,
|
|
136
228
|
query: queryProp,
|
|
137
229
|
onQueryChange,
|
|
230
|
+
searchOn = "input",
|
|
231
|
+
redactLabels = false,
|
|
232
|
+
fullScreenOnPhone = true,
|
|
233
|
+
density = "compact",
|
|
138
234
|
labels,
|
|
139
235
|
}: CommandPaletteProps) {
|
|
140
236
|
const l = useKitLabels("commandPalette", DEFAULTS_WITHOUT_DIALOG, labels);
|
|
@@ -152,6 +248,23 @@ export function CommandPalette({
|
|
|
152
248
|
if (!controlled) setOwnQuery(next);
|
|
153
249
|
onQueryChange?.(next);
|
|
154
250
|
};
|
|
251
|
+
const submitMode = searchOn === "submit";
|
|
252
|
+
// The field's text in `"submit"` mode. It follows the committed query whenever that
|
|
253
|
+
// changes — adjusted during render, React's pattern for state derived from a prop,
|
|
254
|
+
// rather than in an effect that would paint the stale draft first.
|
|
255
|
+
const [draft, setDraft] = useState(query);
|
|
256
|
+
const [draftBase, setDraftBase] = useState(query);
|
|
257
|
+
if (draftBase !== query) {
|
|
258
|
+
setDraftBase(query);
|
|
259
|
+
setDraft(query);
|
|
260
|
+
}
|
|
261
|
+
const fieldText = submitMode ? draft : query;
|
|
262
|
+
const commit = (next: string) => {
|
|
263
|
+
setDraft(next);
|
|
264
|
+
setQuery(next);
|
|
265
|
+
};
|
|
266
|
+
const dirty = submitMode && draft !== query;
|
|
267
|
+
const isPhone = useMediaQuery(PHONE_QUERY, false) && fullScreenOnPhone;
|
|
155
268
|
const [results, setResults] = useState<CommandItem[]>([]);
|
|
156
269
|
const [loading, setLoading] = useState(false);
|
|
157
270
|
const [failed, setFailed] = useState(false);
|
|
@@ -169,6 +282,15 @@ export function CommandPalette({
|
|
|
169
282
|
const searchRef = useRef(search);
|
|
170
283
|
searchRef.current = search;
|
|
171
284
|
const reqId = useRef(0);
|
|
285
|
+
/** What the provider's last answer was: a promise (`false`), an array (`true`), or
|
|
286
|
+
* not known yet. Only a provider known to be async shows "Searching…" while the
|
|
287
|
+
* debounce runs — see `search`. */
|
|
288
|
+
const syncRef = useRef<boolean | undefined>(undefined);
|
|
289
|
+
/** The query the results on screen answer, and the row highlighted in them — so a
|
|
290
|
+
* re-run for the SAME query (a `revision` change: a source streaming in) keeps the
|
|
291
|
+
* highlight on its row instead of throwing it back to the top mid-arrow. */
|
|
292
|
+
const shownQuery = useRef<string | null>(null);
|
|
293
|
+
const activeIdRef = useRef<string | undefined>(undefined);
|
|
172
294
|
|
|
173
295
|
// Back dismisses the palette (Keksdose feedback #172). Declared with the other
|
|
174
296
|
// hooks, above the `if (!open) return null` below — a hook past a conditional
|
|
@@ -187,7 +309,10 @@ export function CommandPalette({
|
|
|
187
309
|
useEffect(() => {
|
|
188
310
|
if (!open) return;
|
|
189
311
|
if (!controlled) setOwnQuery("");
|
|
312
|
+
// An uncommitted draft does not outlive the open it was typed in.
|
|
313
|
+
setDraft(controlled ? query : "");
|
|
190
314
|
setActive(0);
|
|
315
|
+
shownQuery.current = null;
|
|
191
316
|
// `controlled` is read at the open, not watched: a caller switching modes while
|
|
192
317
|
// the palette is open is not a reason to wipe what is typed.
|
|
193
318
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
@@ -197,19 +322,26 @@ export function CommandPalette({
|
|
|
197
322
|
useEffect(() => {
|
|
198
323
|
if (!open) return;
|
|
199
324
|
const id = ++reqId.current;
|
|
200
|
-
setLoading(true);
|
|
325
|
+
if (syncRef.current === false) setLoading(true);
|
|
201
326
|
const run = async () => {
|
|
202
327
|
try {
|
|
203
|
-
const
|
|
328
|
+
const answer = searchRef.current(query);
|
|
329
|
+
syncRef.current = !isThenable(answer);
|
|
330
|
+
if (!syncRef.current) setLoading(true);
|
|
331
|
+
const r = await answer;
|
|
204
332
|
if (reqId.current === id) {
|
|
333
|
+
const keep = shownQuery.current === query ? activeIdRef.current : undefined;
|
|
334
|
+
const at = keep === undefined ? -1 : groupItems(r).flat.findIndex((item) => item.id === keep);
|
|
335
|
+
shownQuery.current = query;
|
|
205
336
|
setResults(r);
|
|
206
337
|
setFailed(false);
|
|
207
|
-
setActive(0);
|
|
338
|
+
setActive(Math.max(at, 0));
|
|
208
339
|
}
|
|
209
340
|
} catch (err) {
|
|
210
341
|
// There was no catch: the previous query's results stayed on screen as if they
|
|
211
342
|
// answered this one, and the rejection went unhandled. Clear them and say so.
|
|
212
343
|
if (reqId.current === id) {
|
|
344
|
+
shownQuery.current = null;
|
|
213
345
|
setResults([]);
|
|
214
346
|
setFailed(true);
|
|
215
347
|
setActive(0);
|
|
@@ -227,21 +359,10 @@ export function CommandPalette({
|
|
|
227
359
|
}, [query, open, revision]);
|
|
228
360
|
|
|
229
361
|
// Group results, preserving first-seen group order; keep a flat list for nav.
|
|
230
|
-
const { groups, flat } = useMemo(() =>
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
if (!byGroup.has(item.group)) {
|
|
235
|
-
byGroup.set(item.group, []);
|
|
236
|
-
order.push(item.group);
|
|
237
|
-
}
|
|
238
|
-
byGroup.get(item.group)!.push(item);
|
|
239
|
-
}
|
|
240
|
-
return {
|
|
241
|
-
groups: order.map((g) => ({ group: g, items: byGroup.get(g)! })),
|
|
242
|
-
flat: order.flatMap((g) => byGroup.get(g)!),
|
|
243
|
-
};
|
|
244
|
-
}, [results]);
|
|
362
|
+
const { groups, flat } = useMemo(() => groupItems(results), [results]);
|
|
363
|
+
useEffect(() => {
|
|
364
|
+
activeIdRef.current = flat[active]?.id;
|
|
365
|
+
});
|
|
245
366
|
|
|
246
367
|
const choose = (item: CommandItem | undefined) => {
|
|
247
368
|
if (!item) return;
|
|
@@ -258,7 +379,8 @@ export function CommandPalette({
|
|
|
258
379
|
setActive((i) => Math.max(i - 1, 0));
|
|
259
380
|
} else if (e.key === "Enter") {
|
|
260
381
|
e.preventDefault();
|
|
261
|
-
|
|
382
|
+
if (dirty) commit(draft);
|
|
383
|
+
else choose(flat[active]);
|
|
262
384
|
}
|
|
263
385
|
};
|
|
264
386
|
|
|
@@ -274,15 +396,21 @@ export function CommandPalette({
|
|
|
274
396
|
// Keep the active row scrolled into view.
|
|
275
397
|
useEffect(() => {
|
|
276
398
|
const el = listRef.current?.querySelector<HTMLElement>(`[data-index="${active}"]`);
|
|
277
|
-
|
|
399
|
+
// Optional-called, as in combobox-core: jsdom has no scrollIntoView, and every
|
|
400
|
+
// consumer's tests crashed on the first highlighted row (kastlan stubbed it).
|
|
401
|
+
el?.scrollIntoView?.({ block: "nearest" });
|
|
278
402
|
}, [active]);
|
|
279
403
|
|
|
280
404
|
if (!open) return null;
|
|
281
405
|
|
|
406
|
+
const comfortable = density === "comfortable";
|
|
282
407
|
let flatIndex = -1;
|
|
283
408
|
return createPortal(
|
|
284
409
|
<div
|
|
285
|
-
className=
|
|
410
|
+
className={cn(
|
|
411
|
+
"fixed inset-0 z-[70] flex items-start justify-center bg-black/40",
|
|
412
|
+
isPhone ? "p-0" : "p-4 pt-[10vh]",
|
|
413
|
+
)}
|
|
286
414
|
onMouseDown={(e) => {
|
|
287
415
|
if (e.target === e.currentTarget) onClose();
|
|
288
416
|
}}
|
|
@@ -297,9 +425,27 @@ export function CommandPalette({
|
|
|
297
425
|
tabIndex={-1}
|
|
298
426
|
// Escape bubbling up from anything inside — a dialog is where it belongs.
|
|
299
427
|
onKeyDown={onDialogKeyDown}
|
|
300
|
-
|
|
428
|
+
data-fullscreen={isPhone || undefined}
|
|
429
|
+
className={cn(
|
|
430
|
+
"flex w-full flex-col overflow-hidden bg-[var(--bg-surface)] outline-none",
|
|
431
|
+
isPhone
|
|
432
|
+
? "h-full"
|
|
433
|
+
: "max-h-[70vh] max-w-xl rounded-xl border border-[var(--border)] shadow-2xl",
|
|
434
|
+
)}
|
|
435
|
+
// Full screen means edge to edge, so the notch, the home indicator and the
|
|
436
|
+
// rounded corners are the panel's to keep clear of.
|
|
437
|
+
style={
|
|
438
|
+
isPhone
|
|
439
|
+
? {
|
|
440
|
+
paddingTop: "max(0px, env(safe-area-inset-top))",
|
|
441
|
+
paddingBottom: "max(0px, env(safe-area-inset-bottom))",
|
|
442
|
+
paddingLeft: "max(0px, env(safe-area-inset-left))",
|
|
443
|
+
paddingRight: "max(0px, env(safe-area-inset-right))",
|
|
444
|
+
}
|
|
445
|
+
: undefined
|
|
446
|
+
}
|
|
301
447
|
>
|
|
302
|
-
<div className="flex items-center gap-2 border-b border-[var(--border)] px-3">
|
|
448
|
+
<div className="flex shrink-0 items-center gap-2 border-b border-[var(--border)] px-3">
|
|
303
449
|
<Search className="size-4 shrink-0 text-[var(--text-placeholder)]" />
|
|
304
450
|
<input
|
|
305
451
|
ref={inputRef}
|
|
@@ -307,13 +453,54 @@ export function CommandPalette({
|
|
|
307
453
|
aria-expanded
|
|
308
454
|
aria-controls={listId}
|
|
309
455
|
aria-activedescendant={flat[active] ? itemId(flat[active].id) : undefined}
|
|
310
|
-
value={
|
|
311
|
-
onChange={(e) => setQuery(e.target.value)}
|
|
456
|
+
value={fieldText}
|
|
457
|
+
onChange={(e) => (submitMode ? setDraft(e.target.value) : setQuery(e.target.value))}
|
|
312
458
|
onKeyDown={onKeyDown}
|
|
313
459
|
placeholder={l.placeholder}
|
|
314
|
-
|
|
460
|
+
enterKeyHint={submitMode ? "search" : undefined}
|
|
461
|
+
className={cn(
|
|
462
|
+
"w-full bg-transparent py-3 outline-none placeholder:text-[var(--text-placeholder)] text-[var(--text-primary)]",
|
|
463
|
+
// 16px on a phone, or iOS zooms the page in on focus.
|
|
464
|
+
isPhone ? "text-base" : "text-sm",
|
|
465
|
+
)}
|
|
315
466
|
/>
|
|
316
467
|
{loading && <span className="shrink-0 text-[11px] text-[var(--text-placeholder)]">{l.loading}</span>}
|
|
468
|
+
{fieldText !== "" && (
|
|
469
|
+
<button
|
|
470
|
+
type="button"
|
|
471
|
+
aria-label={l.clear ?? DEFAULT_COMMAND_PALETTE_LABELS.clear}
|
|
472
|
+
onClick={() => {
|
|
473
|
+
commit("");
|
|
474
|
+
// Clearing starts the next query far more often than it ends this one.
|
|
475
|
+
inputRef.current?.focus();
|
|
476
|
+
}}
|
|
477
|
+
className="shrink-0 rounded p-1 text-[var(--text-placeholder)] hover:text-[var(--text-secondary)]"
|
|
478
|
+
>
|
|
479
|
+
<X className="size-4" />
|
|
480
|
+
</button>
|
|
481
|
+
)}
|
|
482
|
+
{dirty && (
|
|
483
|
+
<button
|
|
484
|
+
type="button"
|
|
485
|
+
aria-label={l.submit ?? DEFAULT_COMMAND_PALETTE_LABELS.submit}
|
|
486
|
+
onClick={() => {
|
|
487
|
+
commit(draft);
|
|
488
|
+
inputRef.current?.focus();
|
|
489
|
+
}}
|
|
490
|
+
className="shrink-0 rounded p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
491
|
+
>
|
|
492
|
+
<CornerDownLeft className="size-4" />
|
|
493
|
+
</button>
|
|
494
|
+
)}
|
|
495
|
+
{isPhone && (
|
|
496
|
+
<button
|
|
497
|
+
type="button"
|
|
498
|
+
onClick={onClose}
|
|
499
|
+
className="-me-1 shrink-0 rounded px-1 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
500
|
+
>
|
|
501
|
+
{l.close ?? DEFAULT_COMMAND_PALETTE_LABELS.close}
|
|
502
|
+
</button>
|
|
503
|
+
)}
|
|
317
504
|
</div>
|
|
318
505
|
|
|
319
506
|
{/* The status lines sit outside the listbox: a listbox owns options and groups,
|
|
@@ -323,7 +510,9 @@ export function CommandPalette({
|
|
|
323
510
|
{l.error ?? DEFAULT_COMMAND_PALETTE_LABELS.error}
|
|
324
511
|
</div>
|
|
325
512
|
)}
|
|
326
|
-
{
|
|
513
|
+
{/* `results`, not `flat`: a group whose only row is its "Searching…" line is not
|
|
514
|
+
an empty result. */}
|
|
515
|
+
{!failed && results.length === 0 && !loading && (
|
|
327
516
|
<div className="px-3 py-6 text-center text-sm text-[var(--text-muted)]">{l.empty}</div>
|
|
328
517
|
)}
|
|
329
518
|
{/* Listbox grouping: each group is a `role="group"` named by its heading, and
|
|
@@ -342,9 +531,25 @@ export function CommandPalette({
|
|
|
342
531
|
</div>
|
|
343
532
|
<ul role="group" aria-labelledby={groupId(groupIndex)}>
|
|
344
533
|
{items.map((item) => {
|
|
534
|
+
if (item.kind === "status") {
|
|
535
|
+
return (
|
|
536
|
+
<li
|
|
537
|
+
key={item.id}
|
|
538
|
+
role="presentation"
|
|
539
|
+
className={cn(
|
|
540
|
+
"flex items-center gap-2.5 px-3 text-xs text-[var(--text-placeholder)]",
|
|
541
|
+
comfortable ? "min-h-11 py-2.5" : "py-2",
|
|
542
|
+
)}
|
|
543
|
+
>
|
|
544
|
+
{item.icon && <span className="flex size-4 shrink-0 items-center justify-center">{item.icon}</span>}
|
|
545
|
+
<span className="min-w-0 flex-1 truncate">{item.label}</span>
|
|
546
|
+
</li>
|
|
547
|
+
);
|
|
548
|
+
}
|
|
345
549
|
flatIndex += 1;
|
|
346
550
|
const idx = flatIndex;
|
|
347
551
|
const isActive = idx === active;
|
|
552
|
+
const redact = item.redact ?? redactLabels;
|
|
348
553
|
// Written once and worn by either tag below, so the anchor and the
|
|
349
554
|
// button can never drift apart in looks or in listbox semantics.
|
|
350
555
|
// `role="option"` on an <a href> is fine: the browser's middle-click
|
|
@@ -356,7 +561,8 @@ export function CommandPalette({
|
|
|
356
561
|
"data-index": idx,
|
|
357
562
|
onMouseMove: () => setActive(idx),
|
|
358
563
|
className: cn(
|
|
359
|
-
"flex w-full items-center gap-2.5 px-3
|
|
564
|
+
"flex w-full items-center gap-2.5 px-3 text-start",
|
|
565
|
+
comfortable ? "min-h-11 py-2.5 text-base" : "py-2 text-sm",
|
|
360
566
|
isActive
|
|
361
567
|
? "bg-[var(--bg-active)] text-[var(--text-primary)]"
|
|
362
568
|
: "text-[var(--text-secondary)]",
|
|
@@ -369,9 +575,13 @@ export function CommandPalette({
|
|
|
369
575
|
{item.icon}
|
|
370
576
|
</span>
|
|
371
577
|
)}
|
|
372
|
-
<span className="min-w-0 flex-1 truncate">
|
|
578
|
+
<span data-private={redact || undefined} className="min-w-0 flex-1 truncate">
|
|
579
|
+
{item.label}
|
|
580
|
+
</span>
|
|
373
581
|
{item.hint && (
|
|
374
|
-
<span className="shrink-0 text-xs text-[var(--text-placeholder)]">
|
|
582
|
+
<span data-private={redact || undefined} className="shrink-0 text-xs text-[var(--text-placeholder)]">
|
|
583
|
+
{item.hint}
|
|
584
|
+
</span>
|
|
375
585
|
)}
|
|
376
586
|
</>
|
|
377
587
|
);
|