@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/dist/components/Combobox/useComboboxRoot.d.ts.map +1 -1
- package/dist/components/Table/SortChip.d.ts +28 -0
- package/dist/components/Table/SortChip.d.ts.map +1 -0
- package/dist/components/Table/Table.d.ts +21 -3
- package/dist/components/Table/Table.d.ts.map +1 -1
- package/dist/components/Table/Table.stories.d.ts +1 -0
- package/dist/components/Table/Table.stories.d.ts.map +1 -1
- package/dist/components/Table/styles.css.d.ts +49 -5
- package/dist/components/Table/styles.css.d.ts.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.js +2155 -2018
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/Combobox/useComboboxRoot.ts +17 -3
- package/src/components/Table/SortChip.tsx +52 -0
- package/src/components/Table/Table.stories.tsx +90 -0
- package/src/components/Table/Table.tsx +87 -30
- package/src/components/Table/styles.css.ts +189 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@duro-app/ui",
|
|
3
|
-
"version": "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.
|
|
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
|
-
//
|
|
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 (
|
|
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,
|
|
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
|
-
// ---
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
53
|
+
function Container({children}: {children: ReactNode}) {
|
|
54
|
+
return <html.div style={styles.container}>{children}</html.div>
|
|
55
|
+
}
|
|
47
56
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
98
|
+
return {template: widths.join(' '), labels}
|
|
77
99
|
}
|
|
78
100
|
|
|
79
|
-
|
|
80
|
-
const inferredTemplateRef = useRef<string | null>(null)
|
|
101
|
+
// --- Root ---
|
|
81
102
|
|
|
82
|
-
|
|
83
|
-
|
|
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={[
|
|
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
|
-
{
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
}
|