@duro-app/ui 0.44.0 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/components/Table/SortChip.d.ts +8 -1
  2. package/dist/components/Table/SortChip.d.ts.map +1 -1
  3. package/dist/components/Table/Table.d.ts +1 -1
  4. package/dist/components/Table/Table.d.ts.map +1 -1
  5. package/dist/components/Table/Table.stories.d.ts +2 -0
  6. package/dist/components/Table/Table.stories.d.ts.map +1 -1
  7. package/dist/components/Table/styles.css.d.ts +41 -0
  8. package/dist/components/Table/styles.css.d.ts.map +1 -1
  9. package/dist/components/Tag/Tag.d.ts +9 -1
  10. package/dist/components/Tag/Tag.d.ts.map +1 -1
  11. package/dist/components/Tag/Tag.stories.d.ts +1 -0
  12. package/dist/components/Tag/Tag.stories.d.ts.map +1 -1
  13. package/dist/components/Tag/styles.css.d.ts +7 -0
  14. package/dist/components/Tag/styles.css.d.ts.map +1 -1
  15. package/dist/components/VirtualTable/VirtualTable.d.ts +12 -1
  16. package/dist/components/VirtualTable/VirtualTable.d.ts.map +1 -1
  17. package/dist/components/VirtualTable/VirtualTable.stories.d.ts +1 -0
  18. package/dist/components/VirtualTable/VirtualTable.stories.d.ts.map +1 -1
  19. package/dist/components/VirtualTable/styles.css.d.ts +40 -0
  20. package/dist/components/VirtualTable/styles.css.d.ts.map +1 -1
  21. package/dist/index.css +1 -1
  22. package/dist/index.js +2793 -2607
  23. package/dist/index.js.map +1 -1
  24. package/package.json +2 -2
  25. package/src/components/Table/SortChip.tsx +15 -2
  26. package/src/components/Table/Table.stories.tsx +125 -1
  27. package/src/components/Table/Table.tsx +140 -8
  28. package/src/components/Table/styles.css.ts +57 -0
  29. package/src/components/Tag/Tag.stories.tsx +22 -0
  30. package/src/components/Tag/Tag.tsx +10 -0
  31. package/src/components/Tag/styles.css.ts +12 -0
  32. package/src/components/VirtualTable/VirtualTable.stories.tsx +29 -0
  33. package/src/components/VirtualTable/VirtualTable.tsx +152 -58
  34. package/src/components/VirtualTable/styles.css.ts +43 -0
@@ -1,4 +1,4 @@
1
- import {useEffect, useRef, useState, type ReactNode} from 'react'
1
+ import {useEffect, useLayoutEffect, useRef, useState, type ReactNode} from 'react'
2
2
  import {html} from 'react-strict-dom'
3
3
  import {
4
4
  useReactTable,
@@ -13,6 +13,9 @@ import {
13
13
  import {useVirtualizer} from '@tanstack/react-virtual'
14
14
  import {styles} from './styles.css'
15
15
 
16
+ // Measure + commit before paint on the client; no-op-safe on the server.
17
+ const useIsoLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect
18
+
16
19
  export interface VirtualTableRange {
17
20
  /** 1-based index of the first visible row (0 when empty). */
18
21
  from: number
@@ -56,6 +59,17 @@ interface VirtualTableProps<TData> {
56
59
  maxHeight?: number | string
57
60
  /** Render every row (no windowing) at or below this count. Default 150. */
58
61
  virtualizeThreshold?: number
62
+ /**
63
+ * Below this container width (px) the table cards up: the header hides and
64
+ * each row becomes a label/value card (like Table's stack mode). Since the
65
+ * flex columns truncate rather than overflow, this is a deliberate
66
+ * phone-width breakpoint, not an auto content measurement. Cards disable
67
+ * windowing (variable heights), so keep phone lists reasonably short.
68
+ * Default 640. Set `responsive={false}` to disable entirely.
69
+ */
70
+ stackBelow?: number
71
+ /** Opt out of the card-up-on-narrow behavior. Default true. */
72
+ responsive?: boolean
59
73
  emptyLabel?: ReactNode
60
74
  }
61
75
 
@@ -84,6 +98,8 @@ export function VirtualTable<TData>({
84
98
  estimateRowHeight = 44,
85
99
  maxHeight = '70vh',
86
100
  virtualizeThreshold = 150,
101
+ stackBelow = 640,
102
+ responsive = true,
87
103
  emptyLabel,
88
104
  }: VirtualTableProps<TData>) {
89
105
  // Support both controlled sorting (SortChip in the caller) and standalone use.
@@ -102,7 +118,35 @@ export function VirtualTable<TData>({
102
118
  })
103
119
 
104
120
  const rows = table.getRowModel().rows
105
- const virtualize = rows.length > virtualizeThreshold
121
+
122
+ // Card-up on narrow containers. Flex cells truncate rather than overflow, so
123
+ // there's no content-overflow signal to measure (as in Table) — we use a
124
+ // deliberate phone-width breakpoint. Cards render every row (no windowing),
125
+ // so `virtualize` is forced off while stacked.
126
+ const wrapRef = useRef<HTMLDivElement>(null)
127
+ const [stacked, setStacked] = useState(false)
128
+ useIsoLayoutEffect(() => {
129
+ if (!responsive) {
130
+ setStacked(false)
131
+ return
132
+ }
133
+ const el = wrapRef.current
134
+ if (!el || typeof ResizeObserver === 'undefined') return
135
+ const apply = (width: number) => {
136
+ if (width > 0) setStacked(width < stackBelow)
137
+ }
138
+ apply(el.getBoundingClientRect().width)
139
+ const ro = new ResizeObserver((entries) => {
140
+ const entry = entries[0]
141
+ if (!entry) return
142
+ const box = entry.contentBoxSize?.[0]
143
+ apply(box ? box.inlineSize : entry.contentRect.width)
144
+ })
145
+ ro.observe(el)
146
+ return () => ro.disconnect()
147
+ }, [responsive, stackBelow])
148
+
149
+ const virtualize = rows.length > virtualizeThreshold && !stacked
106
150
 
107
151
  const scrollRef = useRef<HTMLDivElement>(null)
108
152
  const rv = useVirtualizer({
@@ -174,82 +218,132 @@ export function VirtualTable<TData>({
174
218
  // eslint-disable-next-line react-hooks/exhaustive-deps
175
219
  }, [curPage, virtualize])
176
220
 
221
+ // Stack-mode label for a column: explicit meta override, else the string
222
+ // header, else nothing (value renders label-less, e.g. an actions column).
223
+ const columnStackLabel = (columnDef: {
224
+ header?: unknown
225
+ meta?: {stackLabel?: string; label?: string}
226
+ }): string => {
227
+ const meta = columnDef.meta
228
+ if (typeof meta?.stackLabel === 'string') return meta.stackLabel
229
+ if (typeof meta?.label === 'string') return meta.label
230
+ return typeof columnDef.header === 'string' ? columnDef.header : ''
231
+ }
232
+
177
233
  const renderRow = (
178
234
  row: Row<TData>,
179
235
  index: number,
180
236
  positioned?: ReturnType<typeof styles.rowAbsolute>,
181
237
  measureRef?: (node: Element | null) => void,
182
- ) => (
183
- <html.div
184
- key={row.id}
185
- ref={measureRef}
186
- data-index={index}
187
- role="row"
188
- aria-label={rowLabel?.(row.original)}
189
- onClick={onRowClick ? () => onRowClick(row.original) : undefined}
190
- style={[styles.row, Boolean(onRowClick) && styles.rowClickable, positioned]}
191
- >
192
- {row.getVisibleCells().map((cell) => (
193
- <html.div
194
- key={cell.id}
195
- role="cell"
196
- style={[styles.cell, styles.cellFlex(cell.column.getSize())]}
197
- >
198
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
199
- </html.div>
200
- ))}
201
- </html.div>
202
- )
238
+ ) =>
239
+ stacked ? (
240
+ <html.div
241
+ key={row.id}
242
+ role="row"
243
+ aria-label={rowLabel?.(row.original)}
244
+ onClick={onRowClick ? () => onRowClick(row.original) : undefined}
245
+ style={[styles.stackRow, Boolean(onRowClick) && styles.rowClickable]}
246
+ >
247
+ {row.getVisibleCells().map((cell) => {
248
+ const lbl = columnStackLabel(cell.column.columnDef)
249
+ return (
250
+ <html.div key={cell.id} role="cell" style={styles.stackCell}>
251
+ {lbl ? <html.span style={styles.stackCellLabel}>{lbl}</html.span> : null}
252
+ <html.div style={styles.stackCellValue}>
253
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
254
+ </html.div>
255
+ </html.div>
256
+ )
257
+ })}
258
+ </html.div>
259
+ ) : (
260
+ <html.div
261
+ key={row.id}
262
+ ref={measureRef}
263
+ data-index={index}
264
+ role="row"
265
+ aria-label={rowLabel?.(row.original)}
266
+ onClick={onRowClick ? () => onRowClick(row.original) : undefined}
267
+ style={[styles.row, Boolean(onRowClick) && styles.rowClickable, positioned]}
268
+ >
269
+ {row.getVisibleCells().map((cell) => (
270
+ <html.div
271
+ key={cell.id}
272
+ role="cell"
273
+ style={[styles.cell, styles.cellFlex(cell.column.getSize())]}
274
+ >
275
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
276
+ </html.div>
277
+ ))}
278
+ </html.div>
279
+ )
203
280
 
204
281
  const label =
205
282
  rangeLabel?.(range) ?? (rows.length ? `${range.from}–${range.to} / ${range.total}` : '')
206
283
 
207
284
  return (
208
- <html.div style={styles.wrap}>
285
+ <html.div ref={wrapRef} style={styles.wrap}>
209
286
  <html.div
210
287
  ref={scrollRef}
211
288
  role="table"
212
- style={[styles.scroll, virtualize && styles.scrollMax(maxHeight)]}
289
+ style={[
290
+ stacked ? styles.stackScroll : styles.scroll,
291
+ virtualize && styles.scrollMax(maxHeight),
292
+ ]}
213
293
  >
214
- <html.div role="rowgroup" style={styles.head}>
215
- {table.getHeaderGroups().map((headerGroup) => (
216
- <html.div key={headerGroup.id} role="row" style={styles.headRow}>
217
- {headerGroup.headers.map((header) => {
218
- const col = header.column
219
- const sorted = col.getIsSorted()
220
- return (
221
- <html.div
222
- key={header.id}
223
- role="columnheader"
224
- aria-sort={
225
- sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : undefined
226
- }
227
- onClick={col.getCanSort() ? col.getToggleSortingHandler() : undefined}
228
- style={[
229
- styles.headCell,
230
- col.getCanSort() && styles.headCellSortable,
231
- styles.cellFlex(header.getSize()),
232
- ]}
233
- >
234
- {header.isPlaceholder
235
- ? null
236
- : flexRender(col.columnDef.header, header.getContext())}
237
- {sorted ? (
238
- <html.span style={styles.sortMark}>{sorted === 'asc' ? '↑' : '↓'}</html.span>
239
- ) : null}
240
- </html.div>
241
- )
242
- })}
243
- </html.div>
244
- ))}
245
- </html.div>
294
+ {stacked ? null : (
295
+ <html.div role="rowgroup" style={styles.head}>
296
+ {table.getHeaderGroups().map((headerGroup) => (
297
+ <html.div key={headerGroup.id} role="row" style={styles.headRow}>
298
+ {headerGroup.headers.map((header) => {
299
+ const col = header.column
300
+ const sorted = col.getIsSorted()
301
+ return (
302
+ <html.div
303
+ key={header.id}
304
+ role="columnheader"
305
+ aria-sort={
306
+ sorted === 'asc'
307
+ ? 'ascending'
308
+ : sorted === 'desc'
309
+ ? 'descending'
310
+ : undefined
311
+ }
312
+ onClick={col.getCanSort() ? col.getToggleSortingHandler() : undefined}
313
+ style={[
314
+ styles.headCell,
315
+ col.getCanSort() && styles.headCellSortable,
316
+ styles.cellFlex(header.getSize()),
317
+ ]}
318
+ >
319
+ {header.isPlaceholder
320
+ ? null
321
+ : flexRender(col.columnDef.header, header.getContext())}
322
+ {sorted ? (
323
+ <html.span style={styles.sortMark}>
324
+ {sorted === 'asc' ? '↑' : '↓'}
325
+ </html.span>
326
+ ) : null}
327
+ </html.div>
328
+ )
329
+ })}
330
+ </html.div>
331
+ ))}
332
+ </html.div>
333
+ )}
246
334
 
247
335
  {rows.length === 0 ? (
248
336
  <html.div style={styles.empty}>{emptyLabel}</html.div>
249
337
  ) : (
250
338
  <html.div
251
339
  role="rowgroup"
252
- style={virtualize ? styles.bodyTotal(rv.getTotalSize()) : undefined}
340
+ style={
341
+ stacked
342
+ ? styles.stackBody
343
+ : virtualize
344
+ ? styles.bodyTotal(rv.getTotalSize())
345
+ : undefined
346
+ }
253
347
  >
254
348
  {virtualize
255
349
  ? vItems.map((vi) =>
@@ -105,6 +105,49 @@ export const styles = css.create({
105
105
  flexBasis: 0,
106
106
  minWidth: 0,
107
107
  }),
108
+ // --- Stack (card) mode: narrow containers ---
109
+ // The scroll frame drops its border/background so each row card stands on
110
+ // its own; windowing is off in this mode so no capped viewport.
111
+ stackScroll: {
112
+ position: 'relative',
113
+ width: '100%',
114
+ backgroundColor: 'transparent',
115
+ },
116
+ stackBody: {
117
+ display: 'flex',
118
+ flexDirection: 'column',
119
+ gap: spacing.sm,
120
+ },
121
+ stackRow: {
122
+ display: 'flex',
123
+ flexDirection: 'column',
124
+ gap: spacing.xs,
125
+ width: '100%',
126
+ padding: spacing.sm,
127
+ borderWidth: 1,
128
+ borderStyle: 'solid',
129
+ borderColor: colors.border,
130
+ borderRadius: radii.sm,
131
+ backgroundColor: colors.bgCard,
132
+ },
133
+ stackCell: {
134
+ display: 'grid',
135
+ gridTemplateColumns: '1fr 2fr',
136
+ gap: spacing.sm,
137
+ alignItems: 'center',
138
+ fontFamily: typography.fontFamily,
139
+ fontSize: typography.fontSizeSm,
140
+ color: colors.text,
141
+ },
142
+ stackCellLabel: {
143
+ color: colors.textMuted,
144
+ fontWeight: typography.fontWeightMedium,
145
+ fontSize: typography.fontSizeXs,
146
+ },
147
+ stackCellValue: {
148
+ minWidth: 0,
149
+ overflowWrap: 'break-word',
150
+ },
108
151
  empty: {
109
152
  paddingTop: spacing.xl,
110
153
  paddingBottom: spacing.xl,