@cosxai/ui 0.25.0 → 1.0.0-alpha.1

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 (165) hide show
  1. package/CHANGELOG.md +597 -0
  2. package/NOTICE +11 -0
  3. package/README.md +52 -0
  4. package/package.json +57 -7
  5. package/src/brand.css +13 -0
  6. package/src/components/Badge.tsx +68 -0
  7. package/src/components/Button.tsx +178 -0
  8. package/src/components/Card.tsx +42 -0
  9. package/src/components/Checkbox.tsx +67 -0
  10. package/src/components/Dialog.tsx +111 -0
  11. package/src/components/Field.tsx +66 -0
  12. package/src/components/Figure.tsx +30 -0
  13. package/src/components/Icon.tsx +32 -0
  14. package/src/components/IconButton.tsx +55 -0
  15. package/src/components/Input.tsx +74 -0
  16. package/src/components/Logo.tsx +62 -0
  17. package/src/components/Marker.tsx +26 -0
  18. package/src/components/MetaLabel.tsx +38 -0
  19. package/src/components/Radio.tsx +72 -0
  20. package/src/components/Select.tsx +351 -0
  21. package/src/components/Spinner.tsx +20 -0
  22. package/src/components/Switch.tsx +67 -0
  23. package/src/components/Table.tsx +196 -0
  24. package/src/components/Tabs.tsx +124 -0
  25. package/src/components/Tag.tsx +35 -0
  26. package/src/components/Textarea.tsx +46 -0
  27. package/src/components/ThemeProvider.tsx +80 -0
  28. package/src/components/Toast.tsx +159 -0
  29. package/src/components/Tooltip.tsx +57 -0
  30. package/src/components/core.test.tsx +242 -0
  31. package/src/components/forms-control.ts +36 -0
  32. package/src/components/forms.test.tsx +280 -0
  33. package/src/components/logo-art.ts +4 -0
  34. package/src/components/overlays.test.tsx +245 -0
  35. package/src/components/useButtonAction.ts +57 -0
  36. package/src/fonts.css +20 -0
  37. package/src/index.ts +34 -24
  38. package/src/lib/cn.ts +23 -6
  39. package/src/modes.css +10 -0
  40. package/src/styles.css +19 -0
  41. package/src/theme.css +143 -0
  42. package/src/tokens/colors.css +112 -0
  43. package/src/tokens/compat.css +36 -0
  44. package/src/tokens/elevation.css +14 -0
  45. package/src/tokens/motion.css +42 -0
  46. package/src/tokens/spacing.css +55 -0
  47. package/src/tokens/typography-cjk.css +42 -0
  48. package/src/tokens/typography.css +81 -0
  49. package/src/actionbar/ActionBar.tsx +0 -837
  50. package/src/actionbar/ActionBarButton.tsx +0 -111
  51. package/src/actionbar/ActionBarMenuGroup.tsx +0 -116
  52. package/src/actionbar/ActionBarModeHandle.tsx +0 -171
  53. package/src/actionbar/ActionBarProvider.tsx +0 -91
  54. package/src/actionbar/actionbar-context.ts +0 -45
  55. package/src/actionbar/index.ts +0 -28
  56. package/src/actionbar/types.ts +0 -147
  57. package/src/actionbar/useActionBarItems.ts +0 -60
  58. package/src/actionbar/useActionBarMode.ts +0 -144
  59. package/src/actionbar/useActionBarPanel.ts +0 -56
  60. package/src/actionbar/useActionBarStatusDot.ts +0 -49
  61. package/src/actionbar/useActionBarToast.ts +0 -33
  62. package/src/ambient/AmbientBackdrop.tsx +0 -74
  63. package/src/ambient/CommandInput.tsx +0 -107
  64. package/src/ambient/SuperbarStrip.tsx +0 -36
  65. package/src/ambient/index.ts +0 -6
  66. package/src/bento/BentoCell.tsx +0 -66
  67. package/src/bento/BentoGrid.tsx +0 -42
  68. package/src/bento/index.ts +0 -2
  69. package/src/command/CommandPalette.tsx +0 -329
  70. package/src/command/CommandProvider.tsx +0 -57
  71. package/src/command/command-context.ts +0 -12
  72. package/src/command/index.ts +0 -6
  73. package/src/command/rank.ts +0 -50
  74. package/src/command/types.ts +0 -37
  75. package/src/command/useCommandSource.ts +0 -37
  76. package/src/dialogs/DialogsProvider.tsx +0 -216
  77. package/src/dialogs/Modal.tsx +0 -260
  78. package/src/dialogs/Toast.tsx +0 -85
  79. package/src/dialogs/dialogs-context.ts +0 -6
  80. package/src/dialogs/index.ts +0 -10
  81. package/src/dialogs/types.ts +0 -37
  82. package/src/dialogs/useDialogs.ts +0 -8
  83. package/src/editorial/EditorialSpotlight.tsx +0 -63
  84. package/src/editorial/Folio.tsx +0 -52
  85. package/src/editorial/PlateMarker.tsx +0 -33
  86. package/src/editorial/RomanSection.tsx +0 -65
  87. package/src/editorial/RunningMarginalia.tsx +0 -65
  88. package/src/editorial/index.ts +0 -10
  89. package/src/hooks/index.ts +0 -5
  90. package/src/hooks/useKeyboardHotkey.ts +0 -93
  91. package/src/hooks/useReducedMotion.ts +0 -20
  92. package/src/hooks/useViewport.ts +0 -61
  93. package/src/layout/Breadcrumb.tsx +0 -74
  94. package/src/layout/LeftNavRail.tsx +0 -139
  95. package/src/layout/MobileTabBar.tsx +0 -101
  96. package/src/layout/NavItem.tsx +0 -128
  97. package/src/layout/NavSearchTrigger.tsx +0 -88
  98. package/src/layout/NavSection.tsx +0 -40
  99. package/src/layout/RightSidebarPanel.tsx +0 -111
  100. package/src/layout/Shell.tsx +0 -91
  101. package/src/layout/SidePanel.tsx +0 -316
  102. package/src/layout/StickyBanner.tsx +0 -83
  103. package/src/layout/Topbar.tsx +0 -68
  104. package/src/layout/index.ts +0 -24
  105. package/src/layout/useNavRailState.ts +0 -69
  106. package/src/lib/time-utils.ts +0 -44
  107. package/src/neobrutalism/Marquee.tsx +0 -81
  108. package/src/neobrutalism/Sticker.tsx +0 -71
  109. package/src/neobrutalism/index.ts +0 -4
  110. package/src/primitives/Avatar.tsx +0 -53
  111. package/src/primitives/Button.tsx +0 -67
  112. package/src/primitives/Card.tsx +0 -41
  113. package/src/primitives/Checkbox.tsx +0 -87
  114. package/src/primitives/Chip.tsx +0 -191
  115. package/src/primitives/Combobox.tsx +0 -507
  116. package/src/primitives/CopyField.tsx +0 -139
  117. package/src/primitives/CountBadge.tsx +0 -50
  118. package/src/primitives/Input.tsx +0 -204
  119. package/src/primitives/Kbd.tsx +0 -45
  120. package/src/primitives/MentionCombobox.tsx +0 -612
  121. package/src/primitives/MultiCombobox.tsx +0 -327
  122. package/src/primitives/PageHeader.tsx +0 -77
  123. package/src/primitives/Radio.tsx +0 -131
  124. package/src/primitives/SegmentedControl.tsx +0 -135
  125. package/src/primitives/Select.tsx +0 -779
  126. package/src/primitives/SignaturePad.tsx +0 -461
  127. package/src/primitives/Tag.tsx +0 -56
  128. package/src/primitives/Textarea.tsx +0 -62
  129. package/src/primitives/ToggleSwitch.tsx +0 -79
  130. package/src/primitives/Tooltip.tsx +0 -213
  131. package/src/primitives/index.ts +0 -53
  132. package/src/pwa/InstallPromptBanner.tsx +0 -132
  133. package/src/pwa/index.ts +0 -4
  134. package/src/pwa/manifest.template.json +0 -20
  135. package/src/pwa/registerSW.ts +0 -55
  136. package/src/riso/Halftone.tsx +0 -85
  137. package/src/riso/Misregister.tsx +0 -63
  138. package/src/riso/RisoStamp.tsx +0 -76
  139. package/src/riso/index.ts +0 -3
  140. package/src/sketch/HandUnderline.tsx +0 -53
  141. package/src/sketch/RoughArrow.tsx +0 -91
  142. package/src/sketch/RoughBox.tsx +0 -73
  143. package/src/sketch/StickyNote.tsx +0 -56
  144. package/src/sketch/index.ts +0 -4
  145. package/src/styles/base.css +0 -186
  146. package/src/styles/chrome-ambient.css +0 -235
  147. package/src/styles/chrome-bento.css +0 -192
  148. package/src/styles/chrome-editorial.css +0 -168
  149. package/src/styles/chrome-neobrutalism.css +0 -326
  150. package/src/styles/chrome-riso.css +0 -339
  151. package/src/styles/chrome-sketch.css +0 -362
  152. package/src/styles/chrome-swiss.css +0 -265
  153. package/src/styles/chrome-terminal.css +0 -272
  154. package/src/styles/fonts.css +0 -1034
  155. package/src/styles/index.css +0 -343
  156. package/src/styles/tokens.css +0 -941
  157. package/src/terminal/AsciiBox.tsx +0 -65
  158. package/src/terminal/BrailleSpinner.tsx +0 -46
  159. package/src/terminal/index.ts +0 -4
  160. package/src/theme/ThemeProvider.tsx +0 -99
  161. package/src/theme/index.ts +0 -6
  162. package/src/theme/inline-script.ts +0 -40
  163. package/src/theme/theme-context.ts +0 -7
  164. package/src/theme/types.ts +0 -39
  165. package/src/theme/useTheme.ts +0 -8
@@ -1,327 +0,0 @@
1
- import {
2
- forwardRef,
3
- useId,
4
- useImperativeHandle,
5
- useRef,
6
- type CSSProperties,
7
- type KeyboardEvent,
8
- type MouseEvent,
9
- type ReactNode,
10
- } from "react";
11
-
12
- import { Chip, type ChipTone } from "./Chip";
13
- import {
14
- COMBOBOX_DEFAULT_INVALID_HINT,
15
- Combobox,
16
- type ComboboxHandle,
17
- type ComboboxProps,
18
- } from "./Combobox";
19
-
20
- /**
21
- * MultiCombobox — a Combobox that collects MANY values. The embedded
22
- * search input clears after every commit (`clearOnCommit`) and the
23
- * parent renders what it collected as a row of removable Chips below
24
- * the field. Exists so recipient lists (group share links: one URL,
25
- * N emails) and similar "pick several people / things" fields share
26
- * one interaction model with the single-value Combobox — same async
27
- * search, same free-entry gate, same blur auto-commit.
28
- *
29
- * Like Combobox, the value is PARENT-OWNED: `onCommit` hands the
30
- * parent each pick / free entry, the parent maps it onto its own
31
- * domain state and passes back `entries` for display. Splitting a
32
- * pasted "a@x.co, b@x.co" into several entries is the parent's job
33
- * inside `onCommit` (`allowFreeEntry` sees the whole raw string).
34
- *
35
- * Per entry: `title` (chip text), `subtitle` (native tooltip),
36
- * `tone` (e.g. `warning` for "still needs a name"), `editing` — the
37
- * one entry currently being edited renders `selected` and, when
38
- * `renderEntryEditor` is given, its editor mounts under the chip row
39
- * (the parent owns the editor's state; use the forwarded handle's
40
- * `focus()` to hand focus back to the search input when it closes).
41
- *
42
- * Keyboard: Backspace in the EMPTY search input removes the last
43
- * entry (`backspaceRemovesLast`, default true). The check is scoped
44
- * to the embedded Combobox's input, so Backspace inside an entry
45
- * editor never removes anything.
46
- *
47
- * Layouts (`layout`): `stacked` (default) — the search field, then
48
- * the chip row underneath. `inline` — a mail-client "To" field: one
49
- * Input-like frame (`.ck-multi-combobox-field`) whose `<ul>` is the
50
- * flex-wrap row: chips as `<li>`s and the bare search input in a last
51
- * presentational `<li>`, so typing continues after the last chip and
52
- * wraps with it. The label renders above as an eyebrow; the invalid
53
- * hint (aria-describedby on the input), editor and hint stay below.
54
- *
55
- * Compose with: Chip (entries), Input (inside `renderEntryEditor`),
56
- * dialogs collecting recipients.
57
- *
58
- * Visual: see docs/components/multi-combobox.
59
- *
60
- * Forwards a `MultiComboboxHandle` (= `ComboboxHandle`): `focus()`
61
- * the search input, `clear()` its text (e.g. after rejecting a
62
- * duplicate). Testids (from `testid`, default "multi-combobox"):
63
- * `${testid}` wrapper, `${testid}-combobox-input` / `-dropdown` /
64
- * `-free-entry` (the embedded Combobox), `${testid}-entries` (the
65
- * list), `${testid}-entry-${key}` (each chip), `${testid}-editor`.
66
- */
67
-
68
- export type MultiComboboxEntry = {
69
- // Stable identity — list key, testid suffix, and the argument to
70
- // `onRemoveEntry` / `onEntryClick`.
71
- key: string;
72
- // Chip text.
73
- title: ReactNode;
74
- // Native tooltip on the chip (e.g. the email behind a name).
75
- subtitle?: string | undefined;
76
- tone?: ChipTone | undefined;
77
- // Marks the entry being edited: chip renders `selected`, and
78
- // `renderEntryEditor(entry)` mounts below the chip row.
79
- editing?: boolean | undefined;
80
- };
81
-
82
- export type MultiComboboxHandle = ComboboxHandle;
83
-
84
- export type MultiComboboxLayout = "stacked" | "inline";
85
-
86
- export type MultiComboboxProps = Omit<
87
- ComboboxProps,
88
- | "committed"
89
- | "committedExtra"
90
- | "onUncommit"
91
- | "clearOnCommit"
92
- | "bare"
93
- | "inputId"
94
- | "inputDescribedBy"
95
- > & {
96
- entries: MultiComboboxEntry[];
97
- // `stacked` (default): field, then chips underneath. `inline`:
98
- // chips inside the field, input after the last chip.
99
- layout?: MultiComboboxLayout | undefined;
100
- onRemoveEntry: (key: string) => void;
101
- // When set, chip labels become buttons (e.g. click-to-edit).
102
- onEntryClick?: ((key: string) => void) | undefined;
103
- // Inline editor for the entry with `editing: true`.
104
- renderEntryEditor?: ((entry: MultiComboboxEntry) => ReactNode) | undefined;
105
- // aria-label of the entries list. Default "Selected items".
106
- entriesLabel?: string | undefined;
107
- // Per-entry aria-label for the chip's × button. Default "Remove".
108
- removeLabel?: ((entry: MultiComboboxEntry) => string) | undefined;
109
- // Backspace in the empty search input removes the last entry.
110
- // Default true.
111
- backspaceRemovesLast?: boolean | undefined;
112
- // Small tertiary line under the entries (counts, duplicate notices).
113
- hint?: ReactNode | undefined;
114
- };
115
-
116
- // Inline layout: the host-drawn field mirrors Input's chrome. Focus
117
- // ring / invalid border / disabled dim come from styles/index.css
118
- // (`.ck-multi-combobox-field`).
119
- const INLINE_FIELD_STYLE: CSSProperties = {
120
- position: "relative",
121
- display: "flex",
122
- flexWrap: "wrap",
123
- alignItems: "center",
124
- gap: 6,
125
- minHeight: 36,
126
- padding: "4px 8px",
127
- boxSizing: "border-box",
128
- background: "var(--ck-bg-surface, #fff)",
129
- border: "1px solid var(--ck-border-strong, #C5C9D2)",
130
- borderRadius: "var(--ck-radius-sm, 6px)",
131
- transition: "border-color var(--ck-dur-fast, 120ms) var(--ck-ease, ease)",
132
- };
133
- // The `<ul>` inside the frame is the actual flex-wrap row.
134
- const INLINE_LIST_STYLE: CSSProperties = {
135
- listStyle: "none",
136
- margin: 0,
137
- padding: 0,
138
- width: "100%",
139
- display: "flex",
140
- flexWrap: "wrap",
141
- alignItems: "center",
142
- gap: 6,
143
- };
144
- // Last <li>: holds the bare Combobox (its root is `position: static`,
145
- // so the dropdown anchors to the frame) and takes the remaining width.
146
- const INLINE_COMBOBOX_ITEM: CSSProperties = {
147
- flex: "1 1 140px",
148
- minWidth: 140,
149
- display: "flex",
150
- };
151
-
152
- export const MultiCombobox = forwardRef<MultiComboboxHandle, MultiComboboxProps>(
153
- function MultiCombobox(
154
- {
155
- entries,
156
- onRemoveEntry,
157
- onEntryClick,
158
- renderEntryEditor,
159
- entriesLabel,
160
- removeLabel,
161
- backspaceRemovesLast = true,
162
- hint,
163
- layout = "stacked",
164
- testid = "multi-combobox",
165
- label,
166
- ...comboboxProps
167
- },
168
- ref,
169
- ) {
170
- // Wraps ONLY the embedded Combobox so the Backspace handler can
171
- // tell its input apart from any input inside `renderEntryEditor`.
172
- // A <div> in stacked mode, the last <li> in inline mode.
173
- const comboboxWrapRef = useRef<HTMLElement | null>(null);
174
- const setComboboxWrap = (el: HTMLElement | null): void => {
175
- comboboxWrapRef.current = el;
176
- };
177
- const listRef = useRef<HTMLUListElement | null>(null);
178
- const handleRef = useRef<ComboboxHandle | null>(null);
179
- useImperativeHandle(ref, () => ({
180
- focus: () => handleRef.current?.focus(),
181
- clear: () => handleRef.current?.clear(),
182
- }));
183
- const inputId = useId();
184
- const hintId = useId();
185
- const inline = layout === "inline";
186
- const editing = entries.find((entry) => entry.editing);
187
-
188
- const handleKeyDownCapture = (e: KeyboardEvent<HTMLDivElement>): void => {
189
- if (!backspaceRemovesLast || e.key !== "Backspace") return;
190
- const target = e.target;
191
- if (!(target instanceof HTMLInputElement)) return;
192
- if (!comboboxWrapRef.current?.contains(target)) return;
193
- if (target.value !== "") return;
194
- const last = entries[entries.length - 1];
195
- if (!last) return;
196
- onRemoveEntry(last.key);
197
- };
198
-
199
- // Clicking the field's empty area (inline) focuses the input, like
200
- // a mail client's To field. Chip / input clicks handle themselves.
201
- const handleFieldMouseDown = (e: MouseEvent<HTMLDivElement>): void => {
202
- if (e.target !== e.currentTarget && e.target !== listRef.current) return;
203
- e.preventDefault();
204
- handleRef.current?.focus();
205
- };
206
-
207
- const comboboxEl = (
208
- <Combobox
209
- ref={handleRef}
210
- {...comboboxProps}
211
- label={inline ? undefined : label}
212
- bare={inline}
213
- inputId={inline ? inputId : undefined}
214
- inputDescribedBy={inline ? hintId : undefined}
215
- clearOnCommit
216
- committed={null}
217
- testid={`${testid}-combobox`}
218
- />
219
- );
220
-
221
- const entriesList = (
222
- <ul
223
- ref={listRef}
224
- role="list"
225
- aria-label={entriesLabel ?? "Selected items"}
226
- data-testid={`${testid}-entries`}
227
- style={
228
- inline
229
- ? INLINE_LIST_STYLE
230
- : {
231
- listStyle: "none",
232
- margin: entries.length > 0 ? "8px 0 0" : 0,
233
- padding: 0,
234
- display: "flex",
235
- flexWrap: "wrap",
236
- gap: 6,
237
- }
238
- }
239
- >
240
- {entries.map((entry) => (
241
- <li key={entry.key} style={{ minWidth: 0, maxWidth: "100%" }}>
242
- <Chip
243
- data-testid={`${testid}-entry-${entry.key}`}
244
- title={entry.subtitle}
245
- tone={entry.tone}
246
- selected={entry.editing}
247
- disabled={comboboxProps.disabled}
248
- onClick={onEntryClick ? () => onEntryClick(entry.key) : undefined}
249
- onRemove={() => onRemoveEntry(entry.key)}
250
- removeLabel={removeLabel?.(entry)}
251
- >
252
- {entry.title}
253
- </Chip>
254
- </li>
255
- ))}
256
- {inline ? (
257
- // Not an entry: presentational so the list still counts N
258
- // chips for assistive tech.
259
- <li ref={setComboboxWrap} role="presentation" style={INLINE_COMBOBOX_ITEM}>
260
- {comboboxEl}
261
- </li>
262
- ) : null}
263
- </ul>
264
- );
265
-
266
- return (
267
- <div data-testid={testid} onKeyDownCapture={handleKeyDownCapture}>
268
- {inline ? (
269
- <>
270
- {label ? (
271
- <label
272
- htmlFor={inputId}
273
- className="ck-eyebrow"
274
- style={{ color: "var(--ck-text-secondary)", display: "block", marginBottom: 6 }}
275
- >
276
- {label}
277
- </label>
278
- ) : null}
279
- <div
280
- className="ck-multi-combobox-field"
281
- data-testid={`${testid}-field`}
282
- style={INLINE_FIELD_STYLE}
283
- onMouseDown={handleFieldMouseDown}
284
- >
285
- {entriesList}
286
- </div>
287
- <div
288
- id={hintId}
289
- className="ck-multi-combobox-invalid-hint"
290
- style={{
291
- marginTop: 6,
292
- font: "400 11px/1.4 var(--ck-font-sans)",
293
- color: "var(--ck-critical)",
294
- }}
295
- >
296
- {comboboxProps.invalidHint ?? COMBOBOX_DEFAULT_INVALID_HINT}
297
- </div>
298
- </>
299
- ) : (
300
- <>
301
- <div ref={setComboboxWrap}>{comboboxEl}</div>
302
- {entriesList}
303
- </>
304
- )}
305
-
306
- {editing && renderEntryEditor ? (
307
- <div data-testid={`${testid}-editor`} style={{ marginTop: 8 }}>
308
- {renderEntryEditor(editing)}
309
- </div>
310
- ) : null}
311
-
312
- {hint ? (
313
- <div
314
- style={{
315
- marginTop: 6,
316
- fontSize: 11,
317
- lineHeight: 1.4,
318
- color: "var(--ck-text-tertiary, #8B92A3)",
319
- }}
320
- >
321
- {hint}
322
- </div>
323
- ) : null}
324
- </div>
325
- );
326
- },
327
- );
@@ -1,77 +0,0 @@
1
- import type { ReactNode, CSSProperties } from "react";
2
-
3
- // Standard list-page header — eyebrow + title + optional
4
- // description + right-aligned action cluster. Wrap the top of
5
- // list / overview pages so visual rhythm stays consistent across
6
- // the app.
7
-
8
- export interface PageHeaderProps {
9
- // Small uppercase mono cap above the title — "ACCESS · SHARES".
10
- eyebrow?: string;
11
- // Main heading (typically a sentence: "Bundles you've sent.").
12
- title: ReactNode;
13
- // Optional one-or-two-line caption under the title.
14
- description?: ReactNode;
15
- // Right-aligned cluster of buttons / chips / status items.
16
- actions?: ReactNode;
17
- className?: string;
18
- style?: CSSProperties;
19
- }
20
-
21
- export function PageHeader({
22
- eyebrow,
23
- title,
24
- description,
25
- actions,
26
- className,
27
- style,
28
- }: PageHeaderProps) {
29
- return (
30
- <header
31
- className={className}
32
- style={{
33
- display: "flex",
34
- alignItems: "flex-end",
35
- gap: 16,
36
- marginBottom: 24,
37
- paddingBottom: 16,
38
- borderBottom: "1px solid var(--ck-border-subtle)",
39
- fontFamily: "var(--ck-font-sans)",
40
- ...style,
41
- }}
42
- >
43
- <div style={{ flex: 1, minWidth: 0 }}>
44
- {eyebrow && (
45
- <div className="ck-eyebrow" style={{ marginBottom: 6 }}>
46
- {eyebrow}
47
- </div>
48
- )}
49
- <h1
50
- style={{
51
- font: "500 22px/1.3 var(--ck-font-sans)",
52
- letterSpacing: "-0.005em",
53
- color: "var(--ck-text-primary)",
54
- margin: 0,
55
- }}
56
- >
57
- {title}
58
- </h1>
59
- {description && (
60
- <p
61
- style={{
62
- font: "400 13px/1.55 var(--ck-font-sans)",
63
- color: "var(--ck-text-tertiary)",
64
- margin: "6px 0 0",
65
- maxWidth: "44rem",
66
- }}
67
- >
68
- {description}
69
- </p>
70
- )}
71
- </div>
72
- {actions && (
73
- <div style={{ display: "flex", gap: 8, flex: "none" }}>{actions}</div>
74
- )}
75
- </header>
76
- );
77
- }
@@ -1,131 +0,0 @@
1
- import { cn } from "../lib/cn";
2
-
3
- // Custom-styled radio — accent-bordered circle that becomes filled
4
- // accent + white dot when checked. Paired with Checkbox: same
5
- // disabled/label/suffix contract, same 16px hit target size. Use
6
- // this anywhere you'd reach for a `<input type="radio">` — the
7
- // native control renders as the OS accent (blue on macOS by
8
- // default) which doesn't respect --ck-accent per workspace, and the
9
- // unstyled defaults look inconsistent alongside our Checkbox.
10
- //
11
- // Radio is a single option in a group; the caller owns the group's
12
- // selection state and passes `checked` + `onChange` per option.
13
- // Radios with the same `name` still get the browser's native "one
14
- // selected at a time" behaviour if you need form-post semantics; we
15
- // forward it to a hidden native input so aria-pressed on the visual
16
- // button doesn't drift from the actual form value.
17
-
18
- export interface RadioProps {
19
- checked: boolean;
20
- onChange: (next: boolean) => void;
21
- label?: React.ReactNode;
22
- suffix?: React.ReactNode;
23
- disabled?: boolean;
24
- /**
25
- * Native radio-group name. Optional but recommended: browsers
26
- * enforce "only one checked at a time" for same-name radios, so
27
- * passing a shared name is a belt-and-braces guard against a
28
- * stale onChange handler leaving two visually-checked radios.
29
- */
30
- name?: string;
31
- /**
32
- * Value that lands in the form-post payload when this radio is
33
- * checked. Only meaningful when `name` is also set.
34
- */
35
- value?: string;
36
- className?: string;
37
- /** Test hook forwarded to the visual button — see Checkbox. */
38
- "data-testid"?: string;
39
- }
40
-
41
- export function Radio({
42
- checked,
43
- onChange,
44
- label,
45
- suffix,
46
- disabled,
47
- name,
48
- value,
49
- className,
50
- "data-testid": testId,
51
- }: RadioProps) {
52
- return (
53
- <label
54
- className={cn("ck-radio", className)}
55
- style={{
56
- display: "inline-flex",
57
- alignItems: "center",
58
- gap: 8,
59
- font: "400 13px/1 var(--ck-font-sans)",
60
- color: "var(--ck-text-secondary)",
61
- cursor: disabled ? "not-allowed" : "pointer",
62
- opacity: disabled ? 0.5 : 1,
63
- userSelect: "none",
64
- }}
65
- >
66
- <button
67
- type="button"
68
- role="radio"
69
- aria-checked={checked}
70
- disabled={disabled}
71
- onClick={() => !disabled && !checked && onChange(true)}
72
- data-testid={testId}
73
- style={{
74
- width: 16,
75
- height: 16,
76
- borderRadius: "50%",
77
- border: "1.5px solid var(--ck-accent)",
78
- background: "transparent",
79
- display: "inline-flex",
80
- alignItems: "center",
81
- justifyContent: "center",
82
- padding: 0,
83
- cursor: disabled ? "not-allowed" : "pointer",
84
- flexShrink: 0,
85
- }}
86
- >
87
- {checked && (
88
- <span
89
- aria-hidden
90
- style={{
91
- width: 8,
92
- height: 8,
93
- borderRadius: "50%",
94
- background: "var(--ck-accent)",
95
- display: "inline-block",
96
- }}
97
- />
98
- )}
99
- </button>
100
- {/* Hidden native input keeps the browser's same-name enforcement
101
- + lets consumers use the surrounding <form> for post payload. */}
102
- {name && (
103
- <input
104
- type="radio"
105
- name={name}
106
- value={value}
107
- checked={checked}
108
- onChange={(e) => !disabled && onChange(e.target.checked)}
109
- disabled={disabled}
110
- tabIndex={-1}
111
- aria-hidden
112
- style={{
113
- position: "absolute",
114
- width: 1,
115
- height: 1,
116
- padding: 0,
117
- margin: -1,
118
- overflow: "hidden",
119
- clip: "rect(0,0,0,0)",
120
- whiteSpace: "nowrap",
121
- border: 0,
122
- }}
123
- />
124
- )}
125
- {label}
126
- {suffix && (
127
- <span style={{ color: "var(--ck-text-tertiary)" }}>{suffix}</span>
128
- )}
129
- </label>
130
- );
131
- }
@@ -1,135 +0,0 @@
1
- import { cn } from "../lib/cn";
2
-
3
- // SegmentedControl — a horizontal set of mutually-exclusive options
4
- // rendered as one pill-shaped track with a raised "active" segment.
5
- // Use it where the choice swaps the content below it (mode switch,
6
- // filter scope), i.e. wherever a pair of radio buttons would be
7
- // semantically right but visually too quiet. For true page-level
8
- // navigation reach for router links instead; for on/off state use
9
- // ToggleSwitch.
10
- //
11
- // The caller owns the selection: pass `value` + `onChange`, one
12
- // option per segment. Per-option `disabled` renders the segment
13
- // non-interactive with a hint via `title` (a disabled option should
14
- // tell the user why — pass `disabledHint`).
15
- //
16
- // Semantics are a radiogroup (not tabs): arrow keys move selection
17
- // directly, matching native radio behaviour, and screen readers
18
- // announce "x of y" positioning for free.
19
-
20
- export interface SegmentedControlOption<V extends string = string> {
21
- value: V;
22
- label: React.ReactNode;
23
- disabled?: boolean;
24
- /** Shown as the native tooltip on a disabled segment — say WHY. */
25
- disabledHint?: string;
26
- "data-testid"?: string;
27
- }
28
-
29
- export interface SegmentedControlProps<V extends string = string> {
30
- value: V;
31
- onChange: (next: V) => void;
32
- options: SegmentedControlOption<V>[];
33
- /** Disables the whole control (per-option disabled still applies). */
34
- disabled?: boolean;
35
- /** "full" stretches the track and gives segments equal width. */
36
- fit?: "auto" | "full";
37
- "aria-label"?: string;
38
- className?: string;
39
- "data-testid"?: string;
40
- }
41
-
42
- export function SegmentedControl<V extends string = string>({
43
- value,
44
- onChange,
45
- options,
46
- disabled,
47
- fit = "auto",
48
- "aria-label": ariaLabel,
49
- className,
50
- "data-testid": testId,
51
- }: SegmentedControlProps<V>) {
52
- // Arrow keys move selection to the nearest enabled neighbour,
53
- // wrapping — the native radio-group keyboard model.
54
- const move = (from: number, step: 1 | -1) => {
55
- for (let i = 1; i <= options.length; i++) {
56
- const next = options[(from + step * i + options.length * i) % options.length];
57
- if (next && !next.disabled) {
58
- onChange(next.value);
59
- return;
60
- }
61
- }
62
- };
63
-
64
- return (
65
- <div
66
- role="radiogroup"
67
- aria-label={ariaLabel}
68
- className={cn("ck-segmented", className)}
69
- data-testid={testId}
70
- // The track is deliberately unfilled (owner feedback,
71
- // 2026-08-19): a muted-filled track reads as a heavy block on
72
- // warm canvases — the hairline border delimits the group and
73
- // the raised selected segment carries ALL the contrast. This
74
- // also makes the control look right on any surface (canvas,
75
- // card, modal) without per-context tuning.
76
- style={{
77
- display: fit === "full" ? "flex" : "inline-flex",
78
- alignItems: "stretch",
79
- gap: 2,
80
- padding: 2,
81
- borderRadius: 8,
82
- border: "1px solid var(--ck-border-subtle)",
83
- background: "transparent",
84
- opacity: disabled ? 0.5 : 1,
85
- }}
86
- >
87
- {options.map((opt, i) => {
88
- const selected = opt.value === value;
89
- const optDisabled = disabled || opt.disabled;
90
- return (
91
- <button
92
- key={opt.value}
93
- type="button"
94
- role="radio"
95
- aria-checked={selected}
96
- disabled={optDisabled}
97
- title={opt.disabled ? opt.disabledHint : undefined}
98
- data-testid={opt["data-testid"]}
99
- // Roving tabindex: the group is one tab stop; arrows walk it.
100
- tabIndex={selected ? 0 : -1}
101
- onClick={() => !optDisabled && !selected && onChange(opt.value)}
102
- onKeyDown={(e) => {
103
- if (e.key === "ArrowRight" || e.key === "ArrowDown") {
104
- e.preventDefault();
105
- move(i, 1);
106
- } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
107
- e.preventDefault();
108
- move(i, -1);
109
- }
110
- }}
111
- style={{
112
- flex: fit === "full" ? 1 : undefined,
113
- padding: "6px 14px",
114
- borderRadius: 6,
115
- border: "1px solid transparent",
116
- font: `500 13px/1.2 var(--ck-font-sans)`,
117
- color: selected
118
- ? "var(--ck-text-primary)"
119
- : "var(--ck-text-secondary)",
120
- background: selected ? "var(--ck-bg-surface)" : "transparent",
121
- boxShadow: selected ? "0 1px 2px rgba(0,0,0,0.06)" : "none",
122
- borderColor: selected ? "var(--ck-border-subtle)" : "transparent",
123
- cursor: optDisabled ? "not-allowed" : "pointer",
124
- opacity: opt.disabled && !disabled ? 0.45 : 1,
125
- whiteSpace: "nowrap",
126
- transition: "background 120ms ease, color 120ms ease",
127
- }}
128
- >
129
- {opt.label}
130
- </button>
131
- );
132
- })}
133
- </div>
134
- );
135
- }