@eifi1/ui-kit 0.16.0 → 0.17.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 +7 -7
- package/dist/chart.d.ts +1 -1
- package/dist/components/amount-input.d.ts +9 -1
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/button-group.d.ts +1 -1
- package/dist/components/calculator.d.ts +1 -1
- package/dist/components/copy-button.d.ts +1 -1
- package/dist/components/data-table-cells.d.ts +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 +91 -3
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/facing-pair.d.ts +1 -1
- package/dist/components/file-button.d.ts +1 -1
- package/dist/components/file-button.js +7 -4
- package/dist/components/file-button.js.map +1 -1
- package/dist/components/file-dropzone.d.ts +1 -1
- package/dist/components/form-actions.d.ts +1 -1
- package/dist/components/form-actions.js +32 -1
- package/dist/components/form-actions.js.map +1 -1
- package/dist/components/line-items.d.ts +30 -6
- package/dist/components/line-items.js +77 -33
- package/dist/components/line-items.js.map +1 -1
- package/dist/components/list.d.ts +13 -1
- package/dist/components/list.js.map +1 -1
- package/dist/components/money-field.d.ts +1 -1
- package/dist/components/number-field.d.ts +1 -1
- package/dist/components/number-input.d.ts +1 -1
- package/dist/components/numpad-sheet.d.ts +1 -1
- package/dist/components/progress-bar.d.ts +11 -1
- package/dist/components/progress-bar.js +14 -4
- package/dist/components/progress-bar.js.map +1 -1
- package/dist/components/series-chart.d.ts +1 -1
- package/dist/components/settings-fields.d.ts +1 -1
- package/dist/components/signed-amount.d.ts +12 -3
- package/dist/components/signed-amount.js +6 -4
- package/dist/components/signed-amount.js.map +1 -1
- package/dist/components/text-link.d.ts +1 -1
- package/dist/components/time-input.d.ts +1 -1
- package/dist/components/ui.d.ts +1 -1
- package/dist/components/ui.js +25 -3
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -1
- package/dist/{data-table-CfVXtCeG.d.ts → data-table-CpkWUJBY.d.ts} +41 -1
- package/dist/data-table.d.ts +1 -1
- package/dist/hooks/use-file-drop.d.ts +1 -1
- package/dist/i18n/defaults.d.ts +1 -1
- package/dist/i18n/kit-labels.d.ts +1 -1
- package/dist/i18n/locales/de-CH-informal.d.ts +1 -1
- package/dist/i18n/locales/de-CH.d.ts +1 -1
- package/dist/i18n/locales/de-informal.d.ts +1 -1
- package/dist/i18n/locales/de.d.ts +1 -1
- package/dist/i18n/locales/en.d.ts +71 -0
- package/dist/i18n/locales/en.js +95 -0
- package/dist/i18n/locales/en.js.map +1 -0
- package/dist/i18n/locales/es.d.ts +1 -1
- package/dist/i18n/locales/fr.d.ts +1 -1
- package/dist/i18n/locales/hu.d.ts +1 -1
- package/dist/i18n/locales/it.d.ts +1 -1
- package/dist/i18n/locales/zh.d.ts +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/{kit-labels-DE--LmCk.d.ts → kit-labels-C4uB_zmg.d.ts} +57 -5
- package/dist/rhf/fields.d.ts +1 -1
- package/dist/rhf/form.d.ts +1 -1
- package/dist/rhf/line-items.js.map +1 -1
- package/dist/rhf.d.ts +1 -1
- package/dist/search/global-search.d.ts +22 -4
- package/dist/search/global-search.js +31 -3
- package/dist/search/global-search.js.map +1 -1
- package/dist/search.d.ts +1 -1
- package/dist/shell/app-shell.d.ts +1 -1
- package/dist/shell/top-bar-brand.d.ts +1 -1
- package/dist/shell.d.ts +1 -1
- package/dist/wizard/stepper-nav.d.ts +1 -1
- package/dist/wizard.d.ts +1 -1
- package/package.json +1 -1
- package/src/components/amount-input.tsx +8 -0
- package/src/components/data-table.tsx +165 -3
- package/src/components/file-button.tsx +12 -4
- package/src/components/form-actions.tsx +73 -3
- package/src/components/line-items.tsx +116 -40
- package/src/components/list.tsx +13 -1
- package/src/components/progress-bar.tsx +20 -4
- package/src/components/signed-amount.tsx +16 -5
- package/src/components/ui.tsx +57 -3
- package/src/i18n/locales/en.ts +116 -0
- package/src/rhf/line-items.tsx +2 -1
- package/src/search/global-search.tsx +57 -6
package/src/components/ui.tsx
CHANGED
|
@@ -2269,8 +2269,33 @@ export interface CardProps extends ComponentPropsWithoutRef<"div"> {
|
|
|
2269
2269
|
* nor on `inset`, which has no border to make louder (its tone is a fill).
|
|
2270
2270
|
*/
|
|
2271
2271
|
toneStrength?: "soft" | "strong";
|
|
2272
|
+
/**
|
|
2273
|
+
* Wash the card in the tone's quiet `-bg` surface as well as framing it. A toned
|
|
2274
|
+
* card is a border and a title colour, at either strength — the wash belongs to
|
|
2275
|
+
* `AlertBanner`, the message, not to a card that holds a form — so it stays
|
|
2276
|
+
* off by default. Some tiles want both: keksdose's password / delete-account tile
|
|
2277
|
+
* was `toneFrameClass(tone)` (border AND wash) before 0.16, and after moving to
|
|
2278
|
+
* `toneStrength="strong"` it re-added `bg-[var(--danger-bg)]` by hand. The wash is
|
|
2279
|
+
* layered OVER the card's own `--bg-surface` (as AlertBanner's `elevated` does), so
|
|
2280
|
+
* the dark theme's translucent tints stay a card, not a hole to the page below.
|
|
2281
|
+
*
|
|
2282
|
+
* No effect without `tone`, nor on `inset`, whose tone is already the fill.
|
|
2283
|
+
*/
|
|
2284
|
+
toneFill?: boolean;
|
|
2285
|
+
/**
|
|
2286
|
+
* The parts' type scale. `"compact"`: CardTitle `text-sm font-medium`,
|
|
2287
|
+
* CardDescription `text-xs` and the CardHeader's title-to-description gap
|
|
2288
|
+
* `gap-0.5` — the small settings / admin card. keksdose wrote those three classes by
|
|
2289
|
+
* hand on ~25 cards. Set here, they reach the parts through context; a class on a
|
|
2290
|
+
* part still wins, and a Card nested inside starts again from its own density.
|
|
2291
|
+
*
|
|
2292
|
+
* Default `"comfortable"`: the parts as they have always been.
|
|
2293
|
+
*/
|
|
2294
|
+
density?: CardDensity;
|
|
2272
2295
|
}
|
|
2273
2296
|
|
|
2297
|
+
export type CardDensity = "comfortable" | "compact";
|
|
2298
|
+
|
|
2274
2299
|
export type CardTone = "warning" | "danger" | "info" | "success";
|
|
2275
2300
|
|
|
2276
2301
|
// The title is reached through its `data-slot`, so the tone needs no context and a
|
|
@@ -2312,6 +2337,16 @@ const CARD_PADDING_STRONG: Record<NonNullable<CardProps["padding"]>, string> = {
|
|
|
2312
2337
|
md: "p-[15px]",
|
|
2313
2338
|
};
|
|
2314
2339
|
|
|
2340
|
+
// The wash as a background IMAGE over the card's opaque `--bg-surface` — see
|
|
2341
|
+
// `toneFill`. A plain `bg-[var(--danger-bg)]` would REPLACE the surface, and the dark
|
|
2342
|
+
// theme's washes are a translucent tint.
|
|
2343
|
+
const CARD_TONE_WASH: Record<CardTone, string> = {
|
|
2344
|
+
warning: "bg-[image:linear-gradient(var(--warning-bg),var(--warning-bg))]",
|
|
2345
|
+
danger: "bg-[image:linear-gradient(var(--danger-bg),var(--danger-bg))]",
|
|
2346
|
+
info: "bg-[image:linear-gradient(var(--info-bg),var(--info-bg))]",
|
|
2347
|
+
success: "bg-[image:linear-gradient(var(--success-bg),var(--success-bg))]",
|
|
2348
|
+
};
|
|
2349
|
+
|
|
2315
2350
|
const CARD_TONES_STRONG: Record<CardTone, string> = {
|
|
2316
2351
|
warning: "border-2 border-[var(--warning)]",
|
|
2317
2352
|
danger: "border-2 border-[var(--danger-border-strong)]",
|
|
@@ -2326,6 +2361,9 @@ const CARD_TONES_STRONG: Record<CardTone, string> = {
|
|
|
2326
2361
|
*/
|
|
2327
2362
|
const CardPaddedContext = createContext(false);
|
|
2328
2363
|
|
|
2364
|
+
/** The {@link CardProps.density} of the nearest Card — the parts' type scale. */
|
|
2365
|
+
const CardDensityContext = createContext<CardDensity>("comfortable");
|
|
2366
|
+
|
|
2329
2367
|
export function Card({
|
|
2330
2368
|
className,
|
|
2331
2369
|
children,
|
|
@@ -2333,6 +2371,8 @@ export function Card({
|
|
|
2333
2371
|
variant = "default",
|
|
2334
2372
|
tone,
|
|
2335
2373
|
toneStrength = "soft",
|
|
2374
|
+
toneFill = false,
|
|
2375
|
+
density = "comfortable",
|
|
2336
2376
|
padding,
|
|
2337
2377
|
...rest
|
|
2338
2378
|
}: CardProps) {
|
|
@@ -2347,6 +2387,7 @@ export function Card({
|
|
|
2347
2387
|
<div
|
|
2348
2388
|
data-tone={tone}
|
|
2349
2389
|
data-tone-strength={strong ? "strong" : undefined}
|
|
2390
|
+
data-density={density === "compact" ? "compact" : undefined}
|
|
2350
2391
|
{...rest}
|
|
2351
2392
|
className={cn(
|
|
2352
2393
|
variant === "inset"
|
|
@@ -2365,12 +2406,15 @@ export function Card({
|
|
|
2365
2406
|
),
|
|
2366
2407
|
toned && (bordered ? toned.border : toned.fill),
|
|
2367
2408
|
strong && tone && CARD_TONES_STRONG[tone],
|
|
2409
|
+
toneFill && bordered && tone && CARD_TONE_WASH[tone],
|
|
2368
2410
|
toned?.title,
|
|
2369
2411
|
ownPadding && (strong ? CARD_PADDING_STRONG : CARD_PADDING)[ownPadding],
|
|
2370
2412
|
className,
|
|
2371
2413
|
)}
|
|
2372
2414
|
>
|
|
2373
|
-
<CardPaddedContext.Provider value={ownPadding !== undefined}>
|
|
2415
|
+
<CardPaddedContext.Provider value={ownPadding !== undefined}>
|
|
2416
|
+
<CardDensityContext.Provider value={density}>{children}</CardDensityContext.Provider>
|
|
2417
|
+
</CardPaddedContext.Provider>
|
|
2374
2418
|
</div>
|
|
2375
2419
|
);
|
|
2376
2420
|
}
|
|
@@ -2415,6 +2459,7 @@ export type CardFooterProps = ComponentPropsWithoutRef<"div">;
|
|
|
2415
2459
|
|
|
2416
2460
|
export function CardHeader({ className, stackAction = false, ...props }: CardHeaderProps) {
|
|
2417
2461
|
const padded = useContext(CardPaddedContext);
|
|
2462
|
+
const compact = useContext(CardDensityContext) === "compact";
|
|
2418
2463
|
return (
|
|
2419
2464
|
<div
|
|
2420
2465
|
data-slot="card-header"
|
|
@@ -2433,6 +2478,7 @@ export function CardHeader({ className, stackAction = false, ...props }: CardHea
|
|
|
2433
2478
|
// never inherits an ancestor header's two columns.
|
|
2434
2479
|
"grid auto-rows-min items-start gap-1.5 [--card-header-cols:1fr] has-data-[slot=card-action]:[--card-header-cols:1fr_auto] grid-cols-[var(--card-header-cols,1fr)]",
|
|
2435
2480
|
padded ? "px-0 pt-0" : "px-6 pt-6",
|
|
2481
|
+
compact && "gap-0.5",
|
|
2436
2482
|
// One column under `sm`, and the action back into the flow (DOM order, so
|
|
2437
2483
|
// after the description when it is written after it), at the start edge.
|
|
2438
2484
|
// The descendant selector outranks CardAction's own placement classes.
|
|
@@ -2446,10 +2492,18 @@ export function CardHeader({ className, stackAction = false, ...props }: CardHea
|
|
|
2446
2492
|
}
|
|
2447
2493
|
|
|
2448
2494
|
export function CardTitle({ className, as: Tag = "div", ...props }: CardTitleProps) {
|
|
2449
|
-
|
|
2495
|
+
const compact = useContext(CardDensityContext) === "compact";
|
|
2496
|
+
return (
|
|
2497
|
+
<Tag
|
|
2498
|
+
data-slot="card-title"
|
|
2499
|
+
className={cn(compact ? "text-sm font-medium" : "font-semibold", "leading-none", className)}
|
|
2500
|
+
{...props}
|
|
2501
|
+
/>
|
|
2502
|
+
);
|
|
2450
2503
|
}
|
|
2451
2504
|
|
|
2452
2505
|
export function CardDescription({ className, ...props }: CardDescriptionProps) {
|
|
2506
|
+
const compact = useContext(CardDensityContext) === "compact";
|
|
2453
2507
|
return (
|
|
2454
2508
|
<div
|
|
2455
2509
|
data-slot="card-description"
|
|
@@ -2457,7 +2511,7 @@ export function CardDescription({ className, ...props }: CardDescriptionProps) {
|
|
|
2457
2511
|
// field captions). It was `--money-neutral`, a MONEY token that happened to be
|
|
2458
2512
|
// a similar grey — and a palette that re-tunes the money colours moved every
|
|
2459
2513
|
// card's description with them.
|
|
2460
|
-
className={cn("text-sm text-[var(--text-muted)]", className)}
|
|
2514
|
+
className={cn(compact ? "text-xs" : "text-sm", "text-[var(--text-muted)]", className)}
|
|
2461
2515
|
{...props}
|
|
2462
2516
|
/>
|
|
2463
2517
|
);
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { DEFAULT_UI_KIT_LABELS } from "../defaults";
|
|
2
|
+
import { formatFileSize } from "../kit-labels";
|
|
3
|
+
import type { UiKitLabels } from "../kit-labels";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The kit's English words with every count and file size formatted for
|
|
7
|
+
* `numberLocale` — `uiKitLabelsEn("en-CH")` reads "12’345 rows", where the defaults
|
|
8
|
+
* print "12345". For `<UiKitProvider labels={uiKitLabelsEn("en-CH")}>`.
|
|
9
|
+
*
|
|
10
|
+
* A factory and not a constant on purpose: {@link DEFAULT_UI_KIT_LABELS} prints counts
|
|
11
|
+
* raw and has since 0.1, so an app that passes nothing must keep seeing exactly that.
|
|
12
|
+
* Formatting is something an app asks for, by calling this.
|
|
13
|
+
*
|
|
14
|
+
* Derived rather than copied: every word is the default's own, spread in, and only the
|
|
15
|
+
* labels that print a number are restated — with the same wording and the same
|
|
16
|
+
* singular/plural split — so a key added to the defaults is here the moment it lands.
|
|
17
|
+
* Row and line numbers (`lineItems.row`, `measuredGrid.cell`) are positions, not
|
|
18
|
+
* counts, and stay as the defaults print them, as in the other catalogues.
|
|
19
|
+
*/
|
|
20
|
+
export function uiKitLabelsEn(numberLocale = "en-US"): UiKitLabels {
|
|
21
|
+
const num = new Intl.NumberFormat(numberLocale);
|
|
22
|
+
const n = (value: number) => num.format(value);
|
|
23
|
+
const d = DEFAULT_UI_KIT_LABELS;
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
...d,
|
|
27
|
+
feedbackAttachment: {
|
|
28
|
+
...d.feedbackAttachment,
|
|
29
|
+
attachmentLimit: (max) =>
|
|
30
|
+
`Up to ${n(max)} ${max === 1 ? "attachment" : "attachments"} — remove one to add another.`,
|
|
31
|
+
},
|
|
32
|
+
measuredGrid: {
|
|
33
|
+
...d.measuredGrid,
|
|
34
|
+
points: (count) => (count === 1 ? "1 point" : `${n(count)} points`),
|
|
35
|
+
problems: (count) =>
|
|
36
|
+
count === 1 ? "1 cell is not a number" : `${n(count)} cells are not numbers`,
|
|
37
|
+
},
|
|
38
|
+
dataTable: {
|
|
39
|
+
...d.dataTable,
|
|
40
|
+
filterResults: (shown, total) => `${n(shown)} of ${n(total)} rows`,
|
|
41
|
+
pageChanged: (page, totalPages) => `Page ${n(page)} of ${n(totalPages)}`,
|
|
42
|
+
pageRange: (from, to, total) => `${n(from)}–${n(to)} / ${n(total)}`,
|
|
43
|
+
rowCount: (total) => n(total),
|
|
44
|
+
columnsCount: (visible, total) => `Columns (${n(visible)}/${n(total)})`,
|
|
45
|
+
},
|
|
46
|
+
calendarHeatmap: {
|
|
47
|
+
...d.calendarHeatmap,
|
|
48
|
+
truncated: (count) =>
|
|
49
|
+
`Showing the most recent days; ${n(count)} earlier ${count === 1 ? "day is" : "days are"} not shown.`,
|
|
50
|
+
},
|
|
51
|
+
combobox: {
|
|
52
|
+
...d.combobox,
|
|
53
|
+
selectedCount: (count) => `${n(count)} selected`,
|
|
54
|
+
resultCount: (count) => (count === 1 ? "1 result" : `${n(count)} results`),
|
|
55
|
+
minChars: (count) =>
|
|
56
|
+
count === 1 ? "Type at least 1 character" : `Type at least ${n(count)} characters`,
|
|
57
|
+
},
|
|
58
|
+
multiSelect: {
|
|
59
|
+
...d.multiSelect,
|
|
60
|
+
selectedCount: (count) => n(count),
|
|
61
|
+
},
|
|
62
|
+
chipInput: {
|
|
63
|
+
...d.chipInput,
|
|
64
|
+
atLimit: (max) => `Limit of ${n(max)} reached`,
|
|
65
|
+
},
|
|
66
|
+
iconPicker: {
|
|
67
|
+
...d.iconPicker,
|
|
68
|
+
resultCount: (count) => (count === 1 ? "1 icon" : `${n(count)} icons`),
|
|
69
|
+
},
|
|
70
|
+
bulkActionBar: {
|
|
71
|
+
...d.bulkActionBar,
|
|
72
|
+
selected: (count) => `${n(count)} selected`,
|
|
73
|
+
},
|
|
74
|
+
file: {
|
|
75
|
+
// The kit's own `Intl` unit formatting, pinned to this locale ("3.4 MB").
|
|
76
|
+
size: (bytes) => formatFileSize(bytes, numberLocale),
|
|
77
|
+
},
|
|
78
|
+
filePicker: {
|
|
79
|
+
...d.filePicker,
|
|
80
|
+
rejectedCount: (name, maxFiles) =>
|
|
81
|
+
`“${name}” was not added: at most ${n(maxFiles)} ${maxFiles === 1 ? "file" : "files"}`,
|
|
82
|
+
rejectedMany: (count) => `${n(count)} files were not added`,
|
|
83
|
+
rejectedPick: (count) =>
|
|
84
|
+
count === 1 ? "The file was not added" : `None of the ${n(count)} files were added`,
|
|
85
|
+
selected: (count, firstName) =>
|
|
86
|
+
count === 1 ? `“${firstName}” selected` : `${n(count)} files selected`,
|
|
87
|
+
},
|
|
88
|
+
wizard: {
|
|
89
|
+
...d.wizard,
|
|
90
|
+
step: (current, total) => `Step ${n(current)} of ${n(total)}`,
|
|
91
|
+
},
|
|
92
|
+
tour: {
|
|
93
|
+
...d.tour,
|
|
94
|
+
step: (current, total) => `${n(current)} / ${n(total)}`,
|
|
95
|
+
},
|
|
96
|
+
passwordStrength: {
|
|
97
|
+
...d.passwordStrength,
|
|
98
|
+
ruleLength: (minLength) => `At least ${n(minLength)} characters`,
|
|
99
|
+
tooLong: (maxBytes) =>
|
|
100
|
+
`At most ${n(maxBytes)} characters (accents and emoji count for more than one).`,
|
|
101
|
+
},
|
|
102
|
+
floatingPanel: {
|
|
103
|
+
...d.floatingPanel,
|
|
104
|
+
badge: (count) => `${n(count)} new`,
|
|
105
|
+
},
|
|
106
|
+
imageGrid: {
|
|
107
|
+
...d.imageGrid,
|
|
108
|
+
item: (index, count) => `Image ${n(index)} of ${n(count)}`,
|
|
109
|
+
},
|
|
110
|
+
lightbox: {
|
|
111
|
+
...d.lightbox,
|
|
112
|
+
counter: (index, count) => `${n(index)} / ${n(count)}`,
|
|
113
|
+
position: (index, count) => `Image ${n(index)} of ${n(count)}`,
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
package/src/rhf/line-items.tsx
CHANGED
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
* list's `error`. Each cell's `render` gets `name`, the row's path
|
|
25
25
|
* (`"components.2"`), to bind its field to.
|
|
26
26
|
*
|
|
27
|
-
* `summary`, `fieldLabels` and `
|
|
27
|
+
* `summary`, `fieldLabels`, `narrowColumns`, `removePlacement` and `removeAlign` pass
|
|
28
|
+
* through to {@link LineItems}. With
|
|
28
29
|
* `fieldLabels="floating"` a cell's `fieldLabel` is the column's name; the bound
|
|
29
30
|
* fields' own `label` is a `FormLabel` ABOVE the control, so for the floating
|
|
30
31
|
* one render the kit field through `RhfField`:
|
|
@@ -42,8 +42,20 @@ export const DEFAULT_GLOBAL_SEARCH_LABELS: GlobalSearchLabels = {
|
|
|
42
42
|
export interface GlobalSearchSource {
|
|
43
43
|
/** Stable key — the effect that runs the source is keyed by it, not by identity. */
|
|
44
44
|
id: string;
|
|
45
|
-
/** The group its results are shown under (an entry's own `group` is ignored)
|
|
45
|
+
/** The group its results are shown under (an entry's own `group` is ignored) — unless
|
|
46
|
+
* `groups` is set, where it is only the home of entries that name no declared group. */
|
|
46
47
|
group: string;
|
|
48
|
+
/**
|
|
49
|
+
* Split ONE request into several groups — kastlan's record search answers with
|
|
50
|
+
* properties, units, contacts, leases and invoices at once, and one source per group
|
|
51
|
+
* meant a dedupe layer so the network still saw one request. Each entry is then shown
|
|
52
|
+
* under the group its own `group` names: a declared `key` (shown by its `label`) or a
|
|
53
|
+
* plain string (key and heading at once). The groups appear in THIS order whatever the
|
|
54
|
+
* answer's order, each kept to `limit` rows; an entry naming no declared group falls
|
|
55
|
+
* under `group`. While the request runs, or when it fails, the source shows one status
|
|
56
|
+
* line under its first group, not one per group. Unset: one group, as before.
|
|
57
|
+
*/
|
|
58
|
+
groups?: readonly (string | GlobalSearchSourceGroup)[];
|
|
47
59
|
/** Called with the trimmed query. Its results are shown AS GIVEN — the source ranked
|
|
48
60
|
* them — and never re-filtered by the index's matcher. `signal` aborts when the query
|
|
49
61
|
* moves on or the palette closes; a stale answer is dropped either way. */
|
|
@@ -52,7 +64,7 @@ export interface GlobalSearchSource {
|
|
|
52
64
|
minChars?: number;
|
|
53
65
|
/** Quiet period after the last keystroke before it is asked. Default 200ms. */
|
|
54
66
|
debounceMs?: number;
|
|
55
|
-
/** Rows kept. Default: `indexOptions.groupLimit` (8). */
|
|
67
|
+
/** Rows kept — per group, when `groups` is set. Default: `indexOptions.groupLimit` (8). */
|
|
56
68
|
limit?: number;
|
|
57
69
|
/** Mask every row of this source for session replay, hints (amounts) included. An
|
|
58
70
|
* entry's own `redact` still wins. */
|
|
@@ -62,6 +74,13 @@ export interface GlobalSearchSource {
|
|
|
62
74
|
onError?: (error: unknown) => void;
|
|
63
75
|
}
|
|
64
76
|
|
|
77
|
+
/** One of a multi-group source's groups: the `key` its entries name in `group`, and the
|
|
78
|
+
* heading shown — so a server's type ("unit") need not be translated per entry. */
|
|
79
|
+
export interface GlobalSearchSourceGroup {
|
|
80
|
+
key: string;
|
|
81
|
+
label: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
65
84
|
/** What a custom trigger gets: everything the default button uses. */
|
|
66
85
|
export interface GlobalSearchTriggerProps {
|
|
67
86
|
open: () => void;
|
|
@@ -90,7 +109,7 @@ export interface GlobalSearchProps {
|
|
|
90
109
|
* neither re-indexes nor loops, and the icons and handlers used are always the latest.
|
|
91
110
|
*/
|
|
92
111
|
entries?: readonly SearchEntry[];
|
|
93
|
-
/** The async sources, each streamed into its own group. */
|
|
112
|
+
/** The async sources, each streamed into its own group — or several (`groups`). */
|
|
94
113
|
sources?: readonly GlobalSearchSource[];
|
|
95
114
|
/** What the empty query shows, under the `suggestions` heading. */
|
|
96
115
|
suggestions?: readonly GlobalSearchSuggestion[];
|
|
@@ -311,7 +330,17 @@ function GlobalSearchImpl({
|
|
|
311
330
|
const byId = useMemo(() => new Map(list.map((e) => [e.id, e])), [sig]);
|
|
312
331
|
|
|
313
332
|
const sourceKey = (sources ?? [])
|
|
314
|
-
.map((s) =>
|
|
333
|
+
.map((s) =>
|
|
334
|
+
[
|
|
335
|
+
s.id,
|
|
336
|
+
s.group,
|
|
337
|
+
(s.groups ?? []).map((g) => (typeof g === "string" ? g : `${g.key}\u0003${g.label}`)).join("\u0004"),
|
|
338
|
+
s.minChars ?? 2,
|
|
339
|
+
s.debounceMs ?? 200,
|
|
340
|
+
s.limit,
|
|
341
|
+
s.redact,
|
|
342
|
+
].join("\u0001"),
|
|
343
|
+
)
|
|
315
344
|
.join("\u0002");
|
|
316
345
|
const sourceList = useMemo(() => sources ?? [], [sourceKey]);
|
|
317
346
|
|
|
@@ -434,10 +463,12 @@ function GlobalSearchImpl({
|
|
|
434
463
|
for (const source of sourceList) {
|
|
435
464
|
if (q.length < (source.minChars ?? 2)) continue;
|
|
436
465
|
const result = sourceResults[source.id];
|
|
466
|
+
const declared = (source.groups ?? []).map((g) => (typeof g === "string" ? { key: g, label: g } : g));
|
|
437
467
|
const status = (label: string, icon: ReactNode): CommandItem => ({
|
|
438
468
|
id: `src:${source.id}:status`,
|
|
439
469
|
label,
|
|
440
|
-
group
|
|
470
|
+
// One line for the one request, under the group that will head its answer.
|
|
471
|
+
group: declared[0]?.label ?? source.group,
|
|
441
472
|
icon,
|
|
442
473
|
kind: "status",
|
|
443
474
|
onSelect: noop,
|
|
@@ -448,10 +479,30 @@ function GlobalSearchImpl({
|
|
|
448
479
|
out.push(status(pl.loading, <Loader2 className="size-3.5 animate-spin" />));
|
|
449
480
|
} else if (result.status === "error") {
|
|
450
481
|
out.push(status(pl.error ?? DEFAULT_COMMAND_PALETTE_LABELS.error!, <AlertCircle className="size-3.5" />));
|
|
451
|
-
} else {
|
|
482
|
+
} else if (!declared.length) {
|
|
452
483
|
for (const entry of result.entries.slice(0, source.limit ?? groupLimit)) {
|
|
453
484
|
out.push(toItem(`src:${source.id}:`, entry, source.group, source.redact ?? redactLabels));
|
|
454
485
|
}
|
|
486
|
+
} else {
|
|
487
|
+
// Bucket by the entry's own group, then emit in the DECLARED order — the answer's
|
|
488
|
+
// order must not reshuffle the headings between keystrokes. Within a group the
|
|
489
|
+
// source's ranking stands. The group key is in the row id: two record types may
|
|
490
|
+
// share an id ("1"), and the palette keys rows by it.
|
|
491
|
+
const buckets = new Map<string, { label: string; entries: SearchEntry[] }>();
|
|
492
|
+
for (const g of declared) buckets.set(g.key, { label: g.label, entries: [] });
|
|
493
|
+
const rest: SearchEntry[] = [];
|
|
494
|
+
for (const entry of result.entries) {
|
|
495
|
+
const bucket = entry.group === undefined ? undefined : buckets.get(entry.group);
|
|
496
|
+
(bucket ? bucket.entries : rest).push(entry);
|
|
497
|
+
}
|
|
498
|
+
const limit = source.limit ?? groupLimit;
|
|
499
|
+
const emit = (key: string, label: string, list: SearchEntry[]) => {
|
|
500
|
+
for (const entry of list.slice(0, limit)) {
|
|
501
|
+
out.push(toItem(`src:${source.id}:${key}:`, entry, label, source.redact ?? redactLabels));
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
for (const [key, { label, entries: list }] of buckets) emit(key, label, list);
|
|
505
|
+
emit("~", source.group, rest);
|
|
455
506
|
}
|
|
456
507
|
}
|
|
457
508
|
|