@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/data-table.tsx
CHANGED
|
@@ -1,14 +1,417 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
cloneElement,
|
|
4
|
+
isValidElement,
|
|
5
|
+
type ReactElement,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
useEffect,
|
|
8
|
+
useLayoutEffect,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
} from 'react'
|
|
2
12
|
import { cn } from '../lib/utils'
|
|
3
13
|
|
|
4
|
-
|
|
14
|
+
interface TableProps extends React.ComponentProps<'table'> {
|
|
15
|
+
/**
|
|
16
|
+
* Pins the header below the app bar while the page scrolls, and lets the
|
|
17
|
+
* table scroll horizontally so many columns keep readable widths instead of
|
|
18
|
+
* squeezing until cells wrap. Opt-in: it assumes the table is the page's
|
|
19
|
+
* main content — inside cards, sheets or dialogs, keep the default.
|
|
20
|
+
*/
|
|
21
|
+
stickyHeader?: boolean
|
|
22
|
+
/**
|
|
23
|
+
* Rounds the last row's outer corners.
|
|
24
|
+
*
|
|
25
|
+
* Left unset this resolves itself: the corners round only when the table's
|
|
26
|
+
* wrapper is the last element in its container, which is exactly when the
|
|
27
|
+
* row's edge is the card's edge. Pass `false` to suppress it, or `true` to
|
|
28
|
+
* force it where the container's own markup hides that (a table wrapped in
|
|
29
|
+
* an extra element that is not itself last).
|
|
30
|
+
*
|
|
31
|
+
* The rule matters in both directions, and callers had it wrong both ways: a
|
|
32
|
+
* table with a pagination footer under it drew a curve floating mid-surface,
|
|
33
|
+
* and one that ended the card left a square hover overflowing the card's
|
|
34
|
+
* own radius.
|
|
35
|
+
*/
|
|
36
|
+
roundedBottom?: boolean
|
|
37
|
+
/**
|
|
38
|
+
* Rounds the header's outer corners, for a table that starts a card. The
|
|
39
|
+
* header's own square corners otherwise sit on top of the card's radius and
|
|
40
|
+
* clip it — the mirror of the `roundedBottom` case, and unset by default
|
|
41
|
+
* because a table usually has a card header or some content above it.
|
|
42
|
+
*
|
|
43
|
+
* Applied from `sm` up, matching where a full-bleed table gains its frame:
|
|
44
|
+
* below that the table spans the page gutter with square edges, and curved
|
|
45
|
+
* header cells there would round nothing.
|
|
46
|
+
*/
|
|
47
|
+
roundedTop?: boolean
|
|
48
|
+
/**
|
|
49
|
+
* Scrolls sideways at every width, not just on narrow viewports. For a table
|
|
50
|
+
* in a bounded container — a card, a sheet, a dialog — where the default
|
|
51
|
+
* `clip` puts the last columns out of reach with no way to get at them: the
|
|
52
|
+
* container is narrow while the viewport is not, so the breakpoint never
|
|
53
|
+
* fires. Not for a page's main table, where `overflow-x: auto` computing
|
|
54
|
+
* `overflow-y` to `auto` would trap a sticky header.
|
|
55
|
+
*/
|
|
56
|
+
scrollX?: boolean
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The sticky variant renders two tables. CSS can't deliver this with one:
|
|
61
|
+
* `overflow-x` on a wrapper computes `overflow-y` to `auto` as well, so a
|
|
62
|
+
* sticky header inside the horizontal scroller can only pin to that box —
|
|
63
|
+
* which would mean capping its height and scrolling the body inside the card.
|
|
64
|
+
*
|
|
65
|
+
* So the header is rendered twice from the same React element:
|
|
66
|
+
*
|
|
67
|
+
* - A pinned table above the scroller carries the *interactive* header. It is
|
|
68
|
+
* live React — sort controls, the select-all checkbox and the bulk actions
|
|
69
|
+
* bar all work — pinned via a zero-height sticky wrapper so it overlays the
|
|
70
|
+
* space the body's header row reserves rather than stacking above it.
|
|
71
|
+
* - The body table keeps an `invisible` copy as a sizer: it reserves that row,
|
|
72
|
+
* drives natural column widths, and being `visibility: hidden` is out of the
|
|
73
|
+
* click path, the tab order and the accessibility tree.
|
|
74
|
+
*
|
|
75
|
+
* A layout-effect measurement copies the sizer's column widths onto the pinned
|
|
76
|
+
* header's cells (same content → same natural widths, so this only nails down
|
|
77
|
+
* rounding), and a scroll listener mirrors the body's `scrollLeft` onto the
|
|
78
|
+
* pinned table so the header tracks horizontal scrolling.
|
|
79
|
+
*/
|
|
80
|
+
function Table({
|
|
81
|
+
className,
|
|
82
|
+
children,
|
|
83
|
+
stickyHeader = false,
|
|
84
|
+
roundedBottom,
|
|
85
|
+
roundedTop,
|
|
86
|
+
scrollX = false,
|
|
87
|
+
...props
|
|
88
|
+
}: TableProps) {
|
|
89
|
+
const scrollRef = useRef<HTMLDivElement | null>(null)
|
|
90
|
+
const bodyTableRef = useRef<HTMLTableElement | null>(null)
|
|
91
|
+
const pinnedTableRef = useRef<HTMLTableElement | null>(null)
|
|
92
|
+
const wrapperRef = useRef<HTMLDivElement | null>(null)
|
|
93
|
+
const [headerPinned, setHeaderPinned] = useState(false)
|
|
94
|
+
// Horizontal overflow, and whether the last column is already in view. The
|
|
95
|
+
// first decides whether the sticky scrollbar is worth showing at all; the
|
|
96
|
+
// second fades the right edge only while something is still hidden there.
|
|
97
|
+
const [overflowsX, setOverflowsX] = useState(false)
|
|
98
|
+
const [atRightEdge, setAtRightEdge] = useState(false)
|
|
99
|
+
// The proxy bar's inner width, so its thumb is the same size as the real
|
|
100
|
+
// scroller's would be.
|
|
101
|
+
const [tableWidth, setTableWidth] = useState(0)
|
|
102
|
+
const stickyBarRef = useRef<HTMLDivElement | null>(null)
|
|
103
|
+
|
|
104
|
+
// The header row is pinned once the wrapper's top has passed the band the row
|
|
105
|
+
// sticks to. Measured from the element's own rect on scroll rather than with
|
|
106
|
+
// an IntersectionObserver: the wrapper sits inside a clipped, internally
|
|
107
|
+
// scrolled sheet, where a zero-height sentinel never registers.
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (!roundedTop) return
|
|
110
|
+
const wrapper = wrapperRef.current
|
|
111
|
+
if (!wrapper) return
|
|
112
|
+
|
|
113
|
+
// Whatever chrome sits above the scroll area publishes the band the row
|
|
114
|
+
// pins below — a page header, or nothing at all.
|
|
115
|
+
const stickyOffset = () =>
|
|
116
|
+
Number.parseFloat(
|
|
117
|
+
getComputedStyle(document.documentElement).getPropertyValue('--spacing-header-height'),
|
|
118
|
+
) || 0
|
|
119
|
+
|
|
120
|
+
const update = () => setHeaderPinned(wrapper.getBoundingClientRect().top < stickyOffset())
|
|
121
|
+
update()
|
|
122
|
+
|
|
123
|
+
// Capture phase on the document: the scroll happens on some ancestor and
|
|
124
|
+
// scroll events do not bubble, so a listener on the element itself would
|
|
125
|
+
// never fire.
|
|
126
|
+
document.addEventListener('scroll', update, { capture: true, passive: true })
|
|
127
|
+
window.addEventListener('resize', update)
|
|
128
|
+
return () => {
|
|
129
|
+
document.removeEventListener('scroll', update, { capture: true })
|
|
130
|
+
window.removeEventListener('resize', update)
|
|
131
|
+
}
|
|
132
|
+
}, [roundedTop])
|
|
133
|
+
|
|
134
|
+
// Applied on the table so it reaches the last row's edge cells without
|
|
135
|
+
// every TableCell paying for the selector.
|
|
136
|
+
// `td:only-child` covers the empty and skeleton rows, whose single spanning
|
|
137
|
+
// cell is both first and last child — matching on those alone rounds one
|
|
138
|
+
// corner and leaves the other square against the card's curve.
|
|
139
|
+
//
|
|
140
|
+
// `in-[&:last-child]` gates the whole set on the table's wrapper being the
|
|
141
|
+
// last element in its container: with a pagination footer under it the
|
|
142
|
+
// wrapper is not last, so the corners stay square on their own. Explicitly
|
|
143
|
+
// passing the prop opts out of that test in either direction.
|
|
144
|
+
// Both variants are written out in full rather than composed at runtime:
|
|
145
|
+
// Tailwind scans this file as text, so a class it never sees spelled out is
|
|
146
|
+
// a class it never generates.
|
|
147
|
+
// `sm:`, matching `roundedTop` and where a full-bleed table gains its frame:
|
|
148
|
+
// below that the table spans the page gutter with square edges, and rounded
|
|
149
|
+
// cells there would round nothing.
|
|
150
|
+
const alwaysRounded =
|
|
151
|
+
'sm:[&_tbody_tr:last-child_td:first-child]:rounded-bl-xl sm:[&_tbody_tr:last-child_td:last-child]:rounded-br-xl sm:[&_tbody_tr:last-child_td:only-child]:rounded-b-xl'
|
|
152
|
+
// Applied to the outermost wrapper, not the table: `in-[:last-child]` is
|
|
153
|
+
// satisfied by *any* last-child ancestor, and the sticky variant nests two
|
|
154
|
+
// more divs that are both last inside it. Gating on the wrapper the
|
|
155
|
+
// pagination is actually a sibling of is the only test that means "nothing
|
|
156
|
+
// follows this table".
|
|
157
|
+
const roundedWhenLast =
|
|
158
|
+
'last:[&_tbody_tr:last-child_td:first-child]:rounded-bl-xl last:[&_tbody_tr:last-child_td:last-child]:rounded-br-xl last:[&_tbody_tr:last-child_td:only-child]:rounded-b-xl'
|
|
159
|
+
// `true`/`false` decide on the table itself; unset defers to the wrapper's
|
|
160
|
+
// own position among its siblings.
|
|
161
|
+
// `thead` cells carry the header's background, so the radius belongs on the
|
|
162
|
+
// cells rather than the row — a rounded row still shows square cell corners.
|
|
163
|
+
// The header row wears the frame's radius only while it is actually sitting
|
|
164
|
+
// in that corner. `data-header-pinned` on the wrapper (driven by the
|
|
165
|
+
// sentinel below) squares it off the moment the row pins, where a curve
|
|
166
|
+
// would read as a notch cut out of the rows.
|
|
167
|
+
// Dropped outright while pinned rather than overridden by a `rounded-none`
|
|
168
|
+
// class: the two utilities have identical specificity, so which one won came
|
|
169
|
+
// down to their order in the stylesheet — and the rounding did.
|
|
170
|
+
const topRounded =
|
|
171
|
+
roundedTop && !headerPinned
|
|
172
|
+
? 'sm:[&_thead_tr:first-child_th:first-child]:rounded-tl-xl sm:[&_thead_tr:first-child_th:last-child]:rounded-tr-xl sm:[&_thead_tr:first-child_th:only-child]:rounded-t-xl'
|
|
173
|
+
: undefined
|
|
174
|
+
const tableRoundedClasses = cn(roundedBottom === true ? alwaysRounded : undefined, topRounded)
|
|
175
|
+
const wrapperRoundedClasses = roundedBottom === undefined ? roundedWhenLast : undefined
|
|
176
|
+
|
|
177
|
+
const kids = Children.toArray(children)
|
|
178
|
+
const headerElement = kids.find(
|
|
179
|
+
(kid): kid is ReactElement<React.ComponentProps<'thead'>> =>
|
|
180
|
+
isValidElement(kid) && kid.type === TableHeader,
|
|
181
|
+
)
|
|
182
|
+
const pinned = stickyHeader && headerElement != null
|
|
183
|
+
|
|
184
|
+
// Copy the sizer's column widths onto the pinned header on every commit —
|
|
185
|
+
// column toggles, data loads and label changes all land here. Written to the
|
|
186
|
+
// DOM directly rather than through state: a state round-trip re-renders,
|
|
187
|
+
// re-measures, and on tables whose widths don't settle to the exact same
|
|
188
|
+
// fraction each pass (the drag-reorder variant), that loop never terminates.
|
|
189
|
+
// DOM writes can't re-enter React, so oscillation is impossible by
|
|
190
|
+
// construction.
|
|
191
|
+
useLayoutEffect(() => {
|
|
192
|
+
if (pinned) syncColumnWidths(bodyTableRef.current, pinnedTableRef.current)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
// Viewport-driven resizes don't pass through React, so track them directly.
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
if (!pinned) return
|
|
198
|
+
const table = bodyTableRef.current
|
|
199
|
+
if (!table) return
|
|
200
|
+
const observer = new ResizeObserver(() => syncColumnWidths(table, pinnedTableRef.current))
|
|
201
|
+
observer.observe(table)
|
|
202
|
+
return () => observer.disconnect()
|
|
203
|
+
}, [pinned])
|
|
204
|
+
|
|
205
|
+
// Track horizontal overflow so the edge fade and the sticky scrollbar only
|
|
206
|
+
// appear when the table actually has columns out of view — a table that fits
|
|
207
|
+
// gets neither, and pays nothing for them.
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
if (!pinned) return
|
|
210
|
+
const scroller = scrollRef.current
|
|
211
|
+
const table = bodyTableRef.current
|
|
212
|
+
if (!scroller || !table) return
|
|
213
|
+
const measure = () => {
|
|
214
|
+
const max = scroller.scrollWidth - scroller.clientWidth
|
|
215
|
+
setOverflowsX(max > 1)
|
|
216
|
+
setAtRightEdge(scroller.scrollLeft >= max - 1)
|
|
217
|
+
setTableWidth(scroller.scrollWidth)
|
|
218
|
+
// Keep the proxy in step when the table is scrolled by any other means —
|
|
219
|
+
// a wheel, a keyboard, focusing a cell off-screen.
|
|
220
|
+
const bar = stickyBarRef.current
|
|
221
|
+
if (bar && bar.scrollLeft !== scroller.scrollLeft) bar.scrollLeft = scroller.scrollLeft
|
|
222
|
+
}
|
|
223
|
+
// Not measured inline: this effect runs in the frame `pinned` flips, when
|
|
224
|
+
// the scroller has only just been mounted and its `scrollWidth` still
|
|
225
|
+
// reads as its `clientWidth`. Measuring then records no overflow, and on a
|
|
226
|
+
// table whose columns never change size afterwards nothing would correct
|
|
227
|
+
// it. A frame later the layout is real.
|
|
228
|
+
const firstMeasure = requestAnimationFrame(measure)
|
|
229
|
+
scroller.addEventListener('scroll', measure, { passive: true })
|
|
230
|
+
// Both boxes: the columns can grow and the scroller can shrink, and either
|
|
231
|
+
// crosses the threshold on its own.
|
|
232
|
+
const observer = new ResizeObserver(measure)
|
|
233
|
+
observer.observe(scroller)
|
|
234
|
+
observer.observe(table)
|
|
235
|
+
return () => {
|
|
236
|
+
cancelAnimationFrame(firstMeasure)
|
|
237
|
+
scroller.removeEventListener('scroll', measure)
|
|
238
|
+
observer.disconnect()
|
|
239
|
+
}
|
|
240
|
+
}, [pinned])
|
|
241
|
+
|
|
242
|
+
// Mirror the body's horizontal scroll onto the pinned header. Imperative —
|
|
243
|
+
// going through state would re-render the whole table every scroll frame.
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
if (!pinned) return
|
|
246
|
+
const scroller = scrollRef.current
|
|
247
|
+
if (!scroller) return
|
|
248
|
+
const sync = () => {
|
|
249
|
+
const pinnedTable = pinnedTableRef.current
|
|
250
|
+
if (pinnedTable) pinnedTable.style.transform = `translate3d(${-scroller.scrollLeft}px,0,0)`
|
|
251
|
+
}
|
|
252
|
+
sync()
|
|
253
|
+
scroller.addEventListener('scroll', sync, { passive: true })
|
|
254
|
+
return () => scroller.removeEventListener('scroll', sync)
|
|
255
|
+
}, [pinned])
|
|
256
|
+
|
|
257
|
+
if (!pinned) {
|
|
258
|
+
return (
|
|
259
|
+
// `overflow-x: auto` computes `overflow-y` to `auto` as well, which makes
|
|
260
|
+
// this a scroll container and traps a sticky <thead> inside it — the
|
|
261
|
+
// header would scroll away with the page. `clip` leaves `overflow-y:
|
|
262
|
+
// visible`, so sticky resolves against the page instead. Narrow viewports
|
|
263
|
+
// keep `auto`, where scrolling a wide table sideways matters more than a
|
|
264
|
+
// sticky header.
|
|
265
|
+
<div
|
|
266
|
+
className={cn(
|
|
267
|
+
'@container/table-scroll overflow-x-auto',
|
|
268
|
+
// The sticky variant's scroller is themed; a scrollbar this one
|
|
269
|
+
// actually shows should match it.
|
|
270
|
+
scrollX ? 'themed-scrollbar' : 'md:overflow-x-clip',
|
|
271
|
+
wrapperRoundedClasses,
|
|
272
|
+
)}
|
|
273
|
+
>
|
|
274
|
+
<table
|
|
275
|
+
className={cn('w-full align-top text-foreground', tableRoundedClasses, className)}
|
|
276
|
+
{...props}
|
|
277
|
+
>
|
|
278
|
+
{children}
|
|
279
|
+
</table>
|
|
280
|
+
</div>
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// `w-max` + `min-w-full`: size to the content, but never narrower than the
|
|
285
|
+
// card, so a table with few columns still fills the width.
|
|
286
|
+
const tableClasses = cn(
|
|
287
|
+
'w-max min-w-full align-top text-foreground',
|
|
288
|
+
tableRoundedClasses,
|
|
289
|
+
className,
|
|
290
|
+
)
|
|
291
|
+
|
|
5
292
|
return (
|
|
6
|
-
<div className=
|
|
7
|
-
|
|
293
|
+
<div ref={wrapperRef} className={cn('relative min-w-0', wrapperRoundedClasses)}>
|
|
294
|
+
{/* Zero-height sticky wrapper: the pinned header overlays the sizer row
|
|
295
|
+
below instead of occupying its own band. The inner div clips the
|
|
296
|
+
horizontal overhang the translateX mirror produces. */}
|
|
297
|
+
<div className="sticky top-header-height z-20 h-0">
|
|
298
|
+
<div className="overflow-hidden">
|
|
299
|
+
{/* `presentation`: this table exists to paint a header that stays put
|
|
300
|
+
and to host its controls. Announcing it as a second table — one
|
|
301
|
+
with column headers but no rows — would just duplicate the header
|
|
302
|
+
names the real table below already provides. The controls inside
|
|
303
|
+
keep their own roles and stay reachable. */}
|
|
304
|
+
<table
|
|
305
|
+
ref={pinnedTableRef}
|
|
306
|
+
data-pinned-header
|
|
307
|
+
className={tableClasses}
|
|
308
|
+
role="presentation"
|
|
309
|
+
>
|
|
310
|
+
{headerElement}
|
|
311
|
+
</table>
|
|
312
|
+
</div>
|
|
313
|
+
</div>
|
|
314
|
+
{/* `min-w-0`: without it this scroller keeps its default
|
|
315
|
+
`min-width: auto`, refuses to shrink below the table's intrinsic
|
|
316
|
+
width, and the overflow escapes to the document instead of scrolling
|
|
317
|
+
here — the whole page then lays out wider than the viewport. */}
|
|
318
|
+
<div
|
|
319
|
+
ref={scrollRef}
|
|
320
|
+
// `@container/table-scroll`: the empty-state row sizes itself to this
|
|
321
|
+
// element's width rather than the viewport's.
|
|
322
|
+
//
|
|
323
|
+
// `overflow-x: auto`, always. It computes `overflow-y` to `auto` as
|
|
324
|
+
// well, which makes this a scroll container that cannot scroll
|
|
325
|
+
// vertically — so Page Up/Down land here and do nothing while the
|
|
326
|
+
// pointer is over the table. `overflow-y: hidden` below takes that back
|
|
327
|
+
// without costing the horizontal scrollbar: reaching the last column is
|
|
328
|
+
// not negotiable, and `clip` loses it or squeezes the columns to fit.
|
|
329
|
+
className="@container/table-scroll themed-scrollbar min-w-0 overflow-x-auto overflow-y-hidden"
|
|
330
|
+
>
|
|
331
|
+
<table ref={bodyTableRef} className={tableClasses} {...props}>
|
|
332
|
+
{cloneElement(headerElement, {
|
|
333
|
+
// The sizer: reserves the header row and drives column widths. It
|
|
334
|
+
// stays in the accessibility tree, because this is the table that
|
|
335
|
+
// holds the data rows — hiding it outright would leave every cell
|
|
336
|
+
// with no column header to resolve against. So hide what it draws
|
|
337
|
+
// rather than the row itself, one concern per utility:
|
|
338
|
+
// `[&_th]:text-transparent` — the labels, which are bare text on
|
|
339
|
+
// the cells, so this has to out-specify the colour `TableHead`
|
|
340
|
+
// sets there.
|
|
341
|
+
// `[&_th>*]:invisible` — any control in a cell, which also drops
|
|
342
|
+
// it from hit-testing and the tab order.
|
|
343
|
+
// `[&>tr]:!static` — the sticky positioning the header row
|
|
344
|
+
// carries for the plain table, which here would lift this row
|
|
345
|
+
// out of flow and let the first data row slide up under the
|
|
346
|
+
// pinned copy.
|
|
347
|
+
className: cn(
|
|
348
|
+
headerElement.props.className,
|
|
349
|
+
'select-none [&>tr]:!static [&_th]:text-transparent [&_th>*]:invisible',
|
|
350
|
+
),
|
|
351
|
+
})}
|
|
352
|
+
{kids.filter((kid) => kid !== headerElement)}
|
|
353
|
+
</table>
|
|
354
|
+
</div>
|
|
355
|
+
|
|
356
|
+
{/* Two halves of one answer to "there is more table to the right".
|
|
357
|
+
The fade says SO, without adding chrome: it sits over the right edge,
|
|
358
|
+
is purely decorative, and disappears the moment the last column is
|
|
359
|
+
reached. The bar below is how you GET there — a table's own scrollbar
|
|
360
|
+
sits at its bottom, which on a long list is far below the fold exactly
|
|
361
|
+
when a reader needs to know the table scrolls at all. */}
|
|
362
|
+
{overflowsX && (
|
|
363
|
+
<div
|
|
364
|
+
aria-hidden
|
|
365
|
+
className={cn(
|
|
366
|
+
'pointer-events-none absolute top-0 bottom-0 right-0 w-12 transition-opacity duration-200 ease-out',
|
|
367
|
+
// Ends where the proxy bar begins, so the gradient does not wash
|
|
368
|
+
// over the bar's track and leave it half-visible at the right.
|
|
369
|
+
'mb-3 bg-gradient-to-l from-card to-transparent',
|
|
370
|
+
atRightEdge ? 'opacity-0' : 'opacity-100',
|
|
371
|
+
)}
|
|
372
|
+
/>
|
|
373
|
+
)}
|
|
374
|
+
|
|
375
|
+
{/* Mirrors the real scroller rather than replacing it: dragging this
|
|
376
|
+
writes `scrollLeft` on the table, and the table's own scroll writes
|
|
377
|
+
back. `sticky bottom-0` keeps it on screen for as long as any part of
|
|
378
|
+
the table is, so it is reachable from the first row rather than only
|
|
379
|
+
from the last. */}
|
|
380
|
+
{overflowsX && (
|
|
381
|
+
<div
|
|
382
|
+
ref={stickyBarRef}
|
|
383
|
+
onScroll={(event) => {
|
|
384
|
+
const scroller = scrollRef.current
|
|
385
|
+
if (scroller) scroller.scrollLeft = event.currentTarget.scrollLeft
|
|
386
|
+
}}
|
|
387
|
+
// `translate-y-px` tucks it against the frame's bottom edge rather
|
|
388
|
+
// than floating a hairline above it.
|
|
389
|
+
className="themed-scrollbar sticky bottom-0 z-10 -mt-3 translate-y-px overflow-x-auto overflow-y-hidden"
|
|
390
|
+
>
|
|
391
|
+
<div style={{ width: tableWidth, height: 1 }} />
|
|
392
|
+
</div>
|
|
393
|
+
)}
|
|
8
394
|
</div>
|
|
9
395
|
)
|
|
10
396
|
}
|
|
11
397
|
|
|
398
|
+
function syncColumnWidths(
|
|
399
|
+
bodyTable: HTMLTableElement | null,
|
|
400
|
+
pinnedTable: HTMLTableElement | null,
|
|
401
|
+
) {
|
|
402
|
+
if (!bodyTable || !pinnedTable) return
|
|
403
|
+
const inFlowCells = (table: HTMLTableElement) =>
|
|
404
|
+
Array.from(table.querySelectorAll<HTMLElement>('thead tr:first-child > th')).filter(
|
|
405
|
+
// Out-of-flow cells (overlays) don't form columns.
|
|
406
|
+
(cell) => getComputedStyle(cell).position !== 'absolute',
|
|
407
|
+
)
|
|
408
|
+
const sizerCells = inFlowCells(bodyTable)
|
|
409
|
+
const pinnedCells = inFlowCells(pinnedTable)
|
|
410
|
+
for (let i = 0; i < Math.min(sizerCells.length, pinnedCells.length); i++) {
|
|
411
|
+
pinnedCells[i].style.width = `${sizerCells[i].getBoundingClientRect().width}px`
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
12
415
|
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
|
|
13
416
|
return <thead className={cn('align-bottom', className)} {...props} />
|
|
14
417
|
}
|
|
@@ -19,15 +422,25 @@ function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
|
|
|
19
422
|
|
|
20
423
|
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
|
|
21
424
|
return (
|
|
22
|
-
<tr
|
|
425
|
+
<tr
|
|
426
|
+
className={cn('group/row hover:bg-accent-strong/50 last:*:border-b-0', className)}
|
|
427
|
+
{...props}
|
|
428
|
+
/>
|
|
23
429
|
)
|
|
24
430
|
}
|
|
25
431
|
|
|
432
|
+
/**
|
|
433
|
+
* The bottom rule is an inset shadow rather than `border-b` so it stays flush
|
|
434
|
+
* with the bulk action bar, which overlays this row and draws the same shadow.
|
|
435
|
+
* Don't add a border alongside it — a border sits outside the padding box and
|
|
436
|
+
* the shadow inside, so the two stack into a 2px line.
|
|
437
|
+
*/
|
|
26
438
|
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
|
|
27
439
|
return (
|
|
28
440
|
<th
|
|
29
441
|
className={cn(
|
|
30
|
-
'text-left text-sm font-
|
|
442
|
+
'text-left text-sm 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',
|
|
443
|
+
'shadow-[inset_0_-1px_0_0_var(--border-subtle)]',
|
|
31
444
|
className,
|
|
32
445
|
)}
|
|
33
446
|
{...props}
|
|
@@ -35,11 +448,30 @@ function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
|
|
|
35
448
|
)
|
|
36
449
|
}
|
|
37
450
|
|
|
451
|
+
/**
|
|
452
|
+
* Header row. `relative` because the bulk action bar positions against it: a
|
|
453
|
+
* cell can't serve as that anchor, since absolute children of a table cell are
|
|
454
|
+
* clipped to the cell's own width.
|
|
455
|
+
*
|
|
456
|
+
* The `md:sticky` matters only for the plain (non-`stickyHeader`) table, whose
|
|
457
|
+
* wrapper clips instead of scrolling, so the row pins to the page. Inside the
|
|
458
|
+
* sticky variant neither copy has a vertically scrolling ancestor, so it
|
|
459
|
+
* behaves as `relative` there.
|
|
460
|
+
*/
|
|
461
|
+
function TableHeaderRow({ className, ...props }: React.ComponentProps<'tr'>) {
|
|
462
|
+
return (
|
|
463
|
+
<tr className={cn('relative md:sticky md:top-header-height md:z-20', className)} {...props} />
|
|
464
|
+
)
|
|
465
|
+
}
|
|
466
|
+
|
|
38
467
|
function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
|
|
39
468
|
return (
|
|
40
469
|
<td
|
|
41
470
|
className={cn(
|
|
42
|
-
|
|
471
|
+
// `h-16` on touch is a floor, not a fixed height: a table cell treats
|
|
472
|
+
// `height` as a minimum, so a row with a thumbnail or a wrapped
|
|
473
|
+
// 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',
|
|
43
475
|
className,
|
|
44
476
|
)}
|
|
45
477
|
{...props}
|
|
@@ -51,10 +483,17 @@ function TableEmpty({ children, colSpan }: { children: ReactNode; colSpan: numbe
|
|
|
51
483
|
return (
|
|
52
484
|
<tr>
|
|
53
485
|
<td colSpan={colSpan} className="py-12 text-center text-muted-foreground">
|
|
54
|
-
{
|
|
486
|
+
{/* The cell spans the table's full scroll width, so centring inside it
|
|
487
|
+
puts the message off-screen once the table is wider than its
|
|
488
|
+
container. `sticky left-0` pins this to the scroller's left edge and
|
|
489
|
+
`100cqw` sizes it to the scroller rather than the viewport — sizing
|
|
490
|
+
it to the viewport made the row itself the widest thing in the
|
|
491
|
+
table and produced a scrollbar on a list that had nothing to
|
|
492
|
+
scroll. */}
|
|
493
|
+
<div className="sticky left-0 flex w-[100cqw] justify-center">{children}</div>
|
|
55
494
|
</td>
|
|
56
495
|
</tr>
|
|
57
496
|
)
|
|
58
497
|
}
|
|
59
498
|
|
|
60
|
-
export { Table, TableBody, TableCell, TableEmpty, TableHead, TableHeader, TableRow }
|
|
499
|
+
export { Table, TableBody, TableCell, TableEmpty, TableHead, TableHeader, TableHeaderRow, TableRow }
|
package/src/ui/date-picker.tsx
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { format, parse, parseISO } from 'date-fns'
|
|
2
2
|
import { formatInTimeZone, fromZonedTime, toZonedTime } from 'date-fns-tz'
|
|
3
3
|
import i18n from 'i18next'
|
|
4
|
-
import { CalendarIcon, XIcon } from 'lucide-react'
|
|
5
4
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
6
5
|
import { activeDateLocale } from '../lib/date-locale'
|
|
7
6
|
import { cn } from '../lib/utils'
|
|
7
|
+
import { CalendarIcon, XIcon } from '../spree/icons'
|
|
8
8
|
import { Button } from './button'
|
|
9
9
|
import { Calendar } from './calendar'
|
|
10
10
|
import { Input } from './input'
|
|
@@ -37,6 +37,13 @@ interface DatePickerProps {
|
|
|
37
37
|
* keep the default Popover behavior.
|
|
38
38
|
*/
|
|
39
39
|
inline?: boolean
|
|
40
|
+
/**
|
|
41
|
+
* Forwarded to the trigger button, so a `<FieldLabel htmlFor>` can name the
|
|
42
|
+
* control. Without it the label sits next to the picker visually but is not
|
|
43
|
+
* associated with it, and a screen reader announces only the formatted date
|
|
44
|
+
* the trigger happens to show.
|
|
45
|
+
*/
|
|
46
|
+
id?: string
|
|
40
47
|
}
|
|
41
48
|
|
|
42
49
|
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/
|
|
@@ -77,6 +84,7 @@ function DatePicker({
|
|
|
77
84
|
includeTime = false,
|
|
78
85
|
timezone,
|
|
79
86
|
inline = false,
|
|
87
|
+
id,
|
|
80
88
|
}: DatePickerProps) {
|
|
81
89
|
const [open, setOpen] = useState(false)
|
|
82
90
|
const containerRef = useRef<HTMLDivElement | null>(null)
|
|
@@ -211,6 +219,7 @@ function DatePicker({
|
|
|
211
219
|
return (
|
|
212
220
|
<div ref={containerRef} className={cn('relative', className)}>
|
|
213
221
|
<Button
|
|
222
|
+
id={id}
|
|
214
223
|
type="button"
|
|
215
224
|
variant="outline"
|
|
216
225
|
data-empty={!isValidDate}
|
|
@@ -218,7 +227,7 @@ function DatePicker({
|
|
|
218
227
|
aria-haspopup="dialog"
|
|
219
228
|
aria-expanded={open}
|
|
220
229
|
onClick={() => setOpen((v) => !v)}
|
|
221
|
-
className="w-full justify-start"
|
|
230
|
+
className="w-full justify-start shadow-none"
|
|
222
231
|
>
|
|
223
232
|
{triggerChildren}
|
|
224
233
|
</Button>
|
|
@@ -238,7 +247,13 @@ function DatePicker({
|
|
|
238
247
|
return (
|
|
239
248
|
<Popover open={open} onOpenChange={setOpen}>
|
|
240
249
|
<PopoverTrigger asChild disabled={disabled}>
|
|
241
|
-
<Button
|
|
250
|
+
<Button
|
|
251
|
+
id={id}
|
|
252
|
+
type="button"
|
|
253
|
+
variant="outline"
|
|
254
|
+
data-empty={!isValidDate}
|
|
255
|
+
className={className}
|
|
256
|
+
>
|
|
242
257
|
{triggerChildren}
|
|
243
258
|
</Button>
|
|
244
259
|
</PopoverTrigger>
|