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,210 @@
|
|
|
1
|
+
import { CONTROL, SIZE_PROP, ex, ICON, typeStyle } from "./_shared.mjs";
|
|
2
|
+
// the reference select / combobox: the Input ring on the field (36 / 40 / 44px), a 16px chevron
|
|
3
|
+
// (20px on lg) at the trailing edge, and the popover listbox — radius 8, white, shadow-lg with a 1px
|
|
4
|
+
// gray-200 ring, 4px vertical padding, rows of 36 / 40 / 44px with radius 6, 8px gap, medium label,
|
|
5
|
+
// muted supporting text, gray-50 highlight and a brand check on the right of the selected row.
|
|
6
|
+
const TRANSITION_FAST = {
|
|
7
|
+
'transition-property': 'box-shadow, background-color, color, opacity',
|
|
8
|
+
'transition-duration': '{motion.duration.fast}',
|
|
9
|
+
'transition-timing-function': '{motion.easing.linear}',
|
|
10
|
+
};
|
|
11
|
+
const ring = (color) => `inset 0 0 0 1px ${color}`;
|
|
12
|
+
const RING_REST = `${ring('{color.border-control}')}, {shadow.xs}`;
|
|
13
|
+
const RING_HOVER = `${ring('{color.border-control-hover}')}, {shadow.xs}`;
|
|
14
|
+
const RING_FOCUS = `${ring('{color.border-action}')}, {shadow.xs}, {shadow.focus}`;
|
|
15
|
+
const RING_INVALID = `${ring('{color.border-danger}')}, {shadow.xs}`;
|
|
16
|
+
const RING_INVALID_FOCUS = `${ring('{color.border-danger}')}, {shadow.xs}, {shadow.focus-danger}`;
|
|
17
|
+
const SIZE = {
|
|
18
|
+
sm: { pl: '{space.3}', pr: '{space.2.5}', chevron: '{size.icon.sm}', icon: '{size.icon.sm}', lh: '{type.body-md.lineHeight}', rowPy: '{space.2}', listPx: '{space.1}', listMax: '224px', avatar: '{space.5}' },
|
|
19
|
+
md: { pl: '{space.3}', pr: '{space.3}', chevron: '{size.icon.sm}', icon: '{size.icon.md}', lh: '{type.body-md.lineHeight}', rowPy: '{space.2}', listPx: '{space.1.5}', listMax: '{space.64}', avatar: '{space.6}' },
|
|
20
|
+
lg: { pl: '{space.3.5}', pr: '{space.3.5}', chevron: '{size.icon.md}', icon: '{size.icon.md}', lh: '{type.body-lg.lineHeight}', rowPy: '{space.2.5}', listPx: '{space.1.5}', listMax: '320px', avatar: '{space.6}' },
|
|
21
|
+
};
|
|
22
|
+
const sizeBlock = (s) => ({
|
|
23
|
+
input: {
|
|
24
|
+
height: CONTROL[s].height,
|
|
25
|
+
'padding-inline-start': SIZE[s].pl,
|
|
26
|
+
'padding-inline-end': `calc(${SIZE[s].pr} + ${SIZE[s].chevron} + {space.2})`,
|
|
27
|
+
'font-size': CONTROL[s].font,
|
|
28
|
+
'line-height': SIZE[s].lh,
|
|
29
|
+
},
|
|
30
|
+
icon: { width: SIZE[s].chevron, height: SIZE[s].chevron, top: `calc((${CONTROL[s].height} - ${SIZE[s].chevron}) / 2)`, 'inset-inline-end': SIZE[s].pr },
|
|
31
|
+
'leading-icon': { width: SIZE[s].icon, height: SIZE[s].icon, top: `calc((${CONTROL[s].height} - ${SIZE[s].icon}) / 2)`, 'inset-inline-start': SIZE[s].pl },
|
|
32
|
+
listbox: { 'padding-inline': SIZE[s].listPx, 'max-height': SIZE[s].listMax },
|
|
33
|
+
option: { height: CONTROL[s].height, 'padding-block': SIZE[s].rowPy, 'font-size': CONTROL[s].font, 'line-height': SIZE[s].lh },
|
|
34
|
+
'option-icon': { width: SIZE[s].icon, height: SIZE[s].icon },
|
|
35
|
+
'option-check': { width: SIZE[s].icon, height: SIZE[s].icon },
|
|
36
|
+
'option-avatar': { width: SIZE[s].avatar, height: SIZE[s].avatar },
|
|
37
|
+
'option-supporting': { 'font-size': CONTROL[s].font, 'margin-inline-start': s === 'sm' ? '{space.1.5}' : '{space.2}' },
|
|
38
|
+
});
|
|
39
|
+
const chevron = ICON.chevronDown.replace('cn-icon', 'cn-combobox__icon');
|
|
40
|
+
const check = ICON.check.replace('cn-icon', 'cn-combobox__option-check');
|
|
41
|
+
const leading = (name) => ICON[name].replace('cn-icon', 'cn-combobox__leading-icon');
|
|
42
|
+
const optIcon = (name) => ICON[name].replace('cn-icon', 'cn-combobox__option-icon');
|
|
43
|
+
const AVATAR = "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";
|
|
44
|
+
const avatar = `<img class="cn-combobox__option-avatar" src="${AVATAR}" alt="">`;
|
|
45
|
+
const option = (id, label, attrs = '', before = '', supporting = '') => `<li class="cn-combobox__option" role="option" id="${id}" ${attrs}>${before}${label}${supporting ? `<span class="cn-combobox__option-supporting">${supporting}</span>` : ''}${check}</li>`;
|
|
46
|
+
const box = (attrs) => `<div class="cn-combobox" ${attrs.root}>${attrs.leading ?? ''}<input class="cn-combobox__input" type="text" role="combobox" aria-autocomplete="list" aria-controls="${attrs.id}-list" ${attrs.input}>${chevron}<ul class="cn-combobox__listbox" role="listbox" id="${attrs.id}-list">${attrs.body ?? ''}</ul></div>`;
|
|
47
|
+
const PEOPLE = [
|
|
48
|
+
['maya', 'Maya Chen', 'Design'],
|
|
49
|
+
['daniel', 'Daniel Costa', 'Product'],
|
|
50
|
+
['sofia', 'Sofia Almeida', 'Research'],
|
|
51
|
+
['lucas', 'Lucas Ferreira', 'Engineering'],
|
|
52
|
+
];
|
|
53
|
+
export const combobox = {
|
|
54
|
+
name: 'Combobox',
|
|
55
|
+
slug: 'combobox',
|
|
56
|
+
category: 'forms',
|
|
57
|
+
description: 'Text input that filters a list: an Input-ringed field with a trailing chevron and the reference popover listbox under it (radius 8, shadow-lg, 1px ring, 4px padding) whose rows are 36 / 40 / 44px with a gray-50 highlight and a brand check on the selected one.',
|
|
58
|
+
usage: 'Choose one item from a long, searchable or user-generated list (people, workspaces, countries), or when rows need an avatar, icon or supporting text. For 5–15 plain options use Select; for free text with suggestions keep the Input; for multiple picks use a TagInput. Pair with Field for the label.',
|
|
59
|
+
anatomy: [
|
|
60
|
+
{ part: 'root', element: 'div', description: 'Wrapper (position: relative) that holds the input, the chevron and the listbox. Carries data-state="open|closed", data-invalid, data-disabled.' },
|
|
61
|
+
{ part: 'leading-icon', element: 'svg', description: 'Optional leading icon (16px on sm, 20px on md and lg) at the padding edge in gray-500; the text starts 8px after it. Decorative.', optional: true },
|
|
62
|
+
{ part: 'input', element: 'input', description: 'The text field with role="combobox": the Input ring, radius 8, shadow-xs; the trailing padding reserves room for the chevron.' },
|
|
63
|
+
{ part: 'icon', element: 'svg', description: 'Chevron-down, 16px (20px on lg), gray-500, absolutely placed 10 / 12 / 14px from the trailing edge; rotates 180° when open. Decorative, pointer-events: none.' },
|
|
64
|
+
{ part: 'listbox', element: 'ul', description: 'The popover: white, radius 8, shadow-lg with a 1px gray-200 ring, 4px vertical padding (4–6px horizontal), 4px under the input, scrolls after 224 / 256 / 320px. Hidden unless data-state="open".' },
|
|
65
|
+
{ part: 'option', element: 'li', description: 'One row: 36 / 40 / 44px, radius 6, padding 8px 10px 8px 8px, 8px gap, medium label. role="option" with a unique id, aria-selected and optional data-highlighted / aria-disabled.' },
|
|
66
|
+
{ part: 'option-icon', element: 'svg', description: 'Optional leading icon in the row (16px on sm, 20px on md and lg), gray-500.', optional: true },
|
|
67
|
+
{ part: 'option-avatar', element: 'img', description: 'Optional round avatar before the label (20px on sm, 24px on md and lg).', optional: true },
|
|
68
|
+
{ part: 'option-supporting', element: 'span', description: 'Optional muted text after the label (email, team, price), same size, regular weight.', optional: true },
|
|
69
|
+
{ part: 'option-check', element: 'svg', description: 'Check icon (16 / 20px) pushed to the right of the row in the brand color, shown only when aria-selected="true".' },
|
|
70
|
+
{ part: 'empty', element: 'li', description: 'Muted "No results" row rendered instead of options when the filter matches nothing.', optional: true },
|
|
71
|
+
],
|
|
72
|
+
props: {
|
|
73
|
+
size: SIZE_PROP(['sm', 'md', 'lg']),
|
|
74
|
+
},
|
|
75
|
+
states: {
|
|
76
|
+
hover: { selector: ':hover:not([data-disabled])', description: 'Pointer over the field. The reference has no hover change; presets with a distinct border-control-hover strengthen the ring.', markup: 'native' },
|
|
77
|
+
focus: { selector: ':focus-within', description: 'The inset ring turns to the brand color and the 4px brand focus ring appears. Focus never leaves the input while the list is open.', markup: 'native :focus-within on the root' },
|
|
78
|
+
open: { selector: '[data-state="open"]', description: 'The listbox is visible under the input and the chevron points up.', markup: 'data-state="open" on the root and aria-expanded="true" on the input' },
|
|
79
|
+
invalid: { selector: '[data-invalid], &:has([aria-invalid="true"])', description: 'Red-300 ring; red focus ring on focus. Always accompanied by a Field error.', markup: 'aria-invalid="true" on the input, or data-invalid on the root' },
|
|
80
|
+
disabled: { selector: '[data-disabled], &:has(input:disabled)', description: 'Whole control at 50% opacity, cursor not-allowed.', markup: 'disabled on the input' },
|
|
81
|
+
highlighted: { selector: '[data-highlighted]', description: 'On the __option (not the root): the row under the keyboard cursor or pointer gets the gray-50 fill. Exactly one at a time.', markup: 'data-highlighted on the option; mirror it with aria-activedescendant on the input' },
|
|
82
|
+
selected: { selector: '[aria-selected="true"]', description: 'On the __option: the current value, gray-50 fill with the brand check at the end.', markup: 'aria-selected="true" on the option' },
|
|
83
|
+
'option-disabled': { selector: '[aria-disabled="true"]', description: 'On the __option: not selectable, 50% opacity, cursor not-allowed, skipped by the keyboard.', markup: 'aria-disabled="true" on the option' },
|
|
84
|
+
},
|
|
85
|
+
base: {
|
|
86
|
+
root: { position: 'relative', display: 'block', width: '100%', 'min-width': '0', color: '{color.fg-default}' },
|
|
87
|
+
'leading-icon': { position: 'absolute', color: '{color.fg-subtle}', 'pointer-events': 'none' },
|
|
88
|
+
input: {
|
|
89
|
+
display: 'block',
|
|
90
|
+
width: '100%',
|
|
91
|
+
'min-width': '0',
|
|
92
|
+
margin: '0',
|
|
93
|
+
'padding-block': '0',
|
|
94
|
+
border: '0',
|
|
95
|
+
...typeStyle('body-md'),
|
|
96
|
+
'background-color': '{color.bg-surface}',
|
|
97
|
+
'border-radius': '{radius.control}',
|
|
98
|
+
'box-shadow': RING_REST,
|
|
99
|
+
color: '{color.fg-default}',
|
|
100
|
+
outline: 'none',
|
|
101
|
+
...TRANSITION_FAST,
|
|
102
|
+
},
|
|
103
|
+
icon: {
|
|
104
|
+
position: 'absolute',
|
|
105
|
+
color: '{color.fg-subtle}',
|
|
106
|
+
'stroke-width': '2.25',
|
|
107
|
+
'pointer-events': 'none',
|
|
108
|
+
'transition-property': 'transform, color',
|
|
109
|
+
'transition-duration': '{motion.duration.fast}',
|
|
110
|
+
'transition-timing-function': '{motion.easing.linear}',
|
|
111
|
+
},
|
|
112
|
+
listbox: {
|
|
113
|
+
display: 'none',
|
|
114
|
+
margin: '{space.1} 0 0',
|
|
115
|
+
'padding-block': '{space.1}',
|
|
116
|
+
'overflow-x': 'hidden',
|
|
117
|
+
'overflow-y': 'auto',
|
|
118
|
+
'background-color': '{color.bg-surface}',
|
|
119
|
+
'border-radius': '{radius.panel}',
|
|
120
|
+
'box-shadow': `{shadow.lg}, ${ring('{color.border-default}')}`,
|
|
121
|
+
'list-style': 'none',
|
|
122
|
+
'z-index': '{z.dropdown}',
|
|
123
|
+
},
|
|
124
|
+
option: {
|
|
125
|
+
display: 'flex',
|
|
126
|
+
'align-items': 'center',
|
|
127
|
+
gap: '{space.2}',
|
|
128
|
+
'margin-block': '{space.px}',
|
|
129
|
+
'padding-inline': '{space.2} {space.2.5}',
|
|
130
|
+
'border-radius': '{radius.md}',
|
|
131
|
+
...typeStyle('body-md'),
|
|
132
|
+
'font-weight': '{font.weight.medium}',
|
|
133
|
+
color: '{color.fg-default}',
|
|
134
|
+
'white-space': 'nowrap',
|
|
135
|
+
overflow: 'hidden',
|
|
136
|
+
cursor: 'pointer',
|
|
137
|
+
'user-select': 'none',
|
|
138
|
+
outline: 'none',
|
|
139
|
+
...TRANSITION_FAST,
|
|
140
|
+
},
|
|
141
|
+
'option-icon': { 'flex-shrink': '0', color: '{color.fg-subtle}' },
|
|
142
|
+
'option-avatar': { 'flex-shrink': '0', 'border-radius': '{radius.full}', 'object-fit': 'cover', 'max-width': 'none', 'background-color': '{color.bg-muted}' },
|
|
143
|
+
'option-supporting': { 'font-weight': '{font.weight.regular}', color: '{color.fg-muted}', overflow: 'hidden', 'text-overflow': 'ellipsis', 'white-space': 'nowrap' },
|
|
144
|
+
'option-check': { display: 'none', 'margin-inline-start': 'auto', 'flex-shrink': '0', color: '{brand.600}', 'stroke-width': '2' },
|
|
145
|
+
empty: { display: 'block', 'padding-block': '{space.2}', 'padding-inline': '{space.2.5}', ...typeStyle('body-md'), color: '{color.fg-muted}' },
|
|
146
|
+
'@states': {
|
|
147
|
+
hover: { input: { 'box-shadow': RING_HOVER } },
|
|
148
|
+
focus: { input: { 'box-shadow': RING_FOCUS } },
|
|
149
|
+
open: { listbox: { display: 'block' }, icon: { transform: 'rotate(180deg)' } },
|
|
150
|
+
invalid: { input: { 'box-shadow': RING_INVALID } },
|
|
151
|
+
disabled: { root: { cursor: 'not-allowed' }, input: { opacity: '{opacity.disabled}', cursor: 'not-allowed' }, icon: { opacity: '{opacity.disabled}' }, 'leading-icon': { opacity: '{opacity.disabled}' } },
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
variants: {
|
|
155
|
+
size: { sm: sizeBlock('sm'), md: sizeBlock('md'), lg: sizeBlock('lg') },
|
|
156
|
+
},
|
|
157
|
+
extraCss: `
|
|
158
|
+
.cn-combobox .cn-combobox__input::placeholder { color: {color.fg-placeholder}; opacity: 1; }
|
|
159
|
+
.cn-combobox[data-invalid]:focus-within .cn-combobox__input, .cn-combobox:has([aria-invalid="true"]):focus-within .cn-combobox__input { box-shadow: ${RING_INVALID_FOCUS}; }
|
|
160
|
+
.cn-combobox .cn-combobox__leading-icon + .cn-combobox__input { padding-inline-start: calc({space.3} + {size.icon.md} + {space.2}); }
|
|
161
|
+
.cn-combobox[data-size="sm"] .cn-combobox__leading-icon + .cn-combobox__input { padding-inline-start: calc({space.3} + {size.icon.sm} + {space.2}); }
|
|
162
|
+
.cn-combobox[data-size="lg"] .cn-combobox__leading-icon + .cn-combobox__input { padding-inline-start: calc({space.3.5} + {size.icon.md} + {space.2}); }
|
|
163
|
+
.cn-combobox .cn-combobox__option[data-highlighted], .cn-combobox .cn-combobox__option:hover, .cn-combobox .cn-combobox__option[aria-selected="true"] { background-color: {color.bg-subtle}; }
|
|
164
|
+
.cn-combobox .cn-combobox__option[aria-selected="true"] .cn-combobox__option-check { display: block; }
|
|
165
|
+
.cn-combobox .cn-combobox__option[aria-disabled="true"] { opacity: {opacity.disabled}; cursor: not-allowed; background-color: transparent; }
|
|
166
|
+
.cn-combobox .cn-combobox__option:focus-visible { box-shadow: inset 0 0 0 2px {brand.500}; }`,
|
|
167
|
+
examples: [
|
|
168
|
+
ex('Closed (default)', box({ root: 'data-size="md" data-state="closed" style="max-width:320px"', input: 'aria-expanded="false" placeholder="Search people…" aria-label="Assignee"', id: 'cb-assignee' }), 'The Input ring, radius 8 and shadow-xs; a 16px gray-500 chevron 12px from the edge.'),
|
|
169
|
+
ex('Open with options', box({
|
|
170
|
+
root: 'data-size="md" data-state="open" style="max-width:320px"',
|
|
171
|
+
input: 'aria-expanded="true" value="a" aria-label="Assignee" aria-activedescendant="cb-owner-daniel"',
|
|
172
|
+
id: 'cb-owner',
|
|
173
|
+
body: PEOPLE.map(([id, name, team], i) => option(`cb-owner-${id}`, name, i === 0 ? 'aria-selected="true"' : i === 1 ? 'aria-selected="false" data-highlighted' : 'aria-selected="false"', avatar, team)).join('') + option('cb-owner-aisha', 'Aisha Khan', 'aria-selected="false" aria-disabled="true"', avatar, 'On leave'),
|
|
174
|
+
}), 'Maya is the current value (gray-50 fill and brand check), Daniel is highlighted by the keyboard, Aisha is disabled. Rows are 40px with 24px avatars and muted supporting text.'),
|
|
175
|
+
ex('With leading icon and icons in rows', box({
|
|
176
|
+
root: 'data-size="md" data-state="open" style="max-width:320px"',
|
|
177
|
+
input: 'aria-expanded="true" value="" placeholder="Choose a view" aria-label="View"',
|
|
178
|
+
id: 'cb-view',
|
|
179
|
+
leading: leading('search'),
|
|
180
|
+
body: option('cb-view-inbox', 'Inbox', 'aria-selected="true"', optIcon('inbox')) + option('cb-view-cal', 'Calendar', 'aria-selected="false"', optIcon('calendar')) + option('cb-view-people', 'People', 'aria-selected="false"', optIcon('user')),
|
|
181
|
+
})),
|
|
182
|
+
ex('Open, no results', box({
|
|
183
|
+
root: 'data-size="md" data-state="open" style="max-width:320px"',
|
|
184
|
+
input: 'aria-expanded="true" value="Zorbo" aria-label="Workspace"',
|
|
185
|
+
id: 'cb-workspace',
|
|
186
|
+
body: '<li class="cn-combobox__empty" role="presentation">No results for “Zorbo”</li>',
|
|
187
|
+
})),
|
|
188
|
+
ex('Sizes', `<div style="display:flex;flex-direction:column;gap:var(--cn-space-4)">${box({ root: 'data-size="sm" data-state="open" style="max-width:280px"', input: 'aria-expanded="true" value="" placeholder="Small · 36px" aria-label="Small"', id: 'cb-sm', body: option('cb-sm-1', 'Sofia Almeida', 'aria-selected="true"') + option('cb-sm-2', 'Lucas Ferreira', 'aria-selected="false"') })}${box({ root: 'data-size="lg" data-state="open" style="max-width:360px"', input: 'aria-expanded="true" value="" placeholder="Large · 44px" aria-label="Large"', id: 'cb-lg', body: option('cb-lg-1', 'Noah Berg', 'aria-selected="true"') + option('cb-lg-2', 'Aisha Khan', 'aria-selected="false"') })}</div>`, 'Field and rows share the height: 36 / 40 / 44px. Text is 16px on lg; the chevron and check grow to 20px.'),
|
|
189
|
+
ex('Invalid', box({ root: 'data-size="md" data-state="closed" style="max-width:320px"', input: 'aria-expanded="false" value="Lumen Labs" aria-invalid="true" aria-label="Workspace"', id: 'cb-invalid' }), 'Pair with a Field error such as "Pick a workspace from the list".'),
|
|
190
|
+
ex('Disabled', box({ root: 'data-size="md" data-state="closed" style="max-width:320px"', input: 'aria-expanded="false" value="Europe (Frankfurt)" disabled aria-label="Data region"', id: 'cb-region' })),
|
|
191
|
+
],
|
|
192
|
+
rules: [
|
|
193
|
+
'Use a Combobox when the list is long (more than 15), user-generated, needs search, or when rows need an avatar, icon or supporting text; otherwise a Select. Typing filters the list; the value is always one of the options.',
|
|
194
|
+
'The listbox renders in the flow 4px under the input inside the root. Toggle data-state="open|closed" on the root; the app moves it to a popper layer only when it would be clipped.',
|
|
195
|
+
'Rows are 36 / 40 / 44px (same as the field) with radius 6 and one line of text. Show at most 5–7 before scrolling (224 / 256 / 320px).',
|
|
196
|
+
'Exactly one option is highlighted at a time (data-highlighted) and follows keyboard and pointer; the selected one has aria-selected="true", the gray-50 fill and the brand check on the right.',
|
|
197
|
+
'When nothing matches, render the empty row ("No results for “…”"), never an empty panel and never close the list.',
|
|
198
|
+
'The chevron rotates when open. An optional clear (×) button goes before the chevron and appears only when there is a value.',
|
|
199
|
+
'Placeholder is an instruction with an ellipsis ("Search people…"); the label lives in the Field.',
|
|
200
|
+
'Never use a Combobox for multiple picks; that is a TagInput.',
|
|
201
|
+
],
|
|
202
|
+
a11y: [
|
|
203
|
+
'The input has role="combobox", aria-expanded, aria-controls="<listbox id>", aria-autocomplete="list" and aria-activedescendant pointing at the highlighted option id.',
|
|
204
|
+
'The list has role="listbox"; each option has role="option", a unique id and aria-selected. Disabled options use aria-disabled="true" and are skipped by the keyboard.',
|
|
205
|
+
'Keyboard: Down/Up move the highlight, Enter selects, Escape closes and restores the previous value, Home/End jump. Focus stays on the input the whole time.',
|
|
206
|
+
'Announce the result count in a visually hidden live region ("4 results") whenever the filtered list changes.',
|
|
207
|
+
'When invalid, aria-invalid="true" on the input and aria-describedby pointing at the Field error id.',
|
|
208
|
+
],
|
|
209
|
+
related: ['select', 'input', 'field', 'menu', 'command-palette'],
|
|
210
|
+
};
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { STATE, ex, ICON, typeStyle } from "./_shared.mjs";
|
|
2
|
+
// the reference command menu: a 640px surface (radius 16, shadow-xl), a 56px search row, groups
|
|
3
|
+
// with 12px medium gray-500 labels, 40px options (14px medium, 16px gray-400 icon, radius 6,
|
|
4
|
+
// gray-50 when highlighted) or 64px stacked user rows with a 40px avatar, kbd chips for
|
|
5
|
+
// shortcuts, and a 52px footer legend on a 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 ring = (color) => `inset 0 0 0 1px ${color}`;
|
|
13
|
+
const icon = (name) => ICON[name].replace('cn-icon', 'cn-command-palette__item-icon');
|
|
14
|
+
const SEARCH = ICON.search.replace('cn-icon', 'cn-command-palette__icon');
|
|
15
|
+
const key = (k) => `<kbd class="cn-command-palette__key">${k}</kbd>`;
|
|
16
|
+
const item = (id, label, i, meta = '', shortcut = '', attrs = '') => `<div role="option" id="${id}" class="cn-command-palette__item" aria-selected="${attrs.includes('data-highlighted') ? 'true' : 'false'}"${attrs}>${icon(i)}<span class="cn-command-palette__item-label">${label}</span>${meta ? `<span class="cn-command-palette__item-meta">${meta}</span>` : ''}${shortcut ? shortcut.split(' ').map((s) => `<kbd class="cn-command-palette__item-shortcut">${s}</kbd>`).join('') : ''}</div>`;
|
|
17
|
+
const person = (id, initials, name, handle, status, attrs = '', size = 'md') => `<div role="option" id="${id}" class="cn-command-palette__item" aria-selected="${attrs.includes('data-highlighted') ? 'true' : 'false'}"${attrs}><span class="cn-command-palette__item-avatar cn-avatar" data-size="${size}" data-shape="circle" data-tone="neutral" data-status="${status}" aria-hidden="true"><span class="cn-avatar__fallback">${initials}</span><span class="cn-avatar__status"></span></span><span class="cn-command-palette__item-text"><span class="cn-command-palette__item-label">${name}</span><span class="cn-command-palette__item-meta">${handle}</span></span></div>`;
|
|
18
|
+
const groupLabel = (id, text) => `<div class="cn-command-palette__group-label" id="${id}" role="presentation">${text}</div>`;
|
|
19
|
+
const group = (id, text, items) => `<div class="cn-command-palette__group" role="group" aria-labelledby="${id}">${groupLabel(id, text)}${items}</div>`;
|
|
20
|
+
const FOOTER = `<footer class="cn-command-palette__footer"><span>${key('↑')}${key('↓')}to navigate</span><span>${key('↵')}to select</span><span>${key('esc')}to close</span></footer>`;
|
|
21
|
+
const search = (value, active, listId = 'cp-list') => `<div class="cn-command-palette__search">${SEARCH}<input class="cn-command-palette__input" type="text" role="combobox" aria-expanded="true" aria-controls="${listId}" aria-activedescendant="${active}" aria-autocomplete="list" aria-label="Search people, pages and commands" placeholder="Search…" value="${value}" autocomplete="off" spellcheck="false">${key('⌘K')}</div>`;
|
|
22
|
+
const panel = (variant, inner) => `<div class="cn-command-palette" data-variant="${variant}" role="dialog" aria-modal="true" aria-label="Command menu" data-state="open">${inner}</div>`;
|
|
23
|
+
export const commandPalette = {
|
|
24
|
+
name: 'CommandPalette',
|
|
25
|
+
slug: 'command-palette',
|
|
26
|
+
category: 'overlays',
|
|
27
|
+
description: 'The ⌘K menu of the reference: a 640px raised surface with a 56px search row on top, grouped results (40px options, or 64px stacked people rows with avatars) in the middle and a 52px key legend at the bottom. Styles the panel only; the fixed backdrop is a separate layer.',
|
|
28
|
+
usage: 'Use as the one global launcher of the app: jump to any record, person or page and run any command by typing. Opened with ⌘K / Ctrl+K from anywhere and from the search field in the Topbar or Sidebar. Not a replacement for a page\'s search Input, not a Menu (no typing) and not a Select.',
|
|
29
|
+
anatomy: [
|
|
30
|
+
{ part: 'root', element: 'div', description: 'The panel: role="dialog" aria-modal, 640px wide (max 100%), bg-surface-raised, hairline ring, radius 16, shadow-xl. data-state="open|closed".' },
|
|
31
|
+
{ part: 'search', element: 'div', description: 'The 56px search row with a hairline below: 20px search icon, bare input, ⌘K key chip.' },
|
|
32
|
+
{ part: 'icon', element: 'svg', description: '20px search icon, fg-subtle, leading the input. Decorative.' },
|
|
33
|
+
{ part: 'input', element: 'input', description: 'The bare text field, 16px, no border. role="combobox" controlling the list, aria-activedescendant pointing at the highlighted option.' },
|
|
34
|
+
{ part: 'list', element: 'div', description: 'role="listbox": scrolling results (max 360px), 8px padding, groups of options.' },
|
|
35
|
+
{ part: 'group', element: 'div', description: 'role="group" with aria-labelledby its label.', optional: true },
|
|
36
|
+
{ part: 'group-label', element: 'div', description: 'Group heading in 12px medium fg-subtle, padding 8 × 8 (4 below): "Recent", "People", "Commands".', optional: true },
|
|
37
|
+
{ part: 'item', element: 'div', description: 'role="option": a 40px row (64px in stacked), radius 6, padding 8 × 8, 14px medium; gray-50 when highlighted. Holds icon or avatar + label + optional meta + optional shortcut.' },
|
|
38
|
+
{ part: 'item-icon', element: 'svg', description: '16px leading icon, fg-subtle: what kind of thing this is (person, page, command).', optional: true },
|
|
39
|
+
{ part: 'item-avatar', element: 'span', description: 'A 32px Avatar (40px in stacked) in the leading slot of people rows, with its status dot.', optional: true },
|
|
40
|
+
{ part: 'item-text', element: 'span', description: 'Column of label + meta for stacked rows.', optional: true },
|
|
41
|
+
{ part: 'item-label', element: 'span', description: 'The name of the thing or the command, 14px medium fg-default. Fills the row and truncates.' },
|
|
42
|
+
{ part: 'item-meta', element: 'span', description: '14px fg-muted context after (or under) the label: "@maya", "Settings".', optional: true },
|
|
43
|
+
{ part: 'item-shortcut', element: 'kbd', description: 'Key chip (12px medium fg-subtle, radius 4, gray-200 ring, padding 1 × 4) showing the global shortcut of a command; one chip per key.', optional: true },
|
|
44
|
+
{ part: 'empty', element: 'div', description: 'Centered muted message shown instead of the list when nothing matches; echoes the query.', optional: true },
|
|
45
|
+
{ part: 'footer', element: 'footer', description: '52px legend on a hairline: ↑↓ to navigate · ↵ to select · esc to close.' },
|
|
46
|
+
{ part: 'key', element: 'kbd', description: 'One key chip in the search row or footer: 12px medium fg-subtle on bg-surface with a gray-200 ring.' },
|
|
47
|
+
],
|
|
48
|
+
props: {
|
|
49
|
+
variant: {
|
|
50
|
+
values: ['default', 'stacked'],
|
|
51
|
+
default: 'default',
|
|
52
|
+
description: 'default = 40px rows with the meta after the label (commands, pages, quick jumps). stacked = 64px rows with a 40px avatar and the meta under the label (people and records with two lines).',
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
states: {
|
|
56
|
+
open: STATE.open(),
|
|
57
|
+
closed: { selector: '[data-state="closed"]', description: 'Hidden. The panel is display: none; the backdrop layer is removed by the app.', markup: 'data-state="closed"' },
|
|
58
|
+
highlighted: { selector: ' .cn-command-palette__item[data-highlighted]', description: 'The option the arrow keys are on (on the ITEM): gray-50 fill, darker icon. Exactly one, the first result by default.', markup: 'data-highlighted on the option' },
|
|
59
|
+
selected: { selector: ' .cn-command-palette__item[aria-selected="true"]', description: 'The option referenced by aria-activedescendant; same look as highlighted so pointer and keyboard agree.', markup: 'aria-selected="true" on the option' },
|
|
60
|
+
disabled: { selector: ' .cn-command-palette__item[aria-disabled="true"]', description: 'A command not available in this context: 50% opacity, no hover; still listed so it can be discovered.', markup: 'aria-disabled="true" on the option' },
|
|
61
|
+
},
|
|
62
|
+
base: {
|
|
63
|
+
root: {
|
|
64
|
+
display: 'flex',
|
|
65
|
+
'flex-direction': 'column',
|
|
66
|
+
width: '{size.container.sm}',
|
|
67
|
+
'max-width': '100%',
|
|
68
|
+
'background-color': '{color.bg-surface-raised}',
|
|
69
|
+
'border-radius': '{radius.overlay}',
|
|
70
|
+
'box-shadow': `${ring('{color.border-default}')}, {shadow.xl}`,
|
|
71
|
+
overflow: 'hidden',
|
|
72
|
+
color: '{color.fg-default}',
|
|
73
|
+
},
|
|
74
|
+
search: {
|
|
75
|
+
display: 'flex',
|
|
76
|
+
'align-items': 'center',
|
|
77
|
+
gap: '{space.3}',
|
|
78
|
+
height: '{space.14}',
|
|
79
|
+
'padding-inline': '{space.5}',
|
|
80
|
+
'border-bottom': HAIRLINE,
|
|
81
|
+
'flex-shrink': '0',
|
|
82
|
+
},
|
|
83
|
+
icon: { width: '{size.icon.md}', height: '{size.icon.md}', 'flex-shrink': '0', color: '{color.fg-subtle}' },
|
|
84
|
+
input: {
|
|
85
|
+
flex: '1 1 auto',
|
|
86
|
+
'min-width': '0',
|
|
87
|
+
height: '100%',
|
|
88
|
+
border: '0',
|
|
89
|
+
outline: 'none',
|
|
90
|
+
background: 'transparent',
|
|
91
|
+
padding: '0',
|
|
92
|
+
...typeStyle('body-lg'),
|
|
93
|
+
color: '{color.fg-default}',
|
|
94
|
+
},
|
|
95
|
+
list: {
|
|
96
|
+
'max-height': 'calc({space.64} + {space.24} + {space.2})',
|
|
97
|
+
'overflow-y': 'auto',
|
|
98
|
+
'overscroll-behavior': 'contain',
|
|
99
|
+
padding: '{space.2}',
|
|
100
|
+
},
|
|
101
|
+
group: { display: 'flex', 'flex-direction': 'column', gap: '{space.0.5}' },
|
|
102
|
+
'group-label': {
|
|
103
|
+
...typeStyle('label-xs'),
|
|
104
|
+
color: '{color.fg-subtle}',
|
|
105
|
+
padding: '{space.2} {space.2} {space.1}',
|
|
106
|
+
'user-select': 'none',
|
|
107
|
+
},
|
|
108
|
+
item: {
|
|
109
|
+
display: 'flex',
|
|
110
|
+
'align-items': 'center',
|
|
111
|
+
gap: '{space.2}',
|
|
112
|
+
height: '{space.10}',
|
|
113
|
+
padding: '{space.2}',
|
|
114
|
+
'border-radius': '{radius.md}',
|
|
115
|
+
...typeStyle('label-sm'),
|
|
116
|
+
color: '{color.fg-default}',
|
|
117
|
+
cursor: 'pointer',
|
|
118
|
+
'user-select': 'none',
|
|
119
|
+
'white-space': 'nowrap',
|
|
120
|
+
outline: 'none',
|
|
121
|
+
...TRANSITION_FAST,
|
|
122
|
+
},
|
|
123
|
+
'item-icon': { width: '{size.icon.sm}', height: '{size.icon.sm}', 'flex-shrink': '0', color: '{color.fg-subtle}', transition: 'inherit' },
|
|
124
|
+
'item-avatar': { 'flex-shrink': '0' },
|
|
125
|
+
'item-text': { display: 'flex', 'flex-direction': 'column', flex: '1 1 auto', 'min-width': '0' },
|
|
126
|
+
'item-label': { flex: '1 1 auto', 'min-width': '0', overflow: 'hidden', 'text-overflow': 'ellipsis', ...typeStyle('label-sm'), color: '{color.fg-default}' },
|
|
127
|
+
'item-meta': { ...typeStyle('body-md'), color: '{color.fg-muted}', 'flex-shrink': '0', overflow: 'hidden', 'text-overflow': 'ellipsis' },
|
|
128
|
+
'item-shortcut': {
|
|
129
|
+
display: 'inline-flex',
|
|
130
|
+
'align-items': 'center',
|
|
131
|
+
'justify-content': 'center',
|
|
132
|
+
'min-width': '{space.5}',
|
|
133
|
+
padding: '{space.px} {space.1}',
|
|
134
|
+
'border-radius': '{radius.sm}',
|
|
135
|
+
'background-color': '{color.bg-surface}',
|
|
136
|
+
'box-shadow': ring('{color.border-default}'),
|
|
137
|
+
...typeStyle('label-xs'),
|
|
138
|
+
color: '{color.fg-subtle}',
|
|
139
|
+
'flex-shrink': '0',
|
|
140
|
+
},
|
|
141
|
+
empty: {
|
|
142
|
+
padding: '{space.10} {space.6}',
|
|
143
|
+
'text-align': 'center',
|
|
144
|
+
...typeStyle('body-md'),
|
|
145
|
+
color: '{color.fg-muted}',
|
|
146
|
+
},
|
|
147
|
+
footer: {
|
|
148
|
+
display: 'flex',
|
|
149
|
+
'align-items': 'center',
|
|
150
|
+
gap: '{space.4}',
|
|
151
|
+
height: 'calc({space.12} + {space.1})',
|
|
152
|
+
'padding-inline': '{space.5}',
|
|
153
|
+
'background-color': '{color.bg-surface}',
|
|
154
|
+
'border-top': HAIRLINE,
|
|
155
|
+
...typeStyle('body-sm'),
|
|
156
|
+
color: '{color.fg-muted}',
|
|
157
|
+
'flex-shrink': '0',
|
|
158
|
+
'user-select': 'none',
|
|
159
|
+
},
|
|
160
|
+
key: {
|
|
161
|
+
display: 'inline-flex',
|
|
162
|
+
'align-items': 'center',
|
|
163
|
+
'justify-content': 'center',
|
|
164
|
+
'min-width': '{space.5}',
|
|
165
|
+
padding: '{space.px} {space.1}',
|
|
166
|
+
'border-radius': '{radius.sm}',
|
|
167
|
+
'background-color': '{color.bg-surface}',
|
|
168
|
+
'box-shadow': ring('{color.border-default}'),
|
|
169
|
+
...typeStyle('label-xs'),
|
|
170
|
+
color: '{color.fg-subtle}',
|
|
171
|
+
'margin-inline-end': '{space.1}',
|
|
172
|
+
'vertical-align': 'middle',
|
|
173
|
+
'flex-shrink': '0',
|
|
174
|
+
},
|
|
175
|
+
'@states': {
|
|
176
|
+
open: { root: { display: 'flex' } },
|
|
177
|
+
closed: { root: { display: 'none' } },
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
variants: {
|
|
181
|
+
variant: {
|
|
182
|
+
default: { root: {} },
|
|
183
|
+
stacked: { item: { height: '{space.16}', gap: '{space.3}', padding: '{space.3} {space.2}' } },
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
extraCss: `
|
|
187
|
+
.cn-command-palette__input::placeholder { color: {color.fg-placeholder}; opacity: 1; }
|
|
188
|
+
.cn-command-palette__search .cn-command-palette__key { margin-inline-end: 0; }
|
|
189
|
+
.cn-command-palette__item[data-highlighted], .cn-command-palette__item[aria-selected="true"], .cn-command-palette__item:hover:not([aria-disabled="true"]) { background-color: {color.bg-subtle}; }
|
|
190
|
+
.cn-command-palette__item[data-highlighted] .cn-command-palette__item-icon, .cn-command-palette__item[aria-selected="true"] .cn-command-palette__item-icon { color: {color.fg-muted}; }
|
|
191
|
+
.cn-command-palette__item:focus-visible { box-shadow: inset 0 0 0 2px {brand.500}; }
|
|
192
|
+
.cn-command-palette__item[aria-disabled="true"] { opacity: {opacity.disabled}; cursor: not-allowed; }
|
|
193
|
+
.cn-command-palette__item > .cn-command-palette__item-shortcut:first-of-type { margin-inline-start: auto; }
|
|
194
|
+
.cn-command-palette__item-text .cn-command-palette__item-meta { flex-shrink: 1; }
|
|
195
|
+
.cn-command-palette__footer > span { display: inline-flex; align-items: center; gap: {space.0.5}; }
|
|
196
|
+
.cn-command-palette__footer > span + span { margin-inline-start: {space.2}; }
|
|
197
|
+
.cn-command-palette-backdrop { position: fixed; inset: 0; z-index: {z.overlay}; display: flex; align-items: flex-start; justify-content: center; padding: 15vh {space.4} {space.4}; background-color: {color.bg-overlay}; backdrop-filter: blur(6px); }`,
|
|
198
|
+
examples: [
|
|
199
|
+
ex('Actions', panel('default', `${search('', 'cp-a1')}<div class="cn-command-palette__list" role="listbox" id="cp-list" aria-label="Suggestions">${group('cp-g-recent', 'Recent', item('cp-a1', 'Lumen website redesign', 'copy', 'Project', '', ' data-highlighted') + item('cp-a2', 'Q3 invoices', 'inbox', 'Folder'))}${group('cp-g-goto', 'Go to', item('cp-a3', 'Dashboard', 'home', '', 'G H') + item('cp-a4', 'Calendar', 'calendar', '', 'G C') + item('cp-a5', 'Settings', 'settings', '', 'G S'))}${group('cp-g-cmd', 'Commands', item('cp-a6', 'New project', 'plus', '', '⌘ N') + item('cp-a7', 'Invite teammate', 'user', '', '⌘ I') + item('cp-a8', 'Import CSV', 'inbox', 'Business plan', '', ' aria-disabled="true"'))}</div>${FOOTER}`), 'Before typing: recent things, navigation with G-prefixed shortcuts, then commands. The first option is highlighted; a disabled command stays listed.'),
|
|
200
|
+
ex('People (stacked)', panel('stacked', `${search('so', 'cp-p1', 'cp-list-p')}<div class="cn-command-palette__list" role="listbox" id="cp-list-p" aria-label="People">${group('cp-g-people', 'People', person('cp-p1', 'SA', 'Sofia Almeida', '@sofia · Design', 'online', ' data-highlighted') + person('cp-p2', 'TS', 'Tomás Silva', '@tomas · Engineering', 'offline') + person('cp-p3', 'NB', 'Noah Berg', '@noah · Product design', 'online'))}</div>${FOOTER}`), 'data-variant="stacked": 64px rows with a 40px Avatar, the name over the handle.'),
|
|
201
|
+
ex('Results for a query', panel('default', `${search('inv', 'cp-r1', 'cp-list-r')}<div class="cn-command-palette__list" role="listbox" id="cp-list-r" aria-label="Results">${group('cp-g-res', 'Results', item('cp-r1', 'INV-0042 · Aisha Khan', 'inbox', 'Invoice', '', ' data-highlighted') + item('cp-r2', 'Invoices', 'copy', 'Page'))}${group('cp-g-act', 'Actions', item('cp-r3', 'Create invoice “inv”', 'plus', '', '⌘ N') + item('cp-r4', 'Search files for “inv”', 'search'))}</div>${FOOTER}`), 'Matching records first, then actions built from the query.'),
|
|
202
|
+
ex('Empty (nothing matches)', panel('default', `${search('zzzq', '', 'cp-list-e')}<div class="cn-command-palette__empty" role="status">No results for “zzzq”. Try a person, a page or a command.</div>${FOOTER}`), 'The query is echoed back; the footer stays.'),
|
|
203
|
+
],
|
|
204
|
+
rules: [
|
|
205
|
+
'One palette per app, opened with ⌘K / Ctrl+K from anywhere and from the search field in the Topbar or Sidebar. It is the only global launcher.',
|
|
206
|
+
'The panel is 640px wide with a 56px search row; options are 40px (stacked 64px) with 14px medium labels, 16px icons or 32 / 40px avatars, radius 6.',
|
|
207
|
+
'The placeholder is short ("Search…") and the search row carries the ⌘K chip so people learn the shortcut.',
|
|
208
|
+
'Group order is fixed: matching records first, then people, then navigation ("Go to"), then commands. Max 4 groups visible, 5–7 options each; the rest is reachable by typing.',
|
|
209
|
+
'The first option is always highlighted so ↵ does something immediately. Highlight follows the pointer and the arrow keys, never both at once.',
|
|
210
|
+
'Always render an empty state that echoes the query and suggests what to type; never an empty list.',
|
|
211
|
+
'The footer legend is always visible; shortcuts on options appear only when they work globally, one key chip per key.',
|
|
212
|
+
'Labels are the name of the thing (record title, page title, command verb); meta says what and where it is. No descriptions longer than a few words.',
|
|
213
|
+
'Render inside .cn-command-palette-backdrop: fixed, overlay z-index, 6px blur, panel top-aligned at 15vh; never vertically centered.',
|
|
214
|
+
'The panel is the component; open/close, focus trap, filtering and scroll lock live in the app layer.',
|
|
215
|
+
],
|
|
216
|
+
a11y: [
|
|
217
|
+
'Root is role="dialog" aria-modal="true" with an aria-label; opening moves focus into the input, closing returns it to where the user was.',
|
|
218
|
+
'The input is role="combobox" with aria-expanded, aria-controls (the listbox), aria-autocomplete="list" and aria-activedescendant pointing at the highlighted option.',
|
|
219
|
+
'The list is role="listbox"; groups are role="group" aria-labelledby their label; options are role="option" with aria-selected on the highlighted one.',
|
|
220
|
+
'Keyboard: ↑/↓ move the highlight (wrapping across groups), ↵ runs the option, Esc closes, typing filters; focus stays in the input the whole time.',
|
|
221
|
+
'Announce result counts with an aria-live="polite" region ("4 results") and use role="status" on the empty state.',
|
|
222
|
+
'Avatars in people rows are decorative (aria-hidden); the label names the person and the meta carries the status in words when it matters.',
|
|
223
|
+
'Key chips are read naturally ("↑ ↓ to navigate"); do not rely on glyphs alone for commands with shortcuts.',
|
|
224
|
+
],
|
|
225
|
+
related: ['menu', 'input', 'dialog', 'topbar', 'kbd', 'avatar'],
|
|
226
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { ex, ICON, typeStyle } from "./_shared.mjs";
|
|
2
|
+
// Content divider: the richer sibling of Divider. A hairline that carries
|
|
3
|
+
// something in the middle (or at the start): body-sm text, an outline button, a
|
|
4
|
+
// 24px icon or a pill chip. Used between sections of long content, in threads
|
|
5
|
+
// ("Earlier this week"), on auth forms ("Or continue with") and as a load-more.
|
|
6
|
+
const btn = (label, leading = '') => `<button type="button" class="cn-button" data-variant="outline" data-size="sm">${leading}<span class="cn-button__label">${label}</span></button>`;
|
|
7
|
+
const LINE = '<span class="cn-content-divider__line" aria-hidden="true"></span>';
|
|
8
|
+
const para = (text) => `<p class="cn-text-body-sm" style="color:var(--cn-color-fg-muted);margin:0">${text}</p>`;
|
|
9
|
+
const divider = (variant, spacing, inner, attrs = '') => `<div class="cn-content-divider" data-variant="${variant}" data-spacing="${spacing}"${attrs}>${inner}</div>`;
|
|
10
|
+
const wrap = (inner) => `<div style="width:100%;max-width:560px">${inner}</div>`;
|
|
11
|
+
export const contentDivider = {
|
|
12
|
+
name: 'ContentDivider',
|
|
13
|
+
slug: 'content-divider',
|
|
14
|
+
category: 'layout',
|
|
15
|
+
description: 'A hairline that carries content: body-sm text centered or at the start, an outline sm Button, a 24px icon, or a pill chip in the middle of the line. The richer sibling of Divider for long content, threads, auth forms and load-more rows.',
|
|
16
|
+
usage: 'Use where a plain rule is not enough: "Or continue with" on sign-in, "Earlier this week" in a feed, "Show 12 more comments" in a thread, "3 new messages" in a chat. For a plain rule or a kicker-voice label use Divider; for a section heading use SectionHeader.',
|
|
17
|
+
anatomy: [
|
|
18
|
+
{ part: 'root', element: 'div', description: 'Flex row, centered, 16px gaps; role="separator" when it only carries text, no role when it holds a button.' },
|
|
19
|
+
{ part: 'line', element: 'span', description: 'The 1px border-default rule that fills the free space (flex 1). aria-hidden. One on each side, or one after the label in text-left.' },
|
|
20
|
+
{ part: 'label', element: 'span', description: 'body-sm muted text on the line ("Or continue with", "Earlier this week").', optional: true },
|
|
21
|
+
{ part: 'button', element: 'div', description: 'Wrapper for an outline sm Button in the middle ("Show 12 more comments").', optional: true },
|
|
22
|
+
{ part: 'icon', element: 'span', description: '24px icon in fg-subtle on the line, for a purely visual break.', optional: true },
|
|
23
|
+
{ part: 'pill', element: 'span', description: 'label-sm chip in bg-surface with a hairline, radius full, 28px tall ("3 new messages").', optional: true },
|
|
24
|
+
],
|
|
25
|
+
props: {
|
|
26
|
+
variant: {
|
|
27
|
+
values: ['plain', 'text', 'text-left', 'button', 'icon', 'pill'],
|
|
28
|
+
default: 'text',
|
|
29
|
+
description: 'plain = the line only (same as Divider, kept here for consistency inside content that mixes variants). text = centered body-sm label between two lines. text-left = the label at the start followed by one line (group headings in feeds). button = an outline sm Button between two lines (load more, show earlier). icon = a 24px icon between two lines (a decorative break in an article). pill = a chip between two lines (unread markers, "new since your last visit").',
|
|
30
|
+
},
|
|
31
|
+
spacing: {
|
|
32
|
+
values: ['sm', 'md', 'lg'],
|
|
33
|
+
default: 'md',
|
|
34
|
+
description: 'Vertical margin: sm 16px inside cards and threads, md 24px between blocks of content, lg 40px between sections of an article.',
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
states: {},
|
|
38
|
+
base: {
|
|
39
|
+
root: { display: 'flex', 'align-items': 'center', gap: '{space.4}', width: '100%', 'min-width': '0', color: '{color.fg-subtle}' },
|
|
40
|
+
line: { flex: '1 1 auto', 'min-width': '{space.4}', height: '{border.width.thin}', 'background-color': '{color.border-default}' },
|
|
41
|
+
label: { ...typeStyle('body-sm'), color: '{color.fg-muted}', 'white-space': 'nowrap', 'text-align': 'center' },
|
|
42
|
+
button: { display: 'inline-flex', 'flex-shrink': '0' },
|
|
43
|
+
icon: { display: 'inline-flex', 'align-items': 'center', 'justify-content': 'center', 'flex-shrink': '0', width: '{size.icon.xl}', height: '{size.icon.xl}', color: '{color.fg-subtle}' },
|
|
44
|
+
pill: {
|
|
45
|
+
display: 'inline-flex',
|
|
46
|
+
'align-items': 'center',
|
|
47
|
+
gap: '{space.1.5}',
|
|
48
|
+
'flex-shrink': '0',
|
|
49
|
+
height: '{space.7}',
|
|
50
|
+
'padding-inline': '{space.3}',
|
|
51
|
+
'border-radius': '{radius.full}',
|
|
52
|
+
'background-color': '{color.bg-surface}',
|
|
53
|
+
border: '{border.width.thin} solid {color.border-default}',
|
|
54
|
+
'box-shadow': '{shadow.xs}',
|
|
55
|
+
...typeStyle('label-sm'),
|
|
56
|
+
color: '{color.fg-muted}',
|
|
57
|
+
'white-space': 'nowrap',
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
variants: {
|
|
61
|
+
variant: {
|
|
62
|
+
plain: { root: {} },
|
|
63
|
+
text: { root: {} },
|
|
64
|
+
'text-left': { label: { 'text-align': 'start' } },
|
|
65
|
+
button: { root: {} },
|
|
66
|
+
icon: { root: {} },
|
|
67
|
+
pill: { root: {} },
|
|
68
|
+
},
|
|
69
|
+
spacing: {
|
|
70
|
+
sm: { root: { 'margin-block': '{space.4}' } },
|
|
71
|
+
md: { root: { 'margin-block': '{space.6}' } },
|
|
72
|
+
lg: { root: { 'margin-block': '{space.10}' } },
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
extraCss: `
|
|
76
|
+
.cn-content-divider__icon .cn-icon { width: 100%; height: 100%; }
|
|
77
|
+
.cn-content-divider__pill .cn-icon { width: {size.icon.xs}; height: {size.icon.xs}; }
|
|
78
|
+
.cn-content-divider[data-variant="text-left"] .cn-content-divider__line:first-child { display: none; }`,
|
|
79
|
+
examples: [
|
|
80
|
+
ex('Plain', wrap(`${para('Maya approved the launch plan and asked for a final read of the pricing page.')}${divider('plain', 'md', LINE, ' role="separator"')}${para('Daniel uploaded the updated timeline.')}`)),
|
|
81
|
+
ex('Text (centered)', wrap(`<button type="button" class="cn-button" data-variant="primary" data-size="md" style="width:100%"><span class="cn-button__label">Sign in</span></button>${divider('text', 'md', `${LINE}<span class="cn-content-divider__label">Or continue with</span>${LINE}`, ' role="separator"')}<div style="display:flex;gap:var(--cn-space-3)">${btn('Google').replace('data-size="sm"', 'data-size="md" style="flex:1"')}${btn('Microsoft').replace('data-size="sm"', 'data-size="md" style="flex:1"')}</div>`), 'The auth-form separator.'),
|
|
82
|
+
ex('Text at the start', wrap(`${divider('text-left', 'sm', `<span class="cn-content-divider__label">Earlier this week</span>${LINE}`, ' role="separator"')}${para('Sofia assigned the homepage redesign to Lucas · Tuesday')}`), 'Group headings in feeds and inboxes; the line starts after the label.'),
|
|
83
|
+
ex('Button (load more)', wrap(`${para('Aisha: I set up the A/B test for Monday.')}${divider('button', 'md', `${LINE}<div class="cn-content-divider__button">${btn('Show 12 more comments', ICON.chevronDown.replace('cn-icon', 'cn-button__icon'))}</div>${LINE}`)}${para('Maya: The second headline reads better.')}`), 'The button is the only interactive thing on the line; no role on the root.'),
|
|
84
|
+
ex('Icon', wrap(`${para('That was the last release of the quarter, and the first one where every workspace shipped on the same day.')}${divider('icon', 'lg', `${LINE}<span class="cn-content-divider__icon" aria-hidden="true">${ICON.spark}</span>${LINE}`, ' role="separator"')}${para('What comes next starts with the reporting workspace.')}`), 'A quiet break inside an article; lg spacing.'),
|
|
85
|
+
ex('Pill', wrap(`${para('Tomás: Sending the revised quote now.')}${divider('pill', 'sm', `${LINE}<span class="cn-content-divider__pill">3 new messages</span>${LINE}`, ' role="separator"')}${para('Elena: Got it, reviewing with finance.')}`), 'Unread marker in a chat: the chip names what is below the line.'),
|
|
86
|
+
],
|
|
87
|
+
rules: [
|
|
88
|
+
'A content divider separates two things that are both present and adds one piece of information about the break; if there is nothing to say, use Divider.',
|
|
89
|
+
'Text labels are 2–4 words in sentence case, body-sm muted: a time ("Earlier this week"), an alternative ("Or continue with"), a count ("3 new messages"). Never a heading.',
|
|
90
|
+
'One element in the middle at most: text, a button, an icon or a pill. Never a button and text together.',
|
|
91
|
+
'The button variant is for revealing more of the same content ("Show 12 more comments", "Load earlier messages"); it is an outline sm Button and the count is in its label.',
|
|
92
|
+
'Pills mark a boundary the user must notice (unread, new since last visit) and disappear once the content below is seen.',
|
|
93
|
+
'Icons are decorative breaks in long-form reading; one per article at most, always aria-hidden.',
|
|
94
|
+
'Spacing is a prop: sm in cards and threads, md between blocks, lg between article sections. Never add margins around it.',
|
|
95
|
+
'Do not put content dividers between List, Table or Timeline rows; those components draw their own rules.',
|
|
96
|
+
],
|
|
97
|
+
a11y: [
|
|
98
|
+
'When the divider only carries text or an icon, the root is role="separator" and the lines are aria-hidden; the label is read as the separator\'s name.',
|
|
99
|
+
'When it carries a Button, the root has no role: the button is a normal control in the tab order and the lines stay aria-hidden.',
|
|
100
|
+
'Labels must be meaningful on their own ("Earlier this week" is fine; a lone dash is not).',
|
|
101
|
+
'A pill that marks unread content also updates an aria-live region or the page title so the count is announced.',
|
|
102
|
+
],
|
|
103
|
+
related: ['divider', 'button', 'section-header', 'activity-feed', 'message', 'badge'],
|
|
104
|
+
};
|