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,207 @@
1
+ import { RESET_BUTTON, ex, ICON, typeStyle } from "./_shared.mjs";
2
+ // Selection persists as a rule or solid segment; hover stays a quieter surface change.
3
+ const TRANSITION_FAST = {
4
+ 'transition-property': 'background-color, border-color, color, box-shadow',
5
+ 'transition-duration': '{motion.duration.fast}',
6
+ 'transition-timing-function': '{motion.easing.linear}',
7
+ };
8
+ // Draw the baseline inside the row so tabs never overhang its scroll area.
9
+ const BASELINE = 'inset 0 calc(-1 * {border.width.thin}) 0 {color.border-default}';
10
+ const ring = (color) => `inset 0 0 0 1px ${color}`;
11
+ /** Persistent selection only; hover must not impersonate the current view. */
12
+ const on = (variant, tail = '') => [`[aria-selected="true"]`, `[aria-current="page"]`]
13
+ .map((s) => `.cn-tabs[data-variant="${variant}"] .cn-tabs__tab${s}${tail}`)
14
+ .join(', ');
15
+ /** A gray-50 track holding white pills (button-border, and its older name "pill"). */
16
+ const track = (radius, padding) => ({
17
+ root: { display: 'inline-flex', 'align-self': 'flex-start', gap: '{space.1}', padding, 'border-radius': radius, 'background-color': '{color.bg-subtle}', 'box-shadow': ring('{color.border-default}') },
18
+ });
19
+ const icon = (name) => ICON[name].replace('cn-icon', 'cn-tabs__icon');
20
+ const tab = (label, selected, id, extra = '') => `<button type="button" role="tab" class="cn-tabs__tab" id="tab-${id}" aria-controls="panel-${id}" aria-selected="${selected}" tabindex="${selected ? '0' : '-1'}"${extra}>${label}</button>`;
21
+ const count = (n) => `<span class="cn-tabs__count">${n}</span>`;
22
+ const SETTINGS = ['My details', 'Profile', 'Password', 'Team', 'Plan', 'Billing'];
23
+ const list = (variant, size, id, labels = SETTINGS, current = 0, orientation = variant === 'line' ? 'vertical' : 'horizontal', width = 'hug') => `<div class="cn-tabs" data-variant="${variant}" data-size="${size}" data-orientation="${orientation}" data-width="${width}" role="tablist" aria-label="Settings"${orientation === 'vertical' ? ' aria-orientation="vertical"' : ''}>${labels.map((l, i) => tab(l, i === current, `${id}-${i}`)).join('')}</div>`;
24
+ export const tabs = {
25
+ name: 'Tabs',
26
+ slug: 'tabs',
27
+ category: 'navigation',
28
+ description: 'A tablist that switches between views of the same object, in several emphasis levels: underline (2px brand bar on a hairline), button-brand (solid action segment), button-gray (gray-50 pill), button-border (white pill in a gray track) and button-minimal (ringed white pill in a gray track); line is the vertical underline. Labels are semibold, 14px (sm, 36 / 32px) or 16px (md, 44 / 36px), with optional icons and count badges.',
29
+ usage: 'Use to switch between peer views of one thing: sections of a settings page, panes of a record, views of a card. Route-level tabs are links with aria-current="page"; in-page tabs are buttons with aria-selected. Not for sequential steps (Stepper), not for filters (Tag / SegmentedControl), not for more than about eight options (SidebarNav or a Select).',
30
+ anatomy: [
31
+ { part: 'root', element: 'div', description: 'The tablist (role="tablist", aria-label). Flex row of tabs (column when data-orientation="vertical"); carries the bottom hairline for underline, or the gray-50 track for button-border / button-minimal / pill.' },
32
+ { part: 'tab', element: 'button', description: 'One tab: <button role="tab"> for in-page views, <a aria-current="page"> for routes. Semibold text-quaternary label, radius 6, gap 8; holds text, optional icon, optional count badge.' },
33
+ { part: 'icon', element: 'svg', description: 'Optional leading icon, 16px (sm) or 20px (md), fg-subtle. If one tab has an icon, all tabs in the list do. Decorative.', optional: true },
34
+ { part: 'count', element: 'span', description: 'Optional count after the label: a 22px badge (12px medium) in gray; turns brand-tinted on the selected tab of underline / line / button-brand, stays a white "modern" badge elsewhere. Hidden under 768px. Only for numbers that call for action.', optional: true },
35
+ { part: 'panel', element: 'div', description: 'The tabpanel (role="tabpanel", aria-labelledby the tab). A sibling AFTER the tablist, never inside it; padding-top separates it from the list.', optional: true },
36
+ ],
37
+ props: {
38
+ variant: {
39
+ values: ['underline', 'pill', 'enclosed', 'button-brand', 'button-gray', 'button-border', 'button-minimal', 'line'],
40
+ default: 'underline',
41
+ description: 'underline = the page-section default: 2px brand-600 bar under the selected tab on a full-width gray-200 hairline, brand-700 text when selected . button-brand = solid action segment with brand-700 text (settings sub-navigation, filters in a card). button-gray = gray-50 pill with gray-700 text (quiet toolbars). button-border = white pill with a fine outline inside a gray-50 track with a gray-200 ring (view switch in a card header). button-minimal = white pill with a control outline inside a gray-50 track, radius 8 (dense toolbars). line = vertical list with a 2px brand bar on the left (settings side navigation; set data-orientation="vertical"). pill = the older name of button-border, kept for existing markup. enclosed = folder tabs that merge into the panel below (editor panes only).',
42
+ },
43
+ size: {
44
+ values: ['sm', 'md'],
45
+ default: 'md',
46
+ description: 'md = 16px medium labels: 44px button tabs, 36px underline tabs, 32px line tabs; the default for page sections and settings. sm = 14px medium labels: 36px button tabs, 32px underline tabs, 24px line tabs; inside cards, drawers and dense toolbars.',
47
+ },
48
+ orientation: {
49
+ values: ['horizontal', 'vertical'],
50
+ default: 'horizontal',
51
+ description: 'horizontal = a row (every variant except line). vertical = a column, 8px between tabs, labels aligned to the start; use with line, button-brand, button-gray, button-border or button-minimal for a settings side navigation.',
52
+ },
53
+ width: {
54
+ values: ['hug', 'full'],
55
+ default: 'hug',
56
+ description: 'hug = tabs take the width of their labels (default). full = the list spans its container and every tab takes an equal share (mobile, card headers with 2–4 tabs); underline gaps grow from 12 to 16px.',
57
+ },
58
+ },
59
+ states: {
60
+ current: { selector: ' .cn-tabs__tab[aria-selected="true"], & .cn-tabs__tab[aria-current="page"]', description: 'The selected tab (lives on the TAB, not the root). underline / line: brand-700 text and the 2px brand-600 bar; button-brand: action fill and on-action text; button-gray: gray-50 fill; button-border: white pill with a fine outline; button-minimal: white pill with a control outline; enclosed: surface fill with an open bottom.', markup: 'aria-selected="true" on a role="tab" button, or aria-current="page" on a link tab' },
61
+ hover: { selector: ' .cn-tabs__tab:hover', description: 'Pointer over a tab: a subtle surface change; the selection bar stays on the current tab.', markup: 'native :hover on the tab' },
62
+ focus: { selector: ' .cn-tabs__tab:focus-visible', description: 'Keyboard focus on a tab: the token-defined focus ring around that tab.', markup: 'native :focus-visible on the tab' },
63
+ disabled: { selector: ' .cn-tabs__tab:disabled, & .cn-tabs__tab[aria-disabled="true"]', description: 'Tab cannot be chosen (feature off, no data yet). 50% opacity, no hover; keep the label so users know the view exists.', markup: 'disabled or aria-disabled="true" on the tab' },
64
+ },
65
+ base: {
66
+ root: {
67
+ display: 'flex',
68
+ 'align-items': 'center',
69
+ gap: '{space.1}',
70
+ 'min-width': '0',
71
+ 'max-width': '100%',
72
+ },
73
+ tab: {
74
+ ...RESET_BUTTON,
75
+ position: 'relative',
76
+ 'z-index': '{z.raised}',
77
+ display: 'inline-flex',
78
+ 'align-items': 'center',
79
+ 'justify-content': 'center',
80
+ gap: '{space.2}',
81
+ 'flex-shrink': '0',
82
+ padding: '{space.2.5}',
83
+ 'border-radius': '{radius.md}',
84
+ ...typeStyle('label-lg'),
85
+ color: '{color.fg-subtle}',
86
+ 'white-space': 'nowrap',
87
+ ...TRANSITION_FAST,
88
+ },
89
+ icon: { width: '{size.icon.md}', height: '{size.icon.md}', 'flex-shrink': '0', display: 'block', color: '{color.fg-subtle}', transition: 'inherit' },
90
+ count: {
91
+ display: 'inline-flex',
92
+ 'align-items': 'center',
93
+ 'justify-content': 'center',
94
+ height: '22px',
95
+ 'padding-inline': '{space.2}',
96
+ 'border-radius': '{radius.sm}',
97
+ 'background-color': '{color.bg-subtle}',
98
+ 'box-shadow': ring('{color.border-default}'),
99
+ color: '{color.fg-muted}',
100
+ ...typeStyle('label-xs'),
101
+ 'font-variant-numeric': 'tabular-nums',
102
+ transition: 'inherit',
103
+ },
104
+ },
105
+ variants: {
106
+ variant: {
107
+ underline: {
108
+ root: { gap: '{space.3}', 'align-items': 'stretch', 'box-shadow': BASELINE },
109
+ tab: { padding: '0 {space.0.5} {space.2.5}', 'border-radius': '0', 'border-bottom': '{border.width.medium} solid transparent' },
110
+ },
111
+ pill: track('{radius.control}', '{space.0.5}'),
112
+ enclosed: {
113
+ root: { 'box-shadow': BASELINE, 'align-items': 'flex-end', gap: '{space.1}' },
114
+ tab: { 'border-width': '{border.width.thin}', 'border-style': 'solid', 'border-color': 'transparent', 'border-bottom-width': '0', 'border-radius': '{radius.control} {radius.control} 0 0' },
115
+ },
116
+ 'button-brand': { root: { gap: '{space.1}' } },
117
+ 'button-gray': { root: { gap: '{space.1}' } },
118
+ 'button-border': track('{radius.control}', '{space.0.5}'),
119
+ 'button-minimal': { root: { display: 'inline-flex', 'align-self': 'flex-start', gap: '{space.0.5}', 'border-radius': '{radius.control}', 'background-color': '{color.bg-subtle}', 'box-shadow': ring('{color.border-default}') }, tab: { 'border-radius': '{radius.control}' } },
120
+ line: {
121
+ root: { 'flex-direction': 'column', 'align-items': 'stretch', gap: '{space.2}', width: 'max-content' },
122
+ tab: { 'justify-content': 'flex-start', padding: '{space.1} {space.3.5} {space.1} {space.3}', 'border-radius': '0', 'border-inline-start': '{border.width.medium} solid transparent' },
123
+ },
124
+ },
125
+ size: {
126
+ sm: {
127
+ tab: { padding: '{space.2} {space.2.5}', ...typeStyle('label-md'), gap: '{space.1.5}' },
128
+ icon: { width: '{size.icon.sm}', height: '{size.icon.sm}' },
129
+ },
130
+ md: { root: {} },
131
+ },
132
+ orientation: {
133
+ horizontal: { root: {} },
134
+ vertical: { root: { 'flex-direction': 'column', 'align-items': 'stretch', gap: '{space.2}', width: 'max-content' }, tab: { 'justify-content': 'flex-start' } },
135
+ },
136
+ width: {
137
+ hug: { root: {} },
138
+ full: { root: { width: '100%', display: 'flex', 'align-self': 'stretch' }, tab: { flex: '1 1 0', 'min-width': '0' } },
139
+ },
140
+ },
141
+ compound: [
142
+ { when: { variant: 'underline', size: 'sm' }, block: { tab: { padding: '0 {space.0.5} {space.2.5}' } } },
143
+ { when: { variant: 'line', size: 'sm' }, block: { tab: { padding: '{space.0.5} {space.3} {space.0.5} {space.2.5}' } } },
144
+ { when: { variant: 'underline', width: 'full' }, block: { root: { gap: '{space.4}' } } },
145
+ { when: { variant: 'pill', size: 'sm' }, block: { root: { 'border-radius': '{radius.control}', padding: '{space.0.5}' } } },
146
+ { when: { variant: 'button-border', size: 'sm' }, block: { root: { 'border-radius': '{radius.control}', padding: '{space.0.5}' } } },
147
+ { when: { variant: 'button-brand', orientation: 'vertical' }, block: { root: { gap: '{space.1}' } } },
148
+ { when: { variant: 'button-gray', orientation: 'vertical' }, block: { root: { gap: '{space.1}' } } },
149
+ ],
150
+ extraCss: `
151
+ .cn-tabs__tab:hover:not(:disabled):not([aria-disabled="true"]):not([aria-selected="true"]):not([aria-current="page"]) { background-color: {color.bg-subtle}; color: {color.fg-default}; }
152
+ .cn-tabs:not([data-orientation="vertical"]):not([data-variant="line"]) { overflow-x: auto; }
153
+ .cn-tabs__panel { padding-top: {space.6}; outline: none; }
154
+ .cn-tabs__panel:focus-visible { box-shadow: {shadow.focus}; border-radius: {radius.sm}; }
155
+ ${on('underline')} { color: {color.fg-action}; border-bottom-color: {color.bg-action}; }
156
+ ${on('line')} { color: {color.fg-action}; border-inline-start-color: {color.bg-action}; }
157
+ ${on('button-brand')} { color: {color.fg-on-action}; background-color: {color.bg-action}; }
158
+ ${on('button-gray')} { color: {color.fg-muted}; background-color: {color.bg-subtle}; }
159
+ ${on('button-border')}, ${on('pill')} { color: {color.fg-default}; background-color: {color.bg-surface}; box-shadow: ${ring('{color.border-control}')}; }
160
+ ${on('button-minimal')} { color: {color.fg-muted}; background-color: {color.bg-surface}; box-shadow: ${ring('{color.border-control}')}, {shadow.xs}; }
161
+ ${on('enclosed')} { color: {color.fg-muted}; background-color: {color.bg-surface}; border-color: {color.border-default}; }
162
+ ${on('underline', ' .cn-tabs__icon')}, ${on('line', ' .cn-tabs__icon')} { color: {color.fg-action}; }
163
+ ${on('button-brand', ' .cn-tabs__icon')} { color: inherit; }
164
+ ${on('button-gray', ' .cn-tabs__icon')}, ${on('button-border', ' .cn-tabs__icon')}, ${on('pill', ' .cn-tabs__icon')}, ${on('button-minimal', ' .cn-tabs__icon')}, ${on('enclosed', ' .cn-tabs__icon')} { color: {color.fg-default}; }
165
+ ${on('underline', ' .cn-tabs__count')}, ${on('line', ' .cn-tabs__count')} { background-color: {color.bg-action-subtle}; box-shadow: none; color: {color.fg-action}; }
166
+ ${on('button-brand', ' .cn-tabs__count')} { background-color: transparent; box-shadow: none; color: inherit; }
167
+ .cn-tabs[data-variant="button-gray"] .cn-tabs__count, .cn-tabs[data-variant="button-border"] .cn-tabs__count, .cn-tabs[data-variant="pill"] .cn-tabs__count, .cn-tabs[data-variant="button-minimal"] .cn-tabs__count, .cn-tabs[data-variant="enclosed"] .cn-tabs__count { background-color: {color.bg-surface}; box-shadow: ${ring('{color.border-control}')}, {shadow.xs}; }
168
+ .cn-tabs__tab:focus-visible { outline: 2px solid {color.border-action}; outline-offset: -2px; }
169
+ .cn-tabs[data-variant="underline"] .cn-tabs__tab:focus-visible, .cn-tabs[data-variant="line"] .cn-tabs__tab:focus-visible { border-radius: {radius.sm}; }
170
+ .cn-tabs__tab:disabled, .cn-tabs__tab[aria-disabled="true"] { opacity: {opacity.disabled}; cursor: not-allowed; pointer-events: none; }
171
+ @media (max-width: {breakpoint.md}) { .cn-tabs .cn-tabs__count { display: none; } }`,
172
+ examples: [
173
+ ex('Underline (default)', list('underline', 'md', 'u'), '16px medium labels, 36px tall; the selected tab is brand-700 with a 2px brand-600 bar on the gray-200 hairline.'),
174
+ ex('Underline sm with icons and counts', `<div class="cn-tabs" data-variant="underline" data-size="sm" data-orientation="horizontal" data-width="hug" role="tablist" aria-label="Inbox">${tab(`${icon('inbox')}Unread${count('12')}`, true, 'in-0')}${tab(`${icon('user')}Mentions${count('3')}`, false, 'in-1')}${tab(`${icon('check')}Done`, false, 'in-2')}</div>`, '14px labels, 32px tall, 16px icons. The count of the selected tab turns brand-tinted; it hides below 768px.'),
175
+ ex('Button brand', list('button-brand', 'sm', 'bb'), 'A solid action-colored segment marks selection; hover stays neutral.'),
176
+ ex('Button gray', list('button-gray', 'sm', 'bg'), 'Same pill in gray-50 with gray-700 text.'),
177
+ ex('Button border', list('button-border', 'sm', 'bo', ['All', 'Active', 'Archived']), 'A quiet track with 2px inset spacing holds a flat, outlined selected segment.'),
178
+ ex('Button minimal', list('button-minimal', 'sm', 'bm', ['Day', 'Week', 'Month']), 'A compact neutral track; the selected segment uses the surface fill and a control outline.'),
179
+ ex('Line (vertical)', `<div style="display:flex;gap:var(--cn-space-8);align-items:flex-start">${list('line', 'md', 'ln')}${list('button-brand', 'sm', 'lv', SETTINGS, 2, 'vertical')}</div>`, 'line = 2px brand bar on the left, 32px rows; any button variant becomes a column with data-orientation="vertical".'),
180
+ ex('Full width', `<div style="width:100%;max-width:480px">${list('underline', 'sm', 'fw', ['Overview', 'Activity', 'Files', 'Notes'], 1, 'horizontal', 'full')}</div>`, 'data-width="full": every tab takes an equal share and the underline gaps grow to 16px.'),
181
+ ex('Pill and enclosed (kept for existing markup)', `<div style="display:flex;flex-direction:column;gap:var(--cn-space-6);align-items:flex-start">${list('pill', 'md', 'pl', ['Overview', 'Activity', 'Files'])}${list('enclosed', 'md', 'en', ['Preview', 'HTML', 'CSS', 'React'], 1)}</div>`, 'pill is the same look as button-border, with token-defined corners and 2px inset spacing. enclosed is the folder tab for editor panes.'),
182
+ ex('With panel', `<div style="width:100%;max-width:560px">${list('underline', 'md', 'pn', ['Steps', 'Replies', 'Settings'])}<div class="cn-tabs__panel" role="tabpanel" id="panel-pn-0" aria-labelledby="tab-pn-0" tabindex="0"><p class="cn-text-body-md">4 steps over 9 days. The next email goes out Thursday at 09:00 to 38 customers.</p></div></div>`, 'The panel is a sibling after the tablist with padding-top 24px.'),
183
+ ex('Route tabs with a disabled tab', `<div class="cn-tabs" data-variant="underline" data-size="md" data-orientation="horizontal" data-width="hug" aria-label="Reports"><a href="#" class="cn-tabs__tab" aria-current="page">Customers</a><a href="#" class="cn-tabs__tab">Invoices</a><a href="#" class="cn-tabs__tab">Files</a><a class="cn-tabs__tab" aria-disabled="true" title="Available on the Business plan">Attribution</a></div>`, 'Links with aria-current="page" when each tab is a URL; no tablist role. The disabled tab keeps its label at 50% opacity.'),
184
+ ],
185
+ rules: [
186
+ '2–8 tabs. With one there is nothing to switch; beyond eight use SidebarNav or a Select.',
187
+ 'Labels are 1–3 words in sentence case, nouns ("My details", "Billing"). Never verbs, never trailing punctuation.',
188
+ 'Exactly one tab is selected at all times; the first one by default. Never start with nothing selected.',
189
+ 'Heights come only from size: md = 44px button tabs / 36px underline / 32px line; sm = 36 / 32 / 24. Never add padding to change them.',
190
+ 'Counts are the 22px badge (count part); only for numbers that ask for action (unread, waiting, failed). Totals belong in the panel.',
191
+ 'Icons are all-or-none within one list, leading, 16px at sm and 20px at md, fg-subtle at rest and brand or ink when selected.',
192
+ 'underline for page sections and settings pages; button-brand or button-gray for a sub-navigation inside a page; button-border / button-minimal for a view switch inside a card header or toolbar; line (vertical) for a settings side navigation; enclosed only for editor panes with a visible panel border.',
193
+ 'Hover uses a quiet fill. The selection rule or solid segment appears only on the current tab.',
194
+ 'Tabs switch views of the same object. Different pages are links with aria-current="page"; a sequential flow is a Stepper.',
195
+ 'Never nest a tablist inside a tabpanel. A second level of navigation is a SidebarNav or a button-* Tabs inside the card, not tabs under tabs.',
196
+ 'Tabs never wrap onto a second line. On narrow screens use data-width="full" with ≤ 4 tabs, or let the tablist scroll horizontally. Focus stays inside the tab so it remains visible in the scroll area.',
197
+ ],
198
+ a11y: [
199
+ 'In-page tabs: root role="tablist" with aria-label, tabs are <button role="tab" aria-selected aria-controls>, panels are role="tabpanel" aria-labelledby, tabindex="0".',
200
+ 'Roving tabindex: only the selected tab is in the tab order (tabindex="0"); Left/Right (Up/Down when vertical) move focus, Home/End jump, Enter/Space or automatic activation selects.',
201
+ 'Route tabs are plain <a href> with aria-current="page" inside a <nav>; do NOT add role="tab" to links that navigate.',
202
+ 'Disabled tabs use aria-disabled="true" (stay perceivable) and a title or Tooltip with the reason.',
203
+ 'Selection is conveyed by text color and the bar or pill, never by color alone; the count keeps its number in text and hides only visually on mobile.',
204
+ 'Vertical lists set aria-orientation="vertical" on the tablist so arrow-key direction matches.',
205
+ ],
206
+ related: ['topbar', 'sidebar-nav', 'segmented-control', 'stepper', 'badge'],
207
+ };
@@ -0,0 +1,196 @@
1
+ import { RESET_BUTTON, STATE, ex, ICON, typeStyle } from "./_shared.mjs";
2
+ // the reference tag: a white chip with a gray-300 ring, radius 6 and 14px medium gray-700 text
3
+ // (12px at sm); sm 24 · md 28 · lg 32px tall; a leading checkbox (14 / 16 / 18), avatar (16)
4
+ // or 8px dot; a trailing count chip (radius 3, gray-100) or a close × (radius 3, gray-400);
5
+ // 50ms transitions, 4px focus ring.
6
+ const TRANSITION_TAG = {
7
+ 'transition-property': 'background-color, box-shadow, color',
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 SELECTED = { 'background-color': '{color.bg-action-subtle}', 'box-shadow': ring('{color.border-action}'), color: '{color.fg-action}' };
13
+ /** Rest paint per tone × variant. */
14
+ const PAINT = {
15
+ 'neutral:outline': { bg: '{color.bg-surface}', fg: '{color.fg-muted}', ring: '{color.border-control}' },
16
+ 'neutral:soft': { bg: '{color.bg-subtle}', fg: '{color.fg-muted}', ring: 'transparent' },
17
+ 'accent:outline': { bg: '{color.bg-surface}', fg: '{color.fg-accent}', ring: '{brand.300}' },
18
+ 'accent:soft': { bg: '{color.bg-accent-subtle}', fg: '{color.fg-accent}', ring: 'transparent' },
19
+ };
20
+ const compound = Object.entries(PAINT).map(([key, p]) => {
21
+ const [tone, variant] = key.split(':');
22
+ return {
23
+ when: { tone, variant },
24
+ block: {
25
+ root: { 'background-color': p.bg, color: p.fg, 'box-shadow': ring(p.ring) },
26
+ '@states': { selected: { root: SELECTED } },
27
+ },
28
+ };
29
+ });
30
+ const AVATAR_SRC = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' fill='%23CDCED1'/%3E%3Ccircle cx='32' cy='25' r='11' fill='%23FAFAFA'/%3E%3Cpath d='M10 64c2-13 11-21 22-21s20 8 22 21z' fill='%23FAFAFA'/%3E%3C/svg%3E";
31
+ const X = '<svg class="cn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg>';
32
+ const CHECK = '<svg class="cn-tag__check" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11.67 3.5L5.25 9.92 2.33 7"/></svg>';
33
+ const mk = (label, o = {}) => `<${o.tag ?? 'span'}${o.tag === 'button' ? ' type="button"' : ''} class="cn-tag" data-tone="${o.tone ?? 'neutral'}" data-variant="${o.variant ?? 'outline'}" data-size="${o.size ?? 'md'}"${o.attrs ?? ''}>${o.before ?? ''}<span class="cn-tag__label">${label}</span>${o.after ?? ''}</${o.tag ?? 'span'}>`;
34
+ const chip = (label, pressed, extra = '') => mk(label, { tag: 'button', attrs: ` aria-pressed="${pressed}"${extra}` });
35
+ const removable = (label, size = 'md', tone = 'neutral', variant = 'outline') => mk(label, { size, tone, variant, after: `<button type="button" class="cn-tag__remove" aria-label="Remove ${label}">${X}</button>` });
36
+ const counted = (label, n, size = 'md') => mk(label, { size, after: `<span class="cn-tag__count">${n}</span>` });
37
+ const checkbox = (label, checked, size = 'md', extra = '') => mk(label, { size, tag: 'button', attrs: ` role="checkbox" aria-checked="${checked}"${extra}`, before: `<span class="cn-tag__checkbox" aria-hidden="true">${CHECK}</span>` });
38
+ const withAvatar = (label, size = 'md') => mk(label, { size, before: `<img class="cn-tag__avatar" src="${AVATAR_SRC}" alt="">`, after: `<button type="button" class="cn-tag__remove" aria-label="Remove ${label}">${X}</button>` });
39
+ const withDot = (label, size = 'md') => mk(label, { size, before: '<span class="cn-tag__dot"></span>' });
40
+ export const tag = {
41
+ name: 'Tag',
42
+ slug: 'tag',
43
+ category: 'data-display',
44
+ description: 'The reference tag: a white chip with a gray-300 ring, radius 6 and 14px medium text (sm 24 · md 28 · lg 32px) that you can toggle or remove; leading checkbox, avatar or dot, trailing count chip or close ×. Unlike Badge it responds to focus and selection.',
45
+ usage: 'Filter bars (toggle or checkbox tags), token fields and applied-filter rows (removable tags), multi-select values, keyword lists with counts. Not for statuses or categories that only inform (Badge), and not for actions with a verb (Button).',
46
+ anatomy: [
47
+ { part: 'root', element: 'span', description: 'A <span> for a value; a <button aria-pressed> (or role="checkbox" aria-checked with the checkbox part) when selectable. White, gray-300 ring, radius 6.' },
48
+ { part: 'checkbox', element: 'span', description: 'Leading 14 / 16 / 18px checkbox (radius 4, gray-300 ring); brand-600 with a white check when the tag is checked.', optional: true },
49
+ { part: 'check', element: 'svg', description: 'The 10 / 12 / 14px check inside the checkbox; hidden until checked.', optional: true },
50
+ { part: 'avatar', element: 'img', description: 'Leading 16px round avatar with the 0.5px contrast ring.', optional: true },
51
+ { part: 'dot', element: 'span', description: 'Leading 8px status dot, green-500 by default (override the color inline for other states).', optional: true },
52
+ { part: 'icon', element: 'svg', description: 'Optional 12px leading icon (a flag, a spark for suggested values). Decorative.', optional: true },
53
+ { part: 'label', element: 'span', description: 'The value text, sentence case, 1–3 words. Never truncated.' },
54
+ { part: 'count', element: 'span', description: 'Trailing count chip: radius 3, gray-100, 12px medium (14px at lg), padding 0 × 4–6.', optional: true },
55
+ { part: 'remove', element: 'button', description: 'Trailing close ×: radius 3, gray-400, 14 / 16 / 20px box with a 10 / 12 / 14px icon; gray-50 on hover. Its own focus target with aria-label "Remove {label}".', optional: true },
56
+ ],
57
+ props: {
58
+ tone: { values: ['neutral', 'accent'], default: 'neutral', description: 'neutral = user-chosen values and filters (the reference); accent = values suggested by the assistant or tied to the brand (use sparingly, a few per view).' },
59
+ variant: { values: ['outline', 'soft'], default: 'outline', description: 'outline = white with the gray-300 ring (the reference tag, on surfaces); soft = gray-50 fill with no ring (on the canvas, inside inputs, in dense rows).' },
60
+ size: { values: ['sm', 'md', 'lg'], default: 'md', description: 'sm 24px with 12px text (table cells, dense filter rows); md 28px with 14px text (default); lg 32px with 14px text (filter bars next to 32px controls).' },
61
+ },
62
+ states: {
63
+ hover: STATE.hover(),
64
+ selected: { selector: '[data-selected], &[aria-pressed="true"], &[aria-checked="true"]', description: 'Toggled on. A pressed chip gets the brand tint; a checkbox tag fills its checkbox brand-600 with the white check.', markup: 'aria-pressed="true" on a button tag, aria-checked="true" on a role="checkbox" tag, or data-selected' },
65
+ focus: STATE.focus(),
66
+ disabled: STATE.disabled(),
67
+ },
68
+ base: {
69
+ root: {
70
+ ...RESET_BUTTON,
71
+ display: 'inline-flex',
72
+ 'align-items': 'center',
73
+ gap: '5px',
74
+ height: '{space.7}',
75
+ 'padding-inline': '9px',
76
+ 'padding-block': '0',
77
+ 'border-radius': '{radius.md}',
78
+ ...typeStyle('label-sm'),
79
+ 'background-color': '{color.bg-surface}',
80
+ 'box-shadow': ring('{color.border-control}'),
81
+ color: '{color.fg-muted}',
82
+ 'white-space': 'nowrap',
83
+ 'vertical-align': 'middle',
84
+ 'max-width': '100%',
85
+ cursor: 'default',
86
+ ...TRANSITION_TAG,
87
+ },
88
+ checkbox: {
89
+ display: 'inline-flex',
90
+ 'align-items': 'center',
91
+ 'justify-content': 'center',
92
+ 'flex-shrink': '0',
93
+ width: '{space.4}',
94
+ height: '{space.4}',
95
+ 'border-radius': '{radius.sm}',
96
+ 'background-color': '{color.bg-surface}',
97
+ 'box-shadow': ring('{color.border-control}'),
98
+ color: '{color.fg-on-action}',
99
+ 'margin-inline-start': '-5px',
100
+ ...TRANSITION_TAG,
101
+ },
102
+ check: { width: '{size.icon.xs}', height: '{size.icon.xs}', display: 'block', opacity: '0', transition: 'inherit' },
103
+ avatar: { width: '{space.4}', height: '{space.4}', 'border-radius': '{radius.full}', 'object-fit': 'cover', 'flex-shrink': '0', 'max-width': 'none', 'background-color': '{color.bg-muted}', 'box-shadow': 'inset 0 0 0 0.5px color-mix(in srgb, {black} 16%, transparent)', 'margin-inline-start': '-4px' },
104
+ dot: { width: '{space.2}', height: '{space.2}', 'border-radius': '{radius.full}', 'flex-shrink': '0', 'background-color': '{green.500}', 'margin-inline-start': '-2px' },
105
+ icon: { width: '{size.icon.xs}', height: '{size.icon.xs}', 'flex-shrink': '0', color: '{color.fg-subtle}' },
106
+ label: { display: 'inline-block', 'min-width': '0' },
107
+ count: {
108
+ display: 'inline-flex',
109
+ 'align-items': 'center',
110
+ 'justify-content': 'center',
111
+ 'flex-shrink': '0',
112
+ 'padding-inline': '5px',
113
+ 'border-radius': '3px',
114
+ 'background-color': '{color.bg-muted}',
115
+ ...typeStyle('label-xs'),
116
+ color: '{color.fg-muted}',
117
+ 'font-variant-numeric': 'tabular-nums',
118
+ 'margin-inline-end': '-6px',
119
+ },
120
+ remove: {
121
+ ...RESET_BUTTON,
122
+ display: 'inline-flex',
123
+ 'align-items': 'center',
124
+ 'justify-content': 'center',
125
+ 'flex-shrink': '0',
126
+ width: '{space.4}',
127
+ height: '{space.4}',
128
+ padding: '0',
129
+ 'margin-inline-start': '-2px',
130
+ 'margin-inline-end': '-5px',
131
+ 'border-radius': '3px',
132
+ color: '{color.fg-subtle}',
133
+ ...TRANSITION_TAG,
134
+ },
135
+ '@states': {
136
+ selected: { root: SELECTED, icon: { color: '{color.fg-action}' } },
137
+ focus: { root: { outline: 'none', 'box-shadow': `${ring('{color.border-control}')}, {shadow.focus}` } },
138
+ disabled: { root: { opacity: '{opacity.disabled}', cursor: 'not-allowed', 'pointer-events': 'none' } },
139
+ },
140
+ },
141
+ variants: {
142
+ tone: { neutral: { root: {} }, accent: { root: {} } },
143
+ variant: { outline: { root: {} }, soft: { root: {} } },
144
+ size: {
145
+ sm: { root: { height: '{space.6}', 'padding-inline': '{space.2}', gap: '{space.1}', ...typeStyle('label-xs') }, checkbox: { width: '{space.3.5}', height: '{space.3.5}', 'margin-inline-start': '-3px' }, check: { width: '{space.2.5}', height: '{space.2.5}' }, avatar: { 'margin-inline-start': '-4px' }, count: { 'padding-inline': '{space.1}', 'margin-inline-end': '-4px' }, remove: { width: '{space.3.5}', height: '{space.3.5}', 'margin-inline-end': '-4px' }, icon: { width: '{space.2.5}', height: '{space.2.5}' } },
146
+ md: { root: { height: '{space.7}', 'padding-inline': '9px' } },
147
+ lg: { root: { height: '{space.8}', 'padding-inline': '{space.2.5}', gap: '{space.1.5}' }, checkbox: { width: '18px', height: '18px', 'margin-inline-start': '-5px' }, check: { width: '{space.3.5}', height: '{space.3.5}' }, avatar: { 'margin-inline-start': '-3px' }, count: { 'padding-inline': '{space.1.5}', ...typeStyle('label-sm'), 'margin-inline-end': '-6px' }, remove: { width: '{space.5}', height: '{space.5}', 'margin-inline-end': '-6px' } },
148
+ },
149
+ },
150
+ compound,
151
+ extraCss: `
152
+ button.cn-tag, a.cn-tag { cursor: pointer; }
153
+ button.cn-tag:hover:not(:disabled):not([aria-pressed="true"]):not([aria-checked="true"]) { background-color: {color.bg-subtle}; }
154
+ .cn-tag[aria-checked="true"] { background-color: {color.bg-surface}; box-shadow: ${ring('{color.border-control}')}; color: {color.fg-muted}; }
155
+ .cn-tag[aria-checked="true"] .cn-tag__checkbox, .cn-tag[data-selected] .cn-tag__checkbox { background-color: {color.bg-action}; box-shadow: ${ring('{color.bg-action}')}; }
156
+ .cn-tag[aria-checked="true"] .cn-tag__check, .cn-tag[data-selected] .cn-tag__check { opacity: 1; }
157
+ .cn-tag[aria-checked="true"]:focus-visible, .cn-tag[aria-checked="true"]:hover:not(:disabled) { background-color: {color.bg-surface}; }
158
+ .cn-tag[aria-checked="true"]:focus-visible { box-shadow: ${ring('{color.border-control}')}, {shadow.focus}; }
159
+ .cn-tag__remove .cn-icon { width: {size.icon.xs}; height: {size.icon.xs}; }
160
+ .cn-tag[data-size="sm"] .cn-tag__remove .cn-icon { width: {space.2.5}; height: {space.2.5}; }
161
+ .cn-tag[data-size="lg"] .cn-tag__remove .cn-icon { width: {space.3.5}; height: {space.3.5}; }
162
+ .cn-tag__remove:hover { background-color: {color.bg-subtle}; color: {color.fg-muted}; }
163
+ .cn-tag__remove:focus-visible { outline: none; box-shadow: {shadow.focus}; }
164
+ .cn-tag[data-selected] .cn-tag__remove, .cn-tag[aria-pressed="true"] .cn-tag__remove { color: {color.fg-action}; }
165
+ .cn-tag:disabled .cn-tag__remove, .cn-tag[aria-disabled="true"] .cn-tag__remove { pointer-events: none; }
166
+ .cn-tag[data-variant="soft"] .cn-tag__checkbox, .cn-tag[data-variant="soft"][aria-checked="true"] { box-shadow: ${ring('{color.border-control}')}; }`,
167
+ examples: [
168
+ ex('Sizes', `<div style="display:flex;align-items:center;gap:var(--cn-space-3)">${mk('Design', { size: 'sm' })}${mk('Design', { size: 'md' })}${mk('Design', { size: 'lg' })}</div>`, 'sm 24px (12px text) · md 28px · lg 32px (14px text). White, gray-300 ring, radius 6.'),
169
+ ex('Removable values', `<div style="display:flex;flex-wrap:wrap;gap:var(--cn-space-2)">${removable('Design')}${removable('Product')}${removable('Marketing')}${removable('Engineering', 'sm')}${removable('Sales', 'lg')}</div>`, 'The close × is a 16px (14 / 20) radius-3 button with its own focus ring; gray-50 on hover.'),
170
+ ex('With count', `<div style="display:flex;flex-wrap:wrap;gap:var(--cn-space-2)">${counted('Design', '5', 'sm')}${counted('Design', '5')}${counted('Design', '5', 'lg')}</div>`, 'The count chip: radius 3, gray-100, 12px medium (14px at lg).'),
171
+ ex('Checkbox tags', `<div role="group" aria-label="Teams" style="display:flex;flex-wrap:wrap;gap:var(--cn-space-2)">${checkbox('Design', true)}${checkbox('Product', false)}${checkbox('Engineering', true)}${checkbox('Sales', false, 'md', ' disabled')}</div>`, 'A leading 16px checkbox (14 / 18 at sm / lg); checked = brand-600 with the white check. The tag stays white.'),
172
+ ex('With avatar and with dot', `<div style="display:flex;flex-wrap:wrap;gap:var(--cn-space-2)">${withAvatar('Maya Chen')}${withAvatar('Daniel Costa', 'lg')}${withDot('Online')}${withDot('Available', 'sm')}</div>`, 'A 16px avatar with the contrast ring; an 8px green dot for live states.'),
173
+ ex('Selectable filter chips', `<div style="display:flex;flex-wrap:wrap;gap:var(--cn-space-2)">${chip('All', true)}${chip('Active', false)}${chip('Archived', false)}${chip('Shared with me', false)}</div>`, 'aria-pressed="true" is the selected state: brand tint, brand ring and text.'),
174
+ ex('Soft and accent', `<div style="display:flex;flex-wrap:wrap;gap:var(--cn-space-2)">${removable('Draft', 'md', 'neutral', 'soft')}${mk('Suggested: Q3 launch', { tone: 'accent', variant: 'soft', before: ICON.spark.replace('cn-icon', 'cn-tag__icon'), after: `<button type="button" class="cn-tag__remove" aria-label="Remove Suggested: Q3 launch">${X}</button>` })}${mk('Lumen', { tone: 'accent' })}</div>`, 'soft = gray-50 fill, no ring; accent for assistant suggestions and brand-linked values.'),
175
+ ex('Disabled', `<div style="display:flex;flex-wrap:wrap;gap:var(--cn-space-2)">${chip('Archived', false, ' disabled')}${mk('Locked filter', { attrs: ' aria-disabled="true"', after: `<button type="button" class="cn-tag__remove" aria-label="Remove Locked filter" disabled>${X}</button>` })}</div>`, '50% opacity, cursor not-allowed.'),
176
+ ],
177
+ rules: [
178
+ 'Tag is interactive or removable; Badge is not. If it cannot be toggled or removed, it is a Badge.',
179
+ 'Labels are nouns in sentence case, 1–3 words, never uppercase and never truncated.',
180
+ 'Heights come only from size: 24 / 28 / 32px with 8 / 9 / 10px side padding; the leading and trailing parts pull in by their margins, never by extra padding.',
181
+ 'Selectable tags are <button aria-pressed> (brand tint) or role="checkbox" with the checkbox part (white tag, brand checkbox); removable tags are a <span> with a remove <button> inside. A tag is never both selectable and removable.',
182
+ 'The remove button is always last, always has aria-label "Remove {label}", and is the only element with hover feedback inside a span tag.',
183
+ 'One leading part at most (checkbox, avatar, dot or icon) and one trailing part at most (count or ×).',
184
+ 'outline on surfaces (filter bars over white); soft on the canvas, inside inputs and in dense rows where rings would pile up.',
185
+ 'accent only for assistant-suggested or brand-linked values, and only a few per view. Never as decoration.',
186
+ 'Rows of tags wrap with an 8px gap and align to the start; do not right-align or justify them. One size per row.',
187
+ ],
188
+ a11y: [
189
+ 'Selectable: <button type="button" aria-pressed="true|false">, or role="checkbox" aria-checked with the checkbox part. Toggle the aria attribute, not only the data attribute.',
190
+ 'Removable: the remove button announces "Remove {label}"; after removing, move focus to the next tag or back to the input.',
191
+ 'Both the tag and its remove button are separate Tab stops with their own :focus-visible ring.',
192
+ 'Avatars inside tags are decorative (alt=""); the label names the person. A dot needs its meaning in the label ("Online").',
193
+ 'Disabled tags stay readable (opacity only). Use disabled on buttons and aria-disabled="true" on span tags.',
194
+ ],
195
+ related: ['badge', 'counter', 'input', 'checkbox', 'button', 'combobox'],
196
+ };
@@ -0,0 +1,146 @@
1
+ import { CONTROL, FOCUS_RING, RESET_BUTTON, TRANSITION_COLORS, SIZE_PROP, ex, ICON, typeStyle } from "./_shared.mjs";
2
+ // Token field: chips wrap inside one bordered box, followed by a bare input that grows to fill
3
+ // the last line. Enter / comma turns the text into a chip; Backspace on an empty input removes the last one.
4
+ const sizeBlock = (s, o) => ({
5
+ root: { 'min-height': CONTROL[s].height, padding: o.pad, gap: o.gap, 'border-radius': CONTROL[s].radius },
6
+ tag: { height: o.chip, 'font-size': o.chipFont },
7
+ field: { height: o.chip, 'font-size': CONTROL[s].font },
8
+ });
9
+ const chip = (label, attrs = '') => `<span class="cn-tags-input__tag">${label}<button type="button" class="cn-tags-input__tag-remove" aria-label="Remove ${label}"${attrs}>${ICON.x}</button></span>`;
10
+ const box = (o) => `<div class="cn-tags-input" data-size="${o.size ?? 'md'}"${o.root ?? ''} style="max-width:420px">${o.tags.map((t) => chip(t, o.removeAttrs)).join('')}<input class="cn-tags-input__field" type="text" ${o.input}></div>`;
11
+ export const tagsInput = {
12
+ name: 'TagsInput',
13
+ slug: 'tags-input',
14
+ category: 'forms',
15
+ description: 'An Input that holds many values as chips: emails, keywords, labels. One hairline box with the control height as its minimum; chips wrap, and a bare text field grows into the remaining space of the last line.',
16
+ usage: 'Free-form lists typed by the user: invite emails, keywords, tags, domains. When the values must come from a known list use MultiSelect; for a single value use Input; for filters you toggle use Tag. Pair with Field for the label and helper ("Press Enter to add").',
17
+ anatomy: [
18
+ { part: 'root', element: 'div', description: 'The bordered box (flex, wrap, align center, gap space.1, padding space.1.5, min-height = control height). Draws background, hairline, radius, shadow-xs and the focus ring on :focus-within. Clicking anywhere focuses the field.' },
19
+ { part: 'tag', element: 'span', description: 'One value chip: surface fill, hairline, radius md, 24px tall, body-sm text, followed by its remove button.' },
20
+ { part: 'tag-remove', element: 'button', description: '16px button with an × icon at the end of a chip. aria-label "Remove {value}".' },
21
+ { part: 'field', element: 'input', description: 'The bare text input (flex 1, min-width 80px): no border, no outline. Enter or comma commits the text as a chip.' },
22
+ ],
23
+ props: {
24
+ size: SIZE_PROP(['sm', 'md', 'lg']),
25
+ },
26
+ states: {
27
+ hover: { selector: ':hover:not([data-disabled])', description: 'Border strengthens.', markup: 'native :hover' },
28
+ focus: { selector: ':focus-within', description: 'Border becomes the action color and the ring appears around the whole box.', markup: 'native :focus-within on the box' },
29
+ invalid: { selector: '[data-invalid], &:has([aria-invalid="true"])', description: 'A value failed validation (bad email, duplicate, too many): danger border, danger ring on focus. Pair with a Field error.', markup: 'aria-invalid="true" on the input, or data-invalid on the box' },
30
+ disabled: { selector: '[data-disabled], &:has(input:disabled)', description: 'Grey fill, chips and text in the disabled color, remove buttons inert.', markup: 'disabled on the input (and on the remove buttons)' },
31
+ removeHover: { selector: ' .cn-tags-input__tag-remove:hover', description: 'On the __tag-remove button: subtle fill and default ink. Styled in extraCss because it lives on a child.', markup: 'native :hover on the remove button' },
32
+ removeFocus: { selector: ' .cn-tags-input__tag-remove:focus-visible', description: 'On the __tag-remove button: its own focus ring. Styled in extraCss.', markup: 'native :focus-visible on the remove button' },
33
+ },
34
+ base: {
35
+ root: {
36
+ display: 'flex',
37
+ 'flex-wrap': 'wrap',
38
+ 'align-items': 'center',
39
+ gap: '{space.1}',
40
+ width: '100%',
41
+ 'min-width': '0',
42
+ 'min-height': CONTROL.md.height,
43
+ padding: '{space.1.5}',
44
+ 'background-color': '{color.bg-surface}',
45
+ border: '{border.width.thin} solid {color.border-control}',
46
+ 'border-radius': '{radius.control}',
47
+ color: '{color.fg-default}',
48
+ 'box-shadow': '{shadow.xs}',
49
+ cursor: 'text',
50
+ ...TRANSITION_COLORS,
51
+ },
52
+ tag: {
53
+ display: 'inline-flex',
54
+ 'align-items': 'center',
55
+ gap: '{space.1}',
56
+ height: '{space.6}',
57
+ 'max-width': '100%',
58
+ 'padding-inline': '{space.2}',
59
+ 'background-color': '{color.bg-surface}',
60
+ border: '{border.width.thin} solid {color.border-default}',
61
+ 'border-radius': '{radius.md}',
62
+ ...typeStyle('body-sm'),
63
+ 'line-height': '{font.lineHeight.none}',
64
+ color: '{color.fg-default}',
65
+ 'white-space': 'nowrap',
66
+ cursor: 'default',
67
+ },
68
+ 'tag-remove': {
69
+ ...RESET_BUTTON,
70
+ display: 'inline-flex',
71
+ 'align-items': 'center',
72
+ 'justify-content': 'center',
73
+ 'flex-shrink': '0',
74
+ width: '{space.4}',
75
+ height: '{space.4}',
76
+ 'margin-inline-end': 'calc({space.1} * -1)',
77
+ 'border-radius': '{radius.sm}',
78
+ 'font-size': '{size.icon.xs}',
79
+ color: '{color.fg-subtle}',
80
+ ...TRANSITION_COLORS,
81
+ },
82
+ field: {
83
+ flex: '1 1 {space.20}',
84
+ 'min-width': '{space.20}',
85
+ height: '{space.6}',
86
+ margin: '0',
87
+ padding: '0 {space.1}',
88
+ border: '0',
89
+ outline: 'none',
90
+ background: 'transparent',
91
+ ...typeStyle('body-md'),
92
+ 'line-height': '{font.lineHeight.none}',
93
+ color: 'inherit',
94
+ },
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
+ tag: { color: '{color.fg-disabled}', 'background-color': 'transparent', 'border-color': '{color.border-disabled}' },
102
+ 'tag-remove': { color: '{color.fg-disabled}', cursor: 'not-allowed', 'pointer-events': 'none' },
103
+ field: { cursor: 'not-allowed' },
104
+ },
105
+ },
106
+ },
107
+ variants: {
108
+ size: {
109
+ sm: sizeBlock('sm', { pad: '{space.1}', chip: '{space.5}', chipFont: '{font.size.xs}', gap: '{space.1}' }),
110
+ md: sizeBlock('md', { pad: '{space.1.5}', chip: '{space.6}', chipFont: '{font.size.sm}', gap: '{space.1}' }),
111
+ lg: sizeBlock('lg', { pad: '{space.2}', chip: '{space.7}', chipFont: '{font.size.md}', gap: '{space.1.5}' }),
112
+ },
113
+ },
114
+ extraCss: `
115
+ .cn-tags-input .cn-tags-input__field::placeholder { color: {color.fg-placeholder}; opacity: 1; }
116
+ .cn-tags-input:has([aria-invalid="true"]):focus-within, .cn-tags-input[data-invalid]:focus-within { box-shadow: {shadow.focus-danger}; border-color: {color.border-danger}; }
117
+ .cn-tags-input .cn-tags-input__tag-remove .cn-icon { stroke-width: 2; }
118
+ .cn-tags-input .cn-tags-input__tag-remove:hover { background-color: {color.bg-subtle}; color: {color.fg-default}; }
119
+ .cn-tags-input .cn-tags-input__tag-remove:focus-visible { outline: none; box-shadow: {shadow.focus}; color: {color.fg-default}; }
120
+ .cn-tags-input .cn-tags-input__tag[data-invalid] { border-color: {color.border-danger}; color: {color.fg-danger}; }`,
121
+ examples: [
122
+ ex('Invite by email', box({ tags: ['maya.chen@lumen.co', 'daniel.costa@lumen.co', 'sofia.almeida@lumen.co'], input: 'placeholder="Add teammate…" aria-label="Invite by email" inputmode="email"' }), 'Chips wrap inside the box; the field grows into the rest of the last line.'),
123
+ ex('Keywords', box({ tags: ['Frozen seafood', 'Importer', 'Japan'], input: 'placeholder="Add a keyword and press Enter" aria-label="Keywords"' })),
124
+ ex('Empty', box({ tags: [], input: 'placeholder="Add domains, comma separated" aria-label="Allowed domains"' }), 'With no chips it looks exactly like an Input of the same size.'),
125
+ ex('Sizes', `<div style="display:flex;flex-direction:column;gap:var(--cn-space-3);max-width:420px">${box({ size: 'sm', tags: ['Osaka', 'Tokyo'], input: 'placeholder="Add a city…" aria-label="Cities (small)"' })}${box({ size: 'md', tags: ['Osaka', 'Tokyo'], input: 'placeholder="Add a city…" aria-label="Cities (medium)"' })}${box({ size: 'lg', tags: ['Osaka', 'Tokyo'], input: 'placeholder="Add a city…" aria-label="Cities (large)"' })}</div>`, 'Chips are 20 / 24 / 28px tall and the box keeps the control height as its minimum.'),
126
+ ex('Invalid', box({ tags: ['aisha.khan@lumen.co'], input: 'value="noah.berg@lumen" aria-invalid="true" aria-label="Invite by email"', root: ' data-invalid' }) + `<p class="cn-text-body-sm" style="color:var(--cn-color-fg-danger);margin:var(--cn-space-1-5) 0 0">"noah.berg@lumen" is not a valid email address.</p>`, 'The box takes the danger border; the message names the bad value.'),
127
+ ex('Disabled', box({ tags: ['lumen.co', 'lumen.dev'], input: 'disabled aria-label="Allowed domains"', removeAttrs: ' disabled' })),
128
+ ],
129
+ rules: [
130
+ 'Enter, comma and blur commit the typed text as a chip; Backspace on an empty field removes the last chip; pasting "a, b, c" creates three chips.',
131
+ 'Validate at commit time, not per keystroke: a bad value stays as text in the field with the invalid state and a message that quotes it.',
132
+ 'Chips show the value as typed (an email, a keyword), 1–4 words, never truncated; long values wrap to a new line instead.',
133
+ 'Clicking anywhere in the box focuses the field; the box, not the field, shows the focus ring.',
134
+ 'Set a maximum where one exists (seats, recipients) and say it in the helper text ("Up to 20 emails"); disable the field, not the remove buttons, when it is reached.',
135
+ 'Sizes: md in forms; sm in filter bars and dense panels; lg on onboarding screens.',
136
+ 'Never mix a TagsInput with a dropdown of suggestions in the same box; that is a MultiSelect.',
137
+ 'Width comes from the layout; never set a fixed width, and let the box grow in height as chips wrap.',
138
+ ],
139
+ a11y: [
140
+ 'The field has an accessible name (label for / aria-label); announce additions and removals in a visually hidden live region ("Added maya.chen@lumen.co, 4 recipients").',
141
+ 'Each remove button is a real <button> with aria-label "Remove {value}"; after removing, focus returns to the field.',
142
+ 'Chips themselves are not focusable; the keyboard interacts through the field (Backspace) and the remove buttons (Tab).',
143
+ 'When invalid, aria-invalid="true" on the field and aria-describedby pointing at the message id.',
144
+ ],
145
+ related: ['input', 'tag', 'multi-select', 'field', 'input-group'],
146
+ };