@duro-app/ui 0.32.0 → 0.34.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.
@@ -1,4 +1,5 @@
1
1
  import {
2
+ type ReactElement,
2
3
  type ReactNode,
3
4
  type MutableRefObject,
4
5
  createContext,
@@ -12,6 +13,7 @@ import {styles} from './styles.css'
12
13
  import {Pagination} from './Pagination'
13
14
  import {SortIndicator} from './SortIndicator'
14
15
  import {ColumnFilter} from './ColumnFilter'
16
+ import {SortChip} from './SortChip'
15
17
 
16
18
  // --- Types ---
17
19
 
@@ -23,6 +25,10 @@ export type TableSize = 'sm' | 'md'
23
25
  interface TableContextValue {
24
26
  variant: TableVariant
25
27
  size: TableSize
28
+ responsive: boolean
29
+ /** Header labels indexed by column position. Populated synchronously by
30
+ * extractColumnMeta() during Root render so SSR HTML carries them. */
31
+ labels: ReadonlyArray<string>
26
32
  /** Mutable ref: header Row writes inferred template, Root reads it */
27
33
  inferredTemplateRef: MutableRefObject<string | null>
28
34
  }
@@ -39,33 +45,95 @@ function useTable() {
39
45
 
40
46
  const HeaderContext = createContext(false)
41
47
 
42
- // --- Template context: set after first render pass ---
48
+ // --- Dev-only warn registry ---
49
+ //
50
+ // One-shot per warning code per process: long-running dev sessions
51
+ // shouldn't drown the console on every re-render of a misconfigured table.
52
+ const _IS_PROD = typeof process !== 'undefined' && process.env?.NODE_ENV === 'production'
53
+ const _devWarned = new Set<string>()
54
+ function devWarnOnce(code: string, message: string) {
55
+ if (_IS_PROD || _devWarned.has(code)) return
56
+ _devWarned.add(code)
57
+ // eslint-disable-next-line no-console
58
+ console.warn(`[duro-app/ui Table] ${message}`)
59
+ }
43
60
 
44
- const TemplateContext = createContext<string | null>(null)
61
+ // --- Container — owns the @container query target ---
62
+ //
63
+ // @deprecated Root sets up its own container query and accepts `sortChip`
64
+ // / `pagination` as slot props. Container is kept exported as a passthrough
65
+ // so existing call sites continue to work; new code should use Root alone.
45
66
 
46
- // --- Root ---
67
+ export function Container({children}: {children: ReactNode}) {
68
+ return <html.div style={styles.container}>{children}</html.div>
69
+ }
47
70
 
48
- interface RootProps {
49
- children: ReactNode
50
- variant?: TableVariant
51
- size?: TableSize
71
+ // --- Synchronous tree walk: widths + labels in document order ---
72
+ //
73
+ // Mirrors the pattern that was previously called extractTemplate(). Walks
74
+ // the JSX tree, finds HeaderCell elements, captures their `width` and
75
+ // `label` props (or extractText(children) fallback). Runs on every Root
76
+ // render — cheap (small N, no DOM) — but worth memoising on `children`
77
+ // reference if profiling flags it.
78
+
79
+ // Shallow string-children extraction. We deliberately do NOT recurse into
80
+ // child elements: an icon, tooltip trigger, or sort indicator nested inside
81
+ // a HeaderCell would otherwise pollute the stack-mode label with its own
82
+ // text content. JSX-only headers must set `label` explicitly.
83
+ function extractText(node: ReactNode): string {
84
+ let out = ''
85
+ Children.forEach(node, (child) => {
86
+ if (typeof child === 'string' || typeof child === 'number') {
87
+ out += String(child)
88
+ }
89
+ })
90
+ return out
52
91
  }
53
92
 
54
- /**
55
- * Scans the React tree for HeaderCell elements to extract width props.
56
- * Returns a grid-template-columns string.
57
- */
58
- function extractTemplate(children: ReactNode): string {
93
+ // Unwrap memo(...) / forwardRef(...) wrappers so JSX like
94
+ // `<MemoizedHeaderCell ...>` still identifies as HeaderCell. Both wrappers
95
+ // expose the inner component via a `.type` (memo) or `.render` (forwardRef)
96
+ // property on the element-type descriptor. We walk both fields until we
97
+ // reach a leaf, which is the underlying function/class component.
98
+ function unwrapElementType(t: unknown): unknown {
99
+ let current = t
100
+ // Bounded loop — defensive cap against pathological infinite-nesting.
101
+ for (let i = 0; i < 8 && current && typeof current === 'object'; i++) {
102
+ const next =
103
+ (current as {type?: unknown; render?: unknown}).type ?? (current as {render?: unknown}).render
104
+ if (next == null) break
105
+ current = next
106
+ }
107
+ return current
108
+ }
109
+
110
+ function extractColumnMeta(children: ReactNode): {template: string; labels: string[]} {
59
111
  const widths: string[] = []
112
+ const labels: string[] = []
60
113
 
61
114
  function walk(node: ReactNode) {
62
115
  Children.forEach(node, (child) => {
63
116
  if (!isValidElement(child)) return
64
117
  const props = child.props as Record<string, any>
65
- // Check if this looks like a HeaderCell (has width prop or is inside Header)
66
- const displayName = (child.type as any)?.name || (child.type as any)?.displayName || ''
67
- if (displayName === 'HeaderCell' || child.type === HeaderCell) {
118
+ const unwrappedType = unwrapElementType(child.type)
119
+ const displayName =
120
+ (unwrappedType as {name?: string; displayName?: string} | null)?.displayName ||
121
+ (unwrappedType as {name?: string} | null)?.name ||
122
+ ''
123
+ if (unwrappedType === HeaderCell || displayName === 'HeaderCell') {
68
124
  widths.push(props.width || '1fr')
125
+ const explicit = typeof props.label === 'string' ? props.label : undefined
126
+ const fallback = extractText(props.children).trim()
127
+ const label = explicit ?? fallback
128
+ if (!explicit && fallback === '' && props.children != null) {
129
+ // Children are JSX with no string content — stack mode will render
130
+ // an unlabeled cell. Tell the developer once.
131
+ devWarnOnce(
132
+ 'headerCell-missing-label',
133
+ 'Table.HeaderCell with JSX children must set `label` for stack-mode rendering.',
134
+ )
135
+ }
136
+ labels.push(label)
69
137
  } else if (props.children) {
70
138
  walk(props.children)
71
139
  }
@@ -73,33 +141,78 @@ function extractTemplate(children: ReactNode): string {
73
141
  }
74
142
 
75
143
  walk(children)
76
- return widths.length > 0 ? widths.join(' ') : ''
144
+ return {template: widths.join(' '), labels}
77
145
  }
78
146
 
79
- function Root({children, variant = 'default', size = 'md'}: RootProps) {
80
- const inferredTemplateRef = useRef<string | null>(null)
147
+ // --- Root ---
81
148
 
82
- // Extract template from HeaderCell children synchronously
83
- const template = extractTemplate(children)
149
+ interface RootProps {
150
+ children: ReactNode
151
+ variant?: TableVariant
152
+ size?: TableSize
153
+ /** Opt out of responsive container-query behavior. Default true. */
154
+ responsive?: boolean
155
+ /**
156
+ * Optional sort UI rendered above the grid. Visible only in stack mode
157
+ * (SortChip carries its own `display: none → inline-flex` rule). Typical
158
+ * value: `<Table.SortChip options={...} value={...} onChange={...} />`.
159
+ */
160
+ sortChip?: ReactNode
161
+ /**
162
+ * Optional pagination UI rendered below the grid. Typical value:
163
+ * `<Table.Pagination table={tanstackTable} />`.
164
+ */
165
+ pagination?: ReactNode
166
+ }
167
+
168
+ export function Root({
169
+ children,
170
+ variant = 'default',
171
+ size = 'md',
172
+ responsive = true,
173
+ sortChip,
174
+ pagination,
175
+ }: RootProps) {
176
+ const inferredTemplateRef = useRef<string | null>(null)
177
+ const {template, labels} = extractColumnMeta(children)
84
178
  if (template) {
85
179
  inferredTemplateRef.current = template
86
180
  }
87
181
 
182
+ const grid = (
183
+ <html.div
184
+ role="table"
185
+ style={[
186
+ styles.root,
187
+ template ? styles.gridColumns(template) : undefined,
188
+ responsive && styles.rootResponsive,
189
+ ]}
190
+ >
191
+ {children}
192
+ </html.div>
193
+ )
194
+
195
+ // Slots render unconditionally — `responsive=false` still wants its sort/
196
+ // pagination chrome. Only the containerType:inline-size wrapper is
197
+ // conditional, since it only matters when @container queries fire.
198
+ const body = (
199
+ <>
200
+ {sortChip}
201
+ {grid}
202
+ {pagination}
203
+ </>
204
+ )
205
+
88
206
  return (
89
- <TableContext.Provider value={{variant, size, inferredTemplateRef}}>
90
- <html.div
91
- role="table"
92
- style={[styles.root, template ? styles.gridColumns(template) : undefined]}
93
- >
94
- {children}
95
- </html.div>
207
+ <TableContext.Provider value={{variant, size, responsive, labels, inferredTemplateRef}}>
208
+ {responsive ? <html.div style={styles.rootContainer}>{body}</html.div> : body}
96
209
  </TableContext.Provider>
97
210
  )
98
211
  }
99
212
 
100
213
  // --- Header ---
101
214
 
102
- function Header({children}: {children: ReactNode}) {
215
+ export function Header({children}: {children: ReactNode}) {
103
216
  return (
104
217
  <HeaderContext.Provider value={true}>
105
218
  <html.div role="rowgroup" style={styles.header}>
@@ -111,22 +224,24 @@ function Header({children}: {children: ReactNode}) {
111
224
 
112
225
  // --- Body ---
113
226
 
114
- function Body({children}: {children: ReactNode}) {
227
+ export function Body({children}: {children: ReactNode}) {
115
228
  const {variant} = useTable()
116
- const childArray = Children.toArray(children)
229
+ const childArray = Children.toArray(children) as ReactElement[]
117
230
 
118
231
  return (
119
232
  <HeaderContext.Provider value={false}>
120
233
  <html.div role="rowgroup" style={styles.body}>
121
234
  {childArray.map((child, index) => {
122
- if (variant === 'striped') {
123
- return (
124
- <RowIndexContext.Provider key={index} value={index}>
125
- {child}
126
- </RowIndexContext.Provider>
127
- )
128
- }
129
- return child
235
+ if (variant !== 'striped') return child
236
+ // Key the provider by the child's React key (Children.toArray
237
+ // guarantees one) so reordered rows carry their providers along
238
+ // with them. Position is passed as the value because striping is
239
+ // a positional concern even when row identity is stable.
240
+ return (
241
+ <RowIndexContext.Provider key={child.key ?? index} value={index}>
242
+ {child}
243
+ </RowIndexContext.Provider>
244
+ )
130
245
  })}
131
246
  </html.div>
132
247
  </HeaderContext.Provider>
@@ -136,30 +251,89 @@ function Body({children}: {children: ReactNode}) {
136
251
  const RowIndexContext = createContext<number>(-1)
137
252
 
138
253
  // --- Row ---
254
+ //
255
+ // Always provides CellIndexContext, regardless of variant. Previously this
256
+ // was only set up for `variant === 'bordered'`, which meant non-bordered
257
+ // tables had every cell reading {index: 0} from the default — fine until
258
+ // we needed to look up labels by column index.
259
+ //
260
+ // Interactive rows: pass `onClick` to make the row a navigation target.
261
+ // The role stays `row` (per ARIA grid pattern) — we don't wrap in an outer
262
+ // element with role=button, which would break the rowgroup → row hierarchy.
263
+ // A click that originates inside an interactive descendant (button, link,
264
+ // input, etc.) is treated as that descendant's click and does not fire
265
+ // onClick — prevents double-fire when a row contains action buttons.
266
+
267
+ interface RowProps {
268
+ children: ReactNode
269
+ /** When set, the row becomes focusable + clickable. */
270
+ onClick?: () => void
271
+ /** Per-row accessible name — required when onClick is set, recommended otherwise. */
272
+ 'aria-label'?: string
273
+ }
139
274
 
140
- function Row({children}: {children: ReactNode}) {
275
+ // Heuristic for "clicking a descendant means the descendant, not the row".
276
+ // Matches the elements the WAI-ARIA grid pattern lists as "widgets" plus the
277
+ // usual native focus-stealers.
278
+ const INTERACTIVE_SELECTOR =
279
+ 'button, a, input, select, textarea, [role="button"], [role="link"], [role="checkbox"], [role="menuitem"], [role="switch"], [role="tab"], [contenteditable="true"]'
280
+
281
+ export function Row({children, onClick, 'aria-label': ariaLabel}: RowProps) {
141
282
  const {variant} = useTable()
142
283
  const isHeader = useContext(HeaderContext)
143
284
  const rowIndex = useContext(RowIndexContext)
144
285
  const isEvenRow = rowIndex >= 0 && rowIndex % 2 === 1
145
- const childArray = Children.toArray(children)
286
+ const childArray = Children.toArray(children) as ReactElement[]
287
+ const isClickable = onClick !== undefined && !isHeader
146
288
 
147
289
  return (
148
290
  <html.div
149
291
  role="row"
292
+ tabIndex={isClickable ? 0 : undefined}
293
+ aria-label={isClickable ? ariaLabel : undefined}
294
+ onClick={
295
+ isClickable
296
+ ? (e) => {
297
+ // react-strict-dom's typed click event omits `target`, but the
298
+ // underlying SyntheticEvent still carries it. Cast through so
299
+ // we can detect clicks that originate inside an interactive
300
+ // descendant (action button, link, input) and let them be
301
+ // attributed to that widget instead of the row.
302
+ const target = (e as unknown as {target?: EventTarget | null}).target
303
+ if (target instanceof Element && target.closest(INTERACTIVE_SELECTOR)) {
304
+ return
305
+ }
306
+ onClick()
307
+ }
308
+ : undefined
309
+ }
310
+ onKeyDown={
311
+ isClickable
312
+ ? (e) => {
313
+ if (e.key === 'Enter' || e.key === ' ') {
314
+ onClick()
315
+ }
316
+ }
317
+ : undefined
318
+ }
150
319
  style={[
151
320
  styles.row,
152
321
  !isHeader && styles.bodyRow,
153
322
  !isHeader && variant === 'striped' && isEvenRow && styles.stripedEven,
323
+ isClickable && styles.clickableRow,
154
324
  ]}
155
325
  >
156
- {variant === 'bordered'
157
- ? childArray.map((child, index) => (
158
- <CellIndexContext.Provider key={index} value={{index, total: childArray.length}}>
159
- {child}
160
- </CellIndexContext.Provider>
161
- ))
162
- : children}
326
+ {childArray.map((child, index) => (
327
+ // Key by the child's React key (Children.toArray assigns one) so a
328
+ // reordered column carries its CellIndexContext provider along.
329
+ // The positional `index` lives in the provider's value, not its key.
330
+ <CellIndexContext.Provider
331
+ key={child.key ?? index}
332
+ value={{index, total: childArray.length}}
333
+ >
334
+ {child}
335
+ </CellIndexContext.Provider>
336
+ ))}
163
337
  </html.div>
164
338
  )
165
339
  }
@@ -168,16 +342,34 @@ const CellIndexContext = createContext<{index: number; total: number}>({index: 0
168
342
 
169
343
  // --- HeaderCell ---
170
344
 
171
- function HeaderCell({
345
+ export function HeaderCell({
172
346
  children,
173
347
  width: _width,
348
+ label: _label,
349
+ isActions,
174
350
  'aria-label': ariaLabel,
175
351
  }: {
176
352
  children?: ReactNode
177
353
  /** Column width: CSS value like '40px', '2fr', 'max-content'. Defaults to '1fr'. */
178
354
  width?: string
355
+ /** Stack-mode label string. Optional when children is a plain string — the
356
+ * text content is used as the label automatically. Required when children
357
+ * contain JSX (icon + text, sort indicator, etc.). */
358
+ label?: string
359
+ /**
360
+ * @deprecated Has no effect on HeaderCell. Pass `isActions` on the
361
+ * matching `Table.Cell` instead — that's where the stack-mode footer
362
+ * layout actually applies.
363
+ */
364
+ isActions?: boolean
179
365
  'aria-label'?: string
180
366
  }) {
367
+ if (isActions === true) {
368
+ devWarnOnce(
369
+ 'headerCell-isActions',
370
+ 'HeaderCell `isActions` prop has no effect and is deprecated. Pass `isActions` on the matching Table.Cell instead.',
371
+ )
372
+ }
181
373
  const {size, variant} = useTable()
182
374
  const {index, total} = useContext(CellIndexContext)
183
375
  const isLast = variant === 'bordered' && index === total - 1
@@ -197,13 +389,19 @@ function HeaderCell({
197
389
  </html.div>
198
390
  )
199
391
  }
392
+ HeaderCell.displayName = 'HeaderCell'
200
393
 
201
394
  // --- Cell ---
395
+ //
396
+ // Renders the column-header label as a real <html.span>, hidden via
397
+ // container query in non-stack modes. The span is omitted entirely when
398
+ // isActions=true — actions footers don't show a label.
202
399
 
203
- function Cell({children}: {children: ReactNode}) {
204
- const {size, variant} = useTable()
400
+ export function Cell({children, isActions}: {children: ReactNode; isActions?: boolean}) {
401
+ const {size, variant, labels, responsive} = useTable()
205
402
  const {index, total} = useContext(CellIndexContext)
206
403
  const isLast = variant === 'bordered' && index === total - 1
404
+ const label = labels[index] ?? ''
207
405
 
208
406
  return (
209
407
  <html.div
@@ -213,8 +411,12 @@ function Cell({children}: {children: ReactNode}) {
213
411
  size === 'sm' ? styles.cellSm : styles.cellMd,
214
412
  variant === 'bordered' && styles.borderedCell,
215
413
  isLast && styles.borderedCellLast,
414
+ isActions && styles.cellActions,
216
415
  ]}
217
416
  >
417
+ {responsive && !isActions && label !== '' ? (
418
+ <html.span style={styles.cellLabel}>{label}</html.span>
419
+ ) : null}
218
420
  {children}
219
421
  </html.div>
220
422
  )
@@ -222,7 +424,11 @@ function Cell({children}: {children: ReactNode}) {
222
424
 
223
425
  // --- Export ---
224
426
 
427
+ import {FromTanstack} from './FromTanstack'
428
+
225
429
  export const Table = {
430
+ /** @deprecated Wrap behaviour is built into Table.Root. Drop this wrapper from new code. */
431
+ Container,
226
432
  Root,
227
433
  Header,
228
434
  Body,
@@ -232,4 +438,7 @@ export const Table = {
232
438
  Pagination,
233
439
  SortIndicator,
234
440
  ColumnFilter,
441
+ SortChip,
442
+ /** Renders a styled Table directly from a TanStack table instance. */
443
+ FromTanstack,
235
444
  }