@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,136 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../spec.schema.json",
|
|
3
|
+
"name": "Banner",
|
|
4
|
+
"family": "banner",
|
|
5
|
+
"exportAlias": "Alert",
|
|
6
|
+
"description": "In-body explanation block — a tinted, self-contained card that sits within the reading flow but visually separates from it, used to teach a feature, surface a capability, or annotate content with a short paragraph and an optional follow-through link. Reach for Banner when a passage of body copy needs a brief aside that the reader can scan or skip, not when the message requires a decision (use Dialog or Bottom Sheet) or carries a transient confirmation weight (use Toast).",
|
|
7
|
+
"element": "div",
|
|
8
|
+
"props": {
|
|
9
|
+
"appearance": {
|
|
10
|
+
"type": "enum",
|
|
11
|
+
"values": [
|
|
12
|
+
"default",
|
|
13
|
+
"accent",
|
|
14
|
+
"destructive"
|
|
15
|
+
],
|
|
16
|
+
"default": "default"
|
|
17
|
+
},
|
|
18
|
+
"icon": {
|
|
19
|
+
"type": "node",
|
|
20
|
+
"optional": true,
|
|
21
|
+
"description": "A 16 × 16 (`sys.icon.md`) glyph at the container's leading edge. Inherits the banner's foreground (`currentColor`) so the mark reads as part of the body copy. The slot occupies the body.sm line-box height so the glyph centers on the **first line** of the body — multi-line bodies keep the icon anchored to the first-line cap, not the block center. Ignored when `thumbnail` is also passed."
|
|
22
|
+
},
|
|
23
|
+
"thumbnail": {
|
|
24
|
+
"type": "node",
|
|
25
|
+
"optional": true,
|
|
26
|
+
"description": "A leading visual rendered by [Thumbnail](../thumbnail/thumbnail.md) — used when the aside is anchored to a channel, author, or sub-brand image rather than to a glyph. Takes precedence over `icon`."
|
|
27
|
+
},
|
|
28
|
+
"action": {
|
|
29
|
+
"type": "object",
|
|
30
|
+
"optional": true,
|
|
31
|
+
"description": "{ label, href? , onClick? } — a follow-through link rendered as a block child below the body."
|
|
32
|
+
},
|
|
33
|
+
"children": {
|
|
34
|
+
"type": "node",
|
|
35
|
+
"required": true,
|
|
36
|
+
"description": "Body text — the explanation copy."
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"slots": {
|
|
40
|
+
"container": {
|
|
41
|
+
"required": true,
|
|
42
|
+
"description": "The tinted block. Horizontal flex with align-items: flex-start so the optional leading icon top-aligns. role='note'.",
|
|
43
|
+
"intrinsic": true
|
|
44
|
+
},
|
|
45
|
+
"icon": {
|
|
46
|
+
"required": false,
|
|
47
|
+
"description": "16 × 16 (`sys.icon.md`) glyph slot. Paints in `currentColor` so the icon inherits the banner's foreground. Slot height equals the body.sm line box, centring the glyph on the body's first line.",
|
|
48
|
+
"accepts": [
|
|
49
|
+
"icon"
|
|
50
|
+
]
|
|
51
|
+
},
|
|
52
|
+
"thumbnail": {
|
|
53
|
+
"required": false,
|
|
54
|
+
"description": "Leading slot that hosts a `Thumbnail` (channel / author / sub-brand image). Footprint and corner shape come from Thumbnail; the slot only top-aligns and prevents shrink.",
|
|
55
|
+
"accepts": [
|
|
56
|
+
"thumbnail"
|
|
57
|
+
]
|
|
58
|
+
},
|
|
59
|
+
"content": {
|
|
60
|
+
"required": true,
|
|
61
|
+
"description": "Vertical column holding body and (optional) action. flex: 1 1 auto.",
|
|
62
|
+
"intrinsic": true
|
|
63
|
+
},
|
|
64
|
+
"body": {
|
|
65
|
+
"required": true,
|
|
66
|
+
"description": "Explanation copy. body.sm / Regular / inherits container's foreground. Single paragraph; wraps freely.",
|
|
67
|
+
"accepts": [
|
|
68
|
+
"text"
|
|
69
|
+
]
|
|
70
|
+
},
|
|
71
|
+
"action": {
|
|
72
|
+
"required": false,
|
|
73
|
+
"description": "Follow-through link below the body. label.md / Semibold / underlined.",
|
|
74
|
+
"accepts": [
|
|
75
|
+
"button"
|
|
76
|
+
]
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
"sizing": {
|
|
80
|
+
"radius": "sys.radius.md",
|
|
81
|
+
"padding": "sys.layout.container.sm",
|
|
82
|
+
"slotGap": "sys.layout.stack.xs",
|
|
83
|
+
"iconSize": "sys.icon.md",
|
|
84
|
+
"iconSlotHeight": "calc(sys.typo.body.sm.size * sys.typo.body.sm.line)",
|
|
85
|
+
"iconColor": "currentColor",
|
|
86
|
+
"contentStackGap": "sys.layout.stack.xs",
|
|
87
|
+
"bodyTypo": "sys.typo.body.sm",
|
|
88
|
+
"actionTypo": "sys.typo.label.md"
|
|
89
|
+
},
|
|
90
|
+
"safeZone": {
|
|
91
|
+
"inline": {
|
|
92
|
+
"token": "sys.layout.container.md",
|
|
93
|
+
"value": "ref.space.200",
|
|
94
|
+
"paidBy": "host",
|
|
95
|
+
"note": "Banner is an `inline` card with no outer margin — the host owns the horizontal inset. At the page-shell level the inset is the shell's `layout.page.md` gutter (16); inside Section / Feed / BottomSheet content / SideSheet column the host's container padding governs the inset. Do not paint per-child horizontal margin on Banner."
|
|
96
|
+
},
|
|
97
|
+
"block": {
|
|
98
|
+
"token": "sys.layout.stack.xs",
|
|
99
|
+
"value": "ref.space.100",
|
|
100
|
+
"paidBy": "parent-gap",
|
|
101
|
+
"note": "Vertical 8 between Banner and its siblings is paid by the parent column as `gap: var(--sys-layout-stack-xs)`. Banner itself owns no outer margin. Do not wrap in Section to 'get spacing', do not paint `padding-block` on a wrapper, do not paint `margin-block` on Banner."
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
"appearances": {
|
|
105
|
+
"default": {
|
|
106
|
+
"background": "sys.color.scrimSubtle",
|
|
107
|
+
"foreground": "sys.color.onSurface",
|
|
108
|
+
"actionColor": "sys.color.primary",
|
|
109
|
+
"note": "Body sits in `onSurface`; the action link steps to primary so it carries the only chromatic emphasis. Background is the translucent inverse-tone scrim (`sys.color.scrimSubtle` — ~8% black light / ~8% white dark) so the banner stays harmonious on any underlying surface — body, raised card, BottomSheet, Dialog — by tinting one step darker (light mode) or lighter (dark mode) instead of pinning to a fixed neutral step that can collide with the surface ladder. Same scrim used by Chip / Tag default, Progress track, StatusTag neutral, and Skeleton."
|
|
110
|
+
},
|
|
111
|
+
"accent": {
|
|
112
|
+
"background": "sys.color.primaryContainer",
|
|
113
|
+
"foreground": "sys.color.onPrimaryContainer",
|
|
114
|
+
"actionColor": "inherit",
|
|
115
|
+
"note": "Both body and action paint in the primary family so the whole banner reads as one highlighted block. Reach for `accent` when the aside should pull more attention — feature explainers, capability nudges."
|
|
116
|
+
},
|
|
117
|
+
"destructive": {
|
|
118
|
+
"background": "sys.color.errorContainer",
|
|
119
|
+
"foreground": "sys.color.onErrorContainer",
|
|
120
|
+
"actionColor": "inherit",
|
|
121
|
+
"note": "Body and action paint in the error family so the whole banner reads as one warning block. Reach for `destructive` when the aside is a blocking error or rejection — failed approvals, integration outages, billing problems. Use sparingly — every destructive banner on a screen competes with the others for the user's alarm budget."
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
"behavior": {
|
|
125
|
+
"actionLink": "When present, renders as an <a> and accepts either href (browser navigation) or onClick (consumer-controlled). Underline persists at rest so the link reads as actionable inside the muted block.",
|
|
126
|
+
"role": "Container carries role='note' so screen readers announce the banner as an aside."
|
|
127
|
+
},
|
|
128
|
+
"forbidden": [
|
|
129
|
+
"default banner background painted with sys.color.brandContainer — informational banners use sys.color.primaryContainer; promotional banners use sys.color.surfaceContainerLow",
|
|
130
|
+
"banner thumbnail slot omitted when banner role carries imagery — empty image area is forbidden, fall back to /placeholder.png",
|
|
131
|
+
"banner used for transient confirmations — that role is the `toast` family (locked)",
|
|
132
|
+
"banner CTA rendered as raw <a> / <button> — use button/text inside the action slot",
|
|
133
|
+
"banner wrapped in Section, padding-block div, or per-child margin-block — vertical 8 (sys.layout.stack.xs) safe zone is paid once by the parent's `gap`, never per child",
|
|
134
|
+
"banner painted with per-child horizontal margin / padding-inline wrapper to fake a safe zone — the horizontal inset is the host's responsibility (page-shell gutter at the top level, container padding inside a host); Banner ships none and does not claim the page rail"
|
|
135
|
+
]
|
|
136
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../family.schema.json",
|
|
3
|
+
"family": "bottom-sheet",
|
|
4
|
+
"name": "BottomSheet",
|
|
5
|
+
"description": "Modal surface anchored to the bottom of the viewport, used for a focused commit (an action menu, a confirmation, a short form) that should not navigate the user away from the underlying screen. Single-spec family.",
|
|
6
|
+
"useCases": [
|
|
7
|
+
"short focused commit",
|
|
8
|
+
"edge-anchored interruption",
|
|
9
|
+
"action menu",
|
|
10
|
+
"confirmation prompt",
|
|
11
|
+
"one-thumb reach decision"
|
|
12
|
+
],
|
|
13
|
+
"visualReuse": "locked",
|
|
14
|
+
"layoutInset": "bounded-surface",
|
|
15
|
+
"wrapperGuidance": "Renders into a body portal (or owns its own surface chrome). Place the call as a sibling of the page shell \u2014 do NOT wrap in a layout container, padding div, or className=\"px-*\". Full-bleed children inside the surface body get the negative-margin opt-out \u2014 see AGENTS.md \u00a7 Composition rules.",
|
|
16
|
+
"spec": "bottom-sheet.md",
|
|
17
|
+
"usage": {
|
|
18
|
+
"note": "Controlled via `open` + `onClose`; actions are objects (`primaryAction`/`secondaryAction`), not children.",
|
|
19
|
+
"example": "<BottomSheet open onClose={close} title=\"…\" body=\"…\" primaryAction={{ label, onClick }} secondaryAction={{ label, onClick }} />"
|
|
20
|
+
},
|
|
21
|
+
"subcomponents": [
|
|
22
|
+
{
|
|
23
|
+
"slug": "bottom-sheet",
|
|
24
|
+
"spec": "bottom-sheet.spec.json",
|
|
25
|
+
"md": "bottom-sheet.md",
|
|
26
|
+
"default": true
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# Bottom sheet
|
|
2
|
+
|
|
3
|
+
An edge-anchored interruption — a panel that rises from the bottom of the viewport, sits over a scrim, and holds richer content than a Dialog can.
|
|
4
|
+
|
|
5
|
+
**Reach for this when** you want to steer the user toward a preferred action *without severing the flow* — present enough to focus attention, light enough that dismissing returns them where they were. **Skip when** the decision must be committed before the flow can continue (use [Dialog](../dialog/dialog.md)).
|
|
6
|
+
|
|
7
|
+
**Layout inset.** bounded-surface — its own modal / popover shell. Owns its outer padding; not a sibling of `full-bleed` page rows. A `full-bleed` child placed inside (List / Feed / AvatarRail / Chip group / Tabs rail) MUST opt out via `marginInline: 'calc(-1 * var(--sys-layout-container-md))'` (matching `width`, `maxWidth: 'none'`) so its row padding becomes the visual inset and the sheet title aligns with row leading content — precedent in `bottom-sheet/overflow` and `bottom-sheet/nested-step`. See [`AGENTS.md` § Composition rules](../../../AGENTS.md#composition-rules).
|
|
8
|
+
|
|
9
|
+
## Default
|
|
10
|
+
|
|
11
|
+
An information-style sheet — title, body paragraph, and a stacked primary / secondary action pair.
|
|
12
|
+
|
|
13
|
+
```preview
|
|
14
|
+
bottom-sheet/default
|
|
15
|
+
---
|
|
16
|
+
import { useState } from 'react';
|
|
17
|
+
import { BottomSheet, Button } from '@teamblind-chorus/ui';
|
|
18
|
+
|
|
19
|
+
const [open, setOpen] = useState(false);
|
|
20
|
+
|
|
21
|
+
<>
|
|
22
|
+
<Button appearance="primary" onClick={() => setOpen(true)}>Open sheet</Button>
|
|
23
|
+
<BottomSheet
|
|
24
|
+
open={open}
|
|
25
|
+
onClose={() => setOpen(false)}
|
|
26
|
+
title="Channel settings"
|
|
27
|
+
body="Manage how this channel shows up in your feed and who can reach you here."
|
|
28
|
+
primaryAction={{ label: 'Done', onClick: () => setOpen(false) }}
|
|
29
|
+
secondaryAction={{ label: 'Cancel', onClick: () => setOpen(false) }}
|
|
30
|
+
/>
|
|
31
|
+
</>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Use cases
|
|
35
|
+
|
|
36
|
+
### Overflow
|
|
37
|
+
|
|
38
|
+
When content exceeds the card's `max-height`, the content slot scrolls internally — handle and actions stay pinned, footer gains its `is-elevated` upward shadow.
|
|
39
|
+
|
|
40
|
+
```preview
|
|
41
|
+
bottom-sheet/overflow
|
|
42
|
+
---
|
|
43
|
+
import { useState } from 'react';
|
|
44
|
+
import { BottomSheet, Button, List } from '@teamblind-chorus/ui';
|
|
45
|
+
|
|
46
|
+
const [open, setOpen] = useState(false);
|
|
47
|
+
|
|
48
|
+
<>
|
|
49
|
+
<Button appearance="primary" onClick={() => setOpen(true)}>Open sheet</Button>
|
|
50
|
+
<BottomSheet
|
|
51
|
+
open={open}
|
|
52
|
+
onClose={() => setOpen(false)}
|
|
53
|
+
title="Channels you follow"
|
|
54
|
+
body="Tap a channel to manage notifications, members, and pinned posts."
|
|
55
|
+
primaryAction={{ label: 'Done', onClick: () => setOpen(false) }}
|
|
56
|
+
secondaryAction={{ label: 'Cancel', onClick: () => setOpen(false) }}
|
|
57
|
+
>
|
|
58
|
+
<List items={/* many rows, each with nav: true */} />
|
|
59
|
+
</BottomSheet>
|
|
60
|
+
</>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Keyboard
|
|
64
|
+
|
|
65
|
+
When the sheet hosts an input that summons the virtual keyboard, the card lifts above the keyboard's top edge so the actions footer stays reachable. Handle and footer pinned; content scrolls to keep the focused input in view.
|
|
66
|
+
|
|
67
|
+
```preview
|
|
68
|
+
bottom-sheet/keyboard
|
|
69
|
+
---
|
|
70
|
+
import { useState } from 'react';
|
|
71
|
+
import { BottomSheet, Button, FormField } from '@teamblind-chorus/ui';
|
|
72
|
+
|
|
73
|
+
const [open, setOpen] = useState(false);
|
|
74
|
+
|
|
75
|
+
<>
|
|
76
|
+
<Button appearance="primary" onClick={() => setOpen(true)}>Open sheet</Button>
|
|
77
|
+
<BottomSheet
|
|
78
|
+
open={open}
|
|
79
|
+
onClose={() => setOpen(false)}
|
|
80
|
+
title="Name this channel"
|
|
81
|
+
primaryAction={{ label: 'Create', onClick: () => setOpen(false) }}
|
|
82
|
+
secondaryAction={{ label: 'Cancel', onClick: () => setOpen(false) }}
|
|
83
|
+
>
|
|
84
|
+
<FormField variant="input" label="Channel name" placeholder="e.g. design-systems" autoFocus />
|
|
85
|
+
</BottomSheet>
|
|
86
|
+
</>
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Nested step
|
|
90
|
+
|
|
91
|
+
The sheet can host a **drill-in step** without spawning a second modal. The consumer swaps title, content, and primary action between renders; passing `onBack` paints a leading back chevron — an Icon Button rendering `ChevronLeftIcon` at `sys.icon.lg` (24), with `sys.layout.inline.md` (8) between glyph and title. Card chrome, scrim, drag handle, and actions footer stay identical across steps.
|
|
92
|
+
|
|
93
|
+
```preview
|
|
94
|
+
bottom-sheet/nested-step
|
|
95
|
+
---
|
|
96
|
+
import { useState } from 'react';
|
|
97
|
+
import { BottomSheet, Button, FormField, List } from '@teamblind-chorus/ui';
|
|
98
|
+
|
|
99
|
+
const [open, setOpen] = useState(false);
|
|
100
|
+
const [step, setStep] = useState('root');
|
|
101
|
+
const [value, setValue] = useState('');
|
|
102
|
+
|
|
103
|
+
<>
|
|
104
|
+
<Button appearance="primary" onClick={() => { setStep('root'); setOpen(true); }}>Open sheet</Button>
|
|
105
|
+
<BottomSheet
|
|
106
|
+
open={open}
|
|
107
|
+
onClose={() => setOpen(false)}
|
|
108
|
+
title={step === 'root' ? 'Yearly Equity Value' : 'RSUs'}
|
|
109
|
+
onBack={step === 'rsus' ? () => setStep('root') : undefined}
|
|
110
|
+
primaryAction={{ label: 'Save', onClick: () => setOpen(false) }}
|
|
111
|
+
>
|
|
112
|
+
{step === 'root' ? (
|
|
113
|
+
<List
|
|
114
|
+
variant="radio"
|
|
115
|
+
items={[
|
|
116
|
+
{ id: 'rsus', label: 'RSUs', onClick: () => setStep('rsus') },
|
|
117
|
+
{ id: 'none', label: 'None' },
|
|
118
|
+
]}
|
|
119
|
+
/>
|
|
120
|
+
) : (
|
|
121
|
+
<FormField
|
|
122
|
+
variant="input"
|
|
123
|
+
leadingIcon="$"
|
|
124
|
+
placeholder="Ex: 100,000"
|
|
125
|
+
value={value}
|
|
126
|
+
onChange={setValue}
|
|
127
|
+
autoFocus
|
|
128
|
+
/>
|
|
129
|
+
)}
|
|
130
|
+
</BottomSheet>
|
|
131
|
+
</>
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Slots
|
|
135
|
+
|
|
136
|
+
- **scrim** — translucent black overlay; dims the host. Clicking fires `onClose`. Aligns the card to the bottom edge.
|
|
137
|
+
- **container** — the sheet card. `surfaceContainerHigh` fill, `radius.xl` top corners (bottom flat), `elevation.sheet` shadow, `max-width: 480px`.
|
|
138
|
+
- **drag handle** — small grey pill centred at the top; decorative dismissal cue.
|
|
139
|
+
- **content** — scrollable region holding title, body, and custom children. Title: `heading.lg` / Semibold / `onSurface`. Body: `body.md` / Regular / `onSurfaceVariant`.
|
|
140
|
+
- **back** *(optional)* — leading back chevron, rendered only when `onBack` is wired. Delegates to [Button](../button/icon.md) `variant="icon"` with `ChevronLeftIcon` at `sys.icon.lg` and `sys.layout.inline.md` between glyph and title.
|
|
141
|
+
- **actions** — pinned footer holding the action stack. Primary on top, secondary below; both stretch to the inner width. Primary = `Button appearance="primary"`; secondary = `Button appearance="secondary"`.
|
|
142
|
+
|
|
143
|
+
## Anatomy
|
|
144
|
+
|
|
145
|
+
| Slot | Token bindings |
|
|
146
|
+
|--------------|----------------|
|
|
147
|
+
| scrim | Fixed full-viewport overlay, `palette.black.600` (~24% alpha), aligns card to bottom |
|
|
148
|
+
| container | `surfaceContainerHigh` fill, `radius.xl` top corners (bottom flat), `elevation.sheet`, `max-width: 480px`, `max-height: 90vh` |
|
|
149
|
+
| drag handle | 48 × 4px pill, `onSurfaceVariant @ 40%`, `sys.radius.full`, 8px vertical gutter |
|
|
150
|
+
| content | Flex column, 16px padding, 16px between children, vertical scroll on overflow |
|
|
151
|
+
| title | `sys.typo.heading.lg` (24 / Semibold), `onSurface` |
|
|
152
|
+
| back chevron | [Icon Button](../button/icon.md) → `ChevronLeftIcon` at `sys.icon.lg` (24), `sys.color.onSurface`. Glyph aligns to the title's leading edge via Icon Button optical-alignment. `sys.layout.inline.md` (8) glyph → title gap. |
|
|
153
|
+
| body | `sys.typo.body.md` (16 / Regular), `onSurfaceVariant` |
|
|
154
|
+
| actions | Flex column, 8px between buttons, 16px padding on all four sides |
|
|
155
|
+
| primary CTA | [Button](../button/button.md) `appearance="primary"`, `size="large"`, `fullWidth` |
|
|
156
|
+
| secondary | [Button](../button/button.md) `appearance="secondary"`, `size="large"`, `fullWidth` |
|
|
157
|
+
|
|
158
|
+
## States
|
|
159
|
+
|
|
160
|
+
Either **open** or **closed** — closed renders nothing. When open, scrim and card both render; the underlying surface is non-interactive. The container itself carries no interactive state — interaction lives in the action buttons.
|
|
161
|
+
|
|
162
|
+
## Focus indicator
|
|
163
|
+
|
|
164
|
+
Sheet itself isn't a focus target; primary and secondary slots inherit [Button → Outward](../button/button.md#focus-indicator) focus composition. On open, focus moves to the primary action. Trigger: `:focus-visible`.
|
|
165
|
+
|
|
166
|
+
## Behavior
|
|
167
|
+
|
|
168
|
+
- **Scrim click closes.** Tapping the scrim fires `onClose`; card stops propagation.
|
|
169
|
+
- **Escape key closes.** `Esc` fires `onClose`.
|
|
170
|
+
- **Focus management.** On open, focus moves to the primary action; returns to the previously-focused element on close.
|
|
171
|
+
- **Body scroll lock.** The page underneath does not scroll while open.
|
|
172
|
+
- **Overflow scrolling.** When content exceeds `max-height`, the content slot scrolls internally; handle and actions stay pinned. Flex column — handle and actions `flex: 0 0 auto`, content `flex: 1 1 auto` with `overflow-y: auto`.
|
|
173
|
+
- **Elevated actions footer.** While content is overflowing, footer gains an upward drop shadow (`0 -2px 6px black/4%, 0 -8px 16px black/8%`) so it reads as pinned above the scrolling content.
|
|
174
|
+
- **Keyboard handling.** When a hosted input opens the virtual keyboard, the scrim absorbs the keyboard height as `padding-bottom` via `--bottom-sheet-keyboard-inset`, translating the card up so the actions footer rides above the keyboard's top edge. Read at runtime from `window.innerHeight - visualViewport.height` on `visualViewport.resize`; resets to `0px` on dismissal.
|
|
175
|
+
- **Portal rendering.** Renders into a portal at `document.body` by default. Pass `inline` to scope to the nearest positioned ancestor.
|
|
176
|
+
- **Nested step.** Passing `onBack` paints the leading back chevron; the component is stateless about which step is active. The consumer owns the step (e.g. `useState`) and swaps `title` / `children` / `primaryAction` / `onBack` between renders. Card chrome, scrim, handle, and actions footer remain identical across steps.
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../spec.schema.json",
|
|
3
|
+
"name": "BottomSheet",
|
|
4
|
+
"family": "bottom-sheet",
|
|
5
|
+
"exportAlias": ["Sheet", "Drawer"],
|
|
6
|
+
"description": "Edge-anchored interruption — a panel that rises from the bottom of the viewport, sits over a scrim, and holds richer content than a Dialog: longer copy, tabular comparisons, multi-section explanations, or a primary commit at the foot. Reach for Bottom Sheet when the answer needs more vertical room than a centred card affords but the surface is still a single decision the user can dismiss to return to the host.",
|
|
7
|
+
"element": "div",
|
|
8
|
+
"props": {
|
|
9
|
+
"open": {
|
|
10
|
+
"type": "boolean",
|
|
11
|
+
"required": true
|
|
12
|
+
},
|
|
13
|
+
"onClose": {
|
|
14
|
+
"type": "function",
|
|
15
|
+
"required": true
|
|
16
|
+
},
|
|
17
|
+
"title": {
|
|
18
|
+
"type": "node",
|
|
19
|
+
"optional": true
|
|
20
|
+
},
|
|
21
|
+
"body": {
|
|
22
|
+
"type": "node",
|
|
23
|
+
"optional": true
|
|
24
|
+
},
|
|
25
|
+
"primaryAction": {
|
|
26
|
+
"type": "object",
|
|
27
|
+
"optional": true,
|
|
28
|
+
"description": "{ label, onClick } — delegates to Button appearance='primary', size='large', fullWidth."
|
|
29
|
+
},
|
|
30
|
+
"secondaryAction": {
|
|
31
|
+
"type": "object",
|
|
32
|
+
"optional": true,
|
|
33
|
+
"description": "{ label, onClick } — delegates to Button appearance='secondary', size='large', fullWidth."
|
|
34
|
+
},
|
|
35
|
+
"onBack": {
|
|
36
|
+
"type": "function",
|
|
37
|
+
"optional": true,
|
|
38
|
+
"description": "When supplied, the sheet is a nested step inside its host flow — a back chevron (Icon Button → ChevronLeftIcon, sys.icon.lg, sys.layout.inline.md gap to the title) is rendered at the title's leading edge and invokes onBack on click. Consumer owns step state; the component only paints the chevron."
|
|
39
|
+
},
|
|
40
|
+
"backLabel": {
|
|
41
|
+
"type": "string",
|
|
42
|
+
"default": "Back",
|
|
43
|
+
"description": "Accessible name on the back Icon Button. Only consulted when onBack is set."
|
|
44
|
+
},
|
|
45
|
+
"children": {
|
|
46
|
+
"type": "node",
|
|
47
|
+
"optional": true
|
|
48
|
+
},
|
|
49
|
+
"inline": {
|
|
50
|
+
"type": "boolean",
|
|
51
|
+
"default": false,
|
|
52
|
+
"description": "When true, scopes the scrim and card to the nearest positioned ancestor instead of portaling to document.body."
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"slots": {
|
|
56
|
+
"scrim": {
|
|
57
|
+
"required": true,
|
|
58
|
+
"intrinsic": true,
|
|
59
|
+
"description": "Translucent black overlay covering the host surface; aligns the card to the bottom edge. Clicking fires onClose."
|
|
60
|
+
},
|
|
61
|
+
"container": {
|
|
62
|
+
"required": true,
|
|
63
|
+
"intrinsic": true,
|
|
64
|
+
"description": "The sheet card. surfaceContainerHigh fill, top radius.xl, bottom corners flat (edge-anchored rule), elevation.sheet shadow, max-width 480px, max-height 90vh."
|
|
65
|
+
},
|
|
66
|
+
"dragHandle": {
|
|
67
|
+
"required": true,
|
|
68
|
+
"intrinsic": true,
|
|
69
|
+
"description": "Small grey pill centred at the top of the card. Decorative — interaction lives in scrim click + Esc key."
|
|
70
|
+
},
|
|
71
|
+
"content": {
|
|
72
|
+
"required": true,
|
|
73
|
+
"intrinsic": true,
|
|
74
|
+
"description": "Scrollable region holding title, body, and any custom children. The component composes this from `title` + `body` + `actions` + any children — consumers do not provide a separate `content` node."
|
|
75
|
+
},
|
|
76
|
+
"title": {
|
|
77
|
+
"required": false,
|
|
78
|
+
"description": "heading.lg / Semibold / onSurface.",
|
|
79
|
+
"accepts": [
|
|
80
|
+
"text"
|
|
81
|
+
]
|
|
82
|
+
},
|
|
83
|
+
"back": {
|
|
84
|
+
"required": false,
|
|
85
|
+
"description": "Optional leading back chevron, rendered only when the host wires `onBack`. Delegates to Button variant='icon' with ChevronLeftIcon at sys.icon.lg and an 8px (sys.layout.inline.md) gap to the title. Icon Button's optical-alignment default places the glyph at the title's leading edge so the row stays flush with the content padding.",
|
|
86
|
+
"accepts": [
|
|
87
|
+
"button"
|
|
88
|
+
]
|
|
89
|
+
},
|
|
90
|
+
"body": {
|
|
91
|
+
"required": false,
|
|
92
|
+
"description": "body.md / Regular / onSurfaceVariant.",
|
|
93
|
+
"accepts": [
|
|
94
|
+
"text"
|
|
95
|
+
]
|
|
96
|
+
},
|
|
97
|
+
"actions": {
|
|
98
|
+
"required": false,
|
|
99
|
+
"description": "Pinned footer holding the action stack. Primary on top, secondary below. Both stretch to the card's inner width.",
|
|
100
|
+
"accepts": [
|
|
101
|
+
"button"
|
|
102
|
+
]
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
"sizing": {
|
|
106
|
+
"scrimTint": "ref.palette.black.600",
|
|
107
|
+
"containerFill": "sys.color.surfaceContainerHigh",
|
|
108
|
+
"containerRadiusTop": "sys.radius.xl",
|
|
109
|
+
"containerRadiusBottom": "0",
|
|
110
|
+
"elevation": "sys.elevation.sheet",
|
|
111
|
+
"maxWidth": "480px",
|
|
112
|
+
"maxHeight": "90vh",
|
|
113
|
+
"dragHandleSize": "48 × 4px",
|
|
114
|
+
"dragHandleFill": "sys.color.onSurfaceVariant @ 40%",
|
|
115
|
+
"dragHandleRadius": "sys.radius.full",
|
|
116
|
+
"dragHandleGutter": "sys.layout.container.xs",
|
|
117
|
+
"contentPadding": "sys.layout.container.md",
|
|
118
|
+
"contentStackGap": "sys.layout.stack.md",
|
|
119
|
+
"actionsStackGap": "sys.layout.stack.xs",
|
|
120
|
+
"actionsPadding": "sys.layout.container.md",
|
|
121
|
+
"titleTypo": "sys.typo.heading.lg",
|
|
122
|
+
"titleColor": "sys.color.onSurface",
|
|
123
|
+
"backIcon": "ChevronLeftIcon",
|
|
124
|
+
"backIconSize": "sys.icon.lg",
|
|
125
|
+
"backIconColor": "sys.color.onSurface",
|
|
126
|
+
"backToTitleGap": "sys.layout.inline.md",
|
|
127
|
+
"bodyTypo": "sys.typo.body.md",
|
|
128
|
+
"bodyColor": "sys.color.onSurfaceVariant"
|
|
129
|
+
},
|
|
130
|
+
"states": {
|
|
131
|
+
"open": {
|
|
132
|
+
"description": "Scrim + card both render; underlying surface remains in DOM but is non-interactive."
|
|
133
|
+
},
|
|
134
|
+
"closed": {
|
|
135
|
+
"description": "Renders nothing."
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
"focusIndicator": {
|
|
139
|
+
"description": "The sheet itself isn't a focus target. Its action slots (primary / secondary Buttons) inherit Button's outward focus composition; on open, focus is moved to the primary action. See the contained sub-components for the visual contract.",
|
|
140
|
+
"composition": "delegated",
|
|
141
|
+
"delegatesTo": "../button/standard.spec.json#/focusIndicator",
|
|
142
|
+
"trigger": ":focus-visible (keyboard / programmatic focus, never plain mouse click)"
|
|
143
|
+
},
|
|
144
|
+
"behavior": {
|
|
145
|
+
"scrimClickCloses": true,
|
|
146
|
+
"escapeKeyCloses": true,
|
|
147
|
+
"focusManagement": "On open, focus moves into the sheet and is trapped there (see accessibility.focusTrap) — for a standard sheet onto the primary action so Enter commits, for a destructive confirmation onto the least-destructive action. Focus returns to the previously-focused element on close.",
|
|
148
|
+
"bodyScrollLock": true,
|
|
149
|
+
"overflow": "When content exceeds max-height, the content slot scrolls internally while drag handle and actions footer stay pinned. Card is a flex column; handle and actions are flex: 0 0 auto; content is flex: 1 1 auto with overflow-y: auto.",
|
|
150
|
+
"elevatedActionsFooter": "While content overflows, the actions footer carries a subtle upward drop shadow (0 -2px 6px black/4%, 0 -8px 16px black/8%) over its surfaceContainerHigh fill so it reads as pinned above the scrolling content. The shadow fades when content fits without scroll.",
|
|
151
|
+
"keyboardInset": "When a hosted input opens the virtual keyboard, the scrim absorbs the keyboard height as padding-bottom via the CSS custom property --bottom-sheet-keyboard-inset. Because the scrim aligns the card with align-items: flex-end, the inset pushes the entire card (and therefore the pinned actions footer) above the keyboard's top edge. Read at runtime from window.innerHeight - visualViewport.height on visualViewport.resize; reset to 0px when the keyboard dismisses.",
|
|
152
|
+
"portalRendering": "By default renders into a portal at document.body. Pass inline to scope to the nearest positioned ancestor.",
|
|
153
|
+
"nestedStep": "A sheet can host a drill-in step inside itself — when the consumer supplies `onBack`, a ChevronLeftIcon Button paints at the title's leading edge with `sys.layout.inline.md` between glyph and title. The component itself is stateless about which step is active: the consumer swaps `title` / `children` / `primaryAction` / `onBack` between renders. The card, scrim, handle, and actions footer remain identical across steps so the transition reads as a same-surface drill-in, not a new modal."
|
|
154
|
+
},
|
|
155
|
+
"accessibility": {
|
|
156
|
+
"role": "container carries role='dialog' (role='alertdialog' for a destructive / irreversible confirmation) — same blocking modal contract as Dialog, anchored to the bottom edge.",
|
|
157
|
+
"ariaModal": "aria-modal='true' on the container — content behind the scrim is inert to assistive tech, mirroring the scrim's pointer-event block.",
|
|
158
|
+
"labelling": "aria-labelledby points at the title slot's id when a title is rendered. When the sheet is title-less, the consumer MUST supply an aria-label so the modal is never announced unnamed.",
|
|
159
|
+
"focusTrap": "While open, Tab / Shift+Tab cycle only through the sheet's focusable descendants and wrap at the ends — focus never reaches the inert page behind the scrim. Required by the APG dialog-modal pattern.",
|
|
160
|
+
"dragHandle": "The drag handle is decorative (dismiss is scrim-tap + Esc, there is no swipe gesture) — it carries aria-hidden='true' and is not a focus target, so it never implies an unavailable drag affordance to assistive tech."
|
|
161
|
+
},
|
|
162
|
+
"forbidden": [
|
|
163
|
+
"BottomSheet without a scrim — same blocking contract as dialog",
|
|
164
|
+
"BottomSheet without a paired triggering action",
|
|
165
|
+
"BottomSheet content rendered with extra horizontal-padding wrappers — sheet body uses container.lg internally; full-bleed children opt out via negative margin per DESIGN.md",
|
|
166
|
+
"Multiple bottom-sheets stacked"
|
|
167
|
+
]
|
|
168
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../family.schema.json",
|
|
3
|
+
"family": "bubble",
|
|
4
|
+
"name": "Bubble",
|
|
5
|
+
"description": "Always-on annotation bubble — a small pill-shaped label with a tail that points at a specific UI element to flag attention (a chat icon with new messages, a search bar with a fresh keyword promotion, a tab with a campaign). Distinct from Tooltip on visual-priority and persistence: Tooltip is a *transient, high-priority* explanation that can overlay surrounding chrome on hover/focus; Bubble is *persistent, lower-priority* annotation that sits inline next to its anchor and never occludes neighbour elements. Reach for Bubble when a hint should remain in view as part of the UI's resting state (an editorial nudge, a feature-flag callout, a count summary tied to an icon); reach for Tooltip when the hint is an on-demand explanation invoked by interaction. Single-spec family.",
|
|
6
|
+
"useCases": [
|
|
7
|
+
"always-on label next to a top-bar icon",
|
|
8
|
+
"feature-flag / new-feature nudge anchored to a UI element",
|
|
9
|
+
"editorial copy promoted next to a search bar",
|
|
10
|
+
"count or status summary that needs more presence than a Badge dot",
|
|
11
|
+
"campaign / event flag that should read at rest, not on hover"
|
|
12
|
+
],
|
|
13
|
+
"visualReuse": "open",
|
|
14
|
+
"layoutInset": "inline",
|
|
15
|
+
"wrapperGuidance": "Inline atom — Bubble owns its own padding, fill, tail, and a viewport-safe width cap (`max-width: min(100%, calc(100vw - 2 × sys.layout.container.xs))`), but ships no positioning. The host anchors the bubble to the target element by VISUAL ALIGNMENT — CSS anchor positioning (`anchor-name` on the target, `position-anchor` + `top: anchor(bottom)` + `left: anchor(center)` + `translateX(-50%)` on the bubble) or a positioned wrapper around the anchor — rather than hand-computed pixel offsets, and owns four coupled decisions: (a) keep every bubble edge ≥8 (`sys.layout.container.xs`) from the viewport edge — visibility of the bubble is the contract; (b) **tail tip flush on the anchor's content edge** (padding excluded) — set the bubble body back from the anchor by the tail's protrusion (the `--bubble-tail-protrusion` token, = `ref.space.50 / √2`) so the tail's outer vertex meets the anchor edge without overlapping it; (c) **tail tip on the anchor's centreX** — centre the bubble on the anchor's visual centre so the tail tip falls on the anchor's horizontal centreline by construction; (d) pick `tailSide` (`top` if the bubble sits below the anchor, `bottom` if above) and keep `tailAlign='center'` for that centred case, flipping to `end` (bubble shifts left) or `start` (bubble shifts right) only when the viewport safe margin forces the bubble off-centre. Do NOT wrap Bubble in a portal / fixed-position scrim — that would defeat the 'never occludes neighbours' contract that separates Bubble from Tooltip. Do NOT wrap Bubble in an `overflow: hidden` ancestor that clips it — fix the ancestor, not the bubble. Inside a bounded surface (Dialog / BottomSheet / SideSheet), apply the negative-margin opt-out only when the bubble needs to escape the host's content box.",
|
|
16
|
+
"spec": "bubble.md",
|
|
17
|
+
"usage": {
|
|
18
|
+
"note": "Label is children; ships no positioning — the host anchors it. `tailSide`/`tailAlign` pick the tail; there is no separate tail element.",
|
|
19
|
+
"example": "<Bubble tailSide=\"top\" tailAlign=\"center\">…</Bubble>"
|
|
20
|
+
},
|
|
21
|
+
"subcomponents": [
|
|
22
|
+
{
|
|
23
|
+
"slug": "bubble",
|
|
24
|
+
"spec": "bubble.spec.json",
|
|
25
|
+
"md": "bubble.md",
|
|
26
|
+
"default": true
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
}
|