@sableui/react 0.10.0 → 0.12.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 (91) hide show
  1. package/AGENTS.md +10 -4
  2. package/CHANGELOG.md +184 -0
  3. package/DESIGN.md +1 -1
  4. package/README.md +15 -1
  5. package/bin/sable.mjs +254 -10
  6. package/dist/components/app-header/app-header.meta.js +2 -1
  7. package/dist/components/button/button.js +3 -0
  8. package/dist/components/button/button.meta.js +1 -1
  9. package/dist/components/button/button.variants.js +1 -1
  10. package/dist/components/button-group/button-group.js +17 -0
  11. package/dist/components/button-group/button-group.meta.js +72 -0
  12. package/dist/components/button-group/index.js +7 -0
  13. package/dist/components/caret-menu/caret-menu.js +221 -0
  14. package/dist/components/caret-menu/caret-menu.meta.js +119 -0
  15. package/dist/components/caret-menu/index.js +8 -0
  16. package/dist/components/chat-composer/chat-composer.js +30 -7
  17. package/dist/components/chat-composer/chat-composer.meta.js +27 -8
  18. package/dist/components/combobox/combobox.meta.js +26 -0
  19. package/dist/components/input-group/index.js +7 -0
  20. package/dist/components/input-group/input-group.js +66 -0
  21. package/dist/components/input-group/input-group.meta.js +106 -0
  22. package/dist/components/menu/index.js +2 -0
  23. package/dist/components/menu/menu.js +51 -9
  24. package/dist/components/menu/menu.meta.js +11 -2
  25. package/dist/components/menubar/index.js +8 -0
  26. package/dist/components/menubar/menubar.js +42 -0
  27. package/dist/components/menubar/menubar.meta.js +99 -0
  28. package/dist/components/message-scroller/message-scroller.meta.js +4 -0
  29. package/dist/components/navigation-menu/index.js +19 -0
  30. package/dist/components/navigation-menu/navigation-menu.js +108 -0
  31. package/dist/components/navigation-menu/navigation-menu.meta.js +141 -0
  32. package/dist/components/overflow-menu/overflow-menu.meta.js +13 -1
  33. package/dist/components/pagination/pagination.js +11 -5
  34. package/dist/components/pagination/pagination.meta.js +21 -3
  35. package/dist/components/panel-section/index.js +2 -1
  36. package/dist/components/panel-section/panel-section.js +15 -0
  37. package/dist/components/panel-section/panel-section.meta.js +32 -0
  38. package/dist/components/scroller/scroller.js +8 -4
  39. package/dist/components/scroller/scroller.meta.js +6 -0
  40. package/dist/components/sheet/sheet.js +82 -9
  41. package/dist/components/sheet/sheet.meta.js +41 -4
  42. package/dist/components/sheet/sheet.variants.js +8 -1
  43. package/dist/components/sidebar/sidebar.js +13 -2
  44. package/dist/components/sidebar/sidebar.meta.js +10 -0
  45. package/dist/components/streaming-text/streaming-text.meta.js +5 -1
  46. package/dist/file-hashes.json +50 -34
  47. package/dist/icon-sets/reicon.js +2 -0
  48. package/dist/index.d.ts +687 -164
  49. package/dist/index.js +12 -1
  50. package/dist/lib/taxonomy.js +5 -0
  51. package/dist/lib/use-caret-trigger.js +187 -0
  52. package/dist/lib/use-copy.js +6 -1
  53. package/dist/lib/use-smooth-wheel.js +164 -0
  54. package/dist/patterns/chat-panel.js +1 -1
  55. package/dist/patterns/chat-panel.meta.js +1 -0
  56. package/dist/styles.css +25 -0
  57. package/llms/components/app-header.md +1 -1
  58. package/llms/components/button-group.md +55 -0
  59. package/llms/components/button.md +1 -1
  60. package/llms/components/caret-menu.md +100 -0
  61. package/llms/components/chat-composer.md +24 -7
  62. package/llms/components/combobox.md +12 -0
  63. package/llms/components/input-group.md +78 -0
  64. package/llms/components/menu.md +16 -1
  65. package/llms/components/menubar.md +110 -0
  66. package/llms/components/message-scroller.md +1 -0
  67. package/llms/components/navigation-menu.md +113 -0
  68. package/llms/components/overflow-menu.md +26 -1
  69. package/llms/components/pagination.md +15 -3
  70. package/llms/components/panel-section.md +28 -0
  71. package/llms/components/scroller.md +1 -0
  72. package/llms/components/sheet.md +18 -3
  73. package/llms/components/sidebar.md +2 -0
  74. package/llms/components/streaming-text.md +2 -1
  75. package/llms/patterns/chat-panel.md +4 -1
  76. package/llms/tasks/building-a-chat.md +158 -0
  77. package/llms/tasks/building-with-sable.md +4 -1
  78. package/llms/tasks/converting-an-app.md +7 -0
  79. package/llms/tasks/extending-a-component.md +170 -0
  80. package/llms/tasks/getting-started.md +22 -4
  81. package/llms/tasks/migrating-from-shadcn.md +351 -0
  82. package/llms-full.txt +1349 -26
  83. package/llms.txt +14 -5
  84. package/package.json +3 -2
  85. package/registry.json +1037 -53
  86. package/scripts/lint-rules.mjs +35 -0
  87. package/scripts/wiring.mjs +541 -0
  88. package/skills/entry/SKILL.md +3 -2
  89. package/skills/sable/SKILL.md +26 -5
  90. package/targets/ios.json +45 -0
  91. package/dist/lib/use-wheel-to-horizontal.js +0 -65
@@ -0,0 +1,141 @@
1
+ // src/components/navigation-menu/navigation-menu.meta.ts
2
+ var navigationMenuMeta = {
3
+ name: "NavigationMenu",
4
+ summary: "A site header's primary nav over Base UI NavigationMenu: a bar of links and triggers, where a trigger opens a panel of links on hover, click or Enter. One shared panel glides between triggers and resizes to each one's content. Links take any router's link through `render` and mark the current page with `active`.",
5
+ status: "stable",
6
+ keywords: ["navigation", "nav", "header nav", "site nav", "mega menu", "top nav", "navbar", "dropdown nav"],
7
+ usage: {
8
+ description: "Use NavigationMenu for a marketing or docs site's header: a few top-level destinations, some of which open a panel of related pages. Use Menu for actions rather than destinations, Tabs to switch views inside a page, Sidebar or SideNav for an app's persistent navigation, and AppHeader for an app's mobile top bar.",
9
+ bestPractices: [
10
+ {
11
+ guidance: "do",
12
+ description: "Keep the bar to a handful of entries. A trigger groups related pages; a destination with nothing beneath it is a plain NavigationMenuLink in its own item."
13
+ },
14
+ {
15
+ guidance: "do",
16
+ description: 'Give panel links a `description` when titles alone are ambiguous ("Sync" \u2192 "Offline first, conflict free"), and lay the Content out with layout classes (`grid w-[30rem] grid-cols-2`); the shared panel sizes itself to it.'
17
+ },
18
+ {
19
+ guidance: "do",
20
+ description: 'Pass your router\'s link through `render` (`<NavigationMenuLink render={<Link to="/pricing" />}>`) and set `active` on the current page\'s link, so the bar shows where the reader is and announces it as `aria-current="page"`.'
21
+ },
22
+ {
23
+ guidance: "do",
24
+ description: 'Below `md`, hide the bar and offer the same destinations in a Sheet opened from a menu Button: SheetContent side="left" holding a SideNav, its groups mirroring the panels. A hover panel has nothing to hover on a phone, and a bar of five entries does not fit one.'
25
+ },
26
+ {
27
+ guidance: "dont",
28
+ description: "Do not put actions in it (Sign in, New project). Those are Buttons beside the nav; the nav holds places to go."
29
+ },
30
+ {
31
+ guidance: "dont",
32
+ description: "Do not assemble the panel yourself. The root renders the one Portal, Positioner, Popup and Viewport every trigger shares; a second one breaks the glide between triggers."
33
+ }
34
+ ],
35
+ anatomy: [
36
+ { name: "NavigationMenu", required: true, description: "The `<nav>` root; also renders the shared floating panel." },
37
+ { name: "NavigationMenuList", required: true, description: "The bar: a `<ul>` the arrow keys rove across." },
38
+ { name: "NavigationMenuItem", required: true, description: "One `<li>`: a link, or a trigger with its content." },
39
+ { name: "NavigationMenuTrigger", required: false, description: "Opens its item's content; carries a chevron." },
40
+ { name: "NavigationMenuContent", required: false, description: "The links a trigger reveals, rendered into the shared panel." },
41
+ { name: "NavigationMenuLink", required: true, description: "A destination: a bar entry in an item, a two-line row in content." }
42
+ ]
43
+ },
44
+ related: {
45
+ components: ["Menu", "SideNav", "Sheet", "AppHeader", "Tabs"]
46
+ },
47
+ props: [
48
+ {
49
+ name: "NavigationMenu (Root)",
50
+ type: "Base UI NavigationMenu.Root props (value, defaultValue, onValueChange, delay, closeDelay, orientation) + className",
51
+ default: "\u2014",
52
+ description: "The `<nav>`. `value` is the open item's value (null when closed); `delay` / `closeDelay` tune hover-to-open."
53
+ },
54
+ {
55
+ name: "align",
56
+ type: '"start" | "center" | "end"',
57
+ default: '"start"',
58
+ description: "Alignment of the shared panel along its trigger."
59
+ },
60
+ {
61
+ name: "sideOffset",
62
+ type: "number",
63
+ default: "8",
64
+ description: "Gap between the bar and the panel, in px. The pointer crosses it on a bridge, so the panel does not close on the way in."
65
+ }
66
+ ],
67
+ subcomponents: [
68
+ {
69
+ name: "NavigationMenuList",
70
+ type: "Base UI NavigationMenu.List props + className",
71
+ description: "The bar of items, a `<ul>`. Arrow keys move between entries."
72
+ },
73
+ {
74
+ name: "NavigationMenuItem",
75
+ type: "Base UI NavigationMenu.Item props + className + value?: string",
76
+ description: "One `<li>` holding either a NavigationMenuLink or a NavigationMenuTrigger with its NavigationMenuContent."
77
+ },
78
+ {
79
+ name: "NavigationMenuTrigger",
80
+ type: "Base UI NavigationMenu.Trigger props + className",
81
+ description: "Opens its item's content on hover, click or Enter, with a chevron that turns while open. Styled like the bar's links: muted at rest, full ink when hovered or open."
82
+ },
83
+ {
84
+ name: "NavigationMenuContent",
85
+ type: "Base UI NavigationMenu.Content props + className + keepMounted?: boolean",
86
+ description: "The item's panel content. Size and lay it out with layout classes; the shared panel follows its size and slides it in from the side it was reached from."
87
+ },
88
+ {
89
+ name: "NavigationMenuLink",
90
+ type: "Base UI NavigationMenu.Link props + className + href?: string + active?: boolean + closeOnClick?: boolean + render?: ReactElement + description?: ReactNode",
91
+ description: 'A destination. In an item it is a bar entry; in content it is a row with an optional `description` line. `active` sets `aria-current="page"`; `render={<Link to="\u2026" />}` keeps the roving focus on your router\'s link.'
92
+ }
93
+ ],
94
+ tokensUsed: ["popover", "popover-foreground", "border-floating", "accent", "accent-foreground", "muted-foreground", "foreground", "ring"],
95
+ motion: [
96
+ {
97
+ slot: "arrival",
98
+ recipe: "custom",
99
+ part: "Panel",
100
+ why: "The panel fades and scales in from its trigger like a popover, but it also resizes as the reader moves between triggers, which motion-pop cannot express: Base UI measures each content into --popup-width/--popup-height and the popup transitions to them on the motion tokens."
101
+ },
102
+ {
103
+ slot: "transition",
104
+ recipe: "custom",
105
+ part: "Panel",
106
+ why: "Moving from one trigger to the next keeps one panel open: it glides along the bar and the new content slides in from the direction of travel, so the reader sees one surface changing rather than two popups swapping. Snaps on first open and on resize (data-instant), where a glide would mean nothing."
107
+ },
108
+ {
109
+ slot: "response",
110
+ recipe: "motion-interactive",
111
+ part: "Trigger, Link",
112
+ why: "Bar entries and panel rows fill on hover and focus like the other interactive surfaces, so the bar reads as live before anything opens."
113
+ },
114
+ {
115
+ slot: "activity",
116
+ recipe: "none",
117
+ part: "root",
118
+ why: "Nothing in a nav moves on its own."
119
+ }
120
+ ],
121
+ a11y: [
122
+ 'The root is a <nav>; give it an aria-label when the page has more than one nav ("Main").',
123
+ "Arrow keys move across the bar; Enter, Space or ArrowDown opens a trigger's content and moves focus into it; Esc closes and returns focus to the trigger.",
124
+ "A trigger is a button with aria-expanded; its content is reachable by Tab in reading order once open.",
125
+ '`active` on a link sets aria-current="page" and gives it full ink, so the current page is announced as well as shown.',
126
+ 'Panel motion runs on the motion tokens, so reduced motion (or MotionProvider "reduced") collapses the glide and resize to instant.'
127
+ ],
128
+ examples: [
129
+ {
130
+ label: "Bar with a panel",
131
+ code: '<NavigationMenu aria-label="Main">\n <NavigationMenuList>\n <NavigationMenuItem>\n <NavigationMenuTrigger>Product</NavigationMenuTrigger>\n <NavigationMenuContent className="grid w-[30rem] grid-cols-2">\n <NavigationMenuLink href="/editor" description="Write and review together.">Editor</NavigationMenuLink>\n <NavigationMenuLink href="/sync" description="Offline first, conflict free.">Sync</NavigationMenuLink>\n </NavigationMenuContent>\n </NavigationMenuItem>\n <NavigationMenuItem>\n <NavigationMenuLink href="/pricing">Pricing</NavigationMenuLink>\n </NavigationMenuItem>\n </NavigationMenuList>\n</NavigationMenu>'
132
+ },
133
+ {
134
+ label: "With a router, marking the current page",
135
+ code: '<NavigationMenu aria-label="Main">\n <NavigationMenuList>\n <NavigationMenuItem>\n <NavigationMenuLink active={path === "/docs"} render={<Link to="/docs" />}>Docs</NavigationMenuLink>\n </NavigationMenuItem>\n <NavigationMenuItem>\n <NavigationMenuLink active={path === "/blog"} render={<Link to="/blog" />}>Blog</NavigationMenuLink>\n </NavigationMenuItem>\n </NavigationMenuList>\n</NavigationMenu>'
136
+ }
137
+ ]
138
+ };
139
+ export {
140
+ navigationMenuMeta
141
+ };
@@ -7,7 +7,11 @@ var overflowMenuMeta = {
7
7
  "dots menu",
8
8
  "more actions",
9
9
  "ellipsis",
10
- "kebab"
10
+ "kebab",
11
+ "responsive actions",
12
+ "responsive action menu",
13
+ "collapse actions",
14
+ "actions overflow on mobile"
11
15
  ],
12
16
  usage: {
13
17
  description: "Use OverflowMenu for compact overflow actions when the primary action is already visible elsewhere.",
@@ -20,6 +24,10 @@ var overflowMenuMeta = {
20
24
  guidance: "do",
21
25
  description: "Keep labels explicit so the icon-only trigger does not obscure the command meaning."
22
26
  },
27
+ {
28
+ guidance: "do",
29
+ description: "For actions that fit inline on a wide screen but not a narrow one, render them twice: inline (a Row inside a wrapper shown from a container width; Row sets its own display, so the wrapper does the hiding), and as MenuItems in an OverflowMenu hidden from that width (`@container` + `@md:`). display:none takes the hidden copy out of the accessibility tree, so only one set is ever announced. Keep the primary action inline at every width."
30
+ },
23
31
  {
24
32
  guidance: "dont",
25
33
  description: "Do not hide the most important or most frequent action inside OverflowMenu."
@@ -92,6 +100,10 @@ var overflowMenuMeta = {
92
100
  {
93
101
  label: "With leading icons",
94
102
  code: '<OverflowMenu><MenuItem><Icon name="check" className="text-sm" />Mark as read</MenuItem><MenuSeparator /><MenuItem variant="destructive"><Icon name="xmark" className="text-sm" />Delete</MenuItem></OverflowMenu>'
103
+ },
104
+ {
105
+ label: "Responsive actions: inline when wide, in a menu when narrow",
106
+ code: '<div className="@container/actions flex w-full justify-end">\n <Row gap={1} align="center">\n <div className="hidden @md/actions:block">\n <Row gap={1}>\n <Button variant="ghost" size="sm" iconStart={<Icon name="share" />}>Share</Button>\n <Button variant="ghost" size="sm" iconStart={<Icon name="download" />}>Export</Button>\n </Row>\n </div>\n <div className="@md/actions:hidden">\n <OverflowMenu label="Report actions" size="icon-sm">\n <MenuItem icon={<Icon name="share" />}>Share</MenuItem>\n <MenuItem icon={<Icon name="download" />}>Export</MenuItem>\n </OverflowMenu>\n </div>\n <Button size="sm" iconStart={<Icon name="edit" />}>Edit report</Button>\n </Row>\n</div>'
95
107
  }
96
108
  ]
97
109
  };
@@ -29,6 +29,7 @@ function Pagination({
29
29
  page,
30
30
  total,
31
31
  onPageChange,
32
+ pageLink,
32
33
  siblingCount = 1,
33
34
  label = "Pagination",
34
35
  className,
@@ -38,10 +39,12 @@ function Pagination({
38
39
  const safeTotal = Math.max(total, 1);
39
40
  const current = Math.min(Math.max(page, 1), safeTotal);
40
41
  const items = buildRange(current, safeTotal, siblingCount);
41
- const goTo = (next) => {
42
+ const goTo = (next, event) => {
42
43
  if (next < 1 || next > safeTotal || next === current) return;
43
- onPageChange(next);
44
+ if (pageLink && (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)) return;
45
+ onPageChange?.(next);
44
46
  };
47
+ const linkTo = (next) => pageLink && next >= 1 && next <= safeTotal ? pageLink(next) : void 0;
45
48
  return /* @__PURE__ */ jsxs(
46
49
  "nav",
47
50
  {
@@ -57,7 +60,8 @@ function Pagination({
57
60
  size: "icon-sm",
58
61
  "aria-label": "Previous page",
59
62
  disabled: current <= 1,
60
- onClick: () => goTo(current - 1),
63
+ render: linkTo(current - 1),
64
+ onClick: (event) => goTo(current - 1, event),
61
65
  children: /* @__PURE__ */ jsx(Icon, { name: "chevron-left", className: "text-base" })
62
66
  }
63
67
  ),
@@ -78,7 +82,8 @@ function Pagination({
78
82
  className: "w-auto min-w-(--control-sm) px-1 tabular-nums",
79
83
  "aria-label": `Page ${item}`,
80
84
  "aria-current": item === current ? "page" : void 0,
81
- onClick: () => goTo(item),
85
+ render: linkTo(item),
86
+ onClick: (event) => goTo(item, event),
82
87
  children: item
83
88
  }
84
89
  ) }, item)
@@ -90,7 +95,8 @@ function Pagination({
90
95
  size: "icon-sm",
91
96
  "aria-label": "Next page",
92
97
  disabled: current >= safeTotal,
93
- onClick: () => goTo(current + 1),
98
+ render: linkTo(current + 1),
99
+ onClick: (event) => goTo(current + 1, event),
94
100
  children: /* @__PURE__ */ jsx(Icon, { name: "chevron-right", className: "text-base" })
95
101
  }
96
102
  )
@@ -1,7 +1,7 @@
1
1
  // src/components/pagination/pagination.meta.ts
2
2
  var paginationMeta = {
3
3
  name: "Pagination",
4
- summary: "Page navigation built from our own Button + Icon (no Base UI). A <nav aria-label='Pagination'> with prev/next icon buttons that disable at the bounds and numbered page buttons; the current page is a secondary button marked aria-current. Collapses to first/last/current plus a sibling window, bridging skipped runs with an ellipsis.",
4
+ summary: "Page navigation built from our own Button + Icon (no Base UI). A <nav aria-label='Pagination'> with prev/next icon buttons that disable at the bounds and numbered page buttons; the current page is a secondary button marked aria-current. With pageLink every control is a real link (crawlable, opens in a new tab, any router through render). Collapses to first/last/current plus a sibling window, bridging skipped runs with an ellipsis.",
5
5
  keywords: [
6
6
  "pagination",
7
7
  "pager",
@@ -10,7 +10,10 @@ var paginationMeta = {
10
10
  "results",
11
11
  "navigation",
12
12
  "next",
13
- "previous"
13
+ "previous",
14
+ "page links",
15
+ "seo",
16
+ "url"
14
17
  ],
15
18
  usage: {
16
19
  description: "Use Pagination when a result set is split into discrete pages and the user needs predictable movement through them. Pair it with DataTable, list results, or server-paged views; use a load-more Button or infinite scroll when page numbers are not meaningful.",
@@ -23,6 +26,10 @@ var paginationMeta = {
23
26
  guidance: "do",
24
27
  description: 'Use a specific label when multiple pagers appear on a page, such as "Customer pages".'
25
28
  },
29
+ {
30
+ guidance: "do",
31
+ description: "Pass pageLink when the page lives in the URL (search results, a blog, a catalogue): each page becomes a link a crawler can follow and a reader can open in a new tab. Use a router's Link there so navigation stays in the app."
32
+ },
26
33
  {
27
34
  guidance: "do",
28
35
  description: "Reset to page 1 when filters or search terms materially change the result set."
@@ -81,7 +88,13 @@ var paginationMeta = {
81
88
  name: "onPageChange",
82
89
  type: "(page: number) => void",
83
90
  default: "\u2014",
84
- description: "Called with the next page when a control is activated. No-op for out-of-range or unchanged values."
91
+ description: "Called with the next page when a control is activated. No-op for out-of-range or unchanged values. Optional with pageLink, where the URL is the page state."
92
+ },
93
+ {
94
+ name: "pageLink",
95
+ type: "(page: number) => ReactElement",
96
+ default: "\u2014",
97
+ description: "Render each page control as a link: return the element for a page, (page) => <a href={`?page=${page}`} /> or a router's <Link to={`?page=${page}`} />. The current page keeps aria-current; previous/next at the bounds stay disabled buttons. onPageChange becomes optional and, if set, still fires on click."
85
98
  },
86
99
  {
87
100
  name: "siblingCount",
@@ -115,6 +128,7 @@ var paginationMeta = {
115
128
  "Renders a landmark <nav> with aria-label so assistive tech can jump to the pager.",
116
129
  "The current page button carries aria-current='page'.",
117
130
  "Prev/next buttons are real <button>s with aria-label and become disabled at the bounds.",
131
+ "With pageLink the controls are real <a> links (or the router's), so they are crawlable and open in a new tab; a bound arrow stays a disabled button rather than a link to nowhere.",
118
132
  "Ellipsis slots are aria-hidden \u2014 they convey no navigable target."
119
133
  ],
120
134
  examples: [
@@ -130,6 +144,10 @@ var paginationMeta = {
130
144
  label: "Table results footer",
131
145
  code: '<Stack gap={2}><DataTable data={rows} columns={columns} rowKey={(row) => row.id} /><Pagination label="Customer pages" page={page} total={12} onPageChange={setPage} /></Stack>'
132
146
  },
147
+ {
148
+ label: "Page links (URL is the state)",
149
+ code: "<Pagination page={page} total={pageCount} pageLink={(page) => <Link to={`/search?q=${query}&page=${page}`} />} />"
150
+ },
133
151
  {
134
152
  label: "Server query state",
135
153
  code: "const [page, setPage] = useState(searchParams.page ?? 1)\n<Pagination page={page} total={pageCount} onPageChange={(next) => { setPage(next); loadPage(next) }} />"
@@ -1,8 +1,9 @@
1
1
  // src/components/panel-section/index.ts
2
- import { PanelSection, PropertyPair, PropertyRow } from "./panel-section.js";
2
+ import { PanelSection, PropertyGroup, PropertyPair, PropertyRow } from "./panel-section.js";
3
3
  import { panelSectionMeta } from "./panel-section.meta.js";
4
4
  export {
5
5
  PanelSection,
6
+ PropertyGroup,
6
7
  PropertyPair,
7
8
  PropertyRow,
8
9
  panelSectionMeta
@@ -1,8 +1,10 @@
1
1
  "use client";
2
2
 
3
3
  // src/components/panel-section/panel-section.tsx
4
+ import { Icon } from "../../icon/index.js";
4
5
  import { cn } from "../../lib/cn.js";
5
6
  import { beginScrub } from "../../lib/scrub-gesture.js";
7
+ import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../collapsible/collapsible.js";
6
8
  import { jsx, jsxs } from "react/jsx-runtime";
7
9
  function PanelSection({
8
10
  title,
@@ -108,8 +110,21 @@ function PropertyPair({ labels, className, children, ...props }) {
108
110
  ] });
109
111
  }
110
112
  PropertyPair.displayName = "PropertyPair";
113
+ function PropertyGroup({ label, summary, className, children, ...props }) {
114
+ return /* @__PURE__ */ jsxs(Collapsible, { className: cn("border-t border-hairline", className), ...props, children: [
115
+ /* @__PURE__ */ jsxs(CollapsibleTrigger, { className: "group/property-group flex h-(--control-sm) w-full items-center gap-2 rounded-xs text-start motion-interactive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", children: [
116
+ /* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1 truncate text-ui-md text-foreground", children: label }),
117
+ summary != null ? /* @__PURE__ */ jsx("span", { className: "flex min-w-0 shrink items-center gap-2 truncate text-ui-md text-muted-foreground", children: summary }) : null,
118
+ /* @__PURE__ */ jsx(Icon, { name: "plus", className: "shrink-0 text-xs text-muted-foreground group-data-[panel-open]/property-group:hidden" }),
119
+ /* @__PURE__ */ jsx(Icon, { name: "minus", className: "hidden shrink-0 text-xs text-muted-foreground group-data-[panel-open]/property-group:block" })
120
+ ] }),
121
+ /* @__PURE__ */ jsx(CollapsiblePanel, { children: /* @__PURE__ */ jsx("div", { className: "flex flex-col gap-1.5 pt-0.5 pb-2", children }) })
122
+ ] });
123
+ }
124
+ PropertyGroup.displayName = "PropertyGroup";
111
125
  export {
112
126
  PanelSection,
127
+ PropertyGroup,
113
128
  PropertyPair,
114
129
  PropertyRow
115
130
  };
@@ -60,6 +60,11 @@ var panelSectionMeta = {
60
60
  name: "PropertyPair",
61
61
  required: false,
62
62
  description: "Two controls side by side, with optional captions above each."
63
+ },
64
+ {
65
+ name: "PropertyGroup",
66
+ required: false,
67
+ description: "A set of properties folded behind one row that names the group and summarises its value, opening with +/\u2212 to the controls beneath."
63
68
  }
64
69
  ]
65
70
  },
@@ -89,9 +94,15 @@ var panelSectionMeta = {
89
94
  name: "PropertyPair",
90
95
  type: "{ labels?: [ReactNode, ReactNode] }",
91
96
  description: "Two controls side by side in a 2-up grid, with optional captions above each."
97
+ },
98
+ {
99
+ name: "PropertyGroup",
100
+ type: "{ label: ReactNode; summary?: ReactNode; defaultOpen?: boolean; open?: boolean; onOpenChange?: (open: boolean) => void }",
101
+ description: "A collapsible set of properties inside a section, at the panel's scale (an Accordion is page furniture). The row shows the label, then the summary (the group's value, readable while closed: a word, a few swatches, or both), then a + that turns to \u2212 when open. Groups open independently and stack with a hairline between them. Built on Collapsible."
92
102
  }
93
103
  ],
94
104
  a11y: [
105
+ "PropertyGroup's row is a real button with aria-expanded (Collapsible); its summary is part of the button's name, so a closed group still announces its value.",
95
106
  "PropertyRow renders a real <label>; pass htmlFor with the control's id so clicking the label focuses it.",
96
107
  "The section title is an h3, so panels contribute a real heading outline rather than styled divs.",
97
108
  "`hint` is exposed as the label's title attribute \u2014 keep the essential meaning in the label itself, since title text is not reliably announced.",
@@ -99,6 +110,27 @@ var panelSectionMeta = {
99
110
  ],
100
111
  tokensUsed: ["foreground", "muted-foreground", "hairline"],
101
112
  examples: [
113
+ {
114
+ label: "Groups that fold behind their value",
115
+ code: `<PanelSection title="Color">
116
+ <PropertyGroup label="Tones" summary="Brand only">
117
+ <PropertyRow label="Scheme">
118
+ <Select value="brand">
119
+ <SelectTrigger size="xs" />
120
+ <SelectContent>
121
+ <SelectItem value="brand">Brand only</SelectItem>
122
+ <SelectItem value="harmonize">Harmonize</SelectItem>
123
+ </SelectContent>
124
+ </Select>
125
+ </PropertyRow>
126
+ </PropertyGroup>
127
+ <PropertyGroup label="Grid" summary="8px">
128
+ <PropertyRow label="Size">
129
+ <ScrubField label="Grid size" value={8} min={1} max={64} suffix="px" />
130
+ </PropertyRow>
131
+ </PropertyGroup>
132
+ </PanelSection>`
133
+ },
102
134
  {
103
135
  label: "Blending section, 2-up",
104
136
  code: `<PanelSection title="Blending">
@@ -8,12 +8,13 @@ import { useEdgeFade } from "../../lib/use-edge-fade.js";
8
8
  import { useScrollActivity } from "../../lib/use-scroll-activity.js";
9
9
  import {
10
10
  scrollRegionKeyDown,
11
- useWheelToHorizontal
12
- } from "../../lib/use-wheel-to-horizontal.js";
11
+ useSmoothWheel
12
+ } from "../../lib/use-smooth-wheel.js";
13
13
  import { jsx } from "react/jsx-runtime";
14
14
  function Scroller({
15
15
  orientation = "vertical",
16
16
  wheelScroll = true,
17
+ smoothWheel = true,
17
18
  label,
18
19
  className,
19
20
  viewportClassName,
@@ -27,7 +28,7 @@ function Scroller({
27
28
  const regionRef = useRef(null);
28
29
  const isHorizontal = orientation === "horizontal";
29
30
  const trackActivity = useScrollActivity();
30
- const trackWheel = useWheelToHorizontal(isHorizontal && wheelScroll);
31
+ const trackWheel = useSmoothWheel({ axis: orientation, mapVertical: isHorizontal && wheelScroll, smooth: smoothWheel });
31
32
  const setRegion = useCallback(
32
33
  (node) => {
33
34
  setRef(ref, node);
@@ -59,7 +60,10 @@ function Scroller({
59
60
  // sizing, and is NOT masked — which is the entire point of the split. A
60
61
  // flex column so the scroller below can be `flex-1 min-h-0`, which bounds
61
62
  // off `h-*`, `max-h-*` and `flex-1` alike with no percentage to resolve.
62
- /* @__PURE__ */ jsx("div", { style, className: cn("flex flex-col", className), children: /* @__PURE__ */ jsx(
63
+ // `min-w-0` because this box, not the scroller inside it, is what a grid
64
+ // or flex parent sizes: left at `min-width: auto`, a horizontal strip
65
+ // widened its column to the full length of its content and never scrolled.
66
+ /* @__PURE__ */ jsx("div", { style, className: cn("flex min-w-0 flex-col", className), children: /* @__PURE__ */ jsx(
63
67
  "div",
64
68
  {
65
69
  ref: setRegion,
@@ -65,6 +65,12 @@ var scrollerMeta = {
65
65
  default: "true",
66
66
  description: "On a horizontal Scroller, let a vertical mouse wheel scroll it sideways. Ignored when vertical. Turn it off only if the region is inside something that owns the wheel itself (a map, a canvas)."
67
67
  },
68
+ {
69
+ name: "smoothWheel",
70
+ type: "boolean",
71
+ default: "true",
72
+ description: "Ease mouse-wheel steps toward their target, on either axis: the inertia a smooth-scroll library such as Lenis adds, scoped to this region. Only a mouse wheel is eased; trackpads and touch keep their own momentum, keys and scrollbar drags stay native, and reduced motion (the OS preference or MotionProvider) turns it off. Anything else that moves the region (scrollIntoView, a stick-to-bottom chat) stops the easing at once. For a scroll container of your own, `useSmoothWheel()` returns the same behaviour as a ref callback."
73
+ },
68
74
  {
69
75
  name: "label",
70
76
  type: "string",
@@ -20,54 +20,127 @@ var SWIPE_DIRECTION = {
20
20
  top: "up",
21
21
  bottom: "down"
22
22
  };
23
- var SheetSideContext = createContext(null);
24
- function Sheet(props) {
23
+ var SheetRootContext = createContext(null);
24
+ function Sheet({
25
+ snapPoints,
26
+ snapPoint,
27
+ defaultSnapPoint,
28
+ onSnapPointChange,
29
+ snapToSequentialPoints,
30
+ ...props
31
+ }) {
25
32
  const [side, setSide] = useState("right");
26
- return /* @__PURE__ */ jsx(SheetSideContext.Provider, { value: setSide, children: /* @__PURE__ */ jsx(Base.Root, { ...props, swipeDirection: SWIPE_DIRECTION[side] }) });
33
+ const snaps = Boolean(snapPoints?.length);
34
+ const context = useMemo(() => ({ reportSide: setSide, snaps }), [snaps]);
35
+ return /* @__PURE__ */ jsx(SheetRootContext.Provider, { value: context, children: /* @__PURE__ */ jsx(
36
+ Base.Root,
37
+ {
38
+ ...props,
39
+ snapPoints: snaps && side === "bottom" ? snapPoints : void 0,
40
+ snapPoint,
41
+ defaultSnapPoint,
42
+ onSnapPointChange,
43
+ snapToSequentialPoints,
44
+ swipeDirection: SWIPE_DIRECTION[side]
45
+ }
46
+ ) });
47
+ }
48
+ function isDevelopment() {
49
+ try {
50
+ return process.env.NODE_ENV !== "production";
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+ function useKeyboardInset(enabled) {
56
+ const [inset, setInset] = useState(0);
57
+ useIsomorphicLayoutEffect(() => {
58
+ const viewport = typeof window === "undefined" ? void 0 : window.visualViewport;
59
+ if (!enabled || !viewport) return;
60
+ const measure = () => setInset(
61
+ Math.abs(viewport.scale - 1) > 0.01 ? 0 : Math.max(0, Math.round(window.innerHeight - viewport.height - viewport.offsetTop))
62
+ );
63
+ measure();
64
+ viewport.addEventListener("resize", measure);
65
+ viewport.addEventListener("scroll", measure);
66
+ return () => {
67
+ viewport.removeEventListener("resize", measure);
68
+ viewport.removeEventListener("scroll", measure);
69
+ setInset(0);
70
+ };
71
+ }, [enabled]);
72
+ return inset;
27
73
  }
28
74
  var SheetTrigger = Base.Trigger;
29
75
  var SheetClose = Base.Close;
30
76
  var SheetPortal = withPortalThemeScope(Base.Portal);
77
+ var warnedSnapSide = false;
31
78
  function SheetContent({
32
79
  className,
33
80
  children,
34
81
  side,
35
82
  initialFocus,
36
83
  ref,
84
+ style,
37
85
  ...props
38
86
  }) {
39
- const reportSide = useContext(SheetSideContext);
87
+ const root = useContext(SheetRootContext);
40
88
  const resolvedSide = side ?? "right";
89
+ const reportSide = root?.reportSide;
41
90
  useIsomorphicLayoutEffect(() => {
42
91
  reportSide?.(resolvedSide);
43
92
  }, [reportSide, resolvedSide]);
93
+ const snaps = Boolean(root?.snaps) && resolvedSide === "bottom";
94
+ if (root?.snaps && resolvedSide !== "bottom" && !warnedSnapSide && isDevelopment()) {
95
+ warnedSnapSide = true;
96
+ console.warn(`Sable: <Sheet snapPoints> applies to side="bottom" only; this sheet opens from the ${resolvedSide}, so its snap points are ignored.`);
97
+ }
44
98
  const popupRef = useRef(null);
45
99
  const [popup, setPopup] = useState(null);
46
100
  const mergedRef = useMemo(() => mergeRefs(popupRef, ref, setPopup), [ref]);
101
+ const keyboardInset = useKeyboardInset(resolvedSide === "bottom" && popup !== null);
102
+ const keyboardVars = { "--sheet-keyboard-inset": `${keyboardInset}px` };
47
103
  useIsomorphicLayoutEffect(() => {
48
104
  if (!popup || resolvedSide !== "left" && resolvedSide !== "right") return;
49
- const root = document.documentElement;
105
+ const root2 = document.documentElement;
50
106
  const name = `--sable-sheet-${resolvedSide}-w`;
51
- const publish = () => root.style.setProperty(name, `${popup.offsetWidth}px`);
107
+ const publish = () => root2.style.setProperty(name, `${popup.offsetWidth}px`);
52
108
  publish();
53
109
  const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(publish);
54
110
  observer?.observe(popup);
55
111
  return () => {
56
112
  observer?.disconnect();
57
- root.style.removeProperty(name);
113
+ root2.style.removeProperty(name);
58
114
  };
59
115
  }, [popup, resolvedSide]);
60
116
  return /* @__PURE__ */ jsxs(SheetPortal, { children: [
61
117
  /* @__PURE__ */ jsx(Base.Backdrop, { className: "fixed inset-0 z-(--z-overlay) bg-overlay/(--scrim-alpha) opacity-[calc(1_-_var(--drawer-swipe-progress))] motion-fade data-[swiping]:transition-none" }),
62
- /* @__PURE__ */ jsx(Base.Viewport, { className: "fixed inset-0 z-(--z-overlay)", children: /* @__PURE__ */ jsx(
118
+ /* @__PURE__ */ jsx(Base.Viewport, { className: "fixed inset-0 z-(--z-overlay)", children: /* @__PURE__ */ jsxs(
63
119
  Base.Popup,
64
120
  {
65
121
  ref: mergedRef,
66
122
  initialFocus: initialFocus === void 0 ? (type) => type === "touch" ? popupRef.current : true : initialFocus,
67
123
  "data-sable-sheet": resolvedSide,
124
+ "data-snap-points": snaps ? "" : void 0,
68
125
  className: cn(sheetVariants({ side }), className),
69
126
  ...props,
70
- children: /* @__PURE__ */ jsx(Base.Content, { className: "contents", children: /* @__PURE__ */ jsx(PresentationProvider, { value: resolvedSide === "bottom" ? "sheet" : "page", children }) })
127
+ style: typeof style === "function" ? (state) => ({ ...keyboardVars, ...style(state) }) : { ...keyboardVars, ...style },
128
+ children: [
129
+ snaps ? (
130
+ // The grip says "drag me". Decorative: the stops are reached by
131
+ // swipe, and the sheet's content stays reachable by scrolling
132
+ // and by keyboard whichever stop it rests at.
133
+ /* @__PURE__ */ jsx(
134
+ "div",
135
+ {
136
+ "aria-hidden": true,
137
+ "data-slot": "sheet-handle",
138
+ className: "-mt-(--sheet-handle-pull) mb-0 h-1 w-10 shrink-0 self-center rounded-full bg-border-strong"
139
+ }
140
+ )
141
+ ) : null,
142
+ /* @__PURE__ */ jsx(Base.Content, { className: "contents", children: /* @__PURE__ */ jsx(PresentationProvider, { value: resolvedSide === "bottom" ? "sheet" : "page", children }) })
143
+ ]
71
144
  }
72
145
  ) })
73
146
  ] });