@sableui/react 0.18.0 → 0.19.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/CHANGELOG.md +129 -0
- package/bin/sable.mjs +108 -14
- package/dist/{_contract-CDa9-NdB.d.ts → _contract-DlklV0Im.d.ts} +2 -2
- package/dist/blocks/index.d.ts +1 -1
- package/dist/components/avatar/avatar.js +16 -7
- package/dist/components/avatar/avatar.meta.js +43 -10
- package/dist/components/avatar/avatar.variants.js +26 -2
- package/dist/components/collapsible/collapsible.js +6 -5
- package/dist/components/collapsible/collapsible.meta.js +14 -5
- package/dist/components/data-table/data-table.js +132 -45
- package/dist/components/data-table/data-table.meta.js +63 -4
- package/dist/components/date-picker/date-picker.js +13 -6
- package/dist/components/date-picker/date-picker.meta.js +22 -0
- package/dist/components/menu/menu.js +2 -0
- package/dist/components/menu/menu.meta.js +2 -2
- package/dist/components/panel-group/panel-group.js +17 -1
- package/dist/components/panel-group/panel-group.meta.js +43 -3
- package/dist/components/side-nav/side-nav.variants.js +8 -3
- package/dist/components/sidebar/index.js +4 -0
- package/dist/components/sidebar/sidebar.js +90 -11
- package/dist/components/sidebar/sidebar.meta.js +38 -4
- package/dist/components/table/table.js +7 -3
- package/dist/components/tabs/tabs.js +10 -2
- package/dist/components/tabs/tabs.meta.js +4 -0
- package/dist/components/workspace/workspace.js +13 -3
- package/dist/components/workspace/workspace.meta.js +109 -1
- package/dist/file-hashes.json +53 -49
- package/dist/foundations/typography.js +13 -0
- package/dist/icon/Icon.js +21 -2
- package/dist/icon/index.d.ts +36 -1
- package/dist/icon/index.js +5 -0
- package/dist/icon/names.js +240 -0
- package/dist/icon-sets/reicon.js +19 -1
- package/dist/index.d.ts +272 -50
- package/dist/index.js +11 -1
- package/dist/lib/chart/heat.js +1 -1
- package/dist/lib/cn.js +1 -0
- package/dist/lib/use-media-query.js +41 -0
- package/dist/{meta-DX5r5wQL.d.ts → meta-Dfm9AExe.d.ts} +6 -0
- package/dist/patterns/dashboard-page.js +23 -17
- package/dist/patterns/index.d.ts +7 -2
- package/dist/patterns/index.js +5 -1
- package/dist/patterns/tracker-page.js +392 -0
- package/dist/patterns/tracker-page.meta.js +40 -0
- package/dist/presets/index.d.ts +1 -1
- package/dist/styles.css +179 -74
- package/dist/{theme-provider-BydfM5Z0.d.ts → theme-provider-BUBealk_.d.ts} +1 -1
- package/dist/themes/_contract.js +9 -1
- package/dist/themes/atelier/index.d.ts +1 -1
- package/dist/themes/ember/index.d.ts +1 -1
- package/dist/themes/index.d.ts +2 -2
- package/dist/themes/meridian/index.d.ts +1 -1
- package/dist/themes/poppy/index.d.ts +1 -1
- package/dist/themes/prism/index.d.ts +1 -1
- package/dist/themes/sable/index.d.ts +1 -1
- package/dist/themes/verdant/index.d.ts +1 -1
- package/llms/components/avatar.md +32 -9
- package/llms/components/collapsible.md +12 -4
- package/llms/components/data-table.md +40 -3
- package/llms/components/date-picker.md +15 -1
- package/llms/components/menu.md +1 -1
- package/llms/components/panel-group.md +34 -2
- package/llms/components/sidebar.md +17 -4
- package/llms/components/tabs.md +1 -0
- package/llms/components/workspace.md +81 -1
- package/llms/foundations/typography.md +4 -1
- package/llms/patterns/dashboard-page.md +18 -15
- package/llms/patterns/tracker-page.md +450 -0
- package/llms/reference/token-intent.md +3 -1
- package/llms/reference/utilities.md +4 -1
- package/llms/tasks/auditing-an-app.md +4 -1
- package/llms/tasks/building-a-page.md +3 -2
- package/llms/tasks/building-a-tool.md +101 -5
- package/llms/tasks/building-with-sable.md +11 -3
- package/llms/tasks/creating-a-preset.md +9 -0
- package/llms-full.txt +843 -54
- package/llms.txt +8 -7
- package/package.json +2 -1
- package/registry.json +491 -36
- package/scripts/adoption.mjs +15 -2
- package/scripts/app-css.mjs +129 -0
- package/scripts/grade.mjs +161 -2
- package/scripts/lint-rules.mjs +126 -0
- package/skills/sable/SKILL.md +33 -4
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/components/avatar/avatar.meta.ts
|
|
2
2
|
var avatarMeta = {
|
|
3
3
|
name: "Avatar",
|
|
4
|
-
summary: 'Circular profile image over Base UI Avatar \u2014 automatic image-load tracking with an initials/glyph fallback. Ergonomic Avatar plus low-level AvatarRoot/AvatarImage/AvatarFallback parts, and AvatarGroup for an overlapping stack with a "+N" overflow count.',
|
|
4
|
+
summary: 'Circular (or square) profile image over Base UI Avatar \u2014 automatic image-load tracking with an initials/glyph fallback. Ergonomic Avatar plus low-level AvatarRoot/AvatarImage/AvatarFallback parts, and AvatarGroup for an overlapping stack with a "+N" overflow count.',
|
|
5
5
|
keywords: [
|
|
6
6
|
"avatar",
|
|
7
7
|
"profile",
|
|
@@ -14,7 +14,12 @@ var avatarMeta = {
|
|
|
14
14
|
"stack",
|
|
15
15
|
"overflow",
|
|
16
16
|
"collaborators",
|
|
17
|
-
"facepile"
|
|
17
|
+
"facepile",
|
|
18
|
+
"monogram",
|
|
19
|
+
"project mark",
|
|
20
|
+
"workspace icon",
|
|
21
|
+
"org logo",
|
|
22
|
+
"assignee"
|
|
18
23
|
],
|
|
19
24
|
usage: {
|
|
20
25
|
description: 'Use Avatar to represent a person, team, or account with a compact visual identity. Wrap several in AvatarGroup for collaborators, assignees, or attendees \u2014 it overlaps them, rings each in the surface colour, and collapses the rest into a "+N" count; use the low-level parts only when you need custom image/fallback layout.',
|
|
@@ -33,12 +38,16 @@ var avatarMeta = {
|
|
|
33
38
|
},
|
|
34
39
|
{
|
|
35
40
|
guidance: "do",
|
|
36
|
-
description: "Pick the size by the job: sm in dense rows and tables, md (the default) beside one line of text, lg in a row with a title and subtitle, xl (96px) for a profile header beside the person's name, 2xl (160px) for an artist or profile hero and roster portraits. Keep one size within a list or group."
|
|
41
|
+
description: "Pick the size by the job: 2xs (20px) or xs (24px) in a compact workspace row (an assignee in an issue list, a project mark in a nav item), sm in dense rows and tables, md (the default) beside one line of text, lg in a row with a title and subtitle, xl (96px) for a profile header beside the person's name, 2xl (160px) for an artist or profile hero and roster portraits. Keep one size within a list or group."
|
|
37
42
|
},
|
|
38
43
|
{
|
|
39
44
|
guidance: "do",
|
|
40
45
|
description: 'Use AvatarGroup for a stack of people, and give it an aria-label with the real count ("12 collaborators"). Set `surface` (or `--avatar-group-ring`) to whatever the group sits on, so the separating ring matches a card or popover, not the page. When it shares a row with actions, set `fit` so it gives up avatars to the "+N" count before the row has to wrap.'
|
|
41
46
|
},
|
|
47
|
+
{
|
|
48
|
+
guidance: "do",
|
|
49
|
+
description: 'Use `shape="square"` for what is not a person (a workspace, a project, an org, a team), with `tone` set to a palette family so each one keeps its own colour: `<Avatar shape="square" tone="teal" fallback="N" alt="Northwind storefront" size="xs" />`. Circles mean people; keep that distinction across the app.'
|
|
50
|
+
},
|
|
42
51
|
{
|
|
43
52
|
guidance: "dont",
|
|
44
53
|
description: 'Hand-build a stack with negative margins and per-avatar ring classes, or fake the overflow as an Avatar with "+4" initials \u2014 screen readers hear "plus 4" with no context. Use AvatarGroup\'s `max`/`total`, or AvatarGroupCount.'
|
|
@@ -53,7 +62,7 @@ var avatarMeta = {
|
|
|
53
62
|
},
|
|
54
63
|
{
|
|
55
64
|
guidance: "dont",
|
|
56
|
-
description: '
|
|
65
|
+
description: 'Resize an Avatar with size classes (`className="size-5"`, `className="size-40"`) \u2014 the initials, the status indicator and the image fade stay at the size they were drawn for. Use size="2xs"/"xs" for dense rows and size="xl"/"2xl" for portraits, which scale all three. Don\'t hand-build a tinted initials tile for a project or workspace mark either: that is Avatar shape="square" with a tone. Past 160px, or for a cover, it is media: an AspectBox, not an avatar.'
|
|
57
66
|
}
|
|
58
67
|
],
|
|
59
68
|
anatomy: [
|
|
@@ -118,9 +127,21 @@ var avatarMeta = {
|
|
|
118
127
|
},
|
|
119
128
|
{
|
|
120
129
|
name: "size",
|
|
121
|
-
type: '"sm" | "md" | "lg" | "xl" | "2xl"',
|
|
130
|
+
type: '"2xs" | "xs" | "sm" | "md" | "lg" | "xl" | "2xl"',
|
|
122
131
|
default: '"md"',
|
|
123
|
-
description: "Square footprint: 32px / 40px / 48px for rows; 96px / 160px for portraits (a profile header, an artist hero, a roster tile). The initials, a status indicator and its cut-out ring scale with it, and at the portrait sizes the photo fades in over the resting surface."
|
|
132
|
+
description: "Square footprint: 20px / 24px for compact workspace rows; 32px / 40px / 48px for rows; 96px / 160px for portraits (a profile header, an artist hero, a roster tile). The initials, a status indicator and its cut-out ring scale with it, and at the portrait sizes the photo fades in over the resting surface."
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
name: "shape",
|
|
136
|
+
type: '"circle" | "square"',
|
|
137
|
+
default: '"circle"',
|
|
138
|
+
description: "The frame's corner. circle is a person. square is what is not a face \u2014 a workspace, a project, an org, a team \u2014 with a radius from the theme's steps that grows with the size (4px at 20\u201324px to 20px at 160px), so a square theme stays square. A status indicator sits on the square's corner."
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: "tone",
|
|
142
|
+
type: '"neutral" | "red" | "orange" | "yellow" | "green" | "teal" | "blue" | "purple" | "pink"',
|
|
143
|
+
default: '"neutral"',
|
|
144
|
+
description: `The fallback's colour. neutral is the muted surface; a palette family paints its wash behind the initials and its deep ink on them (a soft Badge's pair, held to contrast in every theme), for a category colour such as one per project. Ignored by variant="art", which colours by seed.`
|
|
124
145
|
},
|
|
125
146
|
{
|
|
126
147
|
name: "variant",
|
|
@@ -137,7 +158,7 @@ var avatarMeta = {
|
|
|
137
158
|
subcomponents: [
|
|
138
159
|
{
|
|
139
160
|
name: "AvatarRoot",
|
|
140
|
-
type: "Base UI Avatar.Root props + size + className",
|
|
161
|
+
type: "Base UI Avatar.Root props + size + shape + tone + className",
|
|
141
162
|
description: "Low-level circular frame for full composition control \u2014 clips the image and holds the `size` axis. Wraps AvatarImage and AvatarFallback."
|
|
142
163
|
},
|
|
143
164
|
{
|
|
@@ -152,17 +173,18 @@ var avatarMeta = {
|
|
|
152
173
|
},
|
|
153
174
|
{
|
|
154
175
|
name: "AvatarGroup",
|
|
155
|
-
type: '{ size?: "sm" | "md" | "lg"; surface?: "background" | "card" | "popover" | "muted"; max?: number; fit?: boolean; total?: number; countLabel?: (count: number) => string; "aria-label"?: string; className?: string; children: ReactNode }',
|
|
176
|
+
type: '{ size?: "2xs" | "xs" | "sm" | "md" | "lg"; surface?: "background" | "card" | "popover" | "muted"; max?: number; fit?: boolean; total?: number; countLabel?: (count: number) => string; "aria-label"?: string; className?: string; children: ReactNode }',
|
|
156
177
|
description: 'Overlapping stack (role=group) \u2014 name it with aria-label ("12 collaborators"). `size` applies to every avatar inside. `max` shows that many and collapses the rest into a "+N" count; `total` is the real head-count when you only pass some people. `fit` shows only as many avatars as fit the width the group is given (it fills the space left in a flex row) and folds the rest into the count, so a tight row reads "MO SR +5" instead of wrapping. The separating ring reads the inherited `--avatar-group-ring` (default: the page background); `surface` sets it to card, popover, or muted.'
|
|
157
178
|
},
|
|
158
179
|
{
|
|
159
180
|
name: "AvatarGroupCount",
|
|
160
|
-
type: '{ count: number; label?: string; size?: "sm" | "md" | "lg"; className?: string }',
|
|
181
|
+
type: '{ count: number; label?: string; size?: "2xs" | "xs" | "sm" | "md" | "lg"; className?: string }',
|
|
161
182
|
description: 'The "+N" avatar. Shows "+4" and reads "and 4 more" (override with label). AvatarGroup renders it for you from max/total; use it directly only when composing the stack yourself.'
|
|
162
183
|
}
|
|
163
184
|
],
|
|
164
185
|
variants: {
|
|
165
|
-
size: ["sm", "md", "lg", "xl", "2xl"],
|
|
186
|
+
size: ["2xs", "xs", "sm", "md", "lg", "xl", "2xl"],
|
|
187
|
+
shape: ["circle", "square"],
|
|
166
188
|
variant: ["neutral", "art"]
|
|
167
189
|
},
|
|
168
190
|
tokensUsed: ["muted", "muted-foreground", "background", "card", "popover", "overlay", "overlay-foreground"],
|
|
@@ -205,6 +227,17 @@ var avatarMeta = {
|
|
|
205
227
|
<p className="text-body-sm text-muted-foreground">1.2M monthly listeners</p>
|
|
206
228
|
</div>
|
|
207
229
|
</div>`
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
label: "Dense row sizes",
|
|
233
|
+
code: `<Avatar fallback="EL" alt="Emma Lee" size="2xs" />
|
|
234
|
+
<Avatar fallback="EL" alt="Emma Lee" size="xs" />`
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
label: "Project marks",
|
|
238
|
+
code: `<Avatar shape="square" tone="teal" size="xs" fallback="N" alt="Northwind storefront" />
|
|
239
|
+
<Avatar shape="square" tone="orange" size="xs" fallback="H" alt="Harbor brand refresh" />
|
|
240
|
+
<Avatar shape="square" tone="purple" size="xs" fallback="L" alt="Lumen editorial" />`
|
|
208
241
|
},
|
|
209
242
|
{
|
|
210
243
|
label: "Low-level parts",
|
|
@@ -1,19 +1,40 @@
|
|
|
1
1
|
// src/components/avatar/avatar.variants.ts
|
|
2
2
|
import { cva } from "class-variance-authority";
|
|
3
|
+
var AVATAR_SQUARE_RADIUS = {
|
|
4
|
+
"2xs": "rounded-xs",
|
|
5
|
+
xs: "rounded-xs",
|
|
6
|
+
sm: "rounded-sm",
|
|
7
|
+
md: "rounded-sm",
|
|
8
|
+
lg: "rounded-md",
|
|
9
|
+
xl: "rounded-lg",
|
|
10
|
+
"2xl": "rounded-xl"
|
|
11
|
+
};
|
|
3
12
|
var avatarVariants = cva(
|
|
4
|
-
"relative inline-flex shrink-0 select-none items-center justify-center overflow-hidden
|
|
13
|
+
"relative inline-flex shrink-0 select-none items-center justify-center overflow-hidden bg-muted",
|
|
5
14
|
{
|
|
6
15
|
variants: {
|
|
7
16
|
size: {
|
|
17
|
+
"2xs": "size-5 text-ui-micro",
|
|
18
|
+
xs: "size-6 text-label-xs",
|
|
8
19
|
sm: "size-8 text-label-sm",
|
|
9
20
|
md: "size-10 text-label-md",
|
|
10
21
|
lg: "size-12 text-label-lg",
|
|
11
22
|
xl: "size-24 text-heading-2",
|
|
12
23
|
"2xl": "size-40 text-display-lg"
|
|
24
|
+
},
|
|
25
|
+
shape: {
|
|
26
|
+
circle: "rounded-full",
|
|
27
|
+
square: ""
|
|
13
28
|
}
|
|
14
29
|
},
|
|
30
|
+
compoundVariants: Object.entries(AVATAR_SQUARE_RADIUS).map(([size, className]) => ({
|
|
31
|
+
shape: "square",
|
|
32
|
+
size,
|
|
33
|
+
className
|
|
34
|
+
})),
|
|
15
35
|
defaultVariants: {
|
|
16
|
-
size: "md"
|
|
36
|
+
size: "md",
|
|
37
|
+
shape: "circle"
|
|
17
38
|
}
|
|
18
39
|
}
|
|
19
40
|
);
|
|
@@ -22,6 +43,8 @@ var avatarGroupVariants = cva(
|
|
|
22
43
|
{
|
|
23
44
|
variants: {
|
|
24
45
|
size: {
|
|
46
|
+
"2xs": "*:not-first:-ms-1",
|
|
47
|
+
xs: "*:not-first:-ms-1",
|
|
25
48
|
sm: "*:not-first:-ms-1",
|
|
26
49
|
md: "*:not-first:-ms-1.5",
|
|
27
50
|
lg: "*:not-first:-ms-2"
|
|
@@ -39,6 +62,7 @@ var avatarGroupVariants = cva(
|
|
|
39
62
|
}
|
|
40
63
|
);
|
|
41
64
|
export {
|
|
65
|
+
AVATAR_SQUARE_RADIUS,
|
|
42
66
|
avatarGroupVariants,
|
|
43
67
|
avatarVariants
|
|
44
68
|
};
|
|
@@ -5,15 +5,16 @@ import { cn } from "../../lib/cn.js";
|
|
|
5
5
|
import { jsx } from "react/jsx-runtime";
|
|
6
6
|
var Collapsible = Base.Root;
|
|
7
7
|
var CollapsibleTrigger = Base.Trigger;
|
|
8
|
-
|
|
8
|
+
var DIRECTION = {
|
|
9
|
+
down: "[[data-panel-open]_&]:rotate-180",
|
|
10
|
+
end: "-rotate-90 rtl:rotate-90 [[data-panel-open]_&]:rotate-0"
|
|
11
|
+
};
|
|
12
|
+
function CollapsibleIndicator({ direction = "down", className }) {
|
|
9
13
|
return /* @__PURE__ */ jsx(
|
|
10
14
|
Icon,
|
|
11
15
|
{
|
|
12
16
|
name: "chevron-down",
|
|
13
|
-
className: cn(
|
|
14
|
-
"text-xs text-muted-foreground motion-interactive group-data-[panel-open]/collapsible:rotate-180",
|
|
15
|
-
className
|
|
16
|
-
)
|
|
17
|
+
className: cn("text-xs text-muted-foreground motion-interactive", DIRECTION[direction], className)
|
|
17
18
|
}
|
|
18
19
|
);
|
|
19
20
|
}
|
|
@@ -7,7 +7,12 @@ var collapsibleMeta = {
|
|
|
7
7
|
"collapsible",
|
|
8
8
|
"disclosure",
|
|
9
9
|
"expand",
|
|
10
|
-
"collapse"
|
|
10
|
+
"collapse",
|
|
11
|
+
"chevron",
|
|
12
|
+
"caret",
|
|
13
|
+
"rotate",
|
|
14
|
+
"group header",
|
|
15
|
+
"section toggle"
|
|
11
16
|
],
|
|
12
17
|
usage: {
|
|
13
18
|
description: "Use Collapsible for a single piece of optional content that can expand in place.",
|
|
@@ -87,8 +92,8 @@ var collapsibleMeta = {
|
|
|
87
92
|
},
|
|
88
93
|
{
|
|
89
94
|
name: "CollapsibleIndicator",
|
|
90
|
-
type:
|
|
91
|
-
description:
|
|
95
|
+
type: '{ direction?: "down" | "end"; className? }',
|
|
96
|
+
description: `Auto-rotating disclosure chevron. Place it anywhere inside the trigger content; it reads the trigger's open state itself, no class to add. direction="down" (default) flips 180\xB0 on open; direction="end" points to the inline end while closed and turns down on open \u2014 the caret for a grouped list's section header or a tree row. Use it instead of rotating your own chevron with CSS, which snaps with no transition.`
|
|
92
97
|
}
|
|
93
98
|
],
|
|
94
99
|
tokensUsed: ["foreground", "muted-foreground"],
|
|
@@ -115,7 +120,7 @@ var collapsibleMeta = {
|
|
|
115
120
|
slot: "transition",
|
|
116
121
|
recipe: "motion-interactive",
|
|
117
122
|
part: "CollapsibleIndicator",
|
|
118
|
-
why: "The chevron turns with the panel rather than flipping in one frame: motion-interactive lists `rotate`, the property rotate-180
|
|
123
|
+
why: "The chevron turns with the panel rather than flipping in one frame: motion-interactive lists `rotate`, the property rotate-180 and rotate-0 write."
|
|
119
124
|
},
|
|
120
125
|
{
|
|
121
126
|
slot: "activity",
|
|
@@ -141,7 +146,11 @@ var collapsibleMeta = {
|
|
|
141
146
|
},
|
|
142
147
|
{
|
|
143
148
|
label: "With rotating chevron",
|
|
144
|
-
code: '<Collapsible><CollapsibleTrigger
|
|
149
|
+
code: '<Collapsible><CollapsibleTrigger render={<Button variant="ghost">Details <CollapsibleIndicator /></Button>} /><CollapsiblePanel><p className="pt-3">Revealed on toggle.</p></CollapsiblePanel></Collapsible>'
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
label: "Group header with a side caret",
|
|
153
|
+
code: '<Collapsible defaultOpen><CollapsibleTrigger render={<Button variant="ghost" size="xs"><CollapsibleIndicator direction="end" />In progress <span className="text-ui-num text-muted-foreground">5</span></Button>} /><CollapsiblePanel><ul className="text-ui-body"><li>Refine product detail page</li><li>Build the ingredient library</li></ul></CollapsiblePanel></Collapsible>'
|
|
145
154
|
}
|
|
146
155
|
]
|
|
147
156
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
// src/components/data-table/data-table.tsx
|
|
4
|
-
import { useMemo, useState } from "react";
|
|
4
|
+
import { useId, useMemo, useState } from "react";
|
|
5
5
|
import { Icon } from "../../icon/index.js";
|
|
6
6
|
import { cn } from "../../lib/cn.js";
|
|
7
7
|
import { Button } from "../button/button.js";
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
TableHeader,
|
|
16
16
|
TableRow
|
|
17
17
|
} from "../table/index.js";
|
|
18
|
-
import { jsx, jsxs } from "react/jsx-runtime";
|
|
18
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
19
19
|
var ALIGN = {
|
|
20
20
|
start: "text-start",
|
|
21
21
|
center: "text-center",
|
|
@@ -36,9 +36,18 @@ function DataTable({
|
|
|
36
36
|
defaultSort,
|
|
37
37
|
empty = "No results.",
|
|
38
38
|
caption,
|
|
39
|
+
groupBy,
|
|
40
|
+
groups,
|
|
41
|
+
collapsible = true,
|
|
42
|
+
expandedGroups,
|
|
43
|
+
onExpandedGroupsChange,
|
|
39
44
|
className
|
|
40
45
|
}) {
|
|
41
46
|
const [sort, setSort] = useState(defaultSort ?? null);
|
|
47
|
+
const [closedGroups, setClosedGroups] = useState(
|
|
48
|
+
() => new Set((groups ?? []).filter((g) => g.defaultExpanded === false).map((g) => g.key))
|
|
49
|
+
);
|
|
50
|
+
const uid = useId();
|
|
42
51
|
function toggleSort(key) {
|
|
43
52
|
setSort((prev) => {
|
|
44
53
|
if (prev?.key !== key) return { key, direction: "asc" };
|
|
@@ -63,13 +72,44 @@ function DataTable({
|
|
|
63
72
|
return sort.direction === "asc" ? c : -c;
|
|
64
73
|
});
|
|
65
74
|
}, [data, sort, columns]);
|
|
75
|
+
const sections = useMemo(() => {
|
|
76
|
+
if (!groupBy) return null;
|
|
77
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
78
|
+
for (const row of data) {
|
|
79
|
+
const key = groupBy(row);
|
|
80
|
+
if (!byKey.has(key)) byKey.set(key, []);
|
|
81
|
+
}
|
|
82
|
+
rows.forEach((row, index) => byKey.get(groupBy(row))?.push({ row, index }));
|
|
83
|
+
const listed = new Map((groups ?? []).map((g) => [g.key, g]));
|
|
84
|
+
const order = [...listed.keys(), ...[...byKey.keys()].filter((k) => !listed.has(k))];
|
|
85
|
+
return order.filter((key) => (byKey.get(key)?.length ?? 0) > 0).map((key) => ({ key, group: listed.get(key), items: byKey.get(key) ?? [] }));
|
|
86
|
+
}, [groupBy, groups, data, rows]);
|
|
87
|
+
const isExpanded = (key) => !collapsible || (expandedGroups ? expandedGroups.has(key) : !closedGroups.has(key));
|
|
88
|
+
function toggleGroup(key) {
|
|
89
|
+
const open = isExpanded(key);
|
|
90
|
+
if (!expandedGroups) {
|
|
91
|
+
setClosedGroups((prev) => {
|
|
92
|
+
const next = new Set(prev);
|
|
93
|
+
if (open) next.add(key);
|
|
94
|
+
else next.delete(key);
|
|
95
|
+
return next;
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
if (onExpandedGroupsChange) {
|
|
99
|
+
const next = new Set((sections ?? []).map((s) => s.key).filter(isExpanded));
|
|
100
|
+
if (open) next.delete(key);
|
|
101
|
+
else next.add(key);
|
|
102
|
+
onExpandedGroupsChange(next);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const shownRows = sections ? sections.flatMap((s) => isExpanded(s.key) ? s.items.map((it) => it.row) : []) : rows;
|
|
66
106
|
const selectable = Boolean(onSelectedKeysChange);
|
|
67
107
|
const dataIndex = selectable ? new Map(data.map((row, i) => [row, i])) : null;
|
|
68
108
|
const keyOf = (row) => {
|
|
69
109
|
const i = dataIndex?.get(row) ?? 0;
|
|
70
110
|
return rowKey ? rowKey(row, i) : i;
|
|
71
111
|
};
|
|
72
|
-
const shownKeys = selectable ?
|
|
112
|
+
const shownKeys = selectable ? shownRows.map(keyOf) : [];
|
|
73
113
|
const checkedCount = selectable ? shownKeys.filter((k) => selectedKeys?.has(k)).length : 0;
|
|
74
114
|
const allChecked = shownKeys.length > 0 && checkedCount === shownKeys.length;
|
|
75
115
|
function setChecked(keys, checked) {
|
|
@@ -80,6 +120,50 @@ function DataTable({
|
|
|
80
120
|
}
|
|
81
121
|
onSelectedKeysChange?.(next);
|
|
82
122
|
}
|
|
123
|
+
const span = columns.length + (selectable ? 1 : 0);
|
|
124
|
+
function renderRow(row, i, hidden = false) {
|
|
125
|
+
const key = selectable ? keyOf(row) : i;
|
|
126
|
+
const checked = selectable && Boolean(selectedKeys?.has(key));
|
|
127
|
+
return /* @__PURE__ */ jsxs(
|
|
128
|
+
TableRow,
|
|
129
|
+
{
|
|
130
|
+
hidden: hidden || void 0,
|
|
131
|
+
"data-selected": checked || isRowSelected?.(row) ? "" : void 0,
|
|
132
|
+
onClick: onRowClick ? () => onRowClick(row) : void 0,
|
|
133
|
+
tabIndex: onRowClick ? 0 : void 0,
|
|
134
|
+
onKeyDown: onRowClick ? (e) => {
|
|
135
|
+
if (e.target !== e.currentTarget) return;
|
|
136
|
+
if (e.key !== "Enter" && e.key !== " ") return;
|
|
137
|
+
e.preventDefault();
|
|
138
|
+
onRowClick(row);
|
|
139
|
+
} : void 0,
|
|
140
|
+
className: onRowClick ? "cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring" : void 0,
|
|
141
|
+
children: [
|
|
142
|
+
selectable ? (
|
|
143
|
+
// A click on the box checks it; it never opens the row.
|
|
144
|
+
/* @__PURE__ */ jsx(TableCell, { className: "w-0", onClick: (e) => e.stopPropagation(), children: /* @__PURE__ */ jsx(
|
|
145
|
+
Checkbox,
|
|
146
|
+
{
|
|
147
|
+
"aria-label": rowSelectionLabel(row),
|
|
148
|
+
checked,
|
|
149
|
+
onCheckedChange: (next) => setChecked([key], next),
|
|
150
|
+
className: "align-middle"
|
|
151
|
+
}
|
|
152
|
+
) })
|
|
153
|
+
) : null,
|
|
154
|
+
columns.map((col) => /* @__PURE__ */ jsx(
|
|
155
|
+
TableCell,
|
|
156
|
+
{
|
|
157
|
+
className: cn(col.align && ALIGN[col.align], col.className),
|
|
158
|
+
children: col.cell ? col.cell(row) : row[col.key]
|
|
159
|
+
},
|
|
160
|
+
col.key
|
|
161
|
+
))
|
|
162
|
+
]
|
|
163
|
+
},
|
|
164
|
+
rowKey ? rowKey(row, i) : i
|
|
165
|
+
);
|
|
166
|
+
}
|
|
83
167
|
return /* @__PURE__ */ jsxs(Table, { className, children: [
|
|
84
168
|
caption ? /* @__PURE__ */ jsx(TableCaption, { children: caption }) : null,
|
|
85
169
|
/* @__PURE__ */ jsx(TableHeader, { children: /* @__PURE__ */ jsxs(TableRow, { children: [
|
|
@@ -139,58 +223,61 @@ function DataTable({
|
|
|
139
223
|
);
|
|
140
224
|
})
|
|
141
225
|
] }) }),
|
|
142
|
-
/* @__PURE__ */ jsx(TableBody, { children:
|
|
226
|
+
rows.length === 0 ? /* @__PURE__ */ jsx(TableBody, { children: /* @__PURE__ */ jsx(TableRow, { children: /* @__PURE__ */ jsx(
|
|
143
227
|
TableCell,
|
|
144
228
|
{
|
|
145
|
-
colSpan:
|
|
229
|
+
colSpan: span,
|
|
146
230
|
className: cn(
|
|
147
231
|
"text-center text-muted-foreground",
|
|
148
232
|
typeof empty === "string" && "py-8"
|
|
149
233
|
),
|
|
150
234
|
children: empty
|
|
151
235
|
}
|
|
152
|
-
) }) :
|
|
153
|
-
const
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
{
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
className: onRowClick ? "cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring" : void 0,
|
|
168
|
-
children: [
|
|
169
|
-
selectable ? (
|
|
170
|
-
// A click on the box checks it; it never opens the row.
|
|
171
|
-
/* @__PURE__ */ jsx(TableCell, { className: "w-0", onClick: (e) => e.stopPropagation(), children: /* @__PURE__ */ jsx(
|
|
172
|
-
Checkbox,
|
|
173
|
-
{
|
|
174
|
-
"aria-label": rowSelectionLabel(row),
|
|
175
|
-
checked,
|
|
176
|
-
onCheckedChange: (next) => setChecked([key], next),
|
|
177
|
-
className: "align-middle"
|
|
178
|
-
}
|
|
179
|
-
) })
|
|
180
|
-
) : null,
|
|
181
|
-
columns.map((col) => /* @__PURE__ */ jsx(
|
|
182
|
-
TableCell,
|
|
236
|
+
) }) }) : sections ? sections.map(({ key, group, items }, n) => {
|
|
237
|
+
const expanded = isExpanded(key);
|
|
238
|
+
const bodyId = `${uid}-group-${n}`;
|
|
239
|
+
const name = /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
240
|
+
group?.icon,
|
|
241
|
+
/* @__PURE__ */ jsx("span", { children: group?.label ?? key }),
|
|
242
|
+
/* @__PURE__ */ jsx("span", { className: "tabular-nums text-muted-foreground", children: items.length })
|
|
243
|
+
] });
|
|
244
|
+
return (
|
|
245
|
+
// One tbody per group: a row group to assistive tech, headed by its
|
|
246
|
+
// own header row, so the group rows are never read as data rows.
|
|
247
|
+
/* @__PURE__ */ jsxs(TableBody, { id: bodyId, "data-group": key, children: [
|
|
248
|
+
/* @__PURE__ */ jsx(TableRow, { "data-group-header": "", className: "border-t [tbody>&:hover]:bg-transparent", children: /* @__PURE__ */ jsx(TableHead, { scope: "rowgroup", colSpan: span, className: "h-auto bg-muted/50 py-1 text-foreground", children: /* @__PURE__ */ jsxs("div", { className: "flex min-w-0 items-center justify-between gap-2", children: [
|
|
249
|
+
collapsible ? /* @__PURE__ */ jsxs(
|
|
250
|
+
Button,
|
|
183
251
|
{
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
252
|
+
variant: "ghost",
|
|
253
|
+
size: "xs",
|
|
254
|
+
bleed: "start",
|
|
255
|
+
"aria-expanded": expanded,
|
|
256
|
+
"aria-controls": bodyId,
|
|
257
|
+
onClick: () => toggleGroup(key),
|
|
258
|
+
className: "gap-2",
|
|
259
|
+
children: [
|
|
260
|
+
/* @__PURE__ */ jsx(
|
|
261
|
+
Icon,
|
|
262
|
+
{
|
|
263
|
+
name: "chevron-down",
|
|
264
|
+
className: cn(
|
|
265
|
+
"text-xs text-muted-foreground motion-interactive",
|
|
266
|
+
// Closed, it points along the reading direction.
|
|
267
|
+
!expanded && "-rotate-90 rtl:rotate-90"
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
),
|
|
271
|
+
name
|
|
272
|
+
]
|
|
273
|
+
}
|
|
274
|
+
) : /* @__PURE__ */ jsx("span", { className: "flex min-w-0 items-center gap-2", children: name }),
|
|
275
|
+
group?.action ? /* @__PURE__ */ jsx("div", { className: "flex shrink-0 items-center gap-1", children: group.action }) : null
|
|
276
|
+
] }) }) }),
|
|
277
|
+
items.map(({ row, index }) => renderRow(row, index, !expanded))
|
|
278
|
+
] }, key)
|
|
192
279
|
);
|
|
193
|
-
}) })
|
|
280
|
+
}) : /* @__PURE__ */ jsx(TableBody, { children: rows.map((row, i) => renderRow(row, i)) })
|
|
194
281
|
] });
|
|
195
282
|
}
|
|
196
283
|
DataTable.displayName = "DataTable";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/components/data-table/data-table.meta.ts
|
|
2
2
|
var dataTableMeta = {
|
|
3
3
|
name: "DataTable",
|
|
4
|
-
summary: "A data-driven table over the Table primitives \u2014 a columns def + data array with built-in client-side sorting (click a sortable header to cycle asc \u2192 desc \u2192 none), optional row click / selection, and an empty state. For server data or paging, sort/page the data and pair with Pagination.",
|
|
4
|
+
summary: "A data-driven table over the Table primitives \u2014 a columns def + data array with built-in client-side sorting (click a sortable header to cycle asc \u2192 desc \u2192 none), optional row click / selection, rows grouped under collapsible header rows (groupBy), and an empty state. For server data or paging, sort/page the data and pair with Pagination.",
|
|
5
5
|
keywords: [
|
|
6
6
|
"data table",
|
|
7
7
|
"table",
|
|
@@ -10,7 +10,14 @@ var dataTableMeta = {
|
|
|
10
10
|
"rows",
|
|
11
11
|
"empty state",
|
|
12
12
|
"pagination",
|
|
13
|
-
"list"
|
|
13
|
+
"list",
|
|
14
|
+
"grouped",
|
|
15
|
+
"group by",
|
|
16
|
+
"sections",
|
|
17
|
+
"issue list",
|
|
18
|
+
"issue tracker",
|
|
19
|
+
"status groups",
|
|
20
|
+
"collapsible groups"
|
|
14
21
|
],
|
|
15
22
|
usage: {
|
|
16
23
|
description: "Use DataTable when a data array and column definition should render a sortable, semantic table with optional row actions and an empty state. Use the lower-level Table parts for hand-authored markup or highly custom row layouts.",
|
|
@@ -33,7 +40,11 @@ var dataTableMeta = {
|
|
|
33
40
|
},
|
|
34
41
|
{
|
|
35
42
|
guidance: "do",
|
|
36
|
-
description: "
|
|
43
|
+
description: "Group rows by status for an issue tracker (or by owner, by day) with groupBy + groups on ONE DataTable: every group shares the header and the column widths, each header row carries the group's count and an add action, and sorting applies within each group. Do not stack one DataTable per group inside Collapsibles; the header repeats and the columns stop lining up."
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
guidance: "do",
|
|
47
|
+
description: "Move to TanStack Table, rendered with Sable's Table parts, once the table needs server-side sorting or filtering, column visibility or resizing, nested or aggregated grouping, or virtualised rows. DataTable stays dependency-free and does not grow into a data grid; `sable docs tables-with-tanstack` maps one onto the other."
|
|
37
48
|
},
|
|
38
49
|
{
|
|
39
50
|
guidance: "dont",
|
|
@@ -60,6 +71,11 @@ var dataTableMeta = {
|
|
|
60
71
|
required: false,
|
|
61
72
|
description: "Content spanning all columns when the row array is empty."
|
|
62
73
|
},
|
|
74
|
+
{
|
|
75
|
+
name: "Group header row",
|
|
76
|
+
required: false,
|
|
77
|
+
description: "With groupBy: one row per group heading its own tbody (a th with scope=rowgroup across every column) holding the disclosure toggle, the group's icon, label and count, and an optional trailing action."
|
|
78
|
+
},
|
|
63
79
|
{
|
|
64
80
|
name: "Caption",
|
|
65
81
|
required: false,
|
|
@@ -136,6 +152,32 @@ var dataTableMeta = {
|
|
|
136
152
|
name: "caption",
|
|
137
153
|
type: "ReactNode",
|
|
138
154
|
description: "Bottom table caption."
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
name: "groupBy",
|
|
158
|
+
type: "(row) => string",
|
|
159
|
+
description: "Group the rows under header rows by the key this returns (a status, an owner, a day). One header serves every group, so the columns line up across them; sorting applies within each group."
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
name: "groups",
|
|
163
|
+
type: "{ key: string; label?: ReactNode; icon?: ReactNode; action?: ReactNode; defaultExpanded?: boolean }[]",
|
|
164
|
+
description: "Order and presentation of the groups, by key: the header row's label (defaults to the key), a leading icon such as a status mark, a trailing action such as an icon Button that adds a row to the group, and whether it starts open (default true). Groups groupBy returns that are not listed follow, in the order their first row appears in data. A listed group with no rows is not shown."
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
name: "collapsible",
|
|
168
|
+
type: "boolean",
|
|
169
|
+
default: "true",
|
|
170
|
+
description: "Whether each group's header row is a toggle that opens and closes the group. A closed group keeps its header row and count; its rows are hidden and select-all skips them."
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
name: "expandedGroups",
|
|
174
|
+
type: "ReadonlySet<string>",
|
|
175
|
+
description: "The open groups, by key (controlled). Without it each group starts at its defaultExpanded and the table holds the state."
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
name: "onExpandedGroupsChange",
|
|
179
|
+
type: "(keys: Set<string>) => void",
|
|
180
|
+
description: "Called with the new set of open group keys when a group header is toggled."
|
|
139
181
|
}
|
|
140
182
|
],
|
|
141
183
|
tokensUsed: ["muted-foreground", "foreground", "ring"],
|
|
@@ -164,6 +206,18 @@ var dataTableMeta = {
|
|
|
164
206
|
part: "Row",
|
|
165
207
|
why: "A checked row's fill eases in across its cells, so selecting by checkbox reads as the row being picked rather than a band stamped over it."
|
|
166
208
|
},
|
|
209
|
+
{
|
|
210
|
+
slot: "transition",
|
|
211
|
+
recipe: "motion-interactive",
|
|
212
|
+
part: "Group chevron",
|
|
213
|
+
why: "A group's chevron turns from pointing along the reading direction (closed) to down (open), so the group reads as folding rather than a glyph being swapped."
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
slot: "arrival",
|
|
217
|
+
recipe: "none",
|
|
218
|
+
part: "Group rows",
|
|
219
|
+
why: "Opening a group shows its rows at once: table rows cannot animate their height without leaving the table layout, and the rows are the content the reader asked for."
|
|
220
|
+
},
|
|
167
221
|
{
|
|
168
222
|
slot: "activity",
|
|
169
223
|
recipe: "none",
|
|
@@ -174,7 +228,8 @@ var dataTableMeta = {
|
|
|
174
228
|
a11y: [
|
|
175
229
|
"Sorted columns set aria-sort=ascending/descending on the header cell.",
|
|
176
230
|
"Sortable headers are real <button>s with a focus-visible ring, keyboard-operable.",
|
|
177
|
-
"Inherits the Table's scrollable frame and semantic <table>/<th>/<td> structure."
|
|
231
|
+
"Inherits the Table's scrollable frame and semantic <table>/<th>/<td> structure.",
|
|
232
|
+
'Grouped, each group is its own <tbody> (a rowgroup) headed by a <th scope="rowgroup"> spanning the columns, so group headers are never read as data rows; the toggle is a real button with aria-expanded and aria-controls naming that tbody.'
|
|
178
233
|
],
|
|
179
234
|
examples: [
|
|
180
235
|
{
|
|
@@ -197,6 +252,10 @@ var dataTableMeta = {
|
|
|
197
252
|
label: "In a card, edge to edge",
|
|
198
253
|
code: '<Card><CardHeader><CardTitle>Deployments</CardTitle></CardHeader><CardContent flush><DataTable data={deployments} rowKey={(row) => row.id} columns={[{ key: "project", header: "Project", sortable: true }, { key: "duration", header: "Duration", align: "end" }]} /></CardContent></Card>'
|
|
199
254
|
},
|
|
255
|
+
{
|
|
256
|
+
label: "Issues grouped by status",
|
|
257
|
+
code: 'const STATUSES = [\n { key: "in-progress", label: "In progress", icon: <StatusDot tone="warning" /> },\n { key: "in-review", label: "In review", icon: <StatusDot tone="purple" /> },\n { key: "todo", label: "Todo", icon: <StatusDot /> },\n { key: "done", label: "Done", icon: <StatusDot tone="success" />, defaultExpanded: false },\n]\n\n<DataTable\n data={issues}\n rowKey={(t) => t.id}\n groupBy={(t) => t.status}\n groups={STATUSES.map((s) => ({ ...s, action: <Button variant="ghost" size="icon-xs" aria-label={`Add issue to ${s.label}`} onClick={() => create(s.key)}><Icon name="plus" /></Button> }))}\n onRowClick={(t) => setSelectedId(t.id)}\n isRowSelected={(t) => t.id === selectedId}\n columns={[\n { key: "id", header: "ID", className: "w-20", cell: (t) => <span className="text-mono-caption text-muted-foreground">{t.id}</span> },\n { key: "title", header: "Issue", sortable: true },\n { key: "due", header: "Due", sortable: true, sortValue: (t) => t.due.getTime(), cell: (t) => formatDate(t.due) },\n ]}\n/>'
|
|
258
|
+
},
|
|
200
259
|
{
|
|
201
260
|
label: "Empty state",
|
|
202
261
|
code: '<DataTable data={[]} columns={[{ key: "name", header: "Name" }, { key: "status", header: "Status" }]} empty={<EmptyState icon={<Icon name="search" />} title="No deployments" description="Try a different filter or create a deployment." action={<Button>Create deployment</Button>} />} />'
|