cursedbelt 5.55.2 → 5.58.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 (174) hide show
  1. package/dist/react/charts/tokens.js +4 -4
  2. package/dist/react/code-editor/CodeEditor.d.ts.map +1 -1
  3. package/dist/react/code-editor/CodeEditor.js +2 -1
  4. package/dist/react/code-editor/CodeEditor.js.map +1 -1
  5. package/dist/react/code-editor/lineAbove.d.ts +17 -0
  6. package/dist/react/code-editor/lineAbove.d.ts.map +1 -0
  7. package/dist/react/code-editor/lineAbove.js +34 -0
  8. package/dist/react/code-editor/lineAbove.js.map +1 -0
  9. package/dist/react/companion-link/CompanionLinkStatus.d.ts.map +1 -1
  10. package/dist/react/companion-link/CompanionLinkStatus.js +2 -1
  11. package/dist/react/companion-link/CompanionLinkStatus.js.map +1 -1
  12. package/dist/react/components/Accordion.d.ts.map +1 -1
  13. package/dist/react/components/Accordion.js +2 -2
  14. package/dist/react/components/Accordion.js.map +1 -1
  15. package/dist/react/components/Badge.d.ts +20 -1
  16. package/dist/react/components/Badge.d.ts.map +1 -1
  17. package/dist/react/components/Badge.js +30 -3
  18. package/dist/react/components/Badge.js.map +1 -1
  19. package/dist/react/components/Button.d.ts.map +1 -1
  20. package/dist/react/components/Button.js +8 -2
  21. package/dist/react/components/Button.js.map +1 -1
  22. package/dist/react/components/Collapsible.d.ts.map +1 -1
  23. package/dist/react/components/Collapsible.js +2 -2
  24. package/dist/react/components/Collapsible.js.map +1 -1
  25. package/dist/react/components/DisclosureButton.d.ts +2 -2
  26. package/dist/react/components/DisclosureButton.d.ts.map +1 -1
  27. package/dist/react/components/DisclosureButton.js +9 -6
  28. package/dist/react/components/DisclosureButton.js.map +1 -1
  29. package/dist/react/components/EditorPage.d.ts +78 -0
  30. package/dist/react/components/EditorPage.d.ts.map +1 -0
  31. package/dist/react/components/EditorPage.js +133 -0
  32. package/dist/react/components/EditorPage.js.map +1 -0
  33. package/dist/react/components/IconPicker.d.ts +70 -0
  34. package/dist/react/components/IconPicker.d.ts.map +1 -0
  35. package/dist/react/components/IconPicker.js +175 -0
  36. package/dist/react/components/IconPicker.js.map +1 -0
  37. package/dist/react/components/PropertyList.d.ts +45 -0
  38. package/dist/react/components/PropertyList.d.ts.map +1 -0
  39. package/dist/react/components/PropertyList.js +12 -0
  40. package/dist/react/components/PropertyList.js.map +1 -0
  41. package/dist/react/components/Wizard/ReviewStep.d.ts +1 -1
  42. package/dist/react/components/Wizard/ReviewStep.d.ts.map +1 -1
  43. package/dist/react/components/Wizard/ReviewStep.js +3 -3
  44. package/dist/react/components/Wizard/ReviewStep.js.map +1 -1
  45. package/dist/react/components/editorPage.css +421 -0
  46. package/dist/react/components/formKit.d.ts +3 -1
  47. package/dist/react/components/formKit.d.ts.map +1 -1
  48. package/dist/react/components/formKit.js +3 -1
  49. package/dist/react/components/formKit.js.map +1 -1
  50. package/dist/react/file-tree/FileTree.d.ts.map +1 -1
  51. package/dist/react/file-tree/FileTree.js +7 -0
  52. package/dist/react/file-tree/FileTree.js.map +1 -1
  53. package/dist/react/file-tree/fileTree.css +14 -0
  54. package/dist/react/filter-rail/FilterRail.js +1 -1
  55. package/dist/react/filter-rail/FilterRail.js.map +1 -1
  56. package/dist/react/filter-rail/RailSection.js +2 -2
  57. package/dist/react/filter-rail/RailSection.js.map +1 -1
  58. package/dist/react/filter-rail/headerContextMenu.d.ts.map +1 -1
  59. package/dist/react/filter-rail/headerContextMenu.js +20 -1
  60. package/dist/react/filter-rail/headerContextMenu.js.map +1 -1
  61. package/dist/react/hooks/longPressContextMenu.d.ts +35 -0
  62. package/dist/react/hooks/longPressContextMenu.d.ts.map +1 -0
  63. package/dist/react/hooks/longPressContextMenu.js +136 -0
  64. package/dist/react/hooks/longPressContextMenu.js.map +1 -0
  65. package/dist/react/hooks/useLongPressContextMenu.d.ts +4 -0
  66. package/dist/react/hooks/useLongPressContextMenu.d.ts.map +1 -0
  67. package/dist/react/hooks/useLongPressContextMenu.js +12 -0
  68. package/dist/react/hooks/useLongPressContextMenu.js.map +1 -0
  69. package/dist/react/index.d.ts +4 -1
  70. package/dist/react/index.d.ts.map +1 -1
  71. package/dist/react/index.js +3 -0
  72. package/dist/react/index.js.map +1 -1
  73. package/dist/react/line-keys/index.d.ts +2 -0
  74. package/dist/react/line-keys/index.d.ts.map +1 -0
  75. package/dist/react/line-keys/index.js +2 -0
  76. package/dist/react/line-keys/index.js.map +1 -0
  77. package/dist/react/line-keys/lineKeys.d.ts +62 -0
  78. package/dist/react/line-keys/lineKeys.d.ts.map +1 -0
  79. package/dist/react/line-keys/lineKeys.js +159 -0
  80. package/dist/react/line-keys/lineKeys.js.map +1 -0
  81. package/dist/react/master-detail/DetailPane.d.ts +3 -0
  82. package/dist/react/master-detail/DetailPane.d.ts.map +1 -1
  83. package/dist/react/master-detail/DetailPane.js +5 -2
  84. package/dist/react/master-detail/DetailPane.js.map +1 -1
  85. package/dist/react/nav/SideNav.js +1 -1
  86. package/dist/react/nav/SideNav.js.map +1 -1
  87. package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
  88. package/dist/styles-areas/analytics.css +14 -14
  89. package/dist/styles-areas/charts.css +3 -3
  90. package/dist/styles-areas/companion-link.css +2 -2
  91. package/dist/styles-areas/core.css +50 -45
  92. package/dist/styles-areas/data-table.css +15 -16
  93. package/dist/styles-areas/disk-usage.css +4 -4
  94. package/dist/styles-areas/fields.css +11 -11
  95. package/dist/styles-areas/folder-tree.css +3 -3
  96. package/dist/styles-areas/layout-engine.css +2 -2
  97. package/dist/styles-areas/master-detail.css +4 -5
  98. package/dist/styles-areas/media-gallery.css +9 -9
  99. package/dist/styles-areas/media.css +3 -3
  100. package/dist/styles-areas/nav.css +5 -5
  101. package/dist/styles-areas/notifications.css +3 -3
  102. package/dist/styles-areas/problem-detail.css +3 -3
  103. package/dist/styles-areas/sharing.css +2 -3
  104. package/dist/styles-areas/stats.css +3 -3
  105. package/dist/styles-areas/wizard.css +7 -8
  106. package/dist/styles-areas/workbench.css +4 -4
  107. package/dist/styles-utilities.css +49 -10
  108. package/dist/theme.css +38 -22
  109. package/package.json +14 -2
  110. package/scripts/guardrails-baseline.json +2 -1
  111. package/scripts/guardrailsEnforce.spec.ts +32 -0
  112. package/scripts/guardrailsEnforce.ts +67 -1
  113. package/scripts/styleAreas.ts +3 -0
  114. package/src/react/charts/tokens.ts +4 -4
  115. package/src/react/code-editor/CodeEditor.tsx +2 -1
  116. package/src/react/code-editor/lineAbove.spec.ts +36 -0
  117. package/src/react/code-editor/lineAbove.ts +35 -0
  118. package/src/react/companion-link/CompanionLinkStatus.tsx +10 -12
  119. package/src/react/components/Accordion.tsx +5 -4
  120. package/src/react/components/Badge.capsule.spec.ts +1 -1
  121. package/src/react/components/Badge.spec.tsx +22 -1
  122. package/src/react/components/Badge.tsx +66 -11
  123. package/src/react/components/Button.spec.tsx +5 -4
  124. package/src/react/components/Button.tsx +8 -2
  125. package/src/react/components/Collapsible.tsx +5 -4
  126. package/src/react/components/DisclosureButton.spec.tsx +47 -0
  127. package/src/react/components/DisclosureButton.tsx +11 -7
  128. package/src/react/components/EditorPage.spec.tsx +106 -0
  129. package/src/react/components/EditorPage.tsx +281 -0
  130. package/src/react/components/IconPicker.spec.tsx +84 -0
  131. package/src/react/components/IconPicker.tsx +289 -0
  132. package/src/react/components/PropertyList.spec.tsx +110 -0
  133. package/src/react/components/PropertyList.tsx +92 -0
  134. package/src/react/components/Wizard/ReviewStep.tsx +7 -7
  135. package/src/react/components/editorPage.css +421 -0
  136. package/src/react/components/formKit.ts +10 -1
  137. package/src/react/components/formKitStylesheets.spec.ts +47 -0
  138. package/src/react/file-tree/FileTree.spec.tsx +19 -0
  139. package/src/react/file-tree/FileTree.tsx +8 -0
  140. package/src/react/file-tree/fileTree.css +14 -0
  141. package/src/react/filter-rail/FilterRail.tsx +1 -1
  142. package/src/react/filter-rail/RailSection.tsx +2 -2
  143. package/src/react/filter-rail/headerContextMenu.spec.tsx +52 -0
  144. package/src/react/filter-rail/headerContextMenu.ts +19 -1
  145. package/src/react/hooks/longPressContextMenu.spec.ts +102 -0
  146. package/src/react/hooks/longPressContextMenu.ts +135 -0
  147. package/src/react/hooks/useLongPressContextMenu.ts +15 -0
  148. package/src/react/index.ts +11 -1
  149. package/src/react/line-keys/index.ts +1 -0
  150. package/src/react/line-keys/lineKeys.spec.tsx +176 -0
  151. package/src/react/line-keys/lineKeys.ts +176 -0
  152. package/src/react/master-detail/DetailPane.tsx +14 -18
  153. package/src/react/nav/SideNav.tsx +1 -1
  154. package/src/styles-areas/analytics.css +14 -14
  155. package/src/styles-areas/charts.css +3 -3
  156. package/src/styles-areas/companion-link.css +2 -2
  157. package/src/styles-areas/core.css +50 -45
  158. package/src/styles-areas/data-table.css +15 -16
  159. package/src/styles-areas/disk-usage.css +4 -4
  160. package/src/styles-areas/fields.css +11 -11
  161. package/src/styles-areas/folder-tree.css +3 -3
  162. package/src/styles-areas/layout-engine.css +2 -2
  163. package/src/styles-areas/master-detail.css +4 -5
  164. package/src/styles-areas/media-gallery.css +9 -9
  165. package/src/styles-areas/media.css +3 -3
  166. package/src/styles-areas/nav.css +5 -5
  167. package/src/styles-areas/notifications.css +3 -3
  168. package/src/styles-areas/problem-detail.css +3 -3
  169. package/src/styles-areas/sharing.css +2 -3
  170. package/src/styles-areas/stats.css +3 -3
  171. package/src/styles-areas/wizard.css +7 -8
  172. package/src/styles-areas/workbench.css +4 -4
  173. package/src/styles-utilities.css +49 -10
  174. package/src/theme.css +38 -22
@@ -0,0 +1,281 @@
1
+ import { ArrowLeft } from 'lucide-react';
2
+ import {
3
+ type CSSProperties,
4
+ type KeyboardEvent,
5
+ type PointerEvent,
6
+ type ReactNode,
7
+ useCallback,
8
+ useEffect,
9
+ useId,
10
+ useRef,
11
+ useState,
12
+ } from 'react';
13
+ import { useDiscardGuard } from '../hooks/useDiscardGuard.js';
14
+ import { cn } from '../lib/cn.js';
15
+ import { UnstyledButton } from './UnstyledButton.js';
16
+ import './formKit.css';
17
+ import './editorPage.css';
18
+
19
+ export interface EditorPageProps {
20
+ /** What is being made or changed — *"New template"*, *"Edit Dynatrace"*. The page's heading. */
21
+ title: ReactNode;
22
+ /** Where Back returns to, said before the title as a crumb — *"Templates"*. */
23
+ backLabel?: string;
24
+ /** Leave the page. Back, and Escape from anywhere on it — asked first while `isDirty`. */
25
+ onBack: () => void;
26
+ /**
27
+ * The form holds edits nobody has saved. Back and Escape then ask *"Discard changes?"*
28
+ * before leaving, as an editing `Modal` does (`useDiscardGuard`) — a half-made thing is
29
+ * never thrown away by a stray key. The page's own Cancel, if it has one, is the caller's.
30
+ */
31
+ isDirty?: boolean;
32
+ /** Top-right: Cancel / Save, a delete. An `ActionBar`'s buttons, or a few `Button`s. */
33
+ actions?: ReactNode;
34
+ /** The form — the left side. Drawn quiet (`QuietForm`) unless `quiet={false}`. */
35
+ children: ReactNode;
36
+ /** The live result of the form — the right side, as a mock somebody can tinker with. */
37
+ preview: ReactNode;
38
+ /** What the preview is. Its heading, and its tab on a phone. Default "Preview". */
39
+ previewLabel?: string;
40
+ /** Small controls over the preview — show or hide parts of it. Drawn beside its heading. */
41
+ previewTools?: ReactNode;
42
+ /** The form's share of the width, 0–1, before anybody drags. Default 0.5. */
43
+ defaultSplit?: number;
44
+ /** localStorage key the dragged split is kept under. Omit to forget it on close. */
45
+ storageKey?: string;
46
+ /** Linear's form: no boxes until a control is pointed at. Default `true`. */
47
+ quiet?: boolean;
48
+ /**
49
+ * `'overlay'` (default) covers the window — the page opens over whatever was there and Back
50
+ * returns to it untouched. `'inline'` fills its container, for a host whose router owns pages.
51
+ */
52
+ presentation?: 'overlay' | 'inline';
53
+ className?: string;
54
+ }
55
+
56
+ /** What answers Escape itself, before the page does. */
57
+ const OWNS_ESCAPE =
58
+ '[role="listbox"],[role="menu"],[role="combobox"][aria-expanded="true"],[aria-haspopup]:not([aria-haspopup="false"])[aria-expanded="true"]';
59
+
60
+ const MIN_SPLIT = 0.25;
61
+ const MAX_SPLIT = 0.75;
62
+ const clampSplit = (value: number): number => Math.min(MAX_SPLIT, Math.max(MIN_SPLIT, value));
63
+
64
+ function readSplit(key: string | undefined, fallback: number): number {
65
+ if (!key || typeof localStorage === 'undefined') return fallback;
66
+ const stored = Number(localStorage.getItem(key));
67
+ return Number.isFinite(stored) && stored > 0 ? clampSplit(stored) : fallback;
68
+ }
69
+
70
+ /**
71
+ * A creation page: the form on the left, a live mock of what it makes on the right, and a
72
+ * divider between them that drags.
73
+ *
74
+ * The owner, 2026-10-05, on desk's template editor: *"instead of opening a modal I want to open a
75
+ * page with a split view where I see the forms and fields … on the left and I see what it looks
76
+ * like so far and can tinker with a mock version of it on the right. I should be able to drag to
77
+ * resize a vertical divider … On mobile it will need an appropriate narrow layout."*
78
+ *
79
+ * - **Wide** (its container ≥ 48rem): two columns and a `separator` between them — drag it,
80
+ * or focus it and use ← / → (Home / End for the limits); a double-click puts it back. The
81
+ * split is kept under `storageKey`. Each side scrolls on its own, so a long form never pushes
82
+ * the preview out of sight.
83
+ * - **Narrow**: one side at a time, chosen by an Edit / Preview switch in the header — a phone
84
+ * has no room for both, and a pinned half-height preview leaves neither usable.
85
+ *
86
+ * The header is the page's own: a Back arrow with the crumb, the title, and the actions
87
+ * top-right where Linear and every desktop editor put Save. Escape anywhere is Back, as it was
88
+ * when the same form was a dialog.
89
+ *
90
+ * Plain stylesheet (`formKit.css`) — draws without Tailwind; `cursedbelt/react/form-kit`.
91
+ */
92
+ export function EditorPage({
93
+ title,
94
+ backLabel = 'Back',
95
+ onBack,
96
+ isDirty,
97
+ actions,
98
+ children,
99
+ preview,
100
+ previewLabel = 'Preview',
101
+ previewTools,
102
+ defaultSplit = 0.5,
103
+ storageKey,
104
+ quiet = true,
105
+ presentation = 'overlay',
106
+ className,
107
+ }: EditorPageProps) {
108
+ const [split, setSplit] = useState(() => readSplit(storageKey, clampSplit(defaultSplit)));
109
+ const [mode, setMode] = useState<'edit' | 'preview'>('edit');
110
+ const [dragging, setDragging] = useState(false);
111
+ const splitRef = useRef<HTMLDivElement>(null);
112
+ const rootRef = useRef<HTMLDivElement>(null);
113
+ const titleId = useId();
114
+ const formId = useId();
115
+ const previewId = useId();
116
+
117
+ const { guardedClose, discardOverlay, isConfirming, cancelDiscard } = useDiscardGuard({ onClose: onBack, isDirty });
118
+
119
+ const commit = useCallback(
120
+ (value: number) => {
121
+ const next = clampSplit(value);
122
+ setSplit(next);
123
+ if (storageKey && typeof localStorage !== 'undefined') localStorage.setItem(storageKey, String(next));
124
+ },
125
+ [storageKey],
126
+ );
127
+
128
+ // An overlay takes focus on open and gives it back on close, so the keyboard is where the
129
+ // eye is; the first field's own `autoFocus` still wins, because it runs first.
130
+ useEffect(() => {
131
+ if (presentation !== 'overlay') return;
132
+ const before = document.activeElement as HTMLElement | null;
133
+ const root = rootRef.current;
134
+ if (root && !root.contains(document.activeElement)) root.focus({ preventScroll: true });
135
+ return () => {
136
+ if (before?.isConnected) before.focus({ preventScroll: true });
137
+ };
138
+ }, [presentation]);
139
+
140
+ const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
141
+ if (event.button !== 0) return;
142
+ event.preventDefault();
143
+ event.currentTarget.setPointerCapture(event.pointerId);
144
+ setDragging(true);
145
+ };
146
+
147
+ const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
148
+ if (!dragging) return;
149
+ const box = splitRef.current?.getBoundingClientRect();
150
+ if (!box || box.width === 0) return;
151
+ commit((event.clientX - box.left) / box.width);
152
+ };
153
+
154
+ const onPointerUp = (event: PointerEvent<HTMLDivElement>) => {
155
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
156
+ setDragging(false);
157
+ };
158
+
159
+ const onDividerKey = (event: KeyboardEvent<HTMLDivElement>) => {
160
+ const step = event.shiftKey ? 0.1 : 0.02;
161
+ const next =
162
+ event.key === 'ArrowLeft' ? split - step
163
+ : event.key === 'ArrowRight' ? split + step
164
+ : event.key === 'Home' ? MIN_SPLIT
165
+ : event.key === 'End' ? MAX_SPLIT
166
+ : null;
167
+ if (next === null) return;
168
+ event.preventDefault();
169
+ commit(next);
170
+ };
171
+
172
+ const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
173
+ if (event.key !== 'Escape' || event.defaultPrevented) return;
174
+ // A menu, a listbox or an open combobox inside the form closes itself on Escape first. Only a
175
+ // POPUP's trigger counts: a disclosure (a folded row's ▸, `aria-expanded` with no popup) has
176
+ // nothing to close, and Escape on it is the page's.
177
+ if ((event.target as HTMLElement).closest(OWNS_ESCAPE)) return;
178
+ event.preventDefault();
179
+ if (isConfirming) cancelDiscard();
180
+ else guardedClose();
181
+ };
182
+
183
+ return (
184
+ // biome-ignore lint/a11y/noStaticElementInteractions: a11y-needs — Escape anywhere on the page is Back, as it was in the dialog this replaces; the role is a dialog or a region, which biome cannot read through the ternary.
185
+ // biome-ignore lint/a11y/useAriaPropsSupportedByRole: aria-modal is only set when the role is dialog.
186
+ <div
187
+ ref={rootRef}
188
+ className={cn('cbfk-page', className)}
189
+ data-presentation={presentation}
190
+ data-mode={mode}
191
+ data-dragging={dragging ? 'true' : undefined}
192
+ role={presentation === 'overlay' ? 'dialog' : 'region'}
193
+ aria-modal={presentation === 'overlay' ? true : undefined}
194
+ aria-labelledby={titleId}
195
+ tabIndex={-1}
196
+ onKeyDown={onKeyDown}
197
+ >
198
+ <header className='cbfk-page-head'>
199
+ <UnstyledButton className='cbfk-page-back' onClick={guardedClose} aria-label={backLabel} tooltip={backLabel}>
200
+ <ArrowLeft aria-hidden />
201
+ <span className='cbfk-page-crumb'>{backLabel}</span>
202
+ </UnstyledButton>
203
+ <span className='cbfk-page-sep' aria-hidden>
204
+ /
205
+ </span>
206
+ <h1 id={titleId} className='cbfk-page-title'>
207
+ {title}
208
+ </h1>
209
+ <div className='cbfk-page-modes' role='tablist' aria-label='Show'>
210
+ {(['edit', 'preview'] as const).map((each) => (
211
+ <UnstyledButton
212
+ key={each}
213
+ role='tab'
214
+ className='cbfk-page-mode'
215
+ aria-selected={mode === each}
216
+ aria-controls={each === 'edit' ? formId : previewId}
217
+ onClick={() => setMode(each)}
218
+ >
219
+ {each === 'edit' ? 'Edit' : previewLabel}
220
+ </UnstyledButton>
221
+ ))}
222
+ </div>
223
+ {actions ? <div className='cbfk-page-actions'>{actions}</div> : null}
224
+ </header>
225
+ <div
226
+ ref={splitRef}
227
+ className='cbfk-page-split'
228
+ style={{ '--cbfk-page-split': `${(split * 100).toFixed(2)}%` } as CSSProperties}
229
+ >
230
+ <div id={formId} className={cn('cbfk-page-form', quiet && 'cbfk-quiet')}>
231
+ <div className='cbfk-page-form-inner'>{children}</div>
232
+ </div>
233
+ {/* biome-ignore lint/a11y/useSemanticElements: WAI-ARIA's window splitter is a focusable separator with a value; an <hr> is a static rule that takes neither. */}
234
+ <div
235
+ className='cbfk-page-divider'
236
+ role='separator'
237
+ aria-orientation='vertical'
238
+ aria-label='Resize the form and the preview'
239
+ aria-valuemin={MIN_SPLIT * 100}
240
+ aria-valuemax={MAX_SPLIT * 100}
241
+ aria-valuenow={Math.round(split * 100)}
242
+ aria-controls={formId}
243
+ tabIndex={0}
244
+ onPointerDown={onPointerDown}
245
+ onPointerMove={onPointerMove}
246
+ onPointerUp={onPointerUp}
247
+ onPointerCancel={onPointerUp}
248
+ onKeyDown={onDividerKey}
249
+ onDoubleClick={() => commit(defaultSplit)}
250
+ />
251
+ <section id={previewId} className='cbfk-page-preview' aria-label={previewLabel}>
252
+ <div className='cbfk-page-preview-head'>
253
+ <p className='cbfk-page-preview-label'>{previewLabel}</p>
254
+ {previewTools ? <div className='cbfk-page-preview-tools'>{previewTools}</div> : null}
255
+ </div>
256
+ <div className='cbfk-page-preview-body'>{preview}</div>
257
+ </section>
258
+ </div>
259
+ {discardOverlay}
260
+ </div>
261
+ );
262
+ }
263
+
264
+ export interface QuietFormProps {
265
+ children: ReactNode;
266
+ className?: string;
267
+ }
268
+
269
+ /**
270
+ * Linear's form, for any form: inputs, textareas and selects drawn with no box until they are
271
+ * pointed at or focused, labels muted and small, so the values are the only full-strength text.
272
+ *
273
+ * The owner, 2026-10-05, beside a Linear project page: *"I like how it doesn't have boxes around
274
+ * all the fields and doesn't fill in extra text or explanations. I want this minimal look in our
275
+ * apps too."* `EditorPage` puts its form inside one; wrap any other form to get the same.
276
+ * The words are the caller's half: a field that needs a description to be understood has the
277
+ * wrong label or the wrong placeholder.
278
+ */
279
+ export function QuietForm({ children, className }: QuietFormProps) {
280
+ return <div className={cn('cbfk-quiet', className)}>{children}</div>;
281
+ }
@@ -0,0 +1,84 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import { cleanup, fireEvent, render, waitFor } from '@testing-library/react';
3
+ import { IconPicker, iconDataUrl, type PickedIcon, searchIcons } from './IconPicker.js';
4
+
5
+ afterEach(cleanup);
6
+
7
+ /** Iconify's two answers — the search, then each set's drawings — from a table. */
8
+ function fakeIconify(hits: string[]): { fetcher: typeof fetch; asked: string[] } {
9
+ const asked: string[] = [];
10
+ const fetcher = (async (input: string | URL | Request) => {
11
+ const url = new URL(String(input));
12
+ asked.push(url.pathname + url.search);
13
+ if (url.pathname === '/search') return Response.json({ icons: hits });
14
+ const prefix = url.pathname.slice(1).replace(/\.json$/, '');
15
+ const icons = Object.fromEntries((url.searchParams.get('icons') ?? '').split(',').map((name) => [name, { body: `<path d="M0 0" data-n="${name}"/>` }]));
16
+ return Response.json({ prefix, icons, width: 24, height: 24 });
17
+ }) as typeof fetch;
18
+ return { fetcher, asked };
19
+ }
20
+
21
+ describe('searchIcons', () => {
22
+ test('one search, one request per set, and the search’s own order kept', async () => {
23
+ const { fetcher, asked } = fakeIconify(['tabler:code', 'lucide:code', 'lucide:code-xml']);
24
+ const found = await searchIcons('code', ['lucide', 'tabler'], fetcher);
25
+ expect(found.map((icon) => icon.name)).toEqual(['tabler:code', 'lucide:code', 'lucide:code-xml']);
26
+ expect(found[0]).toMatchObject({ width: 24, height: 24 });
27
+ expect(asked.filter((path) => path.startsWith('/search'))).toHaveLength(1);
28
+ expect(asked.filter((path) => path.endsWith('.json') || path.includes('.json?'))).toHaveLength(2);
29
+ });
30
+
31
+ test('an empty query asks nothing', async () => {
32
+ const { fetcher, asked } = fakeIconify([]);
33
+ expect(await searchIcons(' ', ['lucide'], fetcher)).toEqual([]);
34
+ expect(asked).toEqual([]);
35
+ });
36
+ });
37
+
38
+ describe('iconDataUrl', () => {
39
+ test('draws in the colour given, and only a hex or a name is spliced in', () => {
40
+ const icon = { body: '<path fill="currentColor"/>', width: 24, height: 24 };
41
+ expect(decodeURIComponent(iconDataUrl(icon, 'orange'))).toContain('color="orange"');
42
+ const hostile = decodeURIComponent(iconDataUrl(icon, '"><script>x</script>'));
43
+ expect(hostile).not.toContain('<script>');
44
+ expect(hostile).not.toContain('color=');
45
+ });
46
+ });
47
+
48
+ describe('IconPicker', () => {
49
+ test('searches what was typed, draws the hits, and hands back the drawing that was picked', async () => {
50
+ const { fetcher } = fakeIconify(['lucide:code', 'lucide:braces']);
51
+ let picked: PickedIcon | null = null;
52
+ const view = render(<IconPicker color='orange' initialQuery='code' fetcher={fetcher} value='lucide:braces' onPick={(icon) => { picked = icon; }} />);
53
+ const cell = await waitFor(() => view.getByRole('radio', { name: 'code (Lucide)' }));
54
+ expect(view.getByRole('radio', { name: 'braces (Lucide)' }).getAttribute('aria-checked')).toBe('true');
55
+ fireEvent.click(cell);
56
+ expect(picked).toMatchObject({ name: 'lucide:code', width: 24 });
57
+ expect((picked as PickedIcon | null)?.body).toContain('data-n="code"');
58
+ });
59
+
60
+ test('the search follows the name it was given until somebody types in it', async () => {
61
+ const { fetcher, asked } = fakeIconify(['lucide:server']);
62
+ const view = render(<IconPicker color='orange' initialQuery='' fetcher={fetcher} onPick={() => {}} />);
63
+ view.rerender(<IconPicker color='orange' initialQuery='server' fetcher={fetcher} onPick={() => {}} />);
64
+ await waitFor(() => view.getByRole('radio', { name: 'server (Lucide)' }));
65
+ fireEvent.change(view.getByLabelText('Search icons'), { target: { value: 'disk' } });
66
+ view.rerender(<IconPicker color='orange' initialQuery='other' fetcher={fetcher} onPick={() => {}} />);
67
+ expect((view.getByLabelText('Search icons') as HTMLInputElement).value).toBe('disk');
68
+ expect(asked.some((path) => path.includes('query=server'))).toBe(true);
69
+ });
70
+
71
+ test('a set chip narrows the search to that set', async () => {
72
+ const { fetcher, asked } = fakeIconify(['simple-icons:kubernetes']);
73
+ const view = render(<IconPicker color='orange' initialQuery='kube' fetcher={fetcher} onPick={() => {}} />);
74
+ await waitFor(() => view.getByRole('radio', { name: 'kubernetes (Brands)' }));
75
+ fireEvent.click(view.getByRole('button', { name: 'Brands' }));
76
+ await waitFor(() => expect(asked.some((path) => path.includes('prefixes=simple-icons&') || path.endsWith('prefixes=simple-icons'))).toBe(true));
77
+ });
78
+
79
+ test('no network says so, in words', async () => {
80
+ const fetcher = (async () => { throw new TypeError('Failed to fetch'); }) as unknown as typeof fetch;
81
+ const view = render(<IconPicker color='orange' initialQuery='code' fetcher={fetcher} onPick={() => {}} />);
82
+ await waitFor(() => expect(view.getByRole('status').textContent).toContain('needs the internet'));
83
+ });
84
+ });
@@ -0,0 +1,289 @@
1
+ import { type KeyboardEvent, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { cn } from '../lib/cn.js';
3
+ import { SearchInput } from './SearchInput.js';
4
+ import { UnstyledButton } from './UnstyledButton.js';
5
+ import './formKit.css';
6
+ import './editorPage.css';
7
+
8
+ /**
9
+ * An icon somebody picked, with its drawing — so whoever stores it can draw it again with no
10
+ * icon library and no network: `iconDataUrl(icon, color)`.
11
+ */
12
+ export interface PickedIcon {
13
+ /** Iconify's name, `set:icon` — `lucide:code`, `simple-icons:kubernetes`. */
14
+ name: string;
15
+ /** The SVG's inner markup, every colour `currentColor`. */
16
+ body: string;
17
+ width: number;
18
+ height: number;
19
+ }
20
+
21
+ export interface IconSet {
22
+ prefix: string;
23
+ label: string;
24
+ }
25
+
26
+ /**
27
+ * The sets searched, all single-colour (they tint): the general-purpose four, and Simple Icons
28
+ * for the brands a runbook names — Kubernetes, Datadog, GitHub. Multi-colour logo sets are left
29
+ * out because a colour of the owner's choosing is the point of the mark.
30
+ */
31
+ export const ICON_SETS: readonly IconSet[] = [
32
+ { prefix: 'lucide', label: 'Lucide' },
33
+ { prefix: 'tabler', label: 'Tabler' },
34
+ { prefix: 'ph', label: 'Phosphor' },
35
+ { prefix: 'mdi', label: 'Material' },
36
+ { prefix: 'simple-icons', label: 'Brands' },
37
+ ];
38
+
39
+ export const ICONIFY_API = 'https://api.iconify.design';
40
+ const RESULTS = 96;
41
+ /** A hex, or a named colour — nothing that could close the attribute it is spliced into. */
42
+ const SAFE_COLOR = /^(?:#[0-9a-f]{3,8}|[a-z]+)$/i;
43
+
44
+ /**
45
+ * A picked icon as an `<img>` source, in `color`. An image rather than inline SVG on purpose: a
46
+ * stored body is drawn wherever the record goes, and an SVG inside an `<img>` cannot run script
47
+ * or reach the page, whatever the stored markup says. `color` must be a hex or a colour's name —
48
+ * anything else is left out, and the icon draws in the SVG's default black.
49
+ */
50
+ export function iconDataUrl(icon: Pick<PickedIcon, 'body' | 'width' | 'height'>, color: string): string {
51
+ const tint = SAFE_COLOR.test(color) ? ` color="${color}"` : '';
52
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${icon.width} ${icon.height}" width="${icon.width}" height="${icon.height}"${tint}>${icon.body}</svg>`;
53
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
54
+ }
55
+
56
+ interface IconifySetJson {
57
+ prefix: string;
58
+ icons: Record<string, { body: string; width?: number; height?: number }>;
59
+ width?: number;
60
+ height?: number;
61
+ }
62
+
63
+ /** Iconify's search, then every hit's drawing, one request per set. */
64
+ export async function searchIcons(
65
+ query: string,
66
+ prefixes: readonly string[],
67
+ fetcher: typeof fetch = fetch,
68
+ signal?: AbortSignal,
69
+ ): Promise<PickedIcon[]> {
70
+ const q = query.trim();
71
+ if (q === '') return [];
72
+ const search = new URL('/search', ICONIFY_API);
73
+ search.searchParams.set('query', q);
74
+ search.searchParams.set('limit', String(RESULTS));
75
+ search.searchParams.set('prefixes', prefixes.join(','));
76
+ const found = await fetcher(search.toString(), { signal });
77
+ if (!found.ok) throw new Error(`The icon search answered ${found.status}.`);
78
+ const names = ((await found.json()) as { icons?: string[] }).icons ?? [];
79
+ const bySet = new Map<string, string[]>();
80
+ for (const name of names) {
81
+ const [prefix, icon] = name.split(':');
82
+ if (!prefix || !icon) continue;
83
+ bySet.set(prefix, [...(bySet.get(prefix) ?? []), icon]);
84
+ }
85
+ const drawn = new Map<string, PickedIcon>();
86
+ await Promise.all(
87
+ [...bySet].map(async ([prefix, icons]) => {
88
+ const url = new URL(`/${prefix}.json`, ICONIFY_API);
89
+ url.searchParams.set('icons', icons.join(','));
90
+ const answer = await fetcher(url.toString(), { signal });
91
+ if (!answer.ok) return;
92
+ const set = (await answer.json()) as IconifySetJson;
93
+ for (const [icon, data] of Object.entries(set.icons ?? {})) {
94
+ drawn.set(`${prefix}:${icon}`, {
95
+ name: `${prefix}:${icon}`,
96
+ body: data.body,
97
+ width: data.width ?? set.width ?? 24,
98
+ height: data.height ?? set.height ?? 24,
99
+ });
100
+ }
101
+ }),
102
+ );
103
+ // In the search's own order — its ranking is the useful part of it.
104
+ return names.map((name) => drawn.get(name)).filter((icon): icon is PickedIcon => icon !== undefined);
105
+ }
106
+
107
+ export interface IconPickerProps {
108
+ /** The chosen icon's name, drawn as selected. */
109
+ value?: string | null;
110
+ onPick: (icon: PickedIcon) => void;
111
+ /**
112
+ * The colour the results are drawn in — the mark's own, so the choice is made in it. A hex
113
+ * or a colour's name: the hits are images, which cannot inherit the page's `currentColor`.
114
+ */
115
+ color: string;
116
+ /**
117
+ * What to search for until somebody types — the thing's name is a good first guess. Followed
118
+ * as it changes (a name typed after the picker opened still searches), and let go of for good
119
+ * the moment the search field is typed in.
120
+ */
121
+ initialQuery?: string;
122
+ sets?: readonly IconSet[];
123
+ /** The request function — a test's seam. */
124
+ fetcher?: typeof fetch;
125
+ className?: string;
126
+ }
127
+
128
+ /**
129
+ * Search big icon sets and pick one: a search field, a row of sets, and a grid of hits drawn in
130
+ * the mark's own colour.
131
+ *
132
+ * The owner, 2026-10-05: *"For the mark I want a way to search in icon lists to choose a good
133
+ * icon … if I make a code snippet template I want to be able to pick an icon instead of letters
134
+ * like "CS". If we can have a search field and access to big icon sets like at shadcn.io/icons or
135
+ * iconify that will be good."* So Iconify's public API: 200,000 icons across the sets above,
136
+ * searched where they live rather than shipped in every app's bundle. A picked icon carries its
137
+ * own drawing (`PickedIcon.body`), so drawing it later needs neither the API nor the network.
138
+ *
139
+ * Keys: type to search; ↓ from the field enters the grid; arrows move in it; Enter or Space
140
+ * picks. Plain stylesheet (`formKit.css`); `cursedbelt/react/form-kit`.
141
+ */
142
+ export function IconPicker({
143
+ value,
144
+ onPick,
145
+ color,
146
+ initialQuery = '',
147
+ sets = ICON_SETS,
148
+ fetcher,
149
+ className,
150
+ }: IconPickerProps) {
151
+ const [query, setQuery] = useState(initialQuery);
152
+ const [typed, setTyped] = useState(false);
153
+ const [only, setOnly] = useState<string | null>(null);
154
+ const [results, setResults] = useState<PickedIcon[]>([]);
155
+ const [state, setState] = useState<'idle' | 'loading' | 'done' | 'error'>('idle');
156
+ const [active, setActive] = useState(0);
157
+ const grid = useRef<HTMLDivElement>(null);
158
+ useEffect(() => {
159
+ if (!typed) setQuery(initialQuery);
160
+ }, [initialQuery, typed]);
161
+ const prefixes = useMemo(() => (only ? [only] : sets.map((set) => set.prefix)), [only, sets]);
162
+
163
+ useEffect(() => {
164
+ if (query.trim() === '') {
165
+ setResults([]);
166
+ setState('idle');
167
+ return;
168
+ }
169
+ const abort = new AbortController();
170
+ setState('loading');
171
+ const timer = setTimeout(() => {
172
+ searchIcons(query, prefixes, fetcher, abort.signal).then(
173
+ (found) => {
174
+ setResults(found);
175
+ setActive(0);
176
+ setState('done');
177
+ },
178
+ (cause: unknown) => {
179
+ if ((cause as { name?: string })?.name === 'AbortError') return;
180
+ setState('error');
181
+ },
182
+ );
183
+ }, 200);
184
+ return () => {
185
+ clearTimeout(timer);
186
+ abort.abort();
187
+ };
188
+ }, [query, prefixes, fetcher]);
189
+
190
+ const focusAt = (index: number) => {
191
+ const buttons = grid.current?.querySelectorAll<HTMLButtonElement>('[data-icon]');
192
+ if (!buttons || buttons.length === 0) return;
193
+ const next = Math.max(0, Math.min(buttons.length - 1, index));
194
+ setActive(next);
195
+ buttons[next]?.focus();
196
+ };
197
+
198
+ const columns = (): number => {
199
+ const buttons = grid.current?.querySelectorAll<HTMLElement>('[data-icon]');
200
+ if (!buttons || buttons.length === 0) return 1;
201
+ const top = buttons[0]!.offsetTop;
202
+ let count = 0;
203
+ for (const button of buttons) {
204
+ if (button.offsetTop !== top) break;
205
+ count += 1;
206
+ }
207
+ return Math.max(1, count);
208
+ };
209
+
210
+ const onGridKey = (event: KeyboardEvent<HTMLDivElement>) => {
211
+ const step =
212
+ event.key === 'ArrowRight' ? 1
213
+ : event.key === 'ArrowLeft' ? -1
214
+ : event.key === 'ArrowDown' ? columns()
215
+ : event.key === 'ArrowUp' ? -columns()
216
+ : null;
217
+ if (step === null) return;
218
+ event.preventDefault();
219
+ focusAt(active + step);
220
+ };
221
+
222
+ return (
223
+ <div className={cn('cbfk-icons', className)}>
224
+ <SearchInput
225
+ value={query}
226
+ onChange={(next) => {
227
+ setTyped(true);
228
+ setQuery(next);
229
+ }}
230
+ syntax={false}
231
+ placeholder='Search icons — code, server, chart, kubernetes…'
232
+ aria-label='Search icons'
233
+ onKeyDown={(event) => {
234
+ if (event.key === 'ArrowDown' && results.length > 0) {
235
+ event.preventDefault();
236
+ focusAt(0);
237
+ }
238
+ }}
239
+ />
240
+ <fieldset className='cbfk-icons-sets' aria-label='Icon sets'>
241
+ {[{ prefix: null, label: 'All' }, ...sets].map((set) => (
242
+ <UnstyledButton
243
+ key={set.prefix ?? 'all'}
244
+ className='cbfk-icons-set'
245
+ aria-pressed={only === set.prefix}
246
+ onClick={() => setOnly(set.prefix)}
247
+ >
248
+ {set.label}
249
+ </UnstyledButton>
250
+ ))}
251
+ </fieldset>
252
+ <div
253
+ ref={grid}
254
+ className='cbfk-icons-grid'
255
+ role='radiogroup'
256
+ aria-label='Icons'
257
+ aria-busy={state === 'loading'}
258
+ onKeyDown={onGridKey}
259
+ >
260
+ {results.map((icon, index) => {
261
+ const [set, name] = icon.name.split(':');
262
+ return (
263
+ <UnstyledButton
264
+ key={icon.name}
265
+ role='radio'
266
+ data-icon={icon.name}
267
+ className='cbfk-icons-cell'
268
+ aria-checked={icon.name === value}
269
+ aria-label={`${name} (${sets.find((each) => each.prefix === set)?.label ?? set})`}
270
+ tooltip={icon.name}
271
+ tabIndex={index === active ? 0 : -1}
272
+ onFocus={() => setActive(index)}
273
+ onClick={() => onPick(icon)}
274
+ >
275
+ <img src={iconDataUrl(icon, color)} alt='' width={22} height={22} draggable={false} />
276
+ </UnstyledButton>
277
+ );
278
+ })}
279
+ </div>
280
+ <p className='cbfk-icons-note' role='status'>
281
+ {state === 'idle' ? 'Type to search Lucide, Tabler, Phosphor, Material and brand icons.'
282
+ : state === 'loading' ? 'Searching…'
283
+ : state === 'error' ? 'Icon search needs the internet — api.iconify.design did not answer.'
284
+ : results.length === 0 ? `No icon matches “${query.trim()}”.`
285
+ : `${results.length} icons${results.length === RESULTS ? ' — the closest; type more to narrow' : ''}.`}
286
+ </p>
287
+ </div>
288
+ );
289
+ }