@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.
Files changed (84) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/bin/sable.mjs +108 -14
  3. package/dist/{_contract-CDa9-NdB.d.ts → _contract-DlklV0Im.d.ts} +2 -2
  4. package/dist/blocks/index.d.ts +1 -1
  5. package/dist/components/avatar/avatar.js +16 -7
  6. package/dist/components/avatar/avatar.meta.js +43 -10
  7. package/dist/components/avatar/avatar.variants.js +26 -2
  8. package/dist/components/collapsible/collapsible.js +6 -5
  9. package/dist/components/collapsible/collapsible.meta.js +14 -5
  10. package/dist/components/data-table/data-table.js +132 -45
  11. package/dist/components/data-table/data-table.meta.js +63 -4
  12. package/dist/components/date-picker/date-picker.js +13 -6
  13. package/dist/components/date-picker/date-picker.meta.js +22 -0
  14. package/dist/components/menu/menu.js +2 -0
  15. package/dist/components/menu/menu.meta.js +2 -2
  16. package/dist/components/panel-group/panel-group.js +17 -1
  17. package/dist/components/panel-group/panel-group.meta.js +43 -3
  18. package/dist/components/side-nav/side-nav.variants.js +8 -3
  19. package/dist/components/sidebar/index.js +4 -0
  20. package/dist/components/sidebar/sidebar.js +90 -11
  21. package/dist/components/sidebar/sidebar.meta.js +38 -4
  22. package/dist/components/table/table.js +7 -3
  23. package/dist/components/tabs/tabs.js +10 -2
  24. package/dist/components/tabs/tabs.meta.js +4 -0
  25. package/dist/components/workspace/workspace.js +13 -3
  26. package/dist/components/workspace/workspace.meta.js +109 -1
  27. package/dist/file-hashes.json +53 -49
  28. package/dist/foundations/typography.js +13 -0
  29. package/dist/icon/Icon.js +21 -2
  30. package/dist/icon/index.d.ts +36 -1
  31. package/dist/icon/index.js +5 -0
  32. package/dist/icon/names.js +240 -0
  33. package/dist/icon-sets/reicon.js +19 -1
  34. package/dist/index.d.ts +272 -50
  35. package/dist/index.js +11 -1
  36. package/dist/lib/chart/heat.js +1 -1
  37. package/dist/lib/cn.js +1 -0
  38. package/dist/lib/use-media-query.js +41 -0
  39. package/dist/{meta-DX5r5wQL.d.ts → meta-Dfm9AExe.d.ts} +6 -0
  40. package/dist/patterns/dashboard-page.js +23 -17
  41. package/dist/patterns/index.d.ts +7 -2
  42. package/dist/patterns/index.js +5 -1
  43. package/dist/patterns/tracker-page.js +392 -0
  44. package/dist/patterns/tracker-page.meta.js +40 -0
  45. package/dist/presets/index.d.ts +1 -1
  46. package/dist/styles.css +179 -74
  47. package/dist/{theme-provider-BydfM5Z0.d.ts → theme-provider-BUBealk_.d.ts} +1 -1
  48. package/dist/themes/_contract.js +9 -1
  49. package/dist/themes/atelier/index.d.ts +1 -1
  50. package/dist/themes/ember/index.d.ts +1 -1
  51. package/dist/themes/index.d.ts +2 -2
  52. package/dist/themes/meridian/index.d.ts +1 -1
  53. package/dist/themes/poppy/index.d.ts +1 -1
  54. package/dist/themes/prism/index.d.ts +1 -1
  55. package/dist/themes/sable/index.d.ts +1 -1
  56. package/dist/themes/verdant/index.d.ts +1 -1
  57. package/llms/components/avatar.md +32 -9
  58. package/llms/components/collapsible.md +12 -4
  59. package/llms/components/data-table.md +40 -3
  60. package/llms/components/date-picker.md +15 -1
  61. package/llms/components/menu.md +1 -1
  62. package/llms/components/panel-group.md +34 -2
  63. package/llms/components/sidebar.md +17 -4
  64. package/llms/components/tabs.md +1 -0
  65. package/llms/components/workspace.md +81 -1
  66. package/llms/foundations/typography.md +4 -1
  67. package/llms/patterns/dashboard-page.md +18 -15
  68. package/llms/patterns/tracker-page.md +450 -0
  69. package/llms/reference/token-intent.md +3 -1
  70. package/llms/reference/utilities.md +4 -1
  71. package/llms/tasks/auditing-an-app.md +4 -1
  72. package/llms/tasks/building-a-page.md +3 -2
  73. package/llms/tasks/building-a-tool.md +101 -5
  74. package/llms/tasks/building-with-sable.md +11 -3
  75. package/llms/tasks/creating-a-preset.md +9 -0
  76. package/llms-full.txt +843 -54
  77. package/llms.txt +8 -7
  78. package/package.json +2 -1
  79. package/registry.json +491 -36
  80. package/scripts/adoption.mjs +15 -2
  81. package/scripts/app-css.mjs +129 -0
  82. package/scripts/grade.mjs +161 -2
  83. package/scripts/lint-rules.mjs +126 -0
  84. 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: 'Build a portrait by stretching an Avatar with size classes (`className="size-40"`) \u2014 the initials, the status indicator and the image fade stay at row scale. Use size="xl" or "2xl", which scale all three. Past 160px, or for a non-circular cover, it is media: an AspectBox, not an avatar.'
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 rounded-full bg-muted",
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
- function CollapsibleIndicator({ className }) {
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: "{ className? }",
91
- description: "Auto-rotating disclosure chevron. Place it inside the trigger content and add the `group/collapsible` class to the trigger so it flips 180\xB0 on open."
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 writes."
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 className="group/collapsible" render={<Button variant="ghost">Details <CollapsibleIndicator /></Button>} /><CollapsiblePanel><p className="pt-3">Revealed on toggle.</p></CollapsiblePanel></Collapsible>'
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 ? rows.map(keyOf) : [];
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: rows.length === 0 ? /* @__PURE__ */ jsx(TableRow, { children: /* @__PURE__ */ jsx(
226
+ rows.length === 0 ? /* @__PURE__ */ jsx(TableBody, { children: /* @__PURE__ */ jsx(TableRow, { children: /* @__PURE__ */ jsx(
143
227
  TableCell,
144
228
  {
145
- colSpan: columns.length + (selectable ? 1 : 0),
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
- ) }) : rows.map((row, i) => {
153
- const key = selectable ? keyOf(row) : i;
154
- const checked = selectable && Boolean(selectedKeys?.has(key));
155
- return /* @__PURE__ */ jsxs(
156
- TableRow,
157
- {
158
- "data-selected": checked || isRowSelected?.(row) ? "" : void 0,
159
- onClick: onRowClick ? () => onRowClick(row) : void 0,
160
- tabIndex: onRowClick ? 0 : void 0,
161
- onKeyDown: onRowClick ? (e) => {
162
- if (e.target !== e.currentTarget) return;
163
- if (e.key !== "Enter" && e.key !== " ") return;
164
- e.preventDefault();
165
- onRowClick(row);
166
- } : void 0,
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
- className: cn(col.align && ALIGN[col.align], col.className),
185
- children: col.cell ? col.cell(row) : row[col.key]
186
- },
187
- col.key
188
- ))
189
- ]
190
- },
191
- rowKey ? rowKey(row, i) : i
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: "Move to TanStack Table, rendered with Sable's Table parts, once the table needs server-side sorting or filtering, column visibility or resizing, 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."
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>} />} />'