@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,200 @@
|
|
|
1
|
+
import type { TextMatchRange } from "@charcuterie/logic"
|
|
2
|
+
import type { ControlSize } from "@charcuterie/tokens"
|
|
3
|
+
import type {
|
|
4
|
+
MouseEvent as ReactMouseEvent,
|
|
5
|
+
ReactNode,
|
|
6
|
+
} from "react"
|
|
7
|
+
|
|
8
|
+
import { PANEL_ITEM_SIZE_CLASS } from "../controlStyles.ts"
|
|
9
|
+
import { UnstyledLink } from "../RouterLink/UnstyledLink.tsx"
|
|
10
|
+
import { toClassName } from "../toClassName.ts"
|
|
11
|
+
import type { CommandPaletteItem } from "./commandPaletteSections.ts"
|
|
12
|
+
|
|
13
|
+
export type CommandPaletteOptionProps = {
|
|
14
|
+
/** The DOM id `aria-activedescendant` points at. */
|
|
15
|
+
id: string
|
|
16
|
+
isActive: boolean
|
|
17
|
+
item: CommandPaletteItem
|
|
18
|
+
itemSize: ControlSize
|
|
19
|
+
/** A click on the row: the link has navigated (or will), so close and run `onSelect`. */
|
|
20
|
+
onChoose: () => void
|
|
21
|
+
/** The pointer moved over the row. */
|
|
22
|
+
onPoint: () => void
|
|
23
|
+
titleRanges: readonly TextMatchRange[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const ICON_SIZE_CLASS: Record<ControlSize, string> = {
|
|
27
|
+
lg: "size-7 [&>img]:size-7 [&>svg]:size-5",
|
|
28
|
+
md: "size-6 [&>img]:size-6 [&>svg]:size-4",
|
|
29
|
+
sm: "size-5 [&>img]:size-5 [&>svg]:size-4",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The title with each matched range wrapped in a `<mark>`. Plain
|
|
34
|
+
* text in, so nothing the caller typed is ever read as markup.
|
|
35
|
+
*/
|
|
36
|
+
const HighlightedTitle = ({
|
|
37
|
+
ranges,
|
|
38
|
+
title,
|
|
39
|
+
}: {
|
|
40
|
+
ranges: readonly TextMatchRange[]
|
|
41
|
+
title: string
|
|
42
|
+
}): ReactNode => {
|
|
43
|
+
if (ranges.length === 0) {
|
|
44
|
+
return title
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const pieces: ReactNode[] = []
|
|
48
|
+
let cursor = 0
|
|
49
|
+
|
|
50
|
+
for (const { end, start } of ranges) {
|
|
51
|
+
if (start > cursor) {
|
|
52
|
+
pieces.push(title.slice(cursor, start))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
pieces.push(
|
|
56
|
+
<mark
|
|
57
|
+
className="bg-transparent font-semibold text-intent-accent-content"
|
|
58
|
+
key={start}
|
|
59
|
+
>
|
|
60
|
+
{title.slice(start, end)}
|
|
61
|
+
</mark>,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
cursor = end
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (cursor < title.length) {
|
|
68
|
+
pieces.push(title.slice(cursor))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return pieces
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* One row of the palette's listbox.
|
|
76
|
+
*
|
|
77
|
+
* **Focus never comes here.** The input keeps it, and the row the
|
|
78
|
+
* arrows have reached is announced through the input's
|
|
79
|
+
* `aria-activedescendant` — the APG combobox pattern `Combobox` uses
|
|
80
|
+
* too. So the row is `tabIndex={-1}`, and a mouse press is prevented
|
|
81
|
+
* from moving focus (`onMouseDown`), which keeps the caret in the
|
|
82
|
+
* input while the click still lands.
|
|
83
|
+
*
|
|
84
|
+
* **An `href` item is a real `<a href>`.** It goes through
|
|
85
|
+
* `UnstyledLink`, so an app's injected router link handles a plain
|
|
86
|
+
* click, and middle-click, ctrl-click and "copy link address" all
|
|
87
|
+
* work, because it is a link. Enter in the input clicks this same
|
|
88
|
+
* element, so the keyboard and the mouse take one path. An item
|
|
89
|
+
* without one is a `<button>`, as in `Combobox`.
|
|
90
|
+
*
|
|
91
|
+
* **The pointer moves the active row on `pointermove`, not
|
|
92
|
+
* `pointerenter`.** Arrowing scrolls the list under a mouse that has
|
|
93
|
+
* not moved, and an enter event would then hand the active row to
|
|
94
|
+
* whatever slid under the pointer.
|
|
95
|
+
*
|
|
96
|
+
* **One highlight.** The active row is tinted and nothing else is —
|
|
97
|
+
* no separate `:hover` tint — because the pointer makes a row active,
|
|
98
|
+
* and two tinted rows would leave Enter's target ambiguous.
|
|
99
|
+
*/
|
|
100
|
+
export const CommandPaletteOption = ({
|
|
101
|
+
id,
|
|
102
|
+
isActive,
|
|
103
|
+
item,
|
|
104
|
+
itemSize,
|
|
105
|
+
onChoose,
|
|
106
|
+
onPoint,
|
|
107
|
+
titleRanges,
|
|
108
|
+
}: CommandPaletteOptionProps): ReactNode => {
|
|
109
|
+
const { description, hint, href, icon, target, title } =
|
|
110
|
+
item
|
|
111
|
+
|
|
112
|
+
const className = toClassName(
|
|
113
|
+
"flex w-full cursor-pointer items-center rounded-md text-start text-content-primary no-underline",
|
|
114
|
+
PANEL_ITEM_SIZE_CLASS[itemSize],
|
|
115
|
+
isActive && "bg-intent-neutral-surface-hover",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
const content = (
|
|
119
|
+
<>
|
|
120
|
+
{icon === undefined ? null : (
|
|
121
|
+
<span
|
|
122
|
+
aria-hidden="true"
|
|
123
|
+
className={toClassName(
|
|
124
|
+
"flex shrink-0 items-center justify-center overflow-hidden rounded-sm text-content-secondary [&>img]:object-contain",
|
|
125
|
+
ICON_SIZE_CLASS[itemSize],
|
|
126
|
+
)}
|
|
127
|
+
>
|
|
128
|
+
{icon}
|
|
129
|
+
</span>
|
|
130
|
+
)}
|
|
131
|
+
|
|
132
|
+
<span className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-2">
|
|
133
|
+
<span className="min-w-0 wrap-anywhere">
|
|
134
|
+
<HighlightedTitle
|
|
135
|
+
ranges={titleRanges}
|
|
136
|
+
title={title}
|
|
137
|
+
/>
|
|
138
|
+
</span>
|
|
139
|
+
|
|
140
|
+
{description === undefined ? null : (
|
|
141
|
+
<span className="min-w-0 text-content-muted text-sm wrap-anywhere">
|
|
142
|
+
{description}
|
|
143
|
+
</span>
|
|
144
|
+
)}
|
|
145
|
+
</span>
|
|
146
|
+
|
|
147
|
+
{hint === undefined ? null : (
|
|
148
|
+
<span className="shrink-0 text-content-muted text-sm">
|
|
149
|
+
{hint}
|
|
150
|
+
</span>
|
|
151
|
+
)}
|
|
152
|
+
</>
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
const keepFocusInInput = (
|
|
156
|
+
mouseEvent: ReactMouseEvent<HTMLElement>,
|
|
157
|
+
) => {
|
|
158
|
+
mouseEvent.preventDefault()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (href !== undefined) {
|
|
162
|
+
return (
|
|
163
|
+
<UnstyledLink
|
|
164
|
+
aria-selected={isActive}
|
|
165
|
+
className={className}
|
|
166
|
+
href={href}
|
|
167
|
+
id={id}
|
|
168
|
+
onClick={onChoose}
|
|
169
|
+
onMouseDown={keepFocusInInput}
|
|
170
|
+
onPointerMove={onPoint}
|
|
171
|
+
rel={
|
|
172
|
+
target === "_blank"
|
|
173
|
+
? "noopener noreferrer"
|
|
174
|
+
: undefined
|
|
175
|
+
}
|
|
176
|
+
role="option"
|
|
177
|
+
tabIndex={-1}
|
|
178
|
+
target={target}
|
|
179
|
+
>
|
|
180
|
+
{content}
|
|
181
|
+
</UnstyledLink>
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return (
|
|
186
|
+
<button
|
|
187
|
+
aria-selected={isActive}
|
|
188
|
+
className={className}
|
|
189
|
+
id={id}
|
|
190
|
+
onClick={onChoose}
|
|
191
|
+
onMouseDown={keepFocusInInput}
|
|
192
|
+
onPointerMove={onPoint}
|
|
193
|
+
role="option"
|
|
194
|
+
tabIndex={-1}
|
|
195
|
+
type="button"
|
|
196
|
+
>
|
|
197
|
+
{content}
|
|
198
|
+
</button>
|
|
199
|
+
)
|
|
200
|
+
}
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import { useUniqueId } from "@charcuterie/logic"
|
|
2
|
+
import type { ControlSize } from "@charcuterie/tokens"
|
|
3
|
+
import type {
|
|
4
|
+
KeyboardEvent as ReactKeyboardEvent,
|
|
5
|
+
ReactNode,
|
|
6
|
+
RefObject,
|
|
7
|
+
} from "react"
|
|
8
|
+
import { useEffect, useMemo, useRef, useState } from "react"
|
|
9
|
+
|
|
10
|
+
import { usePanelItemSize } from "../Overlay/usePanelItemSize.ts"
|
|
11
|
+
import { SearchInput } from "../SearchInput/SearchInput.tsx"
|
|
12
|
+
import { toClassName } from "../toClassName.ts"
|
|
13
|
+
import { CommandPaletteFooter } from "./CommandPaletteFooter.tsx"
|
|
14
|
+
import { CommandPaletteOption } from "./CommandPaletteOption.tsx"
|
|
15
|
+
import {
|
|
16
|
+
buildCommandPaletteSections,
|
|
17
|
+
type CommandPaletteGroup,
|
|
18
|
+
type CommandPaletteItem,
|
|
19
|
+
type CommandPalettePrefix,
|
|
20
|
+
type CommandPaletteRow,
|
|
21
|
+
} from "./commandPaletteSections.ts"
|
|
22
|
+
|
|
23
|
+
export type CommandPalettePanelProps = {
|
|
24
|
+
/** The glyph inside the clear button. Without one, there is no clear button — Escape closes. */
|
|
25
|
+
clearIcon?: ReactNode
|
|
26
|
+
/** The query the palette opens with, when the caller does not own `query`. Read once per open. */
|
|
27
|
+
defaultQuery?: string
|
|
28
|
+
/** Shown when a query matches nothing and there is no fallback row. Default "No matches". */
|
|
29
|
+
emptyLabel?: string
|
|
30
|
+
/**
|
|
31
|
+
* The strip under the list. Default: the keys, and each prefix
|
|
32
|
+
* with its target. `null` draws none.
|
|
33
|
+
*/
|
|
34
|
+
footer?: ReactNode
|
|
35
|
+
/** The title of a prefix command's row. Default `Search <label> for "<query>"`. */
|
|
36
|
+
formatPrefixTitle?: (
|
|
37
|
+
label: string,
|
|
38
|
+
query: string,
|
|
39
|
+
) => string
|
|
40
|
+
/** The last row for any query that is not a prefix command — the "search the web" row. */
|
|
41
|
+
getFallbackItem?: (
|
|
42
|
+
query: string,
|
|
43
|
+
) => CommandPaletteItem | null | undefined
|
|
44
|
+
/** Ref to the search field, for the dialog's initial focus. */
|
|
45
|
+
inputRef?: RefObject<HTMLInputElement | null>
|
|
46
|
+
/** How tall each row is — and the search field. Default `lg`, stepped down on a short window. */
|
|
47
|
+
itemSize?: ControlSize
|
|
48
|
+
/** Everything a query can match. Folder members included, flattened. */
|
|
49
|
+
items: readonly CommandPaletteItem[]
|
|
50
|
+
/** The accessible name of the search field and the list. Default "Search". */
|
|
51
|
+
label?: string
|
|
52
|
+
/** At most this many matches under one heading. Default: no cap. */
|
|
53
|
+
maxResultsPerGroup?: number
|
|
54
|
+
/** After a row is chosen. `CommandPalette` closes here. */
|
|
55
|
+
onChoose?: () => void
|
|
56
|
+
/** Every change to the query. With `query`, the caller owns it. */
|
|
57
|
+
onQueryChange?: (query: string) => void
|
|
58
|
+
placeholder?: string
|
|
59
|
+
/** The heading over a prefix command's row. Default "Search a site". */
|
|
60
|
+
prefixGroupLabel?: string
|
|
61
|
+
prefixes?: readonly CommandPalettePrefix[]
|
|
62
|
+
/** Controlled query. Uncontrolled (from `defaultQuery`) when absent. */
|
|
63
|
+
query?: string
|
|
64
|
+
/** What the palette lists before anything is typed — "Pinned", "Recently opened". */
|
|
65
|
+
suggestions?: readonly CommandPaletteGroup[]
|
|
66
|
+
/** The heading over matches whose item has no `group`. Default "Results". */
|
|
67
|
+
ungroupedLabel?: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const defaultFormatPrefixTitle = (
|
|
71
|
+
label: string,
|
|
72
|
+
query: string,
|
|
73
|
+
) => `Search ${label} for “${query}”`
|
|
74
|
+
|
|
75
|
+
const GROUP_HEADING_INSET_CLASS: Record<
|
|
76
|
+
ControlSize,
|
|
77
|
+
string
|
|
78
|
+
> = {
|
|
79
|
+
lg: "px-(--control-padding-inline-lg)",
|
|
80
|
+
md: "px-(--control-padding-inline-md)",
|
|
81
|
+
sm: "px-(--control-padding-inline-sm)",
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const SEARCH_SIZE: Record<ControlSize, "lg" | "md"> = {
|
|
85
|
+
lg: "lg",
|
|
86
|
+
md: "md",
|
|
87
|
+
sm: "md",
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
type IndexedRow = CommandPaletteRow & {
|
|
91
|
+
id: string
|
|
92
|
+
index: number
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The palette's contents — the search field, the grouped listbox and
|
|
97
|
+
* the footer — without the dialog around them. `CommandPalette` is
|
|
98
|
+
* this inside a `Modal`; the stories also lay it out inline, so every
|
|
99
|
+
* state can sit on one board.
|
|
100
|
+
*/
|
|
101
|
+
export const CommandPalettePanel = ({
|
|
102
|
+
clearIcon,
|
|
103
|
+
defaultQuery = "",
|
|
104
|
+
emptyLabel = "No matches",
|
|
105
|
+
footer,
|
|
106
|
+
formatPrefixTitle = defaultFormatPrefixTitle,
|
|
107
|
+
getFallbackItem,
|
|
108
|
+
inputRef: externalInputRef,
|
|
109
|
+
itemSize: requestedItemSize = "lg",
|
|
110
|
+
items,
|
|
111
|
+
label = "Search",
|
|
112
|
+
maxResultsPerGroup,
|
|
113
|
+
onChoose,
|
|
114
|
+
onQueryChange,
|
|
115
|
+
placeholder = "Search…",
|
|
116
|
+
prefixGroupLabel = "Search a site",
|
|
117
|
+
prefixes = [],
|
|
118
|
+
query,
|
|
119
|
+
suggestions = [],
|
|
120
|
+
ungroupedLabel = "Results",
|
|
121
|
+
}: CommandPalettePanelProps): ReactNode => {
|
|
122
|
+
const itemSize = usePanelItemSize(requestedItemSize)
|
|
123
|
+
|
|
124
|
+
const listId = useUniqueId()
|
|
125
|
+
|
|
126
|
+
const ownInputRef = useRef<HTMLInputElement>(null)
|
|
127
|
+
const inputRef = externalInputRef ?? ownInputRef
|
|
128
|
+
|
|
129
|
+
const [internalQuery, setInternalQuery] =
|
|
130
|
+
useState(defaultQuery)
|
|
131
|
+
|
|
132
|
+
const currentQuery = query ?? internalQuery
|
|
133
|
+
|
|
134
|
+
const [activeIndex, setActiveIndex] = useState(0)
|
|
135
|
+
|
|
136
|
+
const sections = useMemo(
|
|
137
|
+
() =>
|
|
138
|
+
buildCommandPaletteSections({
|
|
139
|
+
formatPrefixTitle,
|
|
140
|
+
getFallbackItem,
|
|
141
|
+
items,
|
|
142
|
+
maxResultsPerGroup,
|
|
143
|
+
prefixGroupLabel,
|
|
144
|
+
prefixes,
|
|
145
|
+
query: currentQuery,
|
|
146
|
+
suggestions,
|
|
147
|
+
ungroupedLabel,
|
|
148
|
+
}),
|
|
149
|
+
[
|
|
150
|
+
currentQuery,
|
|
151
|
+
formatPrefixTitle,
|
|
152
|
+
getFallbackItem,
|
|
153
|
+
items,
|
|
154
|
+
maxResultsPerGroup,
|
|
155
|
+
prefixGroupLabel,
|
|
156
|
+
prefixes,
|
|
157
|
+
suggestions,
|
|
158
|
+
ungroupedLabel,
|
|
159
|
+
],
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
const indexedSections = useMemo(() => {
|
|
163
|
+
let index = 0
|
|
164
|
+
|
|
165
|
+
return sections.map((section) => ({
|
|
166
|
+
...section,
|
|
167
|
+
headingId: `${listId}-${section.key}`,
|
|
168
|
+
rows: section.rows.map(
|
|
169
|
+
(row): IndexedRow => ({
|
|
170
|
+
...row,
|
|
171
|
+
id: `${listId}-${section.key}-${row.item.key}`,
|
|
172
|
+
index: index++,
|
|
173
|
+
}),
|
|
174
|
+
),
|
|
175
|
+
}))
|
|
176
|
+
}, [listId, sections])
|
|
177
|
+
|
|
178
|
+
const rows = indexedSections.flatMap(
|
|
179
|
+
(section) => section.rows,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
const resolvedActiveIndex =
|
|
183
|
+
rows.length === 0
|
|
184
|
+
? -1
|
|
185
|
+
: Math.min(activeIndex, rows.length - 1)
|
|
186
|
+
|
|
187
|
+
const activeRow = rows[resolvedActiveIndex]
|
|
188
|
+
|
|
189
|
+
const setQuery = (nextQuery: string) => {
|
|
190
|
+
if (query === undefined) {
|
|
191
|
+
setInternalQuery(nextQuery)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
setActiveIndex(0)
|
|
195
|
+
|
|
196
|
+
onQueryChange?.(nextQuery)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// The arrows can walk the active row out of the scrolled list; bring
|
|
200
|
+
// it back. `nearest` is a no-op for a row the pointer is already on.
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
if (activeRow === undefined) {
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
document
|
|
207
|
+
.getElementById(activeRow.id)
|
|
208
|
+
?.scrollIntoView?.({ block: "nearest" })
|
|
209
|
+
}, [activeRow])
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Opening with a typed character puts the caret AFTER it, so the
|
|
213
|
+
* next key extends the query instead of landing in front of it.
|
|
214
|
+
* Once per mount: a later focus (back from the clear button) keeps
|
|
215
|
+
* wherever the person left the caret.
|
|
216
|
+
*/
|
|
217
|
+
const hasPlacedCaret = useRef(false)
|
|
218
|
+
|
|
219
|
+
const choose = (row: IndexedRow) => {
|
|
220
|
+
onChoose?.()
|
|
221
|
+
row.item.onSelect?.()
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const activate = (row: IndexedRow, isNewTab: boolean) => {
|
|
225
|
+
const { href } = row.item
|
|
226
|
+
|
|
227
|
+
if (href === undefined) {
|
|
228
|
+
choose(row)
|
|
229
|
+
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (isNewTab) {
|
|
234
|
+
window.open(href, "_blank", "noopener,noreferrer")
|
|
235
|
+
choose(row)
|
|
236
|
+
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// The row is the `<a href>` itself, so clicking it takes the path
|
|
241
|
+
// a mouse would — the injected router link, `target`, all of it —
|
|
242
|
+
// and its `onClick` runs `choose`.
|
|
243
|
+
document.getElementById(row.id)?.click()
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const handleKeyDown = (
|
|
247
|
+
keyboardEvent: ReactKeyboardEvent<HTMLInputElement>,
|
|
248
|
+
) => {
|
|
249
|
+
const lastIndex = rows.length - 1
|
|
250
|
+
|
|
251
|
+
switch (keyboardEvent.key) {
|
|
252
|
+
case "ArrowDown": {
|
|
253
|
+
keyboardEvent.preventDefault()
|
|
254
|
+
setActiveIndex(
|
|
255
|
+
Math.min(resolvedActiveIndex + 1, lastIndex),
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
case "ArrowUp": {
|
|
261
|
+
keyboardEvent.preventDefault()
|
|
262
|
+
setActiveIndex(Math.max(resolvedActiveIndex - 1, 0))
|
|
263
|
+
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
case "Home": {
|
|
267
|
+
keyboardEvent.preventDefault()
|
|
268
|
+
setActiveIndex(0)
|
|
269
|
+
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
case "End": {
|
|
273
|
+
keyboardEvent.preventDefault()
|
|
274
|
+
setActiveIndex(Math.max(lastIndex, 0))
|
|
275
|
+
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
case "Enter": {
|
|
279
|
+
if (activeRow === undefined) {
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
keyboardEvent.preventDefault()
|
|
284
|
+
activate(
|
|
285
|
+
activeRow,
|
|
286
|
+
keyboardEvent.ctrlKey || keyboardEvent.metaKey,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
return
|
|
290
|
+
}
|
|
291
|
+
default:
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const hasRows = rows.length > 0
|
|
296
|
+
const isQueryEmpty = currentQuery.trim().length === 0
|
|
297
|
+
|
|
298
|
+
const footerContent =
|
|
299
|
+
footer === undefined ? (
|
|
300
|
+
<CommandPaletteFooter prefixes={prefixes} />
|
|
301
|
+
) : (
|
|
302
|
+
footer
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
return (
|
|
306
|
+
<div className="flex min-h-0 flex-1 flex-col">
|
|
307
|
+
<div className="shrink-0 border-border-subtle border-b p-2">
|
|
308
|
+
<SearchInput
|
|
309
|
+
aria-activedescendant={activeRow?.id}
|
|
310
|
+
aria-autocomplete="list"
|
|
311
|
+
aria-controls={hasRows ? listId : undefined}
|
|
312
|
+
aria-expanded={hasRows}
|
|
313
|
+
aria-label={label}
|
|
314
|
+
autoCapitalize="off"
|
|
315
|
+
autoComplete="off"
|
|
316
|
+
autoCorrect="off"
|
|
317
|
+
clearIcon={clearIcon ?? null}
|
|
318
|
+
enterKeyHint="go"
|
|
319
|
+
isClearVisible={
|
|
320
|
+
clearIcon !== undefined &&
|
|
321
|
+
currentQuery.length > 0
|
|
322
|
+
}
|
|
323
|
+
onChange={(changeEvent) =>
|
|
324
|
+
setQuery(changeEvent.currentTarget.value)
|
|
325
|
+
}
|
|
326
|
+
onClear={() => setQuery("")}
|
|
327
|
+
onFocus={(focusEvent) => {
|
|
328
|
+
if (hasPlacedCaret.current) {
|
|
329
|
+
return
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
hasPlacedCaret.current = true
|
|
333
|
+
|
|
334
|
+
const { length } =
|
|
335
|
+
focusEvent.currentTarget.value
|
|
336
|
+
|
|
337
|
+
focusEvent.currentTarget.setSelectionRange(
|
|
338
|
+
length,
|
|
339
|
+
length,
|
|
340
|
+
)
|
|
341
|
+
}}
|
|
342
|
+
onKeyDown={handleKeyDown}
|
|
343
|
+
placeholder={placeholder}
|
|
344
|
+
ref={inputRef}
|
|
345
|
+
role="combobox"
|
|
346
|
+
size={SEARCH_SIZE[itemSize]}
|
|
347
|
+
spellCheck={false}
|
|
348
|
+
value={currentQuery}
|
|
349
|
+
/>
|
|
350
|
+
</div>
|
|
351
|
+
|
|
352
|
+
{hasRows ? (
|
|
353
|
+
<div
|
|
354
|
+
aria-label={label}
|
|
355
|
+
className="charcuterie-scrollbar min-h-0 flex-1 overflow-y-auto overscroll-contain p-1.5"
|
|
356
|
+
id={listId}
|
|
357
|
+
role="listbox"
|
|
358
|
+
>
|
|
359
|
+
{indexedSections.map((section) => (
|
|
360
|
+
// biome-ignore lint/a11y/useSemanticElements: a listbox group is `role="group"` (ARIA APG); a `<fieldset>` is invalid content inside `role="listbox"`.
|
|
361
|
+
<div
|
|
362
|
+
aria-labelledby={section.headingId}
|
|
363
|
+
className="not-first:mt-1"
|
|
364
|
+
key={section.key}
|
|
365
|
+
role="group"
|
|
366
|
+
>
|
|
367
|
+
<div
|
|
368
|
+
className={toClassName(
|
|
369
|
+
GROUP_HEADING_INSET_CLASS[itemSize],
|
|
370
|
+
"pt-1.5 pb-0.5 font-medium text-content-secondary text-xs",
|
|
371
|
+
)}
|
|
372
|
+
id={section.headingId}
|
|
373
|
+
>
|
|
374
|
+
{section.label}
|
|
375
|
+
</div>
|
|
376
|
+
|
|
377
|
+
{section.rows.map((row) => (
|
|
378
|
+
<CommandPaletteOption
|
|
379
|
+
id={row.id}
|
|
380
|
+
isActive={
|
|
381
|
+
row.index === resolvedActiveIndex
|
|
382
|
+
}
|
|
383
|
+
item={row.item}
|
|
384
|
+
itemSize={itemSize}
|
|
385
|
+
key={row.id}
|
|
386
|
+
onChoose={() => choose(row)}
|
|
387
|
+
onPoint={() => {
|
|
388
|
+
if (row.index !== resolvedActiveIndex) {
|
|
389
|
+
setActiveIndex(row.index)
|
|
390
|
+
}
|
|
391
|
+
}}
|
|
392
|
+
titleRanges={row.titleRanges}
|
|
393
|
+
/>
|
|
394
|
+
))}
|
|
395
|
+
</div>
|
|
396
|
+
))}
|
|
397
|
+
</div>
|
|
398
|
+
) : isQueryEmpty ? null : (
|
|
399
|
+
<p className="m-0 px-4 py-6 text-center text-content-muted text-sm">
|
|
400
|
+
{emptyLabel}
|
|
401
|
+
</p>
|
|
402
|
+
)}
|
|
403
|
+
|
|
404
|
+
{footerContent === null ? null : (
|
|
405
|
+
<div className="flex shrink-0 flex-wrap items-center gap-x-4 gap-y-1 border-border-subtle border-t px-4 py-2 text-content-muted text-xs">
|
|
406
|
+
{footerContent}
|
|
407
|
+
</div>
|
|
408
|
+
)}
|
|
409
|
+
</div>
|
|
410
|
+
)
|
|
411
|
+
}
|