@duro-app/ui 0.31.0 → 0.33.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duro-app/ui",
3
- "version": "0.31.0",
3
+ "version": "0.33.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -53,7 +53,7 @@
53
53
  }
54
54
  },
55
55
  "dependencies": {
56
- "@duro-app/tokens": "^0.31.0"
56
+ "@duro-app/tokens": "^0.33.0"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@babel/preset-typescript": "^7.28.0",
@@ -190,12 +190,26 @@ export function useComboboxRoot({
190
190
  item?.element.scrollIntoView({block: 'nearest'})
191
191
  }, [highlightedId])
192
192
 
193
- // Sync input value with selected label when value changes externally
193
+ // Reconcile input text with the committed value once the popup closes.
194
+ // Three cases when transitioning from open → closed:
195
+ // - inputValue is empty: user cleared the field, treat that as
196
+ // "remove the selection" so the consumer's onValueChange fires
197
+ // with null and downstream UI (e.g. dependent fields) can reset.
198
+ // - inputValue doesn't match the selected label: user typed something
199
+ // but didn't pick anything; restore the label so the visible text
200
+ // and the committed value stay consistent.
201
+ // - inputValue equals the selected label: nothing to do.
194
202
  useEffect(() => {
195
- if (value && labels[value] && !open) {
203
+ if (open) return
204
+ if (value === null) return
205
+ if (inputValue === '') {
206
+ setValue(null)
207
+ return
208
+ }
209
+ if (labels[value] && inputValue !== labels[value]) {
196
210
  setInputValue(labels[value])
197
211
  }
198
- }, [value, labels, open, setInputValue])
212
+ }, [open, value, labels, inputValue, setInputValue, setValue])
199
213
 
200
214
  const ctx: ComboboxContextValue = useMemo(
201
215
  () => ({
@@ -0,0 +1,52 @@
1
+ import {html} from 'react-strict-dom'
2
+ import {styles} from './styles.css'
3
+ import {Menu} from '../Menu/Menu'
4
+
5
+ export interface SortValue {
6
+ readonly id: string
7
+ readonly desc: boolean
8
+ }
9
+
10
+ export interface SortChipProps {
11
+ readonly options: ReadonlyArray<{readonly id: string; readonly label: string}>
12
+ readonly value: SortValue | null
13
+ readonly onChange: (next: SortValue | null) => void
14
+ /** Visible label preceding the sort field, e.g. "Sort by". */
15
+ readonly label?: string
16
+ }
17
+
18
+ /**
19
+ * Sort selector for stack-mode Tables. Hidden in default/compact via
20
+ * `@container` query — see styles.sortChip. Consumers render this inside
21
+ * a `Table.Container` so the same query target reaches it.
22
+ *
23
+ * The chip is a simple controlled wrapper around `Menu`. It does NOT
24
+ * couple to @tanstack/react-table — the consumer wires `value` and
25
+ * `onChange` from whatever sort state they manage.
26
+ */
27
+ export function SortChip({options, value, onChange, label = 'Sort by'}: SortChipProps) {
28
+ const current = value ? options.find((o) => o.id === value.id) : null
29
+ const triggerLabel = current ? `${label}: ${current.label} ${value!.desc ? '↓' : '↑'}` : label
30
+
31
+ return (
32
+ <html.div style={styles.sortChip}>
33
+ <Menu.Root>
34
+ <Menu.Trigger>{triggerLabel}</Menu.Trigger>
35
+ <Menu.Popup>
36
+ {options.map((opt) => {
37
+ const isCurrent = value?.id === opt.id
38
+ const nextDesc = isCurrent ? !value!.desc : false
39
+ return (
40
+ <Menu.Item key={opt.id} onClick={() => onChange({id: opt.id, desc: nextDesc})}>
41
+ {opt.label}
42
+ {isCurrent ? ` ${value!.desc ? '↓' : '↑'}` : ''}
43
+ </Menu.Item>
44
+ )
45
+ })}
46
+ {value !== null && <Menu.Item onClick={() => onChange(null)}>Clear sort</Menu.Item>}
47
+ </Menu.Popup>
48
+ </Menu.Root>
49
+ </html.div>
50
+ )
51
+ }
52
+ SortChip.displayName = 'SortChip'
@@ -878,3 +878,93 @@ export const FullFeatured: Story = {
878
878
  await expect(canvas.getByText('Previous')).toBeInTheDocument()
879
879
  },
880
880
  }
881
+
882
+ // Demonstrates the three responsive modes. Resize the canvas (or wrap in
883
+ // a narrower container) to see Compact (~720px) and Stack (~440px).
884
+ const responsiveStyles = css.create({
885
+ resizable: {
886
+ resize: 'horizontal',
887
+ overflow: 'auto',
888
+ minWidth: '320px',
889
+ maxWidth: '100%',
890
+ width: '900px',
891
+ padding: spacing.sm,
892
+ borderWidth: 1,
893
+ borderStyle: 'dashed',
894
+ borderColor: colors.border,
895
+ },
896
+ })
897
+
898
+ export const Responsive: Story = {
899
+ name: 'Responsive (Container Queries)',
900
+ render: () => {
901
+ const grants = [
902
+ {principal: 'daddy', role: 'Admin', resource: 'all', expires: 'Never', granted_by: 'system'},
903
+ {
904
+ principal: 'alice',
905
+ role: 'Editor',
906
+ resource: 'documents/foo.md',
907
+ expires: '2026-12-31',
908
+ granted_by: 'daddy',
909
+ },
910
+ {principal: 'bob', role: 'Viewer', resource: 'all', expires: 'Never', granted_by: 'daddy'},
911
+ ]
912
+ const [sort, setSort] = useState<{id: string; desc: boolean} | null>(null)
913
+ const sortable = [
914
+ {id: 'principal', label: 'Principal'},
915
+ {id: 'role', label: 'Role'},
916
+ {id: 'expires', label: 'Expires'},
917
+ ]
918
+ return (
919
+ <html.div style={responsiveStyles.resizable}>
920
+ <Table.Container>
921
+ <Table.SortChip options={sortable} value={sort} onChange={setSort} />
922
+ <Table.Root>
923
+ <Table.Header>
924
+ <Table.Row>
925
+ <Table.HeaderCell label="Principal">Principal</Table.HeaderCell>
926
+ <Table.HeaderCell label="Role">Role</Table.HeaderCell>
927
+ <Table.HeaderCell label="Resource">Resource</Table.HeaderCell>
928
+ <Table.HeaderCell label="Expires">Expires</Table.HeaderCell>
929
+ <Table.HeaderCell label="Granted by">Granted by</Table.HeaderCell>
930
+ <Table.HeaderCell label="Actions" isActions>
931
+ Actions
932
+ </Table.HeaderCell>
933
+ </Table.Row>
934
+ </Table.Header>
935
+ <Table.Body>
936
+ {grants.map((g) => (
937
+ <Table.Row key={g.principal}>
938
+ <Table.Cell>{g.principal}</Table.Cell>
939
+ <Table.Cell>{g.role}</Table.Cell>
940
+ <Table.Cell>{g.resource}</Table.Cell>
941
+ <Table.Cell>{g.expires}</Table.Cell>
942
+ <Table.Cell>{g.granted_by}</Table.Cell>
943
+ <Table.Cell isActions>
944
+ <Button size="small" variant="secondary">
945
+ Revoke
946
+ </Button>
947
+ </Table.Cell>
948
+ </Table.Row>
949
+ ))}
950
+ </Table.Body>
951
+ </Table.Root>
952
+ </Table.Container>
953
+ </html.div>
954
+ )
955
+ },
956
+ play: async ({canvas}) => {
957
+ // At default canvas width (900px) we're in compact, headers nowrap,
958
+ // sort chip hidden. Just sanity-check the table renders the expected
959
+ // shape — visual regression is covered by Storybook + Chromatic.
960
+ const headers = canvas.getAllByRole('columnheader')
961
+ await expect(headers.length).toBe(6)
962
+ const cells = canvas.getAllByRole('cell')
963
+ // 3 rows × 6 columns = 18 cells
964
+ await expect(cells.length).toBe(18)
965
+ // Each non-actions body cell carries its label as a hidden span — we
966
+ // can't visually assert display:none in jsdom, but the text node
967
+ // exists in the DOM tree.
968
+ await expect(canvas.getAllByText('Principal').length).toBeGreaterThanOrEqual(2)
969
+ },
970
+ }
@@ -12,6 +12,7 @@ import {styles} from './styles.css'
12
12
  import {Pagination} from './Pagination'
13
13
  import {SortIndicator} from './SortIndicator'
14
14
  import {ColumnFilter} from './ColumnFilter'
15
+ import {SortChip} from './SortChip'
15
16
 
16
17
  // --- Types ---
17
18
 
@@ -23,6 +24,10 @@ export type TableSize = 'sm' | 'md'
23
24
  interface TableContextValue {
24
25
  variant: TableVariant
25
26
  size: TableSize
27
+ responsive: boolean
28
+ /** Header labels indexed by column position. Populated synchronously by
29
+ * extractColumnMeta() during Root render so SSR HTML carries them. */
30
+ labels: ReadonlyArray<string>
26
31
  /** Mutable ref: header Row writes inferred template, Root reads it */
27
32
  inferredTemplateRef: MutableRefObject<string | null>
28
33
  }
@@ -39,33 +44,50 @@ function useTable() {
39
44
 
40
45
  const HeaderContext = createContext(false)
41
46
 
42
- // --- Template context: set after first render pass ---
47
+ // --- Container — owns the @container query target ---
48
+ //
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.
43
52
 
44
- const TemplateContext = createContext<string | null>(null)
45
-
46
- // --- Root ---
53
+ function Container({children}: {children: ReactNode}) {
54
+ return <html.div style={styles.container}>{children}</html.div>
55
+ }
47
56
 
48
- interface RootProps {
49
- children: ReactNode
50
- variant?: TableVariant
51
- size?: TableSize
57
+ // --- Synchronous tree walk: widths + labels in document order ---
58
+ //
59
+ // Mirrors the pattern that was previously called extractTemplate(). Walks
60
+ // the JSX tree, finds HeaderCell elements, captures their `width` and
61
+ // `label` props (or extractText(children) fallback). Runs on every Root
62
+ // render — cheap (small N, no DOM) — but worth memoising on `children`
63
+ // reference if profiling flags it.
64
+
65
+ function extractText(node: ReactNode): string {
66
+ let out = ''
67
+ Children.forEach(node, (child) => {
68
+ if (typeof child === 'string' || typeof child === 'number') {
69
+ 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
+ }
74
+ })
75
+ return out
52
76
  }
53
77
 
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 {
78
+ function extractColumnMeta(children: ReactNode): {template: string; labels: string[]} {
59
79
  const widths: string[] = []
80
+ const labels: string[] = []
60
81
 
61
82
  function walk(node: ReactNode) {
62
83
  Children.forEach(node, (child) => {
63
84
  if (!isValidElement(child)) return
64
85
  const props = child.props as Record<string, any>
65
- // Check if this looks like a HeaderCell (has width prop or is inside Header)
66
86
  const displayName = (child.type as any)?.name || (child.type as any)?.displayName || ''
67
87
  if (displayName === 'HeaderCell' || child.type === HeaderCell) {
68
88
  widths.push(props.width || '1fr')
89
+ const label = props.label ?? extractText(props.children).trim()
90
+ labels.push(label)
69
91
  } else if (props.children) {
70
92
  walk(props.children)
71
93
  }
@@ -73,23 +95,35 @@ function extractTemplate(children: ReactNode): string {
73
95
  }
74
96
 
75
97
  walk(children)
76
- return widths.length > 0 ? widths.join(' ') : ''
98
+ return {template: widths.join(' '), labels}
77
99
  }
78
100
 
79
- function Root({children, variant = 'default', size = 'md'}: RootProps) {
80
- const inferredTemplateRef = useRef<string | null>(null)
101
+ // --- Root ---
81
102
 
82
- // Extract template from HeaderCell children synchronously
83
- const template = extractTemplate(children)
103
+ interface RootProps {
104
+ children: ReactNode
105
+ variant?: TableVariant
106
+ size?: TableSize
107
+ /** Opt out of responsive container-query behavior. Default true. */
108
+ responsive?: boolean
109
+ }
110
+
111
+ function Root({children, variant = 'default', size = 'md', responsive = true}: RootProps) {
112
+ const inferredTemplateRef = useRef<string | null>(null)
113
+ const {template, labels} = extractColumnMeta(children)
84
114
  if (template) {
85
115
  inferredTemplateRef.current = template
86
116
  }
87
117
 
88
118
  return (
89
- <TableContext.Provider value={{variant, size, inferredTemplateRef}}>
119
+ <TableContext.Provider value={{variant, size, responsive, labels, inferredTemplateRef}}>
90
120
  <html.div
91
121
  role="table"
92
- style={[styles.root, template ? styles.gridColumns(template) : undefined]}
122
+ style={[
123
+ styles.root,
124
+ template ? styles.gridColumns(template) : undefined,
125
+ responsive && styles.rootResponsive,
126
+ ]}
93
127
  >
94
128
  {children}
95
129
  </html.div>
@@ -136,6 +170,11 @@ function Body({children}: {children: ReactNode}) {
136
170
  const RowIndexContext = createContext<number>(-1)
137
171
 
138
172
  // --- Row ---
173
+ //
174
+ // Always provides CellIndexContext, regardless of variant. Previously this
175
+ // was only set up for `variant === 'bordered'`, which meant non-bordered
176
+ // tables had every cell reading {index: 0} from the default — fine until
177
+ // we needed to look up labels by column index.
139
178
 
140
179
  function Row({children}: {children: ReactNode}) {
141
180
  const {variant} = useTable()
@@ -153,13 +192,11 @@ function Row({children}: {children: ReactNode}) {
153
192
  !isHeader && variant === 'striped' && isEvenRow && styles.stripedEven,
154
193
  ]}
155
194
  >
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}
195
+ {childArray.map((child, index) => (
196
+ <CellIndexContext.Provider key={index} value={{index, total: childArray.length}}>
197
+ {child}
198
+ </CellIndexContext.Provider>
199
+ ))}
163
200
  </html.div>
164
201
  )
165
202
  }
@@ -171,11 +208,19 @@ const CellIndexContext = createContext<{index: number; total: number}>({index: 0
171
208
  function HeaderCell({
172
209
  children,
173
210
  width: _width,
211
+ label: _label,
212
+ isActions: _isActions,
174
213
  'aria-label': ariaLabel,
175
214
  }: {
176
215
  children?: ReactNode
177
216
  /** Column width: CSS value like '40px', '2fr', 'max-content'. Defaults to '1fr'. */
178
217
  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. */
220
+ label?: string
221
+ /** Marks this column as the actions column — its body cells render as a
222
+ * full-width footer in stack mode. */
223
+ isActions?: boolean
179
224
  'aria-label'?: string
180
225
  }) {
181
226
  const {size, variant} = useTable()
@@ -197,13 +242,19 @@ function HeaderCell({
197
242
  </html.div>
198
243
  )
199
244
  }
245
+ HeaderCell.displayName = 'HeaderCell'
200
246
 
201
247
  // --- Cell ---
248
+ //
249
+ // Renders the column-header label as a real <html.span>, hidden via
250
+ // container query in non-stack modes. The span is omitted entirely when
251
+ // isActions=true — actions footers don't show a label.
202
252
 
203
- function Cell({children}: {children: ReactNode}) {
204
- const {size, variant} = useTable()
253
+ function Cell({children, isActions}: {children: ReactNode; isActions?: boolean}) {
254
+ const {size, variant, labels, responsive} = useTable()
205
255
  const {index, total} = useContext(CellIndexContext)
206
256
  const isLast = variant === 'bordered' && index === total - 1
257
+ const label = labels[index] ?? ''
207
258
 
208
259
  return (
209
260
  <html.div
@@ -213,8 +264,12 @@ function Cell({children}: {children: ReactNode}) {
213
264
  size === 'sm' ? styles.cellSm : styles.cellMd,
214
265
  variant === 'bordered' && styles.borderedCell,
215
266
  isLast && styles.borderedCellLast,
267
+ isActions && styles.cellActions,
216
268
  ]}
217
269
  >
270
+ {responsive && !isActions && label !== '' ? (
271
+ <html.span style={styles.cellLabel}>{label}</html.span>
272
+ ) : null}
218
273
  {children}
219
274
  </html.div>
220
275
  )
@@ -223,6 +278,7 @@ function Cell({children}: {children: ReactNode}) {
223
278
  // --- Export ---
224
279
 
225
280
  export const Table = {
281
+ Container,
226
282
  Root,
227
283
  Header,
228
284
  Body,
@@ -232,4 +288,5 @@ export const Table = {
232
288
  Pagination,
233
289
  SortIndicator,
234
290
  ColumnFilter,
291
+ SortChip,
235
292
  }