@duro-app/ui 0.33.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,
@@ -44,13 +45,26 @@ function useTable() {
44
45
 
45
46
  const HeaderContext = createContext(false)
46
47
 
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
+ }
60
+
47
61
  // --- Container — owns the @container query target ---
48
62
  //
49
- // Wraps SortChip + Root + Pagination so they all participate in the same
50
- // container query. Setting `containerType: inline-size` on Root would not
51
- // reach siblings that consumers render above the table.
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.
52
66
 
53
- function Container({children}: {children: ReactNode}) {
67
+ export function Container({children}: {children: ReactNode}) {
54
68
  return <html.div style={styles.container}>{children}</html.div>
55
69
  }
56
70
 
@@ -62,19 +76,37 @@ function Container({children}: {children: ReactNode}) {
62
76
  // render — cheap (small N, no DOM) — but worth memoising on `children`
63
77
  // reference if profiling flags it.
64
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.
65
83
  function extractText(node: ReactNode): string {
66
84
  let out = ''
67
85
  Children.forEach(node, (child) => {
68
86
  if (typeof child === 'string' || typeof child === 'number') {
69
87
  out += String(child)
70
- } else if (isValidElement(child)) {
71
- const props = child.props as {children?: ReactNode}
72
- if (props.children) out += extractText(props.children)
73
88
  }
74
89
  })
75
90
  return out
76
91
  }
77
92
 
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
+
78
110
  function extractColumnMeta(children: ReactNode): {template: string; labels: string[]} {
79
111
  const widths: string[] = []
80
112
  const labels: string[] = []
@@ -83,10 +115,24 @@ function extractColumnMeta(children: ReactNode): {template: string; labels: stri
83
115
  Children.forEach(node, (child) => {
84
116
  if (!isValidElement(child)) return
85
117
  const props = child.props as Record<string, any>
86
- const displayName = (child.type as any)?.name || (child.type as any)?.displayName || ''
87
- 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') {
88
124
  widths.push(props.width || '1fr')
89
- const label = props.label ?? extractText(props.children).trim()
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
+ }
90
136
  labels.push(label)
91
137
  } else if (props.children) {
92
138
  walk(props.children)
@@ -106,34 +152,67 @@ interface RootProps {
106
152
  size?: TableSize
107
153
  /** Opt out of responsive container-query behavior. Default true. */
108
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
109
166
  }
110
167
 
111
- function Root({children, variant = 'default', size = 'md', responsive = true}: RootProps) {
168
+ export function Root({
169
+ children,
170
+ variant = 'default',
171
+ size = 'md',
172
+ responsive = true,
173
+ sortChip,
174
+ pagination,
175
+ }: RootProps) {
112
176
  const inferredTemplateRef = useRef<string | null>(null)
113
177
  const {template, labels} = extractColumnMeta(children)
114
178
  if (template) {
115
179
  inferredTemplateRef.current = template
116
180
  }
117
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
+
118
206
  return (
119
207
  <TableContext.Provider value={{variant, size, responsive, labels, inferredTemplateRef}}>
120
- <html.div
121
- role="table"
122
- style={[
123
- styles.root,
124
- template ? styles.gridColumns(template) : undefined,
125
- responsive && styles.rootResponsive,
126
- ]}
127
- >
128
- {children}
129
- </html.div>
208
+ {responsive ? <html.div style={styles.rootContainer}>{body}</html.div> : body}
130
209
  </TableContext.Provider>
131
210
  )
132
211
  }
133
212
 
134
213
  // --- Header ---
135
214
 
136
- function Header({children}: {children: ReactNode}) {
215
+ export function Header({children}: {children: ReactNode}) {
137
216
  return (
138
217
  <HeaderContext.Provider value={true}>
139
218
  <html.div role="rowgroup" style={styles.header}>
@@ -145,22 +224,24 @@ function Header({children}: {children: ReactNode}) {
145
224
 
146
225
  // --- Body ---
147
226
 
148
- function Body({children}: {children: ReactNode}) {
227
+ export function Body({children}: {children: ReactNode}) {
149
228
  const {variant} = useTable()
150
- const childArray = Children.toArray(children)
229
+ const childArray = Children.toArray(children) as ReactElement[]
151
230
 
152
231
  return (
153
232
  <HeaderContext.Provider value={false}>
154
233
  <html.div role="rowgroup" style={styles.body}>
155
234
  {childArray.map((child, index) => {
156
- if (variant === 'striped') {
157
- return (
158
- <RowIndexContext.Provider key={index} value={index}>
159
- {child}
160
- </RowIndexContext.Provider>
161
- )
162
- }
163
- 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
+ )
164
245
  })}
165
246
  </html.div>
166
247
  </HeaderContext.Provider>
@@ -175,25 +256,81 @@ const RowIndexContext = createContext<number>(-1)
175
256
  // was only set up for `variant === 'bordered'`, which meant non-bordered
176
257
  // tables had every cell reading {index: 0} from the default — fine until
177
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
+ }
178
274
 
179
- 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) {
180
282
  const {variant} = useTable()
181
283
  const isHeader = useContext(HeaderContext)
182
284
  const rowIndex = useContext(RowIndexContext)
183
285
  const isEvenRow = rowIndex >= 0 && rowIndex % 2 === 1
184
- const childArray = Children.toArray(children)
286
+ const childArray = Children.toArray(children) as ReactElement[]
287
+ const isClickable = onClick !== undefined && !isHeader
185
288
 
186
289
  return (
187
290
  <html.div
188
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
+ }
189
319
  style={[
190
320
  styles.row,
191
321
  !isHeader && styles.bodyRow,
192
322
  !isHeader && variant === 'striped' && isEvenRow && styles.stripedEven,
323
+ isClickable && styles.clickableRow,
193
324
  ]}
194
325
  >
195
326
  {childArray.map((child, index) => (
196
- <CellIndexContext.Provider key={index} value={{index, total: childArray.length}}>
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
+ >
197
334
  {child}
198
335
  </CellIndexContext.Provider>
199
336
  ))}
@@ -205,24 +342,34 @@ const CellIndexContext = createContext<{index: number; total: number}>({index: 0
205
342
 
206
343
  // --- HeaderCell ---
207
344
 
208
- function HeaderCell({
345
+ export function HeaderCell({
209
346
  children,
210
347
  width: _width,
211
348
  label: _label,
212
- isActions: _isActions,
349
+ isActions,
213
350
  'aria-label': ariaLabel,
214
351
  }: {
215
352
  children?: ReactNode
216
353
  /** Column width: CSS value like '40px', '2fr', 'max-content'. Defaults to '1fr'. */
217
354
  width?: string
218
- /** Stack-mode label string. Required when responsive=true; falls back to
219
- * text-content of children with a dev-only console.warn. */
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.). */
220
358
  label?: string
221
- /** Marks this column as the actions column — its body cells render as a
222
- * full-width footer in stack mode. */
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
+ */
223
364
  isActions?: boolean
224
365
  'aria-label'?: string
225
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
+ }
226
373
  const {size, variant} = useTable()
227
374
  const {index, total} = useContext(CellIndexContext)
228
375
  const isLast = variant === 'bordered' && index === total - 1
@@ -250,7 +397,7 @@ HeaderCell.displayName = 'HeaderCell'
250
397
  // container query in non-stack modes. The span is omitted entirely when
251
398
  // isActions=true — actions footers don't show a label.
252
399
 
253
- function Cell({children, isActions}: {children: ReactNode; isActions?: boolean}) {
400
+ export function Cell({children, isActions}: {children: ReactNode; isActions?: boolean}) {
254
401
  const {size, variant, labels, responsive} = useTable()
255
402
  const {index, total} = useContext(CellIndexContext)
256
403
  const isLast = variant === 'bordered' && index === total - 1
@@ -277,7 +424,10 @@ function Cell({children, isActions}: {children: ReactNode; isActions?: boolean})
277
424
 
278
425
  // --- Export ---
279
426
 
427
+ import {FromTanstack} from './FromTanstack'
428
+
280
429
  export const Table = {
430
+ /** @deprecated Wrap behaviour is built into Table.Root. Drop this wrapper from new code. */
281
431
  Container,
282
432
  Root,
283
433
  Header,
@@ -289,4 +439,6 @@ export const Table = {
289
439
  SortIndicator,
290
440
  ColumnFilter,
291
441
  SortChip,
442
+ /** Renders a styled Table directly from a TanStack table instance. */
443
+ FromTanstack,
292
444
  }
@@ -14,6 +14,9 @@ export const styles = css.create({
14
14
  // Outer wrapper that hosts the @container query. Wraps SortChip + Root
15
15
  // + Pagination so all three react to the same width. Root itself does
16
16
  // NOT carry containerType — keeping a single query target per Table.
17
+ //
18
+ // @deprecated Use Table.Root's sortChip/pagination slot props instead.
19
+ // This style backs the deprecated <Table.Container> wrapper.
17
20
  container: {
18
21
  containerType: 'inline-size',
19
22
  display: 'flex',
@@ -21,6 +24,28 @@ export const styles = css.create({
21
24
  gap: spacing.sm,
22
25
  },
23
26
 
27
+ // The same containment chrome, but applied by Root when responsive=true.
28
+ // Mirrors `container` so consumers don't need to wrap manually.
29
+ rootContainer: {
30
+ containerType: 'inline-size',
31
+ display: 'flex',
32
+ flexDirection: 'column',
33
+ gap: spacing.sm,
34
+ },
35
+
36
+ // Applied when Row receives an `onClick` handler. Cursor signals clickability;
37
+ // the :focus-visible outline meets WCAG 2.4.7 for keyboard navigation.
38
+ clickableRow: {
39
+ cursor: 'pointer',
40
+ outlineWidth: {
41
+ default: 0,
42
+ ':focus-visible': 2,
43
+ },
44
+ outlineStyle: 'solid',
45
+ outlineColor: colors.accent,
46
+ outlineOffset: -2,
47
+ },
48
+
24
49
  // Root — the single grid container for the table itself
25
50
  root: {
26
51
  display: 'grid',
@@ -0,0 +1,28 @@
1
+ // Side-effect-only module: registers `@duro-app/ui`'s additions to
2
+ // TanStack's `ColumnMeta` so consumers can write
3
+ // meta: { stackLabel: 'X', isActions: true }
4
+ // on column defs and have it type-check, regardless of whether they
5
+ // import from Table/FromTanstack in that particular file.
6
+ //
7
+ // `index.ts` re-exports this module so the augmentation registers as
8
+ // soon as any file in the consumer's TS program imports from
9
+ // `@duro-app/ui`. Keeping the augmentation in a standalone file (instead
10
+ // of co-located with `FromTanstack.tsx`) also keeps it from being
11
+ // tree-shaken out of the published types.
12
+
13
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
14
+ declare module '@tanstack/react-table' {
15
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
16
+ interface ColumnMeta<TData extends unknown, TValue> {
17
+ /** Override the stack-mode label when columnDef.header is JSX (icon + text, etc). */
18
+ stackLabel?: string
19
+ /**
20
+ * Marks this column as the actions column. In stack mode the cells
21
+ * render as a full-width footer instead of a labelled key/value row.
22
+ */
23
+ isActions?: boolean
24
+ }
25
+ }
26
+
27
+ // Empty export so this file is treated as a module by TS and ts-up/vite.
28
+ export {}
package/src/index.ts CHANGED
@@ -46,6 +46,11 @@ export {
46
46
  export {Tag, type TagVariant, type TagSize} from './components/Tag/Tag'
47
47
  export {TagGroup} from './components/TagGroup/TagGroup'
48
48
  export {Table, type TableVariant, type TableSize} from './components/Table/Table'
49
+ // Side-effect import registers our augmentation of TanStack's ColumnMeta
50
+ // (stackLabel / isActions). Standalone module so the augmentation lands
51
+ // whenever this package is in a consumer's TS program, even if FromTanstack
52
+ // itself isn't imported.
53
+ import './components/Table/tanstack-augmentation'
49
54
  // TanStack Table: consumers install @tanstack/react-table directly and use
50
55
  // Table.Pagination, Table.SortIndicator, Table.ColumnFilter for styled integration
51
56
  export {Tabs} from './components/Tabs/Tabs'