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,207 @@
|
|
|
1
|
+
import { ex, ICON, typeStyle } from "./_shared.mjs";
|
|
2
|
+
// the reference header navigation: a 64px bar on a gray-200 hairline, a 1280px container with
|
|
3
|
+
// 32px side padding, the logo 16px before a row of 32px nav pills (radius 6, 14px semibold,
|
|
4
|
+
// gray-50 when hovered or current), actions 12px apart on the right; an optional second 64px tier
|
|
5
|
+
// with more pills or underline tabs whose bar sits on the tier's hairline.
|
|
6
|
+
const TRANSITION_FAST = {
|
|
7
|
+
'transition-property': 'background-color, color, box-shadow',
|
|
8
|
+
'transition-duration': '{motion.duration.fast}',
|
|
9
|
+
'transition-timing-function': '{motion.easing.linear}',
|
|
10
|
+
};
|
|
11
|
+
const HAIRLINE = '{border.width.thin} solid {color.border-default}';
|
|
12
|
+
const item = (label, current = false, i) => `<a href="#" class="cn-topbar__item"${current ? ' aria-current="page"' : ''}>${i ? ICON[i].replace('cn-icon', 'cn-topbar__icon') : ''}${label}</a>`;
|
|
13
|
+
const iconItem = (label, i, count = '') => `<a href="#" class="cn-topbar__item" data-icon-only aria-label="${label}">${ICON[i].replace('cn-icon', 'cn-topbar__icon')}${count ? `<span class="cn-topbar__count">${count}</span>` : ''}</a>`;
|
|
14
|
+
const btn = (label, variant) => `<button type="button" class="cn-button" data-variant="${variant}" data-size="sm"><span class="cn-button__label">${label}</span></button>`;
|
|
15
|
+
const AVATAR = `<button type="button" class="cn-avatar" data-size="md" data-shape="circle" data-tone="neutral" data-status="online" aria-label="Account: Maya Chen" aria-haspopup="menu" tabindex="0"><span class="cn-avatar__fallback" aria-hidden="true">MC</span><span class="cn-avatar__status"></span></button>`;
|
|
16
|
+
const SEARCH = `<div class="cn-topbar__search cn-input" data-variant="default" data-size="sm">${ICON.search.replace('cn-icon', 'cn-input__icon')}<input class="cn-input__field" type="search" placeholder="Search" aria-label="Search"><kbd class="cn-kbd">⌘K</kbd></div>`;
|
|
17
|
+
const BRAND = `<a href="#" class="cn-topbar__brand"><span class="cn-topbar__logo" aria-hidden="true"></span>Lumen</a>`;
|
|
18
|
+
const NAV = `<nav class="cn-topbar__nav" aria-label="Primary">${item('Home')}${item('Dashboard', true)}${item('Projects')}${item('Tasks')}${item('Reporting')}${item('Users')}</nav>`;
|
|
19
|
+
const ACTIONS = `<div class="cn-topbar__actions">${iconItem('Settings', 'settings')}${iconItem('Notifications', 'inbox', '3')}${AVATAR}</div>`;
|
|
20
|
+
const routeTabs = (current, labels) => `<div class="cn-tabs" data-variant="underline" data-size="sm" data-orientation="horizontal" data-width="hug">${labels.map((l, i) => `<a href="#" class="cn-tabs__tab"${i === current ? ' aria-current="page"' : ''}>${l}</a>`).join('')}</div>`;
|
|
21
|
+
export const topbar = {
|
|
22
|
+
name: 'Topbar',
|
|
23
|
+
slug: 'topbar',
|
|
24
|
+
category: 'navigation',
|
|
25
|
+
description: 'The application header of the reference: a 64px bar on a hairline with the logo, a row of 32px navigation pills, a search field and the account on the right; optionally a second 64px tier with more pills or underline tabs. Sticky, opaque or translucent.',
|
|
26
|
+
usage: 'One per app, at the top of every authenticated page when the app has 3–6 top-level destinations and no Sidebar. Holds the product mark, the primary navigation, global search and the account. Page titles, breadcrumbs and filters do not live here; they belong to the PageHeader below.',
|
|
27
|
+
anatomy: [
|
|
28
|
+
{ part: 'root', element: 'header', description: 'Full-width sticky bar on a surface with a gray-200 hairline below; z-index sticky.' },
|
|
29
|
+
{ part: 'inner', element: 'div', description: 'The 64px row: a 1280px container with 32px side padding (16 on mobile), items vertically centered, 16px gap.' },
|
|
30
|
+
{ part: 'brand', element: 'a', description: 'Logo mark + product name (label-lg semibold, 24px tall), links to the home view; 16px before the nav.' },
|
|
31
|
+
{ part: 'logo', element: 'span', description: 'The 24px brand mark (an image, an inline SVG or the placeholder square).', optional: true },
|
|
32
|
+
{ part: 'nav', element: 'nav', description: 'Primary navigation: a row of items 2px apart. May instead hold an underline Tabs sm whose bar sits on the topbar hairline.' },
|
|
33
|
+
{ part: 'item', element: 'a', description: 'One nav pill: 32px tall (padding 6 × 8), radius 6, 14px semibold gray-700; gray-50 when hovered or current. Icon-only pills are 36 × 36 (data-icon-only + aria-label).' },
|
|
34
|
+
{ part: 'icon', element: 'svg', description: '20px icon inside an item, fg-subtle; darkens with the label. Decorative.', optional: true },
|
|
35
|
+
{ part: 'count', element: 'span', description: 'Unread count on an icon-only item: a 14px red circle with 10px bold white text, top-right.', optional: true },
|
|
36
|
+
{ part: 'search', element: 'div', description: 'Global search: an Input sm with a ⌘K Kbd, max 280px, in the actions row.', optional: true },
|
|
37
|
+
{ part: 'actions', element: 'div', description: 'Right side, 12px gap: search, icon-only items (settings, notifications), one outline and one primary Button at most, then the account Avatar (a button with aria-haspopup="menu").' },
|
|
38
|
+
{ part: 'secondary', element: 'div', description: 'Optional second tier under the first: a 64px row on its own hairline holding nav items, or an underline Tabs sm (data-nav="tabs") whose bar sits on the tier\'s hairline (padding 12 × 32, 0 bottom).', optional: true },
|
|
39
|
+
],
|
|
40
|
+
props: {
|
|
41
|
+
variant: {
|
|
42
|
+
values: ['solid', 'translucent'],
|
|
43
|
+
default: 'solid',
|
|
44
|
+
description: 'solid = opaque surface; the default for app shells. translucent = 85% surface with a 12px backdrop blur so scrolling content shows faintly through; only over a plain canvas that scrolls beneath it, never over images or maps.',
|
|
45
|
+
},
|
|
46
|
+
align: {
|
|
47
|
+
values: ['start', 'center'],
|
|
48
|
+
default: 'start',
|
|
49
|
+
description: 'start = brand, then nav, actions pushed right (the reference "simple" header). center = nav centered between a flexible brand block and a flexible actions block (the reference "centered" header).',
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
states: {
|
|
53
|
+
current: { selector: ' .cn-topbar__item[aria-current="page"]', description: 'The destination the user is on (on the ITEM): gray-50 fill, gray-800 text, gray-500 icon; gray-100 when also hovered.', markup: 'aria-current="page" on the item' },
|
|
54
|
+
hover: { selector: ' .cn-topbar__item:hover', description: 'Pointer over an item: gray-50 fill, darker text and icon.', markup: 'native :hover on the item' },
|
|
55
|
+
focus: { selector: ' .cn-topbar__item:focus-visible, & .cn-topbar__brand:focus-visible', description: 'Keyboard focus on an item or the brand: the 4px brand ring.', markup: 'native :focus-visible' },
|
|
56
|
+
},
|
|
57
|
+
base: {
|
|
58
|
+
root: {
|
|
59
|
+
position: 'sticky',
|
|
60
|
+
top: '0',
|
|
61
|
+
'z-index': '{z.sticky}',
|
|
62
|
+
width: '100%',
|
|
63
|
+
'background-color': '{color.bg-surface}',
|
|
64
|
+
'border-bottom': HAIRLINE,
|
|
65
|
+
color: '{color.fg-default}',
|
|
66
|
+
},
|
|
67
|
+
inner: {
|
|
68
|
+
display: 'flex',
|
|
69
|
+
'align-items': 'center',
|
|
70
|
+
gap: '{space.4}',
|
|
71
|
+
height: '{space.16}',
|
|
72
|
+
width: '100%',
|
|
73
|
+
'max-width': '{size.container.xl}',
|
|
74
|
+
'margin-inline': 'auto',
|
|
75
|
+
'padding-inline': '{space.8}',
|
|
76
|
+
'min-width': '0',
|
|
77
|
+
},
|
|
78
|
+
brand: {
|
|
79
|
+
display: 'inline-flex',
|
|
80
|
+
'align-items': 'center',
|
|
81
|
+
gap: '{space.2.5}',
|
|
82
|
+
'flex-shrink': '0',
|
|
83
|
+
height: '{space.6}',
|
|
84
|
+
...typeStyle('label-lg'),
|
|
85
|
+
color: '{color.fg-default}',
|
|
86
|
+
'text-decoration': 'none',
|
|
87
|
+
'white-space': 'nowrap',
|
|
88
|
+
'border-radius': '{radius.xs}',
|
|
89
|
+
},
|
|
90
|
+
logo: { width: '{space.6}', height: '{space.6}', 'border-radius': '{radius.md}', 'background-color': '{color.bg-action}', 'flex-shrink': '0' },
|
|
91
|
+
nav: {
|
|
92
|
+
display: 'flex',
|
|
93
|
+
'align-items': 'center',
|
|
94
|
+
gap: '{space.0.5}',
|
|
95
|
+
'min-width': '0',
|
|
96
|
+
},
|
|
97
|
+
item: {
|
|
98
|
+
position: 'relative',
|
|
99
|
+
display: 'inline-flex',
|
|
100
|
+
'align-items': 'center',
|
|
101
|
+
'justify-content': 'center',
|
|
102
|
+
gap: '{space.1}',
|
|
103
|
+
'flex-shrink': '0',
|
|
104
|
+
height: '{space.8}',
|
|
105
|
+
padding: '{space.1.5} {space.2}',
|
|
106
|
+
'border-radius': '{radius.md}',
|
|
107
|
+
...typeStyle('label-md'),
|
|
108
|
+
color: '{color.fg-muted}',
|
|
109
|
+
'text-decoration': 'none',
|
|
110
|
+
'white-space': 'nowrap',
|
|
111
|
+
'user-select': 'none',
|
|
112
|
+
cursor: 'pointer',
|
|
113
|
+
...TRANSITION_FAST,
|
|
114
|
+
},
|
|
115
|
+
icon: { width: '{size.icon.md}', height: '{size.icon.md}', 'flex-shrink': '0', color: '{color.fg-subtle}', transition: 'inherit' },
|
|
116
|
+
count: {
|
|
117
|
+
position: 'absolute',
|
|
118
|
+
top: '-1px',
|
|
119
|
+
right: '-1px',
|
|
120
|
+
display: 'inline-flex',
|
|
121
|
+
'align-items': 'center',
|
|
122
|
+
'justify-content': 'center',
|
|
123
|
+
width: '{space.3.5}',
|
|
124
|
+
height: '{space.3.5}',
|
|
125
|
+
'border-radius': '{radius.full}',
|
|
126
|
+
'background-color': '{color.bg-danger}',
|
|
127
|
+
color: '{white}',
|
|
128
|
+
'font-size': '10px',
|
|
129
|
+
'font-weight': '{font.weight.bold}',
|
|
130
|
+
'line-height': '1',
|
|
131
|
+
'box-shadow': '0 0 0 1.5px {color.bg-surface}',
|
|
132
|
+
},
|
|
133
|
+
search: { width: '100%', 'max-width': '280px' },
|
|
134
|
+
actions: {
|
|
135
|
+
display: 'flex',
|
|
136
|
+
'align-items': 'center',
|
|
137
|
+
gap: '{space.3}',
|
|
138
|
+
'flex-shrink': '0',
|
|
139
|
+
'margin-inline-start': 'auto',
|
|
140
|
+
},
|
|
141
|
+
secondary: {
|
|
142
|
+
display: 'flex',
|
|
143
|
+
'align-items': 'center',
|
|
144
|
+
gap: '{space.8}',
|
|
145
|
+
height: '{space.16}',
|
|
146
|
+
width: '100%',
|
|
147
|
+
'max-width': '{size.container.xl}',
|
|
148
|
+
'margin-inline': 'auto',
|
|
149
|
+
'padding-inline': '{space.8}',
|
|
150
|
+
'border-top': HAIRLINE,
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
variants: {
|
|
154
|
+
variant: {
|
|
155
|
+
solid: { root: {} },
|
|
156
|
+
translucent: {
|
|
157
|
+
root: {
|
|
158
|
+
'background-color': 'color-mix(in srgb, {color.bg-surface} 85%, transparent)',
|
|
159
|
+
'backdrop-filter': 'blur(12px)',
|
|
160
|
+
'-webkit-backdrop-filter': 'blur(12px)',
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
align: {
|
|
165
|
+
start: { root: {} },
|
|
166
|
+
center: { inner: { gap: '{space.8}' }, brand: { flex: '1 1 0' }, actions: { flex: '1 1 0', 'justify-content': 'flex-end', 'margin-inline-start': '0' } },
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
extraCss: `
|
|
170
|
+
.cn-topbar__item:hover, .cn-topbar__item[aria-current="page"] { background-color: {color.bg-subtle}; color: {color.fg-default}; }
|
|
171
|
+
.cn-topbar__item:hover .cn-topbar__icon, .cn-topbar__item[aria-current="page"] .cn-topbar__icon { color: {color.fg-muted}; }
|
|
172
|
+
.cn-topbar__item[aria-current="page"]:hover { background-color: {color.bg-muted}; }
|
|
173
|
+
.cn-topbar__item[data-icon-only] { width: {space.9}; height: {space.9}; padding: 0; }
|
|
174
|
+
.cn-topbar__item:focus-visible, .cn-topbar__brand:focus-visible { outline: none; box-shadow: {shadow.focus}; z-index: {z.raised}; }
|
|
175
|
+
.cn-topbar__actions > .cn-topbar__item + .cn-topbar__item { margin-inline-start: calc({space.3} * -1 + {space.0.5}); }
|
|
176
|
+
.cn-topbar__nav .cn-tabs[data-variant="underline"], .cn-topbar__secondary .cn-tabs[data-variant="underline"] { border-bottom: 0; margin-bottom: -1px; }
|
|
177
|
+
.cn-topbar__nav .cn-tabs[data-variant="underline"] { align-self: flex-end; }
|
|
178
|
+
.cn-topbar__secondary[data-nav="tabs"] { height: auto; padding: {space.3} {space.8} 0; align-items: flex-end; }
|
|
179
|
+
.cn-topbar__search .cn-kbd { margin-inline-end: {space.2}; }
|
|
180
|
+
@media (max-width: {breakpoint.lg}) { .cn-topbar .cn-topbar__inner, .cn-topbar .cn-topbar__secondary { padding-inline: {space.4}; } .cn-topbar .cn-topbar__nav, .cn-topbar .cn-topbar__search { display: none; } }`,
|
|
181
|
+
examples: [
|
|
182
|
+
ex('Simple header', `<header class="cn-topbar" data-variant="solid" data-align="start"><div class="cn-topbar__inner">${BRAND}${NAV}<div class="cn-topbar__actions">${SEARCH}${iconItem('Settings', 'settings')}${iconItem('Notifications', 'inbox', '3')}${AVATAR}</div></div></header>`, '64px bar, 1280px container with 32px padding. Dashboard is current (gray-50 pill); notifications carry the 14px red count.'),
|
|
183
|
+
ex('Dual tier with tabs', `<header class="cn-topbar" data-variant="solid" data-align="start"><div class="cn-topbar__inner">${BRAND}${NAV}${ACTIONS}</div><div class="cn-topbar__secondary" data-nav="tabs">${routeTabs(1, ['Overview', 'Customers', 'Invoices', 'Files', 'Settings'])}</div></header>`, 'The second tier holds underline Tabs sm whose brand bar sits on the tier\'s hairline.'),
|
|
184
|
+
ex('Dual tier with pills and search', `<header class="cn-topbar" data-variant="solid" data-align="start"><div class="cn-topbar__inner">${BRAND}${NAV}${ACTIONS}</div><div class="cn-topbar__secondary"><nav class="cn-topbar__nav" aria-label="Secondary">${item('All projects', true)}${item('Shared with me')}${item('Archived')}</nav><div class="cn-topbar__actions">${SEARCH}</div></div></header>`, 'A second 64px row of pills with the search on its right.'),
|
|
185
|
+
ex('Centered, translucent', `<header class="cn-topbar" data-variant="translucent" data-align="center"><div class="cn-topbar__inner">${BRAND}${NAV}<div class="cn-topbar__actions">${iconItem('Search', 'search')}${iconItem('Notifications', 'inbox')}${AVATAR}</div></div></header>`, 'Nav centered between the brand and the actions; 85% surface with a 12px blur over scrolling content.'),
|
|
186
|
+
ex('Minimal (marketing / auth)', `<header class="cn-topbar" data-variant="solid" data-align="start"><div class="cn-topbar__inner">${BRAND}<div class="cn-topbar__actions">${btn('Log in', 'ghost')}${btn('Sign up', 'primary')}</div></div></header>`, 'No nav at all; brand left, two actions right.'),
|
|
187
|
+
],
|
|
188
|
+
rules: [
|
|
189
|
+
'Exactly one Topbar per app, 64px per tier, sticky. Secondary navigation is the second tier, a Sidebar or page-level Tabs; never a third bar.',
|
|
190
|
+
'The brand is the 24px mark plus the product name (label-lg semibold) and always links to the home view.',
|
|
191
|
+
'Nav items are 32px pills, 2px apart, 14px semibold; labels are the product\'s top-level nouns ("Projects", "Reporting"), never verbs. The current one carries aria-current="page".',
|
|
192
|
+
'Actions hold at most: search (280px), two icon-only items, one outline and one primary Button, then the account Avatar. Everything else goes into a Menu behind the account or a ⋯ item.',
|
|
193
|
+
'Global search lives in actions as an Input sm with the ⌘K Kbd and opens the CommandPalette; it is not a page filter.',
|
|
194
|
+
'Page titles, breadcrumbs, filters and tabs of the current page live in the PageHeader below the topbar, never inside it.',
|
|
195
|
+
'translucent only when content scrolls beneath the bar on a plain canvas; on app shells with a Sidebar and over images use solid.',
|
|
196
|
+
'Under 1024px the nav and search hide; use the Sidebar\'s mobile header (menu button + drawer) instead of squeezing pills.',
|
|
197
|
+
],
|
|
198
|
+
a11y: [
|
|
199
|
+
'Root is a <header> (banner landmark); the nav slot is a <nav aria-label="Primary"> of plain links with aria-current="page"; a second tier gets aria-label="Secondary".',
|
|
200
|
+
'Provide a "Skip to content" link as the first focusable element before the topbar, visible on focus.',
|
|
201
|
+
'The brand link has an accessible name (the visible text, or aria-label when it is only a logo image).',
|
|
202
|
+
'Icon-only items carry aria-label; the notification count is read with it ("Notifications, 3 unread") by adding the number to the label or an sr-only span.',
|
|
203
|
+
'The account avatar is a <button aria-haspopup="menu" aria-expanded>; the menu it opens is a Menu.',
|
|
204
|
+
'Because the bar is sticky, set scroll-padding-top on the html element to the total topbar height (64 or 128px).',
|
|
205
|
+
],
|
|
206
|
+
related: ['tabs', 'sidebar', 'sidebar-nav', 'input', 'kbd', 'avatar', 'menu', 'command-palette'],
|
|
207
|
+
};
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { FOCUS_RING, TRANSITION_COLORS, ex, ICON, typeStyle } from "./_shared.mjs";
|
|
2
|
+
// Tree view: nested rows (36px md / 32px sm) with a rotating chevron, a 20px
|
|
3
|
+
// folder or file icon, a label and an optional count, groups indented by 24px
|
|
4
|
+
// with an optional 1px guide line, and an optional checkbox per row.
|
|
5
|
+
const FOLDER = '<svg class="cn-icon" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.75 5.5A1.5 1.5 0 014.25 4h3.5l2 2h6a1.5 1.5 0 011.5 1.5v7A1.5 1.5 0 0115.75 16h-11.5a1.5 1.5 0 01-1.5-1.5v-9z"/></svg>';
|
|
6
|
+
const FILE = '<svg class="cn-icon" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5.25 3h6.5l3.5 3.5V16a1 1 0 01-1 1h-9a1 1 0 01-1-1V4a1 1 0 011-1z"/><path d="M11.5 3v4h4"/></svg>';
|
|
7
|
+
const CHECK = ICON.check.replace('cn-icon', 'cn-checkbox__indicator');
|
|
8
|
+
const checkbox = (label, checked, indeterminate = false) => `<label class="cn-checkbox" data-size="sm"${indeterminate ? ' data-indeterminate' : ''}><input class="cn-checkbox__input" type="checkbox" aria-label="Select ${label}"${checked ? ' checked' : ''}><span class="cn-checkbox__control" aria-hidden="true">${CHECK}</span></label>`;
|
|
9
|
+
const node = (o) => `<li role="none"><div class="cn-tree-view__item" role="treeitem" aria-level="${o.level}"${o.expanded !== undefined ? ` aria-expanded="${o.expanded}"` : ''} aria-selected="${o.selected ? 'true' : 'false'}"${o.disabled ? ' aria-disabled="true"' : ''} tabindex="${o.selected ? '0' : '-1'}"><span class="cn-tree-view__toggle" aria-hidden="true">${ICON.chevronRight}</span>${o.check ? checkbox(o.label, o.check === 'on', o.check === 'mixed') : ''}<span class="cn-tree-view__icon">${o.kind === 'folder' ? FOLDER : FILE}</span><span class="cn-tree-view__label">${o.label}</span>${o.badge ? `<span class="cn-tree-view__badge">${o.badge}</span>` : ''}</div>${o.children ? `<ul class="cn-tree-view__group" role="group">${o.children}</ul>` : ''}</li>`;
|
|
10
|
+
const tree = (size, variant, inner, width = '320px') => `<ul class="cn-tree-view" data-size="${size}" data-variant="${variant}" role="tree" aria-label="Lumen workspace files" style="width:100%;max-width:${width}">${inner}</ul>`;
|
|
11
|
+
const WORKSPACE = (check = false) => node({
|
|
12
|
+
label: 'Lumen workspace', level: 1, kind: 'folder', expanded: true, check: check ? 'mixed' : undefined,
|
|
13
|
+
children: node({ label: 'Brand', level: 2, kind: 'folder', expanded: true, check: check ? 'on' : undefined, children: node({ label: 'lumen-logo.svg', level: 3, kind: 'file', check: check ? 'on' : undefined }) + node({ label: 'brand-guidelines.pdf', level: 3, kind: 'file', check: check ? 'on' : undefined }) }) +
|
|
14
|
+
node({ label: 'Marketing', level: 2, kind: 'folder', expanded: true, check: check ? 'off' : undefined, children: node({ label: 'Q3 campaign', level: 3, kind: 'folder', expanded: true, badge: '2', check: check ? 'off' : undefined, children: node({ label: 'landing-page.fig', level: 4, kind: 'file', selected: !check, check: check ? 'off' : undefined }) + node({ label: 'copy-deck.docx', level: 4, kind: 'file', check: check ? 'off' : undefined }) }) }) +
|
|
15
|
+
node({ label: 'Finance', level: 2, kind: 'folder', expanded: false, badge: '24', check: check ? 'off' : undefined }),
|
|
16
|
+
});
|
|
17
|
+
export const treeView = {
|
|
18
|
+
name: 'TreeView',
|
|
19
|
+
slug: 'tree-view',
|
|
20
|
+
category: 'data-display',
|
|
21
|
+
description: 'A hierarchy of rows that expand and collapse: a chevron that rotates when open, a 20px folder or file icon, the label and an optional count, with nested groups indented 24px and an optional guide line. Rows are 36px (32px small), hover in bg-subtle, the selected row keeps the fill with a semibold label.',
|
|
22
|
+
usage: 'Use for file systems, folder structures, nested categories and org charts where depth matters and people open branches on demand. For a flat list use List; for navigation with two levels use SidebarNav; for a table with expandable rows use Table.',
|
|
23
|
+
anatomy: [
|
|
24
|
+
{ part: 'root', element: 'ul', description: 'The tree (role="tree", aria-label). A list; nested groups are <ul role="group"> after their parent row inside a <li role="none">.' },
|
|
25
|
+
{ part: 'item', element: 'div', description: 'One row (role="treeitem", aria-level, aria-expanded when it has children, aria-selected, roving tabindex). 36px tall, 8px horizontal padding, radius md, body-md.' },
|
|
26
|
+
{ part: 'toggle', element: 'span', description: '16px chevron on the left; rotates 90° when the row is aria-expanded="true"; invisible on leaf rows so labels stay aligned. aria-hidden.' },
|
|
27
|
+
{ part: 'icon', element: 'span', description: '20px folder or file icon in fg-muted.' },
|
|
28
|
+
{ part: 'label', element: 'span', description: 'The name, truncated with an ellipsis; semibold when the row is selected.' },
|
|
29
|
+
{ part: 'badge', element: 'span', description: 'Optional count pill at the end of the row (children, unread), label-xs in bg-subtle.', optional: true },
|
|
30
|
+
{ part: 'group', element: 'ul', description: 'The nested list of children (role="group"), indented by 24px; in the with-lines variant it draws a 1px guide under the parent chevron.', optional: true },
|
|
31
|
+
],
|
|
32
|
+
props: {
|
|
33
|
+
size: {
|
|
34
|
+
values: ['sm', 'md'],
|
|
35
|
+
default: 'md',
|
|
36
|
+
description: 'md = 36px rows with body-md text and 20px icons (file browsers, settings trees). sm = 32px rows with body-sm text and 16px icons for side panels and pickers.',
|
|
37
|
+
},
|
|
38
|
+
variant: {
|
|
39
|
+
values: ['default', 'with-lines', 'with-checkbox'],
|
|
40
|
+
default: 'default',
|
|
41
|
+
description: 'default = indentation only. with-lines = a 1px guide line under each open parent so deep trees stay readable. with-checkbox = a sm Checkbox before the icon on every row for multi-select (moving files, choosing folders to sync); parents show indeterminate when only some children are checked.',
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
states: {
|
|
45
|
+
hover: { selector: ' .cn-tree-view__item:hover', description: 'Pointer over a row (on the item; extraCss): bg-subtle fill.', markup: 'native :hover on .cn-tree-view__item' },
|
|
46
|
+
selected: { selector: ' .cn-tree-view__item[aria-selected="true"]', description: 'The chosen row (on the item; extraCss): bg-subtle fill, ink text, semibold label, ink icon.', markup: 'aria-selected="true" on .cn-tree-view__item' },
|
|
47
|
+
expanded: { selector: ' .cn-tree-view__item[aria-expanded="true"]', description: 'An open parent (on the item; extraCss): the chevron rotates 90°. aria-expanded="false" hides the following group.', markup: 'aria-expanded="true|false" on .cn-tree-view__item' },
|
|
48
|
+
focus: { selector: ' .cn-tree-view__item:focus-visible', description: 'Keyboard focus on a row shows the 3px ring (extraCss).', markup: 'native :focus-visible on .cn-tree-view__item' },
|
|
49
|
+
disabled: { selector: ' .cn-tree-view__item[aria-disabled="true"]', description: 'A row that cannot be chosen (no permission): disabled text, no hover (extraCss).', markup: 'aria-disabled="true" on .cn-tree-view__item' },
|
|
50
|
+
},
|
|
51
|
+
base: {
|
|
52
|
+
root: { display: 'flex', 'flex-direction': 'column', gap: '{space.0.5}', width: '100%', 'min-width': '0', margin: '0', padding: '0', 'list-style': 'none', color: '{color.fg-default}' },
|
|
53
|
+
item: {
|
|
54
|
+
display: 'flex',
|
|
55
|
+
'align-items': 'center',
|
|
56
|
+
gap: '{space.2}',
|
|
57
|
+
height: '{size.control.md}',
|
|
58
|
+
'padding-inline': '{space.2}',
|
|
59
|
+
'border-radius': '{radius.md}',
|
|
60
|
+
...typeStyle('body-md'),
|
|
61
|
+
color: '{color.fg-default}',
|
|
62
|
+
cursor: 'pointer',
|
|
63
|
+
'user-select': 'none',
|
|
64
|
+
'min-width': '0',
|
|
65
|
+
outline: 'none',
|
|
66
|
+
...TRANSITION_COLORS,
|
|
67
|
+
},
|
|
68
|
+
toggle: {
|
|
69
|
+
display: 'inline-flex',
|
|
70
|
+
'align-items': 'center',
|
|
71
|
+
'justify-content': 'center',
|
|
72
|
+
'flex-shrink': '0',
|
|
73
|
+
width: '{size.icon.md}',
|
|
74
|
+
height: '{size.icon.md}',
|
|
75
|
+
color: '{color.fg-subtle}',
|
|
76
|
+
'transition-property': 'transform',
|
|
77
|
+
'transition-duration': '{motion.duration.fast}',
|
|
78
|
+
'transition-timing-function': '{motion.easing.standard}',
|
|
79
|
+
},
|
|
80
|
+
icon: { display: 'inline-flex', 'flex-shrink': '0', width: '{size.icon.lg}', height: '{size.icon.lg}', color: '{color.fg-muted}' },
|
|
81
|
+
label: { flex: '1 1 auto', 'min-width': '0', overflow: 'hidden', 'text-overflow': 'ellipsis', 'white-space': 'nowrap' },
|
|
82
|
+
badge: {
|
|
83
|
+
display: 'inline-flex',
|
|
84
|
+
'align-items': 'center',
|
|
85
|
+
'justify-content': 'center',
|
|
86
|
+
'flex-shrink': '0',
|
|
87
|
+
height: '18px',
|
|
88
|
+
'min-width': '18px',
|
|
89
|
+
'padding-inline': '{space.1.5}',
|
|
90
|
+
'border-radius': '{radius.full}',
|
|
91
|
+
'background-color': '{color.bg-subtle}',
|
|
92
|
+
...typeStyle('label-xs'),
|
|
93
|
+
color: '{color.fg-subtle}',
|
|
94
|
+
'font-variant-numeric': 'tabular-nums',
|
|
95
|
+
},
|
|
96
|
+
group: { position: 'relative', display: 'flex', 'flex-direction': 'column', gap: '{space.0.5}', margin: '0', padding: '0', 'padding-inline-start': '{space.6}', 'list-style': 'none', 'min-width': '0' },
|
|
97
|
+
},
|
|
98
|
+
variants: {
|
|
99
|
+
size: {
|
|
100
|
+
sm: {
|
|
101
|
+
item: { height: '{size.control.sm}', gap: '{space.1.5}', ...typeStyle('body-sm') },
|
|
102
|
+
icon: { width: '{size.icon.md}', height: '{size.icon.md}' },
|
|
103
|
+
badge: { height: '16px', 'min-width': '16px', 'font-size': '{font.size.2xs}', 'padding-inline': '{space.1}' },
|
|
104
|
+
group: { 'padding-inline-start': '{space.5}' },
|
|
105
|
+
},
|
|
106
|
+
md: { root: {} },
|
|
107
|
+
},
|
|
108
|
+
variant: {
|
|
109
|
+
default: { root: {} },
|
|
110
|
+
'with-lines': { root: {} },
|
|
111
|
+
'with-checkbox': { root: {} },
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
extraCss: `
|
|
115
|
+
.cn-tree-view li { list-style: none; margin: 0; padding: 0; min-width: 0; }
|
|
116
|
+
.cn-tree-view__toggle .cn-icon, .cn-tree-view__icon .cn-icon { width: 100%; height: 100%; }
|
|
117
|
+
.cn-tree-view__item:hover:not([aria-disabled="true"]) { background-color: {color.bg-subtle}; }
|
|
118
|
+
.cn-tree-view__item:focus-visible { box-shadow: ${FOCUS_RING['box-shadow']}; position: relative; z-index: {z.raised}; }
|
|
119
|
+
.cn-tree-view__item[aria-selected="true"] { background-color: {color.bg-subtle}; color: {color.fg-default}; }
|
|
120
|
+
.cn-tree-view__item[aria-selected="true"] .cn-tree-view__label { font-weight: {font.weight.semibold}; }
|
|
121
|
+
.cn-tree-view__item[aria-selected="true"] .cn-tree-view__icon { color: {color.fg-default}; }
|
|
122
|
+
.cn-tree-view__item[aria-expanded="true"] .cn-tree-view__toggle { transform: rotate(90deg); }
|
|
123
|
+
.cn-tree-view__item:not([aria-expanded]) .cn-tree-view__toggle { visibility: hidden; }
|
|
124
|
+
.cn-tree-view__item[aria-expanded="false"] + .cn-tree-view__group { display: none; }
|
|
125
|
+
.cn-tree-view__item[aria-disabled="true"] { color: {color.fg-disabled}; cursor: not-allowed; }
|
|
126
|
+
.cn-tree-view__item[aria-disabled="true"] .cn-tree-view__icon { color: {color.fg-disabled}; }
|
|
127
|
+
.cn-tree-view[data-variant="with-lines"] .cn-tree-view__group::before { content: ''; position: absolute; top: 0; bottom: 0; inset-inline-start: calc({space.2} + {size.icon.md} / 2); width: {border.width.thin}; background-color: {color.border-default}; }
|
|
128
|
+
.cn-tree-view[data-variant="with-checkbox"] .cn-tree-view__item .cn-checkbox { flex-shrink: 0; }
|
|
129
|
+
.cn-tree-view[data-variant="with-checkbox"] .cn-tree-view__item .cn-checkbox__control { margin-top: 0; }
|
|
130
|
+
.cn-tree-view[data-variant="with-checkbox"] .cn-tree-view__item .cn-checkbox__input { top: 0; }`,
|
|
131
|
+
examples: [
|
|
132
|
+
ex('Three levels, one selected', tree('md', 'default', WORKSPACE()), 'Open folders rotate the chevron; the leaf rows hide it. "landing-page.fig" is selected; "Finance" is collapsed with a count.'),
|
|
133
|
+
ex('With guide lines, small', tree('sm', 'with-lines', WORKSPACE(), '280px'), '32px rows and a 1px guide under every open parent.'),
|
|
134
|
+
ex('With checkboxes (multi-select)', tree('md', 'with-checkbox', WORKSPACE(true), '360px'), 'The parent of a partially checked branch is indeterminate; checking a parent checks all of its children.'),
|
|
135
|
+
ex('Disabled row', tree('md', 'default', node({ label: 'Shared with me', level: 1, kind: 'folder', expanded: true, selected: true, children: node({ label: 'Board deck (view only)', level: 2, kind: 'file' }) + node({ label: 'Legal', level: 2, kind: 'folder', disabled: true, badge: '3' }) }), '280px'), 'aria-disabled rows stay visible with a disabled color and no hover.'),
|
|
136
|
+
],
|
|
137
|
+
rules: [
|
|
138
|
+
'Rows show one name each, truncated with an ellipsis; the full name lives in a title or Tooltip. Never wrap a row onto two lines.',
|
|
139
|
+
'Folders before files, both alphabetical, unless the user chose another sort. Do not mix sort orders between levels.',
|
|
140
|
+
'Only parents get a chevron; leaves keep an invisible 16px slot so all labels at one level align.',
|
|
141
|
+
'Exactly one selected row in single-select trees; the selected row is the one whose content is shown next to the tree.',
|
|
142
|
+
'Counts in the badge are for children or unread items and update live; never use the badge for status words.',
|
|
143
|
+
'Use with-lines when trees go deeper than three levels; at two levels the indentation is enough.',
|
|
144
|
+
'with-checkbox is for choosing many nodes at once (move, sync, export); the row click still selects, only the box toggles.',
|
|
145
|
+
'Deep trees (more than five levels) are a design smell: flatten with breadcrumbs or a search instead.',
|
|
146
|
+
'sm inside side panels and pickers; md in a file browser or settings page where the tree is the main content.',
|
|
147
|
+
],
|
|
148
|
+
a11y: [
|
|
149
|
+
'Root has role="tree" and an aria-label; rows are role="treeitem" with aria-level, aria-expanded on parents and aria-selected; child lists are role="group" placed right after their parent row. Add aria-owns on the parent when your framework needs explicit ownership.',
|
|
150
|
+
'Roving tabindex: one row is tabbable; Up/Down move, Right expands or moves into children, Left collapses or moves to the parent, Home/End jump, Enter activates, typing jumps to a matching name.',
|
|
151
|
+
'The chevron and icons are aria-hidden; the label is the accessible name. Counts are read as text after the name.',
|
|
152
|
+
'In with-checkbox each Checkbox has an aria-label "Select {name}"; Space toggles the box while the row keeps its own selection.',
|
|
153
|
+
'Selection and hover are conveyed by fill and weight, never by color alone; the disabled state uses aria-disabled so the row is still announced.',
|
|
154
|
+
],
|
|
155
|
+
related: ['list', 'sidebar-nav', 'checkbox', 'accordion', 'table'],
|
|
156
|
+
};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { RESET_BUTTON, TRANSITION_COLORS, ex, typeStyle } from "./_shared.mjs";
|
|
2
|
+
// Video player: a 16:9 inverse-colored frame with a poster, a centered 64px play
|
|
3
|
+
// button on a 90% surface circle, and a bottom control bar on a color-mix scrim
|
|
4
|
+
// with a 2px progress line, a scrubber, 32px icon buttons and a mono time label.
|
|
5
|
+
const SVG = {
|
|
6
|
+
play: '<svg class="cn-icon" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M5 3.2v9.6L12.6 8z"/></svg>',
|
|
7
|
+
pause: '<svg class="cn-icon" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><rect x="4" y="3.5" width="2.75" height="9" rx=".5"/><rect x="9.25" y="3.5" width="2.75" height="9" rx=".5"/></svg>',
|
|
8
|
+
volume: '<svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 6.25h2.25L8 3.5v9L4.75 9.75H2.5z"/><path d="M10.5 5.75a3 3 0 010 4.5M12.25 4a5.5 5.5 0 010 8"/></svg>',
|
|
9
|
+
captions: '<svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><rect x="2.5" y="3.5" width="11" height="9" rx="1.5"/><path d="M5 8.75h2.5M8.75 8.75H11M5 6.25h6"/></svg>',
|
|
10
|
+
settings: '<svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><circle cx="8" cy="8" r="2"/><path d="M8 1.75v1.5M8 12.75v1.5M1.75 8h1.5M12.75 8h1.5M3.6 3.6l1.05 1.05M11.35 11.35l1.05 1.05M3.6 12.4l1.05-1.05M11.35 4.65l1.05-1.05"/></svg>',
|
|
11
|
+
fullscreen: '<svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6.5v-3.5h3.5M13 6.5v-3.5H9.5M3 9.5v3.5h3.5M13 9.5v3.5H9.5"/></svg>',
|
|
12
|
+
};
|
|
13
|
+
/** Dark 16:9 poster drawn with fg-inverse shapes at low opacity, no network. */
|
|
14
|
+
const POSTER = `<svg class="cn-video-player__media" viewBox="0 0 640 360" role="img" aria-label="Poster: the Lumen reporting workspace"><rect width="640" height="360" style="fill:var(--cn-color-bg-inverse)"/><rect x="72" y="56" width="496" height="248" rx="16" style="fill:var(--cn-color-fg-inverse);opacity:.08"/><rect x="96" y="80" width="140" height="10" rx="5" style="fill:var(--cn-color-fg-inverse);opacity:.32"/><rect x="96" y="100" width="88" height="8" rx="4" style="fill:var(--cn-color-fg-inverse);opacity:.18"/><rect x="96" y="132" width="212" height="148" rx="10" style="fill:var(--cn-color-fg-inverse);opacity:.1"/><rect x="332" y="132" width="212" height="64" rx="10" style="fill:var(--cn-color-fg-inverse);opacity:.1"/><rect x="332" y="216" width="212" height="64" rx="10" style="fill:var(--cn-color-fg-inverse);opacity:.1"/><path d="M116 250l40-32 38 18 44-52 42 20 28-36" fill="none" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" style="stroke:var(--cn-color-bg-accent)"/></svg>`;
|
|
15
|
+
const btn = (svg, label, attrs = '') => `<button type="button" class="cn-video-player__button" aria-label="${label}"${attrs}>${svg}</button>`;
|
|
16
|
+
const bar = (playing, pct, time) => `<div class="cn-video-player__controls"><div class="cn-video-player__progress" role="slider" aria-label="Seek" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${pct}" tabindex="0"><div class="cn-video-player__progress-fill" style="width:${pct}%"></div><span class="cn-video-player__scrubber" style="left:${pct}%"></span></div><div class="cn-video-player__row"><div class="cn-video-player__group">${btn(playing ? SVG.pause : SVG.play, playing ? 'Pause' : 'Play')}${btn(SVG.volume, 'Mute')}<span class="cn-video-player__time">${time}</span></div><div class="cn-video-player__group">${btn(SVG.captions, 'Captions', ' aria-pressed="false"')}${btn(SVG.settings, 'Settings')}${btn(SVG.fullscreen, 'Full screen')}</div></div></div>`;
|
|
17
|
+
const PLAY = `<button type="button" class="cn-video-player__play" aria-label="Play">${SVG.play}</button>`;
|
|
18
|
+
const player = (variant, size, state, inner) => `<div class="cn-video-player" data-variant="${variant}" data-size="${size}" data-state="${state}" role="region" aria-label="Video: Introducing Lumen reporting">${POSTER}${inner}</div>`;
|
|
19
|
+
export const videoPlayer = {
|
|
20
|
+
name: 'VideoPlayer',
|
|
21
|
+
slug: 'video-player',
|
|
22
|
+
category: 'media',
|
|
23
|
+
description: 'A 16:9 inverse frame with a poster, a centered 64px play button on a translucent surface circle, and a bottom control bar on a scrim: a 2px progress line with a scrubber, 32px icon buttons in fg-inverse and a mono time label. Three looks: default, minimal (play only) and mockup (a framed device-like shot for marketing).',
|
|
24
|
+
usage: 'Use for product videos, onboarding clips and recorded sessions inside the app or on marketing pages. The component styles the chrome; the <video> element and its behavior come from the app. For images use MediaFrame; for a slideshow use Carousel.',
|
|
25
|
+
anatomy: [
|
|
26
|
+
{ part: 'root', element: 'div', description: 'The frame: relative, 16:9, bg-inverse, radius card, overflow hidden. data-state="paused|playing". Max width from size.' },
|
|
27
|
+
{ part: 'media', element: 'video', description: 'The <video> (with poster) or an <img>, absolutely filling the frame with object-fit cover. In the gallery an inline svg poster.' },
|
|
28
|
+
{ part: 'play', element: 'button', description: 'Centered 64px circle, bg-surface at 90%, shadow-lg, with a 24px play icon. Hidden while playing.' },
|
|
29
|
+
{ part: 'controls', element: 'div', description: 'Bottom bar on a scrim (bg-inverse at 70%): the progress line, then the row of buttons and time.' },
|
|
30
|
+
{ part: 'progress', element: 'div', description: '2px full-width track (fg-inverse at 30%), role="slider" for seeking; grows to 4px on hover.' },
|
|
31
|
+
{ part: 'progress-fill', element: 'div', description: 'The played portion in fg-inverse; width is the inline percentage.' },
|
|
32
|
+
{ part: 'scrubber', element: 'span', description: '12px round handle at the end of the fill (left = the same percentage).' },
|
|
33
|
+
{ part: 'row', element: 'div', description: 'The controls row: a left group (play, volume, time) and a right group (captions, settings, full screen).' },
|
|
34
|
+
{ part: 'group', element: 'div', description: 'A cluster of buttons with a 4px gap.' },
|
|
35
|
+
{ part: 'button', element: 'button', description: '32px icon button in fg-inverse, radius md, translucent hover. aria-label required; captions is a toggle with aria-pressed.' },
|
|
36
|
+
{ part: 'time', element: 'span', description: 'Elapsed / total in code-sm fg-inverse, tabular ("0:42 / 3:18").' },
|
|
37
|
+
],
|
|
38
|
+
props: {
|
|
39
|
+
variant: {
|
|
40
|
+
values: ['default', 'minimal', 'mockup'],
|
|
41
|
+
default: 'default',
|
|
42
|
+
description: 'default = play button plus the full control bar (in-app video). minimal = poster and play button only; controls appear once playing (embeds in cards and docs). mockup = the default chrome inside a framed, shadowed device-like shot for marketing hero sections; never inside the app shell.',
|
|
43
|
+
},
|
|
44
|
+
size: {
|
|
45
|
+
values: ['sm', 'md', 'lg'],
|
|
46
|
+
default: 'md',
|
|
47
|
+
description: 'Maximum width of the frame: sm 480px (cards, side panels), md 720px (content pages), lg 960px (marketing sections). Always 100% wide below that.',
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
states: {
|
|
51
|
+
playing: { selector: '[data-state="playing"]', description: 'Media is playing: the centered play button hides and the bar shows a pause icon. The app toggles the attribute.', markup: 'data-state="playing" on the root' },
|
|
52
|
+
paused: { selector: '[data-state="paused"]', description: 'Media is paused or not started: the centered play button is visible over the poster.', markup: 'data-state="paused" on the root (default)' },
|
|
53
|
+
playHover: { selector: ' .cn-video-player__play:hover', description: 'Pointer over the big play button (extraCss): the circle becomes fully opaque.', markup: 'native :hover on .cn-video-player__play' },
|
|
54
|
+
buttonHover: { selector: ' .cn-video-player__button:hover', description: 'Pointer over a bar button (extraCss): a translucent fg-inverse fill.', markup: 'native :hover on .cn-video-player__button' },
|
|
55
|
+
buttonFocus: { selector: ' .cn-video-player__button:focus-visible', description: 'Keyboard focus on a bar button or the play button shows the focus ring (extraCss).', markup: 'native :focus-visible on .cn-video-player__button' },
|
|
56
|
+
buttonPressed: { selector: ' .cn-video-player__button[aria-pressed="true"]', description: 'A toggle such as captions that is on (extraCss): keeps the translucent fill at rest.', markup: 'aria-pressed="true" on .cn-video-player__button' },
|
|
57
|
+
},
|
|
58
|
+
base: {
|
|
59
|
+
root: {
|
|
60
|
+
position: 'relative',
|
|
61
|
+
width: '100%',
|
|
62
|
+
'max-width': '720px',
|
|
63
|
+
'aspect-ratio': '16 / 9',
|
|
64
|
+
'background-color': '{color.bg-inverse}',
|
|
65
|
+
color: '{color.fg-inverse}',
|
|
66
|
+
'border-radius': '{radius.card}',
|
|
67
|
+
overflow: 'hidden',
|
|
68
|
+
isolation: 'isolate',
|
|
69
|
+
},
|
|
70
|
+
media: { position: 'absolute', inset: '0', display: 'block', width: '100%', height: '100%', 'object-fit': 'cover' },
|
|
71
|
+
play: {
|
|
72
|
+
...RESET_BUTTON,
|
|
73
|
+
position: 'absolute',
|
|
74
|
+
top: '50%',
|
|
75
|
+
left: '50%',
|
|
76
|
+
transform: 'translate(-50%, -50%)',
|
|
77
|
+
display: 'inline-flex',
|
|
78
|
+
'align-items': 'center',
|
|
79
|
+
'justify-content': 'center',
|
|
80
|
+
width: '{space.16}',
|
|
81
|
+
height: '{space.16}',
|
|
82
|
+
'border-radius': '{radius.full}',
|
|
83
|
+
'background-color': 'color-mix(in srgb, {color.bg-surface} 90%, transparent)',
|
|
84
|
+
color: '{color.fg-default}',
|
|
85
|
+
'box-shadow': '{shadow.lg}',
|
|
86
|
+
'z-index': '{z.raised}',
|
|
87
|
+
...TRANSITION_COLORS,
|
|
88
|
+
},
|
|
89
|
+
controls: {
|
|
90
|
+
position: 'absolute',
|
|
91
|
+
'inset-inline': '0',
|
|
92
|
+
bottom: '0',
|
|
93
|
+
display: 'flex',
|
|
94
|
+
'flex-direction': 'column',
|
|
95
|
+
gap: '{space.2}',
|
|
96
|
+
padding: '{space.3} {space.4}',
|
|
97
|
+
'background-color': 'color-mix(in srgb, {color.bg-inverse} 70%, transparent)',
|
|
98
|
+
color: '{color.fg-inverse}',
|
|
99
|
+
'z-index': '{z.raised}',
|
|
100
|
+
},
|
|
101
|
+
progress: {
|
|
102
|
+
position: 'relative',
|
|
103
|
+
width: '100%',
|
|
104
|
+
height: '2px',
|
|
105
|
+
'border-radius': '{radius.full}',
|
|
106
|
+
'background-color': 'color-mix(in srgb, {color.fg-inverse} 30%, transparent)',
|
|
107
|
+
cursor: 'pointer',
|
|
108
|
+
outline: 'none',
|
|
109
|
+
'transition-property': 'height',
|
|
110
|
+
'transition-duration': '{motion.duration.fast}',
|
|
111
|
+
'transition-timing-function': '{motion.easing.standard}',
|
|
112
|
+
},
|
|
113
|
+
'progress-fill': { height: '100%', width: '0', 'border-radius': '{radius.full}', 'background-color': '{color.fg-inverse}' },
|
|
114
|
+
scrubber: {
|
|
115
|
+
position: 'absolute',
|
|
116
|
+
top: '50%',
|
|
117
|
+
width: '{space.3}',
|
|
118
|
+
height: '{space.3}',
|
|
119
|
+
'border-radius': '{radius.full}',
|
|
120
|
+
'background-color': '{color.fg-inverse}',
|
|
121
|
+
transform: 'translate(-50%, -50%)',
|
|
122
|
+
'box-shadow': '{shadow.sm}',
|
|
123
|
+
},
|
|
124
|
+
row: { display: 'flex', 'align-items': 'center', 'justify-content': 'space-between', gap: '{space.2}' },
|
|
125
|
+
group: { display: 'flex', 'align-items': 'center', gap: '{space.1}' },
|
|
126
|
+
button: {
|
|
127
|
+
...RESET_BUTTON,
|
|
128
|
+
display: 'inline-flex',
|
|
129
|
+
'align-items': 'center',
|
|
130
|
+
'justify-content': 'center',
|
|
131
|
+
width: '{space.8}',
|
|
132
|
+
height: '{space.8}',
|
|
133
|
+
'border-radius': '{radius.md}',
|
|
134
|
+
color: '{color.fg-inverse}',
|
|
135
|
+
...TRANSITION_COLORS,
|
|
136
|
+
},
|
|
137
|
+
time: { ...typeStyle('code-sm'), color: '{color.fg-inverse}', 'font-variant-numeric': 'tabular-nums', 'padding-inline': '{space.1}', 'white-space': 'nowrap' },
|
|
138
|
+
'@states': {
|
|
139
|
+
playing: { play: { display: 'none' } },
|
|
140
|
+
paused: { play: { display: 'inline-flex' } },
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
variants: {
|
|
144
|
+
variant: {
|
|
145
|
+
default: { root: {} },
|
|
146
|
+
minimal: { controls: { display: 'none' } },
|
|
147
|
+
mockup: { root: { 'border-radius': '{radius.overlay}', 'box-shadow': '{shadow.2xl}', border: '{border.width.thin} solid color-mix(in srgb, {color.fg-inverse} 14%, transparent)' } },
|
|
148
|
+
},
|
|
149
|
+
size: {
|
|
150
|
+
sm: { root: { 'max-width': '{size.container.xs}' } },
|
|
151
|
+
md: { root: { 'max-width': '720px' } },
|
|
152
|
+
lg: { root: { 'max-width': '960px' } },
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
extraCss: `
|
|
156
|
+
.cn-video-player__play .cn-icon { width: {size.icon.xl}; height: {size.icon.xl}; margin-inline-start: {space.0.5}; }
|
|
157
|
+
.cn-video-player__play:hover { background-color: {color.bg-surface}; }
|
|
158
|
+
.cn-video-player__play:focus-visible { outline: none; box-shadow: {shadow.lg}, {shadow.focus}; }
|
|
159
|
+
.cn-video-player__button .cn-icon { width: {size.icon.lg}; height: {size.icon.lg}; }
|
|
160
|
+
.cn-video-player__button:hover { background-color: color-mix(in srgb, {color.fg-inverse} 12%, transparent); }
|
|
161
|
+
.cn-video-player__button[aria-pressed="true"] { background-color: color-mix(in srgb, {color.fg-inverse} 16%, transparent); }
|
|
162
|
+
.cn-video-player__button:focus-visible, .cn-video-player__progress:focus-visible { outline: none; box-shadow: {shadow.focus}; }
|
|
163
|
+
.cn-video-player__progress:hover { height: {space.1}; }
|
|
164
|
+
.cn-video-player[data-variant="minimal"][data-state="playing"] .cn-video-player__controls { display: flex; }`,
|
|
165
|
+
examples: [
|
|
166
|
+
ex('Default (paused)', player('default', 'md', 'paused', `${PLAY}${bar(false, 22, '0:42 / 3:18')}`), 'Poster, centered play button and the full bar. The progress line is 2px and grows on hover.'),
|
|
167
|
+
ex('Playing, large', player('default', 'lg', 'playing', `${PLAY}${bar(true, 58, '1:54 / 3:18')}`), 'data-state="playing" hides the big play button; the bar shows pause.'),
|
|
168
|
+
ex('Minimal, small', player('minimal', 'sm', 'paused', PLAY), 'Poster and play button only, for cards and documentation; the bar appears once playing.'),
|
|
169
|
+
ex('Mockup for a marketing section', player('mockup', 'lg', 'paused', `${PLAY}${bar(false, 0, '0:00 / 2:05')}`), 'The same chrome inside a framed, shadowed shot. Never inside the app shell.'),
|
|
170
|
+
],
|
|
171
|
+
rules: [
|
|
172
|
+
'Always 16:9 unless the source is vertical; the frame keeps its ratio while loading so the page does not jump.',
|
|
173
|
+
'Show a real poster frame, never a black rectangle; the big play button is the only overlay on it.',
|
|
174
|
+
'The bar holds at most six controls: play/pause, volume, time on the left; captions, settings, full screen on the right. Extra features go in the settings menu.',
|
|
175
|
+
'The time label is "elapsed / total" in the mono style with tabular figures; show a live stream as "LIVE" with a red dot instead.',
|
|
176
|
+
'Hide the bar 3 seconds after the pointer leaves while playing; show it on hover, focus or touch. Never hide it while paused.',
|
|
177
|
+
'Never autoplay with sound. Autoplay muted only for decorative loops on marketing pages, with a visible play/pause control.',
|
|
178
|
+
'sm inside cards and side panels, md on content pages, lg in marketing sections; mockup only on marketing pages.',
|
|
179
|
+
'Captions are a toggle (aria-pressed) and default to on when the viewer\'s system prefers captions.',
|
|
180
|
+
],
|
|
181
|
+
a11y: [
|
|
182
|
+
'Root is a labelled region; the <video> has a title and, for real content, captions (WebVTT) and an audio description track when available.',
|
|
183
|
+
'Every control is a <button> with an aria-label; play/pause changes its label with the state and captions uses aria-pressed.',
|
|
184
|
+
'The progress line is role="slider" with aria-valuemin/max/now and a text alternative for the time; Left/Right seek 5 seconds, Home/End jump.',
|
|
185
|
+
'Keyboard: Space or K toggles play, M mutes, F enters full screen, C toggles captions; Escape leaves full screen.',
|
|
186
|
+
'Focus rings stay visible on the dark chrome (the ring color is independent of the theme); never remove outline without the box-shadow ring.',
|
|
187
|
+
],
|
|
188
|
+
related: ['media-frame', 'carousel', 'icon-button', 'progress'],
|
|
189
|
+
};
|