@spree/dashboard-ui 6.0.3 → 6.0.4
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/package.json +20 -19
- package/src/index.ts +5 -0
- package/src/lib/field-trigger.ts +10 -0
- package/src/lib/same-rich-text.ts +12 -3
- package/src/lib/utils.ts +14 -1
- package/src/spree/address-block.tsx +3 -3
- package/src/spree/address-book-row.tsx +88 -0
- package/src/spree/bulk-price-table.tsx +3 -3
- package/src/spree/claim-resolve-dialog.tsx +1 -1
- package/src/spree/collapsible-card.tsx +2 -2
- package/src/spree/color-picker.tsx +1 -1
- package/src/spree/currency-label.tsx +71 -0
- package/src/spree/data-grid/cells.tsx +2 -2
- package/src/spree/data-grid/data-grid.tsx +2 -2
- package/src/spree/delivery-form-dialog.tsx +1 -1
- package/src/spree/drag-handle.tsx +1 -1
- package/src/spree/editable-note-card.tsx +2 -2
- package/src/spree/form-actions.tsx +32 -10
- package/src/spree/fulfill-items-form.tsx +20 -22
- package/src/spree/fulfillment-edit-dialog.tsx +1 -1
- package/src/spree/fulfillment-item-list.tsx +87 -28
- package/src/spree/fulfillment-panel.tsx +2 -2
- package/src/spree/icons.ts +8 -0
- package/src/spree/json-preview-drawer.tsx +32 -7
- package/src/spree/json-value-view.tsx +14 -4
- package/src/spree/label-upload-dialog.tsx +1 -1
- package/src/spree/language-menu-items.tsx +1 -1
- package/src/spree/metadata/metadata-card.tsx +2 -2
- package/src/spree/order-cancel-dialog.tsx +3 -3
- package/src/spree/post-sale-create-dialogs.tsx +36 -33
- package/src/spree/post-sale-fields.tsx +30 -1
- package/src/spree/product-identity.tsx +74 -0
- package/src/spree/product-membership-list.tsx +79 -41
- package/src/spree/products/status-card.tsx +1 -1
- package/src/spree/quantity-tier-editor.tsx +1 -1
- package/src/spree/return-dialogs.tsx +2 -2
- package/src/spree/search-input.tsx +3 -1
- package/src/spree/secret-input.tsx +1 -1
- package/src/spree/section.tsx +55 -0
- package/src/spree/stat-item.tsx +27 -0
- package/src/spree/tax-identifiers-card.tsx +117 -76
- package/src/spree/theme-toggle.tsx +1 -1
- package/src/spree/wizard.tsx +2 -2
- package/src/styles.css +226 -120
- package/src/ui/alert.tsx +20 -17
- package/src/ui/attachment.tsx +1 -1
- package/src/ui/avatar.tsx +2 -2
- package/src/ui/badge.tsx +1 -1
- package/src/ui/breadcrumb.tsx +1 -1
- package/src/ui/button.tsx +14 -11
- package/src/ui/calendar.tsx +2 -2
- package/src/ui/card.tsx +14 -37
- package/src/ui/chart.tsx +1 -1
- package/src/ui/checkbox.tsx +2 -2
- package/src/ui/code-editor.tsx +6 -2
- package/src/ui/collapsible.tsx +21 -2
- package/src/ui/combobox.tsx +12 -16
- package/src/ui/command.tsx +8 -6
- package/src/ui/context-menu.tsx +1 -1
- package/src/ui/data-table.tsx +58 -15
- package/src/ui/date-picker.tsx +3 -3
- package/src/ui/date-range-panel.tsx +102 -0
- package/src/ui/date-range-picker.tsx +33 -72
- package/src/ui/dialog.tsx +3 -3
- package/src/ui/dropdown-menu.tsx +6 -6
- package/src/ui/empty.tsx +3 -3
- package/src/ui/field.tsx +33 -12
- package/src/ui/input-group.tsx +17 -6
- package/src/ui/input.tsx +148 -4
- package/src/ui/item.tsx +7 -4
- package/src/ui/label.tsx +1 -1
- package/src/ui/media-preview.tsx +40 -5
- package/src/ui/pagination.tsx +16 -4
- package/src/ui/progress.tsx +2 -2
- package/src/ui/radio-group.tsx +2 -2
- package/src/ui/rich-text-editor.tsx +3 -3
- package/src/ui/select.tsx +7 -16
- package/src/ui/sheet.tsx +13 -5
- package/src/ui/sidebar.tsx +5 -5
- package/src/ui/switch.tsx +8 -2
- package/src/ui/tabs.tsx +45 -9
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/toast.tsx +2 -2
- package/src/ui/tooltip.tsx +1 -1
package/src/ui/data-table.tsx
CHANGED
|
@@ -35,16 +35,25 @@ interface TableProps extends React.ComponentProps<'table'> {
|
|
|
35
35
|
*/
|
|
36
36
|
roundedBottom?: boolean
|
|
37
37
|
/**
|
|
38
|
-
* Rounds the header's outer corners, for a table that starts a card
|
|
39
|
-
* header's own square corners otherwise sit on top
|
|
40
|
-
* clip it — the mirror of the `roundedBottom` case,
|
|
41
|
-
* because a table usually has a card header or some
|
|
38
|
+
* Rounds the header's outer corners, for a table that starts a card or any
|
|
39
|
+
* other bordered box. The header's own square corners otherwise sit on top
|
|
40
|
+
* of the card's radius and clip it — the mirror of the `roundedBottom` case,
|
|
41
|
+
* and unset by default because a table usually has a card header or some
|
|
42
|
+
* content above it. It also drops the header's top rule, which would double
|
|
43
|
+
* the box's own edge.
|
|
42
44
|
*
|
|
43
45
|
* Applied from `sm` up, matching where a full-bleed table gains its frame:
|
|
44
46
|
* below that the table spans the page gutter with square edges, and curved
|
|
45
47
|
* header cells there would round nothing.
|
|
46
48
|
*/
|
|
47
49
|
roundedTop?: boolean
|
|
50
|
+
/**
|
|
51
|
+
* Draws the header as a free-standing rounded band, for a table with no
|
|
52
|
+
* card around it — a list page's main table. Rows then sit straight on the
|
|
53
|
+
* page, divided by their own rules; there is no frame for the header to
|
|
54
|
+
* round into, so this replaces `roundedTop`.
|
|
55
|
+
*/
|
|
56
|
+
band?: boolean
|
|
48
57
|
/**
|
|
49
58
|
* Scrolls sideways at every width, not just on narrow viewports. For a table
|
|
50
59
|
* in a bounded container — a card, a sheet, a dialog — where the default
|
|
@@ -83,6 +92,7 @@ function Table({
|
|
|
83
92
|
stickyHeader = false,
|
|
84
93
|
roundedBottom,
|
|
85
94
|
roundedTop,
|
|
95
|
+
band = false,
|
|
86
96
|
scrollX = false,
|
|
87
97
|
...props
|
|
88
98
|
}: TableProps) {
|
|
@@ -148,14 +158,14 @@ function Table({
|
|
|
148
158
|
// below that the table spans the page gutter with square edges, and rounded
|
|
149
159
|
// cells there would round nothing.
|
|
150
160
|
const alwaysRounded =
|
|
151
|
-
'sm:[&_tbody_tr:last-child_td:first-child]:rounded-bl-
|
|
161
|
+
'sm:[&_tbody_tr:last-child_td:first-child]:rounded-bl-[calc(var(--radius-card)-1px)] sm:[&_tbody_tr:last-child_td:last-child]:rounded-br-[calc(var(--radius-card)-1px)] sm:[&_tbody_tr:last-child_td:only-child]:rounded-b-[calc(var(--radius-card)-1px)]'
|
|
152
162
|
// Applied to the outermost wrapper, not the table: `in-[:last-child]` is
|
|
153
163
|
// satisfied by *any* last-child ancestor, and the sticky variant nests two
|
|
154
164
|
// more divs that are both last inside it. Gating on the wrapper the
|
|
155
165
|
// pagination is actually a sibling of is the only test that means "nothing
|
|
156
166
|
// follows this table".
|
|
157
167
|
const roundedWhenLast =
|
|
158
|
-
'last:[&_tbody_tr:last-child_td:first-child]:rounded-bl-
|
|
168
|
+
'last:[&_tbody_tr:last-child_td:first-child]:rounded-bl-[calc(var(--radius-card)-1px)] last:[&_tbody_tr:last-child_td:last-child]:rounded-br-[calc(var(--radius-card)-1px)] last:[&_tbody_tr:last-child_td:only-child]:rounded-b-[calc(var(--radius-card)-1px)]'
|
|
159
169
|
// `true`/`false` decide on the table itself; unset defers to the wrapper's
|
|
160
170
|
// own position among its siblings.
|
|
161
171
|
// `thead` cells carry the header's background, so the radius belongs on the
|
|
@@ -169,9 +179,34 @@ function Table({
|
|
|
169
179
|
// down to their order in the stylesheet — and the rounding did.
|
|
170
180
|
const topRounded =
|
|
171
181
|
roundedTop && !headerPinned
|
|
172
|
-
? 'sm:[&_thead_tr:first-child_th:first-child]:rounded-tl-
|
|
182
|
+
? '[&_thead_th]:shadow-[inset_0_-1px_0_0_var(--border-subtle)] sm:[&_thead_tr:first-child_th:first-child]:rounded-tl-[calc(var(--radius-card)-1px)] sm:[&_thead_tr:first-child_th:last-child]:rounded-tr-[calc(var(--radius-card)-1px)] sm:[&_thead_tr:first-child_th:only-child]:rounded-t-[calc(var(--radius-card)-1px)]'
|
|
173
183
|
: undefined
|
|
174
|
-
|
|
184
|
+
// Both ends of the header band round, top and bottom, and the band drops
|
|
185
|
+
// the rule under it: it floats over the rows rather than capping a frame.
|
|
186
|
+
const bandClasses = band
|
|
187
|
+
? cn(
|
|
188
|
+
'[&_thead_th]:shadow-none [&_thead_tr:first-child_th:first-child]:rounded-s-[10px] [&_thead_tr:first-child_th:last-child]:rounded-e-[10px] [&_thead_tr:first-child_th:only-child]:rounded-[10px]',
|
|
189
|
+
// Each row's fill and divider hang off its first cell — a row's own
|
|
190
|
+
// pseudo-element would become an extra table column — and stretch
|
|
191
|
+
// across the row, which is their positioning box. The divider runs
|
|
192
|
+
// along a row's top and the fill covers the rest; a filled row
|
|
193
|
+
// (hovered, selected, menu open) hides the dividers above and below
|
|
194
|
+
// it, so neighbouring filled rows read as separate rounded shapes
|
|
195
|
+
// split by a 2px gap.
|
|
196
|
+
'[&_tbody_tr]:relative [&_tbody_tr]:isolate [&_tbody_tr:hover]:bg-transparent [&_tbody_td]:border-b-0',
|
|
197
|
+
"[&_tbody_td:first-child]:before:absolute [&_tbody_td:first-child]:before:inset-x-0 [&_tbody_td:first-child]:before:top-0.5 [&_tbody_td:first-child]:before:bottom-0 [&_tbody_td:first-child]:before:-z-10 [&_tbody_td:first-child]:before:rounded-[10px] [&_tbody_td:first-child]:before:content-['']",
|
|
198
|
+
"[&_tbody_tr:not(:first-child)>td:first-child]:after:absolute [&_tbody_tr:not(:first-child)>td:first-child]:after:inset-x-0 [&_tbody_tr:not(:first-child)>td:first-child]:after:top-0 [&_tbody_tr:not(:first-child)>td:first-child]:after:h-px [&_tbody_tr:not(:first-child)>td:first-child]:after:bg-border-subtle [&_tbody_tr:not(:first-child)>td:first-child]:after:content-['']",
|
|
199
|
+
'[&_tbody_tr:hover>td:first-child]:before:bg-muted [&_tbody_tr:has([data-popup-open])>td:first-child]:before:bg-accent-strong/40',
|
|
200
|
+
'[&_tbody_tr[data-selected]>td:first-child]:before:bg-accent-strong/75 [&_tbody_tr[data-selected]:hover>td:first-child]:before:bg-accent-strong-hover',
|
|
201
|
+
'[&_tbody_tr:not(:first-child):is(:hover,[data-selected],:has([data-popup-open]))>td:first-child]:after:bg-transparent [&_tbody_tr:is(:hover,[data-selected],:has([data-popup-open]))+tr:not(:first-child)>td:first-child]:after:bg-transparent',
|
|
202
|
+
// A roomier header band with smaller labels.
|
|
203
|
+
'sm:[&_thead_th]:h-10 sm:[&_thead_th]:py-2.5 [&_thead_th]:font-medium [&_thead_th]:text-xs',
|
|
204
|
+
)
|
|
205
|
+
: undefined
|
|
206
|
+
const tableRoundedClasses = cn(
|
|
207
|
+
roundedBottom === true ? alwaysRounded : undefined,
|
|
208
|
+
band ? bandClasses : topRounded,
|
|
209
|
+
)
|
|
175
210
|
const wrapperRoundedClasses = roundedBottom === undefined ? roundedWhenLast : undefined
|
|
176
211
|
|
|
177
212
|
const kids = Children.toArray(children)
|
|
@@ -430,17 +465,21 @@ function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
|
|
|
430
465
|
}
|
|
431
466
|
|
|
432
467
|
/**
|
|
433
|
-
* The
|
|
434
|
-
* with the bulk action bar, which overlays this row and draws the same
|
|
435
|
-
* Don't add a border alongside
|
|
436
|
-
* the shadow inside, so the two stack into a 2px line.
|
|
468
|
+
* The rules above and below are inset shadows rather than borders so they stay
|
|
469
|
+
* flush with the bulk action bar, which overlays this row and draws the same
|
|
470
|
+
* shadow. Don't add a border alongside them — a border sits outside the padding
|
|
471
|
+
* box and the shadow inside, so the two stack into a 2px line.
|
|
472
|
+
*
|
|
473
|
+
* The top rule separates the band from whatever sits above it in the card. A
|
|
474
|
+
* table that starts its card drops it (`roundedTop`), as the card's own edge is
|
|
475
|
+
* already there, and so does the free-standing `band`.
|
|
437
476
|
*/
|
|
438
477
|
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
|
|
439
478
|
return (
|
|
440
479
|
<th
|
|
441
480
|
className={cn(
|
|
442
|
-
'text-left text-
|
|
443
|
-
'shadow-[inset_0_-1px_0_0_var(--border-subtle)]',
|
|
481
|
+
'text-left text-base font-normal text-muted-foreground bg-muted px-3 py-2.5 h-9 sm:p-2 whitespace-nowrap first:pl-4 last:pr-4',
|
|
482
|
+
'shadow-[inset_0_1px_0_0_var(--border-subtle),inset_0_-1px_0_0_var(--border-subtle)]',
|
|
444
483
|
className,
|
|
445
484
|
)}
|
|
446
485
|
{...props}
|
|
@@ -471,7 +510,11 @@ function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
|
|
|
471
510
|
// `h-16` on touch is a floor, not a fixed height: a table cell treats
|
|
472
511
|
// `height` as a minimum, so a row with a thumbnail or a wrapped
|
|
473
512
|
// second line still grows past it.
|
|
474
|
-
'h-16 py-3 px-3 sm:h-auto sm:py-3 sm:px-2 border-b border-border-subtle align-middle first:pl-4 last:pr-4',
|
|
513
|
+
'h-16 py-3 px-3 sm:h-auto sm:py-3 sm:px-2 border-b border-border-subtle align-middle text-base first:pl-4 last:pr-4',
|
|
514
|
+
// Compact rows (the columns menu's density choice, set on <html>) fit
|
|
515
|
+
// about half as many again on a laptop screen. Desktop only: on touch
|
|
516
|
+
// the row stays a full-size target.
|
|
517
|
+
'sm:in-data-[table-density=compact]:py-1.5 sm:in-data-[table-density=compact]:[&_[data-slot=thumbnail]]:size-7',
|
|
475
518
|
className,
|
|
476
519
|
)}
|
|
477
520
|
{...props}
|
package/src/ui/date-picker.tsx
CHANGED
|
@@ -181,7 +181,7 @@ function DatePicker({
|
|
|
181
181
|
const triggerChildren = (
|
|
182
182
|
<>
|
|
183
183
|
<CalendarIcon className="size-4 shrink-0 text-muted-foreground" />
|
|
184
|
-
<span className="flex-1 truncate">{triggerLabel}</span>
|
|
184
|
+
<span className="flex-1 truncate text-left">{triggerLabel}</span>
|
|
185
185
|
{isValidDate && (
|
|
186
186
|
<span
|
|
187
187
|
role="button"
|
|
@@ -235,7 +235,7 @@ function DatePicker({
|
|
|
235
235
|
<div
|
|
236
236
|
role="dialog"
|
|
237
237
|
aria-label={placeholder}
|
|
238
|
-
className="absolute top-full left-0 z-50 mt-2 w-auto rounded-
|
|
238
|
+
className="absolute top-full left-0 z-50 mt-2 w-auto rounded-xl border border-border bg-popover p-0 text-popover-foreground shadow-md"
|
|
239
239
|
>
|
|
240
240
|
{panelChildren}
|
|
241
241
|
</div>
|
|
@@ -252,7 +252,7 @@ function DatePicker({
|
|
|
252
252
|
type="button"
|
|
253
253
|
variant="outline"
|
|
254
254
|
data-empty={!isValidDate}
|
|
255
|
-
className={className}
|
|
255
|
+
className={cn('justify-start', className)}
|
|
256
256
|
>
|
|
257
257
|
{triggerChildren}
|
|
258
258
|
</Button>
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { addMonths, startOfMonth, subMonths } from 'date-fns'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import type { DateRange as DayPickerDateRange } from 'react-day-picker'
|
|
4
|
+
import { useIsMobile } from '../hooks/use-mobile'
|
|
5
|
+
import { cn } from '../lib/utils'
|
|
6
|
+
import { CheckIcon } from '../spree/icons'
|
|
7
|
+
import { Calendar } from './calendar'
|
|
8
|
+
|
|
9
|
+
export interface DateRangePanelPreset<K extends string = string> {
|
|
10
|
+
key: K
|
|
11
|
+
label: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface DateRangePanelProps<K extends string> {
|
|
15
|
+
presets: DateRangePanelPreset<K>[]
|
|
16
|
+
/** The preset the applied range matches, if any. */
|
|
17
|
+
activePreset?: string | null
|
|
18
|
+
/** Picking a preset applies it straight away; the caller closes the popover. */
|
|
19
|
+
onPresetSelect: (key: K) => void
|
|
20
|
+
/** Seeds the calendar's highlight. */
|
|
21
|
+
initialRange?: DayPickerDateRange
|
|
22
|
+
/** Called once a picked range has both ends; the caller closes the popover. */
|
|
23
|
+
onApply: (range: { from: Date; to: Date }) => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Popover body for picking a date range: named ranges on the left, a range
|
|
28
|
+
* calendar on the right. Mount it inside a popover's content so it reseeds
|
|
29
|
+
* from `initialRange` each time it opens.
|
|
30
|
+
*/
|
|
31
|
+
export function DateRangePanel<K extends string>({
|
|
32
|
+
presets,
|
|
33
|
+
activePreset,
|
|
34
|
+
onPresetSelect,
|
|
35
|
+
initialRange,
|
|
36
|
+
onApply,
|
|
37
|
+
}: DateRangePanelProps<K>) {
|
|
38
|
+
const isMobile = useIsMobile()
|
|
39
|
+
const months = isMobile ? 1 : 2
|
|
40
|
+
const [range, setRange] = useState<DayPickerDateRange | undefined>(initialRange)
|
|
41
|
+
// A click starts a new range and the next one ends and applies it, the way
|
|
42
|
+
// a preset applies on click; the same day twice picks that one day. DayPicker's own
|
|
43
|
+
// selection moves the nearest end of the applied range instead, so picking
|
|
44
|
+
// the 10th and then the 12th over "Last 30 days" kept its start.
|
|
45
|
+
const [pickingEnd, setPickingEnd] = useState(false)
|
|
46
|
+
|
|
47
|
+
function pickDay(day: Date) {
|
|
48
|
+
if (pickingEnd && range?.from) {
|
|
49
|
+
const picked =
|
|
50
|
+
day < range.from ? { from: day, to: range.from } : { from: range.from, to: day }
|
|
51
|
+
setRange(picked)
|
|
52
|
+
setPickingEnd(false)
|
|
53
|
+
onApply(picked)
|
|
54
|
+
} else {
|
|
55
|
+
setRange({ from: day, to: day })
|
|
56
|
+
setPickingEnd(true)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// The last month shown, so a range ending today keeps this month in view
|
|
60
|
+
// whether one month shows or two.
|
|
61
|
+
const [endMonth, setEndMonth] = useState(() =>
|
|
62
|
+
startOfMonth(initialRange?.to ?? initialRange?.from ?? new Date()),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
// Phones get the full popover width: the presets as a list, then one
|
|
67
|
+
// month with finger-sized days. Wider screens put them side by side.
|
|
68
|
+
<div className="flex w-[min(calc(100vw-2rem),24rem)] flex-col md:w-auto md:flex-row">
|
|
69
|
+
<div className="flex flex-col gap-0.5 border-b p-2 md:w-44 md:border-r md:border-b-0">
|
|
70
|
+
{presets.map((preset) => {
|
|
71
|
+
const active = preset.key === activePreset
|
|
72
|
+
return (
|
|
73
|
+
<button
|
|
74
|
+
key={preset.key}
|
|
75
|
+
type="button"
|
|
76
|
+
aria-pressed={active}
|
|
77
|
+
onClick={() => onPresetSelect(preset.key)}
|
|
78
|
+
className={cn(
|
|
79
|
+
'flex items-center justify-between gap-2 whitespace-nowrap rounded-lg px-3 py-2.5 text-left text-base md:px-2.5 md:py-1.5 transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50',
|
|
80
|
+
active ? 'bg-accent font-medium text-foreground' : 'text-muted-foreground',
|
|
81
|
+
)}
|
|
82
|
+
>
|
|
83
|
+
{preset.label}
|
|
84
|
+
{active && <CheckIcon className="size-3.5 shrink-0" />}
|
|
85
|
+
</button>
|
|
86
|
+
)
|
|
87
|
+
})}
|
|
88
|
+
</div>
|
|
89
|
+
<div className="flex flex-col p-3">
|
|
90
|
+
<Calendar
|
|
91
|
+
className="max-md:mx-auto max-md:[&_td>button]:size-10 max-md:[&_th]:w-10"
|
|
92
|
+
mode="range"
|
|
93
|
+
month={subMonths(endMonth, months - 1)}
|
|
94
|
+
onMonthChange={(first) => setEndMonth(addMonths(first, months - 1))}
|
|
95
|
+
selected={range}
|
|
96
|
+
onSelect={(_next, day) => pickDay(day)}
|
|
97
|
+
numberOfMonths={months}
|
|
98
|
+
/>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { format } from 'date-fns'
|
|
2
2
|
import { useMemo, useState } from 'react'
|
|
3
|
-
import type { DateRange as DayPickerDateRange } from 'react-day-picker'
|
|
4
3
|
import { useTranslation } from 'react-i18next'
|
|
5
4
|
import { activeDateLocale } from '../lib/date-locale'
|
|
6
5
|
import {
|
|
@@ -9,10 +8,10 @@ import {
|
|
|
9
8
|
matchDateRangePreset,
|
|
10
9
|
resolveDateRangePreset,
|
|
11
10
|
} from '../lib/date-range-presets'
|
|
11
|
+
import { fieldTriggerClassName } from '../lib/field-trigger'
|
|
12
12
|
import { cn } from '../lib/utils'
|
|
13
|
-
import { CalendarIcon,
|
|
14
|
-
import {
|
|
15
|
-
import { Calendar } from './calendar'
|
|
13
|
+
import { CalendarIcon, ChevronsUpDownIcon } from '../spree/icons'
|
|
14
|
+
import { DateRangePanel } from './date-range-panel'
|
|
16
15
|
import { Popover, PopoverContent, PopoverTrigger } from './popover'
|
|
17
16
|
|
|
18
17
|
export interface DateRange {
|
|
@@ -43,8 +42,6 @@ export interface DateRangePickerProps {
|
|
|
43
42
|
export function DateRangePicker({ value, onChange, timezone }: DateRangePickerProps) {
|
|
44
43
|
const { t } = useTranslation()
|
|
45
44
|
const [open, setOpen] = useState(false)
|
|
46
|
-
const [calendarRange, setCalendarRange] = useState<DayPickerDateRange | undefined>()
|
|
47
|
-
const [month, setMonth] = useState<Date>(value.from)
|
|
48
45
|
|
|
49
46
|
const zone = useMemo(
|
|
50
47
|
() => timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC',
|
|
@@ -62,75 +59,39 @@ export function DateRangePicker({ value, onChange, timezone }: DateRangePickerPr
|
|
|
62
59
|
? `${format(value.from, 'MMM d', { locale: dateLocale })} – ${format(value.to, 'MMM d', { locale: dateLocale })}`
|
|
63
60
|
: t(PRESET_LABEL_KEYS[activePreset])
|
|
64
61
|
|
|
65
|
-
function selectPreset(key: Exclude<DateRangePresetKey, 'custom'>) {
|
|
66
|
-
const range = resolveDateRangePreset(key, zone)
|
|
67
|
-
setCalendarRange({ from: range.from, to: range.to })
|
|
68
|
-
setMonth(range.from)
|
|
69
|
-
onChange(range)
|
|
70
|
-
setOpen(false)
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function applyCustomRange() {
|
|
74
|
-
if (calendarRange?.from && calendarRange?.to) {
|
|
75
|
-
onChange({ from: calendarRange.from, to: calendarRange.to })
|
|
76
|
-
setOpen(false)
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const canApply = calendarRange?.from && calendarRange?.to
|
|
81
|
-
|
|
82
62
|
return (
|
|
83
|
-
<Popover
|
|
84
|
-
open={open}
|
|
85
|
-
onOpenChange={(nextOpen) => {
|
|
86
|
-
setOpen(nextOpen)
|
|
87
|
-
if (nextOpen) {
|
|
88
|
-
setCalendarRange({ from: value.from, to: value.to })
|
|
89
|
-
setMonth(value.from)
|
|
90
|
-
}
|
|
91
|
-
}}
|
|
92
|
-
>
|
|
63
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
93
64
|
<PopoverTrigger asChild>
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
65
|
+
{/* Field-shaped like the select and combobox triggers it sits beside. */}
|
|
66
|
+
<button
|
|
67
|
+
type="button"
|
|
68
|
+
data-size="sm"
|
|
69
|
+
className={cn(fieldTriggerClassName, 'w-auto shrink-0 whitespace-nowrap')}
|
|
70
|
+
>
|
|
71
|
+
<span className="flex items-center gap-2">
|
|
72
|
+
<CalendarIcon className="size-3.5" />
|
|
73
|
+
{triggerLabel}
|
|
74
|
+
</span>
|
|
75
|
+
<ChevronsUpDownIcon className="size-3.5 text-muted-foreground" />
|
|
76
|
+
</button>
|
|
99
77
|
</PopoverTrigger>
|
|
100
|
-
<PopoverContent align="end" className="w-auto p-0">
|
|
101
|
-
<
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
</div>
|
|
118
|
-
<div className="flex flex-col p-3">
|
|
119
|
-
<Calendar
|
|
120
|
-
mode="range"
|
|
121
|
-
month={month}
|
|
122
|
-
onMonthChange={setMonth}
|
|
123
|
-
selected={calendarRange}
|
|
124
|
-
onSelect={setCalendarRange}
|
|
125
|
-
numberOfMonths={2}
|
|
126
|
-
/>
|
|
127
|
-
<div className="flex justify-end border-t pt-3">
|
|
128
|
-
<Button size="sm" disabled={!canApply} onClick={applyCustomRange}>
|
|
129
|
-
{t('admin.actions.apply')}
|
|
130
|
-
</Button>
|
|
131
|
-
</div>
|
|
132
|
-
</div>
|
|
133
|
-
</div>
|
|
78
|
+
<PopoverContent align="end" className="w-auto max-w-[calc(100vw-1rem)] p-0">
|
|
79
|
+
<DateRangePanel
|
|
80
|
+
// Reseeds the calendar whenever the applied range changes, even if
|
|
81
|
+
// the popover reopens before its closing animation unmounts it.
|
|
82
|
+
key={`${value.from.getTime()}:${value.to.getTime()}`}
|
|
83
|
+
presets={DATE_RANGE_PRESET_KEYS.map((key) => ({ key, label: t(PRESET_LABEL_KEYS[key]) }))}
|
|
84
|
+
activePreset={activePreset}
|
|
85
|
+
onPresetSelect={(key) => {
|
|
86
|
+
onChange(resolveDateRangePreset(key, zone))
|
|
87
|
+
setOpen(false)
|
|
88
|
+
}}
|
|
89
|
+
initialRange={{ from: value.from, to: value.to }}
|
|
90
|
+
onApply={(range) => {
|
|
91
|
+
onChange(range)
|
|
92
|
+
setOpen(false)
|
|
93
|
+
}}
|
|
94
|
+
/>
|
|
134
95
|
</PopoverContent>
|
|
135
96
|
</Popover>
|
|
136
97
|
)
|
package/src/ui/dialog.tsx
CHANGED
|
@@ -113,7 +113,7 @@ function DialogContent({
|
|
|
113
113
|
<DialogPrimitive.Popup
|
|
114
114
|
data-slot="dialog-content"
|
|
115
115
|
className={cn(
|
|
116
|
-
'fixed top-1/2 left-1/2 z-50 flex w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-
|
|
116
|
+
'fixed top-1/2 left-1/2 z-50 flex w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-2xl border border-border bg-card text-foreground text-base shadow-lg duration-100 outline-none sm:max-w-[min(500px,calc(100%-2rem))] 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]',
|
|
117
117
|
fullScreen && 'inset-3 w-auto max-w-none translate-x-0 translate-y-0 sm:max-w-none',
|
|
118
118
|
className,
|
|
119
119
|
)}
|
|
@@ -186,7 +186,7 @@ function DialogTitle({ className, ...props }: React.ComponentProps<typeof Dialog
|
|
|
186
186
|
return (
|
|
187
187
|
<DialogPrimitive.Title
|
|
188
188
|
data-slot="dialog-title"
|
|
189
|
-
className={cn('text-lg font-
|
|
189
|
+
className={cn('text-lg font-semibold', className)}
|
|
190
190
|
{...props}
|
|
191
191
|
/>
|
|
192
192
|
)
|
|
@@ -200,7 +200,7 @@ function DialogDescription({
|
|
|
200
200
|
<DialogPrimitive.Description
|
|
201
201
|
data-slot="dialog-description"
|
|
202
202
|
className={cn(
|
|
203
|
-
'text-
|
|
203
|
+
'text-base text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground',
|
|
204
204
|
className,
|
|
205
205
|
)}
|
|
206
206
|
{...props}
|
package/src/ui/dropdown-menu.tsx
CHANGED
|
@@ -119,7 +119,7 @@ function DropdownMenuItem({
|
|
|
119
119
|
data-variant={variant}
|
|
120
120
|
render={children}
|
|
121
121
|
className={cn(
|
|
122
|
-
"group/dropdown-menu-item relative flex cursor-pointer items-center gap-3 rounded-
|
|
122
|
+
"group/dropdown-menu-item relative flex cursor-pointer items-center gap-3 rounded-md px-2 py-1.5 text-base 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",
|
|
123
123
|
className,
|
|
124
124
|
)}
|
|
125
125
|
{...props}
|
|
@@ -133,7 +133,7 @@ function DropdownMenuItem({
|
|
|
133
133
|
data-inset={inset}
|
|
134
134
|
data-variant={variant}
|
|
135
135
|
className={cn(
|
|
136
|
-
"group/dropdown-menu-item relative flex cursor-pointer items-center gap-3 rounded-
|
|
136
|
+
"group/dropdown-menu-item relative flex cursor-pointer items-center gap-3 rounded-md px-2 py-1.5 text-base 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",
|
|
137
137
|
className,
|
|
138
138
|
)}
|
|
139
139
|
{...props}
|
|
@@ -160,7 +160,7 @@ function DropdownMenuCheckboxItem({
|
|
|
160
160
|
data-slot="dropdown-menu-checkbox-item"
|
|
161
161
|
data-inset={inset}
|
|
162
162
|
className={cn(
|
|
163
|
-
"group/checkbox-item relative flex cursor-pointer items-center gap-2.5 rounded-
|
|
163
|
+
"group/checkbox-item relative flex cursor-pointer items-center gap-2.5 rounded-md py-1.5 pr-2.5 pl-2.5 text-base outline-hidden select-none transition-colors duration-75 focus:bg-accent focus: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",
|
|
164
164
|
className,
|
|
165
165
|
)}
|
|
166
166
|
checked={checked}
|
|
@@ -203,7 +203,7 @@ function DropdownMenuRadioItem({
|
|
|
203
203
|
data-slot="dropdown-menu-radio-item"
|
|
204
204
|
data-inset={inset}
|
|
205
205
|
className={cn(
|
|
206
|
-
"relative flex cursor-pointer items-center gap-3 rounded-
|
|
206
|
+
"relative flex cursor-pointer items-center gap-3 rounded-md py-1.5 pr-8 pl-2.5 text-base outline-hidden select-none transition-colors duration-75 focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
207
207
|
className,
|
|
208
208
|
)}
|
|
209
209
|
{...props}
|
|
@@ -234,7 +234,7 @@ function DropdownMenuLabel({
|
|
|
234
234
|
data-slot="dropdown-menu-label"
|
|
235
235
|
data-inset={inset}
|
|
236
236
|
className={cn(
|
|
237
|
-
'px-2 py-1.5 text-
|
|
237
|
+
'px-2 py-1.5 text-base font-normal text-muted-foreground data-inset:pl-7',
|
|
238
238
|
className,
|
|
239
239
|
)}
|
|
240
240
|
{...props}
|
|
@@ -298,7 +298,7 @@ function DropdownMenuSubTrigger({
|
|
|
298
298
|
data-slot="dropdown-menu-sub-trigger"
|
|
299
299
|
data-inset={inset}
|
|
300
300
|
className={cn(
|
|
301
|
-
"flex cursor-pointer items-center gap-3 rounded-
|
|
301
|
+
"flex cursor-pointer items-center gap-3 rounded-md px-2 py-1.5 text-base outline-hidden select-none transition-colors duration-75 focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[popup-open]:bg-accent data-[popup-open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
302
302
|
className,
|
|
303
303
|
)}
|
|
304
304
|
{...props}
|
package/src/ui/empty.tsx
CHANGED
|
@@ -59,7 +59,7 @@ function EmptyTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
|
|
59
59
|
return (
|
|
60
60
|
<div
|
|
61
61
|
data-slot="empty-title"
|
|
62
|
-
className={cn('cn-font-heading text-
|
|
62
|
+
className={cn('cn-font-heading text-base font-medium tracking-tight', className)}
|
|
63
63
|
{...props}
|
|
64
64
|
/>
|
|
65
65
|
)
|
|
@@ -70,7 +70,7 @@ function EmptyDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
|
|
70
70
|
<div
|
|
71
71
|
data-slot="empty-description"
|
|
72
72
|
className={cn(
|
|
73
|
-
'text-
|
|
73
|
+
'text-base/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary',
|
|
74
74
|
className,
|
|
75
75
|
)}
|
|
76
76
|
{...props}
|
|
@@ -83,7 +83,7 @@ function EmptyContent({ className, ...props }: React.ComponentProps<'div'>) {
|
|
|
83
83
|
<div
|
|
84
84
|
data-slot="empty-content"
|
|
85
85
|
className={cn(
|
|
86
|
-
'flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-
|
|
86
|
+
'flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-base text-balance',
|
|
87
87
|
className,
|
|
88
88
|
)}
|
|
89
89
|
{...props}
|
package/src/ui/field.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { cva, type VariantProps } from 'class-variance-authority'
|
|
2
2
|
import { useMemo } from 'react'
|
|
3
|
+
import { useTranslation } from 'react-i18next'
|
|
3
4
|
import { cn } from '../lib/utils'
|
|
4
5
|
import { Label } from './label'
|
|
5
6
|
import { Separator } from './separator'
|
|
@@ -26,10 +27,7 @@ function FieldLegend({
|
|
|
26
27
|
<legend
|
|
27
28
|
data-slot="field-legend"
|
|
28
29
|
data-variant={variant}
|
|
29
|
-
className={cn(
|
|
30
|
-
'mb-1.5 font-semibold data-[variant=label]:text-sm data-[variant=legend]:text-base',
|
|
31
|
-
className,
|
|
32
|
-
)}
|
|
30
|
+
className={cn('mb-1.5 font-semibold text-base', className)}
|
|
33
31
|
{...props}
|
|
34
32
|
/>
|
|
35
33
|
)
|
|
@@ -83,18 +81,36 @@ function FieldContent({ className, ...props }: React.ComponentProps<'div'>) {
|
|
|
83
81
|
return (
|
|
84
82
|
<div
|
|
85
83
|
data-slot="field-content"
|
|
86
|
-
className={cn('group/field-content flex flex-1 flex-col gap-0.5
|
|
84
|
+
className={cn('group/field-content flex flex-1 flex-col gap-0.5', className)}
|
|
87
85
|
{...props}
|
|
88
86
|
/>
|
|
89
87
|
)
|
|
90
88
|
}
|
|
91
89
|
|
|
92
|
-
|
|
90
|
+
/**
|
|
91
|
+
* `optional` marks a field the form can be saved without. Forms mark the
|
|
92
|
+
* optional fields rather than the required ones: most fields are required,
|
|
93
|
+
* so the few that are not are the ones worth pointing out.
|
|
94
|
+
*/
|
|
95
|
+
function FieldLabel({
|
|
96
|
+
className,
|
|
97
|
+
optional = false,
|
|
98
|
+
children,
|
|
99
|
+
...props
|
|
100
|
+
}: React.ComponentProps<typeof Label> & { optional?: boolean }) {
|
|
101
|
+
const { t } = useTranslation()
|
|
102
|
+
|
|
93
103
|
return (
|
|
94
104
|
<Label
|
|
95
105
|
data-slot="field-label"
|
|
96
106
|
className={cn(
|
|
97
|
-
'group/field-label peer/field-label flex w-fit gap-2
|
|
107
|
+
'group/field-label peer/field-label flex w-fit gap-2 group-data-[disabled=true]/field:opacity-50',
|
|
108
|
+
// A label wrapping a whole `Field` is a choice card: a card's corner,
|
|
109
|
+
// edge, fill, lift and inset. Checked, it takes a blue edge and tint;
|
|
110
|
+
// those rules repeat the card-label condition so they outrank the
|
|
111
|
+
// edge above rather than racing it on stylesheet order.
|
|
112
|
+
'has-[>[data-slot=field]]:rounded-card has-[>[data-slot=field]]:border has-[>[data-slot=field]]:border-card-edge has-[>[data-slot=field]]:bg-card has-[>[data-slot=field]]:shadow-card-lift *:data-[slot=field]:p-3.5',
|
|
113
|
+
'has-[>[data-slot=field]]:has-data-checked:border-blue-500 has-[>[data-slot=field]]:has-data-checked:bg-blue-50/70 dark:has-[>[data-slot=field]]:has-data-checked:bg-blue-500/10',
|
|
98
114
|
// Card-shaped labels (the ones wrapping a whole `Field`) are clickable
|
|
99
115
|
// targets, so they need the affordance a bare text label doesn't:
|
|
100
116
|
// a pointer and a hover tint. Scoped by `has-[>[data-slot=field]]` so
|
|
@@ -107,7 +123,12 @@ function FieldLabel({ className, ...props }: React.ComponentProps<typeof Label>)
|
|
|
107
123
|
className,
|
|
108
124
|
)}
|
|
109
125
|
{...props}
|
|
110
|
-
|
|
126
|
+
>
|
|
127
|
+
{children}
|
|
128
|
+
{optional && (
|
|
129
|
+
<span className="font-normal text-muted-foreground">{t('admin.common.optional')}</span>
|
|
130
|
+
)}
|
|
131
|
+
</Label>
|
|
111
132
|
)
|
|
112
133
|
}
|
|
113
134
|
|
|
@@ -116,7 +137,7 @@ function FieldTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
|
|
116
137
|
<div
|
|
117
138
|
data-slot="field-label"
|
|
118
139
|
className={cn(
|
|
119
|
-
'flex w-fit items-center gap-2 text-
|
|
140
|
+
'flex w-fit items-center gap-2 text-base font-medium group-data-[disabled=true]/field:opacity-50',
|
|
120
141
|
className,
|
|
121
142
|
)}
|
|
122
143
|
{...props}
|
|
@@ -129,7 +150,7 @@ function FieldDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
|
|
129
150
|
<p
|
|
130
151
|
data-slot="field-description"
|
|
131
152
|
className={cn(
|
|
132
|
-
'text-left text-
|
|
153
|
+
'text-left text-base font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5',
|
|
133
154
|
'last:mt-0 nth-last-2:-mt-1',
|
|
134
155
|
'[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary',
|
|
135
156
|
className,
|
|
@@ -151,7 +172,7 @@ function FieldSeparator({
|
|
|
151
172
|
data-slot="field-separator"
|
|
152
173
|
data-content={!!children}
|
|
153
174
|
className={cn(
|
|
154
|
-
'relative -my-2 h-5 text-
|
|
175
|
+
'relative -my-2 h-5 text-base group-data-[variant=outline]/field-group:-mb-2',
|
|
155
176
|
className,
|
|
156
177
|
)}
|
|
157
178
|
{...props}
|
|
@@ -218,7 +239,7 @@ function FieldError({
|
|
|
218
239
|
<div
|
|
219
240
|
role="alert"
|
|
220
241
|
data-slot="field-error"
|
|
221
|
-
className={cn('text-
|
|
242
|
+
className={cn('text-base font-normal text-destructive', className)}
|
|
222
243
|
{...props}
|
|
223
244
|
>
|
|
224
245
|
{content}
|