@teamblind-chorus/ui 1.0.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/LICENSE +21 -0
- package/README.md +112 -0
- package/agents/AGENTS.md +143 -0
- package/agents/DESIGN.md +1311 -0
- package/agents/LOVABLE.md +472 -0
- package/agents/anti-patterns.md +533 -0
- package/agents/catalog.md +232 -0
- package/agents/components/avatar-rail/avatar-rail.family.json +46 -0
- package/agents/components/avatar-rail/avatar-rail.md +103 -0
- package/agents/components/avatar-rail/avatar-rail.spec.json +160 -0
- package/agents/components/badge/badge.family.json +45 -0
- package/agents/components/badge/badge.md +10 -0
- package/agents/components/badge/role.md +100 -0
- package/agents/components/badge/role.spec.json +75 -0
- package/agents/components/badge/update.md +132 -0
- package/agents/components/badge/update.spec.json +114 -0
- package/agents/components/banner/banner.family.json +28 -0
- package/agents/components/banner/banner.md +136 -0
- package/agents/components/banner/banner.spec.json +136 -0
- package/agents/components/bottom-sheet/bottom-sheet.family.json +29 -0
- package/agents/components/bottom-sheet/bottom-sheet.md +176 -0
- package/agents/components/bottom-sheet/bottom-sheet.spec.json +168 -0
- package/agents/components/bubble/bubble.family.json +29 -0
- package/agents/components/bubble/bubble.md +134 -0
- package/agents/components/bubble/bubble.spec.json +91 -0
- package/agents/components/button/button.family.json +76 -0
- package/agents/components/button/button.md +31 -0
- package/agents/components/button/check.md +138 -0
- package/agents/components/button/check.spec.json +161 -0
- package/agents/components/button/fab.md +161 -0
- package/agents/components/button/fab.spec.json +106 -0
- package/agents/components/button/icon.md +141 -0
- package/agents/components/button/icon.spec.json +164 -0
- package/agents/components/button/standard.md +219 -0
- package/agents/components/button/standard.spec.json +205 -0
- package/agents/components/button/text.md +186 -0
- package/agents/components/button/text.spec.json +215 -0
- package/agents/components/button/toggle.md +108 -0
- package/agents/components/button/toggle.spec.json +124 -0
- package/agents/components/button/toolbar.md +189 -0
- package/agents/components/button/toolbar.spec.json +109 -0
- package/agents/components/carousel/carousel.family.json +41 -0
- package/agents/components/carousel/carousel.md +40 -0
- package/agents/components/carousel/post.md +148 -0
- package/agents/components/carousel/post.spec.json +229 -0
- package/agents/components/carousel/profile.md +184 -0
- package/agents/components/carousel/profile.spec.json +219 -0
- package/agents/components/chip/chip.family.json +37 -0
- package/agents/components/chip/chip.md +10 -0
- package/agents/components/chip/filter.md +212 -0
- package/agents/components/chip/filter.spec.json +124 -0
- package/agents/components/chip/tag.md +137 -0
- package/agents/components/chip/tag.spec.json +104 -0
- package/agents/components/dialog/dialog.family.json +29 -0
- package/agents/components/dialog/dialog.md +113 -0
- package/agents/components/dialog/dialog.spec.json +156 -0
- package/agents/components/directory-list/directory-list.family.json +46 -0
- package/agents/components/directory-list/directory-list.md +87 -0
- package/agents/components/directory-list/directory-list.spec.json +104 -0
- package/agents/components/divider/divider.family.json +28 -0
- package/agents/components/divider/divider.md +78 -0
- package/agents/components/divider/divider.spec.json +51 -0
- package/agents/components/feed/ad.md +108 -0
- package/agents/components/feed/ad.spec.json +187 -0
- package/agents/components/feed/feed.family.json +48 -0
- package/agents/components/feed/feed.md +30 -0
- package/agents/components/feed/post.md +240 -0
- package/agents/components/feed/post.spec.json +361 -0
- package/agents/components/form-field/form-field.family.json +50 -0
- package/agents/components/form-field/form-field.md +11 -0
- package/agents/components/form-field/input.md +198 -0
- package/agents/components/form-field/input.spec.json +202 -0
- package/agents/components/form-field/search.md +81 -0
- package/agents/components/form-field/search.spec.json +135 -0
- package/agents/components/form-field/select.md +101 -0
- package/agents/components/form-field/select.spec.json +194 -0
- package/agents/components/form-field/textarea.md +89 -0
- package/agents/components/form-field/textarea.spec.json +176 -0
- package/agents/components/header/header.family.json +43 -0
- package/agents/components/header/header.md +18 -0
- package/agents/components/header/main.md +101 -0
- package/agents/components/header/main.spec.json +117 -0
- package/agents/components/header/sub.md +129 -0
- package/agents/components/header/sub.spec.json +81 -0
- package/agents/components/list/accordion.md +183 -0
- package/agents/components/list/accordion.spec.json +201 -0
- package/agents/components/list/entry.md +280 -0
- package/agents/components/list/entry.spec.json +237 -0
- package/agents/components/list/list.family.json +75 -0
- package/agents/components/list/list.md +24 -0
- package/agents/components/list/radio.md +144 -0
- package/agents/components/list/radio.spec.json +186 -0
- package/agents/components/list/standard.md +262 -0
- package/agents/components/list/standard.spec.json +221 -0
- package/agents/components/metadata/compact.md +69 -0
- package/agents/components/metadata/compact.spec.json +69 -0
- package/agents/components/metadata/metadata.family.json +42 -0
- package/agents/components/metadata/metadata.md +26 -0
- package/agents/components/metadata/standard.md +104 -0
- package/agents/components/metadata/standard.spec.json +152 -0
- package/agents/components/nav-card/nav-card.family.json +29 -0
- package/agents/components/nav-card/nav-card.md +179 -0
- package/agents/components/nav-card/nav-card.spec.json +161 -0
- package/agents/components/nav-list/nav-list.family.json +46 -0
- package/agents/components/nav-list/nav-list.md +91 -0
- package/agents/components/nav-list/nav-list.spec.json +107 -0
- package/agents/components/navigation-bar/main.md +201 -0
- package/agents/components/navigation-bar/main.spec.json +109 -0
- package/agents/components/navigation-bar/navigation-bar.family.json +44 -0
- package/agents/components/navigation-bar/navigation-bar.md +21 -0
- package/agents/components/navigation-bar/search.md +96 -0
- package/agents/components/navigation-bar/search.spec.json +142 -0
- package/agents/components/navigation-bar/sub.md +174 -0
- package/agents/components/navigation-bar/sub.spec.json +123 -0
- package/agents/components/page-shell/page-shell.family.json +22 -0
- package/agents/components/page-shell/page-shell.md +51 -0
- package/agents/components/profile-header/profile-header.family.json +29 -0
- package/agents/components/profile-header/profile-header.md +149 -0
- package/agents/components/profile-header/profile-header.spec.json +200 -0
- package/agents/components/progress/progress.family.json +27 -0
- package/agents/components/progress/progress.md +38 -0
- package/agents/components/progress/progress.spec.json +67 -0
- package/agents/components/side-sheet/side-sheet.family.json +30 -0
- package/agents/components/side-sheet/side-sheet.md +154 -0
- package/agents/components/side-sheet/side-sheet.spec.json +109 -0
- package/agents/components/skeleton/skeleton.family.json +28 -0
- package/agents/components/skeleton/skeleton.md +123 -0
- package/agents/components/skeleton/skeleton.spec.json +73 -0
- package/agents/components/status-tag/status-tag.family.json +26 -0
- package/agents/components/status-tag/status-tag.md +114 -0
- package/agents/components/status-tag/status-tag.spec.json +69 -0
- package/agents/components/suggestion-list/suggestion-list.family.json +46 -0
- package/agents/components/suggestion-list/suggestion-list.md +91 -0
- package/agents/components/suggestion-list/suggestion-list.spec.json +178 -0
- package/agents/components/switch/switch.family.json +27 -0
- package/agents/components/switch/switch.md +114 -0
- package/agents/components/switch/switch.spec.json +123 -0
- package/agents/components/tab-bar/tab-bar.family.json +27 -0
- package/agents/components/tab-bar/tab-bar.md +178 -0
- package/agents/components/tab-bar/tab-bar.spec.json +184 -0
- package/agents/components/tabs/rounded.md +150 -0
- package/agents/components/tabs/rounded.spec.json +140 -0
- package/agents/components/tabs/segmented.md +114 -0
- package/agents/components/tabs/segmented.spec.json +100 -0
- package/agents/components/tabs/tabs.family.json +59 -0
- package/agents/components/tabs/tabs.md +18 -0
- package/agents/components/tabs/underline.md +147 -0
- package/agents/components/tabs/underline.spec.json +139 -0
- package/agents/components/thumbnail/thumbnail.family.json +28 -0
- package/agents/components/thumbnail/thumbnail.md +152 -0
- package/agents/components/thumbnail/thumbnail.spec.json +172 -0
- package/agents/components/toast/toast.family.json +28 -0
- package/agents/components/toast/toast.md +133 -0
- package/agents/components/toast/toast.spec.json +89 -0
- package/agents/components/tooltip/tooltip.family.json +29 -0
- package/agents/components/tooltip/tooltip.md +139 -0
- package/agents/components/tooltip/tooltip.spec.json +110 -0
- package/agents/compose.md +240 -0
- package/agents/icons.json +831 -0
- package/agents/images.md +66 -0
- package/agents/manifest.json +87 -0
- package/agents/patterns/README.md +59 -0
- package/agents/patterns/actions.md +50 -0
- package/agents/patterns/browsing.md +52 -0
- package/agents/patterns/communications.md +56 -0
- package/agents/patterns/layout.md +72 -0
- package/agents/patterns/modals.md +50 -0
- package/agents/patterns/visual.md +55 -0
- package/agents/reconstruct.md +55 -0
- package/agents/scoped-adoption.md +111 -0
- package/agents/tokens.usage.json +1657 -0
- package/agents/usage.json +422 -0
- package/dist/icons/index.cjs +1332 -0
- package/dist/icons/index.cjs.map +1 -0
- package/dist/icons/index.d.cts +228 -0
- package/dist/icons/index.d.ts +228 -0
- package/dist/icons/index.js +1114 -0
- package/dist/icons/index.js.map +1 -0
- package/dist/index.cjs +5905 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +896 -0
- package/dist/index.d.ts +896 -0
- package/dist/index.js +5847 -0
- package/dist/index.js.map +1 -0
- package/dist/styles.css +5765 -0
- package/eslint/README.md +79 -0
- package/eslint/index.js +78 -0
- package/eslint/rules.js +472 -0
- package/eslint/test.mjs +135 -0
- package/package.json +96 -0
- package/placeholder.png +0 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../spec.schema.json",
|
|
3
|
+
"name": "StatusTag",
|
|
4
|
+
"family": "status-tag",
|
|
5
|
+
"description": "Small inline status pill sized for the trailing edge of a row label. Tonal label-style chrome — `surfaceContainerHighest` + `onSurfaceVariant` in `neutral`, `errorContainer` + `onErrorContainer` in `error`. 10px text, 4px inline / 2px block padding, `sys.radius.xs` corners. Decorative — `role='status'` only, never a `<button>`. The two appearances are the only emphasis axis; chromatic primary / accent fills are forbidden so a row never reads as having two competing affordances.",
|
|
6
|
+
"element": "span",
|
|
7
|
+
"props": {
|
|
8
|
+
"appearance": {
|
|
9
|
+
"type": "enum",
|
|
10
|
+
"values": ["neutral", "error"],
|
|
11
|
+
"default": "neutral",
|
|
12
|
+
"description": "Tonal fill / foreground pair. `neutral` is the quiet informational default; `error` is the rejection / blocked / failed state."
|
|
13
|
+
},
|
|
14
|
+
"children": {
|
|
15
|
+
"type": "node",
|
|
16
|
+
"required": true,
|
|
17
|
+
"description": "Tag label text. Single line; should be a short word or phrase (≤ 6 chars in Latin, ≤ 4 in CJK). Wraps would push the pill onto two lines and break the inline-with-label rhythm — keep it short instead."
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"slots": {
|
|
21
|
+
"container": {
|
|
22
|
+
"required": true,
|
|
23
|
+
"description": "Tonal pill. Carries `role='status'`. Inline-block layout so the host row's baseline aligns with the label next to it.",
|
|
24
|
+
"intrinsic": true
|
|
25
|
+
},
|
|
26
|
+
"label": {
|
|
27
|
+
"required": true,
|
|
28
|
+
"description": "Tag text. `ref.fontSize.125` (10px) / Semibold / appearance-bound foreground.",
|
|
29
|
+
"accepts": ["text"]
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"sizing": {
|
|
33
|
+
"paddingBlock": "sys.layout.container.3xs",
|
|
34
|
+
"paddingInline": "sys.layout.container.2xs",
|
|
35
|
+
"radius": "sys.radius.xs",
|
|
36
|
+
"labelFontSize": "ref.fontSize.125",
|
|
37
|
+
"labelFontWeight": "sys.typo.label.md.weight",
|
|
38
|
+
"labelLineHeight": "1.2",
|
|
39
|
+
"labelGapFromHostLabel": "sys.layout.container.2xs"
|
|
40
|
+
},
|
|
41
|
+
"appearances": {
|
|
42
|
+
"neutral": {
|
|
43
|
+
"background": "sys.color.scrimSubtle",
|
|
44
|
+
"foreground": "sys.color.onSurfaceVariant",
|
|
45
|
+
"note": "The quiet informational default. Background paints `sys.color.scrimSubtle` — the translucent inverse-tone scrim (~8% black in light, ~8% white in dark) shared with Chip / Tag default, Progress track, and Skeleton — so the pill stays visible against every surface tier in either theme. Pair with statuses that describe an in-progress / awaiting state — 'pending', 'draft', 'queued', 'in review'."
|
|
46
|
+
},
|
|
47
|
+
"error": {
|
|
48
|
+
"background": "sys.color.errorContainer",
|
|
49
|
+
"foreground": "sys.color.onErrorContainer",
|
|
50
|
+
"note": "The rejection / blocked / failed state. Pair with statuses that describe a terminal negative outcome — 'rejected', 'failed', 'blocked'. Keep usage scarce — every error pill on a screen competes with the others for attention."
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"states": {
|
|
54
|
+
"default": { "overlay": null, "note": "StatusTag has no lifecycle state — it is decorative. No hover / pressed / focus / disabled paint." }
|
|
55
|
+
},
|
|
56
|
+
"behavior": {
|
|
57
|
+
"decorativeOnly": "StatusTag never carries `onClick` / `href` / `tabIndex`. It is a label-style mark only; if the consumer needs the state to be tappable, wrap the host row (List row, NavCard) in the interactive surface and keep StatusTag inert.",
|
|
58
|
+
"ariaRole": "Container carries `role='status'` so screen readers announce the tag as a status update, not as a generic span.",
|
|
59
|
+
"composition": "Sized to sit next to a List row's label with a `sys.layout.container.2xs` (4px) inline gap (see Use cases). The label column owns the gap, NOT the tag.",
|
|
60
|
+
"truncationNotWrap": "Container is `inline-block` and `white-space: nowrap` — long content overflows the row layout instead of wrapping the pill onto two lines. Keep labels short."
|
|
61
|
+
},
|
|
62
|
+
"forbidden": [
|
|
63
|
+
"StatusTag rendered as a `<button>` / `<a href>` — the family is decorative; if the state needs to be tappable, the host row owns the click target",
|
|
64
|
+
"primary / accent / chromatic fill on either appearance — the family carries only `neutral` and `error`; new states (success, warning, info) must land as new appearances in the spec, never as ad-hoc colour overrides",
|
|
65
|
+
"StatusTag placed inside a chip row as if it were a Chip — visual rung (16px vs 32px) and interaction model (decorative vs selectable / dismissable) are different; chip/filter or chip/tag is the right rung for chip rows",
|
|
66
|
+
"StatusTag laid out flush against the host label with zero gap — always pay `sys.layout.container.2xs` (4px) of inline whitespace between the row label and the tag",
|
|
67
|
+
"long phrases wrapped onto a second line inside the pill — container is `nowrap`; if the phrase is long, the label is wrong, not the pill"
|
|
68
|
+
]
|
|
69
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../family.schema.json",
|
|
3
|
+
"family": "suggestion-list",
|
|
4
|
+
"name": "SuggestionList",
|
|
5
|
+
"description": "Swipeable pager of follow-suggestions \u2014 labelled block where each page shows three rows of (avatar + name + meta lines + trailing Follow toggle). Built around the recommendation pattern (suggested channels, suggested people, suggested companies, suggested topics) but the anatomy is content-neutral. Single-spec family. Historically shipped as ChannelList; that name is kept as a deprecated alias.",
|
|
6
|
+
"useCases": [
|
|
7
|
+
"suggested entities to follow",
|
|
8
|
+
"recommendations block",
|
|
9
|
+
"discover panel",
|
|
10
|
+
"horizontal-paged suggestion cards"
|
|
11
|
+
],
|
|
12
|
+
"visualReuse": "open",
|
|
13
|
+
"layoutInset": "full-bleed",
|
|
14
|
+
"wrapperGuidance": "Owns its inline rail internally \u2014 the host is full-bleed (no inline padding); its Header pays the 16 rail and the pager re-pays it via padding-left. Place as a direct child of the page-shell <main> (or any host that pays the gutter once). Do NOT wrap in a padding-inline div, className=\"px-*\", or style={{ padding }} \u2014 the page rail is paid once at the shell, never on the full-bleed child. Inside a bounded surface (Dialog / BottomSheet / SideSheet), apply the negative-margin opt-out \u2014 see AGENTS.md \u00a7 Composition rules.",
|
|
15
|
+
"compositionModes": {
|
|
16
|
+
"standalone": {
|
|
17
|
+
"default": true,
|
|
18
|
+
"chrome": {
|
|
19
|
+
"background": "sys.color.surface",
|
|
20
|
+
"padding": "internal block; full-bleed inline — the Header pays the 16 rail and the pager re-pays it via padding-left"
|
|
21
|
+
},
|
|
22
|
+
"context": "Direct child of the page shell or any host that pays the page-rail gutter once."
|
|
23
|
+
},
|
|
24
|
+
"embedded": {
|
|
25
|
+
"trigger": "prop `embedded={true}` on <SuggestionList /> OR direct child of `.chorus-carousel` / `.chorus-feed` (DOM-ancestry safety net).",
|
|
26
|
+
"chrome": {
|
|
27
|
+
"background": "transparent",
|
|
28
|
+
"padding": "0"
|
|
29
|
+
},
|
|
30
|
+
"context": "Composed inside another rail-responsible host (`<Carousel label=\"Recommended\"><SuggestionList embedded /></Carousel>`). Row content takes over from the host's content-box edge."
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"spec": "suggestion-list.md",
|
|
34
|
+
"usage": {
|
|
35
|
+
"note": "Also exported as the deprecated alias `ChannelList`. Rows carry `name`/`followers`/`description`, not `label`.",
|
|
36
|
+
"example": "<SuggestionList label=\"…\" headerAction={{ label, href }} items={[{ value, name, followers, description, thumbnail, active, onToggle }]} />"
|
|
37
|
+
},
|
|
38
|
+
"subcomponents": [
|
|
39
|
+
{
|
|
40
|
+
"slug": "suggestion-list",
|
|
41
|
+
"spec": "suggestion-list.spec.json",
|
|
42
|
+
"md": "suggestion-list.md",
|
|
43
|
+
"default": true
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Suggestion list
|
|
2
|
+
|
|
3
|
+
A vertically-stacked block of follow-able recommendations rendered as a swipeable pager. One page shows three [list/entry](../list/entry.md)-shaped rows at the `xlarge` rung (56px [Thumbnail](../thumbnail/thumbnail.md), label + stacked `secondary` followers + `description`, trailing [Toggle Button](../button/toggle.md)); the next page peeks at the trailing edge to invite the swipe. Anatomy is entity-agnostic — channels, people, companies, topics share one shape.
|
|
4
|
+
|
|
5
|
+
**Reach for this when** a follow-able set is best surfaced as a peek of three with horizontal paging — recommended channels, people you may know, suggested companies. **Skip when** the full set should scan vertically with no pager (use [DirectoryList](../directory-list/directory-list.md)), the rail is a label-only nav strip ([AvatarRail](../avatar-rail/avatar-rail.md)), or the surface needs fixed-width profile cards ([Profile carousel](../carousel/profile.md)).
|
|
6
|
+
|
|
7
|
+
**Layout inset.** `full-bleed` — sits as a direct child of the page shell (or any surface that pays the gutter) and stretches edge-to-edge. The container pays **no** inline padding (matches [DirectoryList](../directory-list/directory-list.md)): its [Header](../header/header.md) owns the 16 inline rail and its 24/16 block padding, and the pager re-pays the 16 left rail via `padding-left` while spanning the full-bleed surface so the next-page peek reaches the trailing edge. Each row keeps its list/entry native `16px inline padding` for the tap target and adds a `-16` inline margin so the visible content (avatar / toggle) still sits flush at the page boundaries — net result, the avatar reads at 16 from the surface (aligned with the header label) and the next page's avatar peeks 8 past the surface's right edge. Do **not** wrap in another `padding-inline` / `px-*` / `style={{ padding: … }}` div. Inside a bounded surface (Card / Dialog / BottomSheet / Sheet), apply the negative-margin opt-out — see [`AGENTS.md` § Composition rules](../../../AGENTS.md#composition-rules).
|
|
8
|
+
|
|
9
|
+
## Default
|
|
10
|
+
|
|
11
|
+
Two-page list (six channels), header label only.
|
|
12
|
+
|
|
13
|
+
```preview
|
|
14
|
+
suggestion-list/default
|
|
15
|
+
---
|
|
16
|
+
import { SuggestionList } from '@teamblind-chorus/ui';
|
|
17
|
+
|
|
18
|
+
<SuggestionList
|
|
19
|
+
label="Recommended channels"
|
|
20
|
+
items={[
|
|
21
|
+
{ value: 'sourdough', name: 'Sourdough Bakers', followers: '12.4K Followers', description: 'Open-crumb obsession, cold-proof timing, starter help.', thumbnail: { alt: 'Sourdough Bakers' } },
|
|
22
|
+
{ value: 'indiedev', name: 'Indie Game Devs', followers: '8,210 Followers', description: 'Shipping logs, postmortems, marketing on a budget.', thumbnail: { alt: 'Indie Game Devs' } },
|
|
23
|
+
{ value: 'plants', name: 'Plant People', followers: '21.7K Followers', description: 'Houseplant troubleshooting and propagation threads.', thumbnail: { alt: 'Plant People' } },
|
|
24
|
+
{ value: 'movies', name: 'Movie Talk', followers: '34.2K Followers', description: 'Festival coverage, director threads, link shares.', thumbnail: { alt: 'Movie Talk' } },
|
|
25
|
+
{ value: 'mods', name: 'Community Mods', followers: '1,840 Followers', description: 'Weekly digests and rules discussion across channels.', thumbnail: { alt: 'Community Mods' } },
|
|
26
|
+
{ value: 'amas', name: 'AMAs', followers: '5,120 Followers', description: 'Scheduled Q&A sessions with founders and creators.', thumbnail: { alt: 'AMAs' } },
|
|
27
|
+
]}
|
|
28
|
+
/>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Use cases
|
|
32
|
+
|
|
33
|
+
### With header action
|
|
34
|
+
|
|
35
|
+
Extends the header with a trailing `accent` Text Button when the screen has a broader index page to route to.
|
|
36
|
+
|
|
37
|
+
```preview
|
|
38
|
+
suggestion-list/with-header-action
|
|
39
|
+
---
|
|
40
|
+
import { SuggestionList } from '@teamblind-chorus/ui';
|
|
41
|
+
|
|
42
|
+
<SuggestionList
|
|
43
|
+
label="People you may know"
|
|
44
|
+
headerAction={{ label: 'See all', href: '/channels' }}
|
|
45
|
+
items={[
|
|
46
|
+
{ value: 'jordan', name: 'Jordan Lee', followers: '342 Followers', description: 'PM at a logistics startup. Mostly here for the threads on roadmap reviews.', thumbnail: { alt: 'Jordan Lee' } },
|
|
47
|
+
{ value: 'taylor', name: 'Taylor Brooks', followers: '1.1K Followers', description: 'Frontend engineer. Writes about the bits between the framework and the user.', thumbnail: { alt: 'Taylor Brooks' } },
|
|
48
|
+
{ value: 'morgan', name: 'Morgan Park', followers: '512 Followers', description: 'Designer-turned-PM. Notes on the handoff layer.', thumbnail: { alt: 'Morgan Park' } },
|
|
49
|
+
]}
|
|
50
|
+
/>
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Slots
|
|
54
|
+
|
|
55
|
+
- **container** — `surface` block, 24px block padding, **full-bleed inline** (the Header pays the 16 rail). Holds the header above the pager.
|
|
56
|
+
- **header** — the shared [Header](../header/header.md) component (`size="large"` default): section label leading, optional text-link action trailing. Anchored outside the pager; owns its own 16 inline rail and 24/16 block padding.
|
|
57
|
+
- **label** — Header's section title. `heading.md` (20px / Semibold) / `onSurface`.
|
|
58
|
+
- **headerAction** *(optional)* — Header's trailing [`xsmall` Text Button](../button/text.md), `accent` appearance. Renders as `<a>` when `href` is set.
|
|
59
|
+
- **pager** — horizontally-scrolling track with mandatory inline scroll-snap; native scrollbar hidden. Next page peeks at the trailing edge.
|
|
60
|
+
- **page** — one swipe target. Vertical stack of exactly three rows.
|
|
61
|
+
- **row** — channel suggestion rendered as a [list/entry](../list/entry.md)-shaped row at the `xlarge` rung (56 Thumbnail). Each item descriptor (`name` / `followers` / `description` / `thumbnail` / `active` / `onToggle`) maps to the entry contract (`label` / `secondary` / `description` / `thumbnail` / `trailingIcon`). The row keeps its family-default `container.md` inline padding for the tap target and adds a `-container.md` inline margin so the visible avatar / toggle sit flush at the page boundaries; the row body itself is not clickable — it's presentational. SuggestionList wraps the row with its own bottom-padding + hairline divider (the text-column-anchored rule that ties the divider's left edge to the start of the text column).
|
|
62
|
+
|
|
63
|
+
## Anatomy
|
|
64
|
+
|
|
65
|
+
| Slot | Token bindings |
|
|
66
|
+
|----------------|----------------|
|
|
67
|
+
| container | `surface` fill, 24px block padding, **full-bleed inline**, vertical stack |
|
|
68
|
+
| header | Shared [Header](../header/header.md) (`size="large"`). Its `sys.layout.stack.md` (16px) block-end is the header↔pager gap (host gap → 0); its `sys.layout.stack.lg` (24px) block-start replaces the container's leading inset. |
|
|
69
|
+
| label | Header's label — `heading.md` / Semibold / `onSurface` |
|
|
70
|
+
| headerAction | Header's trailing `xsmall` [Text Button](../button/text.md), `accent` appearance |
|
|
71
|
+
| pager | Horizontal scroll, `scroll-snap-type: x mandatory`, scrollbar hidden; `sys.layout.inline.xl` (16/24px) gap. Re-pays the 16 left rail via `padding-left: sys.layout.container.md` (+ matching `scroll-padding-left` snap-port); the full-bleed host means the pager spans the surface so the next-page peek reaches the trailing edge intrinsically (no negative margin). When embedded in a padded host (Feed), the pager re-adds `margin-inline: -sys.layout.container.md` to pierce the host's rail. |
|
|
72
|
+
| page | `flex: 0 0 calc(100% - sys.layout.inline.xl - sys.layout.inline.md)` so the next page leading edge shows by 8px; `scroll-snap-align: start`; `sys.layout.stack.sm` (12px) between rows |
|
|
73
|
+
| row | [list/entry](../list/entry.md)-shaped row at `xlarge` rung — 56 avatar, `inline.lg` gap, label.md primary / label.sm `secondary` + `description`. Keeps the list/entry native `container.md` inline padding (tap target) and adds `margin-inline: -container.md` so the visible content (avatar / toggle) sits flush at the page boundaries — the avatar reads at 16 from the surface, aligned with the header label. SuggestionList adds: 12px bottom padding + `::after` divider 1px / `outlineVariant` anchored at the text column (standalone: `container.md` 16 + `ref.space.700` 56 + `inline.lg` 12 = 84px from row left; embedded: 68px since the row's inline padding + margin are zeroed). |
|
|
74
|
+
| trailingAction | [Toggle Button](../button/toggle.md), `variant="toggle"` — composed into the row's `trailingIcon` slot. |
|
|
75
|
+
|
|
76
|
+
## States
|
|
77
|
+
|
|
78
|
+
Container has no interactive state. Each row's only interactive surface is its **trailingAction** — a Toggle Button obeying the [Toggle Button](../button/toggle.md) state contract. Row body is presentational; tapping the row does not route. The **headerAction** is an `xsmall` Text Button (rendered as `<a>` when `href` is set).
|
|
79
|
+
|
|
80
|
+
## Focus indicator
|
|
81
|
+
|
|
82
|
+
Row body is presentational; the only row-level focus target is the trailing Toggle Button (Outward ring). headerAction also paints its own Outward ring. Composition for any future row-level focus target: Inward — rows tile with a hairline divider. Trigger: `:focus-visible`.
|
|
83
|
+
|
|
84
|
+
## Behavior
|
|
85
|
+
|
|
86
|
+
- **Header is required.** Every SuggestionList carries a `label`; the optional `headerAction` extends the header with a trailing `accent` Text Button when there's an index page to route to.
|
|
87
|
+
- **Pages of three rows.** A page is exactly three rows; the final page pads with empty space rather than collapsing.
|
|
88
|
+
- **Horizontal scroll-snap.** `scroll-snap-type: x mandatory`; each page declares `scroll-snap-align: start`.
|
|
89
|
+
- **Next-page peek.** The host is full-bleed, so the pager spans the surface and the peek reaches the trailing edge intrinsically; it re-pays the 16 left rail via `padding-left` + `scroll-padding-left` so page 1 snaps at the content rail and the row's leftward tap-area extension stays visible inside the left padding strip. When embedded in a padded host (Feed) the pager re-adds `margin-inline: -sys.layout.container.md` to pierce the host's rail. Page basis composes inter-page gap (`inline.xl`) plus visible peek (`inline.md` = 8px) in one `calc`.
|
|
90
|
+
- **Toggle commits in place.** State is owned by the consumer — `active` and `onToggle` forward per row through `items`.
|
|
91
|
+
- **Entity-agnostic anatomy.** Same row shape carries channels, people, companies, or topics.
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../spec.schema.json",
|
|
3
|
+
"name": "SuggestionList",
|
|
4
|
+
"family": "suggestion-list",
|
|
5
|
+
"exportAlias": "ChannelList",
|
|
6
|
+
"description": "Vertically-stacked block of follow suggestions rendered as a swipeable pager. One page shows three rows that follow the [list/entry](../list/entry.md) visual contract at the `xlarge` rung (56 Thumbnail + identity group of label + stacked `secondary` follower count + `description` + trailing Toggle Button flipping between 'Follow' and 'Following'); the next page peeks at the trailing edge to invite the swipe. Anatomy is entity-agnostic — suggested channels, suggested people, suggested companies, suggested topics all share the same shape.",
|
|
7
|
+
"element": "div",
|
|
8
|
+
"props": {
|
|
9
|
+
"embedded": {
|
|
10
|
+
"type": "boolean",
|
|
11
|
+
"default": false,
|
|
12
|
+
"description": "Composition mode flag. When `true` (or when SuggestionList is a direct child of `.chorus-carousel` / `.chorus-feed`), the list enters **embedded mode**: zeroes its own `background` + `padding` so chrome defers to the host. Pass explicitly inside `<Carousel>` / `<Feed>` for the contract to be visible in JSX; the DOM-ancestry safety net in styles.css also activates the mode when omitted. See `compositionModes` in `suggestion-list.family.json`."
|
|
13
|
+
},
|
|
14
|
+
"label": {
|
|
15
|
+
"type": "string",
|
|
16
|
+
"required": true,
|
|
17
|
+
"description": "Section title."
|
|
18
|
+
},
|
|
19
|
+
"headerAction": {
|
|
20
|
+
"type": "object",
|
|
21
|
+
"optional": true,
|
|
22
|
+
"description": "{ label, href? , onClick? } — trailing text link in the header."
|
|
23
|
+
},
|
|
24
|
+
"items": {
|
|
25
|
+
"type": "node",
|
|
26
|
+
"required": true,
|
|
27
|
+
"description": "Array of channel descriptors: { value, name, followers, description, thumbnail, active?, onToggle? }."
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"slots": {
|
|
31
|
+
"container": {
|
|
32
|
+
"required": true,
|
|
33
|
+
"description": "Surface block holding header above the swipeable pager. Full-bleed inline (no host inline padding) — the Header pays the 16 rail.",
|
|
34
|
+
"intrinsic": true
|
|
35
|
+
},
|
|
36
|
+
"header": {
|
|
37
|
+
"required": true,
|
|
38
|
+
"description": "The shared [Header](../header/header.md) component (`size='large'`) — section label leading, optional text-link action trailing. Stays anchored while pages scroll; owns its own 16 inline rail and 24/16 block padding.",
|
|
39
|
+
"intrinsic": true
|
|
40
|
+
},
|
|
41
|
+
"label": {
|
|
42
|
+
"required": true,
|
|
43
|
+
"description": "Section title. heading.md / Semibold / onSurface.",
|
|
44
|
+
"accepts": [
|
|
45
|
+
"text"
|
|
46
|
+
]
|
|
47
|
+
},
|
|
48
|
+
"headerAction": {
|
|
49
|
+
"required": false,
|
|
50
|
+
"description": "Trailing [Text Button](../button/text.md) (`size={'xsmall'}`, `appearance={'accent'}`) — typically 'See all'. Renders as a link-affordance, so it follows the family-wide rule: link-shaped Text Buttons take `accent` for chromatic emphasis (see [text.md](../button/text.md#accent) and rule 8 of the agent contract).",
|
|
51
|
+
"accepts": [
|
|
52
|
+
"button"
|
|
53
|
+
]
|
|
54
|
+
},
|
|
55
|
+
"pager": {
|
|
56
|
+
"required": true,
|
|
57
|
+
"description": "Horizontally-scrolling track of pages with mandatory inline scroll-snap; native scrollbar hidden.",
|
|
58
|
+
"intrinsic": true
|
|
59
|
+
},
|
|
60
|
+
"page": {
|
|
61
|
+
"required": true,
|
|
62
|
+
"description": "Vertical stack of exactly three rows. Width = container inner width minus a peek amount.",
|
|
63
|
+
"intrinsic": true
|
|
64
|
+
},
|
|
65
|
+
"row": {
|
|
66
|
+
"required": true,
|
|
67
|
+
"intrinsic": true,
|
|
68
|
+
"description": "Single channel suggestion rendered as a [list/entry](../list/entry.md)-shaped row at `xlarge` rung. Item descriptors map to entry slots: `name` → `label`, `followers` → `secondary`, `description` → `description`, `thumbnail` → `thumbnail` (forwarded verbatim at the 56 rung), `active` + `onToggle` → `trailingIcon` (a Toggle Button). SuggestionList wraps the row with its own bottom-padding + hairline divider (text-column anchored).",
|
|
69
|
+
"accepts": [
|
|
70
|
+
"row"
|
|
71
|
+
]
|
|
72
|
+
},
|
|
73
|
+
"trailingAction": {
|
|
74
|
+
"required": true,
|
|
75
|
+
"description": "[Toggle Button](../button/toggle.md) — the row's only commit affordance, composed into the row's `trailingIcon` slot. The row body itself is not clickable.",
|
|
76
|
+
"accepts": [
|
|
77
|
+
"button"
|
|
78
|
+
]
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
"sizing": {
|
|
82
|
+
"containerFill": "sys.color.surface",
|
|
83
|
+
"containerPaddingBlock": "sys.layout.container.lg",
|
|
84
|
+
"containerPaddingInline": "0",
|
|
85
|
+
"containerPaddingInlineNote": "Full-bleed: the host pays no inline padding. The Header pays the 16 inline rail (sys.layout.container.md) and the pager re-pays it via padding-left.",
|
|
86
|
+
"headerToPagerGap": "sys.layout.stack.md",
|
|
87
|
+
"headerToPagerGapNote": "Header's own block-end (stack.md = 16) is the header↔pager gap; the host gap collapses to 0 (matches DirectoryList).",
|
|
88
|
+
"labelTypo": "sys.typo.heading.md",
|
|
89
|
+
"labelColor": "sys.color.onSurface",
|
|
90
|
+
"headerActionRendersAs": "Button variant='text' size='xsmall' appearance='accent' — label paints in sys.color.primary via the Text Button accent token.",
|
|
91
|
+
"headerActionTypo": "sys.typo.label.sm",
|
|
92
|
+
"headerActionColor": "sys.color.primary",
|
|
93
|
+
"pageGap": "sys.layout.inline.xl",
|
|
94
|
+
"pagePeek": "sys.layout.inline.md",
|
|
95
|
+
"pageRowGap": "sys.layout.stack.sm",
|
|
96
|
+
"rowComposition": "list/entry-shaped row at `xlarge` rung — avatar 56, row gap inline.lg, label.md / onSurface, stacked `secondary` label.sm / onSurface, `description` label.sm / onSurfaceVariant separated from the identity group by `ref.space.25`. SuggestionList adds the per-row chrome (bottom padding + text-column-anchored divider) on top of that.",
|
|
97
|
+
"rowInlinePaddingNote": "Each row keeps the list/entry native sys.layout.container.md inline padding (the tap target reaches the surface edge) and adds margin-inline: calc(-1 * sys.layout.container.md) so the visible avatar / toggle still sit flush at the page boundaries — the avatar reads at 16 from the surface, aligned with the header label, and the trailing toggle ends one inline.xl (16) before the next page's avatar. Embedded mode zeroes both the row padding and the negative margin since the host pays the rail directly (the pager re-adds its both-side bleed there).",
|
|
98
|
+
"rowBottomPadding": "sys.layout.stack.sm",
|
|
99
|
+
"rowBottomPaddingNote": "The bottom padding sits between the row's text content and the hairline divider so the divider reads as a separator rather than a baseline rule.",
|
|
100
|
+
"dividerWidth": "sys.borderWidth.hairline",
|
|
101
|
+
"dividerColor": "sys.color.outlineVariant",
|
|
102
|
+
"dividerInset": "calc(sys.layout.container.md + ref.space.700 + sys.layout.inline.lg) = 84px from the row's leading edge in standalone mode — anchors to the start of the row's text column so the divider aligns with the column, not the avatar. Embedded mode re-anchors to 68px (drops the container.md term) since the row's inline padding + negative margin are zeroed."
|
|
103
|
+
},
|
|
104
|
+
"rowProps": {
|
|
105
|
+
"value": {
|
|
106
|
+
"type": "string",
|
|
107
|
+
"required": true
|
|
108
|
+
},
|
|
109
|
+
"name": {
|
|
110
|
+
"type": "string",
|
|
111
|
+
"required": true
|
|
112
|
+
},
|
|
113
|
+
"followers": {
|
|
114
|
+
"type": "string",
|
|
115
|
+
"required": true
|
|
116
|
+
},
|
|
117
|
+
"description": {
|
|
118
|
+
"type": "string",
|
|
119
|
+
"required": true
|
|
120
|
+
},
|
|
121
|
+
"thumbnail": {
|
|
122
|
+
"type": "object",
|
|
123
|
+
"required": true,
|
|
124
|
+
"agentRequired": true,
|
|
125
|
+
"omittedBehavior": "error",
|
|
126
|
+
"fallbackOnMissingSrc": "sys.color.surfaceContainerHigh",
|
|
127
|
+
"description": "Forwarded to Thumbnail verbatim — src, alt, updateDot, logoBadge. Agents MUST pass `src`; fill `/placeholder.png` when no real subject is implied. `fallbackOnMissingSrc` is the runtime safety net for load failures, not a scaffold-time omission license."
|
|
128
|
+
},
|
|
129
|
+
"active": {
|
|
130
|
+
"type": "boolean",
|
|
131
|
+
"default": false
|
|
132
|
+
},
|
|
133
|
+
"onToggle": {
|
|
134
|
+
"type": "function",
|
|
135
|
+
"optional": true
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
"states": {
|
|
139
|
+
"note": "Container has no interactive state. Each row's only interactive surface is the trailing Toggle Button (its own state contract). The headerAction is a text link (Text links state contract). The row body is presentational — tapping does not route."
|
|
140
|
+
},
|
|
141
|
+
"focusIndicator": {
|
|
142
|
+
"description": "Keyboard-focus visual — an accessibility indicator, not a lifecycle state. Channel List's own rows are presentational (tap-to-route is not owned by the row body); this contract documents the composition any nested focus target inherits — the trailing Toggle Button paints its own outward ring per its spec, but any forthcoming row-level focus target tiles flush with hairline dividers and would use the Inward composition.",
|
|
143
|
+
"composition": "inward",
|
|
144
|
+
"compositionReason": "Rows tile the column with a hairline divider; an outward ring would overlap divider and neighbour row.",
|
|
145
|
+
"overlay": {
|
|
146
|
+
"color": "label",
|
|
147
|
+
"opacity": "sys.state.focus"
|
|
148
|
+
},
|
|
149
|
+
"ring": {
|
|
150
|
+
"outerWidth": "sys.borderWidth.thin",
|
|
151
|
+
"outerColor": "sys.color.focus",
|
|
152
|
+
"outerLayerPosition": "depth 0..2px from the row edge (the outer stroke)",
|
|
153
|
+
"insetWidth": "sys.borderWidth.hairline",
|
|
154
|
+
"insetColor": "sys.color.focusInset",
|
|
155
|
+
"insetLayerPosition": "depth 2..3px from the row edge (the counter-ring just inside the outer stroke)",
|
|
156
|
+
"implementation": "inset box-shadow constrained strictly inside the row's footprint."
|
|
157
|
+
},
|
|
158
|
+
"trigger": ":focus-visible (keyboard / programmatic focus, never plain mouse click)"
|
|
159
|
+
},
|
|
160
|
+
"behavior": {
|
|
161
|
+
"pagesOfThree": "A page is always exactly three rows. If items is not a multiple of three, the final page is padded with empty space — it never collapses to a shorter page.",
|
|
162
|
+
"horizontalScrollSnap": "scroll-snap-type: x mandatory; each page declares scroll-snap-align: start.",
|
|
163
|
+
"nextPagePeek": "The host is full-bleed, so the pager spans the surface and the peek reaches the trailing edge intrinsically (no negative margin); it re-pays the 16 left rail via padding-left + scroll-padding-left so page 1 snaps at the content rail and the row's leftward tap-area extension stays visible inside the left padding strip. When embedded in a padded host (Feed) the pager re-adds margin-inline: -container.md to pierce the host's rail. Page basis composes the inter-page gap (inline.xl) plus the visible peek (inline.md, 8px) into one calc — the 8px peek holds across the responsive breakpoint.",
|
|
164
|
+
"toggleCommitsInPlace": "Tapping 'Follow' flips the row's button to 'Following' and stays there. State is owned by the consumer via items[i].active + onToggle."
|
|
165
|
+
},
|
|
166
|
+
"accessibility": {
|
|
167
|
+
"region": "the pager is a labelled scroll region: role='group' with aria-roledescription='carousel' and aria-label / aria-labelledby wired to the block's `label` heading, so assistive tech announces a named, swipeable group rather than an unlabelled scroll box.",
|
|
168
|
+
"keyboardReach": "Rows on pages 2+ MUST stay reachable without swiping: each row's trailing Toggle Button is in the natural tab order, and focusing an off-screen row scrolls its page into the viewport (scroll-into-view on :focus). The pager has no decorative-only navigation that would otherwise strand later pages from the keyboard.",
|
|
169
|
+
"paddingNotAnnounced": "When the final page is padded to three rows (see behavior.pagesOfThree), the empty padding cells carry aria-hidden='true' so a screen reader does not traverse phantom rows.",
|
|
170
|
+
"perRowName": "Each row exposes its entity name as its accessible label so the Toggle Button tab stop reads as 'Follow {name}', not a bare 'Follow'."
|
|
171
|
+
},
|
|
172
|
+
"forbidden": [
|
|
173
|
+
"row anatomy diverged from the [list/entry](../list/entry.md) visual contract — SuggestionList's row IS an entry-shaped row at the xlarge rung (56); rebuilding the avatar + text column + trailing slot with different typography or geometry is a forbidden divergence",
|
|
174
|
+
"follow action rendered as button/standard — the follow affordance is button/toggle per the spec",
|
|
175
|
+
"pager-style scroll snap removed — the swipeable pager is the family contract",
|
|
176
|
+
"more than the declared page-of-rows shown at once — overflow is the swipeable affordance, not a wrap"
|
|
177
|
+
]
|
|
178
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../family.schema.json",
|
|
3
|
+
"family": "switch",
|
|
4
|
+
"name": "Switch",
|
|
5
|
+
"description": "Binary on/off control — a pill-shaped track with a circular thumb that translates between the two ends. The off state reads as a muted `surfaceContainerHighest` track; the on state paints the track in `primary` so the contract reads chromatically without an inline label. Use for instant-commit settings where the change applies the moment the thumb moves; reach for a [Button](../button/button.md) when the commit needs a confirmation step. Single-spec family.",
|
|
6
|
+
"useCases": [
|
|
7
|
+
"instant-commit settings toggle",
|
|
8
|
+
"notification on/off",
|
|
9
|
+
"privacy / visibility toggle",
|
|
10
|
+
"list row trailing-slot toggle"
|
|
11
|
+
],
|
|
12
|
+
"visualReuse": "open",
|
|
13
|
+
"layoutInset": "inline",
|
|
14
|
+
"spec": "switch.md",
|
|
15
|
+
"usage": {
|
|
16
|
+
"note": "Change handler is `onCheckedChange` (not `onChange`); instant commit, no confirmation step. Whole pill is the target — the thumb is decorative.",
|
|
17
|
+
"example": "<Switch checked={on} onCheckedChange={setOn} aria-label=\"…\" />"
|
|
18
|
+
},
|
|
19
|
+
"subcomponents": [
|
|
20
|
+
{
|
|
21
|
+
"slug": "switch",
|
|
22
|
+
"spec": "switch.spec.json",
|
|
23
|
+
"md": "switch.md",
|
|
24
|
+
"default": true
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# Switch
|
|
2
|
+
|
|
3
|
+
A binary active/inactive control — a pill-shaped track with a circular thumb that translates between ends. **Inactive** reads as a `scrimSubtle` track with an `outlineVariant` hairline and a fixed-white thumb; **active** paints the track in `primary` so the contract reads chromatically without an inline label.
|
|
4
|
+
|
|
5
|
+
**Reach for this when** a setting commits the moment it changes — notifications, privacy toggles, *show in feed*, instant-commit list trailing. **Skip when** the commit needs confirmation (use [Button](../button/button.md) + [Dialog](../dialog/dialog.md)), when the user picks one of several options ([List/radio](../list/radio.md), [Tabs](../tabs/tabs.md)), or when destructive — Switch carries no undo.
|
|
6
|
+
|
|
7
|
+
**Layout inset.** inline — ships no padding. Sits next to its label with at least `sys.layout.inline.md` (12px) gap; the host surface pays surrounding padding.
|
|
8
|
+
|
|
9
|
+
## Inactive
|
|
10
|
+
|
|
11
|
+
The resting state — a `scrimSubtle` track (inverse-tone ~8% tint: black in light, white in dark) with an `outlineVariant` hairline and a fixed-white thumb at the leading end. The tint stays distinct on any host surface tier. The specimen below is pinned to this type (rendered `checked={false}`); see [Behavior](#behavior) for the live toggle contract.
|
|
12
|
+
|
|
13
|
+
```preview
|
|
14
|
+
switch/inactive
|
|
15
|
+
---
|
|
16
|
+
import { Switch } from '@teamblind-chorus/ui';
|
|
17
|
+
|
|
18
|
+
<Switch checked={false} aria-label="Notifications" />
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Active
|
|
22
|
+
|
|
23
|
+
The committed state — `primary` track with the thumb stepped to `onPrimary` at the trailing end. The outline disappears so the filled track reads as one solid block. Like the inactive specimen, this one is pinned to its type (rendered `checked`).
|
|
24
|
+
|
|
25
|
+
```preview
|
|
26
|
+
switch/active
|
|
27
|
+
---
|
|
28
|
+
import { Switch } from '@teamblind-chorus/ui';
|
|
29
|
+
|
|
30
|
+
<Switch checked aria-label="Notifications" />
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Use cases
|
|
34
|
+
|
|
35
|
+
### With label
|
|
36
|
+
|
|
37
|
+
The canonical pairing — a visible label to the left, `sys.layout.inline.md` (12px) gap. The label carries the accessible name via `htmlFor` + `id` or `aria-labelledby`; Switch drops `aria-label`.
|
|
38
|
+
|
|
39
|
+
```preview
|
|
40
|
+
switch/with-label
|
|
41
|
+
---
|
|
42
|
+
import { Switch } from '@teamblind-chorus/ui';
|
|
43
|
+
|
|
44
|
+
<div
|
|
45
|
+
style={{
|
|
46
|
+
display: 'flex',
|
|
47
|
+
alignItems: 'center',
|
|
48
|
+
justifyContent: 'space-between',
|
|
49
|
+
gap: 'var(--sys-layout-inline-md)',
|
|
50
|
+
padding: 'var(--sys-layout-container-xs) var(--sys-layout-container-md)',
|
|
51
|
+
}}
|
|
52
|
+
>
|
|
53
|
+
<span id="notif-label" className="sys-typo-body-sm" style={{ color: 'var(--sys-color-onSurface)' }}>
|
|
54
|
+
Push notifications
|
|
55
|
+
</span>
|
|
56
|
+
<Switch checked aria-labelledby="notif-label" />
|
|
57
|
+
</div>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Focus indicator
|
|
61
|
+
|
|
62
|
+
Outward 3-layer ring on the track's outer edge, shown on the inactive specimen. The card is pinned to its focused state via `forcedState="focused"`; in production the ring triggers on `:focus-visible` (keyboard / programmatic focus, never a plain mouse click).
|
|
63
|
+
|
|
64
|
+
```preview
|
|
65
|
+
switch/focused
|
|
66
|
+
---
|
|
67
|
+
import { Switch } from '@teamblind-chorus/ui';
|
|
68
|
+
|
|
69
|
+
<Switch checked={false} forcedState="focused" aria-label="Notifications" />
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Slots
|
|
73
|
+
|
|
74
|
+
- **track** — pill container, 52 × 32, fully rounded. Carries click, focus ring, and overlay paint. `role="switch"`; `aria-checked` reflects active/inactive.
|
|
75
|
+
- **thumb** *(decorative)* — 28 × 28 knob that translates between ends on toggle. Never a separate hit target.
|
|
76
|
+
|
|
77
|
+
## Anatomy
|
|
78
|
+
|
|
79
|
+
| Slot | Token bindings |
|
|
80
|
+
|------------------|----------------|
|
|
81
|
+
| track (inactive) | `sys.color.scrimSubtle` fill, hairline `outlineVariant` stroke, fully rounded |
|
|
82
|
+
| track (active) | `sys.color.primary` fill, no stroke, fully rounded |
|
|
83
|
+
| thumb (inactive) | `ref.palette.white.1000` (fixed white) fill, 28 × 28, fully rounded, 2px inset from leading edge |
|
|
84
|
+
| thumb (active) | `sys.color.onPrimary` fill, 28 × 28, translated 20px to the trailing end |
|
|
85
|
+
| transition | 120ms `ease-out` on track-fill, thumb-fill, and thumb-translate |
|
|
86
|
+
|
|
87
|
+
## Appearance
|
|
88
|
+
|
|
89
|
+
A single appearance — no emphasis axis. The visible variation is the `data-state="active"` / `"inactive"` contract.
|
|
90
|
+
|
|
91
|
+
| State | Track fill | Track stroke | Thumb fill |
|
|
92
|
+
|----------|---------------------|---------------------------|----------------------------------------|
|
|
93
|
+
| inactive | `sys.color.scrimSubtle` | `outlineVariant` hairline | `ref.palette.white.1000` (fixed white) |
|
|
94
|
+
| active | `sys.color.primary` | none | `sys.color.onPrimary` |
|
|
95
|
+
|
|
96
|
+
## States
|
|
97
|
+
|
|
98
|
+
| State | Overlay | Additional |
|
|
99
|
+
|------------|-----------------------------------|------------|
|
|
100
|
+
| `default` | — | Resting paint per the active/inactive contract. |
|
|
101
|
+
| `hovered` | label tone at `sys.state.hover` | Overlay paints across the track. |
|
|
102
|
+
| `pressed` | label tone at `sys.state.pressed` | Overlay deepens; no other shift. |
|
|
103
|
+
| `disabled` | overlay suppressed | Container at `sys.state.disabled` opacity; `pointer-events: none`. |
|
|
104
|
+
|
|
105
|
+
## Focus indicator
|
|
106
|
+
|
|
107
|
+
Outward 3-layer ring on the track's outer edge via an `::after` overlay. Trigger: `:focus-visible`. Switch sits inline next to siblings with whitespace around it, so outward reads cleanly — see [Focus ring composition](../../DESIGN.md#focus-ring-composition).
|
|
108
|
+
|
|
109
|
+
## Behavior
|
|
110
|
+
|
|
111
|
+
- **Instant commit.** `onCheckedChange` fires the moment the user releases — no confirmation step.
|
|
112
|
+
- **Whole track is the click target.** The thumb is decorative; it never receives focus or events.
|
|
113
|
+
- **Keyboard.** Space and Enter both toggle when Switch holds focus.
|
|
114
|
+
- **Controlled / uncontrolled.** Pass `checked` + `onCheckedChange`, or `defaultChecked` to let Switch own state.
|