@supertype.ai/foundations 0.1.28 → 0.1.30

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 (54) hide show
  1. package/README.md +27 -23
  2. package/bin/foundations.mjs +5 -2
  3. package/dist/blocks/accordion.js +2 -1
  4. package/dist/blocks/badge.d.ts +27 -0
  5. package/dist/blocks/badge.js +86 -0
  6. package/dist/blocks/button.d.ts +32 -0
  7. package/dist/blocks/button.js +127 -0
  8. package/dist/blocks/callout.d.ts +2 -39
  9. package/dist/blocks/callout.js +13 -38
  10. package/dist/blocks/card.d.ts +5 -6
  11. package/dist/blocks/card.js +28 -12
  12. package/dist/blocks/focus.d.ts +9 -0
  13. package/dist/blocks/focus.js +9 -0
  14. package/dist/blocks/index.d.ts +3 -1
  15. package/dist/blocks/index.js +3 -1
  16. package/dist/blocks/interactive-accordion.js +2 -1
  17. package/dist/blocks/render-as.d.ts +11 -0
  18. package/dist/blocks/render-as.js +22 -0
  19. package/dist/blocks/segment.d.ts +22 -14
  20. package/dist/blocks/segment.js +23 -14
  21. package/dist/blocks/tabs.d.ts +52 -17
  22. package/dist/blocks/tabs.js +86 -37
  23. package/dist/cjs/eslint.js +49 -14
  24. package/dist/contrast.d.ts +56 -0
  25. package/dist/contrast.js +144 -12
  26. package/dist/eslint.d.ts +42 -8
  27. package/dist/eslint.js +47 -14
  28. package/dist/essay/essay.js +14 -6
  29. package/dist/essay/index.d.ts +1 -1
  30. package/dist/essay/index.js +1 -1
  31. package/dist/essay/layout.d.ts +37 -1
  32. package/dist/essay/layout.js +33 -1
  33. package/dist/essay/rail.d.ts +11 -1
  34. package/dist/essay/reading.d.ts +1 -1
  35. package/dist/essay/scroll.js +1 -1
  36. package/dist/href.d.ts +42 -0
  37. package/dist/href.js +63 -0
  38. package/dist/index.d.ts +2 -0
  39. package/dist/index.js +9 -0
  40. package/dist/mdx.d.ts +26 -4
  41. package/dist/mdx.js +30 -4
  42. package/dist/tone.d.ts +130 -0
  43. package/dist/tone.js +140 -0
  44. package/dist/typography/header.d.ts +32 -2
  45. package/dist/typography/header.js +10 -5
  46. package/dist/typography/highlight.d.ts +7 -6
  47. package/dist/typography/highlight.js +15 -9
  48. package/dist/typography/paragraph.d.ts +12 -25
  49. package/dist/typography/paragraph.js +40 -29
  50. package/llms.txt +99 -22
  51. package/package.json +2 -1
  52. package/src/theme.css +143 -46
  53. package/src/tokens.css +5 -4
  54. package/src/type.css +1 -1
@@ -1,6 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { cn } from "../cn.js";
3
- import { Link } from "next-view-transitions";
3
+ import { FOCUS_RING } from "./focus.js";
4
+ import { inkOnSurface, toneClass } from "../tone.js";
5
+ import { resolveLink } from "../href.js";
4
6
  /** Two columns from `sm` up: a pair reads as a set rather than two panels. */
5
7
  export function Cards({ className, children, ...props }) {
6
8
  return (_jsx("div", { className: cn("my-6 grid gap-4 sm:grid-cols-2", className), ...props, children: children }));
@@ -10,9 +12,20 @@ export function Cards({ className, children, ...props }) {
10
12
  * grid and `overflow-hidden` clips a bleed image cleanly. Padding is vertical
11
13
  * only — the horizontal inset belongs to the slots, so bands can run edge to edge.
12
14
  */
13
- const CARD_CLASS = "flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-border " +
15
+ const CARD_CLASS = `flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-border ${inkOnSurface("--card-foreground")} ` +
14
16
  "has-[>img:first-child]:pt-0 " +
15
17
  "*:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl";
18
+ /**
19
+ * What a card does when it is a link, which is the only time it does anything: two pixels
20
+ * up, a shadow under it, the ring firming from `--border` to a cut of the page's own ink.
21
+ * Following a link is not a colour, so nothing here is one — `--elevation-raised` is the
22
+ * token for a layer leaving the page plane, and that is the whole gesture. The lift is
23
+ * `motion-safe:` and the shadow is not, so reduced motion keeps the affordance.
24
+ *
25
+ * `toneClass` is declared here so the icon below can take `--tone-hue` rather than naming
26
+ * a token, the way every other tinted role in the package reads it.
27
+ */
28
+ const CARD_LINK_CLASS = cn(toneClass("primary"), FOCUS_RING, "group/card no-underline transition duration-200 ease-out", "hover:shadow-raised hover:ring-foreground/15 motion-safe:hover:-translate-y-0.5");
16
29
  export function CardHeader({ className, ...props }) {
17
30
  return (_jsx("div", { "data-slot": "card-header", className: cn("grid auto-rows-min items-start gap-1 px-4 [.border-b]:pb-4", className), ...props }));
18
31
  }
@@ -36,21 +49,24 @@ export function CardContent({ className, ...props }) {
36
49
  /**
37
50
  * Takes either shape: `title`/`href` fills the header, or compose the slots
38
51
  * directly. Unrecognised props pass through — MDX authors reach for the whole
39
- * HTML surface. An href with a scheme leaves the app; the rest route through
40
- * the router's Link.
52
+ * HTML surface. Where the href goes is ../href.ts's call, the same one Button
53
+ * and TypographyLink make.
41
54
  */
42
- export function Card({ href, className, external, title, description, icon, children, ...rest }) {
43
- const header = title || description || icon ? (_jsxs(CardHeader, { children: [icon ? _jsx("div", { className: "mb-1 text-muted-foreground", children: icon }) : null, title ? _jsx(CardTitle, { children: title }) : null, description ? _jsx(CardDescription, { children: description }) : null] })) : null;
55
+ export function Card({ href, className, external, newTab, title, description, icon, children, ...rest }) {
56
+ const header = title || description || icon ? (_jsxs(CardHeader, { children: [icon || title ? (
57
+ // The icon sits on the title's line and is its mark; stacked, it read as a
58
+ // decoration the title happened to follow. `gap-2` is a gap between two
59
+ // objects, not the header's `gap-1` between two lines.
60
+ _jsxs("div", { className: "flex items-center gap-2", children: [icon ? (
61
+ // Sized here, not at the call site, so two cards cannot disagree about how
62
+ // big an icon is. On a link card it takes the tone as the card lifts.
63
+ _jsx("span", { className: "shrink-0 text-muted-foreground transition-colors group-hover/card:text-(color:--tone-hue) [&_svg]:size-4 [&_svg]:shrink-0", children: icon })) : null, title ? _jsx(CardTitle, { children: title }) : null] })) : null, description ? _jsx(CardDescription, { children: description }) : null] })) : null;
44
64
  // Bare children compose; children under a shorthand header are body copy.
45
65
  const body = header ? (_jsxs(_Fragment, { children: [header, children ? _jsx(CardContent, { children: children }) : null] })) : (children);
46
66
  const shared = { "data-slot": "card" };
47
67
  if (!href) {
48
68
  return (_jsx("div", { className: cn(CARD_CLASS, className), ...shared, ...rest, children: body }));
49
69
  }
50
- const leavesApp = external ?? /^[a-z][a-z0-9+.-]*:/i.test(href);
51
- const classes = cn(CARD_CLASS, "no-underline transition-colors hover:bg-accent", className);
52
- if (leavesApp) {
53
- return (_jsx("a", { href: href, className: classes, target: "_blank", rel: "noopener noreferrer", ...shared, ...rest, children: body }));
54
- }
55
- return (_jsx(Link, { href: href, className: classes, ...shared, ...rest, children: body }));
70
+ const { Component, props: link } = resolveLink(href, { external, newTab });
71
+ return (_jsx(Component, { className: cn(CARD_CLASS, CARD_LINK_CLASS, className), ...link, ...shared, ...rest, children: body }));
56
72
  }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The focus ring, stated once.
3
+ *
4
+ * It was five copies — Button, Badge, Accordion, `SEGMENT.item`, and Card, which
5
+ * added the fifth while its own comment observed that the other four existed.
6
+ * Retuning the width or the ink was a find-and-replace across five files with no
7
+ * compiler help if one was missed.
8
+ */
9
+ export declare const FOCUS_RING = "outline-none focus-visible:ring-3 focus-visible:ring-ring/50";
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The focus ring, stated once.
3
+ *
4
+ * It was five copies — Button, Badge, Accordion, `SEGMENT.item`, and Card, which
5
+ * added the fifth while its own comment observed that the other four existed.
6
+ * Retuning the width or the ink was a find-and-replace across five files with no
7
+ * compiler help if one was missed.
8
+ */
9
+ export const FOCUS_RING = "outline-none focus-visible:ring-3 focus-visible:ring-ring/50";
@@ -2,6 +2,8 @@ export { Cards, Card, CardHeader, CardTitle, CardDescription, CardContent, } fro
2
2
  export { Disclosure, DisclosureGroup } from "./accordion.js";
3
3
  export { Accordion, AccordionItem, AccordionTrigger, AccordionContent, } from "./interactive-accordion.js";
4
4
  export { Callout } from "./callout.js";
5
- export { Tabs, TabsList, TabsTrigger, TabsContent, TabGroup, Tab, } from "./tabs.js";
5
+ export { Button, buttonVariants, type ButtonLook } from "./button.js";
6
+ export { Badge, badgeVariants, type BadgeLook } from "./badge.js";
7
+ export { Tabs, TabsList, TabsTrigger, TabsContent, TabGroup, type TabItem, } from "./tabs.js";
6
8
  export { SEGMENT } from "./segment.js";
7
9
  export { Steps, Step } from "./steps.js";
@@ -2,6 +2,8 @@ export { Cards, Card, CardHeader, CardTitle, CardDescription, CardContent, } fro
2
2
  export { Disclosure, DisclosureGroup } from "./accordion.js";
3
3
  export { Accordion, AccordionItem, AccordionTrigger, AccordionContent, } from "./interactive-accordion.js";
4
4
  export { Callout } from "./callout.js";
5
- export { Tabs, TabsList, TabsTrigger, TabsContent, TabGroup, Tab, } from "./tabs.js";
5
+ export { Button, buttonVariants } from "./button.js";
6
+ export { Badge, badgeVariants } from "./badge.js";
7
+ export { Tabs, TabsList, TabsTrigger, TabsContent, TabGroup, } from "./tabs.js";
6
8
  export { SEGMENT } from "./segment.js";
7
9
  export { Steps, Step } from "./steps.js";
@@ -8,6 +8,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
8
  */
9
9
  import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion";
10
10
  import { cn } from "../cn.js";
11
+ import { FOCUS_RING } from "./focus.js";
11
12
  function Accordion({ className, ...props }) {
12
13
  return (_jsx(AccordionPrimitive.Root, { "data-slot": "accordion", className: cn("flex w-full flex-col", className), ...props }));
13
14
  }
@@ -15,7 +16,7 @@ function AccordionItem({ className, ...props }) {
15
16
  return (_jsx(AccordionPrimitive.Item, { "data-slot": "accordion-item", className: cn("not-last:border-b", className), ...props }));
16
17
  }
17
18
  function AccordionTrigger({ className, children, ...props }) {
18
- return (_jsx(AccordionPrimitive.Header, { className: "flex", children: _jsxs(AccordionPrimitive.Trigger, { "data-slot": "accordion-trigger", className: cn("group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground", className), ...props, children: [children, _jsx(Chevron, { className: "group-aria-expanded/accordion-trigger:hidden" }), _jsx(Chevron, { up: true, className: "hidden group-aria-expanded/accordion-trigger:inline" })] }) }));
19
+ return (_jsx(AccordionPrimitive.Header, { className: "flex", children: _jsxs(AccordionPrimitive.Trigger, { "data-slot": "accordion-trigger", className: cn(FOCUS_RING, "group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition hover:underline focus-visible:border-ring focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground", className), ...props, children: [children, _jsx(Chevron, { className: "group-aria-expanded/accordion-trigger:hidden" }), _jsx(Chevron, { up: true, className: "hidden group-aria-expanded/accordion-trigger:inline" })] }) }));
19
20
  }
20
21
  function AccordionContent({ className, children, ...props }) {
21
22
  return (_jsx(AccordionPrimitive.Panel, { "data-slot": "accordion-content", className: "overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up", ...props, children: _jsx("div", { className: cn("h-(--accordion-panel-height) pt-0 pb-2.5 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4", className), children: children }) }));
@@ -0,0 +1,11 @@
1
+ import { type ReactElement } from "react";
2
+ /**
3
+ * `render={<a href="…" />}` — the component's classes and data marks put onto an element
4
+ * the caller supplies, so a badge or a button can BE a link rather than wrap one. A
5
+ * screen reader announces the element, and the element is the anchor.
6
+ *
7
+ * Shared because Button and Badge both do it, identically, for the same reason; both
8
+ * files' comments used to say so while carrying their own copy. Returns `null` when
9
+ * `render` is not an element, which is the caller's signal to render its own tag.
10
+ */
11
+ export declare function renderAs(render: unknown, classes: string, props: Record<string, unknown>): ReactElement | null;
@@ -0,0 +1,22 @@
1
+ import { cloneElement, isValidElement } from "react";
2
+ import { cn } from "../cn.js";
3
+ /**
4
+ * `render={<a href="…" />}` — the component's classes and data marks put onto an element
5
+ * the caller supplies, so a badge or a button can BE a link rather than wrap one. A
6
+ * screen reader announces the element, and the element is the anchor.
7
+ *
8
+ * Shared because Button and Badge both do it, identically, for the same reason; both
9
+ * files' comments used to say so while carrying their own copy. Returns `null` when
10
+ * `render` is not an element, which is the caller's signal to render its own tag.
11
+ */
12
+ export function renderAs(render, classes, props) {
13
+ // `unknown`, because Base UI's `render` is an element OR a render function; only the
14
+ // element half is ours to clone, and `isValidElement` is the narrowing.
15
+ if (!isValidElement(render))
16
+ return null;
17
+ const { className } = render.props;
18
+ return cloneElement(render, {
19
+ ...props,
20
+ className: cn(classes, className),
21
+ });
22
+ }
@@ -9,29 +9,37 @@
9
9
  * class strings, which is exactly the kind of duplication that drifts the first time
10
10
  * someone retunes the palette.
11
11
  *
12
- * Some entries appear twice, once bare and once `data-active:`-prefixed. That is not
13
- * laziness: Tailwind generates a utility only if it appears literally in scanned source, so
14
- * prefixing at runtime would compile to classes that never got built. Writing both forms
15
- * side by side is the honest version, and it keeps the pair impossible to change by half.
16
- *
17
12
  * The active segment is deliberately flat — a card surface and a hairline, no shadow.
18
13
  * Elevation in this system means "this layer left the page plane" (see --elevation-* in
19
14
  * theme.css), and a segment sitting inside its own track has not.
15
+ *
16
+ * The other half of that: the segment stays on the page plane because the RAIL drops below
17
+ * it. The well is the shadow and the hairline, not a fill — the track is `bg-background`
18
+ * pressed in. A `--muted` rail cannot work, because `--muted` sits below `--card` in light
19
+ * and above it in dark, so a muted fill reads correct in one theme and inverted in the
20
+ * other, and `dark:` is what the package's own ESLint rule exists to stop.
20
21
  */
21
22
  export declare const SEGMENT: {
22
- /** The rail a set of segments sits in. Surface only; each component owns its layout. */
23
- readonly track: "rounded-lg border border-border bg-muted/40 p-0.5";
23
+ /**
24
+ * The rail a set of segments sits in. Surface only; each component owns its layout.
25
+ *
26
+ * `rounded-md` over `activeSurface`'s `rounded-sm` is the 2px of `p-0.5`: concentric
27
+ * radii, so the segment's corner runs parallel to the rail's rather than across it.
28
+ */
29
+ readonly track: "rounded-md border border-border bg-background p-0.5 shadow-recessed";
24
30
  /** Affordances every segment shares, whatever its shape or engine. */
25
- readonly item: "relative inline-flex items-center gap-1.5 font-medium outline-none transition-colors focus-visible:ring-3 focus-visible:ring-ring/50";
31
+ readonly item: "relative inline-flex items-center gap-1.5 font-medium transition-colors outline-none focus-visible:ring-3 focus-visible:ring-ring/50";
26
32
  /** Selected: the ink lifts to full strength. */
27
33
  readonly active: "text-foreground";
28
34
  /**
29
35
  * Unselected: quiet, but lighting its own surface on hover so the whole strip reads as
30
- * reachable rather than only the segment already chosen.
36
+ * reachable rather than only the segment already chosen. It moves toward `activeSurface`
37
+ * and not toward the rail, so a hover previews being picked.
38
+ */
39
+ readonly idle: "text-muted-foreground hover:bg-card/60 hover:text-foreground";
40
+ /**
41
+ * The flat surface marking the selection. One string, worn by both engines: the marketing
42
+ * picker slides it with `motion`, `Tabs` hands it to the element Base UI positions.
31
43
  */
32
- readonly idle: "text-muted-foreground hover:bg-muted/60 hover:text-foreground";
33
- /** The flat surface marking the selection. */
34
- readonly activeSurface: "bg-card ring-1 ring-border";
35
- /** `activeSurface`, for an engine that marks its own trigger with `data-active`. */
36
- readonly dataActiveSurface: "data-active:bg-card data-active:text-foreground data-active:ring-1 data-active:ring-border";
44
+ readonly activeSurface: "rounded-sm bg-card ring-1 ring-border";
37
45
  };
@@ -1,3 +1,4 @@
1
+ import { FOCUS_RING } from "./focus.js";
1
2
  /**
2
3
  * The segmented picker, as one set of surfaces.
3
4
  *
@@ -9,29 +10,37 @@
9
10
  * class strings, which is exactly the kind of duplication that drifts the first time
10
11
  * someone retunes the palette.
11
12
  *
12
- * Some entries appear twice, once bare and once `data-active:`-prefixed. That is not
13
- * laziness: Tailwind generates a utility only if it appears literally in scanned source, so
14
- * prefixing at runtime would compile to classes that never got built. Writing both forms
15
- * side by side is the honest version, and it keeps the pair impossible to change by half.
16
- *
17
13
  * The active segment is deliberately flat — a card surface and a hairline, no shadow.
18
14
  * Elevation in this system means "this layer left the page plane" (see --elevation-* in
19
15
  * theme.css), and a segment sitting inside its own track has not.
16
+ *
17
+ * The other half of that: the segment stays on the page plane because the RAIL drops below
18
+ * it. The well is the shadow and the hairline, not a fill — the track is `bg-background`
19
+ * pressed in. A `--muted` rail cannot work, because `--muted` sits below `--card` in light
20
+ * and above it in dark, so a muted fill reads correct in one theme and inverted in the
21
+ * other, and `dark:` is what the package's own ESLint rule exists to stop.
20
22
  */
21
23
  export const SEGMENT = {
22
- /** The rail a set of segments sits in. Surface only; each component owns its layout. */
23
- track: "rounded-lg border border-border bg-muted/40 p-0.5",
24
+ /**
25
+ * The rail a set of segments sits in. Surface only; each component owns its layout.
26
+ *
27
+ * `rounded-md` over `activeSurface`'s `rounded-sm` is the 2px of `p-0.5`: concentric
28
+ * radii, so the segment's corner runs parallel to the rail's rather than across it.
29
+ */
30
+ track: "rounded-md border border-border bg-background p-0.5 shadow-recessed",
24
31
  /** Affordances every segment shares, whatever its shape or engine. */
25
- item: "relative inline-flex items-center gap-1.5 font-medium outline-none transition-colors focus-visible:ring-3 focus-visible:ring-ring/50",
32
+ item: `relative inline-flex items-center gap-1.5 font-medium transition-colors ${FOCUS_RING}`,
26
33
  /** Selected: the ink lifts to full strength. */
27
34
  active: "text-foreground",
28
35
  /**
29
36
  * Unselected: quiet, but lighting its own surface on hover so the whole strip reads as
30
- * reachable rather than only the segment already chosen.
37
+ * reachable rather than only the segment already chosen. It moves toward `activeSurface`
38
+ * and not toward the rail, so a hover previews being picked.
39
+ */
40
+ idle: "text-muted-foreground hover:bg-card/60 hover:text-foreground",
41
+ /**
42
+ * The flat surface marking the selection. One string, worn by both engines: the marketing
43
+ * picker slides it with `motion`, `Tabs` hands it to the element Base UI positions.
31
44
  */
32
- idle: "text-muted-foreground hover:bg-muted/60 hover:text-foreground",
33
- /** The flat surface marking the selection. */
34
- activeSurface: "bg-card ring-1 ring-border",
35
- /** `activeSurface`, for an engine that marks its own trigger with `data-active`. */
36
- dataActiveSurface: "data-active:bg-card data-active:text-foreground data-active:ring-1 data-active:ring-border",
45
+ activeSurface: "rounded-sm bg-card ring-1 ring-border",
37
46
  };
@@ -1,32 +1,67 @@
1
1
  import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
2
2
  import type { ReactNode } from "react";
3
3
  import { type VariantProps } from "class-variance-authority";
4
+ import { type Tone } from "../tone.js";
4
5
  declare function Tabs({ className, orientation, ...props }: TabsPrimitive.Root.Props): import("react").JSX.Element;
6
+ /**
7
+ * Surfaces come from SEGMENT, so this and marketing/segmented-control cannot drift: a
8
+ * reader who meets the picker on a docs page and again on the usage dashboard should not
9
+ * have to learn it twice. Layout stays local, since only this one has orientation to serve.
10
+ *
11
+ * Each variant states its own box, rather than sharing a base tuned for the boxed track
12
+ * that `line` then had to undo at the call site.
13
+ */
5
14
  declare const tabsListVariants: (props?: ({
6
15
  variant?: "line" | "default" | null | undefined;
7
16
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
8
- declare function TabsList({ className, variant, ...props }: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>): import("react").JSX.Element;
17
+ /**
18
+ * `tone` inks the marker, and only the marker. On `line` that is the underline and the
19
+ * active tab's icon; the boxed track's marker is a card surface and a hairline, which
20
+ * SEGMENT keeps deliberately flat, so a tone there would be a colour with nothing to
21
+ * paint. The label stays `--foreground` in both: it is read, not signalled.
22
+ */
23
+ declare function TabsList({ className, variant, tone, children, ...props }: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants> & {
24
+ tone?: Tone;
25
+ }): import("react").JSX.Element;
9
26
  declare function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props): import("react").JSX.Element;
10
27
  declare function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props): import("react").JSX.Element;
28
+ /** One tab, whole: what it is called, what marks it, and what it shows. */
29
+ export type TabItem = {
30
+ /** Stable across a relabel — it is what `defaultValue` and `onValueChange` speak. */
31
+ value: string;
32
+ label: ReactNode;
33
+ /**
34
+ * An element — `<Icons.Mic />`, `<PriceChip />` — sized and inked by the trigger.
35
+ *
36
+ * An element and not a component, which this briefly also took. `TabGroup` is a client
37
+ * component, so a component reference handed to it from a server page is a function
38
+ * crossing the RSC boundary, which React refuses at render; an element is already
39
+ * rendered and crosses fine. One accepted shape also spares the slot a branch, and
40
+ * matches what `Card`'s `icon` has always taken.
41
+ */
42
+ icon?: ReactNode;
43
+ content: ReactNode;
44
+ };
11
45
  /**
12
- * The declarative shorthand: `items` plus a `<Tab>` per panel. `TabGroup` is to
13
- * `Tabs` what `DisclosureGroup` is to `Accordion` the shape you reach for when
14
- * the tabs are data, and what an MDX author writes as `<Tabs>`.
46
+ * The declarative shorthand: the tabs as data. `TabGroup` is to `Tabs` what
47
+ * `DisclosureGroup` is to `Accordion`: the shape to reach for. An app that rebuilds it
48
+ * over the primitives ends up re-adding the icon, the change handler and the stable value
49
+ * by hand.
15
50
  *
16
- * A caller passing data has no value to bind, so children pair with `items`
17
- * **by position** `value` on a `<Tab>` is for readability and is not matched,
18
- * since matching would silently drop a panel on an edited label. Everything
19
- * below the adapter is the same component the product surfaces use, so a tab
20
- * strip in the docs and one on a dashboard behave identically.
51
+ * Everything below the adapter is the same component the product surfaces use, so a tab
52
+ * strip in the docs and one on a dashboard behave identically. The positional
53
+ * `items`-plus-children shape lives in the MDX map, the only thing that speaks it.
21
54
  */
22
- export declare function TabGroup({ items, children, className, }: {
23
- items: string[];
24
- children: ReactNode;
25
- className?: string;
26
- }): import("react").JSX.Element;
27
- /** `value` names the panel at the call site; it is not used for matching. */
28
- export declare function Tab({ children }: {
55
+ export declare function TabGroup({ tabs, defaultValue, value, onValueChange, variant, tone, iconPosition, className, }: {
56
+ tabs: readonly TabItem[];
57
+ /** Defaults to the first tab, since a picker with nothing picked is not a state. */
58
+ defaultValue?: string;
59
+ /** Pass with `onValueChange` to drive it from outside. */
29
60
  value?: string;
30
- children: ReactNode;
61
+ onValueChange?: (value: string) => void;
62
+ variant?: VariantProps<typeof tabsListVariants>["variant"];
63
+ tone?: Tone;
64
+ iconPosition?: "inline-start" | "inline-end";
65
+ className?: string;
31
66
  }): import("react").JSX.Element;
32
67
  export { Tabs, TabsList, TabsTrigger, TabsContent };
@@ -1,8 +1,9 @@
1
1
  "use client";
2
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
4
4
  import { cva } from "class-variance-authority";
5
5
  import { cn } from "../cn.js";
6
+ import { toneClass } from "../tone.js";
6
7
  import { SEGMENT } from "./segment.js";
7
8
  function Tabs({ className, orientation = "horizontal", ...props }) {
8
9
  return (_jsx(TabsPrimitive.Root, { "data-slot": "tabs", "data-orientation": orientation, className: cn(
@@ -12,58 +13,106 @@ function Tabs({ className, orientation = "horizontal", ...props }) {
12
13
  // flex container and laid its panels out beside the tab strip.
13
14
  "group/tabs flex gap-2 data-[orientation=horizontal]:flex-col", className), ...props }));
14
15
  }
15
- // Surfaces come from SEGMENT, so this and marketing/segmented-control cannot drift: a
16
- // reader who meets the picker on a docs page and again on the usage dashboard should not
17
- // have to learn it twice. Layout stays local, since only this one has orientation to serve.
18
- const tabsListVariants = cva("group/tabs-list inline-flex h-8 w-fit items-center justify-center text-muted-foreground data-[variant=line]:rounded-none", {
16
+ /**
17
+ * Surfaces come from SEGMENT, so this and marketing/segmented-control cannot drift: a
18
+ * reader who meets the picker on a docs page and again on the usage dashboard should not
19
+ * have to learn it twice. Layout stays local, since only this one has orientation to serve.
20
+ *
21
+ * Each variant states its own box, rather than sharing a base tuned for the boxed track
22
+ * that `line` then had to undo at the call site.
23
+ */
24
+ const tabsListVariants = cva("group/tabs-list inline-flex w-fit items-center text-muted-foreground", {
19
25
  variants: {
20
26
  variant: {
21
- default: SEGMENT.track,
22
- line: "gap-1 border-0 bg-transparent",
27
+ /** The boxed track: one fixed-height rail, segments splitting it evenly. */
28
+ default: cn(SEGMENT.track, "h-8 justify-center"),
29
+ /**
30
+ * A strip of labels over a rule. Free to wrap, so no fixed height — and `pb-2` is
31
+ * the marker's own room (6px offset plus its 2px), so the list's box contains
32
+ * everything the list draws and the gaps below measure from the right edge. The
33
+ * row gap clears the same 8px, or a wrapped row wears the rule above it.
34
+ */
35
+ line: "flex-wrap justify-start gap-x-1 gap-y-3 rounded-none border-0 bg-transparent pb-2",
23
36
  },
24
37
  },
25
38
  defaultVariants: {
26
39
  variant: "default",
27
40
  },
28
41
  });
29
- function TabsList({ className, variant = "default", ...props }) {
30
- return (_jsx(TabsPrimitive.List, { "data-slot": "tabs-list", "data-variant": variant, className: cn(tabsListVariants({ variant }), className), ...props }));
42
+ /**
43
+ * The marker: one element, positioned by Base UI from `--active-tab-left/top/width/height`
44
+ * on the list, dressed by the variant it is in. It outlives the selection, so it carries
45
+ * the answer from the old tab to the new instead of being destroyed and rebuilt.
46
+ *
47
+ * `data-activation-direction` is `none` before anything is picked, and that is the one case
48
+ * that must not animate — without the guard every strip on the page slides in from its left
49
+ * edge on hydration.
50
+ */
51
+ const tabsIndicatorVariants = cva("pointer-events-none absolute left-0 top-0 w-(--active-tab-width) transition-[translate,width] duration-200 ease-out data-[activation-direction=none]:transition-none motion-reduce:transition-none", {
52
+ variants: {
53
+ variant: {
54
+ /** The pill, on the page plane. The rail below it is what carries the depth. */
55
+ default: cn(SEGMENT.activeSurface, "h-(--active-tab-height) translate-x-(--active-tab-left) translate-y-(--active-tab-top)"),
56
+ /**
57
+ * A rule under the label, clear of the descenders, in the list's tone. The 6px
58
+ * offset plus its own 2px is the `pb-2` the line list reserves — the two are one
59
+ * measurement, and changing either alone puts the rule back outside its box.
60
+ */
61
+ line: "h-0.5 translate-x-(--active-tab-left) translate-y-[calc(var(--active-tab-top)+var(--active-tab-height)+6px)] rounded-full bg-(--tone-hue)",
62
+ },
63
+ },
64
+ defaultVariants: { variant: "default" },
65
+ });
66
+ /**
67
+ * `tone` inks the marker, and only the marker. On `line` that is the underline and the
68
+ * active tab's icon; the boxed track's marker is a card surface and a hairline, which
69
+ * SEGMENT keeps deliberately flat, so a tone there would be a colour with nothing to
70
+ * paint. The label stays `--foreground` in both: it is read, not signalled.
71
+ */
72
+ function TabsList({ className, variant = "default", tone = "primary", children, ...props }) {
73
+ return (_jsxs(TabsPrimitive.List, { "data-slot": "tabs-list", "data-variant": variant, className: cn("relative", toneClass(tone), tabsListVariants({ variant }), className), ...props, children: [_jsx(TabsPrimitive.Indicator, { renderBeforeHydration: true, className: tabsIndicatorVariants({ variant }) }), children] }));
31
74
  }
32
75
  function TabsTrigger({ className, ...props }) {
33
- return (_jsx(TabsPrimitive.Tab, { "data-slot": "tabs-trigger", className: cn(SEGMENT.item, SEGMENT.dataActiveSurface, "rounded-md text-muted-foreground hover:text-foreground",
34
- // A trigger fills its share of the track and may carry an icon, neither of which a
35
- // standalone picker button has to do.
36
- "h-[calc(100%-1px)] flex-1 justify-center px-1.5 py-0.5 text-sm whitespace-nowrap disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
37
- // The idle hover, scoped to the boxed variant. Spelled out rather than
38
- // interpolated: an interpolated class is invisible to Tailwind's scanner and would
39
- // compile to nothing at all.
40
- "group-data-[variant=default]/tabs-list:not-data-active:hover:bg-muted/60",
41
- // The line variant wears no surface at all; the underline below is its whole signal.
42
- "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent group-data-[variant=line]/tabs-list:data-active:ring-0",
43
- // That underline carries the accent rather than plain ink, so it reads as the
44
- // brand's marker and not as a bold rule.
45
- "after:absolute after:inset-x-0 after:bottom-[-5px] after:h-0.5 after:bg-primary after:opacity-0 after:transition-opacity group-data-[variant=line]/tabs-list:data-active:after:opacity-100", className), ...props }));
76
+ return (_jsx(TabsPrimitive.Tab, { "data-slot": "tabs-trigger", className: cn(SEGMENT.item,
77
+ // Ink only the surface and the underline belong to the indicator. The radius is
78
+ // for the hover wash, and matches the marker that wash previews.
79
+ "rounded-sm text-muted-foreground hover:text-foreground data-active:text-foreground", "px-1.5 py-0.5 text-sm whitespace-nowrap disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
80
+ // An icon sits inside the label's gap, so the padding on that side comes off.
81
+ // `TabGroup` writes the `data-icon` these two read.
82
+ "has-data-[icon=inline-start]:pl-1 has-data-[icon=inline-end]:pr-1",
83
+ // Filling the track is the boxed variant's business; a line tab is as wide as its
84
+ // label. The variant is read off the list's `data-variant` rather than taken as a
85
+ // prop, so a caller states it once on `TabsList` and not on every trigger.
86
+ // Selectors are spelled out, never interpolated: Tailwind's scanner cannot see an
87
+ // interpolated class and would compile nothing.
88
+ "group-data-[variant=default]/tabs-list:h-full group-data-[variant=default]/tabs-list:flex-1 group-data-[variant=default]/tabs-list:justify-center",
89
+ // Hover moves toward the marker's surface, so it previews the selection.
90
+ "group-data-[variant=default]/tabs-list:not-data-active:hover:bg-card/60",
91
+ // The active icon takes the ink the marker is drawn in.
92
+ "group-data-[variant=line]/tabs-list:data-active:[&_[data-icon]]:text-(color:--tone-hue)", className), ...props }));
46
93
  }
47
94
  function TabsContent({ className, ...props }) {
48
95
  return (_jsx(TabsPrimitive.Panel, { "data-slot": "tabs-content", className: cn("flex-1 text-sm outline-none", className), ...props }));
49
96
  }
97
+ /** `data-icon` is the hook the trigger's padding and tone selectors read. */
98
+ function TabIconSlot({ icon, position, }) {
99
+ return (_jsx("span", { "data-icon": position, className: "flex items-center transition-colors [&_svg]:size-4 [&_svg]:shrink-0", children: icon }));
100
+ }
50
101
  /**
51
- * The declarative shorthand: `items` plus a `<Tab>` per panel. `TabGroup` is to
52
- * `Tabs` what `DisclosureGroup` is to `Accordion` the shape you reach for when
53
- * the tabs are data, and what an MDX author writes as `<Tabs>`.
102
+ * The declarative shorthand: the tabs as data. `TabGroup` is to `Tabs` what
103
+ * `DisclosureGroup` is to `Accordion`: the shape to reach for. An app that rebuilds it
104
+ * over the primitives ends up re-adding the icon, the change handler and the stable value
105
+ * by hand.
54
106
  *
55
- * A caller passing data has no value to bind, so children pair with `items`
56
- * **by position** `value` on a `<Tab>` is for readability and is not matched,
57
- * since matching would silently drop a panel on an edited label. Everything
58
- * below the adapter is the same component the product surfaces use, so a tab
59
- * strip in the docs and one on a dashboard behave identically.
107
+ * Everything below the adapter is the same component the product surfaces use, so a tab
108
+ * strip in the docs and one on a dashboard behave identically. The positional
109
+ * `items`-plus-children shape lives in the MDX map, the only thing that speaks it.
60
110
  */
61
- export function TabGroup({ items, children, className, }) {
62
- const panels = Array.isArray(children) ? children : [children];
63
- return (_jsxs(Tabs, { defaultValue: 0, className: cn("my-6", className), children: [_jsx(TabsList, { children: items.map((label, i) => (_jsx(TabsTrigger, { value: i, children: label }, label))) }), panels.map((panel, i) => (_jsx(TabsContent, { value: i, className: "pt-2 text-muted-foreground", children: panel }, i)))] }));
64
- }
65
- /** `value` names the panel at the call site; it is not used for matching. */
66
- export function Tab({ children }) {
67
- return _jsx(_Fragment, { children: children });
111
+ export function TabGroup({ tabs, defaultValue, value, onValueChange, variant, tone, iconPosition = "inline-start", className, }) {
112
+ return (
113
+ // The handler is adapted rather than wrapped when absent: an arrow declared
114
+ // unconditionally is a function crossing the server boundary on every page that
115
+ // renders tabs without one.
116
+ _jsxs(Tabs, { defaultValue: defaultValue ?? tabs[0]?.value, value: value, onValueChange: onValueChange && ((next) => onValueChange(String(next))), className: cn("my-6", className), children: [_jsx(TabsList, { variant: variant, tone: tone, children: tabs.map(({ value: tabValue, label, icon }) => (_jsxs(TabsTrigger, { value: tabValue, children: [icon && iconPosition === "inline-start" && (_jsx(TabIconSlot, { icon: icon, position: "inline-start" })), label, icon && iconPosition === "inline-end" && (_jsx(TabIconSlot, { icon: icon, position: "inline-end" }))] }, tabValue))) }), tabs.map(({ value: tabValue, content }) => (_jsx(TabsContent, { value: tabValue, className: "pt-2 text-muted-foreground", children: content }, tabValue)))] }));
68
117
  }
69
118
  export { Tabs, TabsList, TabsTrigger, TabsContent };