@spree/dashboard-ui 0.13.1 → 1.0.0-beta.2

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.
Files changed (94) hide show
  1. package/README.md +1 -1
  2. package/package.json +13 -9
  3. package/src/hooks/use-prefers-reduced-motion.ts +26 -0
  4. package/src/hooks/use-scrolled.ts +34 -14
  5. package/src/index.ts +38 -1
  6. package/src/lib/date-range-presets.ts +79 -0
  7. package/src/lib/same-rich-text.ts +145 -0
  8. package/src/spree/address-block.tsx +5 -4
  9. package/src/spree/address-book-row.tsx +96 -0
  10. package/src/spree/back-button.tsx +14 -9
  11. package/src/spree/bulk-dialog.tsx +2 -2
  12. package/src/spree/bulk-price-table.tsx +145 -14
  13. package/src/spree/bulk-variants-table.tsx +434 -0
  14. package/src/spree/calculator-summary.tsx +5 -1
  15. package/src/spree/claim-resolve-dialog.tsx +219 -0
  16. package/src/spree/confirm-dialog.tsx +1 -1
  17. package/src/spree/copy-to-clipboard-button.tsx +1 -1
  18. package/src/spree/data-grid/cells.tsx +199 -25
  19. package/src/spree/data-grid/context.ts +6 -0
  20. package/src/spree/data-grid/data-grid.tsx +149 -7
  21. package/src/spree/data-grid/helpers.ts +6 -2
  22. package/src/spree/data-grid/index.ts +9 -1
  23. package/src/spree/data-grid/use-data-grid-keyboard.ts +4 -2
  24. package/src/spree/delivery-form-dialog.tsx +166 -0
  25. package/src/spree/drag-handle.tsx +2 -2
  26. package/src/spree/editable-note-card.tsx +103 -0
  27. package/src/spree/external-link.tsx +56 -0
  28. package/src/spree/file-type-icon.tsx +68 -0
  29. package/src/spree/form-actions.tsx +2 -3
  30. package/src/spree/form-section.tsx +46 -0
  31. package/src/spree/fulfill-items-form.tsx +282 -0
  32. package/src/spree/fulfillment-edit-dialog.tsx +211 -0
  33. package/src/spree/fulfillment-item-list.tsx +61 -0
  34. package/src/spree/fulfillment-panel.tsx +53 -0
  35. package/src/spree/icons.ts +167 -0
  36. package/src/spree/json-preview-drawer.tsx +4 -4
  37. package/src/spree/label-upload-dialog.tsx +195 -0
  38. package/src/spree/language-menu-items.tsx +37 -30
  39. package/src/spree/metadata/metadata-card.tsx +1 -1
  40. package/src/spree/order-cancel-dialog.tsx +149 -0
  41. package/src/spree/post-sale-create-dialogs.tsx +253 -0
  42. package/src/spree/post-sale-fields.tsx +133 -0
  43. package/src/spree/product-membership-list.tsx +453 -0
  44. package/src/spree/products/status-card.tsx +79 -0
  45. package/src/spree/quantity-tier-editor.tsx +162 -0
  46. package/src/spree/resource-combobox.tsx +12 -1
  47. package/src/spree/resource-name-cell.tsx +21 -4
  48. package/src/spree/return-dialogs.tsx +238 -0
  49. package/src/spree/route-error-boundary.tsx +1 -1
  50. package/src/spree/row-actions.tsx +2 -2
  51. package/src/spree/search-input.tsx +76 -0
  52. package/src/spree/secret-input.tsx +26 -40
  53. package/src/spree/tax-identifiers-card.tsx +418 -0
  54. package/src/spree/theme-toggle.tsx +36 -23
  55. package/src/spree/wizard.tsx +254 -0
  56. package/src/styles.css +750 -75
  57. package/src/ui/accordion.tsx +83 -0
  58. package/src/ui/alert.tsx +79 -0
  59. package/src/ui/attachment.tsx +1 -1
  60. package/src/ui/badge.tsx +198 -56
  61. package/src/ui/breadcrumb.tsx +1 -1
  62. package/src/ui/button.tsx +22 -10
  63. package/src/ui/calendar.tsx +1 -1
  64. package/src/ui/card.tsx +52 -6
  65. package/src/ui/checkbox.tsx +13 -5
  66. package/src/ui/combobox.tsx +125 -6
  67. package/src/ui/command.tsx +149 -23
  68. package/src/ui/context-menu.tsx +99 -0
  69. package/src/ui/data-table.tsx +448 -9
  70. package/src/ui/date-picker.tsx +18 -3
  71. package/src/ui/date-range-picker.tsx +47 -60
  72. package/src/ui/dialog.tsx +6 -6
  73. package/src/ui/dropdown-menu.tsx +18 -11
  74. package/src/ui/field.tsx +10 -2
  75. package/src/ui/input-group.tsx +1 -1
  76. package/src/ui/input.tsx +1 -1
  77. package/src/ui/media-preview.tsx +105 -0
  78. package/src/ui/pagination.tsx +19 -11
  79. package/src/ui/popover.tsx +4 -2
  80. package/src/ui/progress.tsx +5 -2
  81. package/src/ui/radio-group.tsx +4 -4
  82. package/src/ui/rich-text-editor.tsx +214 -88
  83. package/src/ui/scroll-area.tsx +65 -0
  84. package/src/ui/select.tsx +17 -10
  85. package/src/ui/separator.tsx +1 -1
  86. package/src/ui/sheet.tsx +49 -7
  87. package/src/ui/sidebar.tsx +138 -37
  88. package/src/ui/skeleton.tsx +3 -1
  89. package/src/ui/tabs.tsx +84 -0
  90. package/src/ui/textarea.tsx +1 -1
  91. package/src/ui/thumbnail.tsx +80 -0
  92. package/src/ui/toast.tsx +112 -0
  93. package/src/ui/tooltip.tsx +3 -1
  94. package/src/ui/sonner.tsx +0 -47
@@ -1,14 +1,417 @@
1
- import type { ReactNode } from 'react'
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
- function Table({ className, ...props }: React.ComponentProps<'table'>) {
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="overflow-x-auto">
7
- <table className={cn('w-full align-top text-foreground', className)} {...props} />
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 className={cn('group/row hover:bg-muted/60 last:*:border-b-0', className)} {...props} />
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-medium text-muted-foreground bg-muted/50 p-2 border-b border-border whitespace-nowrap first:pl-4 last:pr-4',
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
- 'py-3 px-2 border-b border-border align-middle first:pl-4 last:pr-4 group-last/row:first:rounded-bl-xl group-last/row:last:rounded-br-xl',
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
- {children}
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 }
@@ -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 type="button" variant="outline" data-empty={!isValidDate} className={className}>
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>