@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
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The matcher behind {@link GlobalSearch} — pure, React-free, testable on its own.
|
|
5
|
+
*
|
|
6
|
+
* WHY IT EXISTS. `CommandPalette` is deliberately domain-free: it shows whatever a
|
|
7
|
+
* `search(query)` provider returns. So every app wrote that provider, and every app wrote
|
|
8
|
+
* the same one — `label.toLowerCase().includes(q)` over its pages and actions (kastlan's
|
|
9
|
+
* command palette, keksdose's global search) plus a second copy with keywords for the
|
|
10
|
+
* settings catalogue (keksdose's `matchSettingsEntries`). A substring test answers "does
|
|
11
|
+
* this string contain that one", which is not what a search box is asked:
|
|
12
|
+
*
|
|
13
|
+
* - "range date" finds nothing, because the words are in the other order;
|
|
14
|
+
* - "Uberweisung" misses "Überweisung", and "Tooltp" misses "Tooltip";
|
|
15
|
+
* - "del" matches "model" as happily as "delete", and every hit ranks the same, so the
|
|
16
|
+
* exact page name can sit under six unrelated rows.
|
|
17
|
+
*
|
|
18
|
+
* What it does instead, per entry:
|
|
19
|
+
*
|
|
20
|
+
* - NORMALISES both sides: lower case, accents stripped (NFD minus combining marks),
|
|
21
|
+
* `ß` → `ss`, punctuation as a word break, and camelCase split so `DateRangePicker`
|
|
22
|
+
* is also the words date, range, picker.
|
|
23
|
+
* - Requires EVERY query word to match SOMEWHERE in the entry — title, keywords or
|
|
24
|
+
* description, in any order.
|
|
25
|
+
* - A word matches a word of the entry exactly, as its prefix, inside it (four letters or
|
|
26
|
+
* more, for compounds: "bereich" in "Datumsbereich"), or — for words of five letters
|
|
27
|
+
* or more — at edit distance one, transpositions included ("tooltp", "calender").
|
|
28
|
+
* - RANKS in tiers: exact title > title prefix > all words in the title > some word only
|
|
29
|
+
* in the keywords > some word only in the description. Within a tier, better word
|
|
30
|
+
* matches (exact over prefix over infix over typo) score higher, and `weight` is added
|
|
31
|
+
* last. The tiers are {@link SEARCH_TIER_POINTS} points apart, so a weight below that reorders within
|
|
32
|
+
* a tier and never across one.
|
|
33
|
+
* - Caps each group (`groupLimit`, default 8) after ranking, so one broad group cannot
|
|
34
|
+
* push every other group off the list.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** One searchable thing: a page, an action, a record, a settings row. */
|
|
38
|
+
export interface SearchEntry {
|
|
39
|
+
/** Unique across the entries handed to one index. */
|
|
40
|
+
id: string;
|
|
41
|
+
/** What the row says, and what ranks highest. */
|
|
42
|
+
title: string;
|
|
43
|
+
/** Other words or phrases that should find it — synonyms, a translated name, tags.
|
|
44
|
+
* Matched like the title, ranked below it. */
|
|
45
|
+
keywords?: readonly string[];
|
|
46
|
+
/** Longer text, matched last and ranked lowest. Not shown in the row. */
|
|
47
|
+
description?: string;
|
|
48
|
+
/** Heading the row is grouped under in the palette. */
|
|
49
|
+
group?: string;
|
|
50
|
+
/** Secondary text on the row's trailing side (the page it lives on, an amount). */
|
|
51
|
+
hint?: string;
|
|
52
|
+
/** Leading icon. */
|
|
53
|
+
icon?: ReactNode;
|
|
54
|
+
/** Where it leads — the app's own path, handed to `GlobalSearch`'s `navigate`. */
|
|
55
|
+
href?: string;
|
|
56
|
+
/** Run instead of navigating. Wins over `href` for a plain click and ↵; `href` still
|
|
57
|
+
* makes the row a real link for a middle-/⌘-click. */
|
|
58
|
+
onSelect?: () => void;
|
|
59
|
+
/** Added to the score. Below {@link SEARCH_TIER_POINTS} it reorders within a tier. */
|
|
60
|
+
weight?: number;
|
|
61
|
+
/** Masks the row for session replay (see `CommandItem.redact`). */
|
|
62
|
+
redact?: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type SearchMatchField = "title" | "keywords" | "description";
|
|
66
|
+
|
|
67
|
+
export interface SearchHit<E extends SearchEntry = SearchEntry> {
|
|
68
|
+
entry: E;
|
|
69
|
+
score: number;
|
|
70
|
+
/** The weakest field any query word had to be found in. */
|
|
71
|
+
field: SearchMatchField;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface SearchIndexOptions {
|
|
75
|
+
/** Hits kept per `group` after ranking. Default 8; `Infinity` for no cap. */
|
|
76
|
+
groupLimit?: number;
|
|
77
|
+
/** Hits kept in total. Default: no cap beyond `groupLimit`. */
|
|
78
|
+
limit?: number;
|
|
79
|
+
/** Shortest query word allowed an edit-distance-1 match. Default 5 — below that a
|
|
80
|
+
* single typo is a different word ("cat" / "car"). */
|
|
81
|
+
typoMinLength?: number;
|
|
82
|
+
/** Shortest query word matched INSIDE an entry word. Default 4. */
|
|
83
|
+
infixMinLength?: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface SearchIndex<E extends SearchEntry = SearchEntry> {
|
|
87
|
+
/** Ranked hits for `query`; an empty query returns none. */
|
|
88
|
+
search: (query: string) => SearchHit<E>[];
|
|
89
|
+
readonly entries: readonly E[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Points between two ranking tiers. */
|
|
93
|
+
export const SEARCH_TIER_POINTS = 1000;
|
|
94
|
+
|
|
95
|
+
const TIERS = {
|
|
96
|
+
exact: 5,
|
|
97
|
+
prefix: 4,
|
|
98
|
+
title: 3,
|
|
99
|
+
keywords: 2,
|
|
100
|
+
description: 1,
|
|
101
|
+
} as const;
|
|
102
|
+
|
|
103
|
+
/** How well one query word matched one entry word — higher is better. */
|
|
104
|
+
const WORD_SCORE = { exact: 40, prefix: 30, infix: 15, typo: 10 } as const;
|
|
105
|
+
/** How much the field a word matched in is worth, multiplied with the above. */
|
|
106
|
+
const FIELD_FACTOR: Record<SearchMatchField, number> = { title: 3, keywords: 2, description: 1 };
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Lower case, accents off, punctuation to spaces — the form both sides are compared in.
|
|
110
|
+
* Exported so an app can normalise its own keys the same way.
|
|
111
|
+
*/
|
|
112
|
+
export function normalizeSearchText(text: string): string {
|
|
113
|
+
return text
|
|
114
|
+
.normalize("NFD")
|
|
115
|
+
.replace(/\p{M}+/gu, "")
|
|
116
|
+
.toLowerCase()
|
|
117
|
+
.replace(/ß/g, "ss")
|
|
118
|
+
.replace(/[^\p{L}\p{N}]+/gu, " ")
|
|
119
|
+
.trim();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The words of a text, camelCase split too: "DateRangePicker" → daterangepicker, date,
|
|
123
|
+
* range, picker. The joined word stays so "daterange" still prefixes it. */
|
|
124
|
+
function words(text: string): string[] {
|
|
125
|
+
const out = new Set<string>();
|
|
126
|
+
// Split camelCase BEFORE lower-casing, or the boundaries are gone.
|
|
127
|
+
const spaced = text.replace(/(\p{Ll}|\p{N})(\p{Lu})/gu, "$1 $2").replace(/(\p{Lu})(\p{Lu}\p{Ll})/gu, "$1 $2");
|
|
128
|
+
for (const w of normalizeSearchText(text).split(" ")) if (w) out.add(w);
|
|
129
|
+
for (const w of normalizeSearchText(spaced).split(" ")) if (w) out.add(w);
|
|
130
|
+
return [...out];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Restricted Damerau–Levenshtein distance, early-exiting once it must exceed `max`. */
|
|
134
|
+
function withinDistance(a: string, b: string, max: number): boolean {
|
|
135
|
+
if (Math.abs(a.length - b.length) > max) return false;
|
|
136
|
+
if (a === b) return true;
|
|
137
|
+
const n = a.length;
|
|
138
|
+
const m = b.length;
|
|
139
|
+
let prev2: number[] = [];
|
|
140
|
+
let prev = Array.from({ length: m + 1 }, (_, j) => j);
|
|
141
|
+
for (let i = 1; i <= n; i++) {
|
|
142
|
+
const cur = [i];
|
|
143
|
+
let rowMin = i;
|
|
144
|
+
for (let j = 1; j <= m; j++) {
|
|
145
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
146
|
+
let v = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost);
|
|
147
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
|
|
148
|
+
v = Math.min(v, prev2[j - 2] + 1);
|
|
149
|
+
}
|
|
150
|
+
cur.push(v);
|
|
151
|
+
if (v < rowMin) rowMin = v;
|
|
152
|
+
}
|
|
153
|
+
if (rowMin > max) return false;
|
|
154
|
+
prev2 = prev;
|
|
155
|
+
prev = cur;
|
|
156
|
+
}
|
|
157
|
+
return prev[m] <= max;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
type WordKind = keyof typeof WORD_SCORE;
|
|
161
|
+
|
|
162
|
+
function matchWord(q: string, word: string, opts: Required<Pick<SearchIndexOptions, "typoMinLength" | "infixMinLength">>): WordKind | null {
|
|
163
|
+
if (word === q) return "exact";
|
|
164
|
+
if (word.startsWith(q)) return "prefix";
|
|
165
|
+
if (q.length >= opts.infixMinLength && word.includes(q)) return "infix";
|
|
166
|
+
if (q.length >= opts.typoMinLength) {
|
|
167
|
+
// The whole word, or the word's start as long as the query (a typo in a prefix:
|
|
168
|
+
// "calender" is one edit from "calendar", the start of "calendars").
|
|
169
|
+
if (withinDistance(q, word, 1)) return "typo";
|
|
170
|
+
if (word.length > q.length && withinDistance(q, word.slice(0, q.length), 1)) return "typo";
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
interface Prepared<E> {
|
|
176
|
+
entry: E;
|
|
177
|
+
title: string;
|
|
178
|
+
titleCompact: string;
|
|
179
|
+
fields: Array<[SearchMatchField, string[]]>;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Build an index over `entries`. Normalisation happens once here, so `search` is a scan
|
|
184
|
+
* over prepared words — a few thousand entries per keystroke is nothing.
|
|
185
|
+
*/
|
|
186
|
+
export function createSearchIndex<E extends SearchEntry>(
|
|
187
|
+
entries: readonly E[],
|
|
188
|
+
options: SearchIndexOptions = {},
|
|
189
|
+
): SearchIndex<E> {
|
|
190
|
+
const { groupLimit = 8, limit = Infinity, typoMinLength = 5, infixMinLength = 4 } = options;
|
|
191
|
+
const wordOpts = { typoMinLength, infixMinLength };
|
|
192
|
+
const prepared: Prepared<E>[] = entries.map((entry) => {
|
|
193
|
+
const title = normalizeSearchText(entry.title);
|
|
194
|
+
return {
|
|
195
|
+
entry,
|
|
196
|
+
title,
|
|
197
|
+
titleCompact: title.replace(/ /g, ""),
|
|
198
|
+
fields: [
|
|
199
|
+
["title", words(entry.title)],
|
|
200
|
+
["keywords", (entry.keywords ?? []).flatMap(words)],
|
|
201
|
+
["description", entry.description ? words(entry.description) : []],
|
|
202
|
+
],
|
|
203
|
+
};
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const search = (query: string): SearchHit<E>[] => {
|
|
207
|
+
const q = normalizeSearchText(query);
|
|
208
|
+
if (!q) return [];
|
|
209
|
+
const qWords = [...new Set(q.split(" "))];
|
|
210
|
+
const qCompact = q.replace(/ /g, "");
|
|
211
|
+
const hits: SearchHit<E>[] = [];
|
|
212
|
+
|
|
213
|
+
for (const p of prepared) {
|
|
214
|
+
let quality = 0;
|
|
215
|
+
let weakest: SearchMatchField = "title";
|
|
216
|
+
let ok = true;
|
|
217
|
+
for (const qw of qWords) {
|
|
218
|
+
let best = 0;
|
|
219
|
+
let bestField: SearchMatchField | null = null;
|
|
220
|
+
for (const [field, fieldWords] of p.fields) {
|
|
221
|
+
for (const w of fieldWords) {
|
|
222
|
+
const kind = matchWord(qw, w, wordOpts);
|
|
223
|
+
if (!kind) continue;
|
|
224
|
+
const s = WORD_SCORE[kind] * FIELD_FACTOR[field];
|
|
225
|
+
if (s > best) {
|
|
226
|
+
best = s;
|
|
227
|
+
bestField = field;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (!bestField) {
|
|
232
|
+
ok = false;
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
quality += best;
|
|
236
|
+
if (FIELD_FACTOR[bestField] < FIELD_FACTOR[weakest]) weakest = bestField;
|
|
237
|
+
}
|
|
238
|
+
if (!ok) continue;
|
|
239
|
+
|
|
240
|
+
let tier: number = TIERS[weakest];
|
|
241
|
+
if (p.title === q || p.titleCompact === qCompact) tier = TIERS.exact;
|
|
242
|
+
else if (
|
|
243
|
+
p.title.startsWith(q) ||
|
|
244
|
+
p.titleCompact.startsWith(qCompact) ||
|
|
245
|
+
// The whole title, mistyped once ("Tooltp") — as good as a prefix, not exact.
|
|
246
|
+
(qCompact.length >= typoMinLength && withinDistance(qCompact, p.titleCompact, 1))
|
|
247
|
+
) {
|
|
248
|
+
tier = TIERS.prefix;
|
|
249
|
+
}
|
|
250
|
+
// Within a tier: how well the words matched (≤ 120 each, averaged so a long query
|
|
251
|
+
// cannot climb by word count), and how much of the title the query covers — so
|
|
252
|
+
// "Tooltip" beats "Popovers, menus & tooltips" for the same one-word match.
|
|
253
|
+
const coverage = Math.min(1, qCompact.length / Math.max(1, p.titleCompact.length)) * 40;
|
|
254
|
+
const score =
|
|
255
|
+
tier * SEARCH_TIER_POINTS +
|
|
256
|
+
Math.min(quality / qWords.length + coverage, SEARCH_TIER_POINTS / 2 - 1) +
|
|
257
|
+
(p.entry.weight ?? 0);
|
|
258
|
+
hits.push({ entry: p.entry, score, field: weakest });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
hits.sort((a, b) => b.score - a.score || a.entry.title.localeCompare(b.entry.title));
|
|
262
|
+
return capByGroup(hits, groupLimit, limit);
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
return { search, entries };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function capByGroup<E extends SearchEntry>(hits: SearchHit<E>[], groupLimit: number, limit: number): SearchHit<E>[] {
|
|
269
|
+
const perGroup = new Map<string, number>();
|
|
270
|
+
const out: SearchHit<E>[] = [];
|
|
271
|
+
for (const hit of hits) {
|
|
272
|
+
if (out.length >= limit) break;
|
|
273
|
+
const g = hit.entry.group ?? "";
|
|
274
|
+
const n = perGroup.get(g) ?? 0;
|
|
275
|
+
if (n >= groupLimit) continue;
|
|
276
|
+
perGroup.set(g, n + 1);
|
|
277
|
+
out.push(hit);
|
|
278
|
+
}
|
|
279
|
+
return out;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* One-shot form of {@link createSearchIndex}: the matching entries, best first. For a
|
|
284
|
+
* list that changes on every call (a server response, a store selector); build an index
|
|
285
|
+
* once when the entries are stable.
|
|
286
|
+
*/
|
|
287
|
+
export function matchEntries<E extends SearchEntry>(
|
|
288
|
+
entries: readonly E[],
|
|
289
|
+
query: string,
|
|
290
|
+
options?: SearchIndexOptions,
|
|
291
|
+
): E[] {
|
|
292
|
+
return createSearchIndex(entries, options)
|
|
293
|
+
.search(query)
|
|
294
|
+
.map((hit) => hit.entry);
|
|
295
|
+
}
|
package/src/search.ts
CHANGED
|
@@ -42,6 +42,7 @@ export function StepperNav<TData extends Record<string, unknown>>({
|
|
|
42
42
|
labels,
|
|
43
43
|
finishVariant = "brand",
|
|
44
44
|
finishDisabled = false,
|
|
45
|
+
doneDisabled = false,
|
|
45
46
|
renderFinish,
|
|
46
47
|
}: {
|
|
47
48
|
wizard: UseWizardReturn<TData>;
|
|
@@ -56,6 +57,15 @@ export function StepperNav<TData extends Record<string, unknown>>({
|
|
|
56
57
|
/** Disable Finish for a reason of the app's own, on top of the kit's gates
|
|
57
58
|
* (`canFinish`, submitting, validating). */
|
|
58
59
|
finishDisabled?: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Disable Done — the button of the last step after a commit — while the step still
|
|
62
|
+
* has work of its own running. keksdose's YNAB import ends on a recurring step whose
|
|
63
|
+
* convert loop writes one rule at a time (ynab-import-panel.tsx): leaving mid-loop
|
|
64
|
+
* abandons the rest. Until now the panel dropped `onDone` for the duration, which
|
|
65
|
+
* takes the button away instead of saying "not yet": disabled, it stays where the
|
|
66
|
+
* eye left it and comes back to life when the loop ends.
|
|
67
|
+
*/
|
|
68
|
+
doneDisabled?: boolean;
|
|
59
69
|
/**
|
|
60
70
|
* Wrap the Finish button: receives the kit's button element — already labelled,
|
|
61
71
|
* gated and wired to `wizard.finish` — and returns what to render in its place.
|
|
@@ -97,7 +107,8 @@ export function StepperNav<TData extends Record<string, unknown>>({
|
|
|
97
107
|
const pending = wizard.isValidating;
|
|
98
108
|
const stepNextLabel = wizard.currentStep.nextLabel;
|
|
99
109
|
|
|
100
|
-
|
|
110
|
+
// Every branch below assigns it; no initial value to overwrite.
|
|
111
|
+
let forwardButton: ReactNode;
|
|
101
112
|
if (wizard.isCommitStep) {
|
|
102
113
|
const finishButton = (
|
|
103
114
|
<Button
|
|
@@ -117,7 +128,7 @@ export function StepperNav<TData extends Record<string, unknown>>({
|
|
|
117
128
|
// The last step after a commit. Nothing to finish; Done only when the app gave
|
|
118
129
|
// an `onDone` — otherwise the step carries its own way out.
|
|
119
130
|
forwardButton = wizard.canDone ? (
|
|
120
|
-
<Button variant="brand" data-tour="wizard-done" onClick={wizard.done}>
|
|
131
|
+
<Button variant="brand" data-tour="wizard-done" onClick={wizard.done} disabled={doneDisabled}>
|
|
121
132
|
{stepNextLabel ?? l.done ?? DEFAULT_WIZARD_LABELS.done}
|
|
122
133
|
</Button>
|
|
123
134
|
) : null;
|
package/src/wizard/types.ts
CHANGED
|
@@ -109,8 +109,8 @@ export interface UseWizardOptions<TData extends Record<string, unknown>> {
|
|
|
109
109
|
*/
|
|
110
110
|
missingRequiredMessage?: string;
|
|
111
111
|
/**
|
|
112
|
-
* How to surface {@link missingRequiredMessage}. Defaults to
|
|
113
|
-
* (an optional peer,
|
|
112
|
+
* How to surface {@link missingRequiredMessage}. Defaults to the kit's `toast.error`
|
|
113
|
+
* (sonner, an optional peer, is loaded only when a toast is shown). Pass your own to
|
|
114
114
|
* route it somewhere else, or to a no-op to silence it.
|
|
115
115
|
*/
|
|
116
116
|
onValidationFailed?: (message: string) => void;
|
package/src/wizard/use-wizard.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { useCallback, useEffect, useReducer, useRef, useState } from "react";
|
|
|
18
18
|
import { useSearchParams } from "react-router";
|
|
19
19
|
import { useKitLabels } from "../i18n/kit-labels";
|
|
20
20
|
import { DEFAULT_WIZARD_LABELS } from "./types";
|
|
21
|
+
import { toast } from "../components/toast";
|
|
21
22
|
import type {
|
|
22
23
|
FieldErrors,
|
|
23
24
|
StepStatus,
|
|
@@ -312,9 +313,8 @@ export function useWizard<TData extends Record<string, unknown>>(
|
|
|
312
313
|
if (onValidationFailed) {
|
|
313
314
|
onValidationFailed(message);
|
|
314
315
|
} else {
|
|
315
|
-
// sonner
|
|
316
|
-
//
|
|
317
|
-
const { toast } = await import("sonner");
|
|
316
|
+
// The kit's `toast` loads sonner (an optional peer) lazily, so an app
|
|
317
|
+
// that never trips this never has to install it.
|
|
318
318
|
toast.error(message);
|
|
319
319
|
}
|
|
320
320
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Pencil } from "lucide-react";
|
|
2
2
|
import { Card, CardAction, CardContent, CardHeader, CardTitle, IconButton } from "../components/ui";
|
|
3
|
+
import { Tooltip } from "../components/tooltip";
|
|
3
4
|
import type { SummarySection, WizardLabels } from "./types";
|
|
4
5
|
import { DEFAULT_WIZARD_LABELS } from "./types";
|
|
5
6
|
import { useKitLabels } from "../i18n/kit-labels";
|
|
@@ -44,16 +45,19 @@ export function WizardSummary({
|
|
|
44
45
|
<CardTitle className="text-base">{section.label}</CardTitle>
|
|
45
46
|
{section.stepIndex !== undefined && onEditStep && (
|
|
46
47
|
<CardAction>
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
48
|
+
{/* The kit Tooltip, not a native `title`: it shows on keyboard focus too,
|
|
49
|
+
and an app with a one-tooltip rule (keksdose dev#523) sees one. */}
|
|
50
|
+
<Tooltip label={l.edit}>
|
|
51
|
+
<IconButton
|
|
52
|
+
size="sm"
|
|
53
|
+
variant="ghost"
|
|
54
|
+
aria-label={l.edit}
|
|
55
|
+
disabled={disabled}
|
|
56
|
+
onClick={() => onEditStep(section.stepIndex as number)}
|
|
57
|
+
>
|
|
58
|
+
<Pencil />
|
|
59
|
+
</IconButton>
|
|
60
|
+
</Tooltip>
|
|
57
61
|
</CardAction>
|
|
58
62
|
)}
|
|
59
63
|
</CardHeader>
|
package/tokens.css
CHANGED
|
@@ -233,6 +233,103 @@
|
|
|
233
233
|
--success-bg: rgba(6, 78, 59, 0.25);
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
/* ── Toasts ─────────────────────────────────────────────────────────────────
|
|
237
|
+
The kit `<Toaster>` (components/toast.tsx) paints sonner with the AlertBanner
|
|
238
|
+
tones. These are ALIASES, not new colours, and they exist for one reason: sonner
|
|
239
|
+
declares its own `--success-bg` / `--success-border` (and info, warning) on the
|
|
240
|
+
toaster element, so that element cannot read the kit's value under the same name
|
|
241
|
+
— `--success-bg: var(--success-bg)` is a cycle. Declared here, they are computed
|
|
242
|
+
on `<html>` and inherited down under a name sonner does not touch.
|
|
243
|
+
|
|
244
|
+
The background is the tone's wash layered on the surface, as AlertBanner's
|
|
245
|
+
`elevated` frame does: the dark washes are translucent, and a toast floats over
|
|
246
|
+
content. `--z-toast` is above every kit overlay (modal and sheets z-50, tour
|
|
247
|
+
z-60/61, command palette z-70) and above whatever an app stacks on top of those —
|
|
248
|
+
a toast confirming a dialog's action must not land under the dialog. */
|
|
249
|
+
:root,
|
|
250
|
+
.dark {
|
|
251
|
+
--z-toast: 2147483647;
|
|
252
|
+
--toast-success-bg: linear-gradient(var(--success-bg), var(--success-bg)) var(--bg-surface);
|
|
253
|
+
--toast-success-border: var(--success-border);
|
|
254
|
+
--toast-info-bg: linear-gradient(var(--info-bg), var(--info-bg)) var(--bg-surface);
|
|
255
|
+
--toast-info-border: var(--info-border);
|
|
256
|
+
--toast-warning-bg: linear-gradient(var(--warning-bg), var(--warning-bg)) var(--bg-surface);
|
|
257
|
+
--toast-warning-border: var(--warning-border);
|
|
258
|
+
--toast-danger-bg: linear-gradient(var(--danger-bg), var(--danger-bg)) var(--bg-surface);
|
|
259
|
+
--toast-danger-border: var(--danger-border);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/* The description of an untoned toast in the secondary text colour (sonner hard-codes
|
|
263
|
+
#3f3f3f, unreadable in the dark theme); a toned toast's inherits its tone. */
|
|
264
|
+
.kit-toaster [data-sonner-toast][data-styled="true"]:not([data-type="success"], [data-type="info"], [data-type="warning"], [data-type="error"]) [data-description] {
|
|
265
|
+
color: var(--text-secondary);
|
|
266
|
+
}
|
|
267
|
+
/* The action button in the tone's own colour rather than sonner's black pill. */
|
|
268
|
+
.kit-toaster [data-sonner-toast][data-styled="true"] [data-button]:not([data-cancel]) {
|
|
269
|
+
background: var(--toast-action-bg, var(--text-primary));
|
|
270
|
+
color: var(--bg-surface);
|
|
271
|
+
}
|
|
272
|
+
.kit-toaster [data-sonner-toast][data-type="success"] { --toast-action-bg: var(--success); }
|
|
273
|
+
.kit-toaster [data-sonner-toast][data-type="info"] { --toast-action-bg: var(--info); }
|
|
274
|
+
.kit-toaster [data-sonner-toast][data-type="warning"] { --toast-action-bg: var(--warning); }
|
|
275
|
+
.kit-toaster [data-sonner-toast][data-type="error"] { --toast-action-bg: var(--danger); }
|
|
276
|
+
/* The kit's focus ring on the toast's buttons — sonner's is a black shadow that
|
|
277
|
+
vanishes on the dark theme. */
|
|
278
|
+
.kit-toaster [data-sonner-toast][data-styled="true"] :is([data-button], [data-close-button]):focus-visible {
|
|
279
|
+
outline: 2px solid var(--brand);
|
|
280
|
+
outline-offset: 2px;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/* ── Categorical hues ───────────────────────────────────────────────────────
|
|
284
|
+
Colours that MEAN nothing but "this is a different value": kastlan's status
|
|
285
|
+
enums (status-badge.tsx over enum-meta.ts), where ~15 enums map to 9 Tailwind
|
|
286
|
+
hues. Four of those are semantic and already have tokens — green = --success,
|
|
287
|
+
yellow = --warning, red = --danger, gray = the neutral chip — so only the five
|
|
288
|
+
below are new: blue (open / sent / rented), purple (assigned / accepted),
|
|
289
|
+
orange (terminated / high urgency), teal (invoiced) and indigo (a meeting
|
|
290
|
+
stage). The smallest set that covers every enum; deliberately not a ramp.
|
|
291
|
+
|
|
292
|
+
Same triple and same shades as the status family (700 text / 300 border /
|
|
293
|
+
100 surface in light, 300 text / translucent 700 border / 900 wash in dark),
|
|
294
|
+
so a categorical chip sits beside a semantic one at the same weight. Not in
|
|
295
|
+
`TokenSet`: like `--danger`, a hue is a specific colour, not a tint of the
|
|
296
|
+
palette preset, so it lives here and nowhere else. */
|
|
297
|
+
:root {
|
|
298
|
+
--hue-blue: #1d4ed8; /* blue-700 */
|
|
299
|
+
--hue-blue-border: #93c5fd; /* blue-300 */
|
|
300
|
+
--hue-blue-bg: #dbeafe; /* blue-100 */
|
|
301
|
+
--hue-indigo: #4338ca; /* indigo-700 */
|
|
302
|
+
--hue-indigo-border: #a5b4fc; /* indigo-300 */
|
|
303
|
+
--hue-indigo-bg: #e0e7ff; /* indigo-100 */
|
|
304
|
+
--hue-purple: #7e22ce; /* purple-700 */
|
|
305
|
+
--hue-purple-border: #d8b4fe; /* purple-300 */
|
|
306
|
+
--hue-purple-bg: #f3e8ff; /* purple-100 */
|
|
307
|
+
--hue-teal: #0f766e; /* teal-700 */
|
|
308
|
+
--hue-teal-border: #5eead4; /* teal-300 */
|
|
309
|
+
--hue-teal-bg: #ccfbf1; /* teal-100 */
|
|
310
|
+
--hue-orange: #c2410c; /* orange-700 */
|
|
311
|
+
--hue-orange-border: #fdba74; /* orange-300 */
|
|
312
|
+
--hue-orange-bg: #ffedd5; /* orange-100 */
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
.dark {
|
|
316
|
+
--hue-blue: #93c5fd; /* blue-300 */
|
|
317
|
+
--hue-blue-border: rgba(29, 78, 216, 0.6);
|
|
318
|
+
--hue-blue-bg: rgba(30, 58, 138, 0.25);
|
|
319
|
+
--hue-indigo: #a5b4fc; /* indigo-300 */
|
|
320
|
+
--hue-indigo-border: rgba(67, 56, 202, 0.6);
|
|
321
|
+
--hue-indigo-bg: rgba(49, 46, 129, 0.25);
|
|
322
|
+
--hue-purple: #d8b4fe; /* purple-300 */
|
|
323
|
+
--hue-purple-border: rgba(126, 34, 206, 0.6);
|
|
324
|
+
--hue-purple-bg: rgba(88, 28, 135, 0.25);
|
|
325
|
+
--hue-teal: #5eead4; /* teal-300 */
|
|
326
|
+
--hue-teal-border: rgba(15, 118, 110, 0.6);
|
|
327
|
+
--hue-teal-bg: rgba(19, 78, 74, 0.25);
|
|
328
|
+
--hue-orange: #fdba74; /* orange-300 */
|
|
329
|
+
--hue-orange-border: rgba(194, 65, 12, 0.6);
|
|
330
|
+
--hue-orange-bg: rgba(124, 45, 18, 0.25);
|
|
331
|
+
}
|
|
332
|
+
|
|
236
333
|
/* Semantic colour utilities backed by the tokens above. Unlayered so they win
|
|
237
334
|
over Tailwind's layered colour utilities, and theme-aware for free (the var
|
|
238
335
|
flips under .dark) — no dark: variant needed at the call site. */
|