canon-ds 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +153 -0
- package/THIRD_PARTY_NOTICES.md +55 -0
- package/bin/canon.js +6 -0
- package/lib/build-manifest.mjs +54 -0
- package/lib/build.mjs +69 -0
- package/lib/cli.mjs +273 -0
- package/lib/color.js +160 -0
- package/lib/components/_shared.mjs +93 -0
- package/lib/components/accordion.mjs +136 -0
- package/lib/components/account-card.mjs +166 -0
- package/lib/components/activity-feed.mjs +176 -0
- package/lib/components/activity-gauge.mjs +98 -0
- package/lib/components/agent-presence.mjs +156 -0
- package/lib/components/alert.mjs +154 -0
- package/lib/components/app-store-button.mjs +108 -0
- package/lib/components/avatar-group.mjs +128 -0
- package/lib/components/avatar.mjs +225 -0
- package/lib/components/badge-group.mjs +55 -0
- package/lib/components/badge.mjs +174 -0
- package/lib/components/banner.mjs +116 -0
- package/lib/components/breadcrumb.mjs +170 -0
- package/lib/components/button-group.mjs +114 -0
- package/lib/components/button.mjs +197 -0
- package/lib/components/card-header.mjs +115 -0
- package/lib/components/card.mjs +106 -0
- package/lib/components/carousel.mjs +182 -0
- package/lib/components/chart-frame.mjs +101 -0
- package/lib/components/checkbox.mjs +163 -0
- package/lib/components/close-button.mjs +49 -0
- package/lib/components/code.mjs +123 -0
- package/lib/components/color-picker.mjs +122 -0
- package/lib/components/combobox.mjs +210 -0
- package/lib/components/command-palette.mjs +226 -0
- package/lib/components/content-divider.mjs +104 -0
- package/lib/components/counter.mjs +86 -0
- package/lib/components/credit-card.mjs +126 -0
- package/lib/components/date-picker.mjs +202 -0
- package/lib/components/description-list.mjs +88 -0
- package/lib/components/dialog.mjs +140 -0
- package/lib/components/divider.mjs +115 -0
- package/lib/components/drawer.mjs +150 -0
- package/lib/components/empty-state.mjs +112 -0
- package/lib/components/featured-card.mjs +124 -0
- package/lib/components/featured-icon.mjs +67 -0
- package/lib/components/field.mjs +122 -0
- package/lib/components/file-dropzone.mjs +166 -0
- package/lib/components/filter-bar.mjs +179 -0
- package/lib/components/header-navigation.mjs +94 -0
- package/lib/components/icon-button.mjs +179 -0
- package/lib/components/index.mjs +52 -0
- package/lib/components/inline-cta.mjs +139 -0
- package/lib/components/input-group.mjs +164 -0
- package/lib/components/input.mjs +197 -0
- package/lib/components/kanban.mjs +179 -0
- package/lib/components/kbd.mjs +70 -0
- package/lib/components/kicker.mjs +79 -0
- package/lib/components/link.mjs +108 -0
- package/lib/components/list.mjs +117 -0
- package/lib/components/media-frame.mjs +119 -0
- package/lib/components/menu.mjs +190 -0
- package/lib/components/message.mjs +249 -0
- package/lib/components/metric-group.mjs +210 -0
- package/lib/components/mobile-header.mjs +173 -0
- package/lib/components/multi-select.mjs +218 -0
- package/lib/components/notification.mjs +175 -0
- package/lib/components/number-input.mjs +149 -0
- package/lib/components/page-header.mjs +68 -0
- package/lib/components/pagination.mjs +236 -0
- package/lib/components/pin-input.mjs +120 -0
- package/lib/components/popover.mjs +126 -0
- package/lib/components/progress-circle.mjs +115 -0
- package/lib/components/progress.mjs +93 -0
- package/lib/components/prose.mjs +96 -0
- package/lib/components/radio.mjs +160 -0
- package/lib/components/rating.mjs +93 -0
- package/lib/components/rich-text-editor.mjs +178 -0
- package/lib/components/section-header.mjs +62 -0
- package/lib/components/segmented-control.mjs +121 -0
- package/lib/components/select.mjs +163 -0
- package/lib/components/sidebar-nav.mjs +172 -0
- package/lib/components/sidebar.mjs +124 -0
- package/lib/components/skeleton.mjs +66 -0
- package/lib/components/slider.mjs +122 -0
- package/lib/components/social-button.mjs +119 -0
- package/lib/components/spinner.mjs +69 -0
- package/lib/components/stat.mjs +120 -0
- package/lib/components/stepper.mjs +228 -0
- package/lib/components/switch.mjs +159 -0
- package/lib/components/table.mjs +212 -0
- package/lib/components/tabs.mjs +207 -0
- package/lib/components/tag.mjs +196 -0
- package/lib/components/tags-input.mjs +146 -0
- package/lib/components/textarea.mjs +117 -0
- package/lib/components/timeline.mjs +135 -0
- package/lib/components/toast.mjs +115 -0
- package/lib/components/tooltip.mjs +86 -0
- package/lib/components/topbar.mjs +207 -0
- package/lib/components/tree-view.mjs +156 -0
- package/lib/components/video-player.mjs +189 -0
- package/lib/connect.mjs +110 -0
- package/lib/design-files.mjs +175 -0
- package/lib/distribution.mjs +109 -0
- package/lib/editor.js +940 -0
- package/lib/engine.js +339 -0
- package/lib/generators/agents.mjs +68 -0
- package/lib/generators/connection.md +58 -0
- package/lib/generators/css.mjs +2 -0
- package/lib/generators/designmd.mjs +324 -0
- package/lib/generators/documentation.css +82 -0
- package/lib/generators/documentation.html +81 -0
- package/lib/generators/documentation.js +96 -0
- package/lib/generators/documentation.mjs +35 -0
- package/lib/generators/dtcg.mjs +60 -0
- package/lib/generators/preview.css +279 -0
- package/lib/generators/preview.mjs +225 -0
- package/lib/generators/react.mjs +162 -0
- package/lib/generators/tailwind.mjs +114 -0
- package/lib/install.mjs +108 -0
- package/lib/lint.mjs +538 -0
- package/lib/mcp.mjs +268 -0
- package/lib/open.mjs +7 -0
- package/lib/patterns/_app.mjs +56 -0
- package/lib/patterns/app-shell.mjs +25 -0
- package/lib/patterns/audit-log.mjs +37 -0
- package/lib/patterns/auth-pages.mjs +44 -0
- package/lib/patterns/billing-page.mjs +39 -0
- package/lib/patterns/calendar-page.mjs +55 -0
- package/lib/patterns/dashboard-page.mjs +28 -0
- package/lib/patterns/files-page.mjs +30 -0
- package/lib/patterns/first-run.mjs +31 -0
- package/lib/patterns/form-layout.mjs +24 -0
- package/lib/patterns/inbox-page.mjs +58 -0
- package/lib/patterns/index.mjs +29 -0
- package/lib/patterns/list-detail-page.mjs +24 -0
- package/lib/patterns/mobile-app.mjs +46 -0
- package/lib/patterns/modal-flows.mjs +32 -0
- package/lib/patterns/onboarding-page.mjs +42 -0
- package/lib/patterns/permissions-page.mjs +38 -0
- package/lib/patterns/profile-page.mjs +32 -0
- package/lib/patterns/settings-page.mjs +24 -0
- package/lib/project.mjs +28 -0
- package/lib/serve.mjs +279 -0
- package/lib/system.mjs +210 -0
- package/lib/tokens/base.js +251 -0
- package/lib/tokens/canon-preset.mjs +67 -0
- package/lib/tokens/index.mjs +1 -0
- package/lib/tokens/legacy-canon.json +559 -0
- package/lib/tokens/presets.mjs +161 -0
- package/lib/tokens/resolve.mjs +1 -0
- package/lib/types.mjs +4 -0
- package/lib/version.mjs +2 -0
- package/package.json +54 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { FOCUS_RING, RESET_BUTTON, TRANSITION_COLORS, ex, ICON, typeStyle } from "./_shared.mjs";
|
|
2
|
+
// tone → { soft bg (tint), soft border, featured-icon color }
|
|
3
|
+
const TONES = {
|
|
4
|
+
neutral: { bg: '{color.bg-subtle}', border: '{color.border-control}', fg: '{color.fg-muted}' },
|
|
5
|
+
brand: { bg: '{color.bg-action-subtle}', border: '{color.border-action}', fg: '{color.fg-accent}' },
|
|
6
|
+
info: { bg: '{color.bg-info-subtle}', border: '{color.border-info}', fg: '{color.fg-info}' },
|
|
7
|
+
success: { bg: '{color.bg-success-subtle}', border: '{color.border-success}', fg: '{color.fg-success}' },
|
|
8
|
+
warning: { bg: '{color.bg-warning-subtle}', border: '{color.border-warning}', fg: '{color.fg-warning}' },
|
|
9
|
+
danger: { bg: '{color.bg-danger-subtle}', border: '{color.border-danger}', fg: '{color.fg-danger}' },
|
|
10
|
+
};
|
|
11
|
+
// soft = tinted surface + tone border. Text stays neutral (title fg-default, description fg-muted) in every tone: only the icon, the border and the tint carry the color.
|
|
12
|
+
const compound = Object.entries(TONES).map(([t, v]) => ({
|
|
13
|
+
when: { tone: t, variant: 'soft' },
|
|
14
|
+
block: { root: { 'background-color': v.bg, 'border-color': v.border } },
|
|
15
|
+
}));
|
|
16
|
+
const icon = (name) => `<span class="cn-alert__icon" aria-hidden="true">${ICON[name]}</span>`;
|
|
17
|
+
const dismiss = `<button type="button" class="cn-alert__dismiss" aria-label="Dismiss">${ICON.x}</button>`;
|
|
18
|
+
const link = (label) => `<button type="button" class="cn-button" data-variant="link" data-size="sm"><span class="cn-button__label">${label}</span></button>`;
|
|
19
|
+
const actions = (a = 'Dismiss', b = 'View changes') => `<div class="cn-alert__actions">${link(a)}${link(b)}</div>`;
|
|
20
|
+
export const alert = {
|
|
21
|
+
name: 'Alert',
|
|
22
|
+
slug: 'alert',
|
|
23
|
+
category: 'feedback',
|
|
24
|
+
description: 'Callout with a featured icon (a 20px line icon inside two faint concentric rings), a semibold title, a one-line description, optional link actions and a close button. 16px padding, 12px radius; tinted (soft) or white (outline); in the flow of the page, floating with a shadow, or as a full-width bar.',
|
|
25
|
+
usage: 'Use for messages tied to the content on screen: a failed sync, a plan limit, a confirmation after a form saves, a release note above a list. Not for transient feedback (use Toast), not for one-line page-wide notices (use Banner), and not for decisions that block the flow (use Dialog).',
|
|
26
|
+
anatomy: [
|
|
27
|
+
{ part: 'root', element: 'div', description: 'The callout surface. role="alert" for danger/warning that appear dynamically, role="status" otherwise. Carries data-tone, data-variant, data-layout.' },
|
|
28
|
+
{ part: 'icon', element: 'span', description: 'Featured icon, "outline" style: a 20px line icon (ICON.info / ICON.check / ICON.warning inside the span) in the tone color with two concentric 2px rings at 30% and 10% opacity (28px and 38px). aria-hidden.' },
|
|
29
|
+
{ part: 'content', element: 'div', description: 'Text column: title, description, actions. Flexes to fill; min-width 0 so long text wraps.' },
|
|
30
|
+
{ part: 'title', element: 'div', description: 'text-sm semibold (label-md), one line. Says what happened ("We’ve just released a new feature"), not "Error".' },
|
|
31
|
+
{ part: 'description', element: 'p', description: 'text-sm muted (body-md, fg-muted), one or two sentences under the title. Says what to do next.', optional: true },
|
|
32
|
+
{ part: 'actions', element: 'div', description: 'Row of link Buttons (size sm), 12px apart: a quiet one first ("Dismiss", gray) then the one that does something ("View changes", brand). At most two. Inside the content column, or a direct child of the root in the full-width layout (then it sits on the right).', optional: true },
|
|
33
|
+
{ part: 'dismiss', element: 'button', description: 'Close button in the top-right corner: 36px hit area, 20px X icon, fg-subtle, 8px from the edges. Requires aria-label="Dismiss".', optional: true },
|
|
34
|
+
],
|
|
35
|
+
props: {
|
|
36
|
+
tone: {
|
|
37
|
+
values: ['neutral', 'brand', 'info', 'success', 'warning', 'danger'],
|
|
38
|
+
default: 'neutral',
|
|
39
|
+
description: 'Meaning. neutral = context or a tip (gray icon; "default"/"gray"); brand = product news and upsell (brand icon, brand tint); info = something to know, no action needed (blue); success = a completed action; warning = needs attention soon, nothing is broken; danger = failed or blocked, the user must act ("error").',
|
|
40
|
+
},
|
|
41
|
+
variant: {
|
|
42
|
+
values: ['soft', 'outline'],
|
|
43
|
+
default: 'soft',
|
|
44
|
+
description: 'soft = tinted background (tone-50) with the tone border; outline = white surface with the neutral hairline and only the icon in the tone color ("default" alert; quieter, for lists of alerts or inside cards).',
|
|
45
|
+
},
|
|
46
|
+
layout: {
|
|
47
|
+
values: ['inline', 'floating', 'full-width'],
|
|
48
|
+
default: 'inline',
|
|
49
|
+
description: 'inline = sits in the content flow above the thing it describes, no shadow (default); floating = the same card with shadow-lg, for alerts rendered over the page (bottom of the viewport, over a table); full-width = edge-to-edge bar with square corners, a bottom hairline and the actions on the right ("full-width" alert), placed directly under the topbar or at the top of a section.',
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
states: {
|
|
53
|
+
dismissHover: { selector: ' .cn-alert__dismiss:hover', description: 'Pointer over the close button: subtle fill, icon one step darker. Styled in extraCss because it lives on a child.', markup: 'native :hover on .cn-alert__dismiss' },
|
|
54
|
+
dismissFocus: { selector: ' .cn-alert__dismiss:focus-visible', description: 'Keyboard focus on the close button shows the focus ring. Styled in extraCss.', markup: 'native :focus-visible on .cn-alert__dismiss' },
|
|
55
|
+
},
|
|
56
|
+
base: {
|
|
57
|
+
root: {
|
|
58
|
+
position: 'relative',
|
|
59
|
+
display: 'flex',
|
|
60
|
+
'align-items': 'flex-start',
|
|
61
|
+
gap: '{space.4}',
|
|
62
|
+
width: '100%',
|
|
63
|
+
padding: '{space.4}',
|
|
64
|
+
'border-radius': '{radius.card}',
|
|
65
|
+
border: '{border.width.thin} solid {color.border-control}',
|
|
66
|
+
'background-color': '{color.bg-surface}',
|
|
67
|
+
color: '{color.fg-default}',
|
|
68
|
+
...typeStyle('body-md'),
|
|
69
|
+
},
|
|
70
|
+
icon: {
|
|
71
|
+
position: 'relative',
|
|
72
|
+
display: 'inline-flex',
|
|
73
|
+
'align-items': 'center',
|
|
74
|
+
'justify-content': 'center',
|
|
75
|
+
'flex-shrink': '0',
|
|
76
|
+
width: '{size.icon.md}',
|
|
77
|
+
height: '{size.icon.md}',
|
|
78
|
+
color: '{color.fg-muted}',
|
|
79
|
+
},
|
|
80
|
+
content: { flex: '1 1 auto', 'min-width': '0' },
|
|
81
|
+
title: { ...typeStyle('label-md'), color: '{color.fg-default}' },
|
|
82
|
+
description: { ...typeStyle('body-md'), 'margin-top': '{space.1}', color: '{color.fg-muted}' },
|
|
83
|
+
actions: { display: 'flex', 'flex-wrap': 'wrap', 'align-items': 'center', gap: '{space.3}', 'margin-top': '{space.3}' },
|
|
84
|
+
dismiss: {
|
|
85
|
+
...RESET_BUTTON,
|
|
86
|
+
display: 'inline-flex',
|
|
87
|
+
'align-items': 'center',
|
|
88
|
+
'justify-content': 'center',
|
|
89
|
+
'flex-shrink': '0',
|
|
90
|
+
width: '{size.control.sm}',
|
|
91
|
+
height: '{size.control.sm}',
|
|
92
|
+
'margin-block': 'calc({space.2} * -1)',
|
|
93
|
+
'margin-inline-end': 'calc({space.2} * -1)',
|
|
94
|
+
'border-radius': '{radius.control}',
|
|
95
|
+
color: '{color.fg-subtle}',
|
|
96
|
+
...TRANSITION_COLORS,
|
|
97
|
+
'transition-duration': '{motion.duration.fast}',
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
variants: {
|
|
101
|
+
tone: Object.fromEntries(Object.entries(TONES).map(([t, v]) => [t, { icon: { color: v.fg } }])),
|
|
102
|
+
variant: {
|
|
103
|
+
soft: { root: {} },
|
|
104
|
+
outline: { root: { 'background-color': '{color.bg-surface}', 'border-color': '{color.border-control}' } },
|
|
105
|
+
},
|
|
106
|
+
layout: {
|
|
107
|
+
inline: { root: {} },
|
|
108
|
+
floating: { root: { 'box-shadow': '{shadow.lg}' } },
|
|
109
|
+
'full-width': {
|
|
110
|
+
root: { 'border-radius': '{radius.none}', 'border-inline': '0', 'border-top': '0', 'box-shadow': 'none', 'align-items': 'center', 'padding-inline': '{space.8}' },
|
|
111
|
+
dismiss: { 'margin-block': '0' },
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
compound,
|
|
116
|
+
extraCss: `
|
|
117
|
+
.cn-alert .cn-alert__icon .cn-icon { width: {size.icon.md}; height: {size.icon.md}; }
|
|
118
|
+
.cn-alert .cn-alert__icon::before, .cn-alert .cn-alert__icon::after { content: ''; position: absolute; border-radius: {radius.full}; border: {border.width.medium} solid currentColor; pointer-events: none; }
|
|
119
|
+
.cn-alert .cn-alert__icon::before { inset: -4px; opacity: 0.3; }
|
|
120
|
+
.cn-alert .cn-alert__icon::after { inset: -9px; opacity: 0.1; }
|
|
121
|
+
.cn-alert .cn-alert__dismiss .cn-icon { width: {size.icon.md}; height: {size.icon.md}; }
|
|
122
|
+
.cn-alert .cn-alert__dismiss:hover { background-color: {color.bg-subtle}; color: {color.fg-muted}; }
|
|
123
|
+
.cn-alert .cn-alert__dismiss:focus-visible { outline: none; box-shadow: ${FOCUS_RING['box-shadow']}; }
|
|
124
|
+
.cn-alert .cn-alert__actions .cn-button[data-variant="link"]:first-child:not(:last-child) { color: {color.fg-muted}; }
|
|
125
|
+
.cn-alert[data-layout="full-width"] > .cn-alert__actions { margin-top: 0; margin-inline-start: auto; flex-shrink: 0; }`,
|
|
126
|
+
examples: [
|
|
127
|
+
ex('Default (outline, neutral, inline)', `<div class="cn-alert" data-tone="neutral" data-variant="outline" data-layout="inline" role="status" style="max-width:640px">${icon('info')}<div class="cn-alert__content"><div class="cn-alert__title">We’ve just released a new feature</div><p class="cn-alert__description">Check out the all new dashboard view. Pages now load faster.</p>${actions()}</div>${dismiss}</div>`, 'The "default" alert: white surface, gray featured icon, two link actions (gray then brand), close button top-right.'),
|
|
128
|
+
ex('Brand (soft)', `<div class="cn-alert" data-tone="brand" data-variant="soft" data-layout="inline" role="status" style="max-width:640px">${icon('spark')}<div class="cn-alert__content"><div class="cn-alert__title">We’ve just released a new feature</div><p class="cn-alert__description">Check out the all new dashboard view. Pages now load faster.</p>${actions()}</div>${dismiss}</div>`),
|
|
129
|
+
ex('Info (soft)', `<div class="cn-alert" data-tone="info" data-variant="soft" data-layout="inline" role="status" style="max-width:640px">${icon('info')}<div class="cn-alert__content"><div class="cn-alert__title">A new export format is available</div><p class="cn-alert__description">You can now download reports as an Excel workbook with one sheet per team.</p></div>${dismiss}</div>`),
|
|
130
|
+
ex('Success (soft)', `<div class="cn-alert" data-tone="success" data-variant="soft" data-layout="inline" role="status" style="max-width:640px">${icon('check')}<div class="cn-alert__content"><div class="cn-alert__title">Successfully updated profile</div><p class="cn-alert__description">Your changes have been saved and will appear in the team directory within a few minutes.</p>${actions('Dismiss', 'View profile')}</div>${dismiss}</div>`),
|
|
131
|
+
ex('Warning (soft)', `<div class="cn-alert" data-tone="warning" data-variant="soft" data-layout="inline" role="alert" style="max-width:640px">${icon('warning')}<div class="cn-alert__content"><div class="cn-alert__title">Your trial ends in 3 days</div><p class="cn-alert__description">Add a payment method to keep your projects and team members after 14 Sep.</p>${actions('Dismiss', 'Upgrade plan')}</div>${dismiss}</div>`),
|
|
132
|
+
ex('Danger (soft)', `<div class="cn-alert" data-tone="danger" data-variant="soft" data-layout="inline" role="alert" style="max-width:640px">${icon('warning')}<div class="cn-alert__content"><div class="cn-alert__title">There was a problem with that action</div><p class="cn-alert__description">We couldn’t save your changes. Check your connection and try again.</p>${actions('Dismiss', 'Try again')}</div></div>`, 'Danger alerts have no close button while the problem is open; they leave when it is fixed.'),
|
|
133
|
+
ex('Floating (shadow-lg)', `<div class="cn-alert" data-tone="neutral" data-variant="outline" data-layout="floating" role="status" style="max-width:640px">${icon('info')}<div class="cn-alert__content"><div class="cn-alert__title">We’ve just released a new feature</div><p class="cn-alert__description">Check out the all new dashboard view. Pages now load faster.</p>${actions()}</div>${dismiss}</div>`, 'Same card with shadow-lg, for alerts rendered over the page (bottom of the viewport, above a table).'),
|
|
134
|
+
ex('Full-width bar', `<div class="cn-alert" data-tone="neutral" data-variant="outline" data-layout="full-width" role="status">${icon('info')}<div class="cn-alert__content"><div class="cn-alert__title">We’ve just released a new feature</div><p class="cn-alert__description">Check out the all new dashboard view. Pages now load faster.</p></div>${actions()}${dismiss}</div>`, 'Edge to edge under the topbar: square corners, bottom hairline only, actions on the right as a direct child of the root.'),
|
|
135
|
+
],
|
|
136
|
+
rules: [
|
|
137
|
+
'Title says what happened in plain words ("We’ve just released a new feature"); the description says what to do next. Never "Error" or "Warning" as the title.',
|
|
138
|
+
'Keep it to one title plus at most two sentences. If it needs more, link to a page.',
|
|
139
|
+
'At most two actions, size sm, variant link: the quiet one first ("Dismiss", rendered gray), the useful one last ("View changes", brand). Never a primary or danger Button inside an Alert.',
|
|
140
|
+
'Text stays neutral in every tone (title fg-default, description fg-muted). Only the icon, the border and the tint carry the color, so a soft danger alert is still readable.',
|
|
141
|
+
'inline alerts sit at the top of the region they describe (above the form, inside the card); floating alerts are the same card with shadow-lg over the page; full-width bars go directly under the topbar or at the top of a section, never inside a card.',
|
|
142
|
+
'Use soft by default. Use outline (the "default" alert) when several alerts stack, when the alert is inside another tinted surface, or when the message is neutral news.',
|
|
143
|
+
'Only neutral, brand, info and success alerts are dismissible without resolving anything. Danger alerts disappear when the problem is fixed, not when the user closes them.',
|
|
144
|
+
'Icon is required and comes from the tone: neutral/info/brand → ICON.info (or ICON.spark for brand news), success → ICON.check, warning/danger → ICON.warning. Wrap it in the icon span; the rings are drawn by CSS.',
|
|
145
|
+
'Do not animate alerts in or out; they appear with the content.',
|
|
146
|
+
],
|
|
147
|
+
a11y: [
|
|
148
|
+
'role="alert" for warning/danger alerts inserted after load (assistive tech announces them immediately); role="status" for neutral/brand/info/success.',
|
|
149
|
+
'The featured icon is decorative (aria-hidden on the span); the title carries the meaning, so color is never the only signal.',
|
|
150
|
+
'The close button needs aria-label="Dismiss" and is the last focusable element inside the alert.',
|
|
151
|
+
'Actions are real <button> or <a> elements in reading order after the description.',
|
|
152
|
+
],
|
|
153
|
+
related: ['banner', 'toast', 'dialog', 'button', 'badge'],
|
|
154
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { RESET_BUTTON, TRANSITION_COLORS, STATE, ex } from "./_shared.mjs";
|
|
2
|
+
// "Download on the App Store" / "Get it on Google Play" badges: a 40px pill-ish box with a 24px
|
|
3
|
+
// store glyph, a tiny kicker line and a big label. Dark (inverse fill, thin grey border) or outline
|
|
4
|
+
// (surface fill, hairline). The glyphs in the examples are simple monochrome shapes, not trademark art.
|
|
5
|
+
const GLYPH = {
|
|
6
|
+
apple: '<circle cx="12" cy="13.5" r="6.5" fill="none" stroke="currentColor" stroke-width="1.75"/><path d="M12 7c0-2.2 1.3-3.5 3.5-3.5" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/>',
|
|
7
|
+
google: '<path d="M6.5 4.5v15l12.5-7.5z" fill="currentColor"/>',
|
|
8
|
+
};
|
|
9
|
+
const COPY = {
|
|
10
|
+
apple: { kicker: 'Download on the', label: 'App Store', name: 'App Store' },
|
|
11
|
+
google: { kicker: 'Get it on', label: 'Google Play', name: 'Google Play' },
|
|
12
|
+
};
|
|
13
|
+
const badge = (store, theme = 'dark') => `<a href="#" class="cn-app-store-button" data-store="${store}" data-theme="${theme}" aria-label="${COPY[store].kicker} ${COPY[store].name}"><svg class="cn-app-store-button__icon" viewBox="0 0 24 24" aria-hidden="true">${GLYPH[store]}</svg><span class="cn-app-store-button__kicker">${COPY[store].kicker}</span><span class="cn-app-store-button__label">${COPY[store].label}</span></a>`;
|
|
14
|
+
const row = (...items) => `<div style="display:flex;flex-wrap:wrap;align-items:center;gap:var(--cn-space-3)">${items.join('')}</div>`;
|
|
15
|
+
export const appStoreButton = {
|
|
16
|
+
name: 'AppStoreButton',
|
|
17
|
+
slug: 'app-store-button',
|
|
18
|
+
category: 'actions',
|
|
19
|
+
description: 'The download badge for a mobile app: a 40px box with the store glyph on the left and two lines of text, a tiny kicker ("Download on the") over a bold store name. Dark by default, or outline on dark and busy backgrounds.',
|
|
20
|
+
usage: 'Marketing pages, app footers and "get the app" banners, always as a pair (both stores) or a single badge when only one exists. Never inside the app shell, never as a generic download button (use Button with a leading icon).',
|
|
21
|
+
anatomy: [
|
|
22
|
+
{ part: 'root', element: 'a', description: 'The badge link (inline-grid: icon column + two text rows). href points to the store listing; aria-label repeats the two lines. Carries data-store and data-theme.' },
|
|
23
|
+
{ part: 'icon', element: 'svg', description: '24px store glyph spanning both text rows. Monochrome, currentColor. aria-hidden.' },
|
|
24
|
+
{ part: 'kicker', element: 'span', description: 'The small first line ("Download on the", "Get it on"), 11px medium, at 70% of the text color.' },
|
|
25
|
+
{ part: 'label', element: 'span', description: 'The store name in 16px semibold with tight tracking.' },
|
|
26
|
+
],
|
|
27
|
+
props: {
|
|
28
|
+
store: { values: ['apple', 'google'], default: 'apple', description: 'apple = "Download on the App Store"; google = "Get it on Google Play". The store also picks the glyph and the copy in the markup.' },
|
|
29
|
+
theme: { values: ['dark', 'outline'], default: 'dark', description: 'dark = inverse fill with light text and a thin grey border, the standard badge on light pages; outline = surface fill with a hairline and default ink, for dark or photographic backgrounds and for footers where a black block would be too heavy.' },
|
|
30
|
+
},
|
|
31
|
+
states: {
|
|
32
|
+
hover: STATE.hover(),
|
|
33
|
+
active: STATE.active(),
|
|
34
|
+
focus: STATE.focus(),
|
|
35
|
+
},
|
|
36
|
+
base: {
|
|
37
|
+
root: {
|
|
38
|
+
...RESET_BUTTON,
|
|
39
|
+
display: 'inline-grid',
|
|
40
|
+
'grid-template-columns': 'auto 1fr',
|
|
41
|
+
'grid-template-rows': 'auto auto',
|
|
42
|
+
'column-gap': '{space.2}',
|
|
43
|
+
'align-items': 'center',
|
|
44
|
+
'align-content': 'center',
|
|
45
|
+
'justify-items': 'start',
|
|
46
|
+
height: '{space.10}',
|
|
47
|
+
'padding-inline': '{space.2.5} {space.3}',
|
|
48
|
+
'border-radius': '{radius.control}',
|
|
49
|
+
border: '{border.width.thin} solid transparent',
|
|
50
|
+
'text-align': 'start',
|
|
51
|
+
'white-space': 'nowrap',
|
|
52
|
+
'flex-shrink': '0',
|
|
53
|
+
'vertical-align': 'middle',
|
|
54
|
+
...TRANSITION_COLORS,
|
|
55
|
+
},
|
|
56
|
+
icon: { 'grid-row': '1 / span 2', display: 'block', width: '{size.icon.xl}', height: '{size.icon.xl}', 'flex-shrink': '0', 'pointer-events': 'none' },
|
|
57
|
+
kicker: { 'grid-column': '2', 'font-family': '{font.family.sans}', 'font-size': '{font.size.2xs}', 'font-weight': '{font.weight.medium}', 'line-height': '{font.lineHeight.none}', 'letter-spacing': '{font.letterSpacing.normal}', opacity: '{opacity.muted}' },
|
|
58
|
+
label: { 'grid-column': '2', 'font-family': '{font.family.sans}', 'font-size': '{font.size.lg}', 'font-weight': '{font.weight.semibold}', 'line-height': '{font.lineHeight.none}', 'letter-spacing': '{font.letterSpacing.tight}', 'margin-top': '{space.0.5}' },
|
|
59
|
+
'@states': {
|
|
60
|
+
focus: { root: { outline: 'none', 'box-shadow': '{shadow.focus}' } },
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
variants: {
|
|
64
|
+
store: { apple: { root: {} }, google: { root: {} } },
|
|
65
|
+
theme: {
|
|
66
|
+
dark: {
|
|
67
|
+
root: { 'background-color': '{color.bg-inverse}', color: '{color.fg-inverse}', 'border-color': '{neutral.500}' },
|
|
68
|
+
'@states': {
|
|
69
|
+
hover: { root: { 'background-color': 'color-mix(in srgb, {color.bg-inverse} 88%, {color.bg-surface})' } },
|
|
70
|
+
active: { root: { 'background-color': 'color-mix(in srgb, {color.bg-inverse} 80%, {color.bg-surface})' } },
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
outline: {
|
|
74
|
+
root: { 'background-color': '{color.bg-surface}', color: '{color.fg-default}', 'border-color': '{color.border-control}', 'box-shadow': '{shadow.xs}' },
|
|
75
|
+
'@states': {
|
|
76
|
+
hover: { root: { 'background-color': '{color.bg-subtle}', 'border-color': '{color.border-control-hover}' } },
|
|
77
|
+
active: { root: { 'background-color': '{color.bg-muted}' } },
|
|
78
|
+
focus: { root: { 'box-shadow': '{shadow.xs}, {shadow.focus}' } },
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
examples: [
|
|
84
|
+
ex('Dark pair', row(badge('apple'), badge('google')), 'The standard pair on a light page: inverse fill, thin grey border, 40px tall.'),
|
|
85
|
+
ex('Outline pair', row(badge('apple', 'outline'), badge('google', 'outline')), 'Surface fill with a hairline for footers and busy backgrounds.'),
|
|
86
|
+
ex('Outline on an inverse band', `<div style="background:var(--cn-color-bg-inverse);padding:var(--cn-space-5);border-radius:var(--cn-radius-lg)">${row(badge('apple', 'outline'), badge('google', 'outline'))}</div>`, 'On a dark band the outline theme keeps the badges readable; the dark theme would disappear.'),
|
|
87
|
+
ex('Single store', badge('apple', 'dark'), 'When the app exists on one store only. Do not fake the other badge.'),
|
|
88
|
+
],
|
|
89
|
+
recipes: [
|
|
90
|
+
ex('Get-the-app footer row', `<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:var(--cn-space-4);max-width:640px;padding:var(--cn-space-5);border:1px solid var(--cn-color-border-default);border-radius:var(--cn-radius-card)"><div><div class="cn-text-heading-xs">Lumen for iOS and Android</div><div class="cn-text-body-sm" style="color:var(--cn-color-fg-muted)">Reply to buyers and approve quotes from your phone.</div></div>${row(badge('apple'), badge('google'))}</div>`, 'Title and one line of copy on the left, the pair on the right; they wrap under the copy on narrow screens.'),
|
|
91
|
+
],
|
|
92
|
+
rules: [
|
|
93
|
+
'Always 40px tall, never scaled: the badge is a fixed-size mark, not a button that grows with the layout.',
|
|
94
|
+
'Show both stores side by side with a 12px gap, Apple first, in the same theme; show one badge only when the app really exists on one store.',
|
|
95
|
+
'Copy is fixed by the store: "Download on the / App Store" and "Get it on / Google Play". Do not translate the store names or invent kickers.',
|
|
96
|
+
'dark on light pages; outline on dark, photographic or tinted backgrounds and in footers. Never both themes on one page.',
|
|
97
|
+
'In production use the stores\' official badge artwork inside the icon slot and follow their clear-space rules; the example glyphs are placeholders.',
|
|
98
|
+
'The badge links to the store listing (or a smart link) and opens in the same tab on mobile, a new tab on desktop.',
|
|
99
|
+
'Never place the badges inside the app shell or next to in-app actions; they belong to marketing surfaces and onboarding emails.',
|
|
100
|
+
],
|
|
101
|
+
a11y: [
|
|
102
|
+
'It is an <a href> with an aria-label that reads both lines ("Download on the App Store"); the glyph is aria-hidden.',
|
|
103
|
+
'The two text lines are real text, not an image, so they scale with the user\'s font size and remain translatable.',
|
|
104
|
+
'Focus ring on :focus-visible; on the dark theme it is the only hover-independent state, so keep it visible.',
|
|
105
|
+
'Kicker opacity is 70% of the text color, which keeps contrast ≥ 4.5:1 on both themes; do not lower it.',
|
|
106
|
+
],
|
|
107
|
+
related: ['social-button', 'button', 'link'],
|
|
108
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { ex, ICON, typeStyle } from "./_shared.mjs";
|
|
2
|
+
// the reference avatar group: stacked avatars (xs 24 · sm 32 · md 40 · lg 48) overlapping by
|
|
3
|
+
// 4 / 8 / 8 / 12px, each with a 1.5px surface ring, ending in a "+N" gray disc and optionally
|
|
4
|
+
// the dashed add button.
|
|
5
|
+
const SIZE = {
|
|
6
|
+
xs: { box: '{space.6}', font: '{font.size.xs}', overlap: '{space.1}', icon: '{size.icon.sm}' },
|
|
7
|
+
sm: { box: '{space.8}', font: '{font.size.sm}', overlap: '{space.2}', icon: '{size.icon.sm}' },
|
|
8
|
+
md: { box: '{space.10}', font: '{font.size.md}', overlap: '{space.2}', icon: '{size.icon.md}' },
|
|
9
|
+
lg: { box: '{space.12}', font: '{font.size.lg}', overlap: '{space.3}', icon: '{size.icon.md}' },
|
|
10
|
+
};
|
|
11
|
+
const RING = '0 0 0 1.5px {color.bg-surface}';
|
|
12
|
+
const SILHOUETTE = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' fill='%23CDCED1'/%3E%3Ccircle cx='32' cy='25' r='11' fill='%23FAFAFA'/%3E%3Cpath d='M10 64c2-13 11-21 22-21s20 8 22 21z' fill='%23FAFAFA'/%3E%3C/svg%3E";
|
|
13
|
+
const person = (size, text, name, image = false) => `<span class="cn-avatar cn-avatar-group__item" data-size="${size}" data-shape="circle" data-tone="neutral" data-status="none" role="img" aria-label="${name}">${image ? `<img class="cn-avatar__image" src="${SILHOUETTE}" alt="">` : `<span class="cn-avatar__fallback" aria-hidden="true">${text}</span>`}</span>`;
|
|
14
|
+
const PEOPLE = [['MC', 'Maya Chen'], ['DC', 'Daniel Costa'], ['SA', 'Sofia Almeida'], ['LF', 'Lucas Ferreira'], ['AK', 'Aisha Khan']];
|
|
15
|
+
const group = (size, count, overflow, label, images = false, add = false) => `<div class="cn-avatar-group" data-size="${size}" role="group" aria-label="${label ?? `Assigned to ${PEOPLE.slice(0, count).map((p) => p[1]).join(', ')}`}">${PEOPLE.slice(0, count).map(([t, n], i) => person(size, t, n, images && i % 2 === 0)).join('')}${overflow ? `<span class="cn-avatar-group__overflow">${overflow}</span>` : ''}${add ? `<button type="button" class="cn-avatar-group__add" aria-label="Add person">${ICON.plus.replace('cn-icon', 'cn-avatar-group__icon')}</button>` : ''}</div>`;
|
|
16
|
+
export const avatarGroup = {
|
|
17
|
+
name: 'AvatarGroup',
|
|
18
|
+
slug: 'avatar-group',
|
|
19
|
+
category: 'data-display',
|
|
20
|
+
description: 'A row of overlapping avatars for the people on one thing, each separated by a 1.5px surface ring, ending in a "+3" gray disc when there are more than fit and optionally the dashed add button.',
|
|
21
|
+
usage: 'Assignees, participants, watchers: several people in one slot (a table cell, a card footer, a header). One person is an Avatar, not a group of one. Not for companies or integrations.',
|
|
22
|
+
anatomy: [
|
|
23
|
+
{ part: 'root', element: 'div', description: 'Inline-flex row, role="group" with an aria-label that summarizes who is in it.' },
|
|
24
|
+
{ part: 'item', element: 'span', description: 'An Avatar root that also carries this class. Gets the 1.5px surface ring and the negative overlap; the group\'s data-size overrides the avatar size.' },
|
|
25
|
+
{ part: 'overflow', element: 'span', description: 'The "+N" disc, same size as the avatars, gray-50 with a gray-200 ring and 12–16px semibold gray-500 text. A <button> when it opens the full list.', optional: true },
|
|
26
|
+
{ part: 'add', element: 'button', description: 'The add button: a dashed gray-300 circle on white with a plus icon, same size as the avatars, 8px after the row.', optional: true },
|
|
27
|
+
{ part: 'icon', element: 'svg', description: 'The 16 / 20px plus icon inside the add button.', optional: true },
|
|
28
|
+
],
|
|
29
|
+
props: {
|
|
30
|
+
size: {
|
|
31
|
+
values: ['xs', 'sm', 'md', 'lg'],
|
|
32
|
+
default: 'md',
|
|
33
|
+
description: 'Avatar size inside the group: xs 24px (table cells, kanban cards), sm 32px (list rows, card footers), md 40px (default: card headers), lg 48px (page headers). Pass the same value as data-size on each avatar so initials scale too.',
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
states: {},
|
|
37
|
+
base: {
|
|
38
|
+
root: { display: 'inline-flex', 'align-items': 'center', 'flex-shrink': '0', 'vertical-align': 'middle' },
|
|
39
|
+
item: { position: 'relative', 'flex-shrink': '0', 'box-shadow': RING },
|
|
40
|
+
overflow: {
|
|
41
|
+
display: 'inline-flex',
|
|
42
|
+
'align-items': 'center',
|
|
43
|
+
'justify-content': 'center',
|
|
44
|
+
position: 'relative',
|
|
45
|
+
'flex-shrink': '0',
|
|
46
|
+
'padding-inline': '{space.1}',
|
|
47
|
+
'padding-block': '0',
|
|
48
|
+
'border-radius': '{radius.full}',
|
|
49
|
+
'background-color': '{color.bg-subtle}',
|
|
50
|
+
color: '{color.fg-subtle}',
|
|
51
|
+
...typeStyle('label-xs'),
|
|
52
|
+
'font-weight': '{font.weight.semibold}',
|
|
53
|
+
'font-variant-numeric': 'tabular-nums',
|
|
54
|
+
'white-space': 'nowrap',
|
|
55
|
+
'user-select': 'none',
|
|
56
|
+
'box-shadow': `${RING}, inset 0 0 0 1px {color.border-default}`,
|
|
57
|
+
},
|
|
58
|
+
add: {
|
|
59
|
+
display: 'inline-flex',
|
|
60
|
+
'align-items': 'center',
|
|
61
|
+
'justify-content': 'center',
|
|
62
|
+
'flex-shrink': '0',
|
|
63
|
+
appearance: 'none',
|
|
64
|
+
margin: '0',
|
|
65
|
+
padding: '0',
|
|
66
|
+
'margin-inline-start': '{space.2}',
|
|
67
|
+
'border-radius': '{radius.full}',
|
|
68
|
+
border: '{border.width.thin} dashed {color.border-control}',
|
|
69
|
+
'background-color': '{color.bg-surface}',
|
|
70
|
+
color: '{color.fg-subtle}',
|
|
71
|
+
cursor: 'pointer',
|
|
72
|
+
'transition-property': 'background-color, color, box-shadow',
|
|
73
|
+
'transition-duration': '{motion.duration.fast}',
|
|
74
|
+
'transition-timing-function': '{motion.easing.linear}',
|
|
75
|
+
},
|
|
76
|
+
icon: { display: 'block', 'flex-shrink': '0' },
|
|
77
|
+
},
|
|
78
|
+
variants: {
|
|
79
|
+
size: Object.fromEntries(Object.entries(SIZE).map(([s, v]) => [s, {
|
|
80
|
+
item: { width: v.box, height: v.box },
|
|
81
|
+
overflow: { 'min-width': v.box, height: v.box, 'font-size': v.font },
|
|
82
|
+
add: { width: v.box, height: v.box },
|
|
83
|
+
icon: { width: v.icon, height: v.icon },
|
|
84
|
+
}])),
|
|
85
|
+
},
|
|
86
|
+
extraCss: `
|
|
87
|
+
.cn-avatar-group > .cn-avatar-group__item + .cn-avatar-group__item, .cn-avatar-group > .cn-avatar-group__item + .cn-avatar-group__overflow { margin-inline-start: calc({space.2} * -1); }
|
|
88
|
+
.cn-avatar-group[data-size="xs"] > .cn-avatar-group__item + .cn-avatar-group__item, .cn-avatar-group[data-size="xs"] > .cn-avatar-group__item + .cn-avatar-group__overflow { margin-inline-start: calc({space.1} * -1); }
|
|
89
|
+
.cn-avatar-group[data-size="lg"] > .cn-avatar-group__item + .cn-avatar-group__item, .cn-avatar-group[data-size="lg"] > .cn-avatar-group__item + .cn-avatar-group__overflow { margin-inline-start: calc({space.3} * -1); }
|
|
90
|
+
.cn-avatar-group > :nth-child(1) { z-index: 6; }
|
|
91
|
+
.cn-avatar-group > :nth-child(2) { z-index: 5; }
|
|
92
|
+
.cn-avatar-group > :nth-child(3) { z-index: 4; }
|
|
93
|
+
.cn-avatar-group > :nth-child(4) { z-index: 3; }
|
|
94
|
+
.cn-avatar-group > :nth-child(5) { z-index: 2; }
|
|
95
|
+
.cn-avatar-group > :nth-child(6) { z-index: 1; }
|
|
96
|
+
.cn-avatar-group > .cn-avatar-group__add { z-index: 0; }
|
|
97
|
+
button.cn-avatar-group__overflow { appearance: none; border: 0; margin: 0; cursor: pointer; margin-inline-start: calc({space.2} * -1); }
|
|
98
|
+
button.cn-avatar-group__overflow:hover { background-color: {color.bg-muted}; color: {color.fg-muted}; }
|
|
99
|
+
button.cn-avatar-group__overflow:focus-visible { outline: none; box-shadow: ${RING}, {shadow.focus}; }
|
|
100
|
+
.cn-avatar-group__add:hover { background-color: {color.bg-subtle}; color: {color.fg-muted}; }
|
|
101
|
+
.cn-avatar-group__add:focus-visible { outline: none; box-shadow: {shadow.focus}; }
|
|
102
|
+
.cn-avatar-group__add:disabled { opacity: {opacity.disabled}; cursor: not-allowed; }`,
|
|
103
|
+
examples: [
|
|
104
|
+
ex('Three people', group('md', 3, undefined, undefined, true), '40px avatars overlapping by 8px, each with the 1.5px surface ring; the first is drawn on top.'),
|
|
105
|
+
ex('Five visible, then overflow', group('md', 5, '+3', 'Assigned to Maya Chen, Daniel Costa, Sofia Almeida, Lucas Ferreira, Aisha Khan and 3 others', true), 'Never more than five avatars; the gray disc carries the rest.'),
|
|
106
|
+
ex('Sizes', `<div style="display:flex;align-items:center;gap:var(--cn-space-6);flex-wrap:wrap">${group('xs', 3, '+2')} ${group('sm', 3, '+2')} ${group('md', 3, '+2')} ${group('lg', 3, '+2')}</div>`, 'xs 24 · sm 32 · md 40 · lg 48; the overlap is 4 / 8 / 8 / 12px.'),
|
|
107
|
+
ex('With the add button', group('md', 4, undefined, 'Maya Chen, Daniel Costa, Sofia Almeida, Lucas Ferreira', true, true), 'The dashed add button sits 8px after the row, same size as the avatars.'),
|
|
108
|
+
ex('Overflow as a button', `<div class="cn-avatar-group" data-size="sm" role="group" aria-label="8 participants">${PEOPLE.slice(0, 4).map(([t, n]) => person('sm', t, n)).join('')}<button type="button" class="cn-avatar-group__overflow" aria-label="Show all 8 participants">+4</button></div>`, 'Only when clicking reveals the full list.'),
|
|
109
|
+
],
|
|
110
|
+
rules: [
|
|
111
|
+
'Maximum five visible avatars. Beyond that, the overflow disc shows the remaining count ("+3"); never a second row.',
|
|
112
|
+
'Source order is relevance order: owner first, then assignees by recency. The first avatar is drawn on top so it is fully visible.',
|
|
113
|
+
'One size and one shape per group; pass the group size as data-size on every avatar as well.',
|
|
114
|
+
'Initials inside a group are fine at sm and up; at xs use a single letter so the 4px overlap does not clip them. Photos are unaffected.',
|
|
115
|
+
'The overflow disc is a <span> unless clicking it opens the full list; then it is a <button> with aria-label "Show all N people".',
|
|
116
|
+
'The add button belongs to groups the user can edit (assignees, members); never in read-only cells.',
|
|
117
|
+
'The rings are bg-surface: place groups on surface or canvas, not on tinted fills.',
|
|
118
|
+
'No status dots inside a group; the overlap hides them. Show presence in the expanded list instead.',
|
|
119
|
+
'A group of one is an Avatar. A group of zero is nothing (or "Unassigned" text), never an empty disc.',
|
|
120
|
+
],
|
|
121
|
+
a11y: [
|
|
122
|
+
'root has role="group" and an aria-label summarizing the members ("Assigned to Maya Chen, Daniel Costa and 3 others") so it is not read one avatar at a time; each avatar keeps its own label.',
|
|
123
|
+
'The overflow count is real text and is announced; as a button it needs an aria-label with the total count.',
|
|
124
|
+
'The add button needs aria-label ("Add person") and is the only focusable element besides an overflow button.',
|
|
125
|
+
'Overlap is visual only; the DOM order is the meaningful order.',
|
|
126
|
+
],
|
|
127
|
+
related: ['avatar', 'list', 'table', 'kanban', 'card-header'],
|
|
128
|
+
};
|