@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,96 @@
|
|
|
1
|
+
# Search
|
|
2
|
+
|
|
3
|
+
The search top bar — anchored to a dedicated search page reached from a [Main](./main.md) or [Sub](./sub.md) bar's search trigger. Owns the entire search affordance: leading back-arrow Icon Button, single bare-text input filling the row, conditional trailing clear (*×*). Drops the centred title — the input *is* the focus.
|
|
4
|
+
|
|
5
|
+
**Reach for this when** the entire screen exists to capture a query and show results. **Skip when** search is one affordance among several on a tab root (use [Main](./main.md) with a search trailing action) or when the field lives in-line with content (use the [search](../form-field/search.md) form-field).
|
|
6
|
+
|
|
7
|
+
**Layout inset.** `full-bleed` — direct child of the page shell. The bar pays its own `16px inline` padding via `layout.container.*`; do **not** wrap it in another `padding-inline` / `px-*` / `style={{ padding: … }}` div. Inside a bounded surface, apply the negative-margin opt-out — see [`AGENTS.md` § Composition rules](../../../AGENTS.md#composition-rules).
|
|
8
|
+
|
|
9
|
+
## Default
|
|
10
|
+
|
|
11
|
+
At rest with an empty field. Placeholder paints in `outline`; the clear (*×*) stays hidden until a value lands.
|
|
12
|
+
|
|
13
|
+
```preview
|
|
14
|
+
navigation-bar/search/default
|
|
15
|
+
---
|
|
16
|
+
import { NavigationBar } from '@teamblind-chorus/ui';
|
|
17
|
+
|
|
18
|
+
<NavigationBar variant="search" placeholder="Search by keyword" onBack={() => {}} />
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Use cases
|
|
22
|
+
|
|
23
|
+
### With value (clear visible)
|
|
24
|
+
|
|
25
|
+
A non-empty value swaps placeholder for `onSurface` text and reveals the trailing clear (*×*) at the medium 32 × 32 capsule — smaller than the leading back-arrow so it never out-shouts the input. Clicking clear wipes the value, returns focus, and the trailing column collapses; the input's leading edge stays pixel-stable.
|
|
26
|
+
|
|
27
|
+
```preview
|
|
28
|
+
navigation-bar/search/with-value
|
|
29
|
+
---
|
|
30
|
+
import { NavigationBar } from '@teamblind-chorus/ui';
|
|
31
|
+
|
|
32
|
+
<NavigationBar
|
|
33
|
+
variant="search"
|
|
34
|
+
placeholder="Search by keyword"
|
|
35
|
+
defaultValue="lighting"
|
|
36
|
+
onBack={() => {}}
|
|
37
|
+
/>
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Slots
|
|
41
|
+
|
|
42
|
+
- **leading** *(required)* — 24px back-arrow as the canonical [Icon Button](../button/icon.md) capsule (40 × 40 transparent, 24px glyph).
|
|
43
|
+
- **input** *(required)* — single-line *bare* text input filling the leftover middle column. Bare means no border, no background, no inset stroke — not a [Search](../form-field/search.md) field. Value in `sys.color.onSurface`, placeholder in `sys.color.outline` (`typo.body.md`, 16/Regular). Caret follows the [system caret rule](../../DESIGN.md#caret).
|
|
44
|
+
- **trailing** *(conditional)* — clear (*×*) [Icon Button](../button/icon.md) hosting `XCircleFillIcon`. Always Icon Button's `medium` size (32 × 32 capsule, 16px glyph) so it never over-claims weight against the bare input. Rendered only when value is non-empty; wipes value and returns focus.
|
|
45
|
+
|
|
46
|
+
## Anatomy
|
|
47
|
+
|
|
48
|
+
Three-column grid (leading / input / trailing) — side columns size to content, input column takes `minmax(0, 1fr)`. When the trailing column collapses, the input column expands; the field never reflows its leading edge.
|
|
49
|
+
|
|
50
|
+
| Slot | Container | Color |
|
|
51
|
+
|-----------------------|------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------|
|
|
52
|
+
| **Bar container** | `sys.color.surface` fill, 8px block / 16px inline padding, no shadow at rest, **1px bottom divider in `sys.color.outlineVariant`** painted as inset `box-shadow`. | — |
|
|
53
|
+
| **Leading** | Transparent Icon Button capsule (8 padding around 24px glyph). | `sys.color.onSurface` |
|
|
54
|
+
| **Input** | Bare text — no border, no background, no inset stroke. | Value: `sys.color.onSurface`. Placeholder: `sys.color.outline`. Caret: `sys.color.primary`. |
|
|
55
|
+
| **Trailing (clear)** | Transparent Icon Button **medium** capsule (32 × 32, 16px glyph). | `sys.color.onSurface` |
|
|
56
|
+
|
|
57
|
+
The 1px bottom divider (unique to Search) keeps the bare input from bleeding into the results list below; painted as inset `box-shadow` so it never participates in layout.
|
|
58
|
+
|
|
59
|
+
## Sizes
|
|
60
|
+
|
|
61
|
+
A single fixed rung. Same geometry as Page.
|
|
62
|
+
|
|
63
|
+
| Property | Value | Token |
|
|
64
|
+
|-----------------------------------|----------------------|-------------------------------------|
|
|
65
|
+
| Container padding (block × inline)| 8 × 16 | `sys.layout.container.xs` × `sys.layout.container.md` |
|
|
66
|
+
| Min-height | 56px | raw |
|
|
67
|
+
| Slot gap (between slots) | 16px | `sys.layout.inline.xl` |
|
|
68
|
+
| Icon-capsule padding | 8px | `sys.layout.container.xs` |
|
|
69
|
+
| Input text | 16 / Regular | `sys.typo.body.md` |
|
|
70
|
+
| Leading icon | 24px | `sys.icon.lg` |
|
|
71
|
+
| Trailing (clear) icon | 16px (32 × 32 capsule)| `sys.icon.md` ‡ |
|
|
72
|
+
|
|
73
|
+
‡ Clear button uses Icon Button's `medium` size, not `xlarge` — clear is a secondary affordance to typing.
|
|
74
|
+
|
|
75
|
+
## States
|
|
76
|
+
|
|
77
|
+
The bar has no interactive state of its own. Leading and trailing inherit [Icon Button](../button/icon.md)'s recipe. The input follows the bare-text-field shape — only the caret and placeholder ↔ value colour swap signal interaction; no overlay because the field has no chrome to tint.
|
|
78
|
+
|
|
79
|
+
| State | Overlay opacity | Additional |
|
|
80
|
+
|------------|----------------------------|-----------------------------------------------------------------------------|
|
|
81
|
+
| `default` | — | Caret hidden. Empty → placeholder in `outline`; value → `onSurface`. |
|
|
82
|
+
|
|
83
|
+
**No `disabled` state.** The only screen this bar ever lives on is the search results page itself; gating it there reduces the surface to a dead chrome strip with no escape beyond the back-arrow. If search must be gated (offline, throttled, paused indexing), gate the *trigger* on the prior screen instead.
|
|
84
|
+
|
|
85
|
+
## Focus indicator
|
|
86
|
+
|
|
87
|
+
Caret paints per the [system caret rule](../../DESIGN.md#caret). Field has no focus-ring of its own — the bar *is* the page's focus; browser's default focus stays as accessibility floor. Trigger: `:focus-visible`.
|
|
88
|
+
|
|
89
|
+
## Behavior
|
|
90
|
+
|
|
91
|
+
- **Submit on Enter.** Fires `onSubmit(value)`; wrapping `<form>` submits.
|
|
92
|
+
- **Clear visibility.** Clear button in DOM only when value is non-empty. Click sets value to `''`, returns focus, fires `onChange('')`.
|
|
93
|
+
- **Escape key.** Clears non-empty value before firing `onBack` on empty — matches platform convention.
|
|
94
|
+
- **Back affordance.** Leading back-arrow fires `onBack`. Host owns routing.
|
|
95
|
+
- **Auto-focus.** `autoFocus` defaults to `true` — set `false` for hydrated queries the user is reviewing.
|
|
96
|
+
- **IME.** Handles composition normally — `onChange` on every keystroke; `onSubmit` only on real Enter (not IME-confirm Enter).
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../spec.schema.json",
|
|
3
|
+
"name": "NavigationBar",
|
|
4
|
+
"family": "navigation-bar",
|
|
5
|
+
"subcomponent": "search",
|
|
6
|
+
"description": "Search top bar — anchored to a dedicated search page reached from a Home or Page bar's search trigger. The bar owns the entire search affordance: a leading back-arrow, a single bare-text input that fills the row, and a trailing clear (×) button that wipes the value. Drops the centred title slot Page uses — the input *is* the focus of the screen, and a separate title would compete with the placeholder for the same row. Same 56 min-height and 8 (block) / 16 (inline) padding as Home / Page, so the bar stays at one geometry across a navigation flow (Home → Search → results).",
|
|
7
|
+
"element": "header",
|
|
8
|
+
"props": {
|
|
9
|
+
"variant": {
|
|
10
|
+
"type": "literal",
|
|
11
|
+
"value": "search"
|
|
12
|
+
},
|
|
13
|
+
"value": {
|
|
14
|
+
"type": "string",
|
|
15
|
+
"optional": true,
|
|
16
|
+
"description": "Controlled value. Omit (and pass `defaultValue` instead) for an uncontrolled field."
|
|
17
|
+
},
|
|
18
|
+
"defaultValue": {
|
|
19
|
+
"type": "string",
|
|
20
|
+
"optional": true
|
|
21
|
+
},
|
|
22
|
+
"placeholder": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"default": "Search by keyword",
|
|
25
|
+
"description": "Faint guide text shown when the value is empty. Sentence case, typically a one-line invitation (`Search by keyword`, `Search channels`)."
|
|
26
|
+
},
|
|
27
|
+
"onChange": {
|
|
28
|
+
"type": "function",
|
|
29
|
+
"optional": true,
|
|
30
|
+
"description": "Called on every keystroke with the new value. Host owns the typeahead / submit pipeline."
|
|
31
|
+
},
|
|
32
|
+
"onSubmit": {
|
|
33
|
+
"type": "function",
|
|
34
|
+
"optional": true,
|
|
35
|
+
"description": "Called when the user commits the query (Enter / form submit). Host wires the route or query side-effect."
|
|
36
|
+
},
|
|
37
|
+
"onBack": {
|
|
38
|
+
"type": "function",
|
|
39
|
+
"required": true,
|
|
40
|
+
"description": "Wires the leading back-arrow to the host's back action — the bar does not own routing."
|
|
41
|
+
},
|
|
42
|
+
"backLabel": {
|
|
43
|
+
"type": "string",
|
|
44
|
+
"default": "Back",
|
|
45
|
+
"description": "aria-label for the leading back-arrow icon button."
|
|
46
|
+
},
|
|
47
|
+
"clearLabel": {
|
|
48
|
+
"type": "string",
|
|
49
|
+
"default": "Clear search",
|
|
50
|
+
"description": "aria-label for the trailing clear (×) button."
|
|
51
|
+
},
|
|
52
|
+
"autoFocus": {
|
|
53
|
+
"type": "boolean",
|
|
54
|
+
"default": true,
|
|
55
|
+
"description": "Defaults to true because the bar is the search page's primary surface — the user arrived here to type. Set to false when the field receives a hydrated query that the user is reviewing rather than editing."
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"slots": {
|
|
59
|
+
"leading": {
|
|
60
|
+
"required": true,
|
|
61
|
+
"description": "Back-arrow Icon Button (24px glyph in a 40 × 40 transparent capsule). Same anatomy as Page's leading slot — see [navigation-bar.md § Cross-sub contract](./navigation-bar.md#cross-sub-contract). Required because the search bar is always reached from another bar; there is no non-dismissible search screen.",
|
|
62
|
+
"accepts": [
|
|
63
|
+
"button"
|
|
64
|
+
]
|
|
65
|
+
},
|
|
66
|
+
"input": {
|
|
67
|
+
"required": true,
|
|
68
|
+
"description": "Single-line bare text input that fills the leftover middle column. Bare = no `border`, no `background`, no inset stroke — it is *not* a [Search bar](../form-field/search.md) field. Renders the value in `sys.color.onSurface` when present; renders the placeholder in `sys.color.outline` when empty. `body.md` (16/Regular). The bar carries the visible search affordance via the placeholder and the page context; the field itself has no chrome of its own.",
|
|
69
|
+
"accepts": [
|
|
70
|
+
"text"
|
|
71
|
+
]
|
|
72
|
+
},
|
|
73
|
+
"trailing": {
|
|
74
|
+
"required": false,
|
|
75
|
+
"description": "Clear (×) Icon Button hosting `XCircleFillIcon`. **Always uses Icon Button's `medium` size (32 × 32 capsule, 16px / `sys.icon.md` glyph)** — not the 40 × 40 / 24-glyph `large` size the leading back arrow uses. The clear is a secondary affordance (the user's primary act on this bar is typing); the smaller capsule keeps the clear from over-claiming weight against the bare input next to it. Rendered only when the value is non-empty — wipes the value, keeps focus on the input, and disappears the moment the value goes back to empty. Differs from [Search bar](../form-field/search.md)'s clear which gates on focus too: this bar's host page is the search context, so a non-empty value is enough — there is no blurred state to hide it for.",
|
|
76
|
+
"accepts": [
|
|
77
|
+
"button"
|
|
78
|
+
]
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
"sizing": {
|
|
82
|
+
"containerPaddingBlock": "sys.layout.container.xs",
|
|
83
|
+
"containerPaddingBlockTop": "calc(env(safe-area-inset-top, 0px) + sys.layout.container.xs)",
|
|
84
|
+
"containerPaddingInline": "sys.layout.container.md",
|
|
85
|
+
"minHeight": "56px",
|
|
86
|
+
"viewportSafeArea": "Bar's block-top padding stacks env(safe-area-inset-top) on top of container.xs so the surface fill extends through the device status-bar / notch zone — scrolled body content does not bleed through the transparent system chrome. Bar is the owner of the viewport-top inset; page shell MUST NOT re-pay it via its own padding-top.",
|
|
87
|
+
"slotGap": "sys.layout.inline.xl",
|
|
88
|
+
"iconCapsulePadding": "sys.layout.container.xs",
|
|
89
|
+
"inputTypo": "sys.typo.body.md",
|
|
90
|
+
"leadingIconSize": "sys.icon.lg",
|
|
91
|
+
"trailingIconSize": "sys.icon.md"
|
|
92
|
+
},
|
|
93
|
+
"appearance": {
|
|
94
|
+
"containerFill": "sys.color.surface",
|
|
95
|
+
"containerBottomDivider": "sys.color.outlineVariant",
|
|
96
|
+
"leadingColor": "sys.color.onSurface",
|
|
97
|
+
"inputText": "sys.color.onSurface",
|
|
98
|
+
"inputPlaceholder": "sys.color.outline",
|
|
99
|
+
"inputCaret": "sys.color.onSurface",
|
|
100
|
+
"trailingColor": "sys.color.onSurface"
|
|
101
|
+
},
|
|
102
|
+
"layout": {
|
|
103
|
+
"grid": "Three-column grid: leading / input / trailing. Side columns size to content (auto); the input column takes the leftover space (`minmax(0, 1fr)`). Same shape as Page's grid — only the centre slot's contents differ (a bare input instead of a centred title). When the trailing column collapses (value empty → clear hidden), the input column expands to consume the freed space; the field never reflows its leading edge.",
|
|
104
|
+
"noTitleSlot": "There is no separate title slot; the input fills the role the Page bar would give the title. Adding a title above or beside the input would compete with the placeholder for the search affordance and break the bar's 56 footprint."
|
|
105
|
+
},
|
|
106
|
+
"states": {
|
|
107
|
+
"note": "The bar carries no interactive state of its own. The leading and trailing slots inherit Icon Button's recipe (default / hovered / pressed / focused — overlays + three-layer focus ring). The Search bar deliberately omits a `disabled` state: the only screen a `navigation-bar/search` ever lives on is the search results page itself, and a non-typable search bar on that page reduces the surface to a dead chrome strip with no escape affordance beyond the back-arrow. If search must be gated (offline / throttled / paused indexing), gate the *trigger* on the prior screen instead and never route into this bar. The input slot follows the bare-text-field shape — no border, no fill, no rest-vs-active stroke; only the caret and the placeholder→value colour swap signal interaction. See `inputStates` for the input-only details.",
|
|
108
|
+
"inputStates": {
|
|
109
|
+
"default": {
|
|
110
|
+
"caret": "hidden",
|
|
111
|
+
"value": "outline placeholder when empty, onSurface text when present"
|
|
112
|
+
},
|
|
113
|
+
"focused": {
|
|
114
|
+
"caret": "onSurface, per [DESIGN.md → Caret](../../DESIGN.md#caret) — same ink as the value text. The system rule binds caret colour only; intended 2px / 0.75× / rounded-ends geometry is design intent, browser-painted.",
|
|
115
|
+
"ring": "Field has no focus-ring of its own — the bar is the page's focus. Browsers will still paint their default focus indicator if the user tabs into the input from outside; we keep that as the accessibility floor and do not suppress it.",
|
|
116
|
+
"note": "Default state on this bar — `autoFocus` defaults to true so the user arrives in the focused state."
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
"focusIndicator": {
|
|
121
|
+
"description": "The bar itself isn't a focus target. Leading back-arrow and trailing clear inherit Icon Button's outward focus composition; the bare input renders no ring of its own and falls through to the browser's default focus indicator (the platform accessibility floor). See [Icon Button → Focus indicator](../button/icon.md#focus-indicator).",
|
|
122
|
+
"composition": "delegated",
|
|
123
|
+
"delegatesTo": "../button/icon.spec.json#/focusIndicator",
|
|
124
|
+
"inputNote": "The bare input has no field-level ring; browser default focus indicator is intentionally not suppressed so keyboard users still see arrival.",
|
|
125
|
+
"trigger": ":focus-visible (keyboard / programmatic focus, never plain mouse click)"
|
|
126
|
+
},
|
|
127
|
+
"behavior": {
|
|
128
|
+
"submitOnEnter": "Pressing Enter inside the input fires `onSubmit(value)` (and the surrounding `<form>` submits if one wraps the bar). Host wires the route or the typeahead commit.",
|
|
129
|
+
"clearVisibility": "The clear button is in the DOM only when the value is non-empty. Clicking clear sets the value to '', returns focus to the input, and fires `onChange('')`.",
|
|
130
|
+
"escapeKey": "Pressing Escape inside the input clears the value (when non-empty) before firing `onBack` (when already empty) — matches the platform convention that Esc 'undoes' the current input before dismissing the surface.",
|
|
131
|
+
"backAffordance": "Leading back-arrow fires `onBack`. The bar does not own routing — host wires the navigation. The keyboard equivalent is Esc on an empty value.",
|
|
132
|
+
"noTitle": "The bar deliberately omits a title. The search context is established by the page the user arrived on (a search route reached from a search trigger), and the placeholder names the field's intent. A separate title would compete for the same row.",
|
|
133
|
+
"ime": "Input handles IME composition normally — `onChange` fires on every keystroke including composition events; `onSubmit` fires only on a real Enter (not on the IME-confirm Enter that closes a composition)."
|
|
134
|
+
},
|
|
135
|
+
"forbidden": [
|
|
136
|
+
"title bar height different from 56px",
|
|
137
|
+
"second top bar stacked above it — the search variant already owns the back affordance, no page bar above",
|
|
138
|
+
"input rendered outside the embedded slot — the input fills the bar, not a separate region",
|
|
139
|
+
"brand color on the input chrome",
|
|
140
|
+
"`disabled` prop on the Search variant — gating belongs on the trigger that routes to this bar, not on the bar itself"
|
|
141
|
+
]
|
|
142
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# Sub
|
|
2
|
+
|
|
3
|
+
The drill-in top bar — anchored to every screen one step inside a flow. Centred page name, flanked by two independent side slots — each optionally a [Text Button](../button/text.md), an [Icon Button](../button/icon.md), or a [Toolbar Button](../button/toolbar.md) / text link, chosen per side. The title itself is optional too: omit it when the side actions say everything (composer bars). Title drops from Main's `typo.heading.lg` to `typo.heading.sm` (16/Semibold).
|
|
4
|
+
|
|
5
|
+
**Reach for this when** the screen is one step inside a flow and needs a back affordance plus a single primary action. **Skip when** you're on a tab root (use [Main](./main.md)) or a dedicated search page (use [Search](./search.md)).
|
|
6
|
+
|
|
7
|
+
**Layout inset.** `full-bleed` — direct child of the page shell. The bar pays its own `16px inline / 8px block` padding via `layout.container.*`; do **not** wrap it 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
|
+
Back-arrow leading and a *primary*-toned "Save" [Toolbar Button](../button/toolbar.md) trailing.
|
|
12
|
+
|
|
13
|
+
```preview
|
|
14
|
+
navigation-bar/sub/default
|
|
15
|
+
---
|
|
16
|
+
import { NavigationBar, Button } from '@teamblind-chorus/ui';
|
|
17
|
+
|
|
18
|
+
<NavigationBar
|
|
19
|
+
variant="sub"
|
|
20
|
+
title="Edit profile"
|
|
21
|
+
leading={{ icon: <ChevronLeftIcon />, 'aria-label': 'Back' }}
|
|
22
|
+
trailing={<Button variant="toolbar" appearance="accent">Save</Button>}
|
|
23
|
+
/>
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Use cases
|
|
27
|
+
|
|
28
|
+
### With icon trailing
|
|
29
|
+
|
|
30
|
+
The trailing slot carries a single icon.
|
|
31
|
+
|
|
32
|
+
```preview
|
|
33
|
+
navigation-bar/sub/icon-trailing
|
|
34
|
+
---
|
|
35
|
+
import { NavigationBar } from '@teamblind-chorus/ui';
|
|
36
|
+
|
|
37
|
+
<NavigationBar
|
|
38
|
+
variant="sub"
|
|
39
|
+
title="Thread"
|
|
40
|
+
leading={{ icon: <ChevronLeftIcon />, 'aria-label': 'Back' }}
|
|
41
|
+
trailing={{ icon: <EllipsisHorizontalIcon />, 'aria-label': 'More' }}
|
|
42
|
+
/>
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### With text button trailing
|
|
46
|
+
|
|
47
|
+
The trailing slot carries a [Text Button](../button/text.md) — *Skip* or *Done*. Reads as inline 16/Semibold `primary` type at rest.
|
|
48
|
+
|
|
49
|
+
```preview
|
|
50
|
+
navigation-bar/sub/link-trailing
|
|
51
|
+
---
|
|
52
|
+
import { NavigationBar } from '@teamblind-chorus/ui';
|
|
53
|
+
|
|
54
|
+
<NavigationBar
|
|
55
|
+
variant="sub"
|
|
56
|
+
title="Pick your interests"
|
|
57
|
+
leading={{ icon: <ChevronLeftIcon />, 'aria-label': 'Back' }}
|
|
58
|
+
trailing={{ label: 'Skip', href: '#skip' }}
|
|
59
|
+
/>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Text button pair (composer)
|
|
63
|
+
|
|
64
|
+
Modal composer bars carry a [Text Button](../button/text.md) on **both** sides — leading `default` appearance for the dismissing *Cancel*, trailing `accent` for the committing *Post* — and **omit the page title**: the pair says everything, and the centre cell keeps a non-heading placeholder so both buttons hold their edge columns. The accent side is the single CTA of the bar; never paint both sides accent, and never swap the sides (commit always trails).
|
|
65
|
+
|
|
66
|
+
```preview
|
|
67
|
+
navigation-bar/sub/text-button-pair
|
|
68
|
+
---
|
|
69
|
+
import { Button, NavigationBar } from '@teamblind-chorus/ui';
|
|
70
|
+
|
|
71
|
+
<NavigationBar
|
|
72
|
+
variant="sub"
|
|
73
|
+
leading={<Button variant="text">Cancel</Button>}
|
|
74
|
+
trailing={<Button variant="text" appearance="accent">Post</Button>}
|
|
75
|
+
/>
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### External page (close-only)
|
|
79
|
+
|
|
80
|
+
External content visited in-app (embedded webview, in-app browser). Leading drops — no flow to step back; trailing is a single close (×) [Icon Button](../button/icon.md).
|
|
81
|
+
|
|
82
|
+
```preview
|
|
83
|
+
navigation-bar/sub/external-page
|
|
84
|
+
---
|
|
85
|
+
import { NavigationBar } from '@teamblind-chorus/ui';
|
|
86
|
+
|
|
87
|
+
<NavigationBar
|
|
88
|
+
variant="sub"
|
|
89
|
+
title="Help center"
|
|
90
|
+
trailing={{ icon: <XIcon />, 'aria-label': 'Close' }}
|
|
91
|
+
/>
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Title only
|
|
95
|
+
|
|
96
|
+
Both side slots empty — for non-dismissible sub-pages (forced confirmation, terms gate).
|
|
97
|
+
|
|
98
|
+
```preview
|
|
99
|
+
navigation-bar/sub/title-only
|
|
100
|
+
---
|
|
101
|
+
import { NavigationBar } from '@teamblind-chorus/ui';
|
|
102
|
+
|
|
103
|
+
<NavigationBar
|
|
104
|
+
variant="sub"
|
|
105
|
+
title="Terms of service"
|
|
106
|
+
/>
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Overlay (on hero / cover image)
|
|
110
|
+
|
|
111
|
+
`appearance="overlay"` paints a transparent container with fixed-white icons and title — the bar floats over a hero / cover image (canonical host: [Profile header](../profile-header/profile-header.md)). The image beneath provides contrast; theme tokens don't apply. The bar still pays its own `env(safe-area-inset-top)` so the status-bar zone reads cleanly. Staged here via [ProfileHeader](../profile-header/profile-header.md) so the preview matches a real-world consumer.
|
|
112
|
+
|
|
113
|
+
```preview
|
|
114
|
+
navigation-bar/sub/overlay
|
|
115
|
+
---
|
|
116
|
+
import { ProfileHeader } from '@teamblind-chorus/ui';
|
|
117
|
+
|
|
118
|
+
<ProfileHeader
|
|
119
|
+
name="General Topic"
|
|
120
|
+
avatar={{ src: '/avatar.png', alt: 'General Topic' }}
|
|
121
|
+
cover={{ src: '/cover.jpg', alt: 'Forest skyline at dusk' }}
|
|
122
|
+
visibility="public"
|
|
123
|
+
followers="999 followers"
|
|
124
|
+
/>
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Slots
|
|
128
|
+
|
|
129
|
+
- **leading** *(optional)* — typically a 24px back-arrow (transparent icon capsule), but a [Text Button](../button/text.md) is an equally first-class fill (*Cancel* on composer bars). Chosen independently of the trailing slot.
|
|
130
|
+
- **title** *(optional)* — page's name. Single line, `typo.heading.sm` (16/Semibold) `onSurface`, centred; ellipsis on narrow. Omit it for bars whose side actions carry the meaning (canonical: the composer pair) — the centre cell keeps a non-heading placeholder so the side slots hold their columns.
|
|
131
|
+
- **trailing** *(optional)* — exactly one affordance, chosen independently of the leading slot. Either a single 24px icon (transparent capsule — prefer the object form `trailing={{ icon, 'aria-label' }}` so the 24px Icon Button is auto-rendered; a raw `<Button variant="icon" />` must carry `size="large"`), a [Toolbar Button](../button/toolbar.md) with a label (*Save*, *Done*, *Edit*), or a [Text Button](../button/text.md) sized at `typo.heading.sm`.
|
|
132
|
+
|
|
133
|
+
## Anatomy
|
|
134
|
+
|
|
135
|
+
| Slot | Container | Color |
|
|
136
|
+
|-----------------------|--------------------|------------------------------------------|
|
|
137
|
+
| **Bar container** | `sys.color.surface` fill, 8px block / 16px inline padding, no border, no shadow at rest. | — |
|
|
138
|
+
| **Leading** | Transparent icon capsule (8 padding around 24px glyph). | `sys.color.onSurface` |
|
|
139
|
+
| **Title** | Plain text, centred horizontally — not interactive. | `sys.color.onSurface` |
|
|
140
|
+
| **Trailing** | Transparent icon capsule, [Toolbar Button](../button/toolbar.md), or [Text Button](../button/text.md). | `sys.color.onSurface` (icon / Toolbar) or `sys.color.primary` (Text Button) |
|
|
141
|
+
|
|
142
|
+
## Sizes
|
|
143
|
+
|
|
144
|
+
A single fixed rung.
|
|
145
|
+
|
|
146
|
+
| Property | Value | Token |
|
|
147
|
+
|-----------------------------------|----------------------|-------------------------------------|
|
|
148
|
+
| Container padding (block × inline)| 8 × 16 | `sys.layout.container.xs` × `sys.layout.container.md` |
|
|
149
|
+
| Min-height | 56px | raw — matches Main ‡ |
|
|
150
|
+
| Slot gap (between slots) | 16px | `sys.layout.inline.xl` |
|
|
151
|
+
| Icon-capsule padding | 8px | `sys.layout.container.xs` |
|
|
152
|
+
| Title | 16 / Semibold | `sys.typo.heading.sm` |
|
|
153
|
+
| Leading icon | 24px | `sys.icon.lg` |
|
|
154
|
+
| Trailing icon | 24px | `sys.icon.lg` |
|
|
155
|
+
|
|
156
|
+
‡ Same 56 footprint as Main — keeps bar height constant across a flow. Natural height 8 + 40 + 8 = 56.
|
|
157
|
+
|
|
158
|
+
## Layout
|
|
159
|
+
|
|
160
|
+
Three-column grid (leading / title / trailing) sized **`1fr auto 1fr`** — equal side regions, so the centre `auto` column sits at the bar's geometric horizontal centre regardless of side-slot widths. The trailing column collapses to zero when empty.
|
|
161
|
+
|
|
162
|
+
## States
|
|
163
|
+
|
|
164
|
+
The bar has no interactive state. Side slots inherit the recipe of whichever control they host — [Icon Button](../button/icon.md), [Toolbar Button](../button/toolbar.md), or [Text Button](../button/text.md). Title carries no states.
|
|
165
|
+
|
|
166
|
+
## Focus indicator
|
|
167
|
+
|
|
168
|
+
Bar isn't a focus target; side slots inherit each control's own focus composition. Trigger: `:focus-visible`.
|
|
169
|
+
|
|
170
|
+
## Behavior
|
|
171
|
+
|
|
172
|
+
- **Title centring.** `1fr auto 1fr` pins the title to the geometric centre — neither side slot can push it off-axis.
|
|
173
|
+
- **Title truncation.** Long names truncate with ellipsis; bar never grows past 56px. Safety net only.
|
|
174
|
+
- **Back affordance.** Leading slot is conventionally back-arrow; host wires `onClick` to the framework's back action.
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../spec.schema.json",
|
|
3
|
+
"name": "NavigationBar",
|
|
4
|
+
"family": "navigation-bar",
|
|
5
|
+
"subcomponent": "sub",
|
|
6
|
+
"description": "Drill-in top bar — anchored to every screen one step inside a flow rather than a tab root. The page name sits centred so the title reads as the focus of the screen; a leading slot (typically a back-arrow icon) sits at the row's left edge, and a trailing slot (an Icon Button, a Toolbar Button, or a Text Button) sits at the row's right edge. Title type drops from Main's heading.lg to heading.sm (16/Semibold) so the bar reads as page chrome at one density with the body beneath.",
|
|
7
|
+
"element": "header",
|
|
8
|
+
"props": {
|
|
9
|
+
"variant": {
|
|
10
|
+
"type": "literal",
|
|
11
|
+
"value": "sub"
|
|
12
|
+
},
|
|
13
|
+
"appearance": {
|
|
14
|
+
"type": "literal",
|
|
15
|
+
"values": [
|
|
16
|
+
"surface",
|
|
17
|
+
"overlay"
|
|
18
|
+
],
|
|
19
|
+
"default": "surface",
|
|
20
|
+
"description": "Container fill and foreground tone. `surface` (default) — opaque `sys.color.surface` fill, `onSurface` icons and title; the canonical page-chrome treatment. `overlay` — transparent container with **fixed white** icons (`ref.palette.white.1000`), used when the bar floats over a hero/cover image (e.g. inside [Profile header](../profile-header/profile-header.md)). In `overlay`, the title slot is intentionally muted — pass `title=\"\"` (empty string) when the host already carries page identity below the bar."
|
|
21
|
+
},
|
|
22
|
+
"title": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"optional": true,
|
|
25
|
+
"description": "Page name. Optional — omit it for bars whose side actions say everything (canonical: the composer text-button pair, Cancel / Post). A title-less bar renders a non-heading placeholder in the centre cell so the side slots stay in their 1fr/auto/1fr columns. Pass an empty string in `overlay` appearance when the host (e.g. ProfileHeader) carries the identity directly below the bar."
|
|
26
|
+
},
|
|
27
|
+
"leading": {
|
|
28
|
+
"type": "object",
|
|
29
|
+
"optional": true,
|
|
30
|
+
"description": "Optional leading affordance. Accepts the { icon, 'aria-label' } descriptor (renders the 24px Icon Button internally — typically a back-arrow), a { label, href } link descriptor, or a node. Text Button and Icon Button are both first-class fills: pick per side independently of the trailing slot — e.g. a default-appearance Text Button (Cancel) on composer bars."
|
|
31
|
+
},
|
|
32
|
+
"trailing": {
|
|
33
|
+
"type": "node",
|
|
34
|
+
"optional": true,
|
|
35
|
+
"description": "Optional trailing affordance — Icon Button / Toolbar Button / Text Button, chosen independently of the leading slot. **Prefer the object form `{ icon, 'aria-label' }` for the icon case** — the component then renders the 24px Icon Button internally and the `sys.icon.lg` contract is guaranteed. If a raw `<Button variant=\"icon\" />` node is passed instead, it MUST carry `size=\"large\"` (= `sys.icon.lg` / 24); `size=\"medium\"` resolves to `sys.icon.md` (16) and breaks symmetry with the leading slot."
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"slots": {
|
|
39
|
+
"leading": {
|
|
40
|
+
"required": false,
|
|
41
|
+
"description": "Typically a 24px back-arrow icon at the start of the row, rendered as a transparent icon capsule. Optional.",
|
|
42
|
+
"accepts": [
|
|
43
|
+
"icon",
|
|
44
|
+
"button"
|
|
45
|
+
]
|
|
46
|
+
},
|
|
47
|
+
"title": {
|
|
48
|
+
"required": true,
|
|
49
|
+
"description": "Page name. Single line, heading.sm / Semibold / onSurface, centred horizontally. Truncates with ellipsis.",
|
|
50
|
+
"accepts": [
|
|
51
|
+
"text"
|
|
52
|
+
]
|
|
53
|
+
},
|
|
54
|
+
"trailing": {
|
|
55
|
+
"required": false,
|
|
56
|
+
"description": "Exactly one affordance — Icon Button (24px icon), Toolbar Button (with label), or Text Button (primary 16/Semibold inline).",
|
|
57
|
+
"accepts": [
|
|
58
|
+
"icon",
|
|
59
|
+
"button"
|
|
60
|
+
]
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"sizing": {
|
|
64
|
+
"containerPaddingBlock": "sys.layout.container.xs",
|
|
65
|
+
"containerPaddingBlockTop": "calc(env(safe-area-inset-top, 0px) + sys.layout.container.xs)",
|
|
66
|
+
"containerPaddingInline": "sys.layout.container.md",
|
|
67
|
+
"minHeight": "56px",
|
|
68
|
+
"viewportSafeArea": "Bar's block-top padding stacks env(safe-area-inset-top) on top of container.xs so the surface fill extends through the device status-bar / notch zone — scrolled body content does not bleed through the transparent system chrome. Bar is the owner of the viewport-top inset; page shell MUST NOT re-pay it via its own padding-top.",
|
|
69
|
+
"slotGap": "sys.layout.inline.xl",
|
|
70
|
+
"iconCapsulePadding": "sys.layout.container.xs",
|
|
71
|
+
"titleTypo": "sys.typo.heading.sm",
|
|
72
|
+
"leadingIconSize": "sys.icon.lg",
|
|
73
|
+
"trailingIconSize": "sys.icon.lg"
|
|
74
|
+
},
|
|
75
|
+
"appearances": {
|
|
76
|
+
"surface": {
|
|
77
|
+
"containerFill": "sys.color.surface",
|
|
78
|
+
"leadingColor": "sys.color.onSurface",
|
|
79
|
+
"titleColor": "sys.color.onSurface",
|
|
80
|
+
"trailingColor": "sys.color.onSurface",
|
|
81
|
+
"trailingTextButtonColor": "sys.color.primary",
|
|
82
|
+
"default": true,
|
|
83
|
+
"note": "Canonical page chrome — opaque surface fill with onSurface foreground."
|
|
84
|
+
},
|
|
85
|
+
"overlay": {
|
|
86
|
+
"containerFill": "transparent",
|
|
87
|
+
"leadingColor": "ref.palette.white.1000",
|
|
88
|
+
"titleColor": "ref.palette.white.1000",
|
|
89
|
+
"trailingColor": "ref.palette.white.1000",
|
|
90
|
+
"trailingTextButtonColor": "ref.palette.white.1000",
|
|
91
|
+
"note": "Floats over a hero / cover image (e.g. inside [Profile header](../profile-header/profile-header.md)). Container is transparent; icons paint a fixed white tone regardless of theme — the image beneath provides the contrast, not the active surface tier. The bar still pays its own `env(safe-area-inset-top)` so the status bar zone reads cleanly against the image."
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
"layout": {
|
|
95
|
+
"grid": "Three-column grid: leading / title / trailing, sized `1fr auto 1fr`. The two side columns are equal-width regions (each gets half the leftover space) so the centre `auto` column always sits at the bar's geometric horizontal centre regardless of how wide each side slot's content actually is.",
|
|
96
|
+
"titleCentring": "Title is anchored to the bar's geometric horizontal centre — measured against the page component container, not against the side slots. A 40px back arrow paired with a 80px primary Toolbar Button still leaves the title perfectly centred; the side slots anchor at the start / end of their respective halves and never displace the title. The action always stays intact; title truncation (ellipsis) kicks in if the natural width exceeds what the bar can give it."
|
|
97
|
+
},
|
|
98
|
+
"states": {
|
|
99
|
+
"note": "Bar itself has no interactive state. Side slots inherit the state recipe of whichever control they host (Icon Button / Toolbar Button / Text Button) — default / hovered / pressed / disabled / focused via the standard overlays + three-layer focus ring. Title carries no states."
|
|
100
|
+
},
|
|
101
|
+
"focusIndicator": {
|
|
102
|
+
"description": "The bar itself isn't a focus target. Its action slots inherit each control's own focus composition — Icon Button → Outward, Toolbar Button → Outward, Text Button → Outward — so the ring belongs to whichever control the keyboard lands on. See the contained sub-components for the visual contract.",
|
|
103
|
+
"composition": "delegated",
|
|
104
|
+
"delegatesTo": [
|
|
105
|
+
"../button/icon.spec.json#/focusIndicator",
|
|
106
|
+
"../button/toolbar.spec.json#/focusIndicator",
|
|
107
|
+
"../button/text.spec.json#/focusIndicator"
|
|
108
|
+
],
|
|
109
|
+
"trigger": ":focus-visible (keyboard / programmatic focus, never plain mouse click)"
|
|
110
|
+
},
|
|
111
|
+
"behavior": {
|
|
112
|
+
"titleCentring": "Title is anchored to the bar's geometric horizontal centre via a `1fr auto 1fr` grid (equal-width side regions). Side slots anchor at the start / end of their respective halves and cannot push the title off-centre — even when the leading and trailing controls are very different widths.",
|
|
113
|
+
"titleTruncates": "Long page names truncate with ellipsis; the bar never grows past 56px. Treat as a safety net — author concise titles.",
|
|
114
|
+
"backAffordance": "Leading slot is conventionally the back-arrow icon, but the component does not own back routing — host wires onClick or wraps an <a href>."
|
|
115
|
+
},
|
|
116
|
+
"forbidden": [
|
|
117
|
+
"leading action different from back chevron — the sub variant always opens with the back affordance",
|
|
118
|
+
"title not center-aligned — Sub bar title is centered, never left-aligned",
|
|
119
|
+
"title bar height different from 56px",
|
|
120
|
+
"brand color on the title",
|
|
121
|
+
"raw <Button variant=\"icon\" size=\"medium\" /> in the trailing slot — medium resolves to sys.icon.md (16) and breaks symmetry with the leading 24px back-arrow. Use the object form `trailing={{ icon, 'aria-label' }}` or pass `size=\"large\"` explicitly."
|
|
122
|
+
]
|
|
123
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../family.schema.json",
|
|
3
|
+
"family": "page-shell",
|
|
4
|
+
"name": "PageShell",
|
|
5
|
+
"description": "The app scaffold that PINS NavigationBar (top) and TabBar (bottom) while only the body scrolls. Chorus bars render in flow and do not self-pin; PageShell is the pinning mechanism — a full-height (100dvh) flex column whose middle <main> is the sole scroll region (flex:1 1 auto; min-height:0; overflow-y:auto; overscroll-behavior:contain). Without this skeleton the whole page scrolls as one piece and both bars drift off-screen on long content. The `min-height:0` line is load-bearing — omit it and the flex body refuses to shrink. NEVER give the bars position:sticky/fixed: that double-applies their safe-area insets. Replaces the hand-rolled flex-column recipe that was previously prose-only. Single-spec family.",
|
|
6
|
+
"useCases": [
|
|
7
|
+
"app screen scaffold with a pinned top NavigationBar and bottom TabBar",
|
|
8
|
+
"any screen whose long scrolling body must not push the bars off-screen",
|
|
9
|
+
"the canonical page-shell skeleton (was a copy-paste CSS recipe)"
|
|
10
|
+
],
|
|
11
|
+
"visualReuse": "open",
|
|
12
|
+
"layoutInset": "full-bleed",
|
|
13
|
+
"wrapperGuidance": "PageShell IS the top-level shell — render it as the screen root (or directly inside the route / viewport host). It owns ONLY the pin/scroll mechanics, not a content gutter, so the body honors the normal full-bleed / inline padding contract. Pass `nav` and `tabBar` (rendered in flow at top / bottom) and the scrolling content as children; pass `bodyProps` to add a page gutter to <main> when the screen carries inline (non-full-bleed) content.",
|
|
14
|
+
"usage": {
|
|
15
|
+
"note": "Compose the bars INTO the shell — do NOT hand-roll a flex column, and do NOT add position:sticky/fixed to the bars (that double-applies their safe-area insets). The dev-only usePinnedBarGuard warns when a bar is rendered inside a scrolling region instead.",
|
|
16
|
+
"example": "<PageShell nav={<NavigationBar variant=\"home\" … />} tabBar={<TabBar … />}>… scrolling screen content …</PageShell>"
|
|
17
|
+
},
|
|
18
|
+
"spec": "page-shell.md",
|
|
19
|
+
"subcomponents": [
|
|
20
|
+
{ "slug": "page-shell", "md": "page-shell.md", "default": true, "specMissing": true }
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# PageShell
|
|
2
|
+
|
|
3
|
+
The app scaffold that **pins** `NavigationBar` (top) and `TabBar` (bottom) while only the body scrolls.
|
|
4
|
+
|
|
5
|
+
## Why it exists
|
|
6
|
+
|
|
7
|
+
Chorus bars render **in flow** and do **not** self-pin — they only pay their own `env(safe-area-inset-*)`. Pinning is the shell's job. The most common bug is rendering the bars inside the scrolling region (window-scroll + `padding-bottom`, or a tall scrolling `<div>`): the screen looks fine when short, but on a long list the bars scroll away with the content. The fix is a full-height flex column where only `<main>` scrolls.
|
|
8
|
+
|
|
9
|
+
## Anatomy
|
|
10
|
+
|
|
11
|
+
```jsx
|
|
12
|
+
<PageShell
|
|
13
|
+
nav={<NavigationBar variant="main" … />}
|
|
14
|
+
tabBar={<TabBar … />}
|
|
15
|
+
>
|
|
16
|
+
{/* the ONLY part that scrolls */}
|
|
17
|
+
<Feed … />
|
|
18
|
+
<List … />
|
|
19
|
+
</PageShell>
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
renders:
|
|
23
|
+
|
|
24
|
+
```html
|
|
25
|
+
<div class="chorus-page-shell"> <!-- display:flex; flex-direction:column; height:100dvh -->
|
|
26
|
+
…nav… <!-- flow child, natural height -->
|
|
27
|
+
<main class="chorus-page-shell__body"> <!-- flex:1 1 auto; min-height:0; overflow-y:auto; overscroll-behavior:contain -->
|
|
28
|
+
…children…
|
|
29
|
+
</main>
|
|
30
|
+
…tabBar… <!-- flow child, natural height -->
|
|
31
|
+
</div>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Props
|
|
35
|
+
|
|
36
|
+
| prop | type | notes |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| `nav` | node | rendered in flow at the top (a `NavigationBar`). Pays its own safe-area-inset-top. |
|
|
39
|
+
| `tabBar` | node | rendered in flow at the bottom (a `TabBar`). Pays its own safe-area-inset-bottom. |
|
|
40
|
+
| `children` | node | the scrolling body content (the sole scroll region). |
|
|
41
|
+
| `bodyProps` | object | spread onto `<main>` — use to add a page gutter (`style={{ paddingInline: 'var(--sys-layout-page-md)' }}`) when the screen carries inline (non-full-bleed) content. |
|
|
42
|
+
| `className` | string | added to the shell root. |
|
|
43
|
+
|
|
44
|
+
## Rules
|
|
45
|
+
|
|
46
|
+
- **Do not** give `NavigationBar` / `TabBar` `position: sticky` / `fixed` — that double-applies their safe-area insets. The flex shell is the pin.
|
|
47
|
+
- `min-height: 0` on the body is load-bearing — it lets the body (not the shell) shrink and scroll. It ships in the `.chorus-page-shell__body` class, so use the component / class rather than hand-authoring it.
|
|
48
|
+
- PageShell owns only the pin/scroll mechanics, not a content gutter — the body honors the normal full-bleed / inline padding contract.
|
|
49
|
+
- The overlay `NavigationBar` (floating over a `ProfileHeader` cover) is the exception — it scrolls with the hero by design and is not pinned by the shell.
|
|
50
|
+
|
|
51
|
+
A dev-only guard (`usePinnedBarGuard`) warns in the console when a bar is rendered inside a scrolling region instead of a shell. See also `patterns/layout.md`.
|