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.
Files changed (153) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +153 -0
  3. package/THIRD_PARTY_NOTICES.md +55 -0
  4. package/bin/canon.js +6 -0
  5. package/lib/build-manifest.mjs +54 -0
  6. package/lib/build.mjs +69 -0
  7. package/lib/cli.mjs +273 -0
  8. package/lib/color.js +160 -0
  9. package/lib/components/_shared.mjs +93 -0
  10. package/lib/components/accordion.mjs +136 -0
  11. package/lib/components/account-card.mjs +166 -0
  12. package/lib/components/activity-feed.mjs +176 -0
  13. package/lib/components/activity-gauge.mjs +98 -0
  14. package/lib/components/agent-presence.mjs +156 -0
  15. package/lib/components/alert.mjs +154 -0
  16. package/lib/components/app-store-button.mjs +108 -0
  17. package/lib/components/avatar-group.mjs +128 -0
  18. package/lib/components/avatar.mjs +225 -0
  19. package/lib/components/badge-group.mjs +55 -0
  20. package/lib/components/badge.mjs +174 -0
  21. package/lib/components/banner.mjs +116 -0
  22. package/lib/components/breadcrumb.mjs +170 -0
  23. package/lib/components/button-group.mjs +114 -0
  24. package/lib/components/button.mjs +197 -0
  25. package/lib/components/card-header.mjs +115 -0
  26. package/lib/components/card.mjs +106 -0
  27. package/lib/components/carousel.mjs +182 -0
  28. package/lib/components/chart-frame.mjs +101 -0
  29. package/lib/components/checkbox.mjs +163 -0
  30. package/lib/components/close-button.mjs +49 -0
  31. package/lib/components/code.mjs +123 -0
  32. package/lib/components/color-picker.mjs +122 -0
  33. package/lib/components/combobox.mjs +210 -0
  34. package/lib/components/command-palette.mjs +226 -0
  35. package/lib/components/content-divider.mjs +104 -0
  36. package/lib/components/counter.mjs +86 -0
  37. package/lib/components/credit-card.mjs +126 -0
  38. package/lib/components/date-picker.mjs +202 -0
  39. package/lib/components/description-list.mjs +88 -0
  40. package/lib/components/dialog.mjs +140 -0
  41. package/lib/components/divider.mjs +115 -0
  42. package/lib/components/drawer.mjs +150 -0
  43. package/lib/components/empty-state.mjs +112 -0
  44. package/lib/components/featured-card.mjs +124 -0
  45. package/lib/components/featured-icon.mjs +67 -0
  46. package/lib/components/field.mjs +122 -0
  47. package/lib/components/file-dropzone.mjs +166 -0
  48. package/lib/components/filter-bar.mjs +179 -0
  49. package/lib/components/header-navigation.mjs +94 -0
  50. package/lib/components/icon-button.mjs +179 -0
  51. package/lib/components/index.mjs +52 -0
  52. package/lib/components/inline-cta.mjs +139 -0
  53. package/lib/components/input-group.mjs +164 -0
  54. package/lib/components/input.mjs +197 -0
  55. package/lib/components/kanban.mjs +179 -0
  56. package/lib/components/kbd.mjs +70 -0
  57. package/lib/components/kicker.mjs +79 -0
  58. package/lib/components/link.mjs +108 -0
  59. package/lib/components/list.mjs +117 -0
  60. package/lib/components/media-frame.mjs +119 -0
  61. package/lib/components/menu.mjs +190 -0
  62. package/lib/components/message.mjs +249 -0
  63. package/lib/components/metric-group.mjs +210 -0
  64. package/lib/components/mobile-header.mjs +173 -0
  65. package/lib/components/multi-select.mjs +218 -0
  66. package/lib/components/notification.mjs +175 -0
  67. package/lib/components/number-input.mjs +149 -0
  68. package/lib/components/page-header.mjs +68 -0
  69. package/lib/components/pagination.mjs +236 -0
  70. package/lib/components/pin-input.mjs +120 -0
  71. package/lib/components/popover.mjs +126 -0
  72. package/lib/components/progress-circle.mjs +115 -0
  73. package/lib/components/progress.mjs +93 -0
  74. package/lib/components/prose.mjs +96 -0
  75. package/lib/components/radio.mjs +160 -0
  76. package/lib/components/rating.mjs +93 -0
  77. package/lib/components/rich-text-editor.mjs +178 -0
  78. package/lib/components/section-header.mjs +62 -0
  79. package/lib/components/segmented-control.mjs +121 -0
  80. package/lib/components/select.mjs +163 -0
  81. package/lib/components/sidebar-nav.mjs +172 -0
  82. package/lib/components/sidebar.mjs +124 -0
  83. package/lib/components/skeleton.mjs +66 -0
  84. package/lib/components/slider.mjs +122 -0
  85. package/lib/components/social-button.mjs +119 -0
  86. package/lib/components/spinner.mjs +69 -0
  87. package/lib/components/stat.mjs +120 -0
  88. package/lib/components/stepper.mjs +228 -0
  89. package/lib/components/switch.mjs +159 -0
  90. package/lib/components/table.mjs +212 -0
  91. package/lib/components/tabs.mjs +207 -0
  92. package/lib/components/tag.mjs +196 -0
  93. package/lib/components/tags-input.mjs +146 -0
  94. package/lib/components/textarea.mjs +117 -0
  95. package/lib/components/timeline.mjs +135 -0
  96. package/lib/components/toast.mjs +115 -0
  97. package/lib/components/tooltip.mjs +86 -0
  98. package/lib/components/topbar.mjs +207 -0
  99. package/lib/components/tree-view.mjs +156 -0
  100. package/lib/components/video-player.mjs +189 -0
  101. package/lib/connect.mjs +110 -0
  102. package/lib/design-files.mjs +175 -0
  103. package/lib/distribution.mjs +109 -0
  104. package/lib/editor.js +940 -0
  105. package/lib/engine.js +339 -0
  106. package/lib/generators/agents.mjs +68 -0
  107. package/lib/generators/connection.md +58 -0
  108. package/lib/generators/css.mjs +2 -0
  109. package/lib/generators/designmd.mjs +324 -0
  110. package/lib/generators/documentation.css +82 -0
  111. package/lib/generators/documentation.html +81 -0
  112. package/lib/generators/documentation.js +96 -0
  113. package/lib/generators/documentation.mjs +35 -0
  114. package/lib/generators/dtcg.mjs +60 -0
  115. package/lib/generators/preview.css +279 -0
  116. package/lib/generators/preview.mjs +225 -0
  117. package/lib/generators/react.mjs +162 -0
  118. package/lib/generators/tailwind.mjs +114 -0
  119. package/lib/install.mjs +108 -0
  120. package/lib/lint.mjs +538 -0
  121. package/lib/mcp.mjs +268 -0
  122. package/lib/open.mjs +7 -0
  123. package/lib/patterns/_app.mjs +56 -0
  124. package/lib/patterns/app-shell.mjs +25 -0
  125. package/lib/patterns/audit-log.mjs +37 -0
  126. package/lib/patterns/auth-pages.mjs +44 -0
  127. package/lib/patterns/billing-page.mjs +39 -0
  128. package/lib/patterns/calendar-page.mjs +55 -0
  129. package/lib/patterns/dashboard-page.mjs +28 -0
  130. package/lib/patterns/files-page.mjs +30 -0
  131. package/lib/patterns/first-run.mjs +31 -0
  132. package/lib/patterns/form-layout.mjs +24 -0
  133. package/lib/patterns/inbox-page.mjs +58 -0
  134. package/lib/patterns/index.mjs +29 -0
  135. package/lib/patterns/list-detail-page.mjs +24 -0
  136. package/lib/patterns/mobile-app.mjs +46 -0
  137. package/lib/patterns/modal-flows.mjs +32 -0
  138. package/lib/patterns/onboarding-page.mjs +42 -0
  139. package/lib/patterns/permissions-page.mjs +38 -0
  140. package/lib/patterns/profile-page.mjs +32 -0
  141. package/lib/patterns/settings-page.mjs +24 -0
  142. package/lib/project.mjs +28 -0
  143. package/lib/serve.mjs +279 -0
  144. package/lib/system.mjs +210 -0
  145. package/lib/tokens/base.js +251 -0
  146. package/lib/tokens/canon-preset.mjs +67 -0
  147. package/lib/tokens/index.mjs +1 -0
  148. package/lib/tokens/legacy-canon.json +559 -0
  149. package/lib/tokens/presets.mjs +161 -0
  150. package/lib/tokens/resolve.mjs +1 -0
  151. package/lib/types.mjs +4 -0
  152. package/lib/version.mjs +2 -0
  153. package/package.json +54 -0
@@ -0,0 +1,178 @@
1
+ import { FOCUS_RING, RESET_BUTTON, TRANSITION_COLORS, ex, ICON, typeStyle } from "./_shared.mjs";
2
+ // Rich text editor: an Input-like frame (border-control, radius card, focus ring
3
+ // on :focus-within) with a wrapping toolbar of 32px ghost tool buttons grouped by
4
+ // hairlines, a block-type Select, a contenteditable area with a placeholder and
5
+ // prose styles, and an optional footer with a character count and actions.
6
+ const HAIRLINE = '{border.width.thin} solid {color.border-default}';
7
+ const decl = (d) => Object.entries(d).map(([k, v]) => `${k}: ${v};`).join(' ');
8
+ const glyph = (text, attrs = '') => `<svg class="cn-icon" viewBox="0 0 16 16" aria-hidden="true"><text x="8" y="12" text-anchor="middle" font-size="12" fill="currentColor" font-family="inherit" ${attrs}>${text}</text></svg>`;
9
+ const SVG = {
10
+ bold: glyph('B', 'font-weight="700"'),
11
+ italic: glyph('I', 'font-style="italic" font-weight="500"'),
12
+ underline: glyph('U', 'text-decoration="underline" font-weight="500"'),
13
+ h1: glyph('H1', 'font-size="9" font-weight="700"'),
14
+ h2: glyph('H2', 'font-size="9" font-weight="700"'),
15
+ bullets: '<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="M6 4h7M6 8h7M6 12h7"/><circle cx="3" cy="4" r=".9" fill="currentColor" stroke="none"/><circle cx="3" cy="8" r=".9" fill="currentColor" stroke="none"/><circle cx="3" cy="12" r=".9" fill="currentColor" stroke="none"/></svg>',
16
+ numbers: '<svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 4h7M6.5 8h7M6.5 12h7M2.25 3l1-.5v3M2 9.25a1 1 0 011.75.5c0 .8-1.75 1.25-1.75 2.25h2"/></svg>',
17
+ link: '<svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 9.5l3-3M7 11.25l-1.25 1.25a2.12 2.12 0 01-3-3L4 8.25M9 4.75l1.25-1.25a2.12 2.12 0 013 3L12 7.75"/></svg>',
18
+ image: '<svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2.5" y="3.5" width="11" height="9" rx="1.5"/><circle cx="6" cy="7" r="1"/><path d="M13.5 10.5l-3-3-4 4"/></svg>',
19
+ };
20
+ const tool = (svg, label, pressed, extra = '') => `<button type="button" class="cn-rich-text-editor__tool" aria-label="${label}"${pressed !== undefined ? ` aria-pressed="${pressed}"` : ''}${extra}>${svg}</button>`;
21
+ const group = (...tools) => `<div class="cn-rich-text-editor__tool-group" role="group">${tools.join('')}</div>`;
22
+ const SELECT = `<div class="cn-rich-text-editor__select"><div class="cn-select" data-variant="filled" data-size="sm"><select class="cn-select__field" aria-label="Block type"><option value="p" selected>Paragraph</option><option value="h1">Heading 1</option><option value="h2">Heading 2</option><option value="h3">Heading 3</option><option value="quote">Quote</option></select>${ICON.chevronDown.replace('cn-icon', 'cn-select__chevron')}</div></div>`;
23
+ const TOOLBAR = (full = true) => `<div class="cn-rich-text-editor__toolbar" role="toolbar" aria-label="Formatting">${full ? SELECT : ''}${group(tool(SVG.bold, 'Bold', false), tool(SVG.italic, 'Italic', false), tool(SVG.underline, 'Underline', false))}${full ? group(tool(SVG.h1, 'Heading 1', false), tool(SVG.h2, 'Heading 2', false)) : ''}${full ? group(tool(SVG.bullets, 'Bulleted list', true), tool(SVG.numbers, 'Numbered list', false)) : ''}${group(tool(SVG.link, 'Insert link'), full ? tool(SVG.image, 'Insert image') : '')}</div>`;
24
+ const CONTENT = `<h3>Release notes — September</h3><p>This month we shipped the new reporting workspace and a faster search index. Highlights:</p><ul><li>Custom dashboards for every workspace</li><li>Search results in under 200 ms</li><li>Exports to CSV and Google Sheets</li></ul><p>Read the <a href="#">full changelog</a> or reply to this note with questions.</p>`;
25
+ const editor = (variant, size, inner, attrs = '', width = '640px') => `<div class="cn-rich-text-editor" data-variant="${variant}" data-size="${size}"${attrs} style="width:100%;max-width:${width}">${inner}</div>`;
26
+ const area = (content, attrs = '') => `<div class="cn-rich-text-editor__area" contenteditable="true" role="textbox" aria-multiline="true" aria-label="Note" data-placeholder="Write something…"${attrs}>${content}</div>`;
27
+ export const richTextEditor = {
28
+ name: 'RichTextEditor',
29
+ slug: 'rich-text-editor',
30
+ category: 'forms',
31
+ description: 'A formatted-text field: the Input frame (control border, radius card, shadow-xs, focus ring on :focus-within) with a wrapping toolbar of 32px ghost tool buttons separated into groups by hairlines, a block-type Select, a contenteditable area with a placeholder and prose styles, and an optional footer with a character count and actions.',
32
+ usage: 'Use where people write more than a note: descriptions, announcements, email bodies, comments with formatting. Keep the toolbar to what the destination can render. For plain multi-line text use Textarea; for code use Code; for read-only formatted text use Prose.',
33
+ anatomy: [
34
+ { part: 'root', element: 'div', description: 'The frame: bg-surface, control border, radius card, shadow-xs, flex column, overflow hidden. Focus ring on :focus-within; data-invalid and data-disabled for those states.' },
35
+ { part: 'toolbar', element: 'div', description: 'role="toolbar": wrapping row of tool groups and the block-type select, 8px padding, bottom hairline.' },
36
+ { part: 'tool-group', element: 'div', description: 'A cluster of related tools (inline styles, headings, lists, inserts); every group after the first gets a hairline on its left.' },
37
+ { part: 'tool', element: 'button', description: '32px ghost icon button (28px in sm). Toggles carry aria-pressed; actions (link, image) do not.' },
38
+ { part: 'select', element: 'div', description: 'Wrapper for a filled sm Select of block types (Paragraph, Heading 1–3, Quote); 160px wide.', optional: true },
39
+ { part: 'area', element: 'div', description: 'The contenteditable (role="textbox", aria-multiline): min-height 160px, 16px padding, body-md, prose styles for headings, lists, links and quotes. Shows data-placeholder while empty.' },
40
+ { part: 'footer', element: 'div', description: 'Optional bottom row with a top hairline: character count on the left, actions on the right.', optional: true },
41
+ { part: 'count', element: 'span', description: 'Character count in body-xs tabular ("412 / 2,000"); fg-danger when over the limit.', optional: true },
42
+ { part: 'actions', element: 'div', description: 'Right-aligned row of sm Buttons (ghost Cancel, primary Save).', optional: true },
43
+ ],
44
+ props: {
45
+ variant: {
46
+ values: ['default', 'minimal', 'with-footer'],
47
+ default: 'default',
48
+ description: 'default = full toolbar (block type, inline styles, headings, lists, link, image) above a 160px area. minimal = inline styles and link only, no hairline under the toolbar, a 96px area: comments and short descriptions. with-footer = default plus a footer with a character count and Cancel/Save actions, for notes that are saved explicitly.',
49
+ },
50
+ size: {
51
+ values: ['sm', 'md'],
52
+ default: 'md',
53
+ description: 'md = 32px tools, 16px area padding, body-md text (forms and full pages). sm = 28px tools, 12px padding, body-sm text for side panels and comment boxes.',
54
+ },
55
+ },
56
+ states: {
57
+ focus: { selector: ':focus-within', description: 'Writing or using the toolbar: the border becomes action-colored and the 3px ring appears on the frame.', markup: 'native :focus-within on the root' },
58
+ invalid: { selector: '[data-invalid]', description: 'Validation failed (required, over the limit): danger border, danger ring while focused. Pair with a Field error.', markup: 'data-invalid on the root plus aria-invalid="true" on the area' },
59
+ disabled: { selector: '[data-disabled]', description: 'Read-only or locked: grey fill, disabled text, tools at 50% and inert, area not editable.', markup: 'data-disabled on the root plus contenteditable="false" on the area' },
60
+ toolHover: { selector: ' .cn-rich-text-editor__tool:hover', description: 'Pointer over a tool (extraCss): subtle fill, ink icon.', markup: 'native :hover on .cn-rich-text-editor__tool' },
61
+ toolPressed: { selector: ' .cn-rich-text-editor__tool[aria-pressed="true"]', description: 'A formatting toggle that applies to the selection (extraCss): subtle fill and ink icon at rest.', markup: 'aria-pressed="true" on .cn-rich-text-editor__tool' },
62
+ toolFocus: { selector: ' .cn-rich-text-editor__tool:focus-visible', description: 'Keyboard focus on a tool shows the focus ring (extraCss).', markup: 'native :focus-visible on .cn-rich-text-editor__tool' },
63
+ toolDisabled: { selector: ' .cn-rich-text-editor__tool:disabled', description: 'A tool that does not apply now (extraCss): 50% opacity, not-allowed.', markup: 'disabled on .cn-rich-text-editor__tool' },
64
+ },
65
+ base: {
66
+ root: {
67
+ display: 'flex',
68
+ 'flex-direction': 'column',
69
+ width: '100%',
70
+ 'min-width': '0',
71
+ 'background-color': '{color.bg-surface}',
72
+ border: '{border.width.thin} solid {color.border-control}',
73
+ 'border-radius': '{radius.card}',
74
+ 'box-shadow': '{shadow.xs}',
75
+ color: '{color.fg-default}',
76
+ overflow: 'hidden',
77
+ ...TRANSITION_COLORS,
78
+ },
79
+ toolbar: { display: 'flex', 'flex-wrap': 'wrap', 'align-items': 'center', gap: '{space.1}', padding: '{space.2}', 'border-bottom': HAIRLINE },
80
+ 'tool-group': { display: 'flex', 'align-items': 'center', gap: '{space.0.5}' },
81
+ tool: {
82
+ ...RESET_BUTTON,
83
+ display: 'inline-flex',
84
+ 'align-items': 'center',
85
+ 'justify-content': 'center',
86
+ width: '{space.8}',
87
+ height: '{space.8}',
88
+ 'border-radius': '{radius.md}',
89
+ color: '{color.fg-muted}',
90
+ ...TRANSITION_COLORS,
91
+ },
92
+ select: { display: 'inline-flex', flex: '0 0 auto', width: '160px', 'max-width': '100%' },
93
+ area: {
94
+ 'min-height': '{space.40}',
95
+ padding: '{space.4}',
96
+ ...typeStyle('body-md'),
97
+ color: '{color.fg-default}',
98
+ outline: 'none',
99
+ 'overflow-wrap': 'anywhere',
100
+ 'caret-color': '{color.bg-action}',
101
+ cursor: 'text',
102
+ },
103
+ footer: { display: 'flex', 'align-items': 'center', 'justify-content': 'space-between', gap: '{space.3}', padding: '{space.2} {space.3}', 'border-top': HAIRLINE, ...typeStyle('body-xs'), color: '{color.fg-muted}' },
104
+ count: { 'font-variant-numeric': 'tabular-nums', 'white-space': 'nowrap' },
105
+ actions: { display: 'flex', 'align-items': 'center', gap: '{space.2}' },
106
+ '@states': {
107
+ focus: { root: { ...FOCUS_RING, 'border-color': '{color.border-action}' } },
108
+ invalid: { root: { 'border-color': '{color.border-danger}' }, count: { color: '{color.fg-danger}' } },
109
+ disabled: { root: { 'background-color': '{color.bg-disabled}', color: '{color.fg-disabled}', 'border-color': '{color.border-disabled}', 'box-shadow': 'none' }, area: { color: '{color.fg-disabled}', cursor: 'not-allowed' } },
110
+ },
111
+ },
112
+ variants: {
113
+ variant: {
114
+ default: { root: {} },
115
+ minimal: { toolbar: { 'border-bottom': '0', 'padding-bottom': '0' }, area: { 'min-height': '{space.24}' } },
116
+ 'with-footer': { root: {} },
117
+ },
118
+ size: {
119
+ sm: {
120
+ toolbar: { padding: '{space.1.5}', gap: '{space.0.5}' },
121
+ tool: { width: '{space.7}', height: '{space.7}' },
122
+ select: { width: '140px' },
123
+ area: { padding: '{space.3}', 'min-height': '{space.32}', ...typeStyle('body-sm') },
124
+ footer: { padding: '{space.1.5} {space.2.5}' },
125
+ },
126
+ md: { root: {} },
127
+ },
128
+ },
129
+ extraCss: `
130
+ .cn-rich-text-editor__tool .cn-icon { width: {size.icon.md}; height: {size.icon.md}; }
131
+ .cn-rich-text-editor__tool:hover:not(:disabled) { background-color: {color.bg-subtle}; color: {color.fg-default}; }
132
+ .cn-rich-text-editor__tool[aria-pressed="true"] { background-color: {color.bg-subtle}; color: {color.fg-default}; }
133
+ .cn-rich-text-editor__tool:focus-visible { outline: none; box-shadow: ${FOCUS_RING['box-shadow']}; }
134
+ .cn-rich-text-editor__tool:disabled { opacity: {opacity.disabled}; cursor: not-allowed; }
135
+ .cn-rich-text-editor__tool-group + .cn-rich-text-editor__tool-group { margin-inline-start: {space.1}; padding-inline-start: {space.1.5}; border-inline-start: ${HAIRLINE}; }
136
+ .cn-rich-text-editor__select + .cn-rich-text-editor__tool-group { margin-inline-start: {space.1}; padding-inline-start: {space.1.5}; border-inline-start: ${HAIRLINE}; }
137
+ .cn-rich-text-editor__area:empty::before { content: attr(data-placeholder); color: {color.fg-placeholder}; pointer-events: none; }
138
+ .cn-rich-text-editor__area > * + * { margin-top: {space.3}; }
139
+ .cn-rich-text-editor__area h1 { ${decl(typeStyle('heading-lg'))} }
140
+ .cn-rich-text-editor__area h2 { ${decl(typeStyle('heading-md'))} }
141
+ .cn-rich-text-editor__area h3 { ${decl(typeStyle('heading-sm'))} }
142
+ .cn-rich-text-editor__area ul { list-style: disc; padding-inline-start: {space.5}; }
143
+ .cn-rich-text-editor__area ol { list-style: decimal; padding-inline-start: {space.5}; }
144
+ .cn-rich-text-editor__area li + li { margin-top: {space.1}; }
145
+ .cn-rich-text-editor__area a { color: {color.fg-link}; text-decoration: underline; text-underline-offset: 3px; text-decoration-color: {color.border-strong}; }
146
+ .cn-rich-text-editor__area a:hover { color: {color.fg-link-hover}; text-decoration-color: currentColor; }
147
+ .cn-rich-text-editor__area strong { font-weight: {font.weight.semibold}; }
148
+ .cn-rich-text-editor__area blockquote { border-inline-start: {border.width.medium} solid {color.border-strong}; padding-inline-start: {space.3}; color: {color.fg-muted}; }
149
+ .cn-rich-text-editor__area img { max-width: 100%; border-radius: {radius.md}; }
150
+ .cn-rich-text-editor[data-invalid]:focus-within { border-color: {color.border-danger}; box-shadow: {shadow.focus-danger}; }
151
+ .cn-rich-text-editor[data-disabled] .cn-rich-text-editor__tool { opacity: {opacity.disabled}; pointer-events: none; }
152
+ .cn-rich-text-editor[data-disabled] .cn-rich-text-editor__toolbar, .cn-rich-text-editor[data-disabled] .cn-rich-text-editor__footer { border-color: {color.border-disabled}; }`,
153
+ examples: [
154
+ ex('Default with content', editor('default', 'md', `${TOOLBAR()}${area(CONTENT)}`), 'Block-type select, inline styles, headings, lists, link and image. The bulleted-list tool is pressed because the caret sits in the list.'),
155
+ ex('Minimal, small, empty', editor('minimal', 'sm', `${TOOLBAR(false)}${area('')}`, '', '480px'), 'Inline styles and link only; the placeholder comes from data-placeholder.'),
156
+ ex('With footer (count and actions)', editor('with-footer', 'md', `${TOOLBAR()}${area(CONTENT)}<div class="cn-rich-text-editor__footer"><span class="cn-rich-text-editor__count" aria-live="polite">412 / 2,000</span><div class="cn-rich-text-editor__actions"><button type="button" class="cn-button" data-variant="ghost" data-size="sm"><span class="cn-button__label">Cancel</span></button><button type="button" class="cn-button" data-variant="primary" data-size="sm"><span class="cn-button__label">Save note</span></button></div></div>`), 'The count turns fg-danger over the limit and the frame becomes invalid.'),
157
+ ex('Invalid and disabled', `<div style="display:flex;flex-direction:column;gap:var(--cn-space-4);width:100%;max-width:640px">${editor('with-footer', 'sm', `${TOOLBAR(false)}${area('<p>Thanks for the update, looping in the finance team so they can confirm the invoice dates before we publish the notes to every workspace and send the customer digest that goes out on Friday morning.</p>', ' aria-invalid="true"')}<div class="cn-rich-text-editor__footer"><span class="cn-rich-text-editor__count" aria-live="polite">212 / 200</span></div>`, ' data-invalid', '640px')}${editor('minimal', 'sm', `${TOOLBAR(false)}${area('<p>This announcement was published on Sep 9 and can no longer be edited.</p>', ' contenteditable="false"')}`, ' data-disabled', '640px')}</div>`, 'data-invalid draws the danger border; data-disabled greys the frame and makes the tools inert.'),
158
+ ],
159
+ rules: [
160
+ 'Offer only what the destination renders: an email body gets inline styles and links; a document gets headings and lists too. Never a toolbar with twenty tools.',
161
+ 'Group tools by kind (block type, inline, headings, lists, inserts) with the hairline between groups; keep the same order in every editor of the product.',
162
+ 'Toggles (bold, italic, lists) show aria-pressed for the current selection; actions (link, image) open a small Dialog or Popover, never a browser prompt.',
163
+ 'The area starts at 160px (96px minimal) and grows with content; never an inner scrollbar unless the editor sits in a fixed-height panel.',
164
+ 'Placeholders are an example of the expected content ("Write the release notes…"), not a label; the label lives in the Field.',
165
+ 'Show a character count only with a hard limit; over the limit the count turns danger and the frame becomes invalid, but typing is not blocked.',
166
+ 'Paste keeps only the formatting the toolbar offers; strip colors, fonts and sizes.',
167
+ 'Save explicitly with a footer (with-footer) when the text is a record; autosave with a status line when it is a draft. Never both.',
168
+ 'sm inside comment boxes and side panels; md in forms and full pages.',
169
+ ],
170
+ a11y: [
171
+ 'The area is role="textbox" with aria-multiline="true" and an accessible name (a Field label or aria-label); set aria-invalid and aria-describedby with the error when invalid.',
172
+ 'The toolbar is role="toolbar" with an aria-label; Tab enters it once, Left/Right move between tools, and the tools are real <button>s with aria-label.',
173
+ 'Formatting toggles expose aria-pressed; the block-type Select is a native <select> so it works everywhere.',
174
+ 'Keyboard shortcuts (Cmd/Ctrl+B, I, U, K) mirror the toolbar and are listed in a Tooltip on each tool.',
175
+ 'Announce the character count with aria-live="polite" and say "over the limit" in the Field error, not only in color.',
176
+ ],
177
+ related: ['textarea', 'field', 'select', 'icon-button', 'prose', 'code'],
178
+ };
@@ -0,0 +1,62 @@
1
+ import { ex, ICON, typeStyle } from "./_shared.mjs";
2
+ const btn = (v, label, size = 'md') => `<button type="button" class="cn-button" data-variant="${v}" data-size="${size}"><span class="cn-button__label">${label}</span></button>`;
3
+ export const sectionHeader = {
4
+ name: 'SectionHeader',
5
+ slug: 'section-header',
6
+ category: 'layout',
7
+ description: 'Heading row of a section inside a page or a card: title, supporting text, optional badge, tabs or search, and section-level actions. Smaller than PageHeader.',
8
+ usage: 'Above tables, forms, lists and settings groups. Use inside Card as its header row when the card holds a data set. Do not use for the page title (PageHeader).',
9
+ anatomy: [
10
+ { part: 'root', element: 'div', description: 'Flex row with a bottom hairline (divider prop).' },
11
+ { part: 'content', element: 'div', description: 'Title + description column.' },
12
+ { part: 'title', element: 'h2', description: 'heading-sm (18/28 semibold) with optional inline badge.' },
13
+ { part: 'badge', element: 'span', description: 'Optional count/status badge after the title (Badge markup).', optional: true },
14
+ { part: 'description', element: 'p', description: 'body-md in fg-muted.' },
15
+ { part: 'actions', element: 'div', description: 'Right-aligned buttons (sm or md).' },
16
+ { part: 'tabs', element: 'div', description: 'Optional Tabs (pill/button variant) in the actions slot or below.', optional: true },
17
+ { part: 'search', element: 'div', description: 'Optional search Input (width 280) in the actions slot.', optional: true },
18
+ ],
19
+ props: {
20
+ variant: { values: ['default', 'with-badge', 'with-tabs', 'with-search', 'card'], default: 'default', description: 'default; with-badge = count/status next to the title; with-tabs = filter tabs on the right; with-search = search input on the right; card = padding for use as a Card header (20/24px) with the divider.' },
21
+ divider: { values: ['yes', 'no'], default: 'yes', description: 'Hairline under the header.' },
22
+ },
23
+ states: {},
24
+ base: {
25
+ root: { display: 'flex', 'flex-wrap': 'wrap', 'align-items': 'flex-start', 'justify-content': 'space-between', gap: '{space.4}', 'padding-bottom': '{space.5}' },
26
+ content: { display: 'flex', 'flex-direction': 'column', gap: '{space.1}', 'min-width': '0', flex: '1 1 280px' },
27
+ title: { ...typeStyle('heading-sm'), color: '{color.fg-default}', display: 'flex', 'align-items': 'center', gap: '{space.2}', 'flex-wrap': 'wrap' },
28
+ badge: { 'flex-shrink': '0' },
29
+ description: { ...typeStyle('body-md'), color: '{color.fg-muted}' },
30
+ actions: { display: 'flex', 'align-items': 'center', gap: '{space.3}', 'flex-shrink': '0', 'flex-wrap': 'wrap' },
31
+ tabs: { 'flex-shrink': '0' },
32
+ search: { width: '280px', 'max-width': '100%' },
33
+ },
34
+ variants: {
35
+ variant: {
36
+ default: { root: {} },
37
+ 'with-badge': { root: {} },
38
+ 'with-tabs': { root: {} },
39
+ 'with-search': { root: {} },
40
+ card: { root: { padding: '{space.5} {space.6}' } },
41
+ },
42
+ divider: { yes: { root: { 'border-bottom': '{border.width.thin} solid {color.border-default}' } }, no: { root: { 'padding-bottom': '0' } } },
43
+ },
44
+ extraCss: `
45
+ .cn-section-header[data-variant="card"][data-divider="no"] { padding-bottom: {space.5}; }
46
+ @media (max-width: {breakpoint.md}) { .cn-section-header .cn-section-header__actions { width: 100%; } .cn-section-header .cn-section-header__search { width: 100%; } }`,
47
+ examples: [
48
+ ex('Default', `<div class="cn-section-header" data-variant="default" data-divider="yes"><div class="cn-section-header__content"><h2 class="cn-section-header__title">Personal info</h2><p class="cn-section-header__description">Update your photo and personal details here.</p></div><div class="cn-section-header__actions">${btn('outline', 'Cancel')}${btn('primary', 'Save')}</div></div>`),
49
+ ex('With badge', `<div class="cn-section-header" data-variant="with-badge" data-divider="yes"><div class="cn-section-header__content"><h2 class="cn-section-header__title">Team members <span class="cn-section-header__badge cn-badge" data-tone="accent" data-variant="soft" data-size="sm">100 users</span></h2><p class="cn-section-header__description">Manage your team members and their account permissions here.</p></div><div class="cn-section-header__actions">${btn('outline', 'Download all')}${btn('primary', 'Add user')}</div></div>`),
50
+ ex('With tabs', `<div class="cn-section-header" data-variant="with-tabs" data-divider="yes"><div class="cn-section-header__content"><h2 class="cn-section-header__title">Vendor movements</h2><p class="cn-section-header__description">Keep track of vendor and their security ratings.</p></div><div class="cn-section-header__tabs cn-tabs" data-variant="pill" data-size="sm" role="tablist"><button type="button" class="cn-tabs__tab" role="tab" aria-selected="true">12 months</button><button type="button" class="cn-tabs__tab" role="tab" aria-selected="false">30 days</button><button type="button" class="cn-tabs__tab" role="tab" aria-selected="false">7 days</button><button type="button" class="cn-tabs__tab" role="tab" aria-selected="false">24 hours</button></div></div>`),
51
+ ex('With search', `<div class="cn-section-header" data-variant="with-search" data-divider="yes"><div class="cn-section-header__content"><h2 class="cn-section-header__title">Invoices</h2><p class="cn-section-header__description">Pick an account plan that fits your workflow.</p></div><div class="cn-section-header__actions"><div class="cn-section-header__search cn-input" data-variant="default" data-size="md">${ICON.search.replace('cn-icon', 'cn-input__icon')}<input class="cn-input__field" type="search" placeholder="Search" aria-label="Search invoices"></div>${btn('outline', 'Filters')}</div></div>`),
52
+ ex('As a card header', `<section class="cn-card" data-variant="default" data-padding="none" style="max-width:640px"><div class="cn-section-header" data-variant="card" data-divider="yes"><div class="cn-section-header__content"><h2 class="cn-section-header__title">Notifications</h2><p class="cn-section-header__description">Choose what you want to be notified about.</p></div><div class="cn-section-header__actions">${btn('ghost', 'Reset', 'sm')}</div></div><div class="cn-card__body" style="padding:var(--cn-space-6)"><p class="cn-text-body-md">Card content</p></div></section>`),
53
+ ],
54
+ rules: [
55
+ 'Title is an h2 (or h3 inside a card that already has an h2). Never a page title.',
56
+ 'Description one sentence; omit rather than repeat the title.',
57
+ 'Actions on the right are sm inside cards, md on the page. At most one primary.',
58
+ '20px padding below, hairline divider, then 24px (space.6) to the content.',
59
+ ],
60
+ a11y: ['Headings keep document order: h1 (page) → h2 (section) → h3 (card inside a section).', 'Filter tabs use role="tablist" only when they switch panels; otherwise use a SegmentedControl.'],
61
+ related: ['page-header', 'card', 'tabs', 'badge', 'input'],
62
+ };
@@ -0,0 +1,121 @@
1
+ import { RESET_BUTTON, ex, ICON, typeStyle } from "./_shared.mjs";
2
+ // the reference "button-border" rail: a gray-50 track with a 1px gray-200 ring and 4px padding,
3
+ // radius 10 (sm) / 12 (md), items with 14px semibold text (16px on md in the reference tabs — we keep
4
+ // 14px so the rail matches ButtonGroup), radius 6 / 8, gray-600 → the selected item lifts on white
5
+ // with shadow-sm and the default ink. Total heights 36 (sm) / 40 (md) so it sits next to a ButtonGroup.
6
+ const TRANSITION_FAST = {
7
+ 'transition-property': 'background-color, color, box-shadow, 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 icon = (name) => ICON[name].replace('cn-icon', 'cn-segmented-control__icon');
13
+ const item = (label, attrs = '', ic = '') => `<button type="button" role="radio" class="cn-segmented-control__item" ${attrs}>${ic}${label}</button>`;
14
+ const rail = (attrs, items, size = 'md', variant = 'default') => `<div class="cn-segmented-control" role="radiogroup" data-size="${size}" data-variant="${variant}" ${attrs}>${items}</div>`;
15
+ export const segmentedControl = {
16
+ name: 'SegmentedControl',
17
+ slug: 'segmented-control',
18
+ category: 'forms',
19
+ description: 'Single-choice group drawn as one rail: a gray-50 track with a hairline ring and 4px padding where the selected item lifts on a white surface with shadow-sm. Same heights (36 / 40px) and 14px semibold type as ButtonGroup, so the two sit side by side in a toolbar.',
20
+ usage: 'Two to five mutually exclusive views or modes whose change is immediate (List / Board, Day / Week / Month, Monthly / Annual). For form data use Radio; for navigation between pages use Tabs; for actions on one object use ButtonGroup; for more than five options use Select.',
21
+ anatomy: [
22
+ { part: 'root', element: 'div', description: 'The rail: inline-flex, gray-50 fill, 1px gray-200 inset ring, 4px padding, radius 10 (sm) / 12 (md), 4px between items. role="radiogroup" with an aria-label.' },
23
+ { part: 'item', element: 'button', description: 'One option: <button role="radio" aria-checked>. 28px (sm) / 32px (md) tall, padding 10px, radius 6 / 8, 14px semibold gray-600 until selected.' },
24
+ { part: 'icon', element: 'svg', description: 'Optional leading icon (16px on sm, 20px on md) inside an item, gray-500, or the only content with aria-label on the item. Decorative.', optional: true },
25
+ ],
26
+ props: {
27
+ size: { values: ['sm', 'md'], default: 'md', description: 'md = 40px rail with 32px items (matches md controls and a md ButtonGroup); sm = 36px rail with 28px items for cards, tables and popovers.' },
28
+ variant: { values: ['default', 'full'], default: 'default', description: 'default = items at their content width, the rail hugs them; full = the rail fills its container and every item takes an equal share (mobile sheets, narrow cards).' },
29
+ },
30
+ states: {
31
+ selected: { selector: '[aria-checked="true"], &[data-selected]', description: 'On the __item (not the root): white surface, default ink, shadow-sm; the icon darkens. Exactly one per group.', markup: 'aria-checked="true" on the item button (data-selected also accepted)' },
32
+ hover: { selector: ':hover:not(:disabled)', description: 'On an unselected __item: text turns to the default ink and the icon darkens; no background change.', markup: 'native :hover on the item' },
33
+ focus: { selector: ':focus-visible', description: 'On the __item: the 4px brand ring on the item itself, raised above its neighbours.', markup: 'native :focus-visible on the item' },
34
+ 'item-disabled': { selector: ':disabled', description: 'On the __item: 50% opacity, cursor not-allowed; stays visible so the option is known to exist.', markup: 'disabled on the item button' },
35
+ disabled: { selector: '[data-disabled]', description: 'Whole group at 50% opacity and not interactive.', markup: 'data-disabled on the root' },
36
+ },
37
+ base: {
38
+ root: {
39
+ display: 'inline-flex',
40
+ 'align-items': 'center',
41
+ gap: '{space.1}',
42
+ 'max-width': '100%',
43
+ padding: '{space.1}',
44
+ 'background-color': '{color.bg-subtle}',
45
+ 'box-shadow': ring('{color.border-default}'),
46
+ 'border-radius': 'calc({radius.xl} - 2px)',
47
+ },
48
+ item: {
49
+ ...RESET_BUTTON,
50
+ display: 'inline-flex',
51
+ 'align-items': 'center',
52
+ 'justify-content': 'center',
53
+ 'flex-shrink': '0',
54
+ 'white-space': 'nowrap',
55
+ ...typeStyle('label-md'),
56
+ color: '{color.fg-muted}',
57
+ 'border-radius': '{radius.md}',
58
+ ...TRANSITION_FAST,
59
+ },
60
+ icon: { 'flex-shrink': '0', display: 'block', color: '{color.fg-subtle}', transition: 'inherit' },
61
+ '@states': {
62
+ disabled: { root: { opacity: '{opacity.disabled}', 'pointer-events': 'none' } },
63
+ },
64
+ },
65
+ variants: {
66
+ size: {
67
+ sm: {
68
+ root: { 'border-radius': 'calc({radius.xl} - 2px)' },
69
+ item: { height: 'calc({size.control.sm} - {space.2})', 'padding-inline': '{space.2.5}', gap: '{space.1}', 'border-radius': '{radius.md}' },
70
+ icon: { width: '{size.icon.sm}', height: '{size.icon.sm}' },
71
+ },
72
+ md: {
73
+ root: { 'border-radius': '{radius.xl}' },
74
+ item: { height: 'calc({size.control.md} - {space.2})', 'padding-inline': '{space.2.5}', gap: '{space.1.5}', 'border-radius': '{radius.lg}' },
75
+ icon: { width: '{size.icon.md}', height: '{size.icon.md}' },
76
+ },
77
+ },
78
+ variant: {
79
+ default: { root: {} },
80
+ full: { root: { display: 'flex', width: '100%' }, item: { flex: '1 1 0', 'min-width': '0' } },
81
+ },
82
+ },
83
+ extraCss: `
84
+ .cn-segmented-control .cn-segmented-control__item[aria-checked="true"], .cn-segmented-control .cn-segmented-control__item[data-selected] { background-color: {color.bg-surface}; color: {color.fg-default}; box-shadow: {shadow.sm}; }
85
+ .cn-segmented-control .cn-segmented-control__item[aria-checked="true"] .cn-segmented-control__icon, .cn-segmented-control .cn-segmented-control__item[data-selected] .cn-segmented-control__icon { color: {color.fg-muted}; }
86
+ .cn-segmented-control .cn-segmented-control__item:hover:not(:disabled):not([aria-checked="true"]) { color: {color.fg-default}; }
87
+ .cn-segmented-control .cn-segmented-control__item:hover:not(:disabled):not([aria-checked="true"]) .cn-segmented-control__icon { color: {color.fg-muted}; }
88
+ .cn-segmented-control .cn-segmented-control__item:focus-visible { position: relative; z-index: {z.raised}; outline: none; box-shadow: {shadow.focus}; }
89
+ .cn-segmented-control .cn-segmented-control__item[aria-checked="true"]:focus-visible { box-shadow: {shadow.sm}, {shadow.focus}; }
90
+ .cn-segmented-control .cn-segmented-control__item:disabled { opacity: {opacity.disabled}; cursor: not-allowed; }
91
+ .cn-segmented-control .cn-segmented-control__item[aria-checked="true"] + .cn-segmented-control__item { margin-inline-start: 0; }`,
92
+ examples: [
93
+ ex('Default', rail('aria-label="View"', item('List', 'aria-checked="true" tabindex="0"') + item('Board', 'aria-checked="false" tabindex="-1"') + item('Timeline', 'aria-checked="false" tabindex="-1"')), '40px rail: gray-50 with a hairline ring and 4px padding; the selected item is white with shadow-sm.'),
94
+ ex('With icons', rail('aria-label="Inbox view"', item('Inbox', 'aria-checked="true" tabindex="0"', icon('inbox')) + item('Calendar', 'aria-checked="false" tabindex="-1"', icon('calendar')) + item('People', 'aria-checked="false" tabindex="-1"', icon('user'))), '20px gray-500 icons, 6px before the label; darker on the selected item.'),
95
+ ex('Icon-only', rail('aria-label="Layout"', item('', 'aria-checked="true" aria-label="List layout" tabindex="0"', icon('menu')) + item('', 'aria-checked="false" aria-label="Grid layout" tabindex="-1"', icon('home')) + item('', 'aria-checked="false" aria-label="Settings layout" tabindex="-1"', icon('settings'))), 'Every icon-only item carries an aria-label.'),
96
+ ex('Full width', rail('aria-label="Billing cycle" style="max-width:360px"', item('Monthly', 'aria-checked="false" tabindex="-1"') + item('Annual · save 20%', 'aria-checked="true" tabindex="0"'), 'md', 'full')),
97
+ ex('Small', rail('aria-label="Range"', item('Day', 'aria-checked="false" tabindex="-1"') + item('Week', 'aria-checked="true" tabindex="0"') + item('Month', 'aria-checked="false" tabindex="-1"') + item('Quarter', 'aria-checked="false" tabindex="-1"'), 'sm'), '36px rail with 28px items and radius 10.'),
98
+ ex('Disabled item', rail('aria-label="Export format"', item('CSV', 'aria-checked="true" tabindex="0"') + item('Excel', 'aria-checked="false" tabindex="-1"') + item('PDF', 'aria-checked="false" tabindex="-1" disabled aria-disabled="true"')), 'PDF export is a Scale feature: the item stays visible at 50%.'),
99
+ ex('Disabled group', rail('aria-label="View" data-disabled aria-disabled="true"', item('List', 'aria-checked="true" tabindex="-1" disabled') + item('Board', 'aria-checked="false" tabindex="-1" disabled'))),
100
+ ],
101
+ recipes: [
102
+ ex('Next to a ButtonGroup in a toolbar', `<div style="display:flex;align-items:center;gap:var(--cn-space-3)">${rail('aria-label="View"', item('List', 'aria-checked="true" tabindex="0"') + item('Board', 'aria-checked="false" tabindex="-1"'))}<div class="cn-button-group" data-variant="attached" data-orientation="horizontal" role="group" aria-label="Range"><button type="button" class="cn-button" data-variant="outline" data-size="md" aria-pressed="true"><span class="cn-button__label">30 days</span></button><button type="button" class="cn-button" data-variant="outline" data-size="md" aria-pressed="false"><span class="cn-button__label">7 days</span></button></div></div>`, 'Both are 40px tall with 14px semibold labels; the rail is for a view, the group for a range.'),
103
+ ],
104
+ rules: [
105
+ 'Use for 2–5 mutually exclusive views or modes whose change is immediate. For form data use Radio; for page navigation use Tabs; for actions on one object use ButtonGroup.',
106
+ 'Labels are one or two words in sentence case, or an icon with aria-label. Keep every item in the same voice: all nouns or all icons, never mixed.',
107
+ 'One item is always selected; there is no empty state and no toggling off.',
108
+ 'variant="full" only inside narrow containers (mobile sheets, cards ≤ 400px) so each item stretches equally; default keeps items at content width.',
109
+ 'md (40px) matches md controls in a toolbar; sm (36px) sits inside tables, cards and popovers. Never place a SegmentedControl taller than the controls beside it.',
110
+ 'Maximum five items. Beyond that the choice becomes a Select or Tabs.',
111
+ 'Do not mix action buttons into the rail; put related actions in a ButtonGroup beside it, 12px away.',
112
+ ],
113
+ a11y: [
114
+ 'The root has role="radiogroup" with aria-label (or aria-labelledby); each item is a <button role="radio" aria-checked="true|false">.',
115
+ 'Roving tabindex: only the selected item has tabindex="0"; Left/Right (and Up/Down) move selection and check the new item, Home/End jump.',
116
+ 'Icon-only items require aria-label; the svg is aria-hidden.',
117
+ 'Disabled item: disabled plus aria-disabled="true"; a disabled group sets data-disabled and aria-disabled on the root.',
118
+ 'Selection change must not move focus or navigate. If it changes the URL, use Tabs instead.',
119
+ ],
120
+ related: ['radio', 'tabs', 'button-group', 'select'],
121
+ };
@@ -0,0 +1,163 @@
1
+ import { CONTROL, SIZE_PROP, ex, ICON, typeStyle } from "./_shared.mjs";
2
+ // the reference select trigger, measure for measure: 36 / 40 / 44px, padding 8px 10px 8px 12px (sm),
3
+ // 8×12 (md), 10×14 (lg); 14px text (16px on lg; the reference uses 16px on md too — we keep 14px
4
+ // as the default UI size); chevron 16px (20px on lg) in gray-500 at the trailing edge; 1px inset
5
+ // gray-300 ring under shadow-xs, radius 8; focus or open = brand ring; disabled = 50% opacity.
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
+ /** pl/pr = leading/trailing padding, icon = leading icon, chevron = trailing chevron size. */
18
+ const SIZE = {
19
+ sm: { pl: '{space.3}', pr: '{space.2.5}', icon: '{size.icon.sm}', chevron: '{size.icon.sm}', lh: '{type.body-md.lineHeight}' },
20
+ md: { pl: '{space.3}', pr: '{space.3}', icon: '{size.icon.md}', chevron: '{size.icon.sm}', lh: '{type.body-md.lineHeight}' },
21
+ lg: { pl: '{space.3.5}', pr: '{space.3.5}', icon: '{size.icon.md}', chevron: '{size.icon.md}', lh: '{type.body-lg.lineHeight}' },
22
+ };
23
+ const sizeBlock = (s) => ({
24
+ root: { height: CONTROL[s].height },
25
+ field: {
26
+ 'padding-inline-start': SIZE[s].pl,
27
+ 'padding-inline-end': `calc(${SIZE[s].pr} + ${SIZE[s].chevron} + {space.2})`,
28
+ 'font-size': CONTROL[s].font,
29
+ 'line-height': SIZE[s].lh,
30
+ },
31
+ icon: { width: SIZE[s].icon, height: SIZE[s].icon, 'inset-inline-start': SIZE[s].pl },
32
+ chevron: { width: SIZE[s].chevron, height: SIZE[s].chevron, 'inset-inline-end': SIZE[s].pr },
33
+ });
34
+ const chevron = ICON.chevronDown.replace('cn-icon', 'cn-select__chevron');
35
+ const icon = (name) => ICON[name].replace('cn-icon', 'cn-select__icon');
36
+ const sel = (attrs, options, size = 'md', variant = 'default', leading = '', style = 'max-width:280px') => `<div class="cn-select" data-variant="${variant}" data-size="${size}" style="${style}">${leading}<select class="cn-select__field" ${attrs}>${options}</select>${chevron}</div>`;
37
+ const PEOPLE = '<option value="maya" selected>Maya Chen</option><option value="daniel">Daniel Costa</option><option value="sofia">Sofia Almeida</option><option value="lucas">Lucas Ferreira</option><option value="aisha">Aisha Khan</option><option value="noah">Noah Berg</option>';
38
+ export const select = {
39
+ name: 'Select',
40
+ slug: 'select',
41
+ category: 'forms',
42
+ description: 'Native <select> dressed as the reference select trigger: the Input ring, radius 8 and heights (36 / 40 / 44px), an optional leading icon and a gray-500 chevron at the trailing edge. The dropdown itself is the operating system menu.',
43
+ usage: 'Choose one option from a short, known list (5–15 items: owner, plan, status, sort order). Under 5 options use Radio or SegmentedControl; for long or searchable lists, icons or supporting text in the rows, use Combobox; for multiple choices use Checkboxes. Pair with Field for label and hint.',
44
+ anatomy: [
45
+ { part: 'root', element: 'div', description: 'Wrapper that draws the ring, background, radius, shadow-xs and the focus ring (:focus-within). Position: relative for the icon and chevron.' },
46
+ { part: 'icon', element: 'svg', description: 'Optional leading icon (16px on sm, 20px on md and lg) absolutely placed at the padding edge in gray-500; the text starts 8px after it. Decorative, pointer-events: none.', optional: true },
47
+ { part: 'field', element: 'select', description: 'The native <select> with appearance: none. Fills the wrapper; the trailing padding reserves room for the chevron.' },
48
+ { part: 'chevron', element: 'svg', description: 'Chevron-down, 16px (20px on lg), gray-500, absolutely placed 10 / 12 / 14px from the trailing edge. Decorative, pointer-events: none.' },
49
+ ],
50
+ props: {
51
+ size: SIZE_PROP(['sm', 'md', 'lg']),
52
+ variant: { values: ['default', 'filled'], default: 'default', description: 'default = white with the gray-300 ring and shadow-xs (the reference trigger; forms); filled = gray-50 fill without ring or shadow until focus (toolbars, table filters, sort controls — no reference equivalent).' },
53
+ },
54
+ states: {
55
+ hover: { selector: ':hover:not([data-disabled])', description: 'Pointer over the trigger. The reference has no hover change; presets with a distinct border-control-hover strengthen the ring.', markup: 'native' },
56
+ focus: { selector: ':focus-within', description: 'Focused or open: the inset ring turns to the brand color and the 4px brand focus ring appears.', markup: 'native :focus-within on the wrapper' },
57
+ 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 select, or data-invalid on the wrapper' },
58
+ disabled: { selector: '[data-disabled], &:has(select:disabled)', description: 'Whole control at 50% opacity, cursor not-allowed. Scoped to the <select> so a disabled placeholder <option> does not trigger it.', markup: 'disabled on the select' },
59
+ },
60
+ base: {
61
+ root: {
62
+ position: 'relative',
63
+ display: 'flex',
64
+ 'align-items': 'center',
65
+ width: '100%',
66
+ 'min-width': '0',
67
+ 'background-color': '{color.bg-surface}',
68
+ 'border-radius': '{radius.control}',
69
+ 'box-shadow': RING_REST,
70
+ color: '{color.fg-default}',
71
+ ...TRANSITION_FAST,
72
+ },
73
+ field: {
74
+ appearance: 'none',
75
+ '-webkit-appearance': 'none',
76
+ flex: '1 1 auto',
77
+ width: '100%',
78
+ 'min-width': '0',
79
+ height: '100%',
80
+ margin: '0',
81
+ 'padding-block': '0',
82
+ border: '0',
83
+ outline: 'none',
84
+ background: 'transparent',
85
+ 'border-radius': '{radius.control}',
86
+ ...typeStyle('body-md'),
87
+ 'font-weight': '{font.weight.medium}',
88
+ color: 'inherit',
89
+ cursor: 'pointer',
90
+ 'white-space': 'nowrap',
91
+ 'text-overflow': 'ellipsis',
92
+ },
93
+ icon: {
94
+ position: 'absolute',
95
+ top: '50%',
96
+ transform: 'translateY(-50%)',
97
+ 'flex-shrink': '0',
98
+ color: '{color.fg-subtle}',
99
+ 'pointer-events': 'none',
100
+ },
101
+ chevron: {
102
+ position: 'absolute',
103
+ top: '50%',
104
+ transform: 'translateY(-50%)',
105
+ 'flex-shrink': '0',
106
+ color: '{color.fg-subtle}',
107
+ 'stroke-width': '2.25',
108
+ 'pointer-events': 'none',
109
+ },
110
+ '@states': {
111
+ hover: { root: { 'box-shadow': RING_HOVER } },
112
+ focus: { root: { 'box-shadow': RING_FOCUS } },
113
+ invalid: { root: { 'box-shadow': RING_INVALID } },
114
+ disabled: { root: { opacity: '{opacity.disabled}', cursor: 'not-allowed' }, field: { cursor: 'not-allowed' } },
115
+ },
116
+ },
117
+ variants: {
118
+ variant: {
119
+ default: { root: {} },
120
+ filled: {
121
+ root: { 'background-color': '{color.bg-subtle}', 'box-shadow': 'none' },
122
+ '@states': {
123
+ hover: { root: { 'background-color': '{color.bg-muted}', 'box-shadow': 'none' } },
124
+ focus: { root: { 'background-color': '{color.bg-surface}', 'box-shadow': RING_FOCUS } },
125
+ invalid: { root: { 'box-shadow': RING_INVALID } },
126
+ },
127
+ },
128
+ },
129
+ size: { sm: sizeBlock('sm'), md: sizeBlock('md'), lg: sizeBlock('lg') },
130
+ },
131
+ extraCss: `
132
+ .cn-select .cn-select__field::-ms-expand { display: none; }
133
+ .cn-select .cn-select__field:has(option[value=""]:checked) { color: {color.fg-placeholder}; font-weight: {font.weight.regular}; }
134
+ .cn-select:has([aria-invalid="true"]):focus-within, .cn-select[data-invalid]:focus-within { box-shadow: ${RING_INVALID_FOCUS}; }
135
+ .cn-select .cn-select__icon + .cn-select__field { padding-inline-start: calc({space.3} + {size.icon.md} + {space.2}); }
136
+ .cn-select[data-size="sm"] .cn-select__icon + .cn-select__field { padding-inline-start: calc({space.3} + {size.icon.sm} + {space.2}); }
137
+ .cn-select[data-size="lg"] .cn-select__icon + .cn-select__field { padding-inline-start: calc({space.3.5} + {size.icon.md} + {space.2}); }`,
138
+ examples: [
139
+ ex('Default', sel('aria-label="Owner"', PEOPLE), '40px tall, 12px padding, medium text, 16px gray-500 chevron 12px from the edge.'),
140
+ ex('Sizes', `<div style="display:flex;flex-direction:column;gap:var(--cn-space-4)">${sel('aria-label="Owner (small)"', PEOPLE, 'sm')}${sel('aria-label="Owner (medium)"', PEOPLE, 'md')}${sel('aria-label="Owner (large)"', PEOPLE, 'lg')}</div>`, '36 / 40 / 44px; the chevron grows to 20px and the padding to 14px on lg.'),
141
+ ex('With leading icon', sel('aria-label="Assignee"', PEOPLE, 'md', 'default', icon('user')), 'A 20px gray-500 icon at the padding edge; the text starts 8px after it.'),
142
+ ex('With placeholder option', sel('aria-label="Plan"', '<option value="" selected disabled>Select a plan</option><option value="starter">Starter — $29/mo</option><option value="team">Team — $99/mo</option><option value="scale">Scale — $299/mo</option>'), 'An empty-value disabled option renders in the placeholder color at regular weight.'),
143
+ ex('Filled, small (toolbar sort)', sel('aria-label="Sort by"', '<option value="recent" selected>Sort: most recent</option><option value="name">Sort: name</option><option value="owner">Sort: owner</option>', 'sm', 'filled', '', 'max-width:200px')),
144
+ ex('Invalid', sel('aria-invalid="true" aria-label="Region"', '<option value="" selected disabled>Select a region</option><option value="eu">Europe (Frankfurt)</option><option value="us">United States (Oregon)</option><option value="br">South America (São Paulo)</option>'), 'Pair with a Field error such as "Choose a region to continue".'),
145
+ ex('Disabled', sel('disabled aria-label="Currency"', '<option value="usd" selected>USD — US dollar</option><option value="eur">EUR — Euro</option>')),
146
+ ],
147
+ rules: [
148
+ 'Use for 5–15 known options. Fewer: Radio or SegmentedControl (all options visible). More, or user-generated lists: Combobox with search.',
149
+ 'The first option is either a real default or a disabled placeholder with value="" ("Select a plan"); never "Select…" as a selectable option.',
150
+ 'Option labels are nouns in sentence case, 1–4 words; put the unit or price after an em dash ("Team — $99/mo").',
151
+ 'Always inside a Field with a visible label, except sort/filter selects in a toolbar (variant="filled", size="sm") whose label is in the option text ("Sort: most recent").',
152
+ 'Width comes from the layout; set max-width on the Field for short values (currency, region).',
153
+ 'Sizes: md (40px) in forms; sm (36px) in toolbars and tables; lg (44px) on marketing and auth screens. Never change the height with padding.',
154
+ 'Keep the native <select>. If the menu needs search, avatars, supporting text or groups with headers, use Combobox.',
155
+ ],
156
+ a11y: [
157
+ 'Every select has an accessible name: a <label for> from the Field or aria-label for toolbar selects.',
158
+ 'Use <optgroup label> for grouped options instead of fake heading options.',
159
+ 'aria-invalid="true" plus aria-describedby pointing at the Field error id when invalid.',
160
+ 'The icon and chevron are aria-hidden and pointer-events: none; the select itself receives the click and the keyboard (Alt+Down / typing to jump).',
161
+ ],
162
+ related: ['input', 'field', 'combobox', 'radio', 'segmented-control'],
163
+ };