@duro-app/ui 0.33.0 → 0.34.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.
@@ -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,77 @@ 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
+ // When responsive, use gridColumnsResponsive so the explicit column
188
+ // template and the stack-mode @container override live in one StyleX
189
+ // style object. Splitting them across two styles (gridColumns +
190
+ // rootResponsive) causes StyleX conflict resolution to drop the
191
+ // column template because rootResponsive's gridTemplateColumns key
192
+ // always wins as the later entry in the array.
193
+ template
194
+ ? responsive
195
+ ? styles.gridColumnsResponsive(template)
196
+ : styles.gridColumns(template)
197
+ : undefined,
198
+ responsive && styles.rootResponsive,
199
+ ]}
200
+ >
201
+ {children}
202
+ </html.div>
203
+ )
204
+
205
+ // Slots render unconditionally — `responsive=false` still wants its sort/
206
+ // pagination chrome. Only the containerType:inline-size wrapper is
207
+ // conditional, since it only matters when @container queries fire.
208
+ const body = (
209
+ <>
210
+ {sortChip}
211
+ {grid}
212
+ {pagination}
213
+ </>
214
+ )
215
+
118
216
  return (
119
217
  <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>
218
+ {responsive ? <html.div style={styles.rootContainer}>{body}</html.div> : body}
130
219
  </TableContext.Provider>
131
220
  )
132
221
  }
133
222
 
134
223
  // --- Header ---
135
224
 
136
- function Header({children}: {children: ReactNode}) {
225
+ export function Header({children}: {children: ReactNode}) {
137
226
  return (
138
227
  <HeaderContext.Provider value={true}>
139
228
  <html.div role="rowgroup" style={styles.header}>
@@ -145,22 +234,24 @@ function Header({children}: {children: ReactNode}) {
145
234
 
146
235
  // --- Body ---
147
236
 
148
- function Body({children}: {children: ReactNode}) {
237
+ export function Body({children}: {children: ReactNode}) {
149
238
  const {variant} = useTable()
150
- const childArray = Children.toArray(children)
239
+ const childArray = Children.toArray(children) as ReactElement[]
151
240
 
152
241
  return (
153
242
  <HeaderContext.Provider value={false}>
154
243
  <html.div role="rowgroup" style={styles.body}>
155
244
  {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
245
+ if (variant !== 'striped') return child
246
+ // Key the provider by the child's React key (Children.toArray
247
+ // guarantees one) so reordered rows carry their providers along
248
+ // with them. Position is passed as the value because striping is
249
+ // a positional concern even when row identity is stable.
250
+ return (
251
+ <RowIndexContext.Provider key={child.key ?? index} value={index}>
252
+ {child}
253
+ </RowIndexContext.Provider>
254
+ )
164
255
  })}
165
256
  </html.div>
166
257
  </HeaderContext.Provider>
@@ -175,25 +266,81 @@ const RowIndexContext = createContext<number>(-1)
175
266
  // was only set up for `variant === 'bordered'`, which meant non-bordered
176
267
  // tables had every cell reading {index: 0} from the default — fine until
177
268
  // we needed to look up labels by column index.
269
+ //
270
+ // Interactive rows: pass `onClick` to make the row a navigation target.
271
+ // The role stays `row` (per ARIA grid pattern) — we don't wrap in an outer
272
+ // element with role=button, which would break the rowgroup → row hierarchy.
273
+ // A click that originates inside an interactive descendant (button, link,
274
+ // input, etc.) is treated as that descendant's click and does not fire
275
+ // onClick — prevents double-fire when a row contains action buttons.
276
+
277
+ interface RowProps {
278
+ children: ReactNode
279
+ /** When set, the row becomes focusable + clickable. */
280
+ onClick?: () => void
281
+ /** Per-row accessible name — required when onClick is set, recommended otherwise. */
282
+ 'aria-label'?: string
283
+ }
178
284
 
179
- function Row({children}: {children: ReactNode}) {
285
+ // Heuristic for "clicking a descendant means the descendant, not the row".
286
+ // Matches the elements the WAI-ARIA grid pattern lists as "widgets" plus the
287
+ // usual native focus-stealers.
288
+ const INTERACTIVE_SELECTOR =
289
+ 'button, a, input, select, textarea, [role="button"], [role="link"], [role="checkbox"], [role="menuitem"], [role="switch"], [role="tab"], [contenteditable="true"]'
290
+
291
+ export function Row({children, onClick, 'aria-label': ariaLabel}: RowProps) {
180
292
  const {variant} = useTable()
181
293
  const isHeader = useContext(HeaderContext)
182
294
  const rowIndex = useContext(RowIndexContext)
183
295
  const isEvenRow = rowIndex >= 0 && rowIndex % 2 === 1
184
- const childArray = Children.toArray(children)
296
+ const childArray = Children.toArray(children) as ReactElement[]
297
+ const isClickable = onClick !== undefined && !isHeader
185
298
 
186
299
  return (
187
300
  <html.div
188
301
  role="row"
302
+ tabIndex={isClickable ? 0 : undefined}
303
+ aria-label={isClickable ? ariaLabel : undefined}
304
+ onClick={
305
+ isClickable
306
+ ? (e) => {
307
+ // react-strict-dom's typed click event omits `target`, but the
308
+ // underlying SyntheticEvent still carries it. Cast through so
309
+ // we can detect clicks that originate inside an interactive
310
+ // descendant (action button, link, input) and let them be
311
+ // attributed to that widget instead of the row.
312
+ const target = (e as unknown as {target?: EventTarget | null}).target
313
+ if (target instanceof Element && target.closest(INTERACTIVE_SELECTOR)) {
314
+ return
315
+ }
316
+ onClick()
317
+ }
318
+ : undefined
319
+ }
320
+ onKeyDown={
321
+ isClickable
322
+ ? (e) => {
323
+ if (e.key === 'Enter' || e.key === ' ') {
324
+ onClick()
325
+ }
326
+ }
327
+ : undefined
328
+ }
189
329
  style={[
190
330
  styles.row,
191
331
  !isHeader && styles.bodyRow,
192
332
  !isHeader && variant === 'striped' && isEvenRow && styles.stripedEven,
333
+ isClickable && styles.clickableRow,
193
334
  ]}
194
335
  >
195
336
  {childArray.map((child, index) => (
196
- <CellIndexContext.Provider key={index} value={{index, total: childArray.length}}>
337
+ // Key by the child's React key (Children.toArray assigns one) so a
338
+ // reordered column carries its CellIndexContext provider along.
339
+ // The positional `index` lives in the provider's value, not its key.
340
+ <CellIndexContext.Provider
341
+ key={child.key ?? index}
342
+ value={{index, total: childArray.length}}
343
+ >
197
344
  {child}
198
345
  </CellIndexContext.Provider>
199
346
  ))}
@@ -205,24 +352,34 @@ const CellIndexContext = createContext<{index: number; total: number}>({index: 0
205
352
 
206
353
  // --- HeaderCell ---
207
354
 
208
- function HeaderCell({
355
+ export function HeaderCell({
209
356
  children,
210
357
  width: _width,
211
358
  label: _label,
212
- isActions: _isActions,
359
+ isActions,
213
360
  'aria-label': ariaLabel,
214
361
  }: {
215
362
  children?: ReactNode
216
363
  /** Column width: CSS value like '40px', '2fr', 'max-content'. Defaults to '1fr'. */
217
364
  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. */
365
+ /** Stack-mode label string. Optional when children is a plain string — the
366
+ * text content is used as the label automatically. Required when children
367
+ * contain JSX (icon + text, sort indicator, etc.). */
220
368
  label?: string
221
- /** Marks this column as the actions column — its body cells render as a
222
- * full-width footer in stack mode. */
369
+ /**
370
+ * @deprecated Has no effect on HeaderCell. Pass `isActions` on the
371
+ * matching `Table.Cell` instead — that's where the stack-mode footer
372
+ * layout actually applies.
373
+ */
223
374
  isActions?: boolean
224
375
  'aria-label'?: string
225
376
  }) {
377
+ if (isActions === true) {
378
+ devWarnOnce(
379
+ 'headerCell-isActions',
380
+ 'HeaderCell `isActions` prop has no effect and is deprecated. Pass `isActions` on the matching Table.Cell instead.',
381
+ )
382
+ }
226
383
  const {size, variant} = useTable()
227
384
  const {index, total} = useContext(CellIndexContext)
228
385
  const isLast = variant === 'bordered' && index === total - 1
@@ -250,7 +407,7 @@ HeaderCell.displayName = 'HeaderCell'
250
407
  // container query in non-stack modes. The span is omitted entirely when
251
408
  // isActions=true — actions footers don't show a label.
252
409
 
253
- function Cell({children, isActions}: {children: ReactNode; isActions?: boolean}) {
410
+ export function Cell({children, isActions}: {children: ReactNode; isActions?: boolean}) {
254
411
  const {size, variant, labels, responsive} = useTable()
255
412
  const {index, total} = useContext(CellIndexContext)
256
413
  const isLast = variant === 'bordered' && index === total - 1
@@ -277,7 +434,10 @@ function Cell({children, isActions}: {children: ReactNode; isActions?: boolean})
277
434
 
278
435
  // --- Export ---
279
436
 
437
+ import {FromTanstack} from './FromTanstack'
438
+
280
439
  export const Table = {
440
+ /** @deprecated Wrap behaviour is built into Table.Root. Drop this wrapper from new code. */
281
441
  Container,
282
442
  Root,
283
443
  Header,
@@ -289,4 +449,6 @@ export const Table = {
289
449
  SortIndicator,
290
450
  ColumnFilter,
291
451
  SortChip,
452
+ /** Renders a styled Table directly from a TanStack table instance. */
453
+ FromTanstack,
292
454
  }
@@ -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',
@@ -35,13 +60,13 @@ export const styles = css.create({
35
60
  },
36
61
 
37
62
  // Stack-mode override: the Root's grid collapses to a single column so
38
- // each Row spans full width and stacks vertically. Applied AFTER the
39
- // dynamic gridColumns(template) so this rule wins inside the @container.
63
+ // each Row spans full width and stacks vertically.
64
+ // NOTE: gridTemplateColumns is intentionally absent here. It is handled by
65
+ // gridColumnsResponsive() so that StyleX conflict resolution does not remove
66
+ // the explicit column template (a later style with the same property key
67
+ // always wins, so combining gridColumns + rootResponsive in one array would
68
+ // silently drop the column template).
40
69
  rootResponsive: {
41
- gridTemplateColumns: {
42
- default: null,
43
- [`@container (max-width: ${STACK_BP})`]: '1fr',
44
- },
45
70
  // The card-list look needs the outer border to disappear in stack
46
71
  // mode — each row paints its own border.
47
72
  borderWidth: {
@@ -273,8 +298,21 @@ export const styles = css.create({
273
298
  alignSelf: 'flex-start',
274
299
  },
275
300
 
276
- // Dynamic: grid columns applied on Root
301
+ // Dynamic: grid columns applied on Root (non-responsive path only).
277
302
  gridColumns: (template: string) => ({
278
303
  gridTemplateColumns: template,
279
304
  }),
305
+
306
+ // Dynamic: grid columns + stack-mode collapse in one style.
307
+ // Used instead of gridColumns() when responsive=true so that both the
308
+ // explicit column template and the @container override live in the same
309
+ // StyleX style object. If they were in separate styles (gridColumns +
310
+ // rootResponsive), StyleX conflict resolution would keep only the LAST
311
+ // style's gridTemplateColumns class and silently discard the earlier one.
312
+ gridColumnsResponsive: (template: string) => ({
313
+ gridTemplateColumns: {
314
+ default: template,
315
+ [`@container (max-width: ${STACK_BP})`]: '1fr',
316
+ },
317
+ }),
280
318
  })
@@ -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'