@spree/dashboard-ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +26 -0
- package/package.json +86 -0
- package/src/hooks/use-copy-to-clipboard.ts +44 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/hooks/use-scrolled.ts +30 -0
- package/src/index.ts +99 -0
- package/src/lib/date-locale.ts +29 -0
- package/src/lib/utils.ts +6 -0
- package/src/lib/validation-messages.ts +19 -0
- package/src/spree/address-block.tsx +74 -0
- package/src/spree/back-button.tsx +48 -0
- package/src/spree/bulk-dialog.tsx +71 -0
- package/src/spree/bulk-price-table.tsx +254 -0
- package/src/spree/calculator-summary.tsx +120 -0
- package/src/spree/color-picker.tsx +202 -0
- package/src/spree/confirm-dialog.tsx +82 -0
- package/src/spree/copy-to-clipboard-button.tsx +90 -0
- package/src/spree/country-flag.tsx +30 -0
- package/src/spree/data-grid/cells.tsx +415 -0
- package/src/spree/data-grid/context.ts +59 -0
- package/src/spree/data-grid/data-grid.tsx +214 -0
- package/src/spree/data-grid/fill-handle.tsx +153 -0
- package/src/spree/data-grid/helpers.ts +18 -0
- package/src/spree/data-grid/index.ts +5 -0
- package/src/spree/data-grid/types.ts +36 -0
- package/src/spree/data-grid/use-data-grid-keyboard.ts +254 -0
- package/src/spree/data-grid/use-stable-cell-registration.ts +45 -0
- package/src/spree/drag-handle.tsx +51 -0
- package/src/spree/form-actions.tsx +140 -0
- package/src/spree/json-preview-drawer.tsx +254 -0
- package/src/spree/json-value-view.tsx +149 -0
- package/src/spree/language-menu-items.tsx +54 -0
- package/src/spree/metadata/metadata-card.tsx +62 -0
- package/src/spree/relative-time.tsx +49 -0
- package/src/spree/resource-combobox.tsx +108 -0
- package/src/spree/resource-layout.tsx +31 -0
- package/src/spree/resource-multi-autocomplete.tsx +180 -0
- package/src/spree/resource-name-cell.tsx +44 -0
- package/src/spree/route-error-boundary.tsx +102 -0
- package/src/spree/row-actions.tsx +85 -0
- package/src/spree/row-click-bridge.tsx +17 -0
- package/src/spree/secret-input.tsx +137 -0
- package/src/spree/storefront-visible-switch.tsx +58 -0
- package/src/spree/tag-list.tsx +26 -0
- package/src/spree/theme-provider.tsx +83 -0
- package/src/spree/theme-toggle.tsx +35 -0
- package/src/styles.css +338 -0
- package/src/ui/attachment.tsx +187 -0
- package/src/ui/avatar.tsx +94 -0
- package/src/ui/badge.tsx +161 -0
- package/src/ui/breadcrumb.tsx +109 -0
- package/src/ui/button.tsx +60 -0
- package/src/ui/calendar.tsx +66 -0
- package/src/ui/card.tsx +72 -0
- package/src/ui/chart.tsx +94 -0
- package/src/ui/checkbox.tsx +50 -0
- package/src/ui/collapsible.tsx +18 -0
- package/src/ui/combobox.tsx +270 -0
- package/src/ui/command.tsx +172 -0
- package/src/ui/data-table.tsx +60 -0
- package/src/ui/date-picker.tsx +253 -0
- package/src/ui/date-range-picker.tsx +150 -0
- package/src/ui/dialog.tsx +214 -0
- package/src/ui/dropdown-menu.tsx +345 -0
- package/src/ui/empty.tsx +94 -0
- package/src/ui/field.tsx +221 -0
- package/src/ui/input-group.tsx +141 -0
- package/src/ui/input.tsx +19 -0
- package/src/ui/label.tsx +29 -0
- package/src/ui/pagination.tsx +123 -0
- package/src/ui/popover.tsx +76 -0
- package/src/ui/progress.tsx +73 -0
- package/src/ui/radio-group.tsx +42 -0
- package/src/ui/rich-text-editor.tsx +263 -0
- package/src/ui/select.tsx +213 -0
- package/src/ui/separator.tsx +31 -0
- package/src/ui/sheet.tsx +208 -0
- package/src/ui/sidebar.tsx +679 -0
- package/src/ui/skeleton.tsx +13 -0
- package/src/ui/slot.tsx +35 -0
- package/src/ui/sonner.tsx +47 -0
- package/src/ui/switch.tsx +39 -0
- package/src/ui/textarea.tsx +18 -0
- package/src/ui/tooltip.tsx +100 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { CheckIcon, CopyIcon } from 'lucide-react'
|
|
2
|
+
import type { ComponentProps, ReactNode } from 'react'
|
|
3
|
+
import { useCopyToClipboard } from '../hooks/use-copy-to-clipboard'
|
|
4
|
+
import { cn } from '../lib/utils'
|
|
5
|
+
import { Button } from '../ui/button'
|
|
6
|
+
|
|
7
|
+
type ButtonProps = ComponentProps<typeof Button>
|
|
8
|
+
|
|
9
|
+
type CopyToClipboardButtonProps = Omit<ButtonProps, 'onClick' | 'children'> & {
|
|
10
|
+
/** Text to write to the clipboard. */
|
|
11
|
+
value: string
|
|
12
|
+
/** Accessible label for the button — required by a11y rules. */
|
|
13
|
+
'aria-label': string
|
|
14
|
+
/** Custom icon for the idle state. Defaults to `CopyIcon`. */
|
|
15
|
+
icon?: ReactNode
|
|
16
|
+
/** Custom icon for the copied state. Defaults to `CheckIcon`. */
|
|
17
|
+
copiedIcon?: ReactNode
|
|
18
|
+
/** Optional label rendered alongside the icon (e.g. "Copy"). */
|
|
19
|
+
label?: ReactNode
|
|
20
|
+
/** Optional label rendered alongside the icon while in the copied state. */
|
|
21
|
+
copiedLabel?: ReactNode
|
|
22
|
+
/**
|
|
23
|
+
* How long the copied state stays true after a successful copy. Forwarded to
|
|
24
|
+
* `useCopyToClipboard`. Defaults to 1200ms (the hook's default).
|
|
25
|
+
*/
|
|
26
|
+
resetMs?: number
|
|
27
|
+
/**
|
|
28
|
+
* Stop the click event from bubbling. Useful inside `<ResourceTable>` rows
|
|
29
|
+
* where `useRowClickBridge` would otherwise also fire. Defaults to `true`
|
|
30
|
+
* since that's the common case — pass `false` to opt out.
|
|
31
|
+
*/
|
|
32
|
+
stopPropagation?: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Universal copy-to-clipboard button. Renders a standard `<Button>` with a
|
|
37
|
+
* copy icon that flips to a check icon for a short flash after a successful
|
|
38
|
+
* copy. Forwards every `<Button>` prop except `onClick` + `children`, so the
|
|
39
|
+
* caller controls variant, size, className, etc.
|
|
40
|
+
*
|
|
41
|
+
* @example Bare icon button next to a value
|
|
42
|
+
* <span>
|
|
43
|
+
* <code>{token}</code>
|
|
44
|
+
* <CopyToClipboardButton value={token} aria-label="Copy token" size="icon-xs" variant="ghost" />
|
|
45
|
+
* </span>
|
|
46
|
+
*
|
|
47
|
+
* @example Labelled button inside a modal
|
|
48
|
+
* <CopyToClipboardButton
|
|
49
|
+
* value={secret}
|
|
50
|
+
* aria-label="Copy secret"
|
|
51
|
+
* size="sm"
|
|
52
|
+
* variant="outline"
|
|
53
|
+
* label="Copy"
|
|
54
|
+
* copiedLabel="Copied"
|
|
55
|
+
* />
|
|
56
|
+
*/
|
|
57
|
+
export function CopyToClipboardButton({
|
|
58
|
+
value,
|
|
59
|
+
icon = <CopyIcon />,
|
|
60
|
+
copiedIcon = <CheckIcon />,
|
|
61
|
+
label,
|
|
62
|
+
copiedLabel,
|
|
63
|
+
resetMs,
|
|
64
|
+
stopPropagation = true,
|
|
65
|
+
className,
|
|
66
|
+
size = 'icon-xs',
|
|
67
|
+
variant = 'ghost',
|
|
68
|
+
type = 'button',
|
|
69
|
+
...rest
|
|
70
|
+
}: CopyToClipboardButtonProps) {
|
|
71
|
+
const { copied, copy } = useCopyToClipboard(resetMs !== undefined ? { resetMs } : undefined)
|
|
72
|
+
const displayedLabel = copied && copiedLabel !== undefined ? copiedLabel : label
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<Button
|
|
76
|
+
{...rest}
|
|
77
|
+
type={type}
|
|
78
|
+
size={size}
|
|
79
|
+
variant={variant}
|
|
80
|
+
className={cn(className)}
|
|
81
|
+
onClick={(e) => {
|
|
82
|
+
if (stopPropagation) e.stopPropagation()
|
|
83
|
+
copy(value)
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
86
|
+
{copied ? copiedIcon : icon}
|
|
87
|
+
{displayedLabel}
|
|
88
|
+
</Button>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { cn } from '../lib/utils'
|
|
2
|
+
|
|
3
|
+
interface CountryFlagProps {
|
|
4
|
+
/** ISO 3166-1 alpha-2 country code (case-insensitive). */
|
|
5
|
+
iso: string
|
|
6
|
+
/** Override the default `1.333em` × `1em` size. Pass any Tailwind size. */
|
|
7
|
+
className?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Renders a country flag via the `flag-icons` CSS sprite. The sprite resolves
|
|
12
|
+
* `.fi.fi-{iso}` to a `background-image` URL pointing at the matching SVG —
|
|
13
|
+
* the browser lazy-loads each flag only when it actually appears on screen.
|
|
14
|
+
*
|
|
15
|
+
* Decorative by design (`aria-hidden`); the surrounding label/text carries
|
|
16
|
+
* the country name for screen readers.
|
|
17
|
+
*/
|
|
18
|
+
export function CountryFlag({ iso, className }: CountryFlagProps) {
|
|
19
|
+
if (!iso) return null
|
|
20
|
+
return (
|
|
21
|
+
<span
|
|
22
|
+
aria-hidden
|
|
23
|
+
className={cn(
|
|
24
|
+
'fi shrink-0 rounded-[2px] shadow-[0_0_0_0.5px_rgba(0,0,0,0.08)]',
|
|
25
|
+
`fi-${iso.toLowerCase()}`,
|
|
26
|
+
className,
|
|
27
|
+
)}
|
|
28
|
+
/>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import { type KeyboardEvent, type ReactNode, useEffect, useId, useRef, useState } from 'react'
|
|
2
|
+
import { cn } from '../../lib/utils'
|
|
3
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../ui/select'
|
|
4
|
+
import { Switch } from '../../ui/switch'
|
|
5
|
+
import { modeFor, useDataGridContext } from './context'
|
|
6
|
+
import type { CellCoords } from './types'
|
|
7
|
+
import { useStableCellRegistration } from './use-stable-cell-registration'
|
|
8
|
+
|
|
9
|
+
interface BaseCellProps {
|
|
10
|
+
coords: CellCoords
|
|
11
|
+
ariaLabel: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface NumberCellProps extends BaseCellProps {
|
|
15
|
+
value: number
|
|
16
|
+
onChange: (next: number) => void
|
|
17
|
+
/** Visual hint — e.g. low-stock items get a colored border. */
|
|
18
|
+
intent?: 'default' | 'warning'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Editable numeric cell. In selection mode the underlying input is
|
|
22
|
+
* read-only (so arrow keys navigate the grid); in edit mode it accepts
|
|
23
|
+
* typing and arrow keys move the caret. */
|
|
24
|
+
export function NumberCell({
|
|
25
|
+
coords,
|
|
26
|
+
value,
|
|
27
|
+
onChange,
|
|
28
|
+
ariaLabel,
|
|
29
|
+
intent = 'default',
|
|
30
|
+
}: NumberCellProps) {
|
|
31
|
+
const ctx = useDataGridContext()
|
|
32
|
+
const inputRef = useRef<HTMLInputElement | null>(null)
|
|
33
|
+
const [draft, setDraft] = useState(value.toString())
|
|
34
|
+
const isSelected = ctx.isSelected(coords)
|
|
35
|
+
const mode = modeFor(ctx.editing, coords)
|
|
36
|
+
const isEditing = mode === 'edit'
|
|
37
|
+
const isFocused = ctx.anchor?.row === coords.row && ctx.anchor?.col === coords.col
|
|
38
|
+
|
|
39
|
+
// Resync draft with external value when not editing this cell.
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!isEditing) setDraft(value.toString())
|
|
42
|
+
}, [value, isEditing])
|
|
43
|
+
|
|
44
|
+
// Auto-focus + select when entering edit mode.
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (isEditing) {
|
|
47
|
+
const el = inputRef.current
|
|
48
|
+
if (el) {
|
|
49
|
+
el.focus()
|
|
50
|
+
el.select()
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}, [isEditing])
|
|
54
|
+
|
|
55
|
+
useStableCellRegistration(coords, {
|
|
56
|
+
focus: () => inputRef.current?.focus(),
|
|
57
|
+
read: () => value.toString(),
|
|
58
|
+
write: (next) => {
|
|
59
|
+
const n = Number(next)
|
|
60
|
+
if (!Number.isFinite(n)) return
|
|
61
|
+
onChange(Math.trunc(n))
|
|
62
|
+
},
|
|
63
|
+
canWrite: (next) => next === '' || Number.isFinite(Number(next)),
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
function commit() {
|
|
67
|
+
const n = Number(draft)
|
|
68
|
+
if (Number.isFinite(n)) onChange(Math.trunc(n))
|
|
69
|
+
else setDraft(value.toString())
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function onKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
|
73
|
+
if (!isEditing) {
|
|
74
|
+
// Selection mode: forward navigation keys to the grid by NOT preventing
|
|
75
|
+
// default. Mark this cell as the anchor on plain mouse-click style focus.
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
if (event.key === 'Enter') {
|
|
79
|
+
event.preventDefault()
|
|
80
|
+
commit()
|
|
81
|
+
ctx.setEditing(null)
|
|
82
|
+
// Move down to mirror Excel.
|
|
83
|
+
const next = { row: coords.row + 1, col: coords.col }
|
|
84
|
+
ctx.setAnchor(next)
|
|
85
|
+
ctx.setExtent(next)
|
|
86
|
+
ctx.cells.get(`${next.row}.${next.col}`)?.focus()
|
|
87
|
+
} else if (event.key === 'Escape') {
|
|
88
|
+
event.preventDefault()
|
|
89
|
+
setDraft(value.toString())
|
|
90
|
+
ctx.setEditing(null)
|
|
91
|
+
} else if (event.key === 'Tab') {
|
|
92
|
+
event.preventDefault()
|
|
93
|
+
commit()
|
|
94
|
+
ctx.setEditing(null)
|
|
95
|
+
const dx = event.shiftKey ? -1 : 1
|
|
96
|
+
const next = { row: coords.row, col: coords.col + dx }
|
|
97
|
+
ctx.setAnchor(next)
|
|
98
|
+
ctx.setExtent(next)
|
|
99
|
+
ctx.cells.get(`${next.row}.${next.col}`)?.focus()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<input
|
|
105
|
+
ref={inputRef}
|
|
106
|
+
type="text"
|
|
107
|
+
inputMode="numeric"
|
|
108
|
+
value={isEditing ? draft : value.toString()}
|
|
109
|
+
readOnly={!isEditing}
|
|
110
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
111
|
+
onFocus={() => {
|
|
112
|
+
ctx.setAnchor(coords)
|
|
113
|
+
ctx.setExtent(coords)
|
|
114
|
+
}}
|
|
115
|
+
onDoubleClick={() => ctx.setEditing(coords)}
|
|
116
|
+
onKeyDown={onKeyDown}
|
|
117
|
+
onBlur={() => {
|
|
118
|
+
if (isEditing) {
|
|
119
|
+
commit()
|
|
120
|
+
ctx.setEditing(null)
|
|
121
|
+
}
|
|
122
|
+
}}
|
|
123
|
+
aria-label={ariaLabel}
|
|
124
|
+
className={cn(
|
|
125
|
+
'block h-9 w-full cursor-cell border-0 bg-transparent px-3 text-right tabular-nums outline-none ring-inset transition-colors',
|
|
126
|
+
isSelected && 'bg-blue-500/10',
|
|
127
|
+
isFocused && !isEditing && 'ring-2 ring-blue-500',
|
|
128
|
+
isEditing && 'cursor-text bg-card ring-2 ring-blue-500',
|
|
129
|
+
intent === 'warning' && !isSelected && !isFocused && 'text-amber-700',
|
|
130
|
+
)}
|
|
131
|
+
/>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
interface MoneyCellProps extends BaseCellProps {
|
|
136
|
+
/** Canonical decimal string (`12.50`) coming back from the API, or
|
|
137
|
+
* `null`/blank for unset. */
|
|
138
|
+
value: string | null
|
|
139
|
+
/** Receives the user's raw input — locale-formatted strings like
|
|
140
|
+
* `"12,50"` are fine, the backend's `Spree::LocalizedNumber` does the
|
|
141
|
+
* parsing. `null` only when the user explicitly blanks the field. */
|
|
142
|
+
onChange: (next: string | null) => void
|
|
143
|
+
/** Currency symbol rendered as a non-editable prefix (`$`, `€`, `kr`, …). */
|
|
144
|
+
symbol: string
|
|
145
|
+
/**
|
|
146
|
+
* Locale decimal separator used only for display (canonical `.` →
|
|
147
|
+
* locale char). Whatever the user types is shipped as-is; the
|
|
148
|
+
* backend handles thousands separators, grouped digits, and so on.
|
|
149
|
+
* Default `.`.
|
|
150
|
+
*/
|
|
151
|
+
decimal?: string
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Editable money cell. Like `NumberCell` but keeps decimals (no truncation)
|
|
156
|
+
* and renders a currency-symbol prefix. Mirrors the rails admin's
|
|
157
|
+
* `edit_prices` bulk editor: format the API's canonical value with the
|
|
158
|
+
* locale's decimal mark for display, but ship the user's raw input
|
|
159
|
+
* untouched on save. `Spree::LocalizedNumber.parse` on the backend handles
|
|
160
|
+
* locale-aware parsing (thousands separators, decimal comma, etc.) so the
|
|
161
|
+
* SPA doesn't have to reimplement it.
|
|
162
|
+
*/
|
|
163
|
+
export function MoneyCell({
|
|
164
|
+
coords,
|
|
165
|
+
value,
|
|
166
|
+
onChange,
|
|
167
|
+
symbol,
|
|
168
|
+
decimal = '.',
|
|
169
|
+
ariaLabel,
|
|
170
|
+
}: MoneyCellProps) {
|
|
171
|
+
const ctx = useDataGridContext()
|
|
172
|
+
const inputRef = useRef<HTMLInputElement | null>(null)
|
|
173
|
+
|
|
174
|
+
const formatDisplay = (v: string | null): string =>
|
|
175
|
+
v == null || v === '' ? '' : v.replace('.', decimal)
|
|
176
|
+
|
|
177
|
+
const [draft, setDraft] = useState(formatDisplay(value))
|
|
178
|
+
const isSelected = ctx.isSelected(coords)
|
|
179
|
+
const mode = modeFor(ctx.editing, coords)
|
|
180
|
+
const isEditing = mode === 'edit'
|
|
181
|
+
const isFocused = ctx.anchor?.row === coords.row && ctx.anchor?.col === coords.col
|
|
182
|
+
|
|
183
|
+
// Resync draft with external value when not editing.
|
|
184
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional resync
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
if (!isEditing) setDraft(formatDisplay(value))
|
|
187
|
+
}, [value, isEditing, decimal])
|
|
188
|
+
|
|
189
|
+
// Auto-focus + select when entering edit mode.
|
|
190
|
+
useEffect(() => {
|
|
191
|
+
if (isEditing) {
|
|
192
|
+
const el = inputRef.current
|
|
193
|
+
if (el) {
|
|
194
|
+
el.focus()
|
|
195
|
+
el.select()
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}, [isEditing])
|
|
199
|
+
|
|
200
|
+
// The wrapping div is what the FillHandle measures and what
|
|
201
|
+
// elementFromPoint hits during a fill drag — register it as the
|
|
202
|
+
// cell's element. The input itself is what we focus.
|
|
203
|
+
const wrapRef = useRef<HTMLDivElement | null>(null)
|
|
204
|
+
useStableCellRegistration(coords, {
|
|
205
|
+
focus: () => inputRef.current?.focus(),
|
|
206
|
+
read: () => formatDisplay(value),
|
|
207
|
+
write: (next) => onChange(next.trim() ? next : null),
|
|
208
|
+
canWrite: () => true,
|
|
209
|
+
getElement: () => wrapRef.current,
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
function commit() {
|
|
213
|
+
// Ship the user's raw input; the backend's `Spree::LocalizedNumber`
|
|
214
|
+
// handles locale-aware parsing. Empty string is the only "clear"
|
|
215
|
+
// signal we generate frontend-side.
|
|
216
|
+
onChange(draft.trim() ? draft : null)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function onKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
|
220
|
+
if (!isEditing) return
|
|
221
|
+
if (event.key === 'Enter') {
|
|
222
|
+
event.preventDefault()
|
|
223
|
+
commit()
|
|
224
|
+
ctx.setEditing(null)
|
|
225
|
+
const next = { row: coords.row + 1, col: coords.col }
|
|
226
|
+
ctx.setAnchor(next)
|
|
227
|
+
ctx.setExtent(next)
|
|
228
|
+
ctx.cells.get(`${next.row}.${next.col}`)?.focus()
|
|
229
|
+
} else if (event.key === 'Escape') {
|
|
230
|
+
event.preventDefault()
|
|
231
|
+
setDraft(formatDisplay(value))
|
|
232
|
+
ctx.setEditing(null)
|
|
233
|
+
} else if (event.key === 'Tab') {
|
|
234
|
+
event.preventDefault()
|
|
235
|
+
commit()
|
|
236
|
+
ctx.setEditing(null)
|
|
237
|
+
const dx = event.shiftKey ? -1 : 1
|
|
238
|
+
const next = { row: coords.row, col: coords.col + dx }
|
|
239
|
+
ctx.setAnchor(next)
|
|
240
|
+
ctx.setExtent(next)
|
|
241
|
+
ctx.cells.get(`${next.row}.${next.col}`)?.focus()
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return (
|
|
246
|
+
<div
|
|
247
|
+
ref={wrapRef}
|
|
248
|
+
className={cn(
|
|
249
|
+
'flex h-9 w-full items-center transition-colors',
|
|
250
|
+
isSelected && 'bg-blue-500/10',
|
|
251
|
+
isFocused && !isEditing && 'ring-2 ring-blue-500 ring-inset',
|
|
252
|
+
isEditing && 'bg-card ring-2 ring-blue-500 ring-inset',
|
|
253
|
+
)}
|
|
254
|
+
>
|
|
255
|
+
<span
|
|
256
|
+
className="pl-3 text-muted-foreground pointer-events-none select-none tabular-nums"
|
|
257
|
+
aria-hidden
|
|
258
|
+
>
|
|
259
|
+
{symbol}
|
|
260
|
+
</span>
|
|
261
|
+
<input
|
|
262
|
+
ref={inputRef}
|
|
263
|
+
type="text"
|
|
264
|
+
inputMode="decimal"
|
|
265
|
+
value={isEditing ? draft : formatDisplay(value)}
|
|
266
|
+
readOnly={!isEditing}
|
|
267
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
268
|
+
onFocus={() => {
|
|
269
|
+
ctx.setAnchor(coords)
|
|
270
|
+
ctx.setExtent(coords)
|
|
271
|
+
}}
|
|
272
|
+
onDoubleClick={() => ctx.setEditing(coords)}
|
|
273
|
+
onKeyDown={onKeyDown}
|
|
274
|
+
onBlur={() => {
|
|
275
|
+
if (isEditing) {
|
|
276
|
+
commit()
|
|
277
|
+
ctx.setEditing(null)
|
|
278
|
+
}
|
|
279
|
+
}}
|
|
280
|
+
aria-label={ariaLabel}
|
|
281
|
+
className={cn(
|
|
282
|
+
'block h-9 w-full cursor-cell border-0 bg-transparent pl-1 pr-3 text-right tabular-nums outline-none',
|
|
283
|
+
isEditing && 'cursor-text',
|
|
284
|
+
)}
|
|
285
|
+
/>
|
|
286
|
+
</div>
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
interface SwitchCellProps extends BaseCellProps {
|
|
291
|
+
value: boolean
|
|
292
|
+
onChange: (next: boolean) => void
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Boolean cell. Space toggles; there's no separate edit mode because the
|
|
296
|
+
* switch is a single-action control. */
|
|
297
|
+
export function SwitchCell({ coords, value, onChange, ariaLabel }: SwitchCellProps) {
|
|
298
|
+
const ctx = useDataGridContext()
|
|
299
|
+
const wrapRef = useRef<HTMLDivElement | null>(null)
|
|
300
|
+
const isSelected = ctx.isSelected(coords)
|
|
301
|
+
const isFocused = ctx.anchor?.row === coords.row && ctx.anchor?.col === coords.col
|
|
302
|
+
|
|
303
|
+
useStableCellRegistration(coords, {
|
|
304
|
+
focus: () => wrapRef.current?.focus(),
|
|
305
|
+
read: () => (value ? 'true' : 'false'),
|
|
306
|
+
write: (next) => {
|
|
307
|
+
const lower = next.toLowerCase().trim()
|
|
308
|
+
if (lower === 'true' || lower === '1' || lower === 'yes') onChange(true)
|
|
309
|
+
else if (lower === 'false' || lower === '0' || lower === 'no' || lower === '') onChange(false)
|
|
310
|
+
},
|
|
311
|
+
canWrite: (next) =>
|
|
312
|
+
['', 'true', 'false', '1', '0', 'yes', 'no'].includes(next.toLowerCase().trim()),
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
|
316
|
+
if (event.key === ' ' || event.key === 'Enter') {
|
|
317
|
+
event.preventDefault()
|
|
318
|
+
onChange(!value)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return (
|
|
323
|
+
<div
|
|
324
|
+
ref={wrapRef}
|
|
325
|
+
tabIndex={0}
|
|
326
|
+
role="switch"
|
|
327
|
+
aria-checked={value}
|
|
328
|
+
aria-label={ariaLabel}
|
|
329
|
+
onFocus={() => {
|
|
330
|
+
ctx.setAnchor(coords)
|
|
331
|
+
ctx.setExtent(coords)
|
|
332
|
+
}}
|
|
333
|
+
onKeyDown={onKeyDown}
|
|
334
|
+
className={cn(
|
|
335
|
+
'flex h-9 w-full cursor-cell items-center px-3 outline-none ring-inset transition-colors',
|
|
336
|
+
isSelected && 'bg-blue-500/10',
|
|
337
|
+
isFocused && 'ring-2 ring-blue-500',
|
|
338
|
+
)}
|
|
339
|
+
>
|
|
340
|
+
<Switch checked={value} onCheckedChange={onChange} aria-hidden tabIndex={-1} />
|
|
341
|
+
</div>
|
|
342
|
+
)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
interface SelectCellProps<V extends string> extends BaseCellProps {
|
|
346
|
+
value: V
|
|
347
|
+
onChange: (next: V) => void
|
|
348
|
+
options: ReadonlyArray<{ value: V; label: string }>
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Cell for fixed-option pickers. Enter opens the Select; arrow keys inside
|
|
352
|
+
* the open Select navigate options (Base UI default). Esc closes. */
|
|
353
|
+
export function SelectCell<V extends string>({
|
|
354
|
+
coords,
|
|
355
|
+
value,
|
|
356
|
+
onChange,
|
|
357
|
+
options,
|
|
358
|
+
ariaLabel,
|
|
359
|
+
}: SelectCellProps<V>) {
|
|
360
|
+
const ctx = useDataGridContext()
|
|
361
|
+
const triggerRef = useRef<HTMLButtonElement | null>(null)
|
|
362
|
+
const isSelected = ctx.isSelected(coords)
|
|
363
|
+
const isFocused = ctx.anchor?.row === coords.row && ctx.anchor?.col === coords.col
|
|
364
|
+
const id = useId()
|
|
365
|
+
|
|
366
|
+
useStableCellRegistration(coords, {
|
|
367
|
+
focus: () => triggerRef.current?.focus(),
|
|
368
|
+
read: () => value,
|
|
369
|
+
write: (next) => {
|
|
370
|
+
if (options.some((o) => o.value === next)) onChange(next as V)
|
|
371
|
+
},
|
|
372
|
+
canWrite: (next) => options.some((o) => o.value === next) || next === '',
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
return (
|
|
376
|
+
<Select items={options} value={value} onValueChange={(v) => onChange(v as V)}>
|
|
377
|
+
<SelectTrigger
|
|
378
|
+
ref={triggerRef}
|
|
379
|
+
id={id}
|
|
380
|
+
aria-label={ariaLabel}
|
|
381
|
+
onFocus={() => {
|
|
382
|
+
ctx.setAnchor(coords)
|
|
383
|
+
ctx.setExtent(coords)
|
|
384
|
+
}}
|
|
385
|
+
className={cn(
|
|
386
|
+
// Strip the Select's standard chrome so the cell reads as a flat
|
|
387
|
+
// grid slot. The cell's own ring/bg comes from below.
|
|
388
|
+
'h-9 min-h-0 w-full cursor-cell rounded-none border-0 bg-transparent px-3 text-sm shadow-none ring-inset transition-colors focus:border-0 focus:shadow-none',
|
|
389
|
+
isSelected && 'bg-blue-500/10',
|
|
390
|
+
isFocused && 'ring-2 ring-blue-500',
|
|
391
|
+
)}
|
|
392
|
+
>
|
|
393
|
+
<SelectValue />
|
|
394
|
+
</SelectTrigger>
|
|
395
|
+
<SelectContent>
|
|
396
|
+
{options.map((opt) => (
|
|
397
|
+
<SelectItem key={opt.value} value={opt.value}>
|
|
398
|
+
{opt.label}
|
|
399
|
+
</SelectItem>
|
|
400
|
+
))}
|
|
401
|
+
</SelectContent>
|
|
402
|
+
</Select>
|
|
403
|
+
)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
interface ReadOnlyCellProps {
|
|
407
|
+
children: ReactNode
|
|
408
|
+
className?: string
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Plain non-editable cell — first column of a row (e.g. label / link).
|
|
412
|
+
* Not registered with the grid, so Tab/arrows skip it. */
|
|
413
|
+
export function ReadOnlyCell({ children, className }: ReadOnlyCellProps) {
|
|
414
|
+
return <div className={cn('flex h-9 items-center px-3 text-sm', className)}>{children}</div>
|
|
415
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react'
|
|
2
|
+
import type { CellCoords, CellKey, CellRegistration, GridMode } from './types'
|
|
3
|
+
|
|
4
|
+
export interface DataGridContextValue {
|
|
5
|
+
/** Stable map of `${row}.${col}` → registration. Mutated imperatively by
|
|
6
|
+
* cells in their mount effect so we don't churn React state on every
|
|
7
|
+
* render. Consumers should iterate via `eachCell()` rather than peeking
|
|
8
|
+
* at the Map directly. */
|
|
9
|
+
cells: Map<CellKey, CellRegistration>
|
|
10
|
+
|
|
11
|
+
/** Bounds, recomputed when cells register/unregister. */
|
|
12
|
+
bounds: { maxRow: number; maxCol: number }
|
|
13
|
+
|
|
14
|
+
/** Selection anchor (origin of a Shift+Arrow extension) and extent (current
|
|
15
|
+
* far corner). When equal, only one cell is selected. Null means no
|
|
16
|
+
* selection at all. */
|
|
17
|
+
anchor: CellCoords | null
|
|
18
|
+
extent: CellCoords | null
|
|
19
|
+
|
|
20
|
+
/** Currently editing cell. When set, keyboard nav shortcuts are routed
|
|
21
|
+
* through the cell instead of the grid. */
|
|
22
|
+
editing: CellCoords | null
|
|
23
|
+
|
|
24
|
+
/** Dispatchers — components react via subscribed selectors below. */
|
|
25
|
+
setAnchor: (coords: CellCoords | null) => void
|
|
26
|
+
setExtent: (coords: CellCoords | null) => void
|
|
27
|
+
setEditing: (coords: CellCoords | null) => void
|
|
28
|
+
|
|
29
|
+
/** Register/unregister a cell. Returns a cleanup. */
|
|
30
|
+
registerCell: (reg: CellRegistration) => () => void
|
|
31
|
+
|
|
32
|
+
/** Snapshot helpers used by the keyboard hook. */
|
|
33
|
+
isSelected: (coords: CellCoords) => boolean
|
|
34
|
+
selectedCells: () => CellRegistration[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const DataGridContext = createContext<DataGridContextValue | null>(null)
|
|
38
|
+
|
|
39
|
+
export function useDataGridContext(): DataGridContextValue {
|
|
40
|
+
const ctx = useContext(DataGridContext)
|
|
41
|
+
if (!ctx) {
|
|
42
|
+
throw new Error('Data grid cells must be rendered inside <DataGrid>.')
|
|
43
|
+
}
|
|
44
|
+
return ctx
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Inclusive rectangle test. */
|
|
48
|
+
export function coordsInRect(c: CellCoords, a: CellCoords | null, b: CellCoords | null): boolean {
|
|
49
|
+
if (!a || !b) return false
|
|
50
|
+
const minRow = Math.min(a.row, b.row)
|
|
51
|
+
const maxRow = Math.max(a.row, b.row)
|
|
52
|
+
const minCol = Math.min(a.col, b.col)
|
|
53
|
+
const maxCol = Math.max(a.col, b.col)
|
|
54
|
+
return c.row >= minRow && c.row <= maxRow && c.col >= minCol && c.col <= maxCol
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function modeFor(editing: CellCoords | null, coords: CellCoords): GridMode {
|
|
58
|
+
return editing && editing.row === coords.row && editing.col === coords.col ? 'edit' : 'select'
|
|
59
|
+
}
|