@sableui/react 0.0.0-stage → 0.1.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 +157 -0
- package/CHANGELOG.md +50 -0
- package/CONTRIBUTING.md +221 -0
- package/DESIGN.md +548 -0
- package/LICENSE +21 -0
- package/README.md +194 -2
- package/THIRD_PARTY_NOTICES.md +44 -0
- package/bin/sable-lint.mjs +0 -0
- package/bin/sable.mjs +1223 -0
- package/dist/_contract-CYScOdf5.d.ts +150 -0
- package/dist/chunk-3IWZYBGH.js +398 -0
- package/dist/chunk-5Z5KLOAU.js +167 -0
- package/dist/chunk-7MG4SHYX.js +15 -0
- package/dist/chunk-ADLLDWLW.js +15 -0
- package/dist/chunk-EP55KSYA.js +14 -0
- package/dist/chunk-J23ELQQZ.js +9396 -0
- package/dist/chunk-JI7WJP2L.js +286 -0
- package/dist/chunk-K7RUHUGC.js +14 -0
- package/dist/chunk-KO3NAEBF.js +15 -0
- package/dist/chunk-L4IEIQQA.js +14 -0
- package/dist/chunk-L6Y46C62.js +270 -0
- package/dist/chunk-NEQNXI2G.js +25 -0
- package/dist/chunk-PAOZBZME.js +40 -0
- package/dist/chunk-RWP4XBSK.js +139 -0
- package/dist/chunk-SRQWQUTW.js +63 -0
- package/dist/chunk-YXBOYWBN.js +15 -0
- package/dist/foundations/index.d.ts +88 -0
- package/dist/foundations/index.js +792 -0
- package/dist/icon/index.d.ts +60 -0
- package/dist/icon/index.js +2 -0
- package/dist/icon-sets/brands.d.ts +44 -0
- package/dist/icon-sets/brands.js +147 -0
- package/dist/icon-sets/reicon.d.ts +8 -0
- package/dist/icon-sets/reicon.js +1 -0
- package/dist/index.d.ts +7013 -0
- package/dist/index.js +15462 -0
- package/dist/ios/chrome.css +83 -0
- package/dist/ios/frame.css +5 -0
- package/dist/ios/index.d.ts +70 -0
- package/dist/ios/index.js +313 -0
- package/dist/ios/preview.d.ts +64 -0
- package/dist/ios/preview.js +133 -0
- package/dist/ios/styles.css +77 -0
- package/dist/layout/index.d.ts +280 -0
- package/dist/layout/index.js +2 -0
- package/dist/meta-6dUzgC6M.d.ts +205 -0
- package/dist/patterns/index.d.ts +36 -0
- package/dist/patterns/index.js +934 -0
- package/dist/presets/index.d.ts +37 -0
- package/dist/presets/index.js +117 -0
- package/dist/registry-CyL28OzJ.d.ts +28 -0
- package/dist/styles.css +1893 -0
- package/dist/theme-provider-BWcizhAt.d.ts +105 -0
- package/dist/themes/atelier/atelier.css +213 -0
- package/dist/themes/atelier/index.d.ts +9 -0
- package/dist/themes/atelier/index.js +1 -0
- package/dist/themes/base/base.css +443 -0
- package/dist/themes/base/index.d.ts +9 -0
- package/dist/themes/base/index.js +1 -0
- package/dist/themes/ember/ember.css +270 -0
- package/dist/themes/ember/index.d.ts +9 -0
- package/dist/themes/ember/index.js +1 -0
- package/dist/themes/index.d.ts +10 -0
- package/dist/themes/index.js +8 -0
- package/dist/themes/meridian/index.d.ts +9 -0
- package/dist/themes/meridian/index.js +1 -0
- package/dist/themes/meridian/meridian.css +255 -0
- package/dist/themes/poppy/index.d.ts +6 -0
- package/dist/themes/poppy/index.js +1 -0
- package/dist/themes/poppy/poppy.css +174 -0
- package/dist/themes/prism/index.d.ts +6 -0
- package/dist/themes/prism/index.js +1 -0
- package/dist/themes/prism/prism.css +183 -0
- package/dist/themes/verdant/index.d.ts +9 -0
- package/dist/themes/verdant/index.js +1 -0
- package/dist/themes/verdant/verdant.css +210 -0
- package/dist/themes.css +30 -0
- package/llms/components/accordion.md +93 -0
- package/llms/components/app-header.md +70 -0
- package/llms/components/app-shell.md +117 -0
- package/llms/components/attachment.md +105 -0
- package/llms/components/avatar.md +141 -0
- package/llms/components/badge.md +117 -0
- package/llms/components/banner.md +119 -0
- package/llms/components/bottom-nav.md +80 -0
- package/llms/components/breadcrumbs.md +75 -0
- package/llms/components/browser.md +67 -0
- package/llms/components/bubble.md +119 -0
- package/llms/components/button.md +199 -0
- package/llms/components/calendar.md +75 -0
- package/llms/components/card.md +135 -0
- package/llms/components/carousel.md +87 -0
- package/llms/components/chat-composer.md +126 -0
- package/llms/components/checkbox-card.md +98 -0
- package/llms/components/checkbox.md +90 -0
- package/llms/components/code-block.md +81 -0
- package/llms/components/code.md +62 -0
- package/llms/components/collapsible.md +83 -0
- package/llms/components/color-field.md +100 -0
- package/llms/components/combobox.md +119 -0
- package/llms/components/command-menu.md +158 -0
- package/llms/components/context-card.md +73 -0
- package/llms/components/context-menu.md +79 -0
- package/llms/components/data-table.md +99 -0
- package/llms/components/date-picker.md +97 -0
- package/llms/components/description.md +96 -0
- package/llms/components/destructive-action-modal.md +76 -0
- package/llms/components/dialog.md +127 -0
- package/llms/components/dots-menu.md +64 -0
- package/llms/components/empty-state.md +113 -0
- package/llms/components/entity.md +67 -0
- package/llms/components/feedback.md +63 -0
- package/llms/components/fieldset.md +106 -0
- package/llms/components/file-dropzone.md +103 -0
- package/llms/components/form.md +111 -0
- package/llms/components/gliding-highlight.md +84 -0
- package/llms/components/inline-status.md +62 -0
- package/llms/components/input.md +127 -0
- package/llms/components/kbd.md +57 -0
- package/llms/components/label.md +73 -0
- package/llms/components/marker.md +99 -0
- package/llms/components/marquee.md +66 -0
- package/llms/components/menu.md +122 -0
- package/llms/components/message-scroller.md +110 -0
- package/llms/components/message.md +114 -0
- package/llms/components/meter.md +124 -0
- package/llms/components/middle-truncate.md +62 -0
- package/llms/components/note.md +81 -0
- package/llms/components/notification-badge.md +67 -0
- package/llms/components/number-field.md +70 -0
- package/llms/components/otp-field.md +101 -0
- package/llms/components/page-header.md +76 -0
- package/llms/components/page-loader.md +67 -0
- package/llms/components/pagination.md +92 -0
- package/llms/components/panel-group.md +120 -0
- package/llms/components/panel-section.md +122 -0
- package/llms/components/phone.md +70 -0
- package/llms/components/pill.md +73 -0
- package/llms/components/popover.md +79 -0
- package/llms/components/progress.md +98 -0
- package/llms/components/radio-card.md +90 -0
- package/llms/components/radio.md +86 -0
- package/llms/components/relative-time.md +60 -0
- package/llms/components/scroller.md +88 -0
- package/llms/components/scrub-field.md +97 -0
- package/llms/components/search-input.md +84 -0
- package/llms/components/select.md +106 -0
- package/llms/components/separator.md +70 -0
- package/llms/components/settings-menu.md +139 -0
- package/llms/components/sheet.md +97 -0
- package/llms/components/show-more.md +59 -0
- package/llms/components/side-nav.md +106 -0
- package/llms/components/sidebar.md +87 -0
- package/llms/components/skeleton.md +62 -0
- package/llms/components/slider.md +114 -0
- package/llms/components/spinner.md +87 -0
- package/llms/components/status-bar.md +91 -0
- package/llms/components/status-dot.md +70 -0
- package/llms/components/stepper.md +125 -0
- package/llms/components/streaming-text.md +86 -0
- package/llms/components/switch.md +87 -0
- package/llms/components/table.md +112 -0
- package/llms/components/tabs.md +84 -0
- package/llms/components/textarea.md +88 -0
- package/llms/components/theme-selector.md +95 -0
- package/llms/components/theme-switcher.md +71 -0
- package/llms/components/thinking-orb.md +91 -0
- package/llms/components/toast.md +130 -0
- package/llms/components/toggle.md +95 -0
- package/llms/components/toolbar.md +168 -0
- package/llms/components/tooltip.md +93 -0
- package/llms/components/tree.md +227 -0
- package/llms/components/video.md +66 -0
- package/llms/components/workspace.md +138 -0
- package/llms/foundations/accessibility.md +60 -0
- package/llms/foundations/motion.md +96 -0
- package/llms/foundations/scrolling.md +41 -0
- package/llms/foundations/shape.md +73 -0
- package/llms/foundations/spacing.md +82 -0
- package/llms/foundations/surfaces.md +69 -0
- package/llms/foundations/typography.md +120 -0
- package/llms/layout/aspect-box.md +37 -0
- package/llms/layout/bleed.md +31 -0
- package/llms/layout/center.md +37 -0
- package/llms/layout/columns.md +51 -0
- package/llms/layout/grid-overlay.md +40 -0
- package/llms/layout/grid.md +47 -0
- package/llms/layout/page.md +39 -0
- package/llms/layout/region.md +49 -0
- package/llms/layout/row.md +40 -0
- package/llms/layout/safe-area.md +37 -0
- package/llms/layout/spacer.md +39 -0
- package/llms/layout/split-pane.md +41 -0
- package/llms/layout/stack.md +39 -0
- package/llms/patterns/auth-page.md +68 -0
- package/llms/patterns/brand-landing-page.md +423 -0
- package/llms/patterns/chat-panel.md +210 -0
- package/llms/patterns/dashboard-page.md +170 -0
- package/llms/patterns/feed-page.md +103 -0
- package/llms/patterns/landing-page.md +122 -0
- package/llms/patterns/list-detail-page.md +100 -0
- package/llms/patterns/settings-page.md +121 -0
- package/llms/reference/token-intent.md +324 -0
- package/llms/reference/utilities.md +129 -0
- package/llms/tasks/adding-a-component.md +113 -0
- package/llms/tasks/auditing-an-app.md +216 -0
- package/llms/tasks/building-a-page.md +109 -0
- package/llms/tasks/building-a-tool.md +141 -0
- package/llms/tasks/building-an-ios-prototype.md +105 -0
- package/llms/tasks/building-with-sable.md +73 -0
- package/llms/tasks/choosing-a-component.md +109 -0
- package/llms/tasks/converting-an-app.md +139 -0
- package/llms/tasks/creating-a-preset.md +43 -0
- package/llms/tasks/creating-design-assets.md +96 -0
- package/llms/tasks/designing-a-surface.md +115 -0
- package/llms/tasks/developing-taste.md +157 -0
- package/llms/tasks/getting-started.md +63 -0
- package/llms/tasks/learning-from-builds.md +74 -0
- package/llms/tasks/reviewing-a-design.md +114 -0
- package/llms/tasks/reviewing-a-journey.md +84 -0
- package/llms/tasks/reviewing-motion.md +92 -0
- package/llms/tasks/using-presets.md +87 -0
- package/llms/taste/asset-plan.template.json +53 -0
- package/llms/taste/composition-follows-content.md +20 -0
- package/llms/taste/hierarchy-is-a-relationship.md +22 -0
- package/llms/taste/lessons.json +57 -0
- package/llms/taste/motion-needs-a-reason.md +21 -0
- package/llms/taste/surface-states-travel-together.md +20 -0
- package/llms-full.txt +14906 -0
- package/llms.txt +405 -0
- package/package.json +185 -3
- package/registry.json +18812 -0
- package/scripts/agent-setup.mjs +54 -0
- package/scripts/design-atlas.mjs +221 -0
- package/scripts/design-directions.mjs +123 -0
- package/scripts/design-memory.mjs +166 -0
- package/scripts/design-preferences.mjs +227 -0
- package/scripts/design-presets.mjs +48 -0
- package/scripts/grade.mjs +509 -0
- package/scripts/lint-rules.mjs +1065 -0
- package/scripts/review-evidence.mjs +120 -0
- package/scripts/skeleton.mjs +248 -0
- package/skills/entry/SKILL.md +34 -0
- package/skills/sable/SKILL.md +187 -0
- package/skills/sable/agents/openai.yaml +6 -0
- package/targets/ios.json +982 -0
- package/third-party/reicon-LICENSE.txt +20 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# Tree
|
|
2
|
+
|
|
3
|
+
**Status:** stable
|
|
4
|
+
|
|
5
|
+
The object list a tool is built around — layers, scene graph, outline — on one shared grid, so every left edge in the panel resolves to the rail or an exact multiple of the indent step.
|
|
6
|
+
|
|
7
|
+
**Keywords:** tree, layers, outline, scene graph, hierarchy, nesting, indent, disclosure, workspace, panel, file tree, files, folders, explorer, treeview
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
Use Tree for the hierarchy panel in a workspace: layers, objects, an inspector outline. It owns the geometry that makes nesting legible — the rail, the disclosure and type columns, and the indent step — via the density ladder (scope the panel with data-density, e.g. data-density="compact"), so nothing in the panel computes its own inset. It is also the file tree: nest rows with `subtree`, let folders manage their own open state with `defaultExpanded`, and mark kind with the `icon` convention (folder for a row with a subtree, file for a leaf). Reach for Accordion when the content is prose sections rather than nodes in a hierarchy.
|
|
12
|
+
|
|
13
|
+
### Best practices
|
|
14
|
+
|
|
15
|
+
- Do: Give Tree an aria-label naming the hierarchy ("Layers", "Files"). It is a role="tree", and an unnamed tree is announced as just "tree".
|
|
16
|
+
- Do: For a file tree, nest with `subtree` and use the icon convention: `icon={<Icon name="folder" />}` on a row with a subtree, `icon={<Icon name="file" />}` on a leaf. There are no folder/file presets — they would only bind those two props.
|
|
17
|
+
- Do: Give TreeAddRow the same depth as the rows it adds to, so its mark lands in their type column: one deeper than the parent for a child list, the parent's own depth for a sibling list.
|
|
18
|
+
- Do: Put mutable per-row state (visibility, solo, a row menu) in `actions`, on the right rail. Show it on hover, and at rest only when its value is the non-default one.
|
|
19
|
+
- Do: Let section headers sit on the rail at the same height as a depth-0 row. Space and a hairline separate sections; TreeSection handles both, including dropping the divider on the first.
|
|
20
|
+
- Don't: Hardcode a left inset anywhere in the panel. Use treeIndent(depth) or var(--tree-rail); a hand-written px value is how a tree ends up with four different left edges.
|
|
21
|
+
- Don't: Redefine --tree-indent to anything other than --tree-column. A step smaller than the column puts a child's content left of its parent's and the nesting reads inverted.
|
|
22
|
+
- Don't: Put the visibility toggle before the name. A leading toggle makes each row's visual start depend on its depth, so no two rows share an edge.
|
|
23
|
+
- Don't: Hand-copy icon paths into a tree row. Use Icon and the shared semantic set, or provide an app-specific icon through the supported slot.
|
|
24
|
+
- Don't: Use EmptyState inside a panel. It is page-register furniture (px-6 py-12 around a size-12 disc) and in 240px it spends 24px of inset and ~96px of height on nothing. Use TreeEmpty.
|
|
25
|
+
- Don't: Show explanatory prose AND an add row in an empty section. That is two things to read where one carries the affordance; give TreeEmpty a one-line message and pass the add row as its action.
|
|
26
|
+
- Don't: Leave a section actionable when its action cannot do anything yet — a document-level effect list with no content to apply to, for instance. Hide the section or its affordance until the thing it operates on exists.
|
|
27
|
+
|
|
28
|
+
### Anatomy
|
|
29
|
+
|
|
30
|
+
| Part | Required | Description |
|
|
31
|
+
| --- | --- | --- |
|
|
32
|
+
| Tree | yes | The container. Does not scroll itself — put it in a scrolling Panel, or make it the scrolling region with className. |
|
|
33
|
+
| TreeSection | no | A titled group. Header on the rail, hairline above, divider suppressed on the first section. |
|
|
34
|
+
| TreeRow | yes | One node: rail, disclosure column, type column, name, right-rail actions. |
|
|
35
|
+
| TreeAddRow | no | The affordance that ends a branch. Mark in the type column, label in the name column. |
|
|
36
|
+
| TreeEmpty | no | A section's empty state at panel scale: one line, one action, optionally a dashed drop region. |
|
|
37
|
+
|
|
38
|
+
## Subcomponents
|
|
39
|
+
|
|
40
|
+
| Name | Type | Description |
|
|
41
|
+
| --- | --- | --- |
|
|
42
|
+
| TreeSection | { title: ReactNode; hint?: string; action?: ReactNode } | A titled group of rows, with the header on the rail. |
|
|
43
|
+
| TreeRow | { depth?: number; selected?: boolean; accent?: boolean; dimmed?: boolean; expandable?: boolean; expanded?: boolean; defaultExpanded?: boolean; onExpandedChange?: (expanded: boolean) => void; subtree?: ReactNode; toggleLabel?: string; disclosureIcon?: ReactNode; icon?: ReactNode; actions?: ReactNode } | One node in the hierarchy (role=treeitem inside a Tree). Controlled via expanded/onExpandedChange or uncontrolled via defaultExpanded; nest children with subtree. Always reserves both leading columns (disclosure and type), so a leaf's name lines up with its expandable siblings. |
|
|
44
|
+
| TreeAddRow | { depth?: number; label: ReactNode; icon?: ReactNode } | The add affordance, shaped as a row and aligned to the branch it adds to. Its mark sits in the type column (level with the siblings' kind markers), not the name column. `icon` replaces the built-in add mark. |
|
|
45
|
+
| TreeEmpty | { message: ReactNode; action?: ReactNode; dropTarget?: boolean; depth?: number } | A section's or a branch's empty state, at panel scale rather than page scale. One-line message, one action slot; dropTarget renders it as a dashed drop region and sets data-drop-target for styling. `depth` (inherited inside a subtree) starts the line on the edge of the rows that branch would hold. |
|
|
46
|
+
| treeIndent | (depth: number) => string | The left inset for a depth, as a calc() expression reading the density ladder. Use this rather than computing pixels. |
|
|
47
|
+
|
|
48
|
+
## Props
|
|
49
|
+
|
|
50
|
+
| Name | Type | Default | Description |
|
|
51
|
+
| --- | --- | --- | --- |
|
|
52
|
+
| depth | number | 0 | Nesting level, on TreeRow and TreeAddRow. 0 sits on the rail; each level adds one --tree-indent. |
|
|
53
|
+
| expandable | boolean | true with subtree, else false | Whether TreeRow renders a disclosure control. The column is reserved either way, so leaf and parent names stay aligned. |
|
|
54
|
+
| expanded | boolean | — | Controlled open state for a TreeRow. Pair with onExpandedChange; omit both for an uncontrolled row. |
|
|
55
|
+
| defaultExpanded | boolean | true | Uncontrolled initial open state, used when `expanded` is not passed. Pass false for a file tree's closed folders. |
|
|
56
|
+
| onExpandedChange | (expanded: boolean) => void | — | Called when a TreeRow opens or closes — from the disclosure, ArrowRight/ArrowLeft, or activating a row that has no onClick. Fires in both controlled and uncontrolled use. |
|
|
57
|
+
| subtree | ReactNode | — | The rows nested under a TreeRow, rendered after it while it is expanded. Nested rows inherit depth + 1 unless they pass their own `depth`, so a file tree never computes depth by hand. |
|
|
58
|
+
| selected | boolean | false | Selection fill and accent bar on a TreeRow. Inside a Tree it is exposed as aria-selected on the treeitem; a row rendered outside a Tree is a plain div and exposes aria-current instead. |
|
|
59
|
+
| toggleLabel | string | — | Names the thing being disclosed, for the toggle's accessible label — "Expand Background" rather than "Expand group". Every example passes it; a screen-reader user hearing twenty identical "Expand group" buttons has no way to tell them apart. |
|
|
60
|
+
| icon | ReactNode | — | The kind marker for a TreeRow, rendered in the type column at 1em. Use a semantic Icon so it follows the app icon set. |
|
|
61
|
+
| disclosureIcon | ReactNode | — | Replaces TreeRow's semantic chevron-down icon. Omit to use the current IconProvider set or Reicon by default. |
|
|
62
|
+
| actions | ReactNode | — | Right-rail controls for a TreeRow: visibility, solo, a row menu. |
|
|
63
|
+
| label | ReactNode | — | TreeAddRow's visible label. Name what gets added — 'Add effect', not 'Add'. |
|
|
64
|
+
|
|
65
|
+
## Tokens used
|
|
66
|
+
|
|
67
|
+
`panel-raised`, `hairline`, `foreground`, `muted-foreground`, `primary`, `ring`
|
|
68
|
+
|
|
69
|
+
## Accessibility
|
|
70
|
+
|
|
71
|
+
- Tree implements the ARIA tree view pattern: Tree is role="tree", each TreeRow and TreeAddRow is role="treeitem" with aria-level, expandable rows carry aria-expanded, and a TreeSection is a role="group" labelled by its heading. Rows stay flat in the DOM — hierarchy is aria-level — so the fills span the panel's full width.
|
|
72
|
+
- Keyboard: exactly one row is in the tab order (roving tabindex, seeded on the selected row, else the first). ArrowUp/Down move between visible rows; ArrowRight expands a collapsed row, then steps to its first child; ArrowLeft collapses an expanded row, else steps to its parent; Home/End jump to the ends; Enter/Space activate the row — its onClick, or toggling it when it has none. Keys pressed inside a row's own controls (an action button, a rename field) are left to that control.
|
|
73
|
+
- Selection has one mapping: inside a Tree, `selected` is aria-selected on the treeitem; outside a Tree the row is a plain div, where aria-selected is invalid, so it falls back to aria-current. A navigation tree that wants aria-current="page" passes it through props.
|
|
74
|
+
- Each treeitem is named by its own label (aria-labelledby the name), never by the text of its icon or action buttons.
|
|
75
|
+
- The disclosure is a real button with aria-expanded and a label naming what it discloses, and it stops propagation so collapsing a group never changes the selection. Inside a Tree the treeitem carries aria-expanded and the arrow keys, so the button becomes a pointer target only: aria-hidden, out of the tab order, and it leaves focus where it was when clicked.
|
|
76
|
+
- The disclosure's POINTER TARGET is bigger than the mark it draws. `--tree-column` is 20px comfortable and 18px compact, both under WCAG 2.5.8's 24x24 floor, so a centred pseudo-element takes the hit at `--control-xs` (28/24) instead. The drawn box stays on the column, because that column is also the indent step — growing the button would push every name in the tree one column right.
|
|
77
|
+
- Right-rail actions stay in the tab order, so a row with actions costs extra Tab stops. Keep actions few and show them on hover and focus-visible.
|
|
78
|
+
- `dimmed` is presentation only. A row switched off must also say so in its actions' aria-pressed or its accessible name; opacity alone is invisible to a screen reader and fails contrast.
|
|
79
|
+
- Right-rail controls that appear on hover must also appear on focus-visible, or they are unreachable by keyboard.
|
|
80
|
+
|
|
81
|
+
## Related
|
|
82
|
+
|
|
83
|
+
- Components: Panel, PanelSection, Workspace, Accordion, SideNav
|
|
84
|
+
|
|
85
|
+
## Examples
|
|
86
|
+
|
|
87
|
+
### File tree
|
|
88
|
+
|
|
89
|
+
```tsx
|
|
90
|
+
import { Icon, Tree, TreeRow } from "@sableui/react"
|
|
91
|
+
|
|
92
|
+
// Nest with subtree; folders own their open state via defaultExpanded.
|
|
93
|
+
// A row with no onClick toggles when clicked or activated, like a folder in
|
|
94
|
+
// a file explorer. Depth is inherited — never computed by hand.
|
|
95
|
+
<Tree aria-label="Files" className="max-w-xs">
|
|
96
|
+
<TreeRow
|
|
97
|
+
icon={<Icon name="folder" />}
|
|
98
|
+
toggleLabel="src"
|
|
99
|
+
subtree={
|
|
100
|
+
<>
|
|
101
|
+
<TreeRow icon={<Icon name="file" />} onClick={() => open("index.ts")}>index.ts</TreeRow>
|
|
102
|
+
<TreeRow
|
|
103
|
+
icon={<Icon name="folder" />}
|
|
104
|
+
toggleLabel="components"
|
|
105
|
+
defaultExpanded={false}
|
|
106
|
+
subtree={
|
|
107
|
+
<TreeRow icon={<Icon name="file" />} selected onClick={() => open("button.tsx")}>
|
|
108
|
+
button.tsx
|
|
109
|
+
</TreeRow>
|
|
110
|
+
}
|
|
111
|
+
>
|
|
112
|
+
components
|
|
113
|
+
</TreeRow>
|
|
114
|
+
</>
|
|
115
|
+
}
|
|
116
|
+
>
|
|
117
|
+
src
|
|
118
|
+
</TreeRow>
|
|
119
|
+
<TreeRow icon={<Icon name="file" />} onClick={() => open("README.md")}>README.md</TreeRow>
|
|
120
|
+
</Tree>
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Layer tree with per-node effects
|
|
124
|
+
|
|
125
|
+
```tsx
|
|
126
|
+
import { Button, Icon, Tree, TreeAddRow, TreeRow, TreeSection } from "@sableui/react"
|
|
127
|
+
|
|
128
|
+
<Tree aria-label="Layers" className="min-h-0 flex-1 overflow-y-auto scrollbar-quiet">
|
|
129
|
+
<TreeSection title="Document effects" hint="Applied to the flattened composite">
|
|
130
|
+
<TreeRow depth={0} icon={<Icon name="sparkles" />}>Channel shift</TreeRow>
|
|
131
|
+
<TreeAddRow depth={0} label="Add effect" />
|
|
132
|
+
</TreeSection>
|
|
133
|
+
|
|
134
|
+
<TreeSection title="Layers" action={<span className="text-ui-num text-muted-foreground">2</span>}>
|
|
135
|
+
<TreeRow
|
|
136
|
+
depth={0}
|
|
137
|
+
expandable
|
|
138
|
+
expanded
|
|
139
|
+
toggleLabel="Backdrop"
|
|
140
|
+
icon={<Icon name="image-stack" />}
|
|
141
|
+
actions={<Button size="icon-xs" variant="ghost" aria-label="Hide Backdrop"><Icon name="eye" /></Button>}
|
|
142
|
+
>
|
|
143
|
+
Backdrop
|
|
144
|
+
</TreeRow>
|
|
145
|
+
<TreeRow depth={1} icon={<Icon name="sparkles" />}>Block displacement</TreeRow>
|
|
146
|
+
<TreeAddRow depth={1} label="Add effect" />
|
|
147
|
+
|
|
148
|
+
<TreeRow depth={0} icon={<Icon name="image-stack" />} dimmed>Overlay</TreeRow>
|
|
149
|
+
<TreeAddRow depth={0} label="Add image" />
|
|
150
|
+
</TreeSection>
|
|
151
|
+
</Tree>
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Empty state — one line, one action
|
|
155
|
+
|
|
156
|
+
```tsx
|
|
157
|
+
import { TreeAddRow, TreeEmpty, TreeSection } from "@sableui/react"
|
|
158
|
+
|
|
159
|
+
// A section with nothing in it offers exactly ONE way forward. Not EmptyState:
|
|
160
|
+
// that is page-register (px-6 py-12 around a size-12 disc) and in a 240px panel
|
|
161
|
+
// it spends 24px of inset and ~96px of height on nothing.
|
|
162
|
+
<TreeSection title="Layers">
|
|
163
|
+
<TreeEmpty message="No images yet" action={<TreeAddRow depth={0} label="Add image" />} />
|
|
164
|
+
</TreeSection>
|
|
165
|
+
|
|
166
|
+
// As a drop region — the app owns the drag handlers; TreeEmpty owns the frame
|
|
167
|
+
// and sets data-drop-target so a data-[drop-active] style can respond.
|
|
168
|
+
<TreeEmpty
|
|
169
|
+
dropTarget
|
|
170
|
+
message="Drop an image"
|
|
171
|
+
data-drop-active={dragging || undefined}
|
|
172
|
+
onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
|
|
173
|
+
onDragLeave={() => setDragging(false)}
|
|
174
|
+
onDrop={handleDrop}
|
|
175
|
+
className="data-[drop-active]:border-primary"
|
|
176
|
+
/>
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Selection and disclosure
|
|
180
|
+
|
|
181
|
+
```tsx
|
|
182
|
+
import { Icon, Tree, TreeRow } from "@sableui/react"
|
|
183
|
+
|
|
184
|
+
<Tree aria-label="Layers">
|
|
185
|
+
<TreeRow
|
|
186
|
+
depth={0}
|
|
187
|
+
expandable
|
|
188
|
+
expanded={open}
|
|
189
|
+
onExpandedChange={setOpen}
|
|
190
|
+
toggleLabel="Group 1"
|
|
191
|
+
icon={<Icon name="folder" />}
|
|
192
|
+
selected
|
|
193
|
+
onClick={() => select("group-1")}
|
|
194
|
+
>
|
|
195
|
+
Group 1
|
|
196
|
+
</TreeRow>
|
|
197
|
+
{open ? <TreeRow depth={1} icon={<Icon name="file" />}>Child</TreeRow> : null}
|
|
198
|
+
</Tree>
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### An empty branch
|
|
202
|
+
|
|
203
|
+
```tsx
|
|
204
|
+
import { Icon, TreeAddRow, TreeEmpty, TreeRow } from "@sableui/react"
|
|
205
|
+
|
|
206
|
+
// A layer with no effects: TreeEmpty at the branch's depth lands on the
|
|
207
|
+
// same edge its effect rows would — no hand-computed inset.
|
|
208
|
+
<TreeRow depth={0} icon={<Icon name="image-stack" />} toggleLabel="Overlay" subtree={
|
|
209
|
+
<>
|
|
210
|
+
<TreeEmpty message="No effects yet" />
|
|
211
|
+
<TreeAddRow label="Add effect" />
|
|
212
|
+
</>
|
|
213
|
+
}>
|
|
214
|
+
Overlay
|
|
215
|
+
</TreeRow>
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### Aligning a custom affordance
|
|
219
|
+
|
|
220
|
+
```tsx
|
|
221
|
+
import { treeIndent } from "@sableui/react"
|
|
222
|
+
|
|
223
|
+
// Anything else that needs a tree inset reads it from the same place the rows do.
|
|
224
|
+
<div style={{ paddingInlineStart: treeIndent(1) }} className="flex h-(--control-xs) items-center">
|
|
225
|
+
<span className="text-ui-body text-muted-foreground">Drag a layer here</span>
|
|
226
|
+
</div>
|
|
227
|
+
```
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Video
|
|
2
|
+
|
|
3
|
+
**Status:** stable
|
|
4
|
+
|
|
5
|
+
Styled 16:9 video player frame. Mounts a native <video> when given a src, or shows a poster/placeholder with a centered play overlay.
|
|
6
|
+
|
|
7
|
+
**Keywords:** video, media, player, demo
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
Use Video to embed playable or previewable video media inside a page.
|
|
12
|
+
|
|
13
|
+
### Best practices
|
|
14
|
+
|
|
15
|
+
- Do: Use captions, poster imagery, and controls when the video carries information.
|
|
16
|
+
- Do: Keep the surrounding layout stable while media loads.
|
|
17
|
+
- Don't: Do not autoplay important audio or make video the only way to access required information.
|
|
18
|
+
- Don't: Do not ship it without captions or a transcript. Captions are what make video usable for deaf users and searchable for everyone; a video with neither is inaccessible content you also cannot index.
|
|
19
|
+
|
|
20
|
+
## Props
|
|
21
|
+
|
|
22
|
+
| Name | Type | Default | Description |
|
|
23
|
+
| --- | --- | --- | --- |
|
|
24
|
+
| onRequestPlay | () => void | — | Placeholder only (no src): renders the play mark as a real button labelled "Play video" that calls this — e.g. to load the source. Without it the mark is decorative. |
|
|
25
|
+
| src | string | — | Video source URL. When omitted, a poster/placeholder with a play overlay renders. |
|
|
26
|
+
| poster | string | — | Poster image shown before playback (and as the placeholder backdrop). |
|
|
27
|
+
| controls | boolean | true | Show native playback controls. |
|
|
28
|
+
|
|
29
|
+
## Tokens used
|
|
30
|
+
|
|
31
|
+
`border`, `overlay`, `overlay-foreground`, `ring`
|
|
32
|
+
|
|
33
|
+
## Accessibility
|
|
34
|
+
|
|
35
|
+
- Mounts a native <video> element, preserving built-in keyboard controls when controls is enabled.
|
|
36
|
+
- Without onRequestPlay the placeholder play mark is decorative (aria-hidden) — nothing on the frame pretends to be pressable. With it, the mark is a native button named "Play video".
|
|
37
|
+
|
|
38
|
+
## Related
|
|
39
|
+
|
|
40
|
+
- Components: Carousel, Dialog, Badge
|
|
41
|
+
|
|
42
|
+
## Examples
|
|
43
|
+
|
|
44
|
+
### With a poster
|
|
45
|
+
|
|
46
|
+
```tsx
|
|
47
|
+
import { Video } from "@sableui/react"
|
|
48
|
+
|
|
49
|
+
<Video poster="/cover.jpg" src="/clip.mp4" />
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Placeholder (no src)
|
|
53
|
+
|
|
54
|
+
```tsx
|
|
55
|
+
import { Video } from "@sableui/react"
|
|
56
|
+
|
|
57
|
+
<Video poster="/cover.jpg" />
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Placeholder that loads on play
|
|
61
|
+
|
|
62
|
+
```tsx
|
|
63
|
+
import { Video } from "@sableui/react"
|
|
64
|
+
|
|
65
|
+
<Video poster="/cover.jpg" src={src} onRequestPlay={() => setSrc("/clip.mp4")} />
|
|
66
|
+
```
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# Workspace
|
|
2
|
+
|
|
3
|
+
**Status:** stable
|
|
4
|
+
|
|
5
|
+
The shell for a tool: a fixed viewport partitioned into independently scrolling regions, with a compact density scope, a skip link, and a canvas landmark.
|
|
6
|
+
|
|
7
|
+
**Keywords:** workspace, tool, editor, shell, frame, canvas, chrome, panels, density, viewport
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
Use Workspace as the outer frame for a tool — an editor, a console, a canvas app. It owns the viewport, scopes the compact control density, and guarantees a skip link plus a main landmark on the canvas. Reach for AppShell instead for page-register products: it is built around a scrolling document, where a workspace is built around a fixed one. Workspace fills the viewport (h-dvh) by design; to embed one in a page (a docs preview, a modal-sized demo), wrap it in a bounded container and pass className="h-full".
|
|
12
|
+
|
|
13
|
+
### Best practices
|
|
14
|
+
|
|
15
|
+
- Do: Put a PanelGroup in the body and a WorkspaceCanvas inside it, so panels are aside chrome and the work surface is the main landmark.
|
|
16
|
+
- Do: Let each panel own its scrolling. The shell never scrolls, and the canvas clips rather than scrolls — panning is the viewport's job.
|
|
17
|
+
- Do: Keep the density scope at compact unless you have a specific reason; the workspace register's measured control ladder is what makes tool chrome read as tool chrome.
|
|
18
|
+
- Don't: Nest a Region or Page inside it to set a width. Region caps are a page-register idea; a workspace is edge-to-edge and its panels are fixed-width chrome outside the content grid.
|
|
19
|
+
- Don't: Hardcode a control height (h-[32px]). CI rejects it (raw-control-height); read the ladder instead, e.g. h-(--control-sm), so the density scope can size it.
|
|
20
|
+
- Don't: Put a Card in the chrome. Use PanelSection — a card inside a narrow panel spends its border and inset on nothing.
|
|
21
|
+
- Don't: Use it for a dashboard that scrolls. If the primary interaction is reading down a page, that's AppShell plus a Region, not a workspace.
|
|
22
|
+
|
|
23
|
+
### Anatomy
|
|
24
|
+
|
|
25
|
+
| Part | Required | Description |
|
|
26
|
+
| --- | --- | --- |
|
|
27
|
+
| WorkspaceBar | no | Top chrome at --chrome-bar-height with a hairline beneath: toolbar, document tabs, menubar row. |
|
|
28
|
+
| Body | yes | The flexible middle row, normally a PanelGroup. Constrained so its children can shrink and scroll. |
|
|
29
|
+
| WorkspaceCanvas | no | The work surface and the main#main-content landmark the skip link targets. Clips rather than scrolls. |
|
|
30
|
+
| StatusBar | no | Bottom chrome for ambient document facts. |
|
|
31
|
+
|
|
32
|
+
## Subcomponents
|
|
33
|
+
|
|
34
|
+
| Name | Type | Description |
|
|
35
|
+
| --- | --- | --- |
|
|
36
|
+
| WorkspaceBar | ComponentProps<'header'> | The top chrome strip, sized to --chrome-bar-height with a hairline beneath. |
|
|
37
|
+
| WorkspaceCanvas | ComponentProps<'main'> | The work surface: renders main#main-content, fills the body, clips its overflow. |
|
|
38
|
+
|
|
39
|
+
## Props
|
|
40
|
+
|
|
41
|
+
| Name | Type | Default | Description |
|
|
42
|
+
| --- | --- | --- | --- |
|
|
43
|
+
| toolbar | ReactNode | — | Top chrome, normally a WorkspaceBar. |
|
|
44
|
+
| statusBar | ReactNode | — | Bottom chrome, normally a StatusBar. |
|
|
45
|
+
| density | "compact" \| "comfortable" | "compact" | Density scope for everything inside. Sets the --control-* ladder via data-density; compact is the workspace register. Nothing hardcodes a height, so the same size="md" Button is 40px on a page and 32px inside a compact Workspace. A Dialog opened from inside resets to comfortable, because a modal is a page-register surface even when a tool launched it. |
|
|
46
|
+
| skipLinkLabel | string | "Skip to canvas" | Text for the skip link that targets the canvas landmark. |
|
|
47
|
+
|
|
48
|
+
## Tokens used
|
|
49
|
+
|
|
50
|
+
`canvas`, `canvas-foreground`, `panel`, `hairline`, `popover`, `popover-foreground`, `ring`
|
|
51
|
+
|
|
52
|
+
## Accessibility
|
|
53
|
+
|
|
54
|
+
- Renders a skip link to #main-content as the first focusable element, even though there is no scrolling document.
|
|
55
|
+
- WorkspaceCanvas is the <main id="main-content"> landmark; Panels are <aside> chrome with their own names.
|
|
56
|
+
- Density only changes control sizes, never hit-target rules — an icon button at the 24px floor still needs a padded 44px hit area.
|
|
57
|
+
|
|
58
|
+
## Related
|
|
59
|
+
|
|
60
|
+
- Components: PanelGroup, PanelSection, StatusBar, ScrubField, AppShell
|
|
61
|
+
|
|
62
|
+
## Examples
|
|
63
|
+
|
|
64
|
+
### Editor shell
|
|
65
|
+
|
|
66
|
+
```tsx
|
|
67
|
+
import { Button, Panel, PanelGroup, PanelSection, PropertyPair, PropertyRow, ScrubField, Separator, StatusBar, StatusBarGroup, StatusBarSeparator, Switch, Workspace, WorkspaceBar, WorkspaceCanvas } from "@sableui/react"
|
|
68
|
+
|
|
69
|
+
<Workspace
|
|
70
|
+
toolbar={
|
|
71
|
+
<WorkspaceBar>
|
|
72
|
+
<span className="px-1 text-ui-md text-foreground">Studio</span>
|
|
73
|
+
<Separator orientation="vertical" className="mx-1 h-4" />
|
|
74
|
+
<Button size="xs" variant="ghost">Open</Button>
|
|
75
|
+
</WorkspaceBar>
|
|
76
|
+
}
|
|
77
|
+
statusBar={
|
|
78
|
+
<StatusBar>
|
|
79
|
+
<span className="text-ui-num">1024 × 768</span>
|
|
80
|
+
<StatusBarSeparator />
|
|
81
|
+
<span>3 layers</span>
|
|
82
|
+
<StatusBarGroup align="end"><span>Saved</span></StatusBarGroup>
|
|
83
|
+
</StatusBar>
|
|
84
|
+
}
|
|
85
|
+
>
|
|
86
|
+
<PanelGroup>
|
|
87
|
+
<Panel label="Layers" width={240}>
|
|
88
|
+
<PanelSection title="Layers">
|
|
89
|
+
<PropertyRow label="Background"><Switch defaultChecked /></PropertyRow>
|
|
90
|
+
</PanelSection>
|
|
91
|
+
</Panel>
|
|
92
|
+
<WorkspaceCanvas>
|
|
93
|
+
<div className="grid h-full place-items-center text-ui-body text-muted-foreground">Canvas</div>
|
|
94
|
+
</WorkspaceCanvas>
|
|
95
|
+
<Panel label="Properties" width={256}>
|
|
96
|
+
<PanelSection title="Blending">
|
|
97
|
+
<PropertyPair>
|
|
98
|
+
<ScrubField label="Opacity" icon="grid" suffix="%" value={100} min={0} max={100} />
|
|
99
|
+
<ScrubField label="Blur" value={0} min={0} max={64} suffix="px" />
|
|
100
|
+
</PropertyPair>
|
|
101
|
+
</PanelSection>
|
|
102
|
+
</Panel>
|
|
103
|
+
</PanelGroup>
|
|
104
|
+
</Workspace>
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Console shell (no canvas)
|
|
108
|
+
|
|
109
|
+
```tsx
|
|
110
|
+
import { Input, Panel, PanelGroup, PanelSection, PropertyRow, StatusBar, Workspace, WorkspaceCanvas } from "@sableui/react"
|
|
111
|
+
|
|
112
|
+
<Workspace statusBar={<StatusBar><span>Connected</span></StatusBar>}>
|
|
113
|
+
<PanelGroup>
|
|
114
|
+
<Panel label="Sources" width={240}>
|
|
115
|
+
<PanelSection title="Sources">
|
|
116
|
+
<PropertyRow label="Filter"><Input placeholder="Search" /></PropertyRow>
|
|
117
|
+
</PanelSection>
|
|
118
|
+
</Panel>
|
|
119
|
+
<WorkspaceCanvas className="overflow-y-auto scrollbar-quiet">
|
|
120
|
+
<div className="p-3 text-ui-num text-muted-foreground">log output…</div>
|
|
121
|
+
</WorkspaceCanvas>
|
|
122
|
+
</PanelGroup>
|
|
123
|
+
</Workspace>
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Comfortable density
|
|
127
|
+
|
|
128
|
+
```tsx
|
|
129
|
+
import { PanelGroup, Workspace, WorkspaceBar, WorkspaceCanvas } from "@sableui/react"
|
|
130
|
+
|
|
131
|
+
<Workspace density="comfortable" toolbar={<WorkspaceBar><span className="text-ui-md">Review</span></WorkspaceBar>}>
|
|
132
|
+
<PanelGroup>
|
|
133
|
+
<WorkspaceCanvas>
|
|
134
|
+
<div className="grid h-full place-items-center text-body-md text-muted-foreground">Touch-friendly review mode</div>
|
|
135
|
+
</WorkspaceCanvas>
|
|
136
|
+
</PanelGroup>
|
|
137
|
+
</Workspace>
|
|
138
|
+
```
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Accessibility
|
|
2
|
+
|
|
3
|
+
Sable's components ship accessible behavior from Base UI. These rules cover what an app composing them still has to get right.
|
|
4
|
+
|
|
5
|
+
**WCAG AA · 44px targets · visible focus · reduced motion · logical properties**
|
|
6
|
+
|
|
7
|
+
## Floors
|
|
8
|
+
|
|
9
|
+
| Name | Value | Use |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| `Contrast` | 4.5:1 text · 3:1 UI and large text | Every semantic text/surface pair is checked per theme by contrast.test.ts. |
|
|
12
|
+
| `Hit area` | 44×44px | hit-target keeps a small control at the floor without moving layout. |
|
|
13
|
+
| `Focus` | ring-2 ring-ring, full opacity | Visible on every interactive element, in light, dark and forced-colors. |
|
|
14
|
+
| `Mobile input text` | ≥ 16px | iOS Safari zooms the page on focus below it. |
|
|
15
|
+
| `Reduced motion` | --motion-scale: 0 | Transitions resolve instantly; loaders hold their first frame. |
|
|
16
|
+
|
|
17
|
+
## Rules
|
|
18
|
+
|
|
19
|
+
1. **Use the component, not a div with a click handler.** Base UI supplies roles, keyboard handling and focus management. A clickable div has none of them.
|
|
20
|
+
2. **Every control has an accessible name.** Icon-only buttons take aria-label. Fields take a <Label> through <Field>, which also wires aria-describedby and aria-invalid.
|
|
21
|
+
3. **A live region exists before it has anything to say.** Render role="status" empty from the start and swap its text. A region inserted together with its content is announced inconsistently.
|
|
22
|
+
4. **On client-side navigation, update document.title and move focus to the new view's h1.** Route changes are silent to assistive technology unless the app announces them. AppShell supplies the skip link and landmarks; the app does the rest.
|
|
23
|
+
5. **Keep submit enabled; validate on submit and focus the first invalid field.** A disabled submit hides why it is disabled. Focus is the only way a screen-reader user finds the error.
|
|
24
|
+
6. **Use logical properties: ms-/me-/ps-/pe-/text-start/inset-inline-*.** Flex and grid mirror for free under dir="rtl"; hand-positioned elements do not. *Enforced by design-lint: physical-direction (ratchet).*
|
|
25
|
+
7. **Decorative layers over content take pointer-events-none and aria-hidden.** Scrims, glows and fades otherwise swallow clicks on the controls beneath.
|
|
26
|
+
8. **Check focus and disabled states under forced-colors: active.** Windows High Contrast overrides ring colors and erases opacity-based disabled styling.
|
|
27
|
+
|
|
28
|
+
## Never
|
|
29
|
+
|
|
30
|
+
- **Never:** Color as the only signal (a red border with no message). **Instead:** Color plus text or an icon. Roughly 1 in 12 men cannot tell red from green.
|
|
31
|
+
- **Never:** Opacity on the focus ring. **Instead:** ring-ring at full opacity; tune the token in the theme. A faded ring loses the contrast the theme tested. *Enforced by sable-lint: faded-focus-ring.*
|
|
32
|
+
- **Never:** Blocking paste, or type="number" for codes and card numbers. **Instead:** type="text" inputMode="numeric" with the right autocomplete. Blocking paste breaks password managers; number inputs scroll-change identifiers.
|
|
33
|
+
- **Never:** forced-color-adjust: none to 'fix' high-contrast mode. **Instead:** Let the system colors apply; verify the result. It removes the user's chosen colors exactly where they need them.
|
|
34
|
+
- **Never:** outline: none without a replacement. **Instead:** focus-visible:ring-2 ring-ring. Keyboard users lose their place.
|
|
35
|
+
|
|
36
|
+
## Details
|
|
37
|
+
|
|
38
|
+
### An icon-only button
|
|
39
|
+
|
|
40
|
+
```tsx
|
|
41
|
+
// before
|
|
42
|
+
<Button size="icon"><Icon name="xmark" /></Button>
|
|
43
|
+
// after
|
|
44
|
+
<Button size="icon" aria-label="Close"><Icon name="xmark" /></Button>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Without a name, a screen reader announces only 'button'.
|
|
48
|
+
|
|
49
|
+
### A status that announces
|
|
50
|
+
|
|
51
|
+
```tsx
|
|
52
|
+
// before
|
|
53
|
+
{saved && <p role="status">Saved</p>}
|
|
54
|
+
// after
|
|
55
|
+
<p role="status">{saved ? "Saved" : ""}</p>
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The region is in the accessibility tree before its text changes, so the change is announced.
|
|
59
|
+
|
|
60
|
+
**Related:** `sable docs typography` · `sable docs motion` · `sable component <Name> --a11y`
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Motion
|
|
2
|
+
|
|
3
|
+
Motion explains a change: where something came from, what responded, and that work is happening. It is subtle by default, built from named recipes, and one switch turns it off.
|
|
4
|
+
|
|
5
|
+
**4 slots · 3 speeds · enter-only overshoot · 1 kill switch**
|
|
6
|
+
|
|
7
|
+
## Three speeds
|
|
8
|
+
|
|
9
|
+
Match duration to the distance and size of what moves. Each value is multiplied by --motion-scale. Two edge steps sit outside the three: --motion-duration-instant (75ms) for a checkbox tick or tab swap, and --motion-duration-slower (500ms) for progress and value changes.
|
|
10
|
+
|
|
11
|
+
| Name | Value | Use |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| `fast --motion-duration-fast` | 150ms | Hover, press, focus, tooltips, small toggles. Also every exit. |
|
|
14
|
+
| `base --motion-duration-base` | 200ms | Menus, popovers, tabs, disclosure. The default enter. |
|
|
15
|
+
| `slow --motion-duration-slow` | 300ms | Dialogs, sheets, page-level transitions. |
|
|
16
|
+
|
|
17
|
+
## Recipes
|
|
18
|
+
|
|
19
|
+
Reach for one named class before writing a transition.
|
|
20
|
+
|
|
21
|
+
| Name | Value | Use |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| `motion-interactive` | fast · standard | Hover/focus/press chrome on anything clickable. Replaces transition-colors. |
|
|
24
|
+
| `motion-press` | fast · scale settle | Tactile press. Always paired with motion-interactive. |
|
|
25
|
+
| `motion-fade` | enter base / exit fast | Scrims and backdrops. |
|
|
26
|
+
| `motion-pop` | enter base · spring | Anchored or centred surfaces: popovers, menus, dialogs. |
|
|
27
|
+
| `motion-slide-{up,down,left,right}` | enter base / exit fast | Directional surfaces: sheets, toasts. |
|
|
28
|
+
| `motion-disclosure` | base · standard | Height changes: accordion, collapsible. |
|
|
29
|
+
| `motion-icon-swap` | enter · emphasized cross-fade | Two icons of one control trading places: play/pause, outline/fill. |
|
|
30
|
+
| `motion-draw` | slower · decelerate | SVG line art drawing on when a <Reveal> scrolls in. The path needs pathLength="1"; stagger siblings with --motion-draw-delay. |
|
|
31
|
+
| `motion-spin · pulse · shimmer · dot-bounce · ping` | loops | Activity. Scale-aware, so reduced motion freezes them. |
|
|
32
|
+
|
|
33
|
+
## Easings
|
|
34
|
+
|
|
35
|
+
| Name | Value | Use |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| `ease-standard` | cubic-bezier(0.4, 0, 0.2, 1) | The default for state changes. |
|
|
38
|
+
| `ease-decelerate` | cubic-bezier(0, 0, 0.2, 1) | Entrances: arrive fast, settle slowly. |
|
|
39
|
+
| `ease-emphasized` | cubic-bezier(0.2, 0, 0, 1) | Large surfaces covering a lot of distance. |
|
|
40
|
+
| `ease-spring` | ~4% overshoot | A gentle pop on enter. Never on exit. |
|
|
41
|
+
| `ease-bounce` | ~10% overshoot | A playful pop, opt-in only. Enter only, never on exit. |
|
|
42
|
+
|
|
43
|
+
## The four slots
|
|
44
|
+
|
|
45
|
+
Every interactive component answers all four, by filling the slot or declining it with a reason in meta.motion.
|
|
46
|
+
|
|
47
|
+
| Name | Value | Use |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| `arrival` | fade · slide · pop · Reveal | How does it enter? |
|
|
50
|
+
| `response` | motion-interactive · motion-press | What happens under the pointer? |
|
|
51
|
+
| `transition` | motion-disclosure · eased transform | How does it move between its own states? |
|
|
52
|
+
| `activity` | spin · pulse · shimmer | How do you tell working from frozen? |
|
|
53
|
+
|
|
54
|
+
## Rules
|
|
55
|
+
|
|
56
|
+
1. **Every motion has a reason: orientation, feedback, continuity or activity.** Motion with no reason is latency. If you cannot say which of the four it serves, remove it.
|
|
57
|
+
2. **Reach for a recipe first; drop to tokens only when none fits.** Recipes are reviewed once and respect the kill switch. A bespoke transition string is reviewed never. *Enforced by sable-lint: transition-colors, transition-all.*
|
|
58
|
+
3. **Exits are one step faster than entrances, and never overshoot.** Something leaving should get out of the way. A bounce on exit draws the eye to what the user just dismissed.
|
|
59
|
+
4. **Animate opacity and transform only.** They run on the compositor. Width, height, top and left cause layout on every frame. motion-disclosure is the one sanctioned exception.
|
|
60
|
+
5. **Durations come from tokens, so --motion-scale can stop them.** prefers-reduced-motion and <MotionProvider> off both set --motion-scale to 0. A raw duration ignores that. *Enforced by sable-lint: raw-duration, builtin-animate.*
|
|
61
|
+
6. **Interactive elements always respond. Something styled for hover with no motion recipe snaps.** A snap between states reads as broken. The four prohibitive rules could not catch missing motion, so this one does. *Enforced by sable-lint: static-interactive.*
|
|
62
|
+
7. **JS motion reads useMotion().enabled and does nothing when it is false.** Reduced motion is an accessibility setting, not a style preference.
|
|
63
|
+
|
|
64
|
+
## Never
|
|
65
|
+
|
|
66
|
+
- **Never:** Tailwind's animate-spin / animate-pulse / animate-ping / animate-bounce. **Instead:** motion-spin, motion-pulse, motion-ping, motion-dot-bounce. The built-ins ignore --motion-scale, so reduced motion cannot freeze them. *Enforced by sable-lint: builtin-animate.*
|
|
67
|
+
- **Never:** duration-150, duration-300, or any raw duration utility. **Instead:** duration-(--motion-duration-fast) and siblings. Raw durations bypass the kill switch. *Enforced by sable-lint: raw-duration.*
|
|
68
|
+
- **Never:** transition-all, or transition-colors on its own. **Instead:** motion-interactive, or name the properties you animate. transition-all animates unrelated layout changes and defeats the browser's pre-computation. *Enforced by sable-lint: transition-all, transition-colors.*
|
|
69
|
+
- **Never:** Motion on first paint of static content, or staggered entrances on every list. **Instead:** Reveal for one deliberate moment per view. Entrance choreography repeated everywhere becomes a loading delay.
|
|
70
|
+
- **Never:** The motion library in the core package. **Instead:** src/motion (WAAPI, IntersectionObserver, View Transitions). Sable's motion layer is zero-dependency; drag, FLIP and velocity springs belong in an optional adapter.
|
|
71
|
+
|
|
72
|
+
## Details
|
|
73
|
+
|
|
74
|
+
### Hover that respects the kill switch
|
|
75
|
+
|
|
76
|
+
```tsx
|
|
77
|
+
// before
|
|
78
|
+
<button className="transition-colors duration-150 sable-surface-interactive">
|
|
79
|
+
// after
|
|
80
|
+
<button className="motion-interactive sable-surface-interactive">
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Same feel, but reduced motion now resolves it instantly.
|
|
84
|
+
|
|
85
|
+
### Faster exits
|
|
86
|
+
|
|
87
|
+
```tsx
|
|
88
|
+
// before
|
|
89
|
+
transition: opacity 200ms ease-in-out; /* in and out */
|
|
90
|
+
// after
|
|
91
|
+
data-[starting-style]: enter 200ms decelerate · data-[ending-style]: exit 150ms standard
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
motion-pop and motion-slide-* already do this; dismissing feels responsive instead of sluggish.
|
|
95
|
+
|
|
96
|
+
**Related:** `sable docs accessibility` · `sable docs reviewing-motion` · `sable token motion`
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Scrolling
|
|
2
|
+
|
|
3
|
+
Every scroll container declares a policy. Vertical regions get a quiet bar that appears only while you are in them; horizontal strips get edge fades instead of a rail.
|
|
4
|
+
|
|
5
|
+
**2 policies · vertical gets a bar · horizontal gets a fade**
|
|
6
|
+
|
|
7
|
+
## Policies
|
|
8
|
+
|
|
9
|
+
| Name | Value | Use |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| `scrollbar-quiet` | 4px thumb · hidden at rest → 12% active → 16% hovered → 20% dragging | The default for vertical scrolling. Grows to 6px under the pointer. |
|
|
12
|
+
| `scrollbar-hidden` | no bar | Only when something else carries the affordance — and you can name it (an edge fade, paging controls). |
|
|
13
|
+
| `<Scroller>` | edge fades + wheel mapping | Horizontal strips: media rows, chip rows, tab overflow. |
|
|
14
|
+
| `useScrollActivity` | data-scrolling for ~600ms | Lets the bar respond to touch drags and programmatic scrolls, which hover cannot see. |
|
|
15
|
+
|
|
16
|
+
## Rules
|
|
17
|
+
|
|
18
|
+
1. **Every overflow-*-auto|scroll declares scrollbar-quiet or scrollbar-hidden.** Left bare, the platform draws its own: on Windows, stepper arrows hard against the padding edge that poke out of rounded corners. *Enforced by sable-lint: unmanaged-overflow (also checked over every registry example).*
|
|
19
|
+
2. **Vertical regions get a bar; horizontal regions get an edge fade.** A vertical list needs position feedback. A horizontal strip needs 'there is more this way', which a fade says better than a 4px rail.
|
|
20
|
+
3. **A horizontal region must work without a horizontal input.** Most mice have no horizontal wheel. Scroller maps the vertical wheel sideways and handles arrow keys, Home and End.
|
|
21
|
+
4. **Reveal on scrolling, not only on hover.** Touch drags and programmatic scrolls (a chat following a reply) hover nothing, and those are the moments a reader needs to see where they are.
|
|
22
|
+
|
|
23
|
+
## Never
|
|
24
|
+
|
|
25
|
+
- **Never:** scrollbar-hidden on a vertical list with no fade or paging. **Instead:** scrollbar-quiet. It leaves the reader with no sense of position and nothing to discover.
|
|
26
|
+
- **Never:** A styled ::-webkit-scrollbar written per component. **Instead:** The scrollbar-quiet utility. Chromium ignores ::-webkit-scrollbar once scrollbar-width is set; the utility handles both engines.
|
|
27
|
+
|
|
28
|
+
## Details
|
|
29
|
+
|
|
30
|
+
### A scrolling panel
|
|
31
|
+
|
|
32
|
+
```tsx
|
|
33
|
+
// before
|
|
34
|
+
<div className="overflow-y-auto">
|
|
35
|
+
// after
|
|
36
|
+
<div className="overflow-y-auto scrollbar-quiet" ref={useScrollActivity()}>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
No bar at rest; it appears while scrolling, hovered or focused, and fades a beat after. Content cut off at the edge is what says the region scrolls.
|
|
40
|
+
|
|
41
|
+
**Related:** `sable docs surfaces` · `sable docs accessibility` · `sable component Scroller`
|