@spree/dashboard-ui 0.13.1 → 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
|
@@ -49,7 +49,15 @@ export interface ResourceComboboxProps<T extends ComboboxOption> {
|
|
|
49
49
|
placeholder?: string
|
|
50
50
|
/** Empty state text shown in the dropdown when items is empty. */
|
|
51
51
|
emptyText?: string
|
|
52
|
+
/** Optional footer below the list — e.g. a count of rows not shown. */
|
|
53
|
+
listFooter?: ReactNode
|
|
52
54
|
disabled?: boolean
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Applied to the search input, so a sibling `<FieldLabel htmlFor>` names the
|
|
58
|
+
* field for screen readers.
|
|
59
|
+
*/
|
|
60
|
+
id?: string
|
|
53
61
|
}
|
|
54
62
|
|
|
55
63
|
/**
|
|
@@ -72,7 +80,9 @@ export function ResourceCombobox<T extends ComboboxOption>({
|
|
|
72
80
|
renderOption,
|
|
73
81
|
placeholder,
|
|
74
82
|
emptyText,
|
|
83
|
+
listFooter,
|
|
75
84
|
disabled,
|
|
85
|
+
id,
|
|
76
86
|
}: ResourceComboboxProps<T>) {
|
|
77
87
|
const selected = items.find((r) => r.id === value) ?? null
|
|
78
88
|
|
|
@@ -92,7 +102,7 @@ export function ResourceCombobox<T extends ComboboxOption>({
|
|
|
92
102
|
// dropdown trigger + selection mechanics, not just the input.
|
|
93
103
|
disabled={disabled}
|
|
94
104
|
>
|
|
95
|
-
<ComboboxInput placeholder={placeholder} disabled={disabled} showClear />
|
|
105
|
+
<ComboboxInput id={id} placeholder={placeholder} disabled={disabled} showClear />
|
|
96
106
|
<ComboboxContent>
|
|
97
107
|
<ComboboxEmpty>{emptyText}</ComboboxEmpty>
|
|
98
108
|
<ComboboxList>
|
|
@@ -102,6 +112,7 @@ export function ResourceCombobox<T extends ComboboxOption>({
|
|
|
102
112
|
</ComboboxItem>
|
|
103
113
|
)}
|
|
104
114
|
</ComboboxList>
|
|
115
|
+
{listFooter}
|
|
105
116
|
</ComboboxContent>
|
|
106
117
|
</Combobox>
|
|
107
118
|
)
|
|
@@ -4,8 +4,15 @@ import { cn } from '../lib/utils'
|
|
|
4
4
|
interface ResourceNameCellProps {
|
|
5
5
|
/** Resource identifier, mirrored into the bridge data attribute. */
|
|
6
6
|
id: string
|
|
7
|
-
/**
|
|
8
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Data attribute the `useRowClickBridge` listener targets (e.g.
|
|
9
|
+
* `data-payment-method-id`).
|
|
10
|
+
*
|
|
11
|
+
* Omit it for a row that must not be opened — a record this panel may read
|
|
12
|
+
* but not write. The cell then renders as plain text rather than a button
|
|
13
|
+
* that looks clickable and does nothing.
|
|
14
|
+
*/
|
|
15
|
+
dataAttr?: `data-${string}`
|
|
9
16
|
/** Primary text — the resource's display name. */
|
|
10
17
|
name: ReactNode
|
|
11
18
|
/** Optional secondary line. Falsy values render nothing. */
|
|
@@ -31,14 +38,24 @@ export function ResourceNameCell({
|
|
|
31
38
|
secondary,
|
|
32
39
|
nameClassName,
|
|
33
40
|
}: ResourceNameCellProps) {
|
|
41
|
+
const content = (
|
|
42
|
+
<>
|
|
43
|
+
<span className={cn('font-medium', nameClassName)}>{name}</span>
|
|
44
|
+
{secondary && <span className="text-xs text-muted-foreground">{secondary}</span>}
|
|
45
|
+
</>
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
if (!dataAttr) {
|
|
49
|
+
return <div className="flex h-full w-full flex-col items-start text-left">{content}</div>
|
|
50
|
+
}
|
|
51
|
+
|
|
34
52
|
return (
|
|
35
53
|
<button
|
|
36
54
|
type="button"
|
|
37
55
|
{...{ [dataAttr]: id }}
|
|
38
56
|
className="flex h-full w-full cursor-pointer flex-col items-start rounded text-left focus-visible:outline-none"
|
|
39
57
|
>
|
|
40
|
-
|
|
41
|
-
{secondary && <span className="text-xs text-muted-foreground">{secondary}</span>}
|
|
58
|
+
{content}
|
|
42
59
|
</button>
|
|
43
60
|
)
|
|
44
61
|
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { useTranslation } from 'react-i18next'
|
|
3
|
+
import { Button } from '../ui/button'
|
|
4
|
+
import {
|
|
5
|
+
Dialog,
|
|
6
|
+
DialogBody,
|
|
7
|
+
DialogContent,
|
|
8
|
+
DialogFooter,
|
|
9
|
+
DialogHeader,
|
|
10
|
+
DialogTitle,
|
|
11
|
+
} from '../ui/dialog'
|
|
12
|
+
import { Field, FieldLabel } from '../ui/field'
|
|
13
|
+
import { Input } from '../ui/input'
|
|
14
|
+
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from '../ui/input-group'
|
|
15
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
|
16
|
+
import { Switch } from '../ui/switch'
|
|
17
|
+
|
|
18
|
+
/** How money goes back to the buyer. */
|
|
19
|
+
export type RefundMethod = 'original_payment' | 'store_credit'
|
|
20
|
+
|
|
21
|
+
/** One line of a return, as the receipt form needs it. */
|
|
22
|
+
export type ReturnLineForReceipt = {
|
|
23
|
+
id: string
|
|
24
|
+
label: string
|
|
25
|
+
quantity: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type ReceiptRow = { quantity: number; resellable: boolean }
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Records what actually arrived: a quantity per line, and whether each can go
|
|
32
|
+
* back on the shelf.
|
|
33
|
+
*
|
|
34
|
+
* Deliberately not defaulted server-side — an omitted `items` means "receive
|
|
35
|
+
* everything as requested", so a partial receipt has to say so line by line.
|
|
36
|
+
* Shared by the operator's order page and the seller's.
|
|
37
|
+
*/
|
|
38
|
+
export function ReturnReceiveDialog({
|
|
39
|
+
lines,
|
|
40
|
+
onClose,
|
|
41
|
+
onSubmit,
|
|
42
|
+
pending = false,
|
|
43
|
+
}: {
|
|
44
|
+
lines: ReturnLineForReceipt[]
|
|
45
|
+
onClose: () => void
|
|
46
|
+
onSubmit: (
|
|
47
|
+
items: Array<{ return_line_item_id: string; quantity: number; resellable: boolean }>,
|
|
48
|
+
) => void
|
|
49
|
+
pending?: boolean
|
|
50
|
+
}) {
|
|
51
|
+
const { t } = useTranslation()
|
|
52
|
+
const [rows, setRows] = useState<Record<string, ReceiptRow>>(() =>
|
|
53
|
+
Object.fromEntries(
|
|
54
|
+
lines.map((line) => [line.id, { quantity: line.quantity, resellable: true }]),
|
|
55
|
+
),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
|
60
|
+
<DialogContent>
|
|
61
|
+
<DialogHeader>
|
|
62
|
+
<DialogTitle>{t('admin.pages.orders.detail.returns.receive_title')}</DialogTitle>
|
|
63
|
+
</DialogHeader>
|
|
64
|
+
<DialogBody className="flex flex-col gap-4">
|
|
65
|
+
{lines.map((line) => (
|
|
66
|
+
<div key={line.id} className="flex flex-col gap-3">
|
|
67
|
+
<span className="font-medium text-sm">{line.label}</span>
|
|
68
|
+
<div className="flex flex-col gap-3">
|
|
69
|
+
<Field>
|
|
70
|
+
<FieldLabel htmlFor={`qty-${line.id}`}>
|
|
71
|
+
{t('admin.pages.orders.detail.returns.received_quantity')}
|
|
72
|
+
</FieldLabel>
|
|
73
|
+
<Input
|
|
74
|
+
id={`qty-${line.id}`}
|
|
75
|
+
type="number"
|
|
76
|
+
min={0}
|
|
77
|
+
max={line.quantity}
|
|
78
|
+
value={rows[line.id]?.quantity ?? 0}
|
|
79
|
+
onChange={(event) =>
|
|
80
|
+
setRows((current) => ({
|
|
81
|
+
...current,
|
|
82
|
+
[line.id]: {
|
|
83
|
+
// `min`/`max` bind the stepper only — a typed value
|
|
84
|
+
// reaches onChange unclamped.
|
|
85
|
+
quantity: Math.max(
|
|
86
|
+
0,
|
|
87
|
+
Math.min(Number(event.target.value) || 0, line.quantity),
|
|
88
|
+
),
|
|
89
|
+
resellable: current[line.id]?.resellable ?? true,
|
|
90
|
+
},
|
|
91
|
+
}))
|
|
92
|
+
}
|
|
93
|
+
/>
|
|
94
|
+
</Field>
|
|
95
|
+
<Field orientation="horizontal">
|
|
96
|
+
<FieldLabel htmlFor={`resellable-${line.id}`}>
|
|
97
|
+
{t('admin.pages.orders.detail.returns.resellable')}
|
|
98
|
+
</FieldLabel>
|
|
99
|
+
<Switch
|
|
100
|
+
id={`resellable-${line.id}`}
|
|
101
|
+
checked={rows[line.id]?.resellable ?? true}
|
|
102
|
+
onCheckedChange={(checked) =>
|
|
103
|
+
setRows((current) => ({
|
|
104
|
+
...current,
|
|
105
|
+
[line.id]: {
|
|
106
|
+
quantity: current[line.id]?.quantity ?? 0,
|
|
107
|
+
resellable: checked,
|
|
108
|
+
},
|
|
109
|
+
}))
|
|
110
|
+
}
|
|
111
|
+
/>
|
|
112
|
+
</Field>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
))}
|
|
116
|
+
</DialogBody>
|
|
117
|
+
<DialogFooter>
|
|
118
|
+
<Button type="button" variant="outline" onClick={onClose} disabled={pending}>
|
|
119
|
+
{t('admin.actions.cancel')}
|
|
120
|
+
</Button>
|
|
121
|
+
<Button
|
|
122
|
+
type="button"
|
|
123
|
+
disabled={pending}
|
|
124
|
+
onClick={() =>
|
|
125
|
+
onSubmit(
|
|
126
|
+
lines.map((line) => ({
|
|
127
|
+
return_line_item_id: line.id,
|
|
128
|
+
quantity: rows[line.id]?.quantity ?? 0,
|
|
129
|
+
resellable: rows[line.id]?.resellable ?? true,
|
|
130
|
+
})),
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
>
|
|
134
|
+
{t('admin.pages.orders.detail.returns.actions.receive')}
|
|
135
|
+
</Button>
|
|
136
|
+
</DialogFooter>
|
|
137
|
+
</DialogContent>
|
|
138
|
+
</Dialog>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Gives the money back: how much, and by what means.
|
|
144
|
+
*
|
|
145
|
+
* The currency symbol comes from the caller — the operator's panel reads it
|
|
146
|
+
* from the store it is looking at, and a seller has no currency of their own.
|
|
147
|
+
*/
|
|
148
|
+
export function ReturnRefundDialog({
|
|
149
|
+
refundableTotal,
|
|
150
|
+
currencySymbol,
|
|
151
|
+
onClose,
|
|
152
|
+
onSubmit,
|
|
153
|
+
pending = false,
|
|
154
|
+
}: {
|
|
155
|
+
refundableTotal: string
|
|
156
|
+
currencySymbol: string
|
|
157
|
+
onClose: () => void
|
|
158
|
+
onSubmit: (params: { refundMethod: RefundMethod; amount?: string }) => void
|
|
159
|
+
pending?: boolean
|
|
160
|
+
}) {
|
|
161
|
+
const { t } = useTranslation()
|
|
162
|
+
const [refundMethod, setRefundMethod] = useState<RefundMethod>('original_payment')
|
|
163
|
+
const [amount, setAmount] = useState(refundableTotal)
|
|
164
|
+
|
|
165
|
+
const methodOptions = [
|
|
166
|
+
{
|
|
167
|
+
value: 'original_payment',
|
|
168
|
+
label: t('admin.pages.orders.detail.returns.refund_methods.original_payment'),
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
value: 'store_credit',
|
|
172
|
+
label: t('admin.pages.orders.detail.returns.refund_methods.store_credit'),
|
|
173
|
+
},
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
|
178
|
+
<DialogContent>
|
|
179
|
+
<DialogHeader>
|
|
180
|
+
<DialogTitle>{t('admin.pages.orders.detail.returns.refund_title')}</DialogTitle>
|
|
181
|
+
</DialogHeader>
|
|
182
|
+
<DialogBody className="flex flex-col gap-4">
|
|
183
|
+
<Field>
|
|
184
|
+
<FieldLabel htmlFor="refund-amount">
|
|
185
|
+
{t('admin.pages.orders.detail.returns.refund_amount')}
|
|
186
|
+
</FieldLabel>
|
|
187
|
+
<InputGroup>
|
|
188
|
+
<InputGroupAddon>
|
|
189
|
+
<InputGroupText>{currencySymbol}</InputGroupText>
|
|
190
|
+
</InputGroupAddon>
|
|
191
|
+
<InputGroupInput
|
|
192
|
+
id="refund-amount"
|
|
193
|
+
type="number"
|
|
194
|
+
step="0.01"
|
|
195
|
+
min="0"
|
|
196
|
+
value={amount}
|
|
197
|
+
onChange={(event) => setAmount(event.target.value)}
|
|
198
|
+
/>
|
|
199
|
+
</InputGroup>
|
|
200
|
+
</Field>
|
|
201
|
+
<Field>
|
|
202
|
+
<FieldLabel htmlFor="refund-method">
|
|
203
|
+
{t('admin.pages.orders.detail.returns.refund_method')}
|
|
204
|
+
</FieldLabel>
|
|
205
|
+
<Select
|
|
206
|
+
items={methodOptions}
|
|
207
|
+
value={refundMethod}
|
|
208
|
+
onValueChange={(value) => setRefundMethod(value as RefundMethod)}
|
|
209
|
+
>
|
|
210
|
+
<SelectTrigger id="refund-method">
|
|
211
|
+
<SelectValue />
|
|
212
|
+
</SelectTrigger>
|
|
213
|
+
<SelectContent>
|
|
214
|
+
{methodOptions.map((option) => (
|
|
215
|
+
<SelectItem key={option.value} value={option.value}>
|
|
216
|
+
{option.label}
|
|
217
|
+
</SelectItem>
|
|
218
|
+
))}
|
|
219
|
+
</SelectContent>
|
|
220
|
+
</Select>
|
|
221
|
+
</Field>
|
|
222
|
+
</DialogBody>
|
|
223
|
+
<DialogFooter>
|
|
224
|
+
<Button type="button" variant="outline" onClick={onClose} disabled={pending}>
|
|
225
|
+
{t('admin.actions.cancel')}
|
|
226
|
+
</Button>
|
|
227
|
+
<Button
|
|
228
|
+
type="button"
|
|
229
|
+
disabled={pending}
|
|
230
|
+
onClick={() => onSubmit({ refundMethod, amount })}
|
|
231
|
+
>
|
|
232
|
+
{t('admin.pages.orders.detail.returns.actions.refund')}
|
|
233
|
+
</Button>
|
|
234
|
+
</DialogFooter>
|
|
235
|
+
</DialogContent>
|
|
236
|
+
</Dialog>
|
|
237
|
+
)
|
|
238
|
+
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { AlertTriangleIcon, RotateCcwIcon } from 'lucide-react'
|
|
2
1
|
import { Component, type ReactNode } from 'react'
|
|
3
2
|
import { useTranslation } from 'react-i18next'
|
|
4
3
|
import { Button } from '../ui/button'
|
|
@@ -10,6 +9,7 @@ import {
|
|
|
10
9
|
EmptyMedia,
|
|
11
10
|
EmptyTitle,
|
|
12
11
|
} from '../ui/empty'
|
|
12
|
+
import { AlertTriangleIcon, RotateCcwIcon } from './icons'
|
|
13
13
|
|
|
14
14
|
interface ErrorStateProps {
|
|
15
15
|
/** Headline. Defaults to "Something went wrong". */
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { CopyIcon, EllipsisVerticalIcon, EyeIcon, PencilIcon, Trash2Icon } from 'lucide-react'
|
|
2
1
|
import type { ReactNode } from 'react'
|
|
3
2
|
import { useTranslation } from 'react-i18next'
|
|
4
3
|
import { Button } from '../ui/button'
|
|
@@ -8,6 +7,7 @@ import {
|
|
|
8
7
|
DropdownMenuItem,
|
|
9
8
|
DropdownMenuTrigger,
|
|
10
9
|
} from '../ui/dropdown-menu'
|
|
10
|
+
import { CopyIcon, EllipsisVerticalIcon, EyeIcon, PencilIcon, Trash2Icon } from './icons'
|
|
11
11
|
|
|
12
12
|
export interface RowAction {
|
|
13
13
|
/** Stable key for React reconciliation. Doubles as the i18n action slug
|
|
@@ -73,7 +73,7 @@ export function RowActions({ actions }: RowActionsProps) {
|
|
|
73
73
|
key={action.key}
|
|
74
74
|
onClick={action.onSelect}
|
|
75
75
|
disabled={action.disabled}
|
|
76
|
-
|
|
76
|
+
variant={action.destructive ? 'destructive' : undefined}
|
|
77
77
|
>
|
|
78
78
|
{action.icon ?? BUILTIN_ICONS[action.key]}
|
|
79
79
|
{action.label ?? t(`admin.row_actions.${action.key}`)}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type * as React from 'react'
|
|
2
|
+
import { useRef } from 'react'
|
|
3
|
+
import { cn } from '../lib/utils'
|
|
4
|
+
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '../ui/input-group'
|
|
5
|
+
import { SearchIcon, XIcon } from './icons'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Search box with a leading magnifier and our own clear button.
|
|
9
|
+
*
|
|
10
|
+
* The clear button is hand-rolled rather than `type="search"`'s native one:
|
|
11
|
+
* that control is unstyleable, ignores the design tokens, renders at a size no
|
|
12
|
+
* touch guideline would accept, and Safari on iOS omits it entirely — so on a
|
|
13
|
+
* phone the field could not be cleared at all. The input stays `type="text"`
|
|
14
|
+
* to keep the native affordance from rendering alongside this one, and carries
|
|
15
|
+
* `role="searchbox"` so it still announces as a search field — the role a
|
|
16
|
+
* `type="search"` input would have given it for free.
|
|
17
|
+
*
|
|
18
|
+
* Clearing returns focus to the field, since the user is almost always about
|
|
19
|
+
* to type a different query.
|
|
20
|
+
*/
|
|
21
|
+
export function SearchInput({
|
|
22
|
+
value,
|
|
23
|
+
onValueChange,
|
|
24
|
+
className,
|
|
25
|
+
clearLabel = 'Clear search',
|
|
26
|
+
...props
|
|
27
|
+
}: Omit<React.ComponentProps<'input'>, 'value' | 'onChange' | 'type' | 'className'> & {
|
|
28
|
+
value: string
|
|
29
|
+
onValueChange: (value: string) => void
|
|
30
|
+
/** Sizing and surface for the whole field. The input fills it. */
|
|
31
|
+
className?: string
|
|
32
|
+
/** Accessible name for the clear button. */
|
|
33
|
+
clearLabel?: string
|
|
34
|
+
}) {
|
|
35
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<InputGroup className={className}>
|
|
39
|
+
<InputGroupAddon>
|
|
40
|
+
<SearchIcon className="size-4" />
|
|
41
|
+
</InputGroupAddon>
|
|
42
|
+
{/* `h-full` so callers size the field once, on the wrapper, rather than
|
|
43
|
+
repeating the same height rule on both. */}
|
|
44
|
+
<InputGroupInput
|
|
45
|
+
ref={inputRef}
|
|
46
|
+
type="text"
|
|
47
|
+
role="searchbox"
|
|
48
|
+
value={value}
|
|
49
|
+
onChange={(event) => onValueChange(event.target.value)}
|
|
50
|
+
className="h-full"
|
|
51
|
+
{...props}
|
|
52
|
+
/>
|
|
53
|
+
{value && (
|
|
54
|
+
<InputGroupAddon align="inline-end">
|
|
55
|
+
<InputGroupButton
|
|
56
|
+
// `icon-sm` rather than the default `xs`: that variant hardcodes
|
|
57
|
+
// `h-6`, which tailwind-merge cannot reconcile against a `size-*`
|
|
58
|
+
// override, so the button would stay 24px however it was classed.
|
|
59
|
+
size="icon-sm"
|
|
60
|
+
aria-label={clearLabel}
|
|
61
|
+
onClick={() => {
|
|
62
|
+
onValueChange('')
|
|
63
|
+
inputRef.current?.focus()
|
|
64
|
+
}}
|
|
65
|
+
// Touch-sized by default and only compact from `md` up, so a phone
|
|
66
|
+
// always gets the larger target whether or not the field happens to
|
|
67
|
+
// sit inside the mobile drawer. The glyph stays small either way.
|
|
68
|
+
className={cn('size-9 rounded-full md:size-6 in-data-[mobile=true]:size-9')}
|
|
69
|
+
>
|
|
70
|
+
<XIcon className="size-4" />
|
|
71
|
+
</InputGroupButton>
|
|
72
|
+
</InputGroupAddon>
|
|
73
|
+
)}
|
|
74
|
+
</InputGroup>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { EyeIcon, EyeOffIcon } from 'lucide-react'
|
|
2
1
|
import { useState } from 'react'
|
|
3
2
|
import { useTranslation } from 'react-i18next'
|
|
4
3
|
import { Button } from '../ui/button'
|
|
@@ -20,11 +19,9 @@ interface SecretInputProps {
|
|
|
20
19
|
value: unknown
|
|
21
20
|
onChange: (value: unknown) => void
|
|
22
21
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* the original masked value so the backend's round-trip guard keeps
|
|
27
|
-
* the existing secret.
|
|
22
|
+
* @deprecated Masked values always render as the stored badge — there is
|
|
23
|
+
* nothing to reveal, since the server never sends the secret. Retained so
|
|
24
|
+
* existing callers keep compiling; the prop has no effect.
|
|
28
25
|
*/
|
|
29
26
|
redactWhenMasked?: boolean
|
|
30
27
|
/** Replaces the default "Stored on the server. Click Replace to rotate." caption. */
|
|
@@ -34,12 +31,13 @@ interface SecretInputProps {
|
|
|
34
31
|
}
|
|
35
32
|
|
|
36
33
|
/**
|
|
37
|
-
* Stripe-style credential field.
|
|
38
|
-
*
|
|
39
|
-
* button
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
34
|
+
* Stripe-style credential field. A stored secret arrives from the API as a
|
|
35
|
+
* mask (`••••3K9z`) and renders as a read-only badge with a "Replace"
|
|
36
|
+
* button — never as an input, and never with a reveal toggle, because the
|
|
37
|
+
* server does not send the secret and there is nothing to unhide. Replace
|
|
38
|
+
* switches to an empty password input so the admin opts in to rotation;
|
|
39
|
+
* Cancel reverts to the badge so the next save preserves the existing
|
|
40
|
+
* secret via the backend's masked round-trip guard.
|
|
43
41
|
*
|
|
44
42
|
* Domain-agnostic — used by `<PreferencesForm>` for `:password`-typed
|
|
45
43
|
* preferences, but also suitable for any other secret that round-trips
|
|
@@ -50,7 +48,6 @@ export function SecretInput({
|
|
|
50
48
|
label,
|
|
51
49
|
value,
|
|
52
50
|
onChange,
|
|
53
|
-
redactWhenMasked = false,
|
|
54
51
|
helpText,
|
|
55
52
|
placeholder,
|
|
56
53
|
}: SecretInputProps) {
|
|
@@ -59,15 +56,16 @@ export function SecretInput({
|
|
|
59
56
|
// Captured at click time so Cancel can restore the original mask even
|
|
60
57
|
// after `onChange('')` has cleared the parent value.
|
|
61
58
|
const [pendingMask, setPendingMask] = useState<string | null>(null)
|
|
62
|
-
const [revealed, setRevealed] = useState(false)
|
|
63
59
|
const replacing = pendingMask !== null
|
|
64
60
|
|
|
65
|
-
|
|
61
|
+
// A stored secret always renders as the badge: the server sent a mask,
|
|
62
|
+
// not the value, so there is nothing a reveal toggle could show.
|
|
63
|
+
if (storedMask && !replacing) {
|
|
66
64
|
return (
|
|
67
65
|
<Field>
|
|
68
66
|
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
|
69
67
|
<div className="flex items-center gap-2">
|
|
70
|
-
<div className="flex min-h-8 w-full min-w-0 items-center rounded-lg border border-border bg-muted/40 px-2.5 py-1.5
|
|
68
|
+
<div className="flex min-h-8 w-full min-w-0 items-center rounded-lg border border-border bg-muted/40 px-2.5 py-1.5">
|
|
71
69
|
<span className="font-mono text-sm tabular-nums text-muted-foreground" id={id}>
|
|
72
70
|
{storedMask}
|
|
73
71
|
</span>
|
|
@@ -95,37 +93,25 @@ export function SecretInput({
|
|
|
95
93
|
<Field>
|
|
96
94
|
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
|
97
95
|
<div className="flex items-center gap-2">
|
|
98
|
-
<
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
placeholder
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
/>
|
|
110
|
-
<button
|
|
111
|
-
type="button"
|
|
112
|
-
aria-label={revealed ? t('admin.a11y.hide_value') : t('admin.a11y.show_value')}
|
|
113
|
-
aria-pressed={revealed}
|
|
114
|
-
onClick={() => setRevealed((v) => !v)}
|
|
115
|
-
className="absolute inset-y-0 right-0 flex items-center px-2.5 text-muted-foreground hover:text-foreground"
|
|
116
|
-
>
|
|
117
|
-
{revealed ? <EyeOffIcon className="size-4" /> : <EyeIcon className="size-4" />}
|
|
118
|
-
</button>
|
|
119
|
-
</div>
|
|
96
|
+
<Input
|
|
97
|
+
id={id}
|
|
98
|
+
type="password"
|
|
99
|
+
autoComplete="new-password"
|
|
100
|
+
placeholder={
|
|
101
|
+
replacing ? t('admin.components.secret_input.new_value_placeholder') : placeholder
|
|
102
|
+
}
|
|
103
|
+
value={(value as string) ?? ''}
|
|
104
|
+
onChange={(e) => onChange(e.target.value)}
|
|
105
|
+
className="flex-1"
|
|
106
|
+
/>
|
|
120
107
|
{replacing && (
|
|
121
108
|
<Button
|
|
122
109
|
type="button"
|
|
123
110
|
size="sm"
|
|
124
|
-
variant="
|
|
111
|
+
variant="outline"
|
|
125
112
|
onClick={() => {
|
|
126
113
|
onChange(pendingMask)
|
|
127
114
|
setPendingMask(null)
|
|
128
|
-
setRevealed(false)
|
|
129
115
|
}}
|
|
130
116
|
>
|
|
131
117
|
{t('admin.actions.cancel')}
|