@sableui/react 0.16.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +10 -3
- package/CHANGELOG.md +132 -0
- package/README.md +11 -4
- package/bin/sable.mjs +160 -18
- package/dist/activity-DT8VK9fx.d.ts +25 -0
- package/dist/adapters/github.d.ts +41 -0
- package/dist/adapters/github.js +24 -0
- package/dist/blocks/approval-block.js +30 -48
- package/dist/blocks/approval-block.meta.js +7 -8
- package/dist/blocks/index.d.ts +1 -1
- package/dist/components/activity-heatmap/activity-heatmap.js +420 -0
- package/dist/components/activity-heatmap/activity-heatmap.meta.js +142 -0
- package/dist/components/activity-heatmap/index.js +9 -0
- package/dist/components/code-block/code-block.js +5 -3
- package/dist/components/code-block/code-block.meta.js +6 -0
- package/dist/components/dialog/dialog.js +25 -5
- package/dist/components/dialog/dialog.meta.js +26 -5
- package/dist/components/media-image/media-image.js +3 -1
- package/dist/components/media-image/media-image.meta.js +15 -1
- package/dist/components/media-tile/media-tile.js +13 -1
- package/dist/components/media-tile/media-tile.meta.js +10 -0
- package/dist/components/page-header/page-header.js +9 -4
- package/dist/components/page-header/page-header.meta.js +26 -1
- package/dist/components/pill/pill.meta.js +15 -6
- package/dist/components/pill/pill.variants.js +1 -0
- package/dist/components/search-input/search-input.js +30 -5
- package/dist/components/search-input/search-input.meta.js +29 -4
- package/dist/components/section-header/section-header.js +27 -17
- package/dist/components/section-header/section-header.meta.js +27 -5
- package/dist/components/section-rail/index.js +7 -0
- package/dist/components/section-rail/section-rail.js +153 -0
- package/dist/components/section-rail/section-rail.meta.js +189 -0
- package/dist/components/sheet/sheet.js +15 -3
- package/dist/components/sheet/sheet.meta.js +2 -2
- package/dist/file-hashes.json +53 -39
- package/dist/icon-sets/reicon.js +23 -1
- package/dist/index.d.ts +364 -27
- package/dist/index.js +11 -1
- package/dist/layout/AspectBox.js +2 -5
- package/dist/layout/Bleed.js +10 -6
- package/dist/layout/Center.js +11 -3
- package/dist/layout/Columns.js +6 -3
- package/dist/layout/Grid.js +14 -6
- package/dist/layout/GridOverlay.js +7 -7
- package/dist/layout/Region.js +18 -3
- package/dist/layout/Row.js +27 -20
- package/dist/layout/Spacer.js +13 -2
- package/dist/layout/SplitPane.js +1 -1
- package/dist/layout/Stack.js +25 -18
- package/dist/layout/index.d.ts +57 -6
- package/dist/lib/chart/activity.js +32 -0
- package/dist/lib/chart/calendar.js +67 -0
- package/dist/lib/chart/chart-tooltip.js +2 -2
- package/dist/lib/chart/heat.js +55 -0
- package/dist/lib/focus-return.js +53 -0
- package/dist/lib/taxonomy.js +2 -0
- package/dist/lib/use-active-section.js +59 -0
- package/dist/styles.css +122 -114
- package/llms/blocks/approval-block.md +40 -55
- package/llms/components/activity-heatmap.md +133 -0
- package/llms/components/code-block.md +1 -0
- package/llms/components/dialog.md +34 -3
- package/llms/components/media-image.md +12 -0
- package/llms/components/media-tile.md +21 -0
- package/llms/components/page-header.md +16 -1
- package/llms/components/pill.md +24 -5
- package/llms/components/search-input.md +37 -4
- package/llms/components/section-header.md +28 -4
- package/llms/components/section-rail.md +101 -0
- package/llms/components/sheet.md +2 -2
- package/llms/layout/aspect-box.md +1 -1
- package/llms/layout/bleed.md +1 -1
- package/llms/layout/center.md +1 -1
- package/llms/layout/columns.md +1 -1
- package/llms/layout/grid.md +1 -1
- package/llms/layout/region.md +1 -1
- package/llms/layout/row.md +9 -1
- package/llms/layout/spacer.md +1 -1
- package/llms/layout/split-pane.md +1 -1
- package/llms/layout/stack.md +1 -1
- package/llms/tasks/auditing-an-app.md +31 -0
- package/llms/tasks/getting-started.md +10 -3
- package/llms/tasks/using-presets.md +1 -2
- package/llms-full.txt +516 -90
- package/llms.txt +5 -3
- package/package.json +6 -1
- package/registry.json +745 -46
- package/scripts/adoption.mjs +239 -0
- package/scripts/agent-setup.mjs +110 -18
- package/scripts/grade.mjs +17 -0
- package/scripts/wiring.mjs +2 -1
- package/skills/entry/SKILL.md +1 -1
- package/skills/sable/SKILL.md +33 -4
- package/skills/sable/agents/openai.yaml +1 -1
- package/targets/ios.json +18 -0
|
@@ -26,6 +26,7 @@ var pillVariants = cva(
|
|
|
26
26
|
outline: "border"
|
|
27
27
|
},
|
|
28
28
|
size: {
|
|
29
|
+
lg: "h-(--control-sm) px-4 py-0 text-label-lg [--sable-optical-pad:0px]",
|
|
29
30
|
md: "h-(--control-xs) px-3 py-0 [--sable-optical-pad:0px]",
|
|
30
31
|
sm: "h-6 py-0 text-label-sm [--sable-optical-pad:0px]"
|
|
31
32
|
},
|
|
@@ -2,10 +2,31 @@
|
|
|
2
2
|
import { useState } from "react";
|
|
3
3
|
import { Icon } from "../../icon/index.js";
|
|
4
4
|
import { cn } from "../../lib/cn.js";
|
|
5
|
-
import { FIELD_INVALID, FIELD_SHELL, MOBILE_FIELD_TIER } from "../../lib/field-shell.js";
|
|
5
|
+
import { FIELD_INVALID, FIELD_SHELL, FIELD_SHELL_XS, MOBILE_FIELD_TIER } from "../../lib/field-shell.js";
|
|
6
6
|
import { useDensity, useTypeTier } from "../../lib/density.js";
|
|
7
|
+
import { hitTargetMin } from "../../lib/hit-target.js";
|
|
7
8
|
import { Button } from "../button/button.js";
|
|
8
9
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
10
|
+
var SIZES = {
|
|
11
|
+
md: {
|
|
12
|
+
field: "h-(--control-md) ps-9 pe-9",
|
|
13
|
+
icon: "start-3 text-sm",
|
|
14
|
+
clear: "end-2",
|
|
15
|
+
button: void 0
|
|
16
|
+
},
|
|
17
|
+
sm: {
|
|
18
|
+
field: "h-(--control-sm) ps-8 pe-8",
|
|
19
|
+
icon: "start-2.5 text-sm",
|
|
20
|
+
clear: "end-1",
|
|
21
|
+
button: cn("size-[calc(var(--control-sm)-var(--spacing)*2)] [--button-icon:0.875rem]", hitTargetMin)
|
|
22
|
+
},
|
|
23
|
+
xs: {
|
|
24
|
+
field: cn("h-(--control-xs) ps-6 pe-7", FIELD_SHELL_XS),
|
|
25
|
+
icon: "start-1.5 text-xs",
|
|
26
|
+
clear: "end-0.5",
|
|
27
|
+
button: cn("size-[calc(var(--control-xs)-var(--spacing))] [--button-icon:0.75rem]", hitTargetMin)
|
|
28
|
+
}
|
|
29
|
+
};
|
|
9
30
|
function SearchInput({
|
|
10
31
|
value,
|
|
11
32
|
defaultValue,
|
|
@@ -14,10 +35,12 @@ function SearchInput({
|
|
|
14
35
|
placeholder = "Search",
|
|
15
36
|
className,
|
|
16
37
|
inputClassName,
|
|
38
|
+
size = "md",
|
|
17
39
|
ref,
|
|
18
40
|
type,
|
|
19
41
|
...props
|
|
20
42
|
}) {
|
|
43
|
+
const rung = SIZES[size];
|
|
21
44
|
const [uncontrolled, setUncontrolled] = useState(defaultValue ?? "");
|
|
22
45
|
const controlled = value !== void 0;
|
|
23
46
|
const current = controlled ? value : uncontrolled;
|
|
@@ -41,7 +64,7 @@ function SearchInput({
|
|
|
41
64
|
Icon,
|
|
42
65
|
{
|
|
43
66
|
name: "search",
|
|
44
|
-
className: "pointer-events-none absolute
|
|
67
|
+
className: cn("pointer-events-none absolute text-muted-foreground", rung.icon)
|
|
45
68
|
}
|
|
46
69
|
),
|
|
47
70
|
/* @__PURE__ */ jsx(
|
|
@@ -53,10 +76,12 @@ function SearchInput({
|
|
|
53
76
|
onChange: (event) => change(event.target.value),
|
|
54
77
|
placeholder,
|
|
55
78
|
className: cn(
|
|
56
|
-
"flex
|
|
79
|
+
"flex w-full text-foreground",
|
|
57
80
|
// The shell Input and CommandMenuTrigger wear too — lib/field-shell.
|
|
81
|
+
// xs layers the inset tool-panel surface over it, as Input xs does.
|
|
58
82
|
FIELD_SHELL,
|
|
59
83
|
FIELD_INVALID,
|
|
84
|
+
rung.field,
|
|
60
85
|
"[&::-webkit-search-cancel-button]:appearance-none",
|
|
61
86
|
tier.body,
|
|
62
87
|
density === "comfortable" && MOBILE_FIELD_TIER,
|
|
@@ -72,14 +97,14 @@ function SearchInput({
|
|
|
72
97
|
// transition-property slot — whichever sorted last would win, and
|
|
73
98
|
// either the hover ease or the arrival would silently go. Removal
|
|
74
99
|
// stays instant: an emptied field should not wait on its X.
|
|
75
|
-
/* @__PURE__ */ jsx("span", { className: "absolute
|
|
100
|
+
/* @__PURE__ */ jsx("span", { className: cn("absolute flex motion-appear", rung.clear), children: /* @__PURE__ */ jsx(
|
|
76
101
|
Button,
|
|
77
102
|
{
|
|
78
103
|
variant: "ghost",
|
|
79
104
|
size: "icon-xs",
|
|
80
105
|
onClick: handleClear,
|
|
81
106
|
"aria-label": "Clear search",
|
|
82
|
-
className: "text-muted-foreground hover:text-foreground",
|
|
107
|
+
className: cn("text-muted-foreground hover:text-foreground", rung.button),
|
|
83
108
|
children: /* @__PURE__ */ jsx(Icon, { name: "xmark" })
|
|
84
109
|
}
|
|
85
110
|
) })
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/components/search-input/search-input.meta.ts
|
|
2
2
|
var searchInputMeta = {
|
|
3
3
|
name: "SearchInput",
|
|
4
|
-
summary: "Styled search field with a leading magnifier and a clearable X. Soft-square (rounded-sm), the theme's field surface, 2px focus ring; the clear button appears only when there is a value.",
|
|
4
|
+
summary: "Styled search field with a leading magnifier and a clearable X. Soft-square (rounded-sm), the theme's field surface, 2px focus ring; the clear button appears only when there is a value. size md (form), sm (toolbar, matching Pill lg and Button sm) or xs (inset tool-panel field).",
|
|
5
5
|
status: "stable",
|
|
6
6
|
keywords: [
|
|
7
7
|
"search",
|
|
@@ -28,6 +28,14 @@ var searchInputMeta = {
|
|
|
28
28
|
guidance: "do",
|
|
29
29
|
description: "Light the query in the results it filters with `useMatchHighlight(query)` \u2014 a callback ref for the results container, with `selector` to keep it to titles. It paints on the `highlight` pair without wrapping matches in `<mark>`, the same paint Combobox and CommandMenu use for their rows."
|
|
30
30
|
},
|
|
31
|
+
{
|
|
32
|
+
guidance: "do",
|
|
33
|
+
description: `In a toolbar beside filter chips, use size="sm" with Pill size="lg" (and Button size="sm" for actions). All three stand on --control-sm (32px, 28 compact), the rung the spacing doctrine gives toolbar controls, so the row is one height in either density. Keep md for a search field in a form or a page header beside md buttons, and xs for a filter inside a workspace panel, where it matches Input xs's inset surface.`
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
guidance: "dont",
|
|
37
|
+
description: "Do not match a neighbour's height with h-* on className or inputClassName. The magnifier, padding and clear button would stay sized for the old rung; pick the size that shares the neighbour's rung."
|
|
38
|
+
},
|
|
31
39
|
{
|
|
32
40
|
guidance: "dont",
|
|
33
41
|
description: "Do not use SearchInput as a command palette; use CommandMenu for global commands, opened from the header by CommandMenuTrigger. A SearchInput that opens a dialog on focus is a text box you cannot type in."
|
|
@@ -42,10 +50,17 @@ var searchInputMeta = {
|
|
|
42
50
|
components: [
|
|
43
51
|
"Input",
|
|
44
52
|
"CommandMenu",
|
|
45
|
-
"Combobox"
|
|
53
|
+
"Combobox",
|
|
54
|
+
"Pill"
|
|
46
55
|
]
|
|
47
56
|
},
|
|
48
57
|
props: [
|
|
58
|
+
{
|
|
59
|
+
name: "size",
|
|
60
|
+
type: '"md" | "sm" | "xs"',
|
|
61
|
+
default: '"md"',
|
|
62
|
+
description: "The rung of the control ladder, named as on Input, Select and Combobox. md is the form field (--control-md, 40px / 32 compact). sm is the toolbar field (--control-sm, 32px / 28 compact), the rung Pill lg and Button sm share, so filters + search + actions in one row are one height. xs is the tool-panel field (--control-xs, 28px / 24 compact) on Input xs's inset panel-field surface. The magnifier, inline padding and clear button scale with the rung and stay vertically centred."
|
|
63
|
+
},
|
|
49
64
|
{
|
|
50
65
|
name: "value",
|
|
51
66
|
type: "string",
|
|
@@ -91,7 +106,7 @@ var searchInputMeta = {
|
|
|
91
106
|
{
|
|
92
107
|
name: "...props",
|
|
93
108
|
type: 'ComponentPropsWithRef<"input">',
|
|
94
|
-
description: "All native input attributes (name, autoFocus, ref, aria-invalid, \u2026) pass through to the <input> except value/onChange, which are owned by value/onValueChange,
|
|
109
|
+
description: "All native input attributes (name, autoFocus, ref, aria-invalid, \u2026) pass through to the <input> except value/onChange, which are owned by value/onValueChange, className, which sizes the whole field, and the native numeric size attribute, which the size prop replaces."
|
|
95
110
|
}
|
|
96
111
|
],
|
|
97
112
|
tokensUsed: [
|
|
@@ -100,7 +115,9 @@ var searchInputMeta = {
|
|
|
100
115
|
"foreground",
|
|
101
116
|
"muted-foreground",
|
|
102
117
|
"ring",
|
|
103
|
-
"destructive"
|
|
118
|
+
"destructive",
|
|
119
|
+
"panel-field",
|
|
120
|
+
"panel-field-hover"
|
|
104
121
|
],
|
|
105
122
|
motion: [
|
|
106
123
|
{
|
|
@@ -152,6 +169,14 @@ var searchInputMeta = {
|
|
|
152
169
|
label: "Highlighting matches in the results",
|
|
153
170
|
code: 'const [q, setQ] = useState("")\nconst results = useMatchHighlight<HTMLUListElement>(q, { selector: "[data-title]" })\nconst shown = posts.filter((post) => post.title.toLowerCase().includes(q.trim().toLowerCase()))\n\n<SearchInput value={q} onValueChange={setQ} placeholder="Search posts" />\n<ul ref={results}>\n {shown.map((post) => (\n <li key={post.id}><span data-title>{post.title}</span></li>\n ))}\n</ul>'
|
|
154
171
|
},
|
|
172
|
+
{
|
|
173
|
+
label: "Filters with search",
|
|
174
|
+
code: 'const [filter, setFilter] = useState("all")\nconst [q, setQ] = useState("")\n\n<Row gap={2} align="center" justify="between" wrap>\n <Row gap={1}>\n <Pill size="lg" selected={filter === "all"} onClick={() => setFilter("all")}>All 8</Pill>\n <Pill size="lg" selected={filter === "products"} onClick={() => setFilter("products")}>Products 5</Pill>\n <Pill size="lg" selected={filter === "oss"} onClick={() => setFilter("oss")}>Open source 2</Pill>\n </Row>\n <SearchInput size="sm" value={q} onValueChange={setQ} placeholder="Find a project\u2026" aria-label="Find a project" className="max-w-64" />\n</Row>'
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
label: "Sizes",
|
|
178
|
+
code: '<Stack gap={1}>\n <SearchInput size="md" placeholder="Search posts" aria-label="Search posts" />\n <SearchInput size="sm" placeholder="Find a project\u2026" aria-label="Find a project" />\n <SearchInput size="xs" placeholder="Filter layers" aria-label="Filter layers" />\n</Stack>'
|
|
179
|
+
},
|
|
155
180
|
{
|
|
156
181
|
label: "Disabled",
|
|
157
182
|
code: '<SearchInput disabled placeholder="Search" />'
|
|
@@ -3,9 +3,10 @@ import { useId } from "react";
|
|
|
3
3
|
import { cn } from "../../lib/cn.js";
|
|
4
4
|
import { HeadingRow } from "../../lib/heading-row.js";
|
|
5
5
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
6
|
-
var
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
var SIZE = {
|
|
7
|
+
lg: { root: "gap-3", title: "text-heading-2", description: "text-body-md" },
|
|
8
|
+
md: { root: "gap-2", title: "text-heading-3", description: "text-body-sm" },
|
|
9
|
+
sm: { root: "gap-2", title: "text-heading-4", description: "text-body-sm" }
|
|
9
10
|
};
|
|
10
11
|
function SectionHeader({
|
|
11
12
|
title,
|
|
@@ -18,20 +19,29 @@ function SectionHeader({
|
|
|
18
19
|
...props
|
|
19
20
|
}) {
|
|
20
21
|
const generatedId = useId();
|
|
21
|
-
return /* @__PURE__ */ jsxs(
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
22
|
+
return /* @__PURE__ */ jsxs(
|
|
23
|
+
"div",
|
|
24
|
+
{
|
|
25
|
+
"data-slot": "section-header",
|
|
26
|
+
"data-size": size,
|
|
27
|
+
className: cn("flex flex-col", SIZE[size].root, className),
|
|
28
|
+
...props,
|
|
29
|
+
children: [
|
|
30
|
+
/* @__PURE__ */ jsx(
|
|
31
|
+
HeadingRow,
|
|
32
|
+
{
|
|
33
|
+
as: titleAs,
|
|
34
|
+
title,
|
|
35
|
+
titleId: titleId ?? generatedId,
|
|
36
|
+
titleClassName: SIZE[size].title,
|
|
37
|
+
band: "flex min-h-(--control-sm) items-center mt-[min(0px,calc((var(--control-sm)_-_1lh)/2))]",
|
|
38
|
+
actions
|
|
39
|
+
}
|
|
40
|
+
),
|
|
41
|
+
description ? /* @__PURE__ */ jsx("p", { className: cn("max-w-(--measure-prose) text-pretty text-muted-foreground", SIZE[size].description), children: description }) : null
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
);
|
|
35
45
|
}
|
|
36
46
|
SectionHeader.displayName = "SectionHeader";
|
|
37
47
|
export {
|
|
@@ -14,7 +14,9 @@ var sectionHeaderMeta = {
|
|
|
14
14
|
"h2",
|
|
15
15
|
"subsection",
|
|
16
16
|
"optical alignment",
|
|
17
|
-
"bleed"
|
|
17
|
+
"bleed",
|
|
18
|
+
"editorial section",
|
|
19
|
+
"large section title"
|
|
18
20
|
],
|
|
19
21
|
usage: {
|
|
20
22
|
description: "Use SectionHeader to head each section below the page title \u2014 a home-page shelf with View all, a settings group, a list with its own actions. PageHeader is the route's single h1; every section under it takes a SectionHeader, with titleId wired to the section's aria-labelledby.",
|
|
@@ -25,7 +27,7 @@ var sectionHeaderMeta = {
|
|
|
25
27
|
},
|
|
26
28
|
{
|
|
27
29
|
guidance: "do",
|
|
28
|
-
description:
|
|
30
|
+
description: `Pick titleAs by the document outline (h2 under the page h1, h3 inside an h2 section) and size by visual weight: lg for an editorial page's sections under a PageHeader size="lg", md for a page's sections and shelves, sm for denser groups like settings.`
|
|
29
31
|
},
|
|
30
32
|
{
|
|
31
33
|
guidance: "do",
|
|
@@ -87,13 +89,13 @@ var sectionHeaderMeta = {
|
|
|
87
89
|
},
|
|
88
90
|
{
|
|
89
91
|
name: "size",
|
|
90
|
-
type: '"md" | "sm"',
|
|
92
|
+
type: '"lg" | "md" | "sm"',
|
|
91
93
|
default: '"md"',
|
|
92
|
-
description:
|
|
94
|
+
description: `Visual weight, independent of the heading level: lg (text-heading-2 over a text-body-md description) for an editorial page's sections, under a PageHeader size="lg"; md (text-heading-3) for a page's sections and shelves; sm (text-heading-4) for denser sets \u2014 settings groups, subsections. lg stops at heading-2: heading-1 is the page title's, one per route. The actions stay centred on the title's first line at every size.`
|
|
93
95
|
}
|
|
94
96
|
],
|
|
95
97
|
variants: {
|
|
96
|
-
size: ["md", "sm"]
|
|
98
|
+
size: ["lg", "md", "sm"]
|
|
97
99
|
},
|
|
98
100
|
tokensUsed: ["foreground", "muted-foreground"],
|
|
99
101
|
a11y: [
|
|
@@ -161,6 +163,26 @@ var sectionHeaderMeta = {
|
|
|
161
163
|
<Entity className="[--entity-px:0]" title="Late drives" subtitle="42 tracks" />
|
|
162
164
|
<Entity className="[--entity-px:0]" title="Focus" subtitle="18 tracks" />
|
|
163
165
|
</Stack>
|
|
166
|
+
</section>`
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
label: "An editorial section (size lg)",
|
|
170
|
+
code: `<section aria-labelledby="selected-work" className="flex flex-col gap-6">
|
|
171
|
+
<SectionHeader
|
|
172
|
+
titleId="selected-work"
|
|
173
|
+
size="lg"
|
|
174
|
+
title="Selected work"
|
|
175
|
+
description="Five projects from the last two years."
|
|
176
|
+
actions={
|
|
177
|
+
<Button variant="ghost" size="sm" render={<a href="/work" />} aria-label="View all work">
|
|
178
|
+
View all
|
|
179
|
+
</Button>
|
|
180
|
+
}
|
|
181
|
+
/>
|
|
182
|
+
<Grid minItemWidth="16rem" gap={3}>
|
|
183
|
+
<Card><CardHeader><CardTitle>Tidewater</CardTitle><CardDescription>Brand identity</CardDescription></CardHeader></Card>
|
|
184
|
+
<Card><CardHeader><CardTitle>Northlight</CardTitle><CardDescription>Product design</CardDescription></CardHeader></Card>
|
|
185
|
+
</Grid>
|
|
164
186
|
</section>`
|
|
165
187
|
},
|
|
166
188
|
{
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// src/components/section-rail/section-rail.tsx
|
|
4
|
+
import {
|
|
5
|
+
useCallback,
|
|
6
|
+
useEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
useState
|
|
9
|
+
} from "react";
|
|
10
|
+
import { cn } from "../../lib/cn.js";
|
|
11
|
+
import { HighlightGlide } from "../../lib/highlight-glide.js";
|
|
12
|
+
import { setRef } from "../../lib/refs.js";
|
|
13
|
+
import { useActiveSection } from "../../lib/use-active-section.js";
|
|
14
|
+
import { useMotionLevel } from "../../motion/MotionProvider.js";
|
|
15
|
+
import { SimpleTooltip, TooltipGroup } from "../tooltip/index.js";
|
|
16
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
17
|
+
var SCROLL_KEYS = /* @__PURE__ */ new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "]);
|
|
18
|
+
var GLIDE_HANDOFF = "[&[data-glide]_[data-active]>[data-section-rail-mark]]:opacity-0";
|
|
19
|
+
var GLIDE_MARK = "flex items-center justify-center motion-glide after:h-5 after:w-1 after:rounded-full after:bg-primary";
|
|
20
|
+
function SectionRail({
|
|
21
|
+
sections,
|
|
22
|
+
value,
|
|
23
|
+
onValueChange,
|
|
24
|
+
offset,
|
|
25
|
+
root,
|
|
26
|
+
side = "left",
|
|
27
|
+
motion,
|
|
28
|
+
className,
|
|
29
|
+
"aria-label": ariaLabel = "Sections",
|
|
30
|
+
ref,
|
|
31
|
+
...props
|
|
32
|
+
}) {
|
|
33
|
+
const rail = useRef(null);
|
|
34
|
+
const setRail = useCallback(
|
|
35
|
+
(node) => {
|
|
36
|
+
rail.current = node;
|
|
37
|
+
setRef(ref, node);
|
|
38
|
+
},
|
|
39
|
+
[ref]
|
|
40
|
+
);
|
|
41
|
+
const { enabled, attribute } = useMotionLevel(motion);
|
|
42
|
+
const spied = useActiveSection(
|
|
43
|
+
sections.map((section) => section.id),
|
|
44
|
+
{ offset, root }
|
|
45
|
+
);
|
|
46
|
+
const [picked, setPicked] = useState(null);
|
|
47
|
+
const internal = picked ?? spied;
|
|
48
|
+
const active = value ?? internal;
|
|
49
|
+
const report = useRef(onValueChange);
|
|
50
|
+
report.current = onValueChange;
|
|
51
|
+
const reported = useRef(internal);
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (internal === null || internal === reported.current) return;
|
|
54
|
+
reported.current = internal;
|
|
55
|
+
report.current?.(internal);
|
|
56
|
+
}, [internal]);
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (picked === null) return;
|
|
59
|
+
const release = () => setPicked(null);
|
|
60
|
+
const releaseOnKey = (event) => {
|
|
61
|
+
if (SCROLL_KEYS.has(event.key)) release();
|
|
62
|
+
};
|
|
63
|
+
const listeners = new AbortController();
|
|
64
|
+
const options = { passive: true, signal: listeners.signal };
|
|
65
|
+
window.addEventListener("wheel", release, options);
|
|
66
|
+
window.addEventListener("touchstart", release, options);
|
|
67
|
+
window.addEventListener("keydown", releaseOnKey, options);
|
|
68
|
+
window.addEventListener(
|
|
69
|
+
"pointerdown",
|
|
70
|
+
(event) => {
|
|
71
|
+
if (!(event.target instanceof Node && rail.current?.contains(event.target))) release();
|
|
72
|
+
},
|
|
73
|
+
options
|
|
74
|
+
);
|
|
75
|
+
return () => listeners.abort();
|
|
76
|
+
}, [picked]);
|
|
77
|
+
const jump = (event, id) => {
|
|
78
|
+
if (event.defaultPrevented || event.button !== 0) return;
|
|
79
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
80
|
+
const section = document.getElementById(id);
|
|
81
|
+
if (!section) return;
|
|
82
|
+
event.preventDefault();
|
|
83
|
+
setPicked(id);
|
|
84
|
+
const behavior = enabled ? "auto" : "instant";
|
|
85
|
+
const scroller = root?.current;
|
|
86
|
+
if (scroller) {
|
|
87
|
+
const margin = Number.parseFloat(getComputedStyle(section).scrollMarginTop) || 0;
|
|
88
|
+
const top = section.getBoundingClientRect().top - scroller.getBoundingClientRect().top - scroller.clientTop;
|
|
89
|
+
scroller.scrollTo({ top: scroller.scrollTop + top - margin, behavior });
|
|
90
|
+
} else {
|
|
91
|
+
section.scrollIntoView({ behavior, block: "start" });
|
|
92
|
+
window.history.replaceState(window.history.state, "", `#${id}`);
|
|
93
|
+
}
|
|
94
|
+
if (event.detail === 0) {
|
|
95
|
+
if (!section.hasAttribute("tabindex")) {
|
|
96
|
+
section.setAttribute("tabindex", "-1");
|
|
97
|
+
section.addEventListener("blur", () => section.removeAttribute("tabindex"), { once: true });
|
|
98
|
+
}
|
|
99
|
+
section.focus({ preventScroll: true });
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
return /* @__PURE__ */ jsxs(
|
|
103
|
+
"nav",
|
|
104
|
+
{
|
|
105
|
+
ref: setRail,
|
|
106
|
+
"aria-label": ariaLabel,
|
|
107
|
+
"data-motion": attribute,
|
|
108
|
+
className: cn(
|
|
109
|
+
"relative flex w-fit flex-col rounded-full bg-muted py-2",
|
|
110
|
+
GLIDE_HANDOFF,
|
|
111
|
+
className
|
|
112
|
+
),
|
|
113
|
+
...props,
|
|
114
|
+
children: [
|
|
115
|
+
/* @__PURE__ */ jsx(HighlightGlide, { follow: "data-active", timing: "selection", className: GLIDE_MARK }),
|
|
116
|
+
/* @__PURE__ */ jsx(TooltipGroup, { motion, children: sections.map((section) => {
|
|
117
|
+
const current = section.id === active;
|
|
118
|
+
return /* @__PURE__ */ jsx(
|
|
119
|
+
SimpleTooltip,
|
|
120
|
+
{
|
|
121
|
+
content: section.label,
|
|
122
|
+
side: side === "left" ? "right" : "left",
|
|
123
|
+
children: /* @__PURE__ */ jsx(
|
|
124
|
+
"a",
|
|
125
|
+
{
|
|
126
|
+
href: `#${section.id}`,
|
|
127
|
+
"aria-label": section.label,
|
|
128
|
+
"aria-current": current ? "location" : void 0,
|
|
129
|
+
"data-active": current ? "" : void 0,
|
|
130
|
+
onClick: (event) => jump(event, section.id),
|
|
131
|
+
className: "group/rail-link relative flex h-(--control-sm) w-11 items-center justify-center rounded-full text-muted-foreground outline-none motion-interactive hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
|
|
132
|
+
children: /* @__PURE__ */ jsx(
|
|
133
|
+
"span",
|
|
134
|
+
{
|
|
135
|
+
"aria-hidden": true,
|
|
136
|
+
"data-section-rail-mark": "",
|
|
137
|
+
className: "size-1 rounded-full bg-current motion-interactive group-hover/rail-link:scale-150 group-focus-visible/rail-link:scale-150 group-data-[active]/rail-link:h-5 group-data-[active]/rail-link:bg-primary"
|
|
138
|
+
}
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
)
|
|
142
|
+
},
|
|
143
|
+
section.id
|
|
144
|
+
);
|
|
145
|
+
}) })
|
|
146
|
+
]
|
|
147
|
+
}
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
SectionRail.displayName = "SectionRail";
|
|
151
|
+
export {
|
|
152
|
+
SectionRail
|
|
153
|
+
};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// src/components/section-rail/section-rail.meta.ts
|
|
2
|
+
var sectionRailMeta = {
|
|
3
|
+
name: "SectionRail",
|
|
4
|
+
summary: "A slim vertical rail of dots, one per section of a long page, that shows where the reader is and jumps to any section. The current section's bar glides as the page scrolls; a shared tooltip names each dot.",
|
|
5
|
+
keywords: [
|
|
6
|
+
"section rail",
|
|
7
|
+
"scroll spy",
|
|
8
|
+
"scrollspy",
|
|
9
|
+
"dot navigation",
|
|
10
|
+
"page navigation",
|
|
11
|
+
"table of contents",
|
|
12
|
+
"on this page",
|
|
13
|
+
"chapter nav",
|
|
14
|
+
"scroll progress",
|
|
15
|
+
"case study nav"
|
|
16
|
+
],
|
|
17
|
+
usage: {
|
|
18
|
+
description: "Use SectionRail beside a long, single-column read \u2014 a case study, an essay, a long landing page \u2014 so the reader can see which section they are in and jump to another without leaving the text. It follows the scroll by itself (useActiveSection); pass value when the parent already knows the current section.",
|
|
19
|
+
bestPractices: [
|
|
20
|
+
{
|
|
21
|
+
guidance: "do",
|
|
22
|
+
description: "Give every section an id that matches its entry, and keep the list to the page's top-level sections \u2014 about ten at most, so the rail stays shorter than the viewport."
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
guidance: "do",
|
|
26
|
+
description: "Place it with layout classes \u2014 usually `fixed top-1/2 left-6 z-(--z-nav) -translate-y-1/2` \u2014 and hide it below the width where it would overlap the text (`max-lg:hidden`). Offer the same links another way on narrow screens, such as a Collapsible list at the top of the page."
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
guidance: "do",
|
|
30
|
+
description: "Pass `root` when the content scrolls inside an element (a Scroller, an inset AppShell main) rather than the window, and give sections `scroll-mt-*` when a sticky header would cover their tops after a jump."
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
guidance: "do",
|
|
34
|
+
description: 'Set `side="right"` when the rail sits on the right edge, so labels open toward the content.'
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
guidance: "dont",
|
|
38
|
+
description: "Use it for app navigation between pages; that is SideNav or Sidebar. A SectionRail only moves within one page.",
|
|
39
|
+
instead: ["SideNav", "Sidebar"]
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
guidance: "dont",
|
|
43
|
+
description: "Use it to show the steps of a task or a flow; that is Stepper, which shows progress through steps rather than position in a read.",
|
|
44
|
+
instead: ["Stepper"]
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
guidance: "dont",
|
|
48
|
+
description: "Use it for a handful of switchable views in one place; that is Tabs.",
|
|
49
|
+
instead: ["Tabs"]
|
|
50
|
+
}
|
|
51
|
+
],
|
|
52
|
+
anatomy: [
|
|
53
|
+
{
|
|
54
|
+
name: "Rail",
|
|
55
|
+
required: true,
|
|
56
|
+
description: "The `<nav>` landmark: a rounded muted track holding one link per section."
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "Dot",
|
|
60
|
+
required: true,
|
|
61
|
+
description: "A link to `#id`, drawn as a small dot that grows under the pointer or focus."
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: "Marker",
|
|
65
|
+
required: true,
|
|
66
|
+
description: "One primary bar that sits on the current section's dot and glides to the next."
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: "Label",
|
|
70
|
+
required: true,
|
|
71
|
+
description: "A shared tooltip that names the hovered or focused dot and travels between dots."
|
|
72
|
+
}
|
|
73
|
+
]
|
|
74
|
+
},
|
|
75
|
+
related: {
|
|
76
|
+
components: ["SideNav", "Tooltip", "Stepper", "Tabs", "GlidingHighlight"]
|
|
77
|
+
},
|
|
78
|
+
status: "stable",
|
|
79
|
+
props: [
|
|
80
|
+
{
|
|
81
|
+
name: "sections",
|
|
82
|
+
type: "readonly { id: string; label: string }[]",
|
|
83
|
+
description: "The sections, in document order. `id` is the section element's id (the link goes to `#id`); `label` is the tooltip and the link's accessible name."
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: "value",
|
|
87
|
+
type: "string",
|
|
88
|
+
description: "The current section's id, when the parent decides it. Leave it unset and the rail follows the scroll itself."
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: "onValueChange",
|
|
92
|
+
type: "(id: string) => void",
|
|
93
|
+
description: "Called with the new id when the reader scrolls into a section or picks one."
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: "offset",
|
|
97
|
+
type: "number",
|
|
98
|
+
default: "0.35",
|
|
99
|
+
description: "Where the reading line sits, as a fraction of the viewport from its top. A section becomes current once its top crosses it."
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: "root",
|
|
103
|
+
type: "RefObject<HTMLElement | null>",
|
|
104
|
+
description: "The element that scrolls, when it is not the window."
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: "side",
|
|
108
|
+
type: '"left" | "right"',
|
|
109
|
+
default: '"left"',
|
|
110
|
+
description: "Which edge of the page the rail sits on. The labels open on the other side, toward the content."
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: "motion",
|
|
114
|
+
type: '"full" | "subtle" | "off"',
|
|
115
|
+
default: '"full"',
|
|
116
|
+
description: "How much motion this component uses: `full` inherits the app's policy, `subtle` keeps feedback and drops the marker's glide, `off` stills it. It can only turn motion down \u2014 it never overrides a reader's reduce-motion setting."
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: "aria-label",
|
|
120
|
+
type: "string",
|
|
121
|
+
default: '"Sections"',
|
|
122
|
+
description: `The nav landmark's name. Name it for the page ("Case study sections") when it has other navs.`
|
|
123
|
+
}
|
|
124
|
+
],
|
|
125
|
+
motion: [
|
|
126
|
+
{
|
|
127
|
+
slot: "arrival",
|
|
128
|
+
recipe: "none",
|
|
129
|
+
part: "Root",
|
|
130
|
+
why: "Page chrome arrives with the page; the marker lands on the current section with no transition, because a bar sliding in on load narrates a move nobody made."
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
slot: "response",
|
|
134
|
+
recipe: "motion-interactive",
|
|
135
|
+
part: "Dot",
|
|
136
|
+
why: "The dot darkens and grows under the pointer or focus, and the label travels between dots as one tooltip (TooltipGroup) rather than closing and reopening at each."
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
slot: "transition",
|
|
140
|
+
recipe: "motion-glide",
|
|
141
|
+
part: "Marker",
|
|
142
|
+
why: "One bar travels to the new section as the page scrolls, so the change reads as movement down the page. A picked section holds the bar until the reader scrolls by hand, so it goes straight there instead of stepping through every section in between, and stays on the pick even when the page ends before that section can reach the reading line. Expressive: `subtle` moves it at once."
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
slot: "activity",
|
|
146
|
+
recipe: "none",
|
|
147
|
+
part: "Root",
|
|
148
|
+
why: "Navigation chrome has no working state."
|
|
149
|
+
}
|
|
150
|
+
],
|
|
151
|
+
a11y: [
|
|
152
|
+
'Renders a <nav> landmark (default name "Sections") of real links to `#id`, so it works with no script, middle-click and copy-link.',
|
|
153
|
+
`Each link is named by its section's label; the current one carries aria-current="location".`,
|
|
154
|
+
"Activating a link from the keyboard moves focus into the section, as following an anchor does, so the next Tab continues from there.",
|
|
155
|
+
"The tooltip opens on focus as well as hover; the dots are 44\xD732 targets.",
|
|
156
|
+
"Under reduced motion the jump is instant and the marker moves without travel."
|
|
157
|
+
],
|
|
158
|
+
tokensUsed: ["muted", "muted-foreground", "foreground", "primary", "ring", "popover", "popover-foreground", "border-floating"],
|
|
159
|
+
examples: [
|
|
160
|
+
{
|
|
161
|
+
label: "Fixed beside a long read",
|
|
162
|
+
code: `<SectionRail
|
|
163
|
+
aria-label="Case study sections"
|
|
164
|
+
sections={[
|
|
165
|
+
{ id: "overview", label: "Overview" },
|
|
166
|
+
{ id: "problem", label: "The problem" },
|
|
167
|
+
{ id: "process", label: "Process" },
|
|
168
|
+
{ id: "outcome", label: "Outcome" },
|
|
169
|
+
]}
|
|
170
|
+
className="fixed top-1/2 left-6 z-(--z-nav) -translate-y-1/2 max-lg:hidden"
|
|
171
|
+
/>`
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
label: "On the right edge",
|
|
175
|
+
code: `<SectionRail
|
|
176
|
+
side="right"
|
|
177
|
+
sections={[
|
|
178
|
+
{ id: "intro", label: "Introduction" },
|
|
179
|
+
{ id: "usage", label: "Usage" },
|
|
180
|
+
{ id: "api", label: "API" },
|
|
181
|
+
]}
|
|
182
|
+
className="fixed top-1/2 right-6 z-(--z-nav) -translate-y-1/2 max-lg:hidden"
|
|
183
|
+
/>`
|
|
184
|
+
}
|
|
185
|
+
]
|
|
186
|
+
};
|
|
187
|
+
export {
|
|
188
|
+
sectionRailMeta
|
|
189
|
+
};
|