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,218 @@
|
|
|
1
|
+
import { CONTROL, FOCUS_RING, RESET_BUTTON, TRANSITION_COLORS, SIZE_PROP, ex, ICON, typeStyle } from "./_shared.mjs";
|
|
2
|
+
// Combobox that keeps several values: the chosen items sit as chips inside the field box and the
|
|
3
|
+
// listbox under it shows a checkbox square on every option. The chips box draws the border and ring;
|
|
4
|
+
// the root only positions the listbox.
|
|
5
|
+
const sizeBlock = (s, chipH, chipFont) => ({
|
|
6
|
+
chips: { 'min-height': CONTROL[s].height, 'border-radius': CONTROL[s].radius, 'padding-inline-end': `calc(${CONTROL[s].px} + ${CONTROL[s].icon} + {space.1})` },
|
|
7
|
+
chip: { height: chipH, 'font-size': chipFont },
|
|
8
|
+
field: { height: chipH, 'font-size': CONTROL[s].font },
|
|
9
|
+
icon: { width: CONTROL[s].icon, height: CONTROL[s].icon, 'inset-inline-end': CONTROL[s].px, top: `calc((${CONTROL[s].height} - ${CONTROL[s].icon}) / 2)` },
|
|
10
|
+
});
|
|
11
|
+
const chevron = ICON.chevronDown.replace('cn-icon', 'cn-multi-select__icon');
|
|
12
|
+
const chip = (label, attrs = '') => `<span class="cn-multi-select__chip">${label}<button type="button" class="cn-multi-select__chip-remove" aria-label="Remove ${label}"${attrs}>${ICON.x}</button></span>`;
|
|
13
|
+
const option = (id, label, attrs = '') => `<li class="cn-multi-select__option" role="option" id="${id}" ${attrs}><span class="cn-multi-select__option-check" aria-hidden="true">${ICON.check}</span>${label}</li>`;
|
|
14
|
+
const box = (o) => `<div class="cn-multi-select" ${o.root}><div class="cn-multi-select__chips">${o.chips.map((c) => chip(c, o.removeAttrs)).join('')}<input class="cn-multi-select__field" type="text" role="combobox" aria-autocomplete="list" aria-controls="${o.id}-list" ${o.input}>${chevron}</div><ul class="cn-multi-select__listbox" role="listbox" aria-multiselectable="true" id="${o.id}-list">${o.body ?? ''}</ul></div>`;
|
|
15
|
+
const PEOPLE = option('ms-owner-maya', 'Maya Chen', 'aria-selected="true"') + option('ms-owner-daniel', 'Daniel Costa', 'aria-selected="true"') + option('ms-owner-sofia', 'Sofia Almeida', 'aria-selected="false" data-highlighted') + option('ms-owner-lucas', 'Lucas Ferreira', 'aria-selected="false"') + option('ms-owner-aisha', 'Aisha Khan (on leave)', 'aria-selected="false" aria-disabled="true"');
|
|
16
|
+
export const multiSelect = {
|
|
17
|
+
name: 'MultiSelect',
|
|
18
|
+
slug: 'multi-select',
|
|
19
|
+
category: 'forms',
|
|
20
|
+
description: 'A Combobox that keeps several values: chosen items sit as chips inside the field, and the listbox below shows a checkbox square on every option so the selection reads as a list of ticks. Typing filters; Enter toggles; Backspace removes the last chip.',
|
|
21
|
+
usage: 'Pick several items from a known list: assignees, tags from a fixed set, regions, products. For free-typed values use TagsInput; for one value use Select or Combobox; for 2–6 options that fit on screen use a Checkbox group. Pair with Field for the label.',
|
|
22
|
+
anatomy: [
|
|
23
|
+
{ part: 'root', element: 'div', description: 'Wrapper (position: relative) holding the chips box and the listbox. Carries data-size, data-state="open|closed", data-invalid, data-disabled.' },
|
|
24
|
+
{ part: 'chips', element: 'div', description: 'The bordered field box (flex, wrap, min-height = control height): draws background, hairline, radius, shadow-xs and the focus ring on :focus-within. Holds the chips, the input and the chevron.' },
|
|
25
|
+
{ part: 'chip', element: 'span', description: 'One selected value: surface fill, hairline, radius md, 24px tall, body-sm, with its remove button.' },
|
|
26
|
+
{ part: 'chip-remove', element: 'button', description: '16px × button at the end of a chip. aria-label "Remove {value}".' },
|
|
27
|
+
{ part: 'field', element: 'input', description: 'The bare text input with role="combobox" (flex 1, min-width 80px). Filters the list; never holds a committed value.' },
|
|
28
|
+
{ part: 'icon', element: 'svg', description: 'Chevron-down absolutely placed at the end of the first line; rotates when open. Decorative.' },
|
|
29
|
+
{ part: 'listbox', element: 'ul', description: 'The panel under the box: surface-raised, hairline, radius panel, shadow-lg, padding space.1, 36px rows, scrolls after 256px. Hidden unless the root has data-state="open". role="listbox" aria-multiselectable="true".' },
|
|
30
|
+
{ part: 'option', element: 'li', description: 'One row: checkbox square, then the label. role="option" with a unique id and aria-selected; data-highlighted for the keyboard cursor; aria-disabled="true" when not selectable.' },
|
|
31
|
+
{ part: 'option-check', element: 'span', description: '16px checkbox square at the start of the row (hairline, radius sm) that fills with the action color and shows the check when the option is selected. Contains the check svg.' },
|
|
32
|
+
{ part: 'empty', element: 'li', description: 'Muted "No results" row rendered instead of options when the filter matches nothing.', optional: true },
|
|
33
|
+
],
|
|
34
|
+
props: {
|
|
35
|
+
size: SIZE_PROP(['sm', 'md', 'lg']),
|
|
36
|
+
},
|
|
37
|
+
states: {
|
|
38
|
+
hover: { selector: ':hover:not([data-disabled])', description: 'Chips box border strengthens.', markup: 'native :hover' },
|
|
39
|
+
focus: { selector: ':focus-within', description: 'Chips box border becomes the action color and the ring appears. Focus stays on the input while the list is open.', markup: 'native :focus-within on the root' },
|
|
40
|
+
open: { selector: '[data-state="open"]', description: 'The listbox is visible under the box and the chevron points up.', markup: 'data-state="open" on the root and aria-expanded="true" on the input' },
|
|
41
|
+
invalid: { selector: '[data-invalid], &:has([aria-invalid="true"])', description: 'Required selection missing or over the limit: danger border, danger ring on focus. Pair with a Field error.', markup: 'aria-invalid="true" on the input, or data-invalid on the root' },
|
|
42
|
+
disabled: { selector: '[data-disabled], &:has(input:disabled)', description: 'Grey fill, disabled chips and chevron, not interactive.', markup: 'disabled on the input (and on the chip remove buttons)' },
|
|
43
|
+
highlighted: { selector: ' .cn-multi-select__option[data-highlighted]', description: 'On the __option: the row under the keyboard cursor or pointer, bg-subtle. Exactly one at a time. Styled in extraCss.', markup: 'data-highlighted on the option; mirror it with aria-activedescendant on the input' },
|
|
44
|
+
selected: { selector: ' .cn-multi-select__option[aria-selected="true"]', description: 'On the __option: a chosen value; the checkbox square fills with the action color and shows the check, the label turns medium. Styled in extraCss.', markup: 'aria-selected="true" on the option' },
|
|
45
|
+
'option-disabled': { selector: ' .cn-multi-select__option[aria-disabled="true"]', description: 'On the __option: not selectable, disabled ink, skipped by the keyboard. Styled in extraCss.', markup: 'aria-disabled="true" on the option' },
|
|
46
|
+
},
|
|
47
|
+
base: {
|
|
48
|
+
root: { position: 'relative', display: 'block', width: '100%', 'min-width': '0', color: '{color.fg-default}' },
|
|
49
|
+
chips: {
|
|
50
|
+
position: 'relative',
|
|
51
|
+
display: 'flex',
|
|
52
|
+
'flex-wrap': 'wrap',
|
|
53
|
+
'align-items': 'center',
|
|
54
|
+
gap: '{space.1}',
|
|
55
|
+
width: '100%',
|
|
56
|
+
'min-height': CONTROL.md.height,
|
|
57
|
+
padding: '{space.1.5}',
|
|
58
|
+
'background-color': '{color.bg-surface}',
|
|
59
|
+
border: '{border.width.thin} solid {color.border-control}',
|
|
60
|
+
'border-radius': '{radius.control}',
|
|
61
|
+
'box-shadow': '{shadow.xs}',
|
|
62
|
+
cursor: 'text',
|
|
63
|
+
...TRANSITION_COLORS,
|
|
64
|
+
},
|
|
65
|
+
chip: {
|
|
66
|
+
display: 'inline-flex',
|
|
67
|
+
'align-items': 'center',
|
|
68
|
+
gap: '{space.1}',
|
|
69
|
+
height: '{space.6}',
|
|
70
|
+
'max-width': '100%',
|
|
71
|
+
'padding-inline': '{space.2}',
|
|
72
|
+
'background-color': '{color.bg-surface}',
|
|
73
|
+
border: '{border.width.thin} solid {color.border-default}',
|
|
74
|
+
'border-radius': '{radius.md}',
|
|
75
|
+
...typeStyle('body-sm'),
|
|
76
|
+
'line-height': '{font.lineHeight.none}',
|
|
77
|
+
color: '{color.fg-default}',
|
|
78
|
+
'white-space': 'nowrap',
|
|
79
|
+
cursor: 'default',
|
|
80
|
+
},
|
|
81
|
+
'chip-remove': {
|
|
82
|
+
...RESET_BUTTON,
|
|
83
|
+
display: 'inline-flex',
|
|
84
|
+
'align-items': 'center',
|
|
85
|
+
'justify-content': 'center',
|
|
86
|
+
'flex-shrink': '0',
|
|
87
|
+
width: '{space.4}',
|
|
88
|
+
height: '{space.4}',
|
|
89
|
+
'margin-inline-end': 'calc({space.1} * -1)',
|
|
90
|
+
'border-radius': '{radius.sm}',
|
|
91
|
+
'font-size': '{size.icon.xs}',
|
|
92
|
+
color: '{color.fg-subtle}',
|
|
93
|
+
...TRANSITION_COLORS,
|
|
94
|
+
},
|
|
95
|
+
field: {
|
|
96
|
+
flex: '1 1 {space.20}',
|
|
97
|
+
'min-width': '{space.20}',
|
|
98
|
+
height: '{space.6}',
|
|
99
|
+
margin: '0',
|
|
100
|
+
padding: '0 {space.1}',
|
|
101
|
+
border: '0',
|
|
102
|
+
outline: 'none',
|
|
103
|
+
background: 'transparent',
|
|
104
|
+
...typeStyle('body-md'),
|
|
105
|
+
'line-height': '{font.lineHeight.none}',
|
|
106
|
+
color: 'inherit',
|
|
107
|
+
},
|
|
108
|
+
icon: {
|
|
109
|
+
position: 'absolute',
|
|
110
|
+
color: '{color.fg-subtle}',
|
|
111
|
+
'pointer-events': 'none',
|
|
112
|
+
'transition-property': 'transform, color',
|
|
113
|
+
'transition-duration': '{motion.duration.normal}',
|
|
114
|
+
'transition-timing-function': '{motion.easing.standard}',
|
|
115
|
+
},
|
|
116
|
+
listbox: {
|
|
117
|
+
display: 'none',
|
|
118
|
+
margin: '{space.1} 0 0',
|
|
119
|
+
padding: '{space.1}',
|
|
120
|
+
'max-height': '{space.64}',
|
|
121
|
+
'overflow-y': 'auto',
|
|
122
|
+
'background-color': '{color.bg-surface-raised}',
|
|
123
|
+
border: '{border.width.thin} solid {color.border-default}',
|
|
124
|
+
'border-radius': '{radius.panel}',
|
|
125
|
+
'box-shadow': '{shadow.lg}',
|
|
126
|
+
'list-style': 'none',
|
|
127
|
+
},
|
|
128
|
+
option: {
|
|
129
|
+
display: 'flex',
|
|
130
|
+
'align-items': 'center',
|
|
131
|
+
gap: '{space.2.5}',
|
|
132
|
+
height: '{space.9}',
|
|
133
|
+
'padding-inline': '{space.2.5}',
|
|
134
|
+
'border-radius': '{radius.md}',
|
|
135
|
+
...typeStyle('body-sm'),
|
|
136
|
+
color: '{color.fg-default}',
|
|
137
|
+
'white-space': 'nowrap',
|
|
138
|
+
overflow: 'hidden',
|
|
139
|
+
cursor: 'pointer',
|
|
140
|
+
'user-select': 'none',
|
|
141
|
+
},
|
|
142
|
+
'option-check': {
|
|
143
|
+
display: 'inline-flex',
|
|
144
|
+
'align-items': 'center',
|
|
145
|
+
'justify-content': 'center',
|
|
146
|
+
'flex-shrink': '0',
|
|
147
|
+
width: '{size.icon.md}',
|
|
148
|
+
height: '{size.icon.md}',
|
|
149
|
+
'font-size': '{size.icon.xs}',
|
|
150
|
+
'background-color': '{color.bg-surface}',
|
|
151
|
+
border: '{border.width.thin} solid {color.border-strong}',
|
|
152
|
+
'border-radius': '{radius.sm}',
|
|
153
|
+
color: '{color.fg-on-action}',
|
|
154
|
+
...TRANSITION_COLORS,
|
|
155
|
+
},
|
|
156
|
+
empty: { display: 'block', padding: '{space.2} {space.2.5}', ...typeStyle('body-sm'), color: '{color.fg-muted}' },
|
|
157
|
+
'@states': {
|
|
158
|
+
hover: { chips: { 'border-color': '{color.border-control-hover}' } },
|
|
159
|
+
focus: { chips: { ...FOCUS_RING, 'border-color': '{color.border-action}' } },
|
|
160
|
+
open: { listbox: { display: 'block' }, icon: { transform: 'rotate(180deg)' } },
|
|
161
|
+
invalid: { chips: { 'border-color': '{color.border-danger}' } },
|
|
162
|
+
disabled: {
|
|
163
|
+
chips: { 'background-color': '{color.bg-disabled}', color: '{color.fg-disabled}', 'border-color': '{color.border-disabled}', 'box-shadow': 'none', cursor: 'not-allowed' },
|
|
164
|
+
chip: { color: '{color.fg-disabled}', 'background-color': 'transparent', 'border-color': '{color.border-disabled}' },
|
|
165
|
+
'chip-remove': { color: '{color.fg-disabled}', 'pointer-events': 'none' },
|
|
166
|
+
field: { cursor: 'not-allowed' },
|
|
167
|
+
icon: { color: '{color.fg-disabled}' },
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
variants: {
|
|
172
|
+
size: {
|
|
173
|
+
sm: sizeBlock('sm', '{space.5}', '{font.size.xs}'),
|
|
174
|
+
md: sizeBlock('md', '{space.6}', '{font.size.sm}'),
|
|
175
|
+
lg: sizeBlock('lg', '{space.7}', '{font.size.md}'),
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
extraCss: `
|
|
179
|
+
.cn-multi-select .cn-multi-select__field::placeholder { color: {color.fg-placeholder}; opacity: 1; }
|
|
180
|
+
.cn-multi-select[data-invalid]:focus-within .cn-multi-select__chips, .cn-multi-select:has([aria-invalid="true"]):focus-within .cn-multi-select__chips { box-shadow: {shadow.focus-danger}; border-color: {color.border-danger}; }
|
|
181
|
+
.cn-multi-select .cn-multi-select__chip-remove .cn-icon { stroke-width: 2; }
|
|
182
|
+
.cn-multi-select .cn-multi-select__chip-remove:hover { background-color: {color.bg-subtle}; color: {color.fg-default}; }
|
|
183
|
+
.cn-multi-select .cn-multi-select__chip-remove:focus-visible { outline: none; box-shadow: {shadow.focus}; color: {color.fg-default}; }
|
|
184
|
+
.cn-multi-select .cn-multi-select__option-check .cn-icon { display: none; stroke-width: 2.5; }
|
|
185
|
+
.cn-multi-select .cn-multi-select__option[data-highlighted], .cn-multi-select .cn-multi-select__option:hover { background-color: {color.bg-subtle}; }
|
|
186
|
+
.cn-multi-select .cn-multi-select__option[aria-selected="true"] { font-weight: {font.weight.medium}; }
|
|
187
|
+
.cn-multi-select .cn-multi-select__option[aria-selected="true"] .cn-multi-select__option-check { background-color: {color.bg-action}; border-color: {color.bg-action}; }
|
|
188
|
+
.cn-multi-select .cn-multi-select__option[aria-selected="true"] .cn-multi-select__option-check .cn-icon { display: block; }
|
|
189
|
+
.cn-multi-select .cn-multi-select__option[aria-disabled="true"] { color: {color.fg-disabled}; cursor: not-allowed; background-color: transparent; }
|
|
190
|
+
.cn-multi-select .cn-multi-select__option[aria-disabled="true"] .cn-multi-select__option-check { background-color: {color.bg-disabled}; border-color: {color.border-disabled}; }`,
|
|
191
|
+
examples: [
|
|
192
|
+
ex('Closed with values', box({ root: 'data-size="md" data-state="closed" style="max-width:360px"', chips: ['Maya Chen', 'Daniel Costa'], input: 'aria-expanded="false" placeholder="Add people…" aria-label="Assignees"', id: 'ms-closed' }), 'Chips inside the box, the input after them, the chevron at the end of the first line.'),
|
|
193
|
+
ex('Open with options', box({ root: 'data-size="md" data-state="open" style="max-width:360px"', chips: ['Maya Chen', 'Daniel Costa'], input: 'aria-expanded="true" value="" aria-label="Assignees" aria-activedescendant="ms-owner-sofia"', id: 'ms-owner', body: PEOPLE }), 'Maya and Daniel are selected (filled squares), Sofia is highlighted by the keyboard, Aisha is disabled.'),
|
|
194
|
+
ex('Open, no results', box({ root: 'data-size="md" data-state="open" style="max-width:360px"', chips: ['Europe'], input: 'aria-expanded="true" value="Antarc" aria-label="Regions"', id: 'ms-region', body: '<li class="cn-multi-select__empty" role="presentation">No regions match “Antarc”</li>' })),
|
|
195
|
+
ex('Empty (placeholder)', box({ root: 'data-size="md" data-state="closed" style="max-width:360px"', chips: [], input: 'aria-expanded="false" placeholder="Select products…" aria-label="Products"', id: 'ms-empty' }), 'Without chips it looks like a Combobox of the same size.'),
|
|
196
|
+
ex('Small and large', `<div style="display:flex;flex-direction:column;gap:var(--cn-space-3);max-width:360px">${box({ root: 'data-size="sm" data-state="closed"', chips: ['Japan', 'Norway'], input: 'aria-expanded="false" placeholder="Add a country…" aria-label="Countries (small)"', id: 'ms-sm' })}${box({ root: 'data-size="lg" data-state="closed"', chips: ['Japan', 'Norway'], input: 'aria-expanded="false" placeholder="Add a country…" aria-label="Countries (large)"', id: 'ms-lg' })}</div>`),
|
|
197
|
+
ex('Invalid', box({ root: 'data-size="md" data-state="closed" data-invalid style="max-width:360px"', chips: [], input: 'aria-expanded="false" aria-invalid="true" placeholder="Add at least one reviewer…" aria-label="Reviewers"', id: 'ms-invalid' }), 'Pair with a Field error such as "Pick at least one reviewer".'),
|
|
198
|
+
ex('Disabled', box({ root: 'data-size="md" data-state="closed" style="max-width:360px"', chips: ['Elena Rossi', 'Tomás Silva'], input: 'aria-expanded="false" disabled aria-label="Approvers"', id: 'ms-disabled', removeAttrs: ' disabled' })),
|
|
199
|
+
],
|
|
200
|
+
rules: [
|
|
201
|
+
'Values come from the list only; typing filters, it never creates. If users must add their own values, use TagsInput.',
|
|
202
|
+
'Selected items render as chips in the box in selection order and stay ticked in the list; removing a chip unticks the option and vice versa.',
|
|
203
|
+
'The listbox renders in the flow directly under the box (margin-top space.1) inside the root; toggle data-state="open|closed" on the root. Move it to a popper layer only when it would be clipped.',
|
|
204
|
+
'Options are 36px rows with a 16px checkbox square first; show at most 7 before scrolling (max-height 256px).',
|
|
205
|
+
'Exactly one option is highlighted at a time (data-highlighted) and follows keyboard and pointer; Enter or Space toggles it without closing the list.',
|
|
206
|
+
'When nothing matches, render the empty row ("No regions match “…”"); never an empty panel and never close the list.',
|
|
207
|
+
'State the limit in the helper text when one exists ("Up to 5 reviewers") and mark the box invalid when it is exceeded, not silently ignore the click.',
|
|
208
|
+
'Sizes: md in forms; sm in filter bars; lg on onboarding screens. Chips are 20 / 24 / 28px tall.',
|
|
209
|
+
],
|
|
210
|
+
a11y: [
|
|
211
|
+
'The input has role="combobox", aria-expanded, aria-controls="<listbox id>", aria-autocomplete="list" and aria-activedescendant pointing at the highlighted option id.',
|
|
212
|
+
'The list has role="listbox" aria-multiselectable="true"; each option has role="option", a unique id and aria-selected="true|false". Disabled options use aria-disabled="true".',
|
|
213
|
+
'Keyboard: Down/Up move the highlight, Enter or Space toggle it, Escape closes, Backspace on an empty input removes the last chip. Focus stays on the input the whole time.',
|
|
214
|
+
'Each chip remove button is a real <button> with aria-label "Remove {value}"; announce changes in a live region ("Daniel Costa removed, 1 selected").',
|
|
215
|
+
'The checkbox square is decorative (aria-hidden); the state is carried by aria-selected on the option.',
|
|
216
|
+
],
|
|
217
|
+
related: ['combobox', 'select', 'tags-input', 'checkbox', 'field'],
|
|
218
|
+
};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { FOCUS_RING, RESET_BUTTON, TRANSITION_COLORS, ex, ICON, typeStyle } from "./_shared.mjs";
|
|
2
|
+
const HAIRLINE = '{border.width.thin} solid {color.border-default}';
|
|
3
|
+
/** Inline icons for the featured-icon media slot (20px, stroke 1.5, aria-hidden). */
|
|
4
|
+
const SVG = {
|
|
5
|
+
comment: '<svg class="cn-icon" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.5 5.5A2 2 0 015.5 3.5h9a2 2 0 012 2v6a2 2 0 01-2 2H8l-3.5 3v-3H5.5a2 2 0 01-2-2v-6z"/></svg>',
|
|
6
|
+
checkCircle: '<svg class="cn-icon" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="10" cy="10" r="7.25"/><path d="M6.75 10.25l2.25 2.25 4.25-4.5"/></svg>',
|
|
7
|
+
alert: '<svg class="cn-icon" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 3.25l7.25 12.5H2.75L10 3.25zM10 8v3.5M10 14v.5"/></svg>',
|
|
8
|
+
upload: '<svg class="cn-icon" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 13.5a3.25 3.25 0 01-.5-6.46A4.5 4.5 0 0114.6 8.2a2.75 2.75 0 01-.6 5.3M10 16V9.5M7.25 12.25L10 9.5l2.75 2.75"/></svg>',
|
|
9
|
+
spark: '<svg class="cn-icon" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 2.75l1.9 5.35 5.35 1.9-5.35 1.9L10 17.25l-1.9-5.35-5.35-1.9 5.35-1.9z"/></svg>',
|
|
10
|
+
};
|
|
11
|
+
/** 16:9 placeholder image: a muted surface with a simple "chart" silhouette, no network. */
|
|
12
|
+
const IMAGE = `<svg class="cn-notification__image" viewBox="0 0 368 207" role="img" aria-label="Preview of the new reporting dashboard"><rect width="368" height="207" style="fill:var(--cn-color-bg-subtle)"/><rect x="24" y="24" width="140" height="10" rx="5" style="fill:var(--cn-color-border-strong)"/><rect x="24" y="44" width="88" height="8" rx="4" style="fill:var(--cn-color-border-default)"/><path d="M24 160 L80 128 L136 142 L192 96 L248 110 L304 64 L344 80" fill="none" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" style="stroke:var(--cn-color-bg-action)"/><path d="M24 183h320" stroke-width="1" style="stroke:var(--cn-color-border-default)"/></svg>`;
|
|
13
|
+
const dismiss = `<button type="button" class="cn-notification__dismiss" aria-label="Dismiss">${ICON.x}</button>`;
|
|
14
|
+
const avatar = (initials, name) => `<span class="cn-notification__media" role="img" aria-label="${name}">${initials}</span>`;
|
|
15
|
+
const featured = (svg) => `<span class="cn-notification__media" aria-hidden="true">${svg}</span>`;
|
|
16
|
+
const actions = (secondary, primary) => `<div class="cn-notification__actions"><button type="button" class="cn-button" data-variant="ghost" data-size="sm"><span class="cn-button__label">${secondary}</span></button><button type="button" class="cn-button" data-variant="link" data-size="sm"><span class="cn-button__label">${primary}</span></button></div>`;
|
|
17
|
+
const card = (variant, tone, inner) => `<div class="cn-notification" data-variant="${variant}" data-tone="${tone}" role="status">${inner}</div>`;
|
|
18
|
+
export const notification = {
|
|
19
|
+
name: 'Notification',
|
|
20
|
+
slug: 'notification',
|
|
21
|
+
category: 'feedback',
|
|
22
|
+
description: 'An in-app notification card: a 400px raised white panel with an optional avatar or featured icon on the left, a semibold title, one or two lines of supporting text, a timestamp and up to two text actions. Unlike Toast it is not timed: it sits in a notification panel or feed until dismissed.',
|
|
23
|
+
usage: 'Use in a notifications drawer, a bell-menu panel or an inbox list to tell a person something happened that they may act on later: a mention, a comment, an upload finishing, a product announcement. Not for transient confirmations (Toast), not for page-level warnings (Alert or Banner) and not for anything that needs a decision now (Dialog).',
|
|
24
|
+
anatomy: [
|
|
25
|
+
{ part: 'root', element: 'div', description: 'The card. 400px wide (max 100%), raised surface, hairline, radius card, shadow-lg, padding 16. Flex row: media, content, then the absolutely-positioned dismiss button. role="status".' },
|
|
26
|
+
{ part: 'media', element: 'span', description: 'Optional 40px slot on the left: an avatar circle with initials (with-avatar) or a featured icon square/circle holding a 20px icon (with-featured-icon). Tone colors the featured icon.', optional: true },
|
|
27
|
+
{ part: 'content', element: 'div', description: 'Title, description, time, optional progress, image and actions. Flexes to fill; keeps 32px of right padding clear for the dismiss button.' },
|
|
28
|
+
{ part: 'title', element: 'div', description: 'label-md (14 semibold), one line: who did what ("Maya Chen commented on your post") or what happened ("Export ready").' },
|
|
29
|
+
{ part: 'description', element: 'p', description: 'body-md muted, one or two lines: the comment excerpt, the file name, the change summary.', optional: true },
|
|
30
|
+
{ part: 'time', element: 'time', description: 'body-sm subtle timestamp under the description ("2 minutes ago"). Use a <time datetime>.', optional: true },
|
|
31
|
+
{ part: 'progress', element: 'div', description: 'Optional upload/processing row: an 8px track with the action-colored fill plus a percentage label (label-sm muted). Set the fill width inline and mirror it in aria-valuenow.', optional: true },
|
|
32
|
+
{ part: 'progress-track', element: 'div', description: 'The 8px rounded track (bg-muted) inside the progress row. role="progressbar".', optional: true },
|
|
33
|
+
{ part: 'progress-fill', element: 'div', description: 'The filled portion of the track. Width is the percentage (inline style).', optional: true },
|
|
34
|
+
{ part: 'image', element: 'img', description: 'Optional full-width 16:9 image under the text (product announcements). Radius md, object-fit cover.', optional: true },
|
|
35
|
+
{ part: 'actions', element: 'div', description: 'Row of at most two Buttons: a ghost sm ("Dismiss", "Later") then a link sm ("View changes", "Reply").', optional: true },
|
|
36
|
+
{ part: 'dismiss', element: 'button', description: '36px ghost icon button with a 20px X, absolutely positioned in the top-right corner (8px inset). aria-label="Dismiss".', optional: true },
|
|
37
|
+
],
|
|
38
|
+
props: {
|
|
39
|
+
variant: {
|
|
40
|
+
values: ['default', 'with-avatar', 'with-featured-icon', 'with-image', 'with-progress', 'with-actions'],
|
|
41
|
+
default: 'default',
|
|
42
|
+
description: 'default = text only (title, description, time); with-avatar = a 40px avatar of the person who acted, for social events (comments, mentions, invites); with-featured-icon = a 40px featured icon for system events (payment, export, sync); with-image = a full-width 16:9 image below the text, for announcements; with-progress = a progress row for uploads and long jobs; with-actions = the text plus a Dismiss + primary-link action row (any variant may also carry actions).',
|
|
43
|
+
},
|
|
44
|
+
tone: {
|
|
45
|
+
values: ['neutral', 'success', 'warning', 'danger'],
|
|
46
|
+
default: 'neutral',
|
|
47
|
+
description: 'Colors the featured icon only; the card stays white. neutral = informational (grey icon in a hairline square); success = something completed (green tinted circle); warning = needs attention soon (amber); danger = something failed (red). Avatars and images ignore the tone.',
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
states: {
|
|
51
|
+
dismissHover: { selector: ' .cn-notification__dismiss:hover', description: 'Pointer over the dismiss button: subtle fill, default ink. Styled in extraCss because it lives on a child.', markup: 'native :hover on .cn-notification__dismiss' },
|
|
52
|
+
dismissFocus: { selector: ' .cn-notification__dismiss:focus-visible', description: 'Keyboard focus on the dismiss button shows the focus ring. Styled in extraCss.', markup: 'native :focus-visible on .cn-notification__dismiss' },
|
|
53
|
+
unread: { selector: '[data-unread]', description: 'Not yet seen: the title stays semibold and a 8px action-colored dot sits after the title. Clear the attribute when the panel is opened.', markup: 'data-unread on the root' },
|
|
54
|
+
},
|
|
55
|
+
base: {
|
|
56
|
+
root: {
|
|
57
|
+
position: 'relative',
|
|
58
|
+
display: 'flex',
|
|
59
|
+
'align-items': 'flex-start',
|
|
60
|
+
gap: '{space.4}',
|
|
61
|
+
width: '400px',
|
|
62
|
+
'max-width': '100%',
|
|
63
|
+
padding: '{space.4}',
|
|
64
|
+
'background-color': '{color.bg-surface-raised}',
|
|
65
|
+
border: HAIRLINE,
|
|
66
|
+
'border-radius': '{radius.card}',
|
|
67
|
+
'box-shadow': '{shadow.lg}',
|
|
68
|
+
color: '{color.fg-default}',
|
|
69
|
+
...typeStyle('body-md'),
|
|
70
|
+
},
|
|
71
|
+
media: {
|
|
72
|
+
display: 'inline-flex',
|
|
73
|
+
'align-items': 'center',
|
|
74
|
+
'justify-content': 'center',
|
|
75
|
+
'flex-shrink': '0',
|
|
76
|
+
width: '{space.10}',
|
|
77
|
+
height: '{space.10}',
|
|
78
|
+
'border-radius': '{radius.full}',
|
|
79
|
+
'background-color': '{color.bg-subtle}',
|
|
80
|
+
color: '{color.fg-muted}',
|
|
81
|
+
...typeStyle('label-sm'),
|
|
82
|
+
'letter-spacing': '{font.letterSpacing.wide}',
|
|
83
|
+
overflow: 'hidden',
|
|
84
|
+
'user-select': 'none',
|
|
85
|
+
},
|
|
86
|
+
content: { flex: '1 1 auto', 'min-width': '0', 'padding-inline-end': '{space.8}' },
|
|
87
|
+
title: { ...typeStyle('label-md'), color: '{color.fg-default}', 'overflow-wrap': 'anywhere' },
|
|
88
|
+
description: { ...typeStyle('body-md'), color: '{color.fg-muted}', 'margin-top': '{space.1}', 'overflow-wrap': 'anywhere' },
|
|
89
|
+
time: { display: 'block', ...typeStyle('body-sm'), color: '{color.fg-subtle}', 'margin-top': '{space.1}' },
|
|
90
|
+
progress: {
|
|
91
|
+
display: 'flex',
|
|
92
|
+
'align-items': 'center',
|
|
93
|
+
gap: '{space.3}',
|
|
94
|
+
'margin-top': '{space.3}',
|
|
95
|
+
...typeStyle('label-sm'),
|
|
96
|
+
color: '{color.fg-muted}',
|
|
97
|
+
'font-variant-numeric': 'tabular-nums',
|
|
98
|
+
},
|
|
99
|
+
'progress-track': { flex: '1 1 auto', height: '{space.2}', 'border-radius': '{radius.full}', 'background-color': '{color.bg-muted}', overflow: 'hidden' },
|
|
100
|
+
'progress-fill': { height: '100%', 'border-radius': '{radius.full}', 'background-color': '{color.bg-action}', 'transition-property': 'width', 'transition-duration': '{motion.duration.slow}', 'transition-timing-function': '{motion.easing.standard}' },
|
|
101
|
+
image: { display: 'block', width: '100%', 'aspect-ratio': '16 / 9', 'object-fit': 'cover', 'border-radius': '{radius.md}', 'background-color': '{color.bg-subtle}', 'margin-top': '{space.3}' },
|
|
102
|
+
actions: { display: 'flex', 'align-items': 'center', gap: '{space.3}', 'margin-top': '{space.3}' },
|
|
103
|
+
dismiss: {
|
|
104
|
+
...RESET_BUTTON,
|
|
105
|
+
position: 'absolute',
|
|
106
|
+
top: '{space.2}',
|
|
107
|
+
right: '{space.2}',
|
|
108
|
+
display: 'inline-flex',
|
|
109
|
+
'align-items': 'center',
|
|
110
|
+
'justify-content': 'center',
|
|
111
|
+
width: '{space.9}',
|
|
112
|
+
height: '{space.9}',
|
|
113
|
+
'border-radius': '{radius.md}',
|
|
114
|
+
color: '{color.fg-subtle}',
|
|
115
|
+
...TRANSITION_COLORS,
|
|
116
|
+
},
|
|
117
|
+
'@states': {
|
|
118
|
+
unread: { title: { 'font-weight': '{font.weight.semibold}' } },
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
variants: {
|
|
122
|
+
variant: {
|
|
123
|
+
default: { root: {} },
|
|
124
|
+
'with-avatar': { media: { 'border-radius': '{radius.full}' } },
|
|
125
|
+
'with-featured-icon': { media: { 'border-radius': '{radius.lg}', 'background-color': '{color.bg-surface}', border: HAIRLINE, 'box-shadow': '{shadow.xs}', color: '{color.fg-muted}' } },
|
|
126
|
+
'with-image': { root: {} },
|
|
127
|
+
'with-progress': { root: {} },
|
|
128
|
+
'with-actions': { root: {} },
|
|
129
|
+
},
|
|
130
|
+
tone: {
|
|
131
|
+
neutral: { media: {} },
|
|
132
|
+
success: { media: { 'background-color': '{color.bg-success-subtle}', color: '{color.fg-success}', 'border-color': 'transparent', 'border-radius': '{radius.full}', 'box-shadow': 'none' } },
|
|
133
|
+
warning: { media: { 'background-color': '{color.bg-warning-subtle}', color: '{color.fg-warning}', 'border-color': 'transparent', 'border-radius': '{radius.full}', 'box-shadow': 'none' } },
|
|
134
|
+
danger: { media: { 'background-color': '{color.bg-danger-subtle}', color: '{color.fg-danger}', 'border-color': 'transparent', 'border-radius': '{radius.full}', 'box-shadow': 'none' } },
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
extraCss: `
|
|
138
|
+
.cn-notification .cn-notification__media .cn-icon { width: {size.icon.md}; height: {size.icon.md}; }
|
|
139
|
+
.cn-notification .cn-notification__dismiss .cn-icon { width: {size.icon.md}; height: {size.icon.md}; }
|
|
140
|
+
.cn-notification .cn-notification__dismiss:hover { background-color: {color.bg-subtle}; color: {color.fg-default}; }
|
|
141
|
+
.cn-notification .cn-notification__dismiss:focus-visible { outline: none; box-shadow: ${FOCUS_RING['box-shadow']}; }
|
|
142
|
+
.cn-notification .cn-notification__actions > .cn-button[data-variant="ghost"]:first-child { margin-inline-start: calc(-1 * {size.controlPadding.sm}); }
|
|
143
|
+
.cn-notification[data-unread] .cn-notification__title::after { content: ''; display: inline-block; width: {space.2}; height: {space.2}; margin-inline-start: {space.2}; border-radius: {radius.full}; background-color: {color.bg-action}; vertical-align: middle; }`,
|
|
144
|
+
examples: [
|
|
145
|
+
ex('Default (text only)', card('default', 'neutral', `<div class="cn-notification__content"><div class="cn-notification__title">Your export is ready</div><p class="cn-notification__description">prospects-q3.csv · 1.2 MB · 2,418 rows</p><time class="cn-notification__time" datetime="2026-09-11T09:41">Just now</time></div>${dismiss}`)),
|
|
146
|
+
ex('With avatar and actions', card('with-avatar', 'neutral', `${avatar('OR', 'Maya Chen')}<div class="cn-notification__content"><div class="cn-notification__title">Maya Chen commented on your post</div><p class="cn-notification__description">“Looks great — can we ship the reporting tab on Monday instead of Friday?”</p><time class="cn-notification__time" datetime="2026-09-11T09:39">2 minutes ago</time>${actions('Dismiss', 'Reply')}</div>${dismiss}`), 'Social events show who acted. Two actions at most: a ghost Dismiss and one link action.'),
|
|
147
|
+
ex('Featured icon, success tone', card('with-featured-icon', 'success', `${featured(SVG.checkCircle)}<div class="cn-notification__content"><div class="cn-notification__title">Payment received</div><p class="cn-notification__description">Nakamura Trading Co. paid invoice INV-3066 · $12,400.00</p><time class="cn-notification__time" datetime="2026-09-11T08:12">1 hour ago</time>${actions('Dismiss', 'View invoice')}</div>${dismiss}`)),
|
|
148
|
+
ex('Upload in progress', card('with-progress', 'neutral', `${featured(SVG.upload)}<div class="cn-notification__content"><div class="cn-notification__title">Uploading design-system.fig</div><p class="cn-notification__description">1.4 MB of 2.3 MB · about 20 seconds left</p><div class="cn-notification__progress"><div class="cn-notification__progress-track" role="progressbar" aria-label="Upload progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="60"><div class="cn-notification__progress-fill" style="width:60%"></div></div><span>60%</span></div></div>${dismiss}`), 'The neutral featured icon is a hairline square ("modern"); toned icons become tinted circles.'),
|
|
149
|
+
ex('With image (announcement)', card('with-image', 'neutral', `${featured(SVG.spark)}<div class="cn-notification__content"><div class="cn-notification__title">New feature: reporting</div><p class="cn-notification__description">Build custom dashboards from any search and share them with your team.</p><time class="cn-notification__time" datetime="2026-09-10T17:00">Yesterday</time>${IMAGE}${actions('Dismiss', 'Learn more')}</div>${dismiss}`), 'The image is an inline SVG placeholder here; in the app it is an <img> with alt text.'),
|
|
150
|
+
ex('Warning with actions, unread', card('with-actions', 'warning', `${featured(SVG.alert)}<div class="cn-notification__content"><div class="cn-notification__title">Storage is 92% full</div><p class="cn-notification__description">Uploads will pause at 100%. Free up space or upgrade your plan.</p><time class="cn-notification__time" datetime="2026-09-11T07:30">3 hours ago</time>${actions('Later', 'Upgrade plan')}</div>${dismiss}`).replace('role="status"', 'role="status" data-unread'), 'data-unread adds the action-colored dot after the title.'),
|
|
151
|
+
],
|
|
152
|
+
recipes: [
|
|
153
|
+
ex('Danger tone, sync failed', card('with-featured-icon', 'danger', `${featured(SVG.alert)}<div class="cn-notification__content"><div class="cn-notification__title">Pipedrive sync failed</div><p class="cn-notification__description">14 deals were not updated. The token expired on Sep 9.</p><time class="cn-notification__time" datetime="2026-09-11T06:02">5 hours ago</time>${actions('Dismiss', 'Reconnect')}</div>${dismiss}`), 'Danger notifications report a background failure and offer the fix as the link action; anything needing an immediate decision is a Dialog instead.'),
|
|
154
|
+
ex('Stack in a panel', `<div style="display:flex;flex-direction:column;gap:var(--cn-space-3)">${card('with-avatar', 'neutral', `${avatar('PB', 'Daniel Costa')}<div class="cn-notification__content"><div class="cn-notification__title">Daniel Costa mentioned you in Q3 launch plan</div><time class="cn-notification__time" datetime="2026-09-11T09:20">20 minutes ago</time></div>${dismiss}`)}${card('with-avatar', 'neutral', `${avatar('LS', 'Sofia Almeida')}<div class="cn-notification__content"><div class="cn-notification__title">Sofia Almeida invited you to Brand refresh</div><time class="cn-notification__time" datetime="2026-09-11T08:55">45 minutes ago</time>${actions('Decline', 'Accept')}</div>${dismiss}`)}</div>`, 'Newest first, 12px apart, inside a drawer or a popover anchored to the bell icon.'),
|
|
155
|
+
],
|
|
156
|
+
rules: [
|
|
157
|
+
'Width is 400px (max 100%); the card never stretches to fill a wide panel. Panels and drawers are sized to the card, not the other way around.',
|
|
158
|
+
'Title is one line, sentence case, no period: "{Person} {verb} {object}" for social events, "{Object} {state}" for system events.',
|
|
159
|
+
'Description is at most two lines: a quoted excerpt, a file name with size, or the concrete change. Truncate long comments with an ellipsis and let the action open the full thing.',
|
|
160
|
+
'Always show a timestamp (relative under 24h, absolute after) in a <time datetime>.',
|
|
161
|
+
'At most two actions: a ghost sm secondary first ("Dismiss", "Later", "Decline") and one link sm primary last ("View changes", "Reply", "Accept").',
|
|
162
|
+
'The card stays white in every tone; only the featured icon is tinted. Never tint the whole card or add a colored left bar.',
|
|
163
|
+
'Avatar for people, featured icon for the system, image only for announcements. Never two media slots.',
|
|
164
|
+
'Progress rows show a percentage and a plain-language remainder ("about 20 seconds left"); when the job ends the card becomes a success notification, it does not disappear.',
|
|
165
|
+
'Unread state is a dot after the title plus semibold; clear it when the panel opens, never require a click per card.',
|
|
166
|
+
],
|
|
167
|
+
a11y: [
|
|
168
|
+
'role="status" on the card so new notifications are announced politely; the panel that holds them is a region labelled "Notifications".',
|
|
169
|
+
'The dismiss button has aria-label="Dismiss" and is the last Tab stop inside the card; Escape inside the panel dismisses the focused card.',
|
|
170
|
+
'Avatars with initials carry role="img" and aria-label="Full name"; featured icons and images used as decoration are aria-hidden.',
|
|
171
|
+
'Progress uses role="progressbar" with aria-valuenow/min/max on the track and the percentage as visible text.',
|
|
172
|
+
'The unread dot is decorative; expose "unread" in the accessible name or a visually hidden word so screen readers get it too.',
|
|
173
|
+
],
|
|
174
|
+
related: ['toast', 'alert', 'banner', 'button', 'avatar', 'progress'],
|
|
175
|
+
};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { CONTROL, FOCUS_RING, RESET_BUTTON, TRANSITION_COLORS, SIZE_PROP, ex, ICON, typeStyle } from "./_shared.mjs";
|
|
2
|
+
// Numeric field with a unit suffix and a pair of square ghost stepper buttons separated by hairlines.
|
|
3
|
+
// The native spinner is hidden; the wrapper draws border, shadow and focus ring like Input.
|
|
4
|
+
const STEP_WIDTH = { sm: '{space.8}', md: '{space.9}', lg: '{space.10}' };
|
|
5
|
+
const stepper = {
|
|
6
|
+
...RESET_BUTTON,
|
|
7
|
+
display: 'inline-flex',
|
|
8
|
+
'align-items': 'center',
|
|
9
|
+
'justify-content': 'center',
|
|
10
|
+
'flex-shrink': '0',
|
|
11
|
+
'align-self': 'stretch',
|
|
12
|
+
width: '{space.9}',
|
|
13
|
+
'font-size': '{size.icon.md}',
|
|
14
|
+
color: '{color.fg-subtle}',
|
|
15
|
+
'border-inline-start': '{border.width.thin} solid {color.border-control}',
|
|
16
|
+
...TRANSITION_COLORS,
|
|
17
|
+
};
|
|
18
|
+
const sizeBlock = (s) => ({
|
|
19
|
+
root: { height: CONTROL[s].height, 'border-radius': CONTROL[s].radius },
|
|
20
|
+
field: { 'padding-inline': CONTROL[s].px, 'font-size': CONTROL[s].font },
|
|
21
|
+
unit: { 'font-size': CONTROL[s].font },
|
|
22
|
+
decrement: { width: STEP_WIDTH[s] },
|
|
23
|
+
increment: { width: STEP_WIDTH[s] },
|
|
24
|
+
});
|
|
25
|
+
const MINUS = '<svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M3 8h10"/></svg>';
|
|
26
|
+
const steppers = (label, attrs = '') => `<button type="button" class="cn-number-input__decrement" aria-label="Decrease ${label}" tabindex="-1"${attrs}>${MINUS}</button><button type="button" class="cn-number-input__increment" aria-label="Increase ${label}" tabindex="-1"${attrs}>${ICON.plus}</button>`;
|
|
27
|
+
const num = (o) => `<div class="cn-number-input" data-variant="${o.variant ?? 'stepper'}" data-size="${o.size ?? 'md'}"${o.root ?? ''} style="max-width:240px"><input class="cn-number-input__field" type="number" inputmode="decimal" value="${o.value}" aria-label="${o.label}"${o.attrs ?? ''}>${o.unit ? `<span class="cn-number-input__unit">${o.unit}</span>` : ''}${o.variant === 'plain' ? '' : steppers(o.label.toLowerCase(), o.stepAttrs)}</div>`;
|
|
28
|
+
export const numberInput = {
|
|
29
|
+
name: 'NumberInput',
|
|
30
|
+
slug: 'number-input',
|
|
31
|
+
category: 'forms',
|
|
32
|
+
description: 'A numeric Input with an optional unit suffix and, in the stepper variant, two square ghost buttons (−, +) separated by hairlines at the end. The native spinner is hidden; the wrapper draws the border and focus ring.',
|
|
33
|
+
usage: 'Quantities, seats, weights, percentages and other numbers the user adjusts by small steps. For free numeric text (an invoice total, a phone number) use Input with inputmode; for a currency amount with a selector use InputGroup; for a value on a range use Slider.',
|
|
34
|
+
anatomy: [
|
|
35
|
+
{ part: 'root', element: 'div', description: 'The bordered wrapper (inline-flex, stretch): background, hairline, radius, shadow-xs, focus ring on :focus-within. Carries data-variant, data-size, data-invalid, data-disabled.' },
|
|
36
|
+
{ part: 'field', element: 'input', description: 'The native <input type="number" inputmode="decimal">. No border, tabular numerals, spinner hidden.' },
|
|
37
|
+
{ part: 'unit', element: 'span', description: 'Muted suffix after the value ("kg", "%", "seats"). Not part of the value.', optional: true },
|
|
38
|
+
{ part: 'decrement', element: 'button', description: 'Square ghost button with a minus icon, separated by a hairline. tabindex="-1" (keyboard users use the arrow keys). Disable it at min.', optional: true },
|
|
39
|
+
{ part: 'increment', element: 'button', description: 'Square ghost button with a plus icon, last child. Disable it at max.', optional: true },
|
|
40
|
+
],
|
|
41
|
+
props: {
|
|
42
|
+
size: SIZE_PROP(['sm', 'md', 'lg']),
|
|
43
|
+
variant: { values: ['stepper', 'plain'], default: 'stepper', description: 'stepper = value plus the −/+ buttons (quantities, seats, anything adjusted in small steps); plain = value and unit only, no buttons (percentages, weights and other typed numbers where stepping makes no sense).' },
|
|
44
|
+
},
|
|
45
|
+
states: {
|
|
46
|
+
hover: { selector: ':hover:not([data-disabled])', description: 'Border strengthens.', markup: 'native :hover' },
|
|
47
|
+
focus: { selector: ':focus-within', description: 'Border becomes the action color and the ring appears (also when a stepper button is focused).', markup: 'native :focus-within on the wrapper' },
|
|
48
|
+
invalid: { selector: '[data-invalid], &:has([aria-invalid="true"])', description: 'Out of range or not a number: danger border, danger ring on focus. Pair with a Field error.', markup: 'aria-invalid="true" on the input, or data-invalid on the wrapper' },
|
|
49
|
+
disabled: { selector: '[data-disabled], &:has(input:disabled)', description: 'Grey fill, disabled text and buttons, not interactive.', markup: 'disabled on the input (and on both buttons)' },
|
|
50
|
+
stepHover: { selector: ' .cn-number-input__decrement:hover:not(:disabled)', description: 'On a stepper button: subtle fill and default ink. Same for __increment. Styled in extraCss.', markup: 'native :hover on a stepper button' },
|
|
51
|
+
stepDisabled: { selector: ' .cn-number-input__decrement:disabled', description: 'On a stepper button at the min (or __increment at the max): disabled ink, no hover. Styled in extraCss.', markup: 'disabled on the button' },
|
|
52
|
+
},
|
|
53
|
+
base: {
|
|
54
|
+
root: {
|
|
55
|
+
display: 'inline-flex',
|
|
56
|
+
'align-items': 'stretch',
|
|
57
|
+
width: '100%',
|
|
58
|
+
'max-width': '100%',
|
|
59
|
+
'min-width': '0',
|
|
60
|
+
'background-color': '{color.bg-surface}',
|
|
61
|
+
border: '{border.width.thin} solid {color.border-control}',
|
|
62
|
+
color: '{color.fg-default}',
|
|
63
|
+
'box-shadow': '{shadow.xs}',
|
|
64
|
+
overflow: 'hidden',
|
|
65
|
+
...TRANSITION_COLORS,
|
|
66
|
+
},
|
|
67
|
+
field: {
|
|
68
|
+
flex: '1 1 auto',
|
|
69
|
+
width: '100%',
|
|
70
|
+
'min-width': '0',
|
|
71
|
+
height: '100%',
|
|
72
|
+
margin: '0',
|
|
73
|
+
border: '0',
|
|
74
|
+
outline: 'none',
|
|
75
|
+
background: 'transparent',
|
|
76
|
+
...typeStyle('body-md'),
|
|
77
|
+
'line-height': '{font.lineHeight.none}',
|
|
78
|
+
'font-variant-numeric': 'tabular-nums',
|
|
79
|
+
color: 'inherit',
|
|
80
|
+
'-moz-appearance': 'textfield',
|
|
81
|
+
},
|
|
82
|
+
unit: {
|
|
83
|
+
display: 'flex',
|
|
84
|
+
'align-items': 'center',
|
|
85
|
+
'flex-shrink': '0',
|
|
86
|
+
'padding-inline-end': '{space.3}',
|
|
87
|
+
color: '{color.fg-muted}',
|
|
88
|
+
...typeStyle('body-md'),
|
|
89
|
+
'line-height': '{font.lineHeight.none}',
|
|
90
|
+
'white-space': 'nowrap',
|
|
91
|
+
'user-select': 'none',
|
|
92
|
+
},
|
|
93
|
+
decrement: stepper,
|
|
94
|
+
increment: stepper,
|
|
95
|
+
'@states': {
|
|
96
|
+
hover: { root: { 'border-color': '{color.border-control-hover}' } },
|
|
97
|
+
focus: { root: { ...FOCUS_RING, 'border-color': '{color.border-action}' } },
|
|
98
|
+
invalid: { root: { 'border-color': '{color.border-danger}' } },
|
|
99
|
+
disabled: {
|
|
100
|
+
root: { 'background-color': '{color.bg-disabled}', color: '{color.fg-disabled}', 'border-color': '{color.border-disabled}', 'box-shadow': 'none', cursor: 'not-allowed' },
|
|
101
|
+
field: { cursor: 'not-allowed' },
|
|
102
|
+
unit: { color: '{color.fg-disabled}' },
|
|
103
|
+
decrement: { color: '{color.fg-disabled}', cursor: 'not-allowed', 'border-color': '{color.border-disabled}' },
|
|
104
|
+
increment: { color: '{color.fg-disabled}', cursor: 'not-allowed', 'border-color': '{color.border-disabled}' },
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
variants: {
|
|
109
|
+
size: { sm: sizeBlock('sm'), md: sizeBlock('md'), lg: sizeBlock('lg') },
|
|
110
|
+
variant: {
|
|
111
|
+
stepper: { root: {} },
|
|
112
|
+
plain: { decrement: { display: 'none' }, increment: { display: 'none' } },
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
extraCss: `
|
|
116
|
+
.cn-number-input .cn-number-input__field::placeholder { color: {color.fg-placeholder}; opacity: 1; }
|
|
117
|
+
.cn-number-input .cn-number-input__field::-webkit-outer-spin-button, .cn-number-input .cn-number-input__field::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
|
118
|
+
.cn-number-input:has([aria-invalid="true"]):focus-within, .cn-number-input[data-invalid]:focus-within { box-shadow: {shadow.focus-danger}; border-color: {color.border-danger}; }
|
|
119
|
+
.cn-number-input .cn-number-input__decrement:hover:not(:disabled), .cn-number-input .cn-number-input__increment:hover:not(:disabled) { background-color: {color.bg-subtle}; color: {color.fg-default}; }
|
|
120
|
+
.cn-number-input .cn-number-input__decrement:active:not(:disabled), .cn-number-input .cn-number-input__increment:active:not(:disabled) { background-color: {color.bg-muted}; }
|
|
121
|
+
.cn-number-input .cn-number-input__decrement:focus-visible, .cn-number-input .cn-number-input__increment:focus-visible { outline: none; box-shadow: inset 0 0 0 2px {color.border-action}; }
|
|
122
|
+
.cn-number-input .cn-number-input__decrement:disabled, .cn-number-input .cn-number-input__increment:disabled { color: {color.fg-disabled}; cursor: not-allowed; background-color: transparent; }`,
|
|
123
|
+
examples: [
|
|
124
|
+
ex('Stepper (default)', num({ label: 'Seats', value: '12', attrs: ' min="1" max="200" step="1"' }), 'Value on the left, −/+ at the end. Arrow keys step the value from the keyboard.'),
|
|
125
|
+
ex('With unit', num({ label: 'Pallet weight', value: '480', unit: 'kg', attrs: ' min="0" step="10"' }), 'The unit is a muted suffix, not part of the value.'),
|
|
126
|
+
ex('Plain (no stepper)', num({ variant: 'plain', label: 'Discount', value: '15', unit: '%', attrs: ' min="0" max="100"' }), 'For typed numbers where stepping makes no sense.'),
|
|
127
|
+
ex('Sizes', `<div style="display:flex;flex-direction:column;gap:var(--cn-space-3);max-width:240px">${num({ size: 'sm', label: 'Quantity', value: '3' })}${num({ size: 'md', label: 'Quantity', value: '3' })}${num({ size: 'lg', label: 'Quantity', value: '3' })}</div>`, 'Stepper buttons are 32 / 36 / 40px wide and always stretch to the field height.'),
|
|
128
|
+
ex('At the minimum', num({ label: 'Seats', value: '1', attrs: ' min="1"', stepAttrs: '' }).replace('aria-label="Decrease seats" tabindex="-1"', 'aria-label="Decrease seats" tabindex="-1" disabled'), 'The decrement button is disabled at min; the increment at max.'),
|
|
129
|
+
ex('Invalid', num({ label: 'Seats', value: '0', attrs: ' min="1" aria-invalid="true"' }), 'Pair with a Field error such as "Enter at least 1 seat".'),
|
|
130
|
+
ex('Disabled', num({ label: 'Seats', value: '40', unit: 'seats', attrs: ' disabled', stepAttrs: ' disabled' })),
|
|
131
|
+
],
|
|
132
|
+
rules: [
|
|
133
|
+
'Use type="number" with inputmode="decimal" and real min / max / step attributes; the stepper buttons call stepDown() / stepUp() on the input, never their own arithmetic.',
|
|
134
|
+
'Disable the decrement at min and the increment at max instead of clamping silently.',
|
|
135
|
+
'The unit is a suffix after the value ("kg", "%", "seats"); a currency with a selector is an InputGroup, not a NumberInput.',
|
|
136
|
+
'stepper for small-step adjustments (quantities, seats, days); plain for typed values (percentages, weights, prices). Do not add steppers to a value nobody increments by one.',
|
|
137
|
+
'Numbers are right-aligned only in tables; inside forms they stay left-aligned like every other field.',
|
|
138
|
+
'Width comes from the layout; cap it with max-width on the Field (120–240px) since numbers are short.',
|
|
139
|
+
'Sizes: md in forms; sm in tables and toolbars; lg on marketing screens.',
|
|
140
|
+
'Invalid must be accompanied by a Field error that states the allowed range ("Between 1 and 200").',
|
|
141
|
+
],
|
|
142
|
+
a11y: [
|
|
143
|
+
'The input has an accessible name (label for / aria-label); the stepper buttons say "Increase {name}" / "Decrease {name}".',
|
|
144
|
+
'Stepper buttons use tabindex="-1": keyboard users step with Arrow Up/Down (native) and never need to tab through the buttons.',
|
|
145
|
+
'Keep the native input so aria-valuemin / max come for free; when invalid set aria-invalid="true" and aria-describedby to the error id.',
|
|
146
|
+
'Focus is shown on the wrapper (:focus-within); a clicked stepper shows an inset ring so it stays visible inside the clipped wrapper.',
|
|
147
|
+
],
|
|
148
|
+
related: ['input', 'input-group', 'field', 'slider'],
|
|
149
|
+
};
|