@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.
Files changed (54) hide show
  1. package/README.md +13 -0
  2. package/dist/CommandPalette/CommandPalette.d.ts +43 -0
  3. package/dist/CommandPalette/CommandPalette.d.ts.map +1 -0
  4. package/dist/CommandPalette/CommandPalette.js +53 -0
  5. package/dist/CommandPalette/CommandPalette.js.map +1 -0
  6. package/dist/CommandPalette/CommandPaletteFooter.d.ts +16 -0
  7. package/dist/CommandPalette/CommandPaletteFooter.d.ts.map +1 -0
  8. package/dist/CommandPalette/CommandPaletteFooter.js +14 -0
  9. package/dist/CommandPalette/CommandPaletteFooter.js.map +1 -0
  10. package/dist/CommandPalette/CommandPaletteOption.d.ts +44 -0
  11. package/dist/CommandPalette/CommandPaletteOption.d.ts.map +1 -0
  12. package/dist/CommandPalette/CommandPaletteOption.js +72 -0
  13. package/dist/CommandPalette/CommandPaletteOption.js.map +1 -0
  14. package/dist/CommandPalette/CommandPalettePanel.d.ts +52 -0
  15. package/dist/CommandPalette/CommandPalettePanel.d.ts.map +1 -0
  16. package/dist/CommandPalette/CommandPalettePanel.js +168 -0
  17. package/dist/CommandPalette/CommandPalettePanel.js.map +1 -0
  18. package/dist/CommandPalette/commandPaletteSections.d.ts +93 -0
  19. package/dist/CommandPalette/commandPaletteSections.d.ts.map +1 -0
  20. package/dist/CommandPalette/commandPaletteSections.js +94 -0
  21. package/dist/CommandPalette/commandPaletteSections.js.map +1 -0
  22. package/dist/CommandPalette/useCommandPalette.d.ts +32 -0
  23. package/dist/CommandPalette/useCommandPalette.d.ts.map +1 -0
  24. package/dist/CommandPalette/useCommandPalette.js +40 -0
  25. package/dist/CommandPalette/useCommandPalette.js.map +1 -0
  26. package/dist/CommandPalette/useCommandPaletteHotkeys.d.ts +35 -0
  27. package/dist/CommandPalette/useCommandPaletteHotkeys.d.ts.map +1 -0
  28. package/dist/CommandPalette/useCommandPaletteHotkeys.js +114 -0
  29. package/dist/CommandPalette/useCommandPaletteHotkeys.js.map +1 -0
  30. package/dist/TableViewport/TableViewport.d.ts.map +1 -1
  31. package/dist/TableViewport/TableViewport.js +62 -10
  32. package/dist/TableViewport/TableViewport.js.map +1 -1
  33. package/dist/index.d.ts +7 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +3 -0
  36. package/dist/index.js.map +1 -1
  37. package/package.json +2 -2
  38. package/src/CommandPalette/CommandPalette.mdx +129 -0
  39. package/src/CommandPalette/CommandPalette.stories.tsx +413 -0
  40. package/src/CommandPalette/CommandPalette.test.tsx +553 -0
  41. package/src/CommandPalette/CommandPalette.tsx +91 -0
  42. package/src/CommandPalette/CommandPaletteFooter.tsx +45 -0
  43. package/src/CommandPalette/CommandPaletteOption.tsx +200 -0
  44. package/src/CommandPalette/CommandPalettePanel.tsx +411 -0
  45. package/src/CommandPalette/commandPalette.storyHelpers.tsx +248 -0
  46. package/src/CommandPalette/commandPaletteSections.test.ts +156 -0
  47. package/src/CommandPalette/commandPaletteSections.ts +219 -0
  48. package/src/CommandPalette/useCommandPalette.ts +75 -0
  49. package/src/CommandPalette/useCommandPaletteHotkeys.ts +158 -0
  50. package/src/TableViewport/TableViewport.mdx +2 -0
  51. package/src/TableViewport/TableViewport.test.tsx +89 -1
  52. package/src/TableViewport/TableViewport.tsx +82 -9
  53. package/src/index.ts +14 -0
  54. 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
+ }