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,159 @@
|
|
|
1
|
+
import { ex, typeStyle } from "./_shared.mjs";
|
|
2
|
+
// the reference toggle: sm 36×20 track with a 16px thumb, md 44×24 with a 20px thumb (2px inset),
|
|
3
|
+
// gray-100 track with a hairline ring when off, brand-600 when on (brand-700 on hover), white thumb
|
|
4
|
+
// with shadow-sm sliding 16 / 20px; 150ms linear. Slim: a thin 32×16 / 40×20 track with the thumb
|
|
5
|
+
// overhanging it, a 1px gray-300 thumb border that turns brand when on. Focus = 4px brand ring on
|
|
6
|
+
// the track; disabled = track at 50%. Label 14 / 16px medium, muted supporting line, 8 / 12px gap.
|
|
7
|
+
const TRACK_MOTION = {
|
|
8
|
+
'transition-property': 'background-color, box-shadow, opacity',
|
|
9
|
+
'transition-duration': '{motion.duration.normal}',
|
|
10
|
+
'transition-timing-function': '{motion.easing.linear}',
|
|
11
|
+
};
|
|
12
|
+
const THUMB_MOTION = {
|
|
13
|
+
'transition-property': 'transform, border-color, background-color',
|
|
14
|
+
'transition-duration': '{motion.duration.normal}',
|
|
15
|
+
'transition-timing-function': '{motion.easing.standard}',
|
|
16
|
+
};
|
|
17
|
+
const ring = (width, color) => `inset 0 0 0 ${width} ${color}`;
|
|
18
|
+
/** label wrapper → hidden input → track (with thumb) → text (+ optional description) */
|
|
19
|
+
const sw = (root, input, label, description = '', variant = 'default') => `<label class="cn-switch" data-variant="${variant}" ${root}><input class="cn-switch__input" type="checkbox" role="switch" ${input}><span class="cn-switch__track" aria-hidden="true"><span class="cn-switch__thumb"></span></span><span class="cn-switch__label">${label}</span>${description ? `<span class="cn-switch__description">${description}</span>` : ''}</label>`;
|
|
20
|
+
const stack = (...items) => `<div style="display:flex;flex-direction:column;gap:var(--cn-space-4)">${items.join('')}</div>`;
|
|
21
|
+
export const switchSpec = {
|
|
22
|
+
name: 'Switch',
|
|
23
|
+
slug: 'switch',
|
|
24
|
+
category: 'forms',
|
|
25
|
+
description: 'On/off toggle that applies immediately: a 36×20 (sm) or 44×24 (md) pill track with a white thumb that slides right while the track turns from gray-100 to the brand color. A slim variant shrinks the track under an overhanging bordered thumb.',
|
|
26
|
+
usage: 'Settings that take effect the moment they change (notifications, dark mode, auto-approve) and feature flags. If the change needs a Save button use Checkbox; if it is a choice between two named options use SegmentedControl or Radio.',
|
|
27
|
+
anatomy: [
|
|
28
|
+
{ part: 'root', element: 'label', description: 'The <label> wrapping everything: hit target and accessible name. inline-grid: track in column 1, text in column 2, 8px (sm) or 12px (md) apart.' },
|
|
29
|
+
{ part: 'input', element: 'input', description: 'Native <input type="checkbox" role="switch">, visually hidden (opacity 0 over the track) but focusable.' },
|
|
30
|
+
{ part: 'track', element: 'span', description: 'The pill: 36×20 (sm) or 44×24 (md) with a 2px inset, radius full, gray-100 with a hairline ring when off, brand when on. Slim: 32×16 / 40×20 with no inset. aria-hidden.' },
|
|
31
|
+
{ part: 'thumb', element: 'span', description: 'White circle (16px sm / 20px md) with shadow-sm that translates by its own width when on. Slim: shadow-xs and a 1px gray-300 border that turns brand when on.' },
|
|
32
|
+
{ part: 'label', element: 'span', description: '14px (sm) or 16px (md) medium text in the default ink. Names the thing being turned on ("Email digest").' },
|
|
33
|
+
{ part: 'description', element: 'span', description: 'Optional supporting line under the label, same size, regular weight, muted: what happens when it is on.', optional: true },
|
|
34
|
+
],
|
|
35
|
+
props: {
|
|
36
|
+
size: { values: ['sm', 'md'], default: 'sm', description: 'sm = 36×20 track with a 16px thumb and a 14px label: settings rows, tables, popovers (the reference default); md = 44×24 track with a 20px thumb and a 16px label: touch screens and prominent settings.' },
|
|
37
|
+
variant: { values: ['default', 'slim'], default: 'default', description: 'default = the thumb sits inside the track with a 2px inset; slim = a thinner track (32×16 / 40×20) with the bordered thumb overhanging it, for dense rows and table cells.' },
|
|
38
|
+
},
|
|
39
|
+
states: {
|
|
40
|
+
hover: { selector: ':hover', description: 'When on, the track takes the brand hover color (slim: the thumb border does). Off has no hover change. Disabled wins over it.', markup: 'native :hover on the label' },
|
|
41
|
+
checked: { selector: ':has(:checked)', description: 'On: the track turns to the brand color and the thumb slides right by its own width.', markup: 'checked attribute on the input' },
|
|
42
|
+
focus: { selector: ':has(:focus-visible)', description: 'Keyboard focus shows the 4px brand ring around the track.', markup: 'native :focus-visible on the input' },
|
|
43
|
+
disabled: { selector: '[data-disabled], &:has(:disabled)', description: 'Track at 50% opacity, cursor not-allowed on the whole row; the text keeps its color and the on/off position stays readable.', markup: 'disabled on the input' },
|
|
44
|
+
},
|
|
45
|
+
base: {
|
|
46
|
+
root: {
|
|
47
|
+
position: 'relative',
|
|
48
|
+
display: 'inline-grid',
|
|
49
|
+
'grid-template-columns': 'auto minmax(0, 1fr)',
|
|
50
|
+
'column-gap': '{space.2}',
|
|
51
|
+
'row-gap': '0',
|
|
52
|
+
'align-items': 'start',
|
|
53
|
+
'max-width': '100%',
|
|
54
|
+
color: '{color.fg-default}',
|
|
55
|
+
cursor: 'pointer',
|
|
56
|
+
'-webkit-tap-highlight-color': 'transparent',
|
|
57
|
+
},
|
|
58
|
+
input: { position: 'absolute', top: '0', left: '0', margin: '0', padding: '0', opacity: '0', cursor: 'pointer' },
|
|
59
|
+
track: {
|
|
60
|
+
position: 'relative',
|
|
61
|
+
display: 'block',
|
|
62
|
+
'grid-column': '1',
|
|
63
|
+
'grid-row': '1',
|
|
64
|
+
'flex-shrink': '0',
|
|
65
|
+
'border-radius': '{radius.full}',
|
|
66
|
+
'background-color': '{color.bg-muted}',
|
|
67
|
+
'box-shadow': ring('0.5px', '{color.border-default}'),
|
|
68
|
+
...TRACK_MOTION,
|
|
69
|
+
},
|
|
70
|
+
thumb: {
|
|
71
|
+
position: 'absolute',
|
|
72
|
+
top: '{space.0.5}',
|
|
73
|
+
left: '{space.0.5}',
|
|
74
|
+
'box-sizing': 'border-box',
|
|
75
|
+
'border-radius': '{radius.full}',
|
|
76
|
+
'background-color': '{white}',
|
|
77
|
+
'box-shadow': '{shadow.sm}',
|
|
78
|
+
transform: 'translateX(0)',
|
|
79
|
+
...THUMB_MOTION,
|
|
80
|
+
},
|
|
81
|
+
label: { 'grid-column': '2', ...typeStyle('label-sm'), color: '{color.fg-default}', 'user-select': 'none' },
|
|
82
|
+
description: { 'grid-column': '2', ...typeStyle('body-md'), color: '{color.fg-muted}' },
|
|
83
|
+
'@states': {
|
|
84
|
+
checked: { track: { 'background-color': '{color.bg-action}' } },
|
|
85
|
+
focus: { track: { 'box-shadow': `${ring('0.5px', '{color.border-default}')}, {shadow.focus}` } },
|
|
86
|
+
disabled: { root: { cursor: 'not-allowed' }, input: { cursor: 'not-allowed' }, track: { opacity: '{opacity.disabled}' } },
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
variants: {
|
|
90
|
+
size: {
|
|
91
|
+
sm: {
|
|
92
|
+
input: { width: '{space.9}', height: '{space.5}' },
|
|
93
|
+
track: { width: '{space.9}', height: '{space.5}' },
|
|
94
|
+
thumb: { width: '{space.4}', height: '{space.4}' },
|
|
95
|
+
'@states': { checked: { thumb: { transform: 'translateX({space.4})' } } },
|
|
96
|
+
},
|
|
97
|
+
md: {
|
|
98
|
+
root: { 'column-gap': '{space.3}', 'row-gap': '{space.0.5}' },
|
|
99
|
+
input: { width: '{space.11}', height: '{space.6}' },
|
|
100
|
+
track: { width: '{space.11}', height: '{space.6}' },
|
|
101
|
+
thumb: { width: '{space.5}', height: '{space.5}' },
|
|
102
|
+
label: { ...typeStyle('label-lg'), 'font-weight': '{font.weight.medium}' },
|
|
103
|
+
description: { ...typeStyle('body-lg') },
|
|
104
|
+
'@states': { checked: { thumb: { transform: 'translateX({space.5})' } } },
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
variant: {
|
|
108
|
+
default: { root: {} },
|
|
109
|
+
slim: {
|
|
110
|
+
track: { 'margin-top': '{space.0.5}', 'box-shadow': ring('1px', '{color.border-default}') },
|
|
111
|
+
thumb: { top: '0', left: '0', 'box-shadow': '{shadow.xs}', border: '{border.width.thin} solid {color.border-control}' },
|
|
112
|
+
'@states': {
|
|
113
|
+
checked: { track: { 'box-shadow': ring('1px', 'transparent') }, thumb: { 'border-color': '{color.bg-action}' } },
|
|
114
|
+
focus: { track: { 'box-shadow': `${ring('1px', '{color.border-default}')}, {shadow.focus}` } },
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
compound: [
|
|
120
|
+
{ when: { variant: 'slim', size: 'sm' }, block: { input: { top: '{space.0.5}', width: '{space.8}', height: '{space.4}' }, track: { width: '{space.8}', height: '{space.4}' } } },
|
|
121
|
+
{ when: { variant: 'slim', size: 'md' }, block: { input: { top: '{space.0.5}', width: '{space.10}', height: '{space.5}' }, track: { width: '{space.10}', height: '{space.5}' } } },
|
|
122
|
+
],
|
|
123
|
+
extraCss: `
|
|
124
|
+
.cn-switch:hover:has(:checked):not(:has(:disabled)) .cn-switch__track { background-color: {color.bg-action-hover}; }
|
|
125
|
+
.cn-switch:has(:checked):has(:focus-visible) .cn-switch__track { box-shadow: inset 0 0 0 0.5px {color.border-default}, {shadow.focus}; }
|
|
126
|
+
.cn-switch[data-variant="slim"]:has(:checked):has(:focus-visible) .cn-switch__track { box-shadow: inset 0 0 0 1px transparent, {shadow.focus}; }
|
|
127
|
+
.cn-switch[data-variant="slim"]:hover:has(:checked):not(:has(:disabled)) .cn-switch__thumb { border-color: {color.bg-action-hover}; }`,
|
|
128
|
+
examples: [
|
|
129
|
+
ex('Off', sw('data-size="sm"', '', 'Email digest'), '36×20 gray-100 track with a hairline ring; 16px white thumb with shadow-sm.'),
|
|
130
|
+
ex('On', sw('data-size="sm"', 'checked', 'Desktop notifications'), 'Brand-600 track, thumb slid 16px to the right; brand-700 on hover.'),
|
|
131
|
+
ex('With supporting text', sw('data-size="sm"', 'checked', 'Auto-approve replies', 'Lumen posts follow-ups without waiting for your review when the request only asks for documents.'), 'Supporting line: same size, regular weight, muted.'),
|
|
132
|
+
ex('Medium', sw('data-size="md"', 'checked', 'Share usage analytics', 'Helps us prioritise what to build next.'), '44×24 track with a 20px thumb; 16px text 12px away.'),
|
|
133
|
+
ex('Slim', stack(sw('data-size="sm"', '', 'Show archived', '', 'slim'), sw('data-size="sm"', 'checked', 'Compact rows', '', 'slim'), sw('data-size="md"', 'checked', 'Live cursors', 'Teammates see where you are editing.', 'slim')), 'A thin track under an overhanging bordered thumb: 32×16 (sm) and 40×20 (md). The thumb border turns brand when on.'),
|
|
134
|
+
ex('Disabled', stack(sw('data-size="sm"', 'checked disabled', 'SSO enforced', 'Managed by your identity provider.'), sw('data-size="sm"', 'disabled', 'Public profile')), 'The track fades to 50%; the text keeps its color.'),
|
|
135
|
+
],
|
|
136
|
+
recipes: [
|
|
137
|
+
ex('Settings rows', `<div style="width:100%;max-width:480px;display:flex;flex-direction:column">${[
|
|
138
|
+
['Email digest', 'A summary of new comments every weekday at 8:00.', 'checked'],
|
|
139
|
+
['Desktop notifications', 'Only for projects you follow.', 'checked'],
|
|
140
|
+
['Sounds', 'Play a chime when Maya Chen mentions you.', ''],
|
|
141
|
+
].map(([t, d, c], i) => `<div style="display:flex;align-items:center;justify-content:space-between;gap:var(--cn-space-4);padding:var(--cn-space-4) 0;${i ? 'border-top:1px solid var(--cn-color-border-default)' : ''}"><div><div class="cn-text-label-sm">${t}</div><div class="cn-text-body-md" style="color:var(--cn-color-fg-muted)">${d}</div></div>${sw('data-size="sm"', c, `<span class="cn-sr-only">${t}</span>`)}</div>`).join('')}</div>`, 'Text left, switch right, one row per setting with a hairline between rows. The switch keeps a visually hidden label so it stays named.'),
|
|
142
|
+
],
|
|
143
|
+
rules: [
|
|
144
|
+
'A switch applies immediately; there is no Save button after it. If the change needs a submit, use a Checkbox.',
|
|
145
|
+
'The label names the thing being turned on ("Email digest"), not the action ("Enable email digest") and not a question.',
|
|
146
|
+
'Never use a switch for a choice between two named options (Monthly / Annual): that is a SegmentedControl or a Radio group.',
|
|
147
|
+
'In settings lists the text sits left and the switch right, one row per setting, 16px vertical padding and a hairline between rows.',
|
|
148
|
+
'Do not add "On" / "Off" text next to the track; position and color are the state. Confirm with a toast only when the change has side effects (emails sent, data deleted).',
|
|
149
|
+
'sm (36×20) everywhere by default; md (44×24) on touch screens and prominent settings; slim inside dense rows and table cells.',
|
|
150
|
+
'Motion: thumb and track change together in 150ms; nothing else animates.',
|
|
151
|
+
],
|
|
152
|
+
a11y: [
|
|
153
|
+
'The input is a native checkbox with role="switch": screen readers announce on/off and Space toggles it.',
|
|
154
|
+
'The root <label> gives the accessible name. When the text lives elsewhere (settings row), keep a cn-sr-only label inside or use aria-labelledby.',
|
|
155
|
+
'The focus ring appears on the track through :has(:focus-visible); never remove it.',
|
|
156
|
+
'Disabled switches stay readable (track at 50%, text unchanged); say in the supporting line why the setting is locked.',
|
|
157
|
+
],
|
|
158
|
+
related: ['checkbox', 'radio', 'segmented-control'],
|
|
159
|
+
};
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { ex, ICON, typeStyle } from "./_shared.mjs";
|
|
2
|
+
// the reference table: a card (radius 12, gray-200 ring, shadow-xs) with a gray-50 header row
|
|
3
|
+
// (44px, 12px medium gray-600 labels, 8 × 24px padding), 64px rows (72 with two-line avatar cells,
|
|
4
|
+
// 56 at sm) divided by gray-200 hairlines, 14px gray-600 cells with 16 × 24px padding, ink medium
|
|
5
|
+
// primary text, a 44px checkbox column, 12px sort arrows and gray-50 hover / selected rows.
|
|
6
|
+
const HAIRLINE = '{border.width.thin} solid {color.border-default}';
|
|
7
|
+
const ring = (color) => `inset 0 0 0 1px ${color}`;
|
|
8
|
+
const SORT = '<svg class="cn-table__sort" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3v10M4 9l4 4 4-4"/></svg>';
|
|
9
|
+
const SORT_BOTH = '<svg class="cn-table__sort" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 6l3-3 3 3M5 10l3 3 3-3"/></svg>';
|
|
10
|
+
const HELP = ICON.info.replace('cn-icon', 'cn-table__help');
|
|
11
|
+
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";
|
|
12
|
+
const ghost = (label) => `<button type="button" class="cn-button" data-variant="ghost" data-size="sm"><span class="cn-button__label">${label}</span></button>`;
|
|
13
|
+
const link = (label) => `<button type="button" class="cn-button" data-variant="link-color" data-size="sm"><span class="cn-button__label">${label}</span></button>`;
|
|
14
|
+
const badge = (text, tone, dot = false) => `<span class="cn-badge" data-tone="${tone}" data-variant="soft" data-size="sm">${dot ? '<span class="cn-badge__dot"></span>' : ''}${text}</span>`;
|
|
15
|
+
const th = (label, extra = '') => `<th class="cn-table__header-cell" scope="col"${extra}>${label}</th>`;
|
|
16
|
+
const td = (content, extra = '') => `<td class="cn-table__cell"${extra}>${content}</td>`;
|
|
17
|
+
const checkbox = (label, checked = false) => `<label class="cn-checkbox" data-size="md"><input class="cn-checkbox__input" type="checkbox"${checked ? ' checked' : ''} aria-label="${label}"><span class="cn-checkbox__control" aria-hidden="true"><svg class="cn-checkbox__indicator" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 8.5l3 3 7-7"/></svg></span></label>`;
|
|
18
|
+
const person = (initials, name, handle) => `<div class="cn-avatar-label-group" data-size="md"><span class="cn-avatar" data-size="md" data-shape="circle" data-tone="neutral" data-status="none" aria-hidden="true"><img class="cn-avatar__image" src="${SILHOUETTE}" alt=""></span><div class="cn-avatar-label-group__text"><span class="cn-table__primary">${name}</span><span class="cn-table__secondary">${handle}</span></div></div>`;
|
|
19
|
+
const TEAM = [
|
|
20
|
+
['MC', 'Maya Chen', '@maya', 'Product Manager', 'maya@lumen.co', 'Active'],
|
|
21
|
+
['DC', 'Daniel Costa', '@daniel', 'Engineering', 'daniel@lumen.co', 'Active'],
|
|
22
|
+
['SA', 'Sofia Almeida', '@sofia', 'Design', 'sofia@lumen.co', 'Offline'],
|
|
23
|
+
['LF', 'Lucas Ferreira', '@lucas', 'Frontend', 'lucas@lumen.co', 'Active'],
|
|
24
|
+
['AK', 'Aisha Khan', '@aisha', 'Backend', 'aisha@lumen.co', 'Active'],
|
|
25
|
+
];
|
|
26
|
+
const teamRow = ([i, n, h, role, email, status], k) => `<tr class="cn-table__row"${k === 1 ? ' data-selected' : ''}><td class="cn-table__cell cn-table__checkbox">${checkbox(`Select ${n}`, k === 1)}</td>${td(person(i, n, h))}${td(badge(status, status === 'Active' ? 'success' : 'neutral', true))}${td(role)}${td(email)}${td(`<div style="display:flex;gap:var(--cn-space-1)">${badge('Design', 'accent')}${badge('Product', 'info')}${k % 2 ? badge('+2', 'neutral') : ''}</div>`)}<td class="cn-table__cell cn-table__actions">${ghost('Delete')}${link('Edit')}</td></tr>`;
|
|
27
|
+
const TEAM_HEAD = `<thead class="cn-table__head"><tr class="cn-table__row"><th class="cn-table__header-cell cn-table__checkbox" scope="col">${checkbox('Select all')}</th><th class="cn-table__header-cell" scope="col" aria-sort="ascending"><button type="button">Name${SORT}</button></th>${th(`<span>Status</span>${HELP}`)}<th class="cn-table__header-cell" scope="col"><button type="button">Role${SORT_BOTH}</button></th>${th('Email address')}${th('Teams')}<th class="cn-table__header-cell cn-table__actions" scope="col"><span class="cn-sr-only">Actions</span></th></tr></thead>`;
|
|
28
|
+
const INVOICES = [
|
|
29
|
+
['INV-0041', 'Dec 1, 2025', '$2,400.00', 'Paid'],
|
|
30
|
+
['INV-0042', 'Jan 1, 2026', '$2,400.00', 'Paid'],
|
|
31
|
+
['INV-0043', 'Feb 1, 2026', '$2,900.00', 'Overdue'],
|
|
32
|
+
['INV-0044', 'Mar 1, 2026', '$2,900.00', 'Pending'],
|
|
33
|
+
];
|
|
34
|
+
const FILES = [
|
|
35
|
+
['Brand guidelines.pdf', '2.4 MB', 'Sofia Almeida', 'Jan 4, 2026'],
|
|
36
|
+
['Q3 roadmap.xlsx', '720 KB', 'Maya Chen', 'Jan 2, 2026'],
|
|
37
|
+
['Onboarding video.mp4', '16 MB', 'Noah Berg', 'Dec 18, 2025'],
|
|
38
|
+
['Customer list.csv', '96 KB', 'Elena Rossi', 'Dec 12, 2025'],
|
|
39
|
+
['Pricing page.fig', '4.1 MB', 'Tomás Silva', 'Dec 9, 2025'],
|
|
40
|
+
['Release notes.md', '12 KB', 'Lucas Ferreira', 'Dec 2, 2025'],
|
|
41
|
+
];
|
|
42
|
+
export const table = {
|
|
43
|
+
name: 'Table',
|
|
44
|
+
slug: 'table',
|
|
45
|
+
category: 'data-display',
|
|
46
|
+
description: 'Rows of records in the reference table: a gray-50 header row with 12px medium labels, 64px rows (72 with avatars) on gray-200 hairlines, 14px gray-600 cells with ink medium primary text, a checkbox column, sortable headers and gray-50 hover / selected rows; optionally framed as a card (radius 12, ring, shadow-xs).',
|
|
47
|
+
usage: 'Collections of similar records that people scan and compare (team members, customers, invoices, files). For rows with one line of text and an avatar use List; for key/value pairs use DescriptionList; for a handful of visual items use Cards. Put the table in a Card with a CardHeader and a Pagination footer, or use variant="card" standalone.',
|
|
48
|
+
anatomy: [
|
|
49
|
+
{ part: 'root', element: 'table', description: 'The <table>: width 100%, separate borders with zero spacing so radii and sticky headers work; 14px text in fg-muted. Add data-interactive when rows open something and data-sticky to pin the header inside a scroll container.' },
|
|
50
|
+
{ part: 'caption', element: 'caption', description: 'Optional visible name of the table (14px medium fg-muted) above the header, left-aligned; prefer a CardHeader.', optional: true },
|
|
51
|
+
{ part: 'head', element: 'thead', description: 'Header row group: gray-50 fill, 44px tall (36 at compact), gray-200 hairline below.' },
|
|
52
|
+
{ part: 'header-cell', element: 'th', description: 'Column header: 12px medium fg-muted, 8 × 24px padding, no wrap, scope="col". Sortable headers wrap their text in a <button> and carry aria-sort; a help icon may follow the label.' },
|
|
53
|
+
{ part: 'help', element: 'svg', description: '16px help-circle after a header label, fg-subtle, with a Tooltip explaining the column.', optional: true },
|
|
54
|
+
{ part: 'body', element: 'tbody', description: 'The data rows.' },
|
|
55
|
+
{ part: 'row', element: 'tr', description: 'One record, 64px (56 compact, 72 comfortable). data-selected (or aria-selected inside a grid) fills it gray-50; hover fills it gray-50 only when the table is data-interactive.' },
|
|
56
|
+
{ part: 'cell', element: 'td', description: 'A value: 14px fg-muted, 16 × 24px padding (12 × 20 compact), gray-200 hairline below, vertical-align middle. data-align="right" for numbers (tabular figures), "center" for icons.' },
|
|
57
|
+
{ part: 'primary', element: 'span', description: 'The identifying text of a row (a name), 14px medium in ink; stack it over a secondary line inside an avatar label group.', optional: true },
|
|
58
|
+
{ part: 'secondary', element: 'span', description: 'The supporting line under the primary text (handle, email), 14px fg-muted.', optional: true },
|
|
59
|
+
{ part: 'checkbox', element: 'td', description: 'The 44px selection column (add this class alongside the cell / header-cell class): 24px left padding, 0 right, a Checkbox md; the next cell pulls its padding to 12px.', optional: true },
|
|
60
|
+
{ part: 'footer', element: 'tfoot', description: 'Optional totals row group: gray-50 fill, hairline above, medium weight.', optional: true },
|
|
61
|
+
{ part: 'sort', element: 'svg', description: '12px arrow after a sortable header\'s text (stroke 3). The unsorted chevron-selector shows on hover; the arrow shows when aria-sort is set and flips for ascending.', optional: true },
|
|
62
|
+
{ part: 'actions', element: 'td', description: 'The last cell, right-aligned, holding one or two sm ghost / link Buttons or a ⋯ IconButton with a Menu. Add this class alongside the cell class (and on its header cell).', optional: true },
|
|
63
|
+
],
|
|
64
|
+
props: {
|
|
65
|
+
density: {
|
|
66
|
+
values: ['compact', 'default', 'comfortable'],
|
|
67
|
+
default: 'default',
|
|
68
|
+
description: 'Row height and cell padding: compact = 56px rows, 36px header, 12 × 20px cells (the reference sm); default = 64px rows, 44px header, 16 × 24px cells; comfortable = 72px rows for two-line avatar cells (the reference md).',
|
|
69
|
+
},
|
|
70
|
+
variant: {
|
|
71
|
+
values: ['default', 'striped', 'bordered', 'card'],
|
|
72
|
+
default: 'default',
|
|
73
|
+
description: 'default = hairlines between rows only (inside a Card with padding none); striped = alternate rows gray-50, for wide tables with many columns; bordered = full grid lines, for numeric matrices; card = the table draws its own frame: radius 12, gray-200 ring, shadow-xs, for standalone tables on the canvas.',
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
states: {
|
|
77
|
+
rowHover: { selector: '[data-interactive] tbody tr:hover', description: 'On a body row, not the root: gray-50 fill while hovered. Only when the root has data-interactive (rows open a record).', markup: 'data-interactive on the <table>; native :hover on the row' },
|
|
78
|
+
rowSelected: { selector: 'tr[data-selected], tr[aria-selected="true"]', description: 'On a body row: gray-50 fill (the reference highlights selected rows like hovered ones); the checkbox shows the selection.', markup: 'data-selected on the <tr> (aria-selected="true" inside role="grid")' },
|
|
79
|
+
sorted: { selector: 'th[aria-sort]', description: 'On a header cell: the sort arrow is visible; ascending flips it.', markup: 'aria-sort="ascending|descending" on the <th>' },
|
|
80
|
+
sticky: { selector: '[data-sticky]', description: 'Header cells stick to the top of the nearest scroll container.', markup: 'data-sticky on the <table>, inside a container with overflow auto' },
|
|
81
|
+
},
|
|
82
|
+
base: {
|
|
83
|
+
root: {
|
|
84
|
+
width: '100%',
|
|
85
|
+
'border-collapse': 'separate',
|
|
86
|
+
'border-spacing': '0',
|
|
87
|
+
...typeStyle('body-md'),
|
|
88
|
+
color: '{color.fg-muted}',
|
|
89
|
+
'text-align': 'left',
|
|
90
|
+
},
|
|
91
|
+
caption: {
|
|
92
|
+
...typeStyle('label-sm'),
|
|
93
|
+
color: '{color.fg-muted}',
|
|
94
|
+
'text-align': 'left',
|
|
95
|
+
'caption-side': 'top',
|
|
96
|
+
'padding-bottom': '{space.3}',
|
|
97
|
+
},
|
|
98
|
+
head: { 'background-color': '{color.bg-subtle}' },
|
|
99
|
+
'header-cell': {
|
|
100
|
+
...typeStyle('label-xs'),
|
|
101
|
+
color: '{color.fg-muted}',
|
|
102
|
+
padding: '{space.2} {space.6}',
|
|
103
|
+
height: '{space.11}',
|
|
104
|
+
'border-bottom': HAIRLINE,
|
|
105
|
+
'background-color': '{color.bg-subtle}',
|
|
106
|
+
'white-space': 'nowrap',
|
|
107
|
+
'text-align': 'left',
|
|
108
|
+
'vertical-align': 'middle',
|
|
109
|
+
},
|
|
110
|
+
help: { display: 'inline-block', width: '{size.icon.sm}', height: '{size.icon.sm}', 'margin-inline-start': '{space.1}', 'vertical-align': 'middle', color: '{color.fg-subtle}', 'stroke-width': '2' },
|
|
111
|
+
body: { color: '{color.fg-muted}' },
|
|
112
|
+
row: {
|
|
113
|
+
height: '{space.16}',
|
|
114
|
+
'transition-property': 'background-color',
|
|
115
|
+
'transition-duration': '{motion.duration.fast}',
|
|
116
|
+
'transition-timing-function': '{motion.easing.linear}',
|
|
117
|
+
},
|
|
118
|
+
cell: {
|
|
119
|
+
padding: '{space.4} {space.6}',
|
|
120
|
+
'border-bottom': HAIRLINE,
|
|
121
|
+
'vertical-align': 'middle',
|
|
122
|
+
color: '{color.fg-muted}',
|
|
123
|
+
},
|
|
124
|
+
primary: { display: 'block', ...typeStyle('label-sm'), color: '{color.fg-default}', overflow: 'hidden', 'text-overflow': 'ellipsis', 'white-space': 'nowrap' },
|
|
125
|
+
secondary: { display: 'block', ...typeStyle('body-md'), color: '{color.fg-muted}', overflow: 'hidden', 'text-overflow': 'ellipsis', 'white-space': 'nowrap' },
|
|
126
|
+
checkbox: { width: '{space.11}', 'padding-inline': '{space.6} 0' },
|
|
127
|
+
footer: { 'background-color': '{color.bg-subtle}' },
|
|
128
|
+
sort: {
|
|
129
|
+
display: 'inline-block',
|
|
130
|
+
width: '{size.icon.xs}',
|
|
131
|
+
height: '{size.icon.xs}',
|
|
132
|
+
'margin-inline-start': '{space.1}',
|
|
133
|
+
'vertical-align': 'middle',
|
|
134
|
+
color: '{color.fg-subtle}',
|
|
135
|
+
'stroke-width': '3',
|
|
136
|
+
opacity: '0',
|
|
137
|
+
'transition-property': 'opacity, transform',
|
|
138
|
+
'transition-duration': '{motion.duration.fast}',
|
|
139
|
+
'transition-timing-function': '{motion.easing.linear}',
|
|
140
|
+
},
|
|
141
|
+
actions: { 'text-align': 'right', 'white-space': 'nowrap' },
|
|
142
|
+
},
|
|
143
|
+
variants: {
|
|
144
|
+
density: {
|
|
145
|
+
compact: { row: { height: '{space.14}' }, cell: { padding: '{space.3} {space.5}' }, 'header-cell': { height: '{space.9}', padding: '{space.2} {space.5}' }, checkbox: { width: '{space.9}', 'padding-inline': '{space.5} 0' } },
|
|
146
|
+
default: { row: { height: '{space.16}' }, cell: { padding: '{space.4} {space.6}' }, 'header-cell': { height: '{space.11}' } },
|
|
147
|
+
comfortable: { row: { height: 'calc({space.16} + {space.2})' }, cell: { padding: '{space.4} {space.6}' }, 'header-cell': { height: '{space.11}' } },
|
|
148
|
+
},
|
|
149
|
+
variant: {
|
|
150
|
+
default: { root: {} },
|
|
151
|
+
striped: { root: {} },
|
|
152
|
+
bordered: { root: { 'box-shadow': ring('{color.border-default}'), 'border-radius': '{radius.card}' } },
|
|
153
|
+
card: { root: { 'box-shadow': `${ring('{color.border-default}')}, {shadow.xs}`, 'border-radius': '{radius.card}', 'background-color': '{color.bg-surface}' } },
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
extraCss: `
|
|
157
|
+
.cn-table[data-density] .cn-table__head .cn-table__row, .cn-table__head .cn-table__row { height: auto; }
|
|
158
|
+
.cn-table[data-interactive] .cn-table__body .cn-table__row { cursor: pointer; }
|
|
159
|
+
.cn-table[data-interactive] .cn-table__body .cn-table__row:hover .cn-table__cell { background-color: {color.bg-subtle}; }
|
|
160
|
+
.cn-table .cn-table__body .cn-table__row[data-selected] .cn-table__cell, .cn-table .cn-table__body .cn-table__row[aria-selected="true"] .cn-table__cell { background-color: {color.bg-subtle}; }
|
|
161
|
+
.cn-table[data-sticky] .cn-table__header-cell { position: sticky; top: 0; z-index: {z.sticky}; }
|
|
162
|
+
.cn-table__header-cell[data-align="right"], .cn-table__cell[data-align="right"] { text-align: right; font-variant-numeric: tabular-nums; }
|
|
163
|
+
.cn-table__header-cell[data-align="center"], .cn-table__cell[data-align="center"] { text-align: center; }
|
|
164
|
+
.cn-table__header-cell:hover .cn-table__sort, .cn-table__header-cell[aria-sort] .cn-table__sort { opacity: 1; }
|
|
165
|
+
.cn-table__header-cell[aria-sort="ascending"] .cn-table__sort { transform: rotate(180deg); }
|
|
166
|
+
.cn-table__header-cell > button { appearance: none; border: 0; padding: 0; margin: 0; background: none; font: inherit; color: inherit; letter-spacing: inherit; cursor: pointer; display: inline-flex; align-items: center; gap: {space.1}; border-radius: {radius.sm}; }
|
|
167
|
+
.cn-table__header-cell > button .cn-table__sort { margin-inline-start: 0; }
|
|
168
|
+
.cn-table__header-cell > button:focus-visible { outline: none; box-shadow: inset 0 0 0 2px {brand.500}; }
|
|
169
|
+
.cn-table__checkbox + .cn-table__cell, .cn-table__checkbox + .cn-table__header-cell { padding-inline-start: {space.3}; }
|
|
170
|
+
.cn-table__checkbox .cn-checkbox { display: inline-flex; vertical-align: middle; }
|
|
171
|
+
.cn-table__footer .cn-table__cell { background-color: {color.bg-subtle}; border-bottom: 0; border-top: ${HAIRLINE}; font-weight: {font.weight.medium}; color: {color.fg-default}; }
|
|
172
|
+
.cn-table__actions > * + * { margin-inline-start: {space.1}; }
|
|
173
|
+
.cn-table__cell .cn-avatar-label-group { display: flex; }
|
|
174
|
+
.cn-table[data-variant="striped"] .cn-table__body .cn-table__row:nth-child(even) .cn-table__cell { background-color: {color.bg-subtle}; }
|
|
175
|
+
.cn-table[data-variant="striped"][data-interactive] .cn-table__body .cn-table__row:hover .cn-table__cell { background-color: {color.bg-muted}; }
|
|
176
|
+
.cn-table[data-variant="bordered"] .cn-table__header-cell, .cn-table[data-variant="bordered"] .cn-table__cell { border-inline-end: ${HAIRLINE}; }
|
|
177
|
+
.cn-table[data-variant="bordered"] .cn-table__header-cell:last-child, .cn-table[data-variant="bordered"] .cn-table__cell:last-child { border-inline-end: 0; }
|
|
178
|
+
.cn-table[data-variant="card"] .cn-table__head .cn-table__row:first-child .cn-table__header-cell:first-child, .cn-table[data-variant="bordered"] .cn-table__head .cn-table__row:first-child .cn-table__header-cell:first-child { border-start-start-radius: {radius.card}; }
|
|
179
|
+
.cn-table[data-variant="card"] .cn-table__head .cn-table__row:first-child .cn-table__header-cell:last-child, .cn-table[data-variant="bordered"] .cn-table__head .cn-table__row:first-child .cn-table__header-cell:last-child { border-start-end-radius: {radius.card}; }
|
|
180
|
+
.cn-table[data-variant="card"] > :last-child > .cn-table__row:last-child > .cn-table__cell, .cn-table[data-variant="bordered"] > :last-child > .cn-table__row:last-child > .cn-table__cell { border-bottom: 0; }
|
|
181
|
+
.cn-table[data-variant="card"] > :last-child > .cn-table__row:last-child > .cn-table__cell:first-child, .cn-table[data-variant="bordered"] > :last-child > .cn-table__row:last-child > .cn-table__cell:first-child { border-end-start-radius: {radius.card}; }
|
|
182
|
+
.cn-table[data-variant="card"] > :last-child > .cn-table__row:last-child > .cn-table__cell:last-child, .cn-table[data-variant="bordered"] > :last-child > .cn-table__row:last-child > .cn-table__cell:last-child { border-end-end-radius: {radius.card}; }
|
|
183
|
+
.cn-card[data-padding="none"] > .cn-table > :last-child > .cn-table__row:last-child > .cn-table__cell { border-bottom: 0; }`,
|
|
184
|
+
examples: [
|
|
185
|
+
ex('Team members (card, comfortable, sorted, one selected)', `<div style="width:100%;overflow-x:auto" role="region" tabindex="0" aria-label="Scrollable table"><table class="cn-table" data-density="comfortable" data-variant="card" data-interactive>${TEAM_HEAD}<tbody class="cn-table__body">${TEAM.map((r, i) => teamRow(r, i)).join('')}</tbody></table></div>`, 'The reference team table: 44px gray-50 header, 72px rows with a 40px avatar and two lines (primary ink medium, secondary muted), a 44px checkbox column, Name sorted ascending, Role sortable on hover, a help icon on Status, ghost + link actions.'),
|
|
186
|
+
ex('Invoices (default density, in a Card with header and pagination)', `<section class="cn-card" data-variant="default" data-padding="none" style="width:100%;max-width:720px"><header class="cn-card-header" data-variant="with-badge" data-size="md"><div class="cn-card-header__content"><h2 class="cn-card-header__title">Invoices<span class="cn-badge cn-card-header__badge" data-tone="neutral" data-variant="modern" data-size="sm" data-shape="square">4</span></h2><p class="cn-card-header__description">Billing history for the Lumen workspace.</p></div><div class="cn-card-header__actions">${ghost('Download all')}</div></header><div style="width:100%;overflow-x:auto" role="region" tabindex="0" aria-label="Invoices"><table class="cn-table" data-density="default" data-variant="default"><thead class="cn-table__head"><tr class="cn-table__row">${th('Invoice')}${th('Date')}${th('Amount', ' data-align="right"')}${th('Status')}<th class="cn-table__header-cell cn-table__actions" scope="col"><span class="cn-sr-only">Actions</span></th></tr></thead><tbody class="cn-table__body">${INVOICES.map(([id, date, amount, status]) => `<tr class="cn-table__row">${td(`<span class="cn-table__primary">${id}</span>`)}${td(date)}${td(amount, ' data-align="right"')}${td(badge(status, status === 'Paid' ? 'success' : status === 'Overdue' ? 'danger' : 'warning'))}<td class="cn-table__cell cn-table__actions">${link('Download')}</td></tr>`).join('')}</tbody></table></div><footer class="cn-card__footer"><nav class="cn-pagination" data-variant="minimal" data-size="sm" data-shape="square" data-align="center" aria-label="Invoices pagination" style="border-top:0;padding-top:0"><button type="button" class="cn-pagination__prev" aria-label="Previous page" disabled><span>Previous</span></button><span class="cn-pagination__summary">Page 1 of 3</span><button type="button" class="cn-pagination__next" aria-label="Next page"><span>Next</span></button></nav></footer></section>`, '64px rows; the Card supplies the frame, a CardHeader names it and a minimal Pagination closes it. Numbers right-aligned and tabular.'),
|
|
187
|
+
ex('Files (compact, striped)', `<div style="width:100%;overflow-x:auto" role="region" tabindex="0" aria-label="Scrollable table"><table class="cn-table" data-density="compact" data-variant="striped"><thead class="cn-table__head"><tr class="cn-table__row">${th('File name')}${th('Size', ' data-align="right"')}${th('Uploaded by')}${th('Last modified')}</tr></thead><tbody class="cn-table__body">${FILES.map(([n, s, by, d]) => `<tr class="cn-table__row">${td(`<span class="cn-table__primary">${n}</span>`)}${td(s, ' data-align="right"')}${td(by)}${td(d)}</tr>`).join('')}</tbody></table></div>`, '56px rows, 36px header, 12 × 20px cells (the reference sm); even rows gray-50.'),
|
|
188
|
+
ex('Bordered with totals and a sticky header (scrolls)', `<div style="width:100%;max-height:224px;overflow:auto;border-radius:var(--cn-radius-card)" tabindex="0" aria-label="Files, scrollable"><table class="cn-table" data-density="compact" data-variant="bordered" data-sticky><thead class="cn-table__head"><tr class="cn-table__row">${th('File name')}${th('Size', ' data-align="right"')}${th('Uploaded by')}</tr></thead><tbody class="cn-table__body">${FILES.map(([n, s, by]) => `<tr class="cn-table__row">${td(n)}${td(s, ' data-align="right"')}${td(by)}</tr>`).join('')}</tbody><tfoot class="cn-table__footer"><tr class="cn-table__row">${td('6 files')}${td('23.3 MB', ' data-align="right"')}${td('')}</tr></tfoot></table></div>`, 'Full grid lines for numeric matrices; scroll the container and the header stays.'),
|
|
189
|
+
],
|
|
190
|
+
rules: [
|
|
191
|
+
'Column headers are 12px medium, sentence case, 1–2 words, no trailing colon; never uppercase.',
|
|
192
|
+
'Row heights come from density only: 56 / 64 / 72px; comfortable for cells with an avatar and two lines, compact for dense operational tables.',
|
|
193
|
+
'The first column identifies the row: primary text in ink medium (name, ID) over an optional secondary line, inside an avatar label group when there is a picture. It is the widest and the only one that may truncate.',
|
|
194
|
+
'Numbers are right-aligned with data-align="right" (tabular figures) and never truncated; text is left-aligned; center only icons and checkboxes.',
|
|
195
|
+
'The selection column is the 44px checkbox cell with a Checkbox md; the header checkbox selects all. Selected rows fill gray-50, like hover.',
|
|
196
|
+
'Row hover only when rows open something (data-interactive); the fill is gray-50 and never changes the row height.',
|
|
197
|
+
'Actions live in the last column, right-aligned, as at most two sm ghost / link Buttons or one ⋯ IconButton with a Menu, visible at rest (never only on hover).',
|
|
198
|
+
'Statuses in cells are Badge sm with a dot; people are the avatar label group; never an icon without text.',
|
|
199
|
+
'One sorted column at a time, shown by aria-sort and the 12px arrow; the chevron-selector appears on hover for other sortable columns.',
|
|
200
|
+
'Empty or loading tables replace the body with an EmptyState or Skeleton rows; never render a bare header.',
|
|
201
|
+
'Use variant="card" for standalone tables on the canvas; inside a Card with padding="none" use the default variant so the card supplies the frame.',
|
|
202
|
+
],
|
|
203
|
+
a11y: [
|
|
204
|
+
'A real <table> with <th scope="col"> in the header (and scope="row" on the identifying cell when useful). Never a grid of divs.',
|
|
205
|
+
'Name the table with a CardHeader (aria-labelledby), a visible caption or aria-label.',
|
|
206
|
+
'Sortable headers contain a <button>; aria-sort on the <th> reflects the current order. The button label names the column; announce the next order in a visually hidden hint if needed.',
|
|
207
|
+
'Row checkboxes have aria-label "Select {name}"; the header checkbox "Select all". aria-selected on rows is valid only inside role="grid"; in plain tables use data-selected plus the checkbox.',
|
|
208
|
+
'Interactive rows also need a focusable element per row (a link or button in the identifying cell); the row hover is not the only affordance.',
|
|
209
|
+
'A scroll container with a sticky header gets tabindex="0" and an aria-label so keyboard users can scroll it.',
|
|
210
|
+
],
|
|
211
|
+
related: ['card', 'card-header', 'pagination', 'list', 'description-list', 'badge', 'avatar', 'checkbox', 'button', 'empty-state', 'skeleton'],
|
|
212
|
+
};
|