@spree/dashboard-ui 0.13.0 → 1.0.0-beta.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 +1 -1
- package/package.json +15 -9
- package/src/hooks/use-prefers-reduced-motion.ts +26 -0
- package/src/hooks/use-scrolled.ts +34 -14
- package/src/index.ts +39 -1
- package/src/lib/date-range-presets.ts +79 -0
- package/src/lib/same-rich-text.ts +145 -0
- package/src/spree/address-block.tsx +5 -4
- package/src/spree/address-book-row.tsx +96 -0
- package/src/spree/address-map.tsx +62 -0
- package/src/spree/back-button.tsx +14 -9
- package/src/spree/bulk-dialog.tsx +2 -2
- package/src/spree/bulk-price-table.tsx +145 -14
- package/src/spree/bulk-variants-table.tsx +434 -0
- package/src/spree/calculator-summary.tsx +5 -1
- package/src/spree/claim-resolve-dialog.tsx +219 -0
- package/src/spree/confirm-dialog.tsx +1 -1
- package/src/spree/copy-to-clipboard-button.tsx +1 -1
- package/src/spree/data-grid/cells.tsx +199 -25
- package/src/spree/data-grid/context.ts +6 -0
- package/src/spree/data-grid/data-grid.tsx +149 -7
- package/src/spree/data-grid/helpers.ts +6 -2
- package/src/spree/data-grid/index.ts +9 -1
- package/src/spree/data-grid/use-data-grid-keyboard.ts +4 -2
- package/src/spree/delivery-form-dialog.tsx +166 -0
- package/src/spree/drag-handle.tsx +2 -2
- package/src/spree/editable-note-card.tsx +103 -0
- package/src/spree/external-link.tsx +56 -0
- package/src/spree/file-type-icon.tsx +68 -0
- package/src/spree/form-actions.tsx +2 -3
- package/src/spree/form-section.tsx +46 -0
- package/src/spree/fulfill-items-form.tsx +282 -0
- package/src/spree/fulfillment-edit-dialog.tsx +211 -0
- package/src/spree/fulfillment-item-list.tsx +61 -0
- package/src/spree/fulfillment-panel.tsx +53 -0
- package/src/spree/icons.ts +167 -0
- package/src/spree/json-preview-drawer.tsx +4 -4
- package/src/spree/label-upload-dialog.tsx +195 -0
- package/src/spree/language-menu-items.tsx +37 -30
- package/src/spree/metadata/metadata-card.tsx +1 -1
- package/src/spree/order-cancel-dialog.tsx +149 -0
- package/src/spree/post-sale-create-dialogs.tsx +253 -0
- package/src/spree/post-sale-fields.tsx +133 -0
- package/src/spree/product-membership-list.tsx +453 -0
- package/src/spree/products/status-card.tsx +79 -0
- package/src/spree/quantity-tier-editor.tsx +162 -0
- package/src/spree/resource-combobox.tsx +12 -1
- package/src/spree/resource-name-cell.tsx +21 -4
- package/src/spree/return-dialogs.tsx +238 -0
- package/src/spree/route-error-boundary.tsx +1 -1
- package/src/spree/row-actions.tsx +2 -2
- package/src/spree/search-input.tsx +76 -0
- package/src/spree/secret-input.tsx +26 -40
- package/src/spree/tax-identifiers-card.tsx +418 -0
- package/src/spree/theme-toggle.tsx +36 -23
- package/src/spree/wizard.tsx +254 -0
- package/src/styles.css +750 -75
- package/src/ui/accordion.tsx +83 -0
- package/src/ui/alert.tsx +79 -0
- package/src/ui/attachment.tsx +1 -1
- package/src/ui/badge.tsx +198 -56
- package/src/ui/breadcrumb.tsx +1 -1
- package/src/ui/button.tsx +22 -10
- package/src/ui/calendar.tsx +1 -1
- package/src/ui/card.tsx +52 -6
- package/src/ui/checkbox.tsx +13 -5
- package/src/ui/combobox.tsx +125 -6
- package/src/ui/command.tsx +149 -23
- package/src/ui/context-menu.tsx +99 -0
- package/src/ui/data-table.tsx +448 -9
- package/src/ui/date-picker.tsx +18 -3
- package/src/ui/date-range-picker.tsx +47 -60
- package/src/ui/dialog.tsx +6 -6
- package/src/ui/dropdown-menu.tsx +18 -11
- package/src/ui/field.tsx +10 -2
- package/src/ui/input-group.tsx +1 -1
- package/src/ui/input.tsx +1 -1
- package/src/ui/map.tsx +2110 -0
- package/src/ui/media-preview.tsx +105 -0
- package/src/ui/pagination.tsx +19 -11
- package/src/ui/popover.tsx +4 -2
- package/src/ui/progress.tsx +5 -2
- package/src/ui/radio-group.tsx +4 -4
- package/src/ui/rich-text-editor.tsx +214 -88
- package/src/ui/scroll-area.tsx +65 -0
- package/src/ui/select.tsx +17 -10
- package/src/ui/separator.tsx +1 -1
- package/src/ui/sheet.tsx +49 -7
- package/src/ui/sidebar.tsx +138 -37
- package/src/ui/skeleton.tsx +3 -1
- package/src/ui/tabs.tsx +84 -0
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/thumbnail.tsx +80 -0
- package/src/ui/toast.tsx +112 -0
- package/src/ui/tooltip.tsx +3 -1
- package/src/ui/sonner.tsx +0 -47
package/src/ui/checkbox.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Checkbox as CheckboxPrimitive } from '@base-ui/react/checkbox'
|
|
2
|
-
import { CheckIcon, MinusIcon } from 'lucide-react'
|
|
3
2
|
import type * as React from 'react'
|
|
4
3
|
import { cn } from '../lib/utils'
|
|
4
|
+
import { CheckIcon, MinusIcon } from '../spree/icons'
|
|
5
5
|
|
|
6
6
|
function Checkbox({
|
|
7
7
|
className,
|
|
@@ -25,13 +25,21 @@ function Checkbox({
|
|
|
25
25
|
disabled={disabled}
|
|
26
26
|
name={name}
|
|
27
27
|
className={cn(
|
|
28
|
-
'peer relative inline-block size-4 shrink-0 rounded-[4px] border border-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
'peer relative inline-block size-4 shrink-0 cursor-pointer rounded-[4px] border border-border bg-card align-[-3px] outline-none transition-[color,background-color,border-color,box-shadow] duration-100 ease-out',
|
|
29
|
+
// The 16px box is the right *visual* size in a dense table, but on its
|
|
30
|
+
// own it is a 16px target — under the 24px WCAG 2.2 asks for, on the
|
|
31
|
+
// control every bulk action starts from. A pseudo-element extends the
|
|
32
|
+
// pressable area to 34px without taking layout space, so column widths
|
|
33
|
+
// and row height are unchanged. `z-1` keeps it above the row-click
|
|
34
|
+
// bridge, which would otherwise swallow presses in the padded margin
|
|
35
|
+
// and navigate instead of selecting.
|
|
36
|
+
"before:absolute before:inset-[-9px] before:z-1 before:content-['']",
|
|
37
|
+
'focus:border-blue-500 focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ring)_15%,transparent)]',
|
|
38
|
+
'disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground',
|
|
31
39
|
'aria-invalid:border-destructive aria-invalid:ring-destructive/20',
|
|
32
40
|
'data-[checked]:border-blue-500 data-[checked]:bg-blue-500 data-[checked]:text-white',
|
|
33
41
|
'data-[indeterminate]:border-blue-500 data-[indeterminate]:bg-blue-500 data-[indeterminate]:text-white',
|
|
34
|
-
'dark:
|
|
42
|
+
'dark:aria-invalid:ring-destructive/40 dark:data-[checked]:bg-blue-500',
|
|
35
43
|
className,
|
|
36
44
|
)}
|
|
37
45
|
{...props}
|
package/src/ui/combobox.tsx
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { Combobox as ComboboxPrimitive } from '@base-ui/react'
|
|
4
|
-
import
|
|
4
|
+
import i18n from 'i18next'
|
|
5
5
|
import * as React from 'react'
|
|
6
6
|
import { cn } from '../lib/utils'
|
|
7
|
+
import { CheckIcon, ChevronDownIcon, XIcon } from '../spree/icons'
|
|
7
8
|
import { Button } from './button'
|
|
8
9
|
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from './input-group'
|
|
9
10
|
|
|
@@ -26,6 +27,91 @@ function ComboboxTrigger({ className, children, ...props }: ComboboxPrimitive.Tr
|
|
|
26
27
|
)
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Button-shaped trigger for a searchable picker: the field renders the current
|
|
32
|
+
* selection (flag, formatted label) and the search box lives inside the popup,
|
|
33
|
+
* next to the results it filters.
|
|
34
|
+
*
|
|
35
|
+
* Preferred over the editable-input trigger (`<ComboboxInput>`) whenever the
|
|
36
|
+
* selection has a richer display than the raw query text. A text input has to
|
|
37
|
+
* show the typed query, so it erases the selected label the moment the user
|
|
38
|
+
* types, and the browser's address autofill treats a text field named after a
|
|
39
|
+
* country as part of an address form and covers the list with saved addresses.
|
|
40
|
+
* A button has nothing to autofill into, and the search box inside the popup
|
|
41
|
+
* sits outside the form's field flow where those heuristics don't reach.
|
|
42
|
+
*
|
|
43
|
+
* Deliberately matches `<SelectTrigger>` so a picker sitting beside a plain
|
|
44
|
+
* select is indistinguishable from it until opened.
|
|
45
|
+
*/
|
|
46
|
+
function ComboboxButtonTrigger({
|
|
47
|
+
className,
|
|
48
|
+
children,
|
|
49
|
+
size = 'default',
|
|
50
|
+
...props
|
|
51
|
+
}: ComboboxPrimitive.Trigger.Props & {
|
|
52
|
+
/** `sm` is the toolbar size — see `SelectTrigger`, which it matches exactly. */
|
|
53
|
+
size?: 'sm' | 'default'
|
|
54
|
+
}) {
|
|
55
|
+
return (
|
|
56
|
+
<ComboboxPrimitive.Trigger
|
|
57
|
+
data-slot="combobox-button-trigger"
|
|
58
|
+
data-size={size}
|
|
59
|
+
className={cn(
|
|
60
|
+
"group/combobox-trigger flex w-full min-h-8 cursor-pointer items-center justify-between gap-1.5 rounded-md border border-border bg-card py-1.5 pr-2 pl-2.5 text-base font-normal leading-normal text-foreground transition-[color,background-color,border-color,box-shadow] duration-100 ease-out outline-none select-none focus:border-blue-500 focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ring)_15%,transparent)] disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground aria-invalid:border-destructive data-[size=sm]:shadow-xs data-[size=sm]:h-8 data-[size=sm]:min-h-8 data-[size=sm]:gap-2 data-[size=sm]:px-2 data-[size=sm]:py-1 data-[size=sm]:text-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
61
|
+
className,
|
|
62
|
+
)}
|
|
63
|
+
{...props}
|
|
64
|
+
>
|
|
65
|
+
<span className="flex min-w-0 items-center gap-2 truncate">{children}</span>
|
|
66
|
+
<ChevronDownIcon className="pointer-events-none size-4 shrink-0 text-muted-foreground group-data-[size=sm]/combobox-trigger:size-3.5" />
|
|
67
|
+
</ComboboxPrimitive.Trigger>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Placeholder text for a `<ComboboxButtonTrigger>` with nothing selected.
|
|
73
|
+
* A span rather than the trigger's own `data-placeholder`, because the
|
|
74
|
+
* trigger renders arbitrary children (a flag beside a name) rather than a
|
|
75
|
+
* single value string.
|
|
76
|
+
*/
|
|
77
|
+
function ComboboxTriggerPlaceholder({ className, ...props }: React.ComponentProps<'span'>) {
|
|
78
|
+
return (
|
|
79
|
+
<span
|
|
80
|
+
data-slot="combobox-trigger-placeholder"
|
|
81
|
+
className={cn('truncate text-muted-foreground', className)}
|
|
82
|
+
{...props}
|
|
83
|
+
/>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Search box for a button-triggered picker, rendered at the top of the popup.
|
|
89
|
+
* Autofill and spell-check are turned off across the board: this filters an
|
|
90
|
+
* on-screen list, so a password manager or a saved-address panel covering it
|
|
91
|
+
* is pure interference.
|
|
92
|
+
*/
|
|
93
|
+
function ComboboxSearch({ className, ...props }: ComboboxPrimitive.Input.Props) {
|
|
94
|
+
return (
|
|
95
|
+
<div className="border-b border-border p-1">
|
|
96
|
+
<ComboboxInput
|
|
97
|
+
data-slot="combobox-search"
|
|
98
|
+
className={cn(
|
|
99
|
+
'min-h-8 border-0 shadow-none has-[[data-slot=input-group-control]:focus-visible]:border-transparent has-[[data-slot=input-group-control]:focus-visible]:shadow-none',
|
|
100
|
+
className,
|
|
101
|
+
)}
|
|
102
|
+
autoComplete="off"
|
|
103
|
+
autoCorrect="off"
|
|
104
|
+
autoCapitalize="off"
|
|
105
|
+
spellCheck={false}
|
|
106
|
+
data-1p-ignore
|
|
107
|
+
data-lpignore="true"
|
|
108
|
+
showTrigger={false}
|
|
109
|
+
{...props}
|
|
110
|
+
/>
|
|
111
|
+
</div>
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
29
115
|
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
|
|
30
116
|
return (
|
|
31
117
|
<ComboboxPrimitive.Clear
|
|
@@ -100,6 +186,12 @@ function ComboboxContent({
|
|
|
100
186
|
data-slot="combobox-content"
|
|
101
187
|
data-chips={!!anchor}
|
|
102
188
|
className={cn(
|
|
189
|
+
// Deliberately keeps `animate-in`/`animate-out` keyframes where the
|
|
190
|
+
// other floating surfaces use transitions. Converting this one to
|
|
191
|
+
// `data-[starting-style]` transitions broke the multi-select chips
|
|
192
|
+
// picker: the list re-renders as the user filters, and options
|
|
193
|
+
// arriving mid-transition sit at `opacity-0` long enough to be
|
|
194
|
+
// unclickable. The keyframes make the panel opaque immediately.
|
|
103
195
|
'group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
|
|
104
196
|
className,
|
|
105
197
|
)}
|
|
@@ -131,7 +223,7 @@ function ComboboxItem({ className, children, ...props }: ComboboxPrimitive.Item.
|
|
|
131
223
|
// Padding + font sized to match `<Input>` (`py-1.5 px-2.5 text-base
|
|
132
224
|
// leading-normal`) so the dropdown rows feel like a continuation of
|
|
133
225
|
// the trigger field. `pr-8` keeps room for the check indicator.
|
|
134
|
-
"relative flex w-full cursor-
|
|
226
|
+
"relative flex w-full cursor-pointer items-center gap-2 rounded-md py-1.5 pr-8 pl-2.5 text-base font-normal leading-normal outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
135
227
|
className,
|
|
136
228
|
)}
|
|
137
229
|
{...props}
|
|
@@ -197,7 +289,13 @@ function ComboboxChips({
|
|
|
197
289
|
<ComboboxPrimitive.Chips
|
|
198
290
|
data-slot="combobox-chips"
|
|
199
291
|
className={cn(
|
|
200
|
-
|
|
292
|
+
// Keeps `transition-all` where the sibling inputs narrowed theirs: this
|
|
293
|
+
// element is the popup's anchor, and adding a chip reflows it
|
|
294
|
+
// (`flex-wrap`, `has-data-[slot=combobox-chip]:px-1`). Animating that
|
|
295
|
+
// resize lets the positioner follow the anchor smoothly; making it
|
|
296
|
+
// instant jumps the open popup out from under the pointer, so the next
|
|
297
|
+
// option click lands on nothing.
|
|
298
|
+
'flex flex-wrap items-center gap-1.5 rounded-md border border-border bg-card text-foreground px-2.5 py-1.5 text-base transition-all duration-100 ease-out focus-within:border-blue-500 focus-within:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ring)_15%,transparent)] has-aria-invalid:border-destructive has-data-[slot=combobox-chip]:p-1.5',
|
|
201
299
|
className,
|
|
202
300
|
)}
|
|
203
301
|
{...props}
|
|
@@ -217,7 +315,15 @@ function ComboboxChip({
|
|
|
217
315
|
<ComboboxPrimitive.Chip
|
|
218
316
|
data-slot="combobox-chip"
|
|
219
317
|
className={cn(
|
|
220
|
-
|
|
318
|
+
// Height comes from the content rather than a fixed 21px: the old value
|
|
319
|
+
// was shorter than the remove button inside it, and left the label with
|
|
320
|
+
// no vertical breathing room at any screen size.
|
|
321
|
+
//
|
|
322
|
+
// Fill matches the highlighted row in the list below (`bg-accent`): a
|
|
323
|
+
// chip and a selected option are the same thing at two moments, so they
|
|
324
|
+
// read as the same thing. `bg-muted` sat within 1.03:1 of the field it
|
|
325
|
+
// sits on, which left the chips barely visible.
|
|
326
|
+
'flex min-h-7 w-fit max-w-full items-center justify-center gap-1 rounded-sm bg-accent py-1 pl-2.5 pr-2 text-sm font-base text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-1',
|
|
221
327
|
className,
|
|
222
328
|
)}
|
|
223
329
|
{...props}
|
|
@@ -225,9 +331,19 @@ function ComboboxChip({
|
|
|
225
331
|
<span className="min-w-0 truncate">{children}</span>
|
|
226
332
|
{showRemove && (
|
|
227
333
|
<ComboboxPrimitive.ChipRemove
|
|
228
|
-
render={<Button variant="ghost" size="icon-
|
|
229
|
-
className="opacity-50 hover:opacity-100"
|
|
334
|
+
render={<Button variant="ghost" size="icon-sm" />}
|
|
335
|
+
className="size-6 shrink-0 opacity-50 hover:opacity-100"
|
|
230
336
|
data-slot="combobox-chip-remove"
|
|
337
|
+
// Names the chip it clears ("Remove Wholesale") rather than a bare
|
|
338
|
+
// "Remove": several sit side by side, and a dialog's own submit
|
|
339
|
+
// button is often called Remove too. An `aria-label` rather than an
|
|
340
|
+
// `sr-only` span, so the chip's text isn't duplicated as a second
|
|
341
|
+
// text node beside the one already visible.
|
|
342
|
+
aria-label={
|
|
343
|
+
typeof children === 'string'
|
|
344
|
+
? i18n.t('admin.actions.remove_item', { label: children })
|
|
345
|
+
: i18n.t('admin.actions.remove')
|
|
346
|
+
}
|
|
231
347
|
>
|
|
232
348
|
<XIcon className="pointer-events-none" />
|
|
233
349
|
</ComboboxPrimitive.ChipRemove>
|
|
@@ -252,6 +368,7 @@ function useComboboxAnchor() {
|
|
|
252
368
|
|
|
253
369
|
export {
|
|
254
370
|
Combobox,
|
|
371
|
+
ComboboxButtonTrigger,
|
|
255
372
|
ComboboxChip,
|
|
256
373
|
ComboboxChips,
|
|
257
374
|
ComboboxChipsInput,
|
|
@@ -263,8 +380,10 @@ export {
|
|
|
263
380
|
ComboboxItem,
|
|
264
381
|
ComboboxLabel,
|
|
265
382
|
ComboboxList,
|
|
383
|
+
ComboboxSearch,
|
|
266
384
|
ComboboxSeparator,
|
|
267
385
|
ComboboxTrigger,
|
|
386
|
+
ComboboxTriggerPlaceholder,
|
|
268
387
|
ComboboxValue,
|
|
269
388
|
useComboboxAnchor,
|
|
270
389
|
}
|
package/src/ui/command.tsx
CHANGED
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
import { Command as CommandPrimitive } from 'cmdk'
|
|
2
2
|
import i18n from 'i18next'
|
|
3
|
-
import { CheckIcon, SearchIcon } from 'lucide-react'
|
|
4
3
|
import * as React from 'react'
|
|
5
4
|
import { cn } from '../lib/utils'
|
|
5
|
+
import {
|
|
6
|
+
CheckIcon,
|
|
7
|
+
CornerDownLeftIcon,
|
|
8
|
+
Loader2Icon,
|
|
9
|
+
MoveDownIcon,
|
|
10
|
+
MoveUpIcon,
|
|
11
|
+
SearchIcon,
|
|
12
|
+
} from '../spree/icons'
|
|
6
13
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './dialog'
|
|
7
|
-
import { InputGroup, InputGroupAddon } from './input-group'
|
|
8
14
|
|
|
9
15
|
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
|
|
10
16
|
return (
|
|
11
17
|
<CommandPrimitive
|
|
12
18
|
data-slot="command"
|
|
13
19
|
className={cn(
|
|
14
|
-
'flex size-full flex-col overflow-hidden rounded-
|
|
20
|
+
'flex size-full flex-col overflow-hidden rounded-2xl! bg-popover text-popover-foreground',
|
|
15
21
|
className,
|
|
16
22
|
)}
|
|
17
23
|
{...props}
|
|
@@ -39,7 +45,14 @@ function CommandDialog({
|
|
|
39
45
|
<DialogDescription>{description}</DialogDescription>
|
|
40
46
|
</DialogHeader>
|
|
41
47
|
<DialogContent
|
|
42
|
-
|
|
48
|
+
// `-translate-x-1/2` must be restated alongside `translate-y-0`: in
|
|
49
|
+
// Tailwind v4 both compile to the standalone `translate` property, so
|
|
50
|
+
// setting only the Y axis wipes the base dialog's horizontal centring
|
|
51
|
+
// and the panel drifts off both edges of a narrow screen.
|
|
52
|
+
className={cn(
|
|
53
|
+
'top-1/3 -translate-x-1/2 translate-y-0 overflow-hidden rounded-xl! p-0',
|
|
54
|
+
className,
|
|
55
|
+
)}
|
|
43
56
|
showCloseButton={showCloseButton}
|
|
44
57
|
>
|
|
45
58
|
{children}
|
|
@@ -48,35 +61,66 @@ function CommandDialog({
|
|
|
48
61
|
)
|
|
49
62
|
}
|
|
50
63
|
|
|
64
|
+
/**
|
|
65
|
+
* The palette's search field — the top edge of the panel, spanning its full
|
|
66
|
+
* width above a single hairline.
|
|
67
|
+
*
|
|
68
|
+
* While `loading` is set, a spinner sits at the end of the field rather than in
|
|
69
|
+
* the results list, so rows don't shift down when a search starts and back up
|
|
70
|
+
* when it lands. The state is announced to screen readers as well as drawn.
|
|
71
|
+
*/
|
|
51
72
|
function CommandInput({
|
|
52
73
|
className,
|
|
74
|
+
loading = false,
|
|
53
75
|
...props
|
|
54
|
-
}: React.ComponentProps<typeof CommandPrimitive.Input>
|
|
76
|
+
}: React.ComponentProps<typeof CommandPrimitive.Input> & {
|
|
77
|
+
/** Shows a spinner at the end of the field while results are in flight. */
|
|
78
|
+
loading?: boolean
|
|
79
|
+
}) {
|
|
55
80
|
return (
|
|
56
|
-
<div
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
81
|
+
<div
|
|
82
|
+
data-slot="command-input-wrapper"
|
|
83
|
+
className="flex h-14 shrink-0 items-center gap-3 border-b border-border px-5"
|
|
84
|
+
>
|
|
85
|
+
<SearchIcon className="size-5 shrink-0 text-muted-foreground" />
|
|
86
|
+
<CommandPrimitive.Input
|
|
87
|
+
data-slot="command-input"
|
|
88
|
+
aria-busy={loading || undefined}
|
|
89
|
+
className={cn(
|
|
90
|
+
'h-full w-full bg-transparent text-base outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
|
|
91
|
+
className,
|
|
92
|
+
)}
|
|
93
|
+
{...props}
|
|
94
|
+
/>
|
|
95
|
+
{loading && (
|
|
96
|
+
<Loader2Icon
|
|
97
|
+
data-slot="command-input-loading"
|
|
98
|
+
aria-hidden="true"
|
|
99
|
+
className="size-4 shrink-0 animate-spin text-muted-foreground"
|
|
65
100
|
/>
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
101
|
+
)}
|
|
102
|
+
{/* The spinner is decorative, so the progress a sighted user reads from it
|
|
103
|
+
is announced here instead. Polite: it must not cut off the row count
|
|
104
|
+
the listbox announces when results land. */}
|
|
105
|
+
<span role="status" aria-live="polite" className="sr-only">
|
|
106
|
+
{loading ? i18n.t('admin.common.searching') : ''}
|
|
107
|
+
</span>
|
|
70
108
|
</div>
|
|
71
109
|
)
|
|
72
110
|
}
|
|
73
111
|
|
|
112
|
+
/**
|
|
113
|
+
* The results area. Its height is fixed rather than content-driven, so the
|
|
114
|
+
* panel stays put as results arrive instead of resizing under the cursor on
|
|
115
|
+
* every keystroke. Pass `className="h-…"` to change it, or `h-auto max-h-…`
|
|
116
|
+
* for the shrink-to-fit behaviour a short static menu wants.
|
|
117
|
+
*/
|
|
74
118
|
function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
|
|
75
119
|
return (
|
|
76
120
|
<CommandPrimitive.List
|
|
77
121
|
data-slot="command-list"
|
|
78
122
|
className={cn(
|
|
79
|
-
'
|
|
123
|
+
'h-80 min-h-0 scroll-py-2 overflow-x-hidden overflow-y-auto p-2 outline-none',
|
|
80
124
|
className,
|
|
81
125
|
)}
|
|
82
126
|
{...props}
|
|
@@ -91,7 +135,7 @@ function CommandEmpty({
|
|
|
91
135
|
return (
|
|
92
136
|
<CommandPrimitive.Empty
|
|
93
137
|
data-slot="command-empty"
|
|
94
|
-
className={cn('py-
|
|
138
|
+
className={cn('py-10 text-center text-sm text-muted-foreground', className)}
|
|
95
139
|
{...props}
|
|
96
140
|
/>
|
|
97
141
|
)
|
|
@@ -105,7 +149,7 @@ function CommandGroup({
|
|
|
105
149
|
<CommandPrimitive.Group
|
|
106
150
|
data-slot="command-group"
|
|
107
151
|
className={cn(
|
|
108
|
-
'overflow-hidden
|
|
152
|
+
'overflow-hidden text-foreground not-last:pb-2 **:[[cmdk-group-heading]]:px-3 **:[[cmdk-group-heading]]:pt-2 **:[[cmdk-group-heading]]:pb-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground',
|
|
109
153
|
className,
|
|
110
154
|
)}
|
|
111
155
|
{...props}
|
|
@@ -120,7 +164,7 @@ function CommandSeparator({
|
|
|
120
164
|
return (
|
|
121
165
|
<CommandPrimitive.Separator
|
|
122
166
|
data-slot="command-separator"
|
|
123
|
-
className={cn('-mx-1 h-px bg-border', className)}
|
|
167
|
+
className={cn('-mx-2 my-1 h-px bg-border-subtle', className)}
|
|
124
168
|
{...props}
|
|
125
169
|
/>
|
|
126
170
|
)
|
|
@@ -135,7 +179,7 @@ function CommandItem({
|
|
|
135
179
|
<CommandPrimitive.Item
|
|
136
180
|
data-slot="command-item"
|
|
137
181
|
className={cn(
|
|
138
|
-
"group/command-item relative flex cursor-
|
|
182
|
+
"group/command-item relative flex cursor-pointer items-center gap-3 rounded-md px-3 py-2.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-accent-strong data-selected:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-selected:*:[svg]:text-foreground",
|
|
139
183
|
className,
|
|
140
184
|
)}
|
|
141
185
|
{...props}
|
|
@@ -159,13 +203,95 @@ function CommandShortcut({ className, ...props }: React.ComponentProps<'span'>)
|
|
|
159
203
|
)
|
|
160
204
|
}
|
|
161
205
|
|
|
206
|
+
/** A single key cap — used for the palette's footer hints and inline shortcuts. */
|
|
207
|
+
function CommandKey({ className, ...props }: React.ComponentProps<'kbd'>) {
|
|
208
|
+
return (
|
|
209
|
+
<kbd
|
|
210
|
+
data-slot="command-key"
|
|
211
|
+
className={cn(
|
|
212
|
+
'inline-flex h-5 min-w-6 items-center justify-center rounded-sm bg-card border border-border px-1 font-sans text-xs text-muted-foreground',
|
|
213
|
+
className,
|
|
214
|
+
)}
|
|
215
|
+
{...props}
|
|
216
|
+
/>
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** One key cap (or a pair) followed by the action it performs. */
|
|
221
|
+
function CommandHint({
|
|
222
|
+
keys,
|
|
223
|
+
label,
|
|
224
|
+
className,
|
|
225
|
+
...props
|
|
226
|
+
}: Omit<React.ComponentProps<'div'>, 'children'> & {
|
|
227
|
+
keys: React.ReactNode[]
|
|
228
|
+
label: React.ReactNode
|
|
229
|
+
}) {
|
|
230
|
+
return (
|
|
231
|
+
<div data-slot="command-hint" className={cn('flex items-center gap-1.5', className)} {...props}>
|
|
232
|
+
<span className="flex items-center gap-1">
|
|
233
|
+
{keys.map((key, index) => (
|
|
234
|
+
// Hints are static, ordered, and never reordered — index is a stable key.
|
|
235
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: static list
|
|
236
|
+
<CommandKey key={index}>{key}</CommandKey>
|
|
237
|
+
))}
|
|
238
|
+
</span>
|
|
239
|
+
<span className="text-xs text-muted-foreground">{label}</span>
|
|
240
|
+
</div>
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Bottom bar of the palette. Renders the default navigate/select/close hints
|
|
246
|
+
* when given no children, so callers only pass children to replace them.
|
|
247
|
+
*/
|
|
248
|
+
function CommandFooter({ className, children, ...props }: React.ComponentProps<'div'>) {
|
|
249
|
+
return (
|
|
250
|
+
<div
|
|
251
|
+
data-slot="command-footer"
|
|
252
|
+
className={cn(
|
|
253
|
+
// Hidden on touch: the footer documents arrow/enter/esc shortcuts,
|
|
254
|
+
// which a phone has no keys for, and it costs 45px of a short viewport.
|
|
255
|
+
'hidden shrink-0 items-center justify-between gap-4 border-t border-border-subtle bg-muted px-4 py-2.5 text-muted-foreground md:flex capitalize',
|
|
256
|
+
className,
|
|
257
|
+
)}
|
|
258
|
+
{...props}
|
|
259
|
+
>
|
|
260
|
+
{children ?? (
|
|
261
|
+
<>
|
|
262
|
+
<div className="flex items-center gap-4">
|
|
263
|
+
<CommandHint
|
|
264
|
+
keys={[
|
|
265
|
+
<MoveUpIcon key="up" className="size-3.5" />,
|
|
266
|
+
<MoveDownIcon key="down" className="size-3.5" />,
|
|
267
|
+
]}
|
|
268
|
+
label={i18n.t('admin.components.command_palette.hints.navigate')}
|
|
269
|
+
/>
|
|
270
|
+
<CommandHint
|
|
271
|
+
keys={[<CornerDownLeftIcon key="enter" className="size-3.5" />]}
|
|
272
|
+
label={i18n.t('admin.components.command_palette.hints.select')}
|
|
273
|
+
/>
|
|
274
|
+
</div>
|
|
275
|
+
<CommandHint
|
|
276
|
+
keys={[i18n.t('admin.components.command_palette.hints.esc_key')]}
|
|
277
|
+
label={i18n.t('admin.components.command_palette.hints.close')}
|
|
278
|
+
/>
|
|
279
|
+
</>
|
|
280
|
+
)}
|
|
281
|
+
</div>
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
|
|
162
285
|
export {
|
|
163
286
|
Command,
|
|
164
287
|
CommandDialog,
|
|
165
288
|
CommandEmpty,
|
|
289
|
+
CommandFooter,
|
|
166
290
|
CommandGroup,
|
|
291
|
+
CommandHint,
|
|
167
292
|
CommandInput,
|
|
168
293
|
CommandItem,
|
|
294
|
+
CommandKey,
|
|
169
295
|
CommandList,
|
|
170
296
|
CommandSeparator,
|
|
171
297
|
CommandShortcut,
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { ContextMenu as ContextMenuPrimitive } from '@base-ui/react/context-menu'
|
|
4
|
+
import type * as React from 'react'
|
|
5
|
+
import { cn } from '../lib/utils'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Right-click menu. Styled to match {@link DropdownMenu} exactly — the same
|
|
9
|
+
* menu opened a different way should not look like a different menu.
|
|
10
|
+
*
|
|
11
|
+
* Always a shortcut to actions reachable another way: a right-click is
|
|
12
|
+
* undiscoverable on its own, and absent entirely on touch.
|
|
13
|
+
*/
|
|
14
|
+
function ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
|
15
|
+
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function ContextMenuTrigger({
|
|
19
|
+
...props
|
|
20
|
+
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
|
21
|
+
return <ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function ContextMenuGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
|
25
|
+
return <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function ContextMenuContent({
|
|
29
|
+
className,
|
|
30
|
+
children,
|
|
31
|
+
...props
|
|
32
|
+
}: {
|
|
33
|
+
className?: string
|
|
34
|
+
children?: React.ReactNode
|
|
35
|
+
} & Omit<React.ComponentProps<typeof ContextMenuPrimitive.Popup>, 'className'>) {
|
|
36
|
+
return (
|
|
37
|
+
<ContextMenuPrimitive.Portal>
|
|
38
|
+
<ContextMenuPrimitive.Positioner className="z-50">
|
|
39
|
+
<ContextMenuPrimitive.Popup
|
|
40
|
+
data-slot="context-menu-content"
|
|
41
|
+
className={cn(
|
|
42
|
+
// `origin-(--transform-origin)` so the menu grows out of the
|
|
43
|
+
// pointer rather than its own centre.
|
|
44
|
+
'z-50 max-h-[var(--available-height)] min-w-40 max-w-80 overflow-x-hidden overflow-y-auto rounded-xl border border-border bg-popover p-1 text-popover-foreground shadow-lg origin-(--transform-origin) duration-100 ease-out data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 transition-[opacity,transform,translate,scale]',
|
|
45
|
+
className,
|
|
46
|
+
)}
|
|
47
|
+
{...props}
|
|
48
|
+
>
|
|
49
|
+
{children}
|
|
50
|
+
</ContextMenuPrimitive.Popup>
|
|
51
|
+
</ContextMenuPrimitive.Positioner>
|
|
52
|
+
</ContextMenuPrimitive.Portal>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function ContextMenuItem({
|
|
57
|
+
className,
|
|
58
|
+
inset,
|
|
59
|
+
variant = 'default',
|
|
60
|
+
...props
|
|
61
|
+
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
|
62
|
+
inset?: boolean
|
|
63
|
+
variant?: 'default' | 'destructive'
|
|
64
|
+
}) {
|
|
65
|
+
return (
|
|
66
|
+
<ContextMenuPrimitive.Item
|
|
67
|
+
data-slot="context-menu-item"
|
|
68
|
+
data-inset={inset}
|
|
69
|
+
data-variant={variant}
|
|
70
|
+
className={cn(
|
|
71
|
+
"group/context-menu-item relative flex cursor-pointer items-center gap-3 rounded-lg px-2 py-1.5 text-sm outline-hidden select-none transition-colors duration-75 focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
72
|
+
className,
|
|
73
|
+
)}
|
|
74
|
+
{...props}
|
|
75
|
+
/>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function ContextMenuSeparator({
|
|
80
|
+
className,
|
|
81
|
+
...props
|
|
82
|
+
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
|
83
|
+
return (
|
|
84
|
+
<ContextMenuPrimitive.Separator
|
|
85
|
+
data-slot="context-menu-separator"
|
|
86
|
+
className={cn('mx-1 my-1 h-px bg-border-subtle', className)}
|
|
87
|
+
{...props}
|
|
88
|
+
/>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export {
|
|
93
|
+
ContextMenu,
|
|
94
|
+
ContextMenuContent,
|
|
95
|
+
ContextMenuGroup,
|
|
96
|
+
ContextMenuItem,
|
|
97
|
+
ContextMenuSeparator,
|
|
98
|
+
ContextMenuTrigger,
|
|
99
|
+
}
|