@sableui/react 0.11.0 → 0.13.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 (90) hide show
  1. package/AGENTS.md +10 -4
  2. package/CHANGELOG.md +199 -0
  3. package/DESIGN.md +3 -3
  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/scroller/scroller.js +8 -4
  36. package/dist/components/scroller/scroller.meta.js +6 -0
  37. package/dist/components/sheet/sheet.js +82 -9
  38. package/dist/components/sheet/sheet.meta.js +41 -4
  39. package/dist/components/sheet/sheet.variants.js +8 -1
  40. package/dist/components/sidebar/sidebar.js +13 -2
  41. package/dist/components/sidebar/sidebar.meta.js +10 -0
  42. package/dist/components/streaming-text/streaming-text.meta.js +5 -1
  43. package/dist/file-hashes.json +49 -33
  44. package/dist/icon-sets/reicon.js +2 -0
  45. package/dist/index.d.ts +660 -164
  46. package/dist/index.js +12 -1
  47. package/dist/lib/palette.js +46 -6
  48. package/dist/lib/taxonomy.js +5 -0
  49. package/dist/lib/use-caret-trigger.js +187 -0
  50. package/dist/lib/use-copy.js +6 -1
  51. package/dist/lib/use-smooth-wheel.js +164 -0
  52. package/dist/palette.css +243 -165
  53. package/dist/patterns/chat-panel.js +1 -1
  54. package/dist/patterns/chat-panel.meta.js +1 -0
  55. package/dist/styles.css +25 -0
  56. package/llms/components/app-header.md +1 -1
  57. package/llms/components/button-group.md +55 -0
  58. package/llms/components/button.md +1 -1
  59. package/llms/components/caret-menu.md +100 -0
  60. package/llms/components/chat-composer.md +24 -7
  61. package/llms/components/combobox.md +12 -0
  62. package/llms/components/input-group.md +78 -0
  63. package/llms/components/menu.md +16 -1
  64. package/llms/components/menubar.md +110 -0
  65. package/llms/components/message-scroller.md +1 -0
  66. package/llms/components/navigation-menu.md +113 -0
  67. package/llms/components/overflow-menu.md +26 -1
  68. package/llms/components/pagination.md +15 -3
  69. package/llms/components/scroller.md +1 -0
  70. package/llms/components/sheet.md +18 -3
  71. package/llms/components/sidebar.md +2 -0
  72. package/llms/components/streaming-text.md +2 -1
  73. package/llms/patterns/chat-panel.md +4 -1
  74. package/llms/reference/token-intent.md +1 -1
  75. package/llms/tasks/building-a-chat.md +158 -0
  76. package/llms/tasks/building-with-sable.md +4 -1
  77. package/llms/tasks/converting-an-app.md +7 -0
  78. package/llms/tasks/extending-a-component.md +170 -0
  79. package/llms/tasks/getting-started.md +22 -4
  80. package/llms/tasks/migrating-from-shadcn.md +351 -0
  81. package/llms-full.txt +1322 -27
  82. package/llms.txt +13 -4
  83. package/package.json +3 -2
  84. package/registry.json +979 -23
  85. package/scripts/lint-rules.mjs +35 -0
  86. package/scripts/wiring.mjs +541 -0
  87. package/skills/entry/SKILL.md +3 -2
  88. package/skills/sable/SKILL.md +26 -5
  89. package/targets/ios.json +45 -0
  90. package/dist/lib/use-wheel-to-horizontal.js +0 -65
@@ -0,0 +1,66 @@
1
+ // src/components/input-group/input-group.tsx
2
+ import {
3
+ Children,
4
+ cloneElement
5
+ } from "react";
6
+ import { cn } from "../../lib/cn.js";
7
+ import { useDensity, useTypeTier } from "../../lib/density.js";
8
+ import { MOBILE_FIELD_TIER } from "../../lib/field-shell.js";
9
+ import { jsx, jsxs } from "react/jsx-runtime";
10
+ function InputGroup({
11
+ leading,
12
+ trailing,
13
+ children,
14
+ className,
15
+ id,
16
+ required,
17
+ disabled,
18
+ "aria-describedby": describedBy,
19
+ "aria-invalid": invalid,
20
+ ...props
21
+ }) {
22
+ const tier = useTypeTier();
23
+ const density = useDensity();
24
+ const input = Children.only(children);
25
+ const control = cloneElement(input, {
26
+ ...id != null ? { id } : null,
27
+ ...required != null ? { required } : null,
28
+ ...disabled ? { disabled: true } : null,
29
+ ...describedBy ? { "aria-describedby": describedBy } : null,
30
+ ...invalid ? { "aria-invalid": invalid } : null
31
+ });
32
+ const addon = cn(
33
+ "flex shrink-0 items-center gap-1.5 text-muted-foreground whitespace-nowrap",
34
+ tier.body,
35
+ density === "comfortable" && MOBILE_FIELD_TIER
36
+ );
37
+ return /* @__PURE__ */ jsxs(
38
+ "div",
39
+ {
40
+ "data-slot": "input-group",
41
+ className: cn(
42
+ "flex h-(--control-md) w-full items-center",
43
+ // The field shell, moved from the input to the group: focus is the
44
+ // ring on the whole field, and so are the error and disabled states.
45
+ "rounded-sm border border-input bg-card motion-interactive focus-within:ring-2 focus-within:ring-ring",
46
+ "has-[>input[aria-invalid=true]]:border-destructive has-[>input[aria-invalid=true]]:ring-destructive/30",
47
+ "has-[>input:disabled]:cursor-not-allowed has-[>input:disabled]:opacity-50",
48
+ // The Input keeps its type, padding and behaviour; the box is the group's.
49
+ "[&>input]:h-full [&>input]:min-w-0 [&>input]:flex-1 [&>input]:rounded-none [&>input]:border-0 [&>input]:bg-transparent [&>input]:focus-visible:ring-0 [&>input]:disabled:opacity-100",
50
+ leading != null && "[&>input]:ps-1.5",
51
+ trailing != null && "[&>input]:pe-1.5",
52
+ className
53
+ ),
54
+ ...props,
55
+ children: [
56
+ leading != null ? /* @__PURE__ */ jsx("span", { "data-slot": "input-group-leading", className: cn(addon, "ps-3 has-[>[data-variant]]:ps-1"), children: leading }) : null,
57
+ control,
58
+ trailing != null ? /* @__PURE__ */ jsx("span", { "data-slot": "input-group-trailing", className: cn(addon, "pe-3 has-[>[data-variant]]:pe-1"), children: trailing }) : null
59
+ ]
60
+ }
61
+ );
62
+ }
63
+ InputGroup.displayName = "InputGroup";
64
+ export {
65
+ InputGroup
66
+ };
@@ -0,0 +1,106 @@
1
+ // src/components/input-group/input-group.meta.ts
2
+ var inputGroupMeta = {
3
+ name: "InputGroup",
4
+ summary: `An Input with addons inside the same field: a prefix or suffix ("https://", ".com", "$"), an icon, or a borderless Button such as copy or reveal. One shell, one focus ring, one error state; a Field's label and error wiring reaches the Input inside.`,
5
+ keywords: [
6
+ "input group",
7
+ "input addon",
8
+ "prefix",
9
+ "suffix",
10
+ "adornment",
11
+ "url input",
12
+ "currency input",
13
+ "password reveal",
14
+ "copy field",
15
+ "icon in input"
16
+ ],
17
+ usage: {
18
+ description: "Use InputGroup when something belongs inside the field with the text: a unit, a fixed prefix, an icon, or a small action on the value itself (copy, reveal, clear). Use Field's `trailing` for an action beside the field (Apply next to a promo code), SearchInput for search, and NumberField for numbers with steppers.",
19
+ bestPractices: [
20
+ {
21
+ guidance: "do",
22
+ description: 'Keep text addons short and fixed ("https://", "kg", "USD"). They are part of the field, not its label.'
23
+ },
24
+ {
25
+ guidance: "do",
26
+ description: 'For a button addon, use a borderless Button (`variant="ghost"`, `size="icon-sm"`) with an aria-label. The addon tightens around it, so it sits as far from the edge as from the top and bottom.'
27
+ },
28
+ {
29
+ guidance: "do",
30
+ description: 'Wrap the group in Field for a label and errors: `<Field label="Website"><InputGroup leading="https://"><Input /></InputGroup></Field>`.'
31
+ },
32
+ {
33
+ guidance: "dont",
34
+ description: `Do not put the value's meaning only in an addon. A screen reader hears the label and the text, so "Price (USD)" belongs in the label too.`
35
+ },
36
+ {
37
+ guidance: "dont",
38
+ description: "Do not restyle an Input to fake an addon; the group owns the border, the fill and the ring."
39
+ }
40
+ ]
41
+ },
42
+ related: {
43
+ components: ["Input", "Field", "SearchInput", "NumberField", "Button"]
44
+ },
45
+ status: "stable",
46
+ props: [
47
+ {
48
+ name: "leading",
49
+ type: "ReactNode",
50
+ default: "\u2014",
51
+ description: 'Inside the field, before the text: a prefix ("https://", "$"), an icon, or a borderless Button.'
52
+ },
53
+ {
54
+ name: "trailing",
55
+ type: "ReactNode",
56
+ default: "\u2014",
57
+ description: 'Inside the field, after the text: a suffix (".com", "kg"), an icon, or a borderless Button (copy, reveal, clear).'
58
+ },
59
+ {
60
+ name: "children",
61
+ type: "ReactElement",
62
+ default: "\u2014",
63
+ description: "The single `<Input />`. It keeps its own props and type; the group takes over its border, fill and focus ring."
64
+ },
65
+ {
66
+ name: "id / required / disabled / aria-describedby / aria-invalid",
67
+ type: "control props",
68
+ default: "\u2014",
69
+ description: "Forwarded to the Input, not set on the group. This is how a Field's wiring reaches the text box when the group is its child."
70
+ },
71
+ {
72
+ name: "className",
73
+ type: "string",
74
+ default: "\u2014",
75
+ description: "Layout classes for the group (width, margins)."
76
+ }
77
+ ],
78
+ tokensUsed: ["input", "card", "ring", "destructive", "muted-foreground", "foreground"],
79
+ a11y: [
80
+ "The Input stays the focusable control; the group draws the focus ring around the whole field while it is focused.",
81
+ "Inside a Field, the label, description and error are wired to the Input itself, not the group.",
82
+ "Text addons are plain text beside the input, read in order; put a unit or currency in the label as well so it is not only visual.",
83
+ "A button addon is a real Button with its own focus stop and needs an aria-label."
84
+ ],
85
+ examples: [
86
+ {
87
+ label: "Prefix and suffix",
88
+ code: '<Field label="Website"><InputGroup leading="https://" trailing=".com"><Input placeholder="example" /></InputGroup></Field>'
89
+ },
90
+ {
91
+ label: "Leading icon",
92
+ code: '<Field label="Email"><InputGroup leading={<Icon name="mail" />}><Input type="email" placeholder="you@example.com" /></InputGroup></Field>'
93
+ },
94
+ {
95
+ label: "Copy button",
96
+ code: '<Field label="API key"><InputGroup trailing={<Button variant="ghost" size="icon-sm" aria-label="Copy key" onClick={copy}><Icon name={copied ? "check" : "copy"} /></Button>}><Input readOnly value={key} /></InputGroup></Field>'
97
+ },
98
+ {
99
+ label: "Password with reveal",
100
+ code: '<Field label="Password"><InputGroup trailing={<Button variant="ghost" size="icon-sm" aria-label={shown ? "Hide password" : "Show password"} aria-pressed={shown} onClick={() => setShown(!shown)}><Icon name={shown ? "eye-off" : "eye"} /></Button>}><Input type={shown ? "text" : "password"} autoComplete="current-password" /></InputGroup></Field>'
101
+ }
102
+ ]
103
+ };
104
+ export {
105
+ inputGroupMeta
106
+ };
@@ -5,6 +5,7 @@ import {
5
5
  MenuPortal,
6
6
  MenuContent,
7
7
  MenuItem,
8
+ MenuLinkItem,
8
9
  MenuSeparator,
9
10
  MenuGroup,
10
11
  MenuGroupLabel,
@@ -22,6 +23,7 @@ export {
22
23
  MenuGroup,
23
24
  MenuGroupLabel,
24
25
  MenuItem,
26
+ MenuLinkItem,
25
27
  MenuPortal,
26
28
  MenuRadioGroup,
27
29
  MenuRadioItem,
@@ -6,7 +6,7 @@ import { cn } from "../../lib/cn.js";
6
6
  import { withPortalThemeScope } from "../../lib/portal-theme-scope.js";
7
7
  import { useAnchoredDensity, useTypeTier } from "../../lib/density.js";
8
8
  import { HighlightGlide } from "../../lib/highlight-glide.js";
9
- import { jsx, jsxs } from "react/jsx-runtime";
9
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
10
10
  var Menu = Base.Root;
11
11
  var MenuTrigger = Base.Trigger;
12
12
  var MenuPortal = withPortalThemeScope(Base.Portal);
@@ -40,6 +40,28 @@ function MenuContent({
40
40
  }
41
41
  ) }) });
42
42
  }
43
+ var MENU_ITEM = "flex cursor-default select-none items-center gap-(--item-gap) rounded-popup-item px-(--item-px) py-1.5 outline-none motion-interactive data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:shrink-0";
44
+ function useItemContent(children, { icon, description }) {
45
+ const tier = useTypeTier();
46
+ if (!description) return icon ? /* @__PURE__ */ jsxs(Fragment, { children: [
47
+ icon,
48
+ children
49
+ ] }) : children;
50
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
51
+ icon ? /* @__PURE__ */ jsx(
52
+ "span",
53
+ {
54
+ "data-slot": "menu-item-icon",
55
+ className: "flex size-(--control-sm) shrink-0 items-center justify-center rounded-sm border border-border-strong bg-popover text-muted-foreground",
56
+ children: icon
57
+ }
58
+ ) : null,
59
+ /* @__PURE__ */ jsxs("span", { className: "flex min-w-0 flex-col", children: [
60
+ /* @__PURE__ */ jsx("span", { children }),
61
+ /* @__PURE__ */ jsx("span", { "data-slot": "menu-item-description", className: cn("text-muted-foreground", tier.secondary), children: description })
62
+ ] })
63
+ ] });
64
+ }
43
65
  var MENU_ITEM_DESTRUCTIVE = (
44
66
  // The ink is pulled a quarter toward --foreground (as Badge's soft tones
45
67
  // are), from --destructive-text rather than the fill: dark fills sit low
@@ -48,17 +70,34 @@ var MENU_ITEM_DESTRUCTIVE = (
48
70
  // 5.4:1, Prism dark, on the 10% red wash).
49
71
  "text-[color-mix(in_oklch,var(--destructive-text)_75%,var(--foreground))] data-[highlighted]:bg-destructive/10 data-[highlighted]:text-[color-mix(in_oklch,var(--destructive-text)_75%,var(--foreground))]"
50
72
  );
51
- function MenuItem({ className, variant = "default", ...props }) {
73
+ function MenuItem({ className, variant = "default", icon, description, children, ...props }) {
52
74
  const tier = useTypeTier();
75
+ const content = useItemContent(children, { icon, description });
53
76
  return /* @__PURE__ */ jsx(
54
77
  Base.Item,
55
78
  {
56
- className: cn(
57
- `flex cursor-default select-none items-center gap-(--item-gap) rounded-popup-item px-(--item-px) py-1.5 ${tier.body} outline-none motion-interactive data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:shrink-0`,
58
- variant === "destructive" && MENU_ITEM_DESTRUCTIVE,
59
- className
60
- ),
61
- ...props
79
+ className: cn(MENU_ITEM, tier.body, variant === "destructive" && MENU_ITEM_DESTRUCTIVE, className),
80
+ ...props,
81
+ children: content
82
+ }
83
+ );
84
+ }
85
+ function MenuLinkItem({ className, icon, description, children, target, ...props }) {
86
+ const tier = useTypeTier();
87
+ const external = target === "_blank";
88
+ const label = external ? /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-1", children: [
89
+ children,
90
+ /* @__PURE__ */ jsx(Icon, { name: "arrow-up-right", className: "text-[0.875em] text-muted-foreground" })
91
+ ] }) : children;
92
+ const content = useItemContent(label, { icon, description });
93
+ return /* @__PURE__ */ jsx(
94
+ Base.LinkItem,
95
+ {
96
+ className: cn(MENU_ITEM, tier.body, className),
97
+ target,
98
+ rel: external ? "noopener noreferrer" : void 0,
99
+ ...props,
100
+ children: content
62
101
  }
63
102
  );
64
103
  }
@@ -151,6 +190,7 @@ function MenuCheckboxItem({
151
190
  );
152
191
  }
153
192
  export {
193
+ MENU_ITEM,
154
194
  MENU_ITEM_DESTRUCTIVE,
155
195
  Menu,
156
196
  MenuCheckboxItem,
@@ -158,11 +198,13 @@ export {
158
198
  MenuGroup,
159
199
  MenuGroupLabel,
160
200
  MenuItem,
201
+ MenuLinkItem,
161
202
  MenuPortal,
162
203
  MenuRadioGroup,
163
204
  MenuRadioItem,
164
205
  MenuSeparator,
165
206
  MenuSubmenu,
166
207
  MenuSubmenuTrigger,
167
- MenuTrigger
208
+ MenuTrigger,
209
+ useItemContent
168
210
  };
@@ -93,8 +93,13 @@ var menuMeta = {
93
93
  },
94
94
  {
95
95
  name: "MenuItem",
96
- type: 'Base UI Menu.Item props + className (disabled, closeOnClick, onClick) + variant?: "default" | "destructive"',
97
- description: 'An interactive row. Highlights via data-[highlighted]; dims via data-[disabled]. variant="destructive" for a delete or irreversible action: it keeps the destructive ink when highlighted (never className="text-destructive", which the highlight repaints).'
96
+ type: 'Base UI Menu.Item props + className (disabled, closeOnClick, onClick) + variant?: "default" | "destructive" + icon?: ReactNode + description?: ReactNode',
97
+ description: 'An interactive row. Highlights via data-[highlighted]; dims via data-[disabled]. variant="destructive" for a delete or irreversible action: it keeps the destructive ink when highlighted (never className="text-destructive", which the highlight repaints). `icon` leads the row; `description` adds a quieter second line saying what the action does, and the icon then sits in a small tile spanning both lines.'
98
+ },
99
+ {
100
+ name: "MenuLinkItem",
101
+ type: "Base UI Menu.LinkItem props (href, target, closeOnClick) + className + icon?: ReactNode + description?: ReactNode",
102
+ description: 'A row that navigates: a real <a>, so it opens in a new tab, middle-clicks and copies as a link. With target="_blank" it adds rel="noopener noreferrer" and a trailing \u2197. Takes icon and description like MenuItem.'
98
103
  },
99
104
  {
100
105
  name: "MenuSeparator",
@@ -165,6 +170,10 @@ var menuMeta = {
165
170
  label: "Nested, with a single-choice submenu",
166
171
  code: '<Menu>\n <MenuTrigger render={<Button variant="secondary">View</Button>} />\n <MenuContent>\n <MenuItem>Reload</MenuItem>\n <MenuSubmenu>\n <MenuSubmenuTrigger value={density}>Density</MenuSubmenuTrigger>\n <MenuContent side="inline-end" align="start" sideOffset={4}>\n <MenuRadioGroup value={density} onValueChange={setDensity}>\n <MenuRadioItem value="Comfortable">Comfortable</MenuRadioItem>\n <MenuRadioItem value="Compact">Compact</MenuRadioItem>\n </MenuRadioGroup>\n </MenuContent>\n </MenuSubmenu>\n </MenuContent>\n</Menu>'
167
172
  },
173
+ {
174
+ label: "Rows with icons and descriptions, and links",
175
+ code: '<Menu>\n <MenuTrigger render={<Button variant="outline" size="sm">Share</Button>} />\n <MenuContent align="end">\n <MenuItem icon={<Icon name="copy" />} description="Copy page as Markdown for LLMs" onClick={copy}>Copy page</MenuItem>\n <MenuLinkItem icon={<Icon name="file" />} description="View this page as plain text" href={markdownUrl} target="_blank">View as Markdown</MenuLinkItem>\n </MenuContent>\n</Menu>'
176
+ },
168
177
  {
169
178
  label: "Toggling rows",
170
179
  code: '<Menu>\n <MenuTrigger render={<Button variant="secondary">Columns</Button>} />\n <MenuContent>\n <MenuCheckboxItem checked={cols.status} onCheckedChange={(v) => setCol("status", v)}>Status</MenuCheckboxItem>\n <MenuCheckboxItem checked={cols.updated} onCheckedChange={(v) => setCol("updated", v)}>Updated</MenuCheckboxItem>\n </MenuContent>\n</Menu>'
@@ -0,0 +1,8 @@
1
+ // src/components/menubar/index.ts
2
+ import { Menubar, MenubarTrigger } from "./menubar.js";
3
+ import { menubarMeta } from "./menubar.meta.js";
4
+ export {
5
+ Menubar,
6
+ MenubarTrigger,
7
+ menubarMeta
8
+ };
@@ -0,0 +1,42 @@
1
+ // src/components/menubar/menubar.tsx
2
+ import { Menubar as Base } from "@base-ui/react/menubar";
3
+ import { cn } from "../../lib/cn.js";
4
+ import { useTypeTier } from "../../lib/density.js";
5
+ import { trimLabel } from "../../lib/trim-label.js";
6
+ import { buttonVariants } from "../button/button.variants.js";
7
+ import { MenuTrigger } from "../menu/index.js";
8
+ import { jsx } from "react/jsx-runtime";
9
+ function Menubar({ className, ...props }) {
10
+ return /* @__PURE__ */ jsx(
11
+ Base,
12
+ {
13
+ className: cn(
14
+ "inline-flex items-center gap-0.5 data-[orientation=vertical]:flex-col data-[orientation=vertical]:items-stretch",
15
+ className
16
+ ),
17
+ ...props
18
+ }
19
+ );
20
+ }
21
+ Menubar.displayName = "Menubar";
22
+ function MenubarTrigger({ className, children, ...props }) {
23
+ const tier = useTypeTier();
24
+ return /* @__PURE__ */ jsx(
25
+ MenuTrigger,
26
+ {
27
+ className: cn(
28
+ buttonVariants({ variant: "ghost", size: "sm" }),
29
+ tier.control,
30
+ "sable-open:bg-accent sable-open:text-accent-foreground",
31
+ className
32
+ ),
33
+ ...props,
34
+ children: trimLabel(children)
35
+ }
36
+ );
37
+ }
38
+ MenubarTrigger.displayName = "MenubarTrigger";
39
+ export {
40
+ Menubar,
41
+ MenubarTrigger
42
+ };
@@ -0,0 +1,99 @@
1
+ // src/components/menubar/menubar.meta.ts
2
+ var menubarMeta = {
3
+ name: "Menubar",
4
+ summary: "An application's row of menus \u2014 File, Edit, View \u2014 over Base UI's Menubar. Each menu is an ordinary Sable Menu with a MenubarTrigger as its title; the bar joins them into one tab stop with \u2190/\u2192 between titles, and once one menu is open, pointing at a neighbour opens it.",
5
+ status: "stable",
6
+ keywords: ["menubar", "menu bar", "app menu", "file menu", "edit menu", "application menu", "desktop menu"],
7
+ usage: {
8
+ description: "Use Menubar for a tool or editor whose commands are too many for a toolbar and belong under named menus (File, Edit, View, Help). Use Toolbar for a row of direct actions, a single Menu or OverflowMenu for one set of options, and NavigationMenu-style links for site navigation.",
9
+ bestPractices: [
10
+ {
11
+ guidance: "do",
12
+ description: "Compose each menu from the Menu parts: `<Menu><MenubarTrigger>File</MenubarTrigger><MenuContent>\u2026</MenuContent></Menu>`. Rows, groups, submenus, checkbox and radio items are the same parts a lone Menu uses."
13
+ },
14
+ {
15
+ guidance: "do",
16
+ description: `Name the bar with aria-label ("Editor") so it is announced as the application's menus, not a list of buttons.`
17
+ },
18
+ {
19
+ guidance: "do",
20
+ description: "Keep titles to one short word each. A reader scans a menubar left to right; a phrase in the bar is a toolbar button."
21
+ },
22
+ {
23
+ guidance: "dont",
24
+ description: "Do not use MenubarTrigger outside a Menubar, or a plain MenuTrigger inside one. The bar's arrow keys and hover-to-switch work on the triggers it owns; a Button trigger in the bar looks like a menu title and behaves like a stray button."
25
+ },
26
+ {
27
+ guidance: "dont",
28
+ description: "Do not use a menubar for site navigation. Its items are commands in menus (role=menuitem), not links between pages."
29
+ }
30
+ ],
31
+ anatomy: [
32
+ { name: "Menubar", required: true, description: "The bar: role=menubar, one tab stop, arrow keys between titles." },
33
+ { name: "Menu", required: true, description: "One per title; the ordinary Menu root." },
34
+ { name: "MenubarTrigger", required: true, description: "The title in the bar that opens its menu." },
35
+ { name: "MenuContent", required: true, description: "The menu's popup, with MenuItem / MenuSubmenu / MenuCheckboxItem rows inside." }
36
+ ]
37
+ },
38
+ related: {
39
+ components: ["Menu", "Toolbar", "OverflowMenu", "ContextMenu"]
40
+ },
41
+ props: [
42
+ {
43
+ name: "orientation",
44
+ type: '"horizontal" | "vertical"',
45
+ default: '"horizontal"',
46
+ description: "The bar's axis. Arrow keys follow it: \u2190/\u2192 along a horizontal bar, \u2191/\u2193 down a vertical one."
47
+ },
48
+ {
49
+ name: "loopFocus",
50
+ type: "boolean",
51
+ default: "true",
52
+ description: "Wrap from the last title back to the first with the arrow keys."
53
+ },
54
+ {
55
+ name: "modal",
56
+ type: "boolean",
57
+ default: "true",
58
+ description: "While a menu is open, block pointer interaction outside it and lock page scroll, like a lone Menu."
59
+ },
60
+ {
61
+ name: "disabled",
62
+ type: "boolean",
63
+ default: "false",
64
+ description: "Disable every menu in the bar."
65
+ },
66
+ {
67
+ name: "className",
68
+ type: "string",
69
+ description: "Layout classes for the bar. Titles take their look from MenubarTrigger."
70
+ }
71
+ ],
72
+ subcomponents: [
73
+ {
74
+ name: "MenubarTrigger",
75
+ type: "Base UI Menu.Trigger props + className",
76
+ description: "A menu's title in the bar. A small ghost Button in the control type tier, held in the accent wash while its menu is open (`sable-open:`), so the reader sees which title the popup belongs to."
77
+ }
78
+ ],
79
+ tokensUsed: ["accent", "accent-foreground", "foreground", "ring"],
80
+ a11y: [
81
+ 'Base UI renders role="menubar" with aria-orientation; each title is a menuitem with aria-haspopup and aria-expanded.',
82
+ "The bar is one tab stop. \u2190/\u2192 move between titles (\u2191/\u2193 when vertical), \u2193 or Enter opens a menu and moves into it, and Esc closes it and returns focus to its title.",
83
+ "With one menu open, pointing at another title opens that menu instead, so a reader can sweep across the bar, as in a desktop application.",
84
+ "Name the bar with aria-label; titles are their own text."
85
+ ],
86
+ examples: [
87
+ {
88
+ label: "Editor menus",
89
+ code: '<Menubar aria-label="Editor">\n <Menu>\n <MenubarTrigger>File</MenubarTrigger>\n <MenuContent>\n <MenuItem>New</MenuItem>\n <MenuItem>Open\u2026</MenuItem>\n <MenuSeparator />\n <MenuItem>Save</MenuItem>\n </MenuContent>\n </Menu>\n <Menu>\n <MenubarTrigger>Edit</MenubarTrigger>\n <MenuContent>\n <MenuItem>Undo</MenuItem>\n <MenuItem>Redo</MenuItem>\n </MenuContent>\n </Menu>\n</Menubar>'
90
+ },
91
+ {
92
+ label: "With a submenu and toggling rows",
93
+ code: '<Menubar aria-label="Editor">\n <Menu>\n <MenubarTrigger>View</MenubarTrigger>\n <MenuContent>\n <MenuCheckboxItem checked={ruler} onCheckedChange={setRuler}>Show ruler</MenuCheckboxItem>\n <MenuSubmenu>\n <MenuSubmenuTrigger value={zoom}>Zoom</MenuSubmenuTrigger>\n <MenuContent side="inline-end" align="start" sideOffset={4}>\n <MenuRadioGroup value={zoom} onValueChange={setZoom}>\n <MenuRadioItem value="100%">100%</MenuRadioItem>\n <MenuRadioItem value="Fit">Fit</MenuRadioItem>\n </MenuRadioGroup>\n </MenuContent>\n </MenuSubmenu>\n </MenuContent>\n </Menu>\n</Menubar>'
94
+ }
95
+ ]
96
+ };
97
+ export {
98
+ menubarMeta
99
+ };
@@ -20,6 +20,10 @@ var messageScrollerMeta = {
20
20
  usage: {
21
21
  description: "The scroll region a conversation lives in. It owns the scrolling and nothing else \u2014 not your messages, not your AI state, not your transport or persistence \u2014 so you bring the content renderer and it handles the behaviors that make streaming chat feel right: pinning to the newest message, releasing when the reader scrolls up, keeping the view still when older history loads above, jumping to a specific message, restoring a saved read position, and reporting what is actually on screen.",
22
22
  bestPractices: [
23
+ {
24
+ guidance: "do",
25
+ description: "Building a whole chat? `sable docs building-a-chat` covers what the scroller does during a stream, what the app owns, and the failure states around it."
26
+ },
23
27
  {
24
28
  guidance: "do",
25
29
  description: "Bound it the way you would bound any scroll box \u2014 `flex-1 min-h-0` inside a flex column, or `max-h-96`. `className` lands on the element that actually scrolls, so the obvious bound is the correct one (same contract as Scroller)."
@@ -0,0 +1,19 @@
1
+ // src/components/navigation-menu/index.ts
2
+ import {
3
+ NavigationMenu,
4
+ NavigationMenuContent,
5
+ NavigationMenuItem,
6
+ NavigationMenuLink,
7
+ NavigationMenuList,
8
+ NavigationMenuTrigger
9
+ } from "./navigation-menu.js";
10
+ import { navigationMenuMeta } from "./navigation-menu.meta.js";
11
+ export {
12
+ NavigationMenu,
13
+ NavigationMenuContent,
14
+ NavigationMenuItem,
15
+ NavigationMenuLink,
16
+ NavigationMenuList,
17
+ NavigationMenuTrigger,
18
+ navigationMenuMeta
19
+ };
@@ -0,0 +1,108 @@
1
+ // src/components/navigation-menu/navigation-menu.tsx
2
+ import { NavigationMenu as Base } from "@base-ui/react/navigation-menu";
3
+ import { createContext, useContext } from "react";
4
+ import { Icon } from "../../icon/index.js";
5
+ import { cn } from "../../lib/cn.js";
6
+ import { useAnchoredDensity, useTypeTier } from "../../lib/density.js";
7
+ import { withPortalThemeScope } from "../../lib/portal-theme-scope.js";
8
+ import { jsx, jsxs } from "react/jsx-runtime";
9
+ var NavigationMenuPortal = withPortalThemeScope(Base.Portal);
10
+ function NavigationMenu({ className, children, align = "start", sideOffset = 8, ...props }) {
11
+ return /* @__PURE__ */ jsxs(Base.Root, { className: cn("relative", className), ...props, children: [
12
+ children,
13
+ /* @__PURE__ */ jsx(NavigationMenuPanel, { align, sideOffset })
14
+ ] });
15
+ }
16
+ function NavigationMenuPanel({
17
+ align,
18
+ sideOffset
19
+ }) {
20
+ const anchoredDensity = useAnchoredDensity();
21
+ return /* @__PURE__ */ jsx(NavigationMenuPortal, { children: /* @__PURE__ */ jsx(
22
+ Base.Positioner,
23
+ {
24
+ align,
25
+ sideOffset,
26
+ collisionPadding: 16,
27
+ className: cn(
28
+ "z-(--z-overlay) transition-[top,left,right,bottom] duration-(--motion-duration-slow) ease-emphasized data-instant:transition-none",
29
+ // A bridge over the sideOffset gap, so the pointer can travel from
30
+ // the trigger into the panel without crossing open space.
31
+ "before:absolute before:inset-x-0 before:bottom-full before:h-2 before:content-['']"
32
+ ),
33
+ children: /* @__PURE__ */ jsx(
34
+ Base.Popup,
35
+ {
36
+ ...anchoredDensity,
37
+ className: cn(
38
+ "relative h-(--popup-height) w-(--popup-width) origin-(--transform-origin) rounded-lg border border-border-floating bg-popover sable-surface-popover text-popover-foreground shadow-md outline-none",
39
+ "transition-[opacity,scale,width,height] duration-(--motion-duration-slow) ease-emphasized",
40
+ "data-starting-style:scale-96 data-starting-style:opacity-0 data-ending-style:scale-96 data-ending-style:opacity-0",
41
+ "data-ending-style:duration-(--motion-duration-exit) data-ending-style:ease-standard"
42
+ ),
43
+ children: /* @__PURE__ */ jsx(Base.Viewport, { className: "relative size-full overflow-hidden rounded-[inherit]" })
44
+ }
45
+ )
46
+ }
47
+ ) });
48
+ }
49
+ function NavigationMenuList({ className, ...props }) {
50
+ return /* @__PURE__ */ jsx(Base.List, { className: cn("flex items-center gap-1", className), ...props });
51
+ }
52
+ function NavigationMenuItem({ className, ...props }) {
53
+ return /* @__PURE__ */ jsx(Base.Item, { className, ...props });
54
+ }
55
+ var BAR_ENTRY = "inline-flex h-(--control-sm) select-none items-center gap-1 rounded-[var(--button-radius,9999px)] px-3 text-muted-foreground outline-none sable-surface-interactive motion-interactive focus-visible:ring-2 focus-visible:ring-ring data-popup-open:text-foreground";
56
+ function NavigationMenuTrigger({ className, children, ...props }) {
57
+ const tier = useTypeTier();
58
+ return /* @__PURE__ */ jsxs(Base.Trigger, { className: cn(BAR_ENTRY, tier.control, className), ...props, children: [
59
+ children,
60
+ /* @__PURE__ */ jsx(Base.Icon, { className: "flex transition-[rotate] duration-(--motion-duration-fast) ease-standard data-popup-open:rotate-180", children: /* @__PURE__ */ jsx(Icon, { name: "chevron-down", className: "text-[0.875em]" }) })
61
+ ] });
62
+ }
63
+ var InPanelContext = createContext(false);
64
+ function NavigationMenuContent({ className, ...props }) {
65
+ return /* @__PURE__ */ jsx(InPanelContext.Provider, { value: true, children: /* @__PURE__ */ jsx(
66
+ Base.Content,
67
+ {
68
+ className: cn(
69
+ "h-full w-max max-w-[calc(100vw-2rem)] popup-inset",
70
+ "transition-[opacity,translate] duration-(--motion-duration-slow) ease-emphasized",
71
+ "data-starting-style:opacity-0 data-ending-style:opacity-0",
72
+ "data-starting-style:data-[activation-direction=left]:-translate-x-1/2 data-starting-style:data-[activation-direction=right]:translate-x-1/2",
73
+ "data-ending-style:data-[activation-direction=left]:translate-x-1/2 data-ending-style:data-[activation-direction=right]:-translate-x-1/2",
74
+ className
75
+ ),
76
+ ...props
77
+ }
78
+ ) });
79
+ }
80
+ function NavigationMenuLink({ className, description, children, ...props }) {
81
+ const inPanel = useContext(InPanelContext);
82
+ const tier = useTypeTier();
83
+ if (!inPanel) {
84
+ return /* @__PURE__ */ jsx(Base.Link, { className: cn(BAR_ENTRY, tier.control, className), ...props, children });
85
+ }
86
+ return /* @__PURE__ */ jsxs(
87
+ Base.Link,
88
+ {
89
+ className: cn(
90
+ "flex flex-col gap-0.5 rounded-popup-item px-(--item-px) py-2 outline-none sable-surface-interactive motion-interactive focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
91
+ className
92
+ ),
93
+ ...props,
94
+ children: [
95
+ /* @__PURE__ */ jsx("span", { className: tier.control, children }),
96
+ description ? /* @__PURE__ */ jsx("span", { className: cn("text-muted-foreground", tier.secondary), children: description }) : null
97
+ ]
98
+ }
99
+ );
100
+ }
101
+ export {
102
+ NavigationMenu,
103
+ NavigationMenuContent,
104
+ NavigationMenuItem,
105
+ NavigationMenuLink,
106
+ NavigationMenuList,
107
+ NavigationMenuTrigger
108
+ };