@charcuterie/ui 4.25.0 → 4.26.1
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 +13 -0
- package/dist/CommandPalette/CommandPalette.d.ts +43 -0
- package/dist/CommandPalette/CommandPalette.d.ts.map +1 -0
- package/dist/CommandPalette/CommandPalette.js +53 -0
- package/dist/CommandPalette/CommandPalette.js.map +1 -0
- package/dist/CommandPalette/CommandPaletteFooter.d.ts +16 -0
- package/dist/CommandPalette/CommandPaletteFooter.d.ts.map +1 -0
- package/dist/CommandPalette/CommandPaletteFooter.js +14 -0
- package/dist/CommandPalette/CommandPaletteFooter.js.map +1 -0
- package/dist/CommandPalette/CommandPaletteOption.d.ts +44 -0
- package/dist/CommandPalette/CommandPaletteOption.d.ts.map +1 -0
- package/dist/CommandPalette/CommandPaletteOption.js +72 -0
- package/dist/CommandPalette/CommandPaletteOption.js.map +1 -0
- package/dist/CommandPalette/CommandPalettePanel.d.ts +52 -0
- package/dist/CommandPalette/CommandPalettePanel.d.ts.map +1 -0
- package/dist/CommandPalette/CommandPalettePanel.js +168 -0
- package/dist/CommandPalette/CommandPalettePanel.js.map +1 -0
- package/dist/CommandPalette/commandPaletteSections.d.ts +93 -0
- package/dist/CommandPalette/commandPaletteSections.d.ts.map +1 -0
- package/dist/CommandPalette/commandPaletteSections.js +94 -0
- package/dist/CommandPalette/commandPaletteSections.js.map +1 -0
- package/dist/CommandPalette/useCommandPalette.d.ts +32 -0
- package/dist/CommandPalette/useCommandPalette.d.ts.map +1 -0
- package/dist/CommandPalette/useCommandPalette.js +40 -0
- package/dist/CommandPalette/useCommandPalette.js.map +1 -0
- package/dist/CommandPalette/useCommandPaletteHotkeys.d.ts +35 -0
- package/dist/CommandPalette/useCommandPaletteHotkeys.d.ts.map +1 -0
- package/dist/CommandPalette/useCommandPaletteHotkeys.js +114 -0
- package/dist/CommandPalette/useCommandPaletteHotkeys.js.map +1 -0
- package/dist/TableViewport/TableViewport.d.ts.map +1 -1
- package/dist/TableViewport/TableViewport.js +62 -10
- package/dist/TableViewport/TableViewport.js.map +1 -1
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/CommandPalette/CommandPalette.mdx +129 -0
- package/src/CommandPalette/CommandPalette.stories.tsx +413 -0
- package/src/CommandPalette/CommandPalette.test.tsx +553 -0
- package/src/CommandPalette/CommandPalette.tsx +91 -0
- package/src/CommandPalette/CommandPaletteFooter.tsx +45 -0
- package/src/CommandPalette/CommandPaletteOption.tsx +200 -0
- package/src/CommandPalette/CommandPalettePanel.tsx +411 -0
- package/src/CommandPalette/commandPalette.storyHelpers.tsx +248 -0
- package/src/CommandPalette/commandPaletteSections.test.ts +156 -0
- package/src/CommandPalette/commandPaletteSections.ts +219 -0
- package/src/CommandPalette/useCommandPalette.ts +75 -0
- package/src/CommandPalette/useCommandPaletteHotkeys.ts +158 -0
- package/src/TableViewport/TableViewport.mdx +2 -0
- package/src/TableViewport/TableViewport.test.tsx +89 -1
- package/src/TableViewport/TableViewport.tsx +82 -9
- package/src/index.ts +14 -0
- package/src/sourceRules.test.ts +2 -1
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ImageIcon,
|
|
5
|
+
InboxIcon,
|
|
6
|
+
LinkIcon,
|
|
7
|
+
MonitorIcon,
|
|
8
|
+
PlayIcon,
|
|
9
|
+
SearchIcon,
|
|
10
|
+
SettingsIcon,
|
|
11
|
+
SunIcon,
|
|
12
|
+
} from "../icons.storyHelpers.tsx"
|
|
13
|
+
import type {
|
|
14
|
+
CommandPaletteGroup,
|
|
15
|
+
CommandPaletteItem,
|
|
16
|
+
CommandPalettePrefix,
|
|
17
|
+
} from "./commandPaletteSections.ts"
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A generic household launcher: apps, the members of two folders,
|
|
21
|
+
* and a few bookmarks. Every host is `example.com` — this repo is
|
|
22
|
+
* public, and the fixture stands in for a real start page. Each opens
|
|
23
|
+
* in a new tab, so following one from Storybook does not replace the
|
|
24
|
+
* story.
|
|
25
|
+
*/
|
|
26
|
+
export const LAUNCHER_ITEMS: readonly CommandPaletteItem[] =
|
|
27
|
+
[
|
|
28
|
+
{
|
|
29
|
+
description: "Home",
|
|
30
|
+
group: "Apps",
|
|
31
|
+
hint: "App",
|
|
32
|
+
href: "https://automation.example.com/",
|
|
33
|
+
icon: <SettingsIcon />,
|
|
34
|
+
key: "home-automation",
|
|
35
|
+
keywords: ["lights", "thermostat"],
|
|
36
|
+
target: "_blank",
|
|
37
|
+
title: "Home Automation",
|
|
38
|
+
weight: 40,
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
description: "Home Theater",
|
|
42
|
+
group: "Apps",
|
|
43
|
+
hint: "App",
|
|
44
|
+
href: "https://media.example.com/",
|
|
45
|
+
icon: <PlayIcon />,
|
|
46
|
+
key: "media-server",
|
|
47
|
+
keywords: ["movies", "shows"],
|
|
48
|
+
target: "_blank",
|
|
49
|
+
title: "Media Server",
|
|
50
|
+
weight: 25,
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
description: "Household",
|
|
54
|
+
group: "Apps",
|
|
55
|
+
hint: "App",
|
|
56
|
+
href: "https://photos.example.com/",
|
|
57
|
+
icon: <ImageIcon />,
|
|
58
|
+
key: "photo-library",
|
|
59
|
+
target: "_blank",
|
|
60
|
+
title: "Photo Library",
|
|
61
|
+
weight: 12,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
description: "Household",
|
|
65
|
+
group: "Apps",
|
|
66
|
+
hint: "App",
|
|
67
|
+
href: "https://tasks.example.com/",
|
|
68
|
+
icon: <InboxIcon />,
|
|
69
|
+
key: "task-board",
|
|
70
|
+
keywords: ["todo", "chores"],
|
|
71
|
+
target: "_blank",
|
|
72
|
+
title: "Task Board",
|
|
73
|
+
weight: 18,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
description: "Network & Admin",
|
|
77
|
+
group: "Apps",
|
|
78
|
+
hint: "App",
|
|
79
|
+
href: "https://proxy.example.com/",
|
|
80
|
+
icon: <SettingsIcon />,
|
|
81
|
+
key: "reverse-proxy-manager",
|
|
82
|
+
target: "_blank",
|
|
83
|
+
title: "Reverse Proxy Manager",
|
|
84
|
+
weight: 3,
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
description: "Network & Admin",
|
|
88
|
+
group: "Apps",
|
|
89
|
+
hint: "App",
|
|
90
|
+
href: "https://storage.example.com/",
|
|
91
|
+
icon: <MonitorIcon />,
|
|
92
|
+
key: "storage-server",
|
|
93
|
+
keywords: ["nas", "pools"],
|
|
94
|
+
target: "_blank",
|
|
95
|
+
title: "Storage Server",
|
|
96
|
+
weight: 9,
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
description: "Remotes",
|
|
100
|
+
group: "Apps",
|
|
101
|
+
hint: "App",
|
|
102
|
+
href: "https://remote-living.example.com/",
|
|
103
|
+
icon: <MonitorIcon />,
|
|
104
|
+
key: "living-room-remote",
|
|
105
|
+
target: "_blank",
|
|
106
|
+
title: "Living Room Remote",
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
description: "Remotes",
|
|
110
|
+
group: "Apps",
|
|
111
|
+
hint: "App",
|
|
112
|
+
href: "https://remote-bedroom.example.com/",
|
|
113
|
+
icon: <MonitorIcon />,
|
|
114
|
+
key: "bedroom-remote",
|
|
115
|
+
target: "_blank",
|
|
116
|
+
title: "Bedroom Remote",
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
description: "Speed Tests",
|
|
120
|
+
group: "Apps",
|
|
121
|
+
hint: "App",
|
|
122
|
+
href: "https://speed.example.com/",
|
|
123
|
+
icon: <MonitorIcon />,
|
|
124
|
+
key: "speed-test",
|
|
125
|
+
target: "_blank",
|
|
126
|
+
title: "Speed Test",
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
description: "Bookmarks",
|
|
130
|
+
group: "Bookmarks",
|
|
131
|
+
hint: "Bookmark",
|
|
132
|
+
href: "https://weather.example.com/",
|
|
133
|
+
icon: <SunIcon />,
|
|
134
|
+
key: "weather-forecast",
|
|
135
|
+
keywords: ["rain"],
|
|
136
|
+
target: "_blank",
|
|
137
|
+
title: "Weather Forecast",
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
description: "Bookmarks",
|
|
141
|
+
group: "Bookmarks",
|
|
142
|
+
hint: "Bookmark",
|
|
143
|
+
href: "https://parcels.example.com/",
|
|
144
|
+
icon: <LinkIcon />,
|
|
145
|
+
key: "package-tracking",
|
|
146
|
+
keywords: ["shipping", "parcels"],
|
|
147
|
+
target: "_blank",
|
|
148
|
+
title: "Package Tracking",
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
description: "Bookmarks",
|
|
152
|
+
group: "Bookmarks",
|
|
153
|
+
hint: "Bookmark",
|
|
154
|
+
href: "https://recipes.example.com/",
|
|
155
|
+
icon: <LinkIcon />,
|
|
156
|
+
key: "recipe-box",
|
|
157
|
+
keywords: ["cooking", "meals"],
|
|
158
|
+
target: "_blank",
|
|
159
|
+
title: "Recipe Box",
|
|
160
|
+
},
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
const byKey = (key: string): CommandPaletteItem => {
|
|
164
|
+
const item = LAUNCHER_ITEMS.find(
|
|
165
|
+
(candidate) => candidate.key === key,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
if (item === undefined) {
|
|
169
|
+
throw new Error(`No fixture item "${key}"`)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return item
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export const LAUNCHER_SUGGESTIONS: readonly CommandPaletteGroup[] =
|
|
176
|
+
[
|
|
177
|
+
{
|
|
178
|
+
items: [
|
|
179
|
+
byKey("home-automation"),
|
|
180
|
+
byKey("media-server"),
|
|
181
|
+
byKey("task-board"),
|
|
182
|
+
],
|
|
183
|
+
key: "pinned",
|
|
184
|
+
label: "Pinned",
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
items: [
|
|
188
|
+
{
|
|
189
|
+
...byKey("weather-forecast"),
|
|
190
|
+
hint: "2 hours ago",
|
|
191
|
+
},
|
|
192
|
+
{ ...byKey("storage-server"), hint: "Yesterday" },
|
|
193
|
+
{ ...byKey("photo-library"), hint: "3 days ago" },
|
|
194
|
+
],
|
|
195
|
+
key: "recent",
|
|
196
|
+
label: "Recently opened",
|
|
197
|
+
},
|
|
198
|
+
]
|
|
199
|
+
|
|
200
|
+
const searchHref =
|
|
201
|
+
(host: string) =>
|
|
202
|
+
(query: string): string =>
|
|
203
|
+
`https://${host}.example.com/search?q=${encodeURIComponent(query)}`
|
|
204
|
+
|
|
205
|
+
export const LAUNCHER_PREFIXES: readonly CommandPalettePrefix[] =
|
|
206
|
+
[
|
|
207
|
+
{
|
|
208
|
+
getHref: searchHref("auction"),
|
|
209
|
+
icon: <SearchIcon />,
|
|
210
|
+
label: "Auction Site",
|
|
211
|
+
prefix: "au",
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
getHref: searchHref("shop"),
|
|
215
|
+
icon: <SearchIcon />,
|
|
216
|
+
label: "Shopping Site",
|
|
217
|
+
prefix: "sh",
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
getHref: searchHref("video"),
|
|
221
|
+
icon: <SearchIcon />,
|
|
222
|
+
label: "Video Site",
|
|
223
|
+
prefix: "vi",
|
|
224
|
+
},
|
|
225
|
+
]
|
|
226
|
+
|
|
227
|
+
export const getWebSearchItem = (
|
|
228
|
+
query: string,
|
|
229
|
+
): CommandPaletteItem => ({
|
|
230
|
+
group: "The web",
|
|
231
|
+
hint: "Search",
|
|
232
|
+
href: searchHref("web")(query),
|
|
233
|
+
icon: <SearchIcon />,
|
|
234
|
+
key: "web-search",
|
|
235
|
+
target: "_blank",
|
|
236
|
+
title: `Search the web for “${query}”`,
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
/** The bordered box an inline panel sits in on a board, standing in for the dialog's own. */
|
|
240
|
+
export const PanelFrame = ({
|
|
241
|
+
children,
|
|
242
|
+
}: {
|
|
243
|
+
children: ReactNode
|
|
244
|
+
}): ReactNode => (
|
|
245
|
+
<div className="flex max-h-[28rem] w-full flex-col overflow-hidden rounded-lg border border-border-default bg-surface-overlay text-content-primary shadow-high">
|
|
246
|
+
{children}
|
|
247
|
+
</div>
|
|
248
|
+
)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { expect, test } from "vitest"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
buildCommandPaletteSections,
|
|
5
|
+
type CommandPaletteItem,
|
|
6
|
+
type CommandPaletteSectionsOptions,
|
|
7
|
+
} from "./commandPaletteSections.ts"
|
|
8
|
+
|
|
9
|
+
const ITEMS: readonly CommandPaletteItem[] = [
|
|
10
|
+
{ group: "Apps", key: "photos", title: "Photo Server" },
|
|
11
|
+
{
|
|
12
|
+
group: "Apps",
|
|
13
|
+
key: "media",
|
|
14
|
+
title: "Media Server",
|
|
15
|
+
weight: 5,
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
group: "Bookmarks",
|
|
19
|
+
key: "servers",
|
|
20
|
+
title: "Server Room Camera",
|
|
21
|
+
},
|
|
22
|
+
{ group: "Apps", key: "spare", title: "Spare Server" },
|
|
23
|
+
{ key: "loose", title: "Serving Sizes" },
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
const build = (
|
|
27
|
+
overrides: Partial<CommandPaletteSectionsOptions>,
|
|
28
|
+
) =>
|
|
29
|
+
buildCommandPaletteSections({
|
|
30
|
+
formatPrefixTitle: (label, query) =>
|
|
31
|
+
`${label}: ${query}`,
|
|
32
|
+
items: ITEMS,
|
|
33
|
+
prefixGroupLabel: "Search a site",
|
|
34
|
+
prefixes: [
|
|
35
|
+
{
|
|
36
|
+
getHref: (query) =>
|
|
37
|
+
`https://auction.example.com/?q=${query}`,
|
|
38
|
+
label: "Auction Site",
|
|
39
|
+
prefix: "au",
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
query: "",
|
|
43
|
+
suggestions: [],
|
|
44
|
+
ungroupedLabel: "Results",
|
|
45
|
+
...overrides,
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const summarize = (
|
|
49
|
+
sections: ReturnType<typeof buildCommandPaletteSections>,
|
|
50
|
+
) =>
|
|
51
|
+
sections.map(({ label, rows }) => [
|
|
52
|
+
label,
|
|
53
|
+
rows.map(({ item }) => item.title),
|
|
54
|
+
])
|
|
55
|
+
|
|
56
|
+
test("nothing typed lists the suggestions, skipping an empty group", () => {
|
|
57
|
+
const sections = build({
|
|
58
|
+
query: " ",
|
|
59
|
+
suggestions: [
|
|
60
|
+
{
|
|
61
|
+
items: [ITEMS[0] as CommandPaletteItem],
|
|
62
|
+
key: "pinned",
|
|
63
|
+
label: "Pinned",
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
items: [],
|
|
67
|
+
key: "recent",
|
|
68
|
+
label: "Recently opened",
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
expect(summarize(sections)).toStrictEqual([
|
|
74
|
+
["Pinned", ["Photo Server"]],
|
|
75
|
+
])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test("groups are ordered by their best member, so the first row is the best match", () => {
|
|
79
|
+
// `Server Room Camera` starts with the query, so its group leads even
|
|
80
|
+
// though `Apps` comes first in the list.
|
|
81
|
+
expect(summarize(build({ query: "ser" }))).toStrictEqual([
|
|
82
|
+
["Bookmarks", ["Server Room Camera"]],
|
|
83
|
+
["Results", ["Serving Sizes"]],
|
|
84
|
+
[
|
|
85
|
+
"Apps",
|
|
86
|
+
["Media Server", "Photo Server", "Spare Server"],
|
|
87
|
+
],
|
|
88
|
+
])
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test("maxResultsPerGroup caps each heading separately", () => {
|
|
92
|
+
expect(
|
|
93
|
+
summarize(
|
|
94
|
+
build({ maxResultsPerGroup: 1, query: "server" }),
|
|
95
|
+
),
|
|
96
|
+
).toStrictEqual([
|
|
97
|
+
["Bookmarks", ["Server Room Camera"]],
|
|
98
|
+
["Apps", ["Media Server"]],
|
|
99
|
+
])
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test("the fallback row comes last, under its own group, with the trimmed query", () => {
|
|
103
|
+
const sections = build({
|
|
104
|
+
getFallbackItem: (query) => ({
|
|
105
|
+
group: "The web",
|
|
106
|
+
key: "web",
|
|
107
|
+
title: `Search the web for ${query}`,
|
|
108
|
+
}),
|
|
109
|
+
query: " zebra ",
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
expect(summarize(sections)).toStrictEqual([
|
|
113
|
+
["The web", ["Search the web for zebra"]],
|
|
114
|
+
])
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test("a prefix command is the only row, and its link and onSelect get the rest of the query", () => {
|
|
118
|
+
const chosen: string[] = []
|
|
119
|
+
|
|
120
|
+
const sections = build({
|
|
121
|
+
getFallbackItem: () => ({
|
|
122
|
+
key: "web",
|
|
123
|
+
title: "never shown",
|
|
124
|
+
}),
|
|
125
|
+
prefixes: [
|
|
126
|
+
{
|
|
127
|
+
getHref: (query) =>
|
|
128
|
+
`https://auction.example.com/?q=${query}`,
|
|
129
|
+
label: "Auction Site",
|
|
130
|
+
onSelect: (query) => chosen.push(query),
|
|
131
|
+
prefix: "au",
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
query: "AU lego set",
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
expect(summarize(sections)).toStrictEqual([
|
|
138
|
+
["Search a site", ["Auction Site: lego set"]],
|
|
139
|
+
])
|
|
140
|
+
|
|
141
|
+
const item = sections[0]?.rows[0]?.item
|
|
142
|
+
|
|
143
|
+
expect(item?.href).toBe(
|
|
144
|
+
"https://auction.example.com/?q=lego set",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
item?.onSelect?.()
|
|
148
|
+
|
|
149
|
+
expect(chosen).toStrictEqual(["lego set"])
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test("a prefix with nothing after it is searched as text", () => {
|
|
153
|
+
expect(summarize(build({ query: "au " }))).toStrictEqual(
|
|
154
|
+
[],
|
|
155
|
+
)
|
|
156
|
+
})
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import {
|
|
2
|
+
parsePrefixCommand,
|
|
3
|
+
rankWordPrefixMatches,
|
|
4
|
+
type TextMatchRange,
|
|
5
|
+
} from "@charcuterie/logic"
|
|
6
|
+
import type { ReactNode } from "react"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One thing the palette can open or run.
|
|
10
|
+
*
|
|
11
|
+
* A folder's members are items like any other: the caller flattens
|
|
12
|
+
* them into `items`, and puts the folder's name in `description` so a
|
|
13
|
+
* hit inside a folder still says where it lives.
|
|
14
|
+
*/
|
|
15
|
+
export type CommandPaletteItem = {
|
|
16
|
+
/** Secondary text after the title — a folder, a section, a host. Matched, never highlighted. */
|
|
17
|
+
description?: string
|
|
18
|
+
/** Shown under this heading when the palette lists matches. Items without one share `ungroupedLabel`. */
|
|
19
|
+
group?: string
|
|
20
|
+
/** Opens as a link: a real `<a href>`, routed through `RouterLinkProvider`. */
|
|
21
|
+
href?: string
|
|
22
|
+
/** The app's own glyph or image. Decorative; the title is the name. */
|
|
23
|
+
icon?: ReactNode
|
|
24
|
+
/** Matched as extra words and never shown: an old name, an abbreviation, a product the app hosts. */
|
|
25
|
+
keywords?: readonly string[]
|
|
26
|
+
/** Unique within the list it is in. */
|
|
27
|
+
key: string
|
|
28
|
+
/** Trailing text at the end of the row: a kind ("Bookmark") or a status. */
|
|
29
|
+
hint?: ReactNode
|
|
30
|
+
/** Runs on choice. With an `href` too, it runs as well as the navigation — a place to record a visit. */
|
|
31
|
+
onSelect?: () => void
|
|
32
|
+
/** Passed to the link. `_blank` opens a new tab on a plain Enter or click. */
|
|
33
|
+
target?: string
|
|
34
|
+
/** What is typed for and highlighted. */
|
|
35
|
+
title: string
|
|
36
|
+
/** Breaks a tie inside a match tier, higher first: an open count, say. */
|
|
37
|
+
weight?: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A heading and its items, shown before anything is typed. */
|
|
41
|
+
export type CommandPaletteGroup = {
|
|
42
|
+
items: readonly CommandPaletteItem[]
|
|
43
|
+
key: string
|
|
44
|
+
label: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A short prefix that sends the rest of the query to one target:
|
|
49
|
+
* `{ prefix: "au", label: "Auction Site", getHref: (query) => … }`
|
|
50
|
+
* turns `au lego` into one row that searches that site.
|
|
51
|
+
*/
|
|
52
|
+
export type CommandPalettePrefix = {
|
|
53
|
+
/** The link the row opens, given what follows the prefix. */
|
|
54
|
+
getHref?: (query: string) => string
|
|
55
|
+
icon?: ReactNode
|
|
56
|
+
/** The target's name, in the row's title and in the footer. */
|
|
57
|
+
label: string
|
|
58
|
+
/** Runs instead of, or as well as, the link. */
|
|
59
|
+
onSelect?: (query: string) => void
|
|
60
|
+
/** What is typed before the space: `au`. Matched without case. */
|
|
61
|
+
prefix: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type CommandPaletteRow = {
|
|
65
|
+
item: CommandPaletteItem
|
|
66
|
+
/** Where the query matched the title; empty for a suggestion. */
|
|
67
|
+
titleRanges: readonly TextMatchRange[]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type CommandPaletteSection = {
|
|
71
|
+
key: string
|
|
72
|
+
label: string
|
|
73
|
+
rows: readonly CommandPaletteRow[]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type CommandPaletteSectionsOptions = {
|
|
77
|
+
formatPrefixTitle: (
|
|
78
|
+
label: string,
|
|
79
|
+
query: string,
|
|
80
|
+
) => string
|
|
81
|
+
getFallbackItem?: (
|
|
82
|
+
query: string,
|
|
83
|
+
) => CommandPaletteItem | null | undefined
|
|
84
|
+
items: readonly CommandPaletteItem[]
|
|
85
|
+
maxResultsPerGroup?: number
|
|
86
|
+
prefixGroupLabel: string
|
|
87
|
+
prefixes: readonly CommandPalettePrefix[]
|
|
88
|
+
query: string
|
|
89
|
+
suggestions: readonly CommandPaletteGroup[]
|
|
90
|
+
ungroupedLabel: string
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* What the palette lists for a query, as headed sections in order.
|
|
95
|
+
*
|
|
96
|
+
* Three cases, decided in this order:
|
|
97
|
+
*
|
|
98
|
+
* 1. **A prefix command** (`au lego`) lists one row, for that target.
|
|
99
|
+
* 2. **Nothing typed** lists the caller's `suggestions` — the shape
|
|
100
|
+
* the palette exists for: something to choose the moment it opens.
|
|
101
|
+
* 3. **Anything else** lists the word-prefix matches from `items`,
|
|
102
|
+
* under each item's `group`, and then the caller's fallback row.
|
|
103
|
+
*
|
|
104
|
+
* Matched groups are ordered by their best member, so the first row
|
|
105
|
+
* — the one Enter opens — is always the best match, whichever group
|
|
106
|
+
* it belongs to.
|
|
107
|
+
*/
|
|
108
|
+
export const buildCommandPaletteSections = ({
|
|
109
|
+
formatPrefixTitle,
|
|
110
|
+
getFallbackItem,
|
|
111
|
+
items,
|
|
112
|
+
maxResultsPerGroup,
|
|
113
|
+
prefixGroupLabel,
|
|
114
|
+
prefixes,
|
|
115
|
+
query,
|
|
116
|
+
suggestions,
|
|
117
|
+
ungroupedLabel,
|
|
118
|
+
}: CommandPaletteSectionsOptions): CommandPaletteSection[] => {
|
|
119
|
+
const command = parsePrefixCommand(query, prefixes)
|
|
120
|
+
|
|
121
|
+
if (command !== null) {
|
|
122
|
+
const { query: commandQuery, target } = command
|
|
123
|
+
|
|
124
|
+
return [
|
|
125
|
+
{
|
|
126
|
+
key: "prefix",
|
|
127
|
+
label: prefixGroupLabel,
|
|
128
|
+
rows: [
|
|
129
|
+
{
|
|
130
|
+
item: {
|
|
131
|
+
href: target.getHref?.(commandQuery),
|
|
132
|
+
icon: target.icon,
|
|
133
|
+
key: `prefix-${target.prefix}`,
|
|
134
|
+
onSelect:
|
|
135
|
+
target.onSelect === undefined
|
|
136
|
+
? undefined
|
|
137
|
+
: () => target.onSelect?.(commandQuery),
|
|
138
|
+
title: formatPrefixTitle(
|
|
139
|
+
target.label,
|
|
140
|
+
commandQuery,
|
|
141
|
+
),
|
|
142
|
+
},
|
|
143
|
+
titleRanges: [],
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
]
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const trimmedQuery = query.trim()
|
|
151
|
+
|
|
152
|
+
if (trimmedQuery.length === 0) {
|
|
153
|
+
return suggestions
|
|
154
|
+
.filter((group) => group.items.length > 0)
|
|
155
|
+
.map((group) => ({
|
|
156
|
+
key: `suggestion-${group.key}`,
|
|
157
|
+
label: group.label,
|
|
158
|
+
rows: group.items.map((item) => ({
|
|
159
|
+
item,
|
|
160
|
+
titleRanges: [],
|
|
161
|
+
})),
|
|
162
|
+
}))
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const ranked = rankWordPrefixMatches(
|
|
166
|
+
items,
|
|
167
|
+
trimmedQuery,
|
|
168
|
+
(item) => ({
|
|
169
|
+
extra: [
|
|
170
|
+
...(item.description === undefined
|
|
171
|
+
? []
|
|
172
|
+
: [item.description]),
|
|
173
|
+
...(item.keywords ?? []),
|
|
174
|
+
],
|
|
175
|
+
title: item.title,
|
|
176
|
+
weight: item.weight,
|
|
177
|
+
}),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
const sectionsByLabel = new Map<
|
|
181
|
+
string,
|
|
182
|
+
CommandPaletteRow[]
|
|
183
|
+
>()
|
|
184
|
+
|
|
185
|
+
for (const { item, match } of ranked) {
|
|
186
|
+
const label = item.group ?? ungroupedLabel
|
|
187
|
+
const rows = sectionsByLabel.get(label) ?? []
|
|
188
|
+
|
|
189
|
+
if (
|
|
190
|
+
maxResultsPerGroup === undefined ||
|
|
191
|
+
rows.length < maxResultsPerGroup
|
|
192
|
+
) {
|
|
193
|
+
rows.push({ item, titleRanges: match.titleRanges })
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
sectionsByLabel.set(label, rows)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const sections: CommandPaletteSection[] = [
|
|
200
|
+
...sectionsByLabel,
|
|
201
|
+
].map(([label, rows]) => ({
|
|
202
|
+
key: `group-${label}`,
|
|
203
|
+
label,
|
|
204
|
+
rows,
|
|
205
|
+
}))
|
|
206
|
+
|
|
207
|
+
const fallbackItem = getFallbackItem?.(trimmedQuery)
|
|
208
|
+
|
|
209
|
+
return fallbackItem == null
|
|
210
|
+
? sections
|
|
211
|
+
: [
|
|
212
|
+
...sections,
|
|
213
|
+
{
|
|
214
|
+
key: "fallback",
|
|
215
|
+
label: fallbackItem.group ?? ungroupedLabel,
|
|
216
|
+
rows: [{ item: fallbackItem, titleRanges: [] }],
|
|
217
|
+
},
|
|
218
|
+
]
|
|
219
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { useVisibility } from "@charcuterie/logic"
|
|
2
|
+
import { useCallback, useState } from "react"
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
type CommandPaletteHotkeysOptions,
|
|
6
|
+
useCommandPaletteHotkeys,
|
|
7
|
+
} from "./useCommandPaletteHotkeys.ts"
|
|
8
|
+
|
|
9
|
+
export type UseCommandPaletteOptions = Pick<
|
|
10
|
+
CommandPaletteHotkeysOptions,
|
|
11
|
+
"isOpenedByTyping"
|
|
12
|
+
> & {
|
|
13
|
+
/** Register the global keys (`useCommandPaletteHotkeys`). Default `true`. */
|
|
14
|
+
hasHotkeys?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type UseCommandPaletteResult = {
|
|
18
|
+
close: () => void
|
|
19
|
+
isVisible: boolean
|
|
20
|
+
/** Open it, optionally with text already in the field. */
|
|
21
|
+
open: (initialQuery?: string) => void
|
|
22
|
+
/** Spread onto `CommandPalette`. */
|
|
23
|
+
paletteProps: {
|
|
24
|
+
defaultQuery: string
|
|
25
|
+
isVisible: boolean
|
|
26
|
+
onClose: () => void
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The open state for one `CommandPalette`, and its global keys.
|
|
32
|
+
*
|
|
33
|
+
* ```tsx
|
|
34
|
+
* const palette = useCommandPalette()
|
|
35
|
+
*
|
|
36
|
+
* <Button onClick={() => palette.open()}>Search</Button>
|
|
37
|
+
* <CommandPalette {...palette.paletteProps} items={items} />
|
|
38
|
+
* ```
|
|
39
|
+
*
|
|
40
|
+
* The keys are off while it is open, so a second `Ctrl K` does not
|
|
41
|
+
* reset what has been typed.
|
|
42
|
+
*/
|
|
43
|
+
export const useCommandPalette = ({
|
|
44
|
+
hasHotkeys = true,
|
|
45
|
+
isOpenedByTyping,
|
|
46
|
+
}: UseCommandPaletteOptions = {}): UseCommandPaletteResult => {
|
|
47
|
+
const { hide, isVisible, show } = useVisibility()
|
|
48
|
+
|
|
49
|
+
const [initialQuery, setInitialQuery] = useState("")
|
|
50
|
+
|
|
51
|
+
const open = useCallback(
|
|
52
|
+
(nextInitialQuery = "") => {
|
|
53
|
+
setInitialQuery(nextInitialQuery)
|
|
54
|
+
show()
|
|
55
|
+
},
|
|
56
|
+
[show],
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
useCommandPaletteHotkeys({
|
|
60
|
+
isEnabled: hasHotkeys && !isVisible,
|
|
61
|
+
isOpenedByTyping,
|
|
62
|
+
onOpen: open,
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
close: hide,
|
|
67
|
+
isVisible,
|
|
68
|
+
open,
|
|
69
|
+
paletteProps: {
|
|
70
|
+
defaultQuery: initialQuery,
|
|
71
|
+
isVisible,
|
|
72
|
+
onClose: hide,
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
}
|