@yarcl/react 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 (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -0
  3. package/dist/color.d.ts +12 -0
  4. package/dist/color.js +41 -0
  5. package/dist/css.d.ts +10 -0
  6. package/dist/css.js +175 -0
  7. package/dist/define.d.ts +383 -0
  8. package/dist/define.js +29 -0
  9. package/dist/plugin.d.ts +33 -0
  10. package/dist/plugin.js +80 -0
  11. package/package.json +66 -0
  12. package/src/apply.ts +46 -0
  13. package/src/classes.ts +23 -0
  14. package/src/color.ts +48 -0
  15. package/src/components/Accordion.tsx +274 -0
  16. package/src/components/Alert.tsx +85 -0
  17. package/src/components/Badge.tsx +79 -0
  18. package/src/components/Breadcrumb.tsx +239 -0
  19. package/src/components/Button.tsx +58 -0
  20. package/src/components/ButtonGroup.tsx +61 -0
  21. package/src/components/Card.tsx +50 -0
  22. package/src/components/Checkbox.tsx +49 -0
  23. package/src/components/Combobox.tsx +522 -0
  24. package/src/components/CommandPalette.tsx +452 -0
  25. package/src/components/DatePicker.tsx +432 -0
  26. package/src/components/Dialog.tsx +39 -0
  27. package/src/components/Divider.tsx +34 -0
  28. package/src/components/Drawer.tsx +31 -0
  29. package/src/components/Field.tsx +61 -0
  30. package/src/components/Heading.tsx +40 -0
  31. package/src/components/HoverCard.tsx +108 -0
  32. package/src/components/IconButton.tsx +59 -0
  33. package/src/components/Inline.tsx +42 -0
  34. package/src/components/Input.tsx +28 -0
  35. package/src/components/Label.tsx +58 -0
  36. package/src/components/Link.tsx +41 -0
  37. package/src/components/Menu.tsx +207 -0
  38. package/src/components/Modal.tsx +164 -0
  39. package/src/components/NumberInput.tsx +208 -0
  40. package/src/components/Pagination.tsx +227 -0
  41. package/src/components/Popover.tsx +140 -0
  42. package/src/components/Progress.tsx +122 -0
  43. package/src/components/Radio.tsx +59 -0
  44. package/src/components/RadioGroup.tsx +114 -0
  45. package/src/components/Select.tsx +197 -0
  46. package/src/components/Skeleton.tsx +95 -0
  47. package/src/components/Slider.tsx +315 -0
  48. package/src/components/Spinner.tsx +38 -0
  49. package/src/components/Stack.tsx +57 -0
  50. package/src/components/Switch.tsx +42 -0
  51. package/src/components/Table.tsx +99 -0
  52. package/src/components/Tabs.tsx +159 -0
  53. package/src/components/Text.tsx +67 -0
  54. package/src/components/Textarea.tsx +26 -0
  55. package/src/components/Toast.tsx +190 -0
  56. package/src/components/ToggleGroup.tsx +227 -0
  57. package/src/components/Tooltip.tsx +78 -0
  58. package/src/contrast.ts +82 -0
  59. package/src/css.ts +228 -0
  60. package/src/define.check.ts +259 -0
  61. package/src/define.ts +379 -0
  62. package/src/field-context.ts +31 -0
  63. package/src/floating.tsx +61 -0
  64. package/src/hooks.ts +19 -0
  65. package/src/index.ts +119 -0
  66. package/src/plugin.ts +94 -0
  67. package/src/reference/DesignReference.tsx +374 -0
  68. package/src/reference/index.ts +2 -0
  69. package/src/reference/reference.css +58 -0
  70. package/src/runtime.ts +38 -0
  71. package/src/styles.css +1971 -0
  72. package/src/themes/bloom.ts +72 -0
  73. package/src/themes/brutalist.ts +63 -0
  74. package/src/themes/compact.ts +63 -0
  75. package/src/themes/contract.ts +24 -0
  76. package/src/themes/editorial.ts +55 -0
  77. package/src/themes/index.ts +47 -0
  78. package/src/types.ts +69 -0
  79. package/src/yarcl.config.ts +104 -0
@@ -0,0 +1,452 @@
1
+ import {
2
+ cloneElement,
3
+ useEffect,
4
+ useId,
5
+ useState,
6
+ useSyncExternalStore,
7
+ type ChangeEvent,
8
+ type ComponentProps,
9
+ type KeyboardEvent,
10
+ type MouseEvent,
11
+ type ReactElement,
12
+ type ReactNode,
13
+ } from 'react';
14
+ import { colorClass, cx, radiusClass, sizeClass } from '../classes';
15
+ import { useControllable } from '../hooks';
16
+ import { useConfig, useDefaults } from '../runtime';
17
+ import type { TokenProps } from '../types';
18
+ import { useModalDialog } from './Modal';
19
+
20
+ /** A command shown in a {@link CommandPalette}. */
21
+ export interface CommandPaletteCommand {
22
+ /** Unique id of the command. */
23
+ id: string;
24
+ /** Text shown in the list and matched by the search. */
25
+ label: string;
26
+ /** Heading of the group the command is listed under. Groups appear in the order they're first used. */
27
+ group?: string;
28
+ /** Extra words the search matches, e.g. synonyms. */
29
+ keywords?: readonly string[];
30
+ /**
31
+ * Keyboard shortcut hint shown next to the label, e.g. `'Mod+S'`, or `'G D'` for a sequence.
32
+ * `Mod` is ⌘ on Apple devices and Ctrl elsewhere. Only a hint: bind the shortcut yourself.
33
+ */
34
+ shortcut?: string;
35
+ /** An icon shown before the label. */
36
+ icon?: ReactNode;
37
+ /** Shows the command but prevents choosing it. */
38
+ disabled?: boolean;
39
+ /** Called when the command is chosen. The palette closes first. */
40
+ onSelect?: () => void;
41
+ }
42
+
43
+ /** Props for {@link CommandPalette}. */
44
+ export interface CommandPaletteProps
45
+ extends TokenProps,
46
+ Omit<ComponentProps<'dialog'>, 'color' | 'open' | 'onSelect' | 'children' | 'title'> {
47
+ /** The commands to search. Commands with the same `group` are listed together under a heading. */
48
+ commands: readonly CommandPaletteCommand[];
49
+ /** Controlled open state. */
50
+ open?: boolean;
51
+ /**
52
+ * Initial open state when uncontrolled.
53
+ * @default false
54
+ */
55
+ defaultOpen?: boolean;
56
+ /** Called when the palette opens or closes, including by the shortcut, Esc or a backdrop click. */
57
+ onOpenChange?: (open: boolean) => void;
58
+ /**
59
+ * Keyboard shortcut that toggles the palette from anywhere on the page, e.g. `'Mod+K'` or `'Shift+Mod+P'`.
60
+ * `Mod` is ⌘ on Apple devices and Ctrl elsewhere. `false` disables it.
61
+ * @default 'Mod+K'
62
+ */
63
+ shortcut?: string | false;
64
+ /** An element that opens the palette when clicked, e.g. a {@link Button}. */
65
+ trigger?: ReactElement<{ onClick?: (event: MouseEvent) => void }>;
66
+ /** Called with the chosen command, after its own `onSelect`. */
67
+ onSelect?: (command: CommandPaletteCommand) => void;
68
+ /** Controlled search text. */
69
+ inputValue?: string;
70
+ /** Called on every keystroke with the search text. */
71
+ onInputValueChange?: (text: string) => void;
72
+ /**
73
+ * How commands are matched against the search text.
74
+ * `true`: every typed word appears in the label, group or keywords (case-insensitive);
75
+ * a function for custom matching; `false` to show `commands` as given.
76
+ * @default true
77
+ */
78
+ filter?: boolean | ((command: CommandPaletteCommand, text: string) => boolean);
79
+ /**
80
+ * Placeholder of the search input.
81
+ * @default 'Search commands…'
82
+ */
83
+ placeholder?: string;
84
+ /**
85
+ * Accessible name of the palette and its search input.
86
+ * @default 'Command palette'
87
+ */
88
+ label?: string;
89
+ /**
90
+ * Shown when no commands match.
91
+ * @default 'No results'
92
+ */
93
+ emptyMessage?: ReactNode;
94
+ }
95
+
96
+ function detectApple() {
97
+ return typeof navigator !== 'undefined' && /mac|iphone|ipad|ipod/i.test(navigator.platform || navigator.userAgent);
98
+ }
99
+
100
+ const noop = () => () => {};
101
+
102
+ function useIsApple() {
103
+ return useSyncExternalStore(noop, detectApple, () => false);
104
+ }
105
+
106
+ function parseShortcut(shortcut: string, apple: boolean) {
107
+ const parts = shortcut.split('+').map((part) => part.trim().toLowerCase());
108
+ const key = parts[parts.length - 1] ?? '';
109
+ const mods = new Set(parts.slice(0, -1));
110
+ const mod = mods.has('mod');
111
+ return {
112
+ key,
113
+ ctrl: mods.has('ctrl') || mods.has('control') || (mod && !apple),
114
+ meta: mods.has('meta') || mods.has('cmd') || (mod && apple),
115
+ alt: mods.has('alt') || mods.has('option'),
116
+ shift: mods.has('shift'),
117
+ };
118
+ }
119
+
120
+ function matchesShortcut(event: globalThis.KeyboardEvent, shortcut: string, apple: boolean) {
121
+ const s = parseShortcut(shortcut, apple);
122
+ if (event.ctrlKey !== s.ctrl || event.metaKey !== s.meta || event.altKey !== s.alt || event.shiftKey !== s.shift) return false;
123
+ const code = s.key.length === 1 ? (/[a-z]/.test(s.key) ? `Key${s.key.toUpperCase()}` : /\d/.test(s.key) ? `Digit${s.key}` : '') : '';
124
+ return event.key?.toLowerCase() === s.key || (code !== '' && event.code === code);
125
+ }
126
+
127
+ function ariaChord(chord: string, apple: boolean) {
128
+ const s = parseShortcut(chord, apple);
129
+ const key = s.key.length === 1 ? s.key.toUpperCase() : s.key.charAt(0).toUpperCase() + s.key.slice(1);
130
+ return [s.ctrl && 'Control', s.alt && 'Alt', s.shift && 'Shift', s.meta && 'Meta', key].filter(Boolean).join('+');
131
+ }
132
+
133
+ function ariaShortcut(shortcut: string, apple: boolean) {
134
+ return shortcut
135
+ .trim()
136
+ .split(/\s+/)
137
+ .map((chord) => ariaChord(chord, apple))
138
+ .join(' ');
139
+ }
140
+
141
+ function shortcutKeys(shortcut: string, apple: boolean) {
142
+ return shortcut.trim().split(/[\s+]+/).filter(Boolean).map((raw) => {
143
+ const part = raw.trim();
144
+ const lower = part.toLowerCase();
145
+ if (lower === 'mod') return apple ? '⌘' : 'Ctrl';
146
+ if (lower === 'ctrl' || lower === 'control') return apple ? '⌃' : 'Ctrl';
147
+ if (lower === 'alt' || lower === 'option') return apple ? '⌥' : 'Alt';
148
+ if (lower === 'shift') return apple ? '⇧' : 'Shift';
149
+ if (lower === 'meta' || lower === 'cmd') return apple ? '⌘' : 'Meta';
150
+ return part.length === 1 ? part.toUpperCase() : part;
151
+ });
152
+ }
153
+
154
+ function ShortcutHint({ shortcut, apple }: { shortcut: string; apple: boolean }) {
155
+ return (
156
+ <span className="yarcl-command-palette-shortcut" aria-hidden="true">
157
+ {shortcutKeys(shortcut, apple).map((key, i) => (
158
+ <kbd key={i}>{key}</kbd>
159
+ ))}
160
+ </span>
161
+ );
162
+ }
163
+
164
+ function SearchIcon() {
165
+ return (
166
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
167
+ <circle cx="11" cy="11" r="7" />
168
+ <path d="m20 20-3.5-3.5" />
169
+ </svg>
170
+ );
171
+ }
172
+
173
+ function contains(command: CommandPaletteCommand, text: string) {
174
+ const haystack = [command.label, command.group ?? '', ...(command.keywords ?? [])].join(' ').toLowerCase();
175
+ return text
176
+ .toLowerCase()
177
+ .split(/\s+/)
178
+ .filter(Boolean)
179
+ .every((word) => haystack.includes(word));
180
+ }
181
+
182
+ function firstEnabled(list: readonly CommandPaletteCommand[], from = 0, step = 1): number | null {
183
+ for (let i = 0; i < list.length; i++) {
184
+ const index = (((from + i * step) % list.length) + list.length) % list.length;
185
+ if (!list[index]?.disabled) return index;
186
+ }
187
+ return null;
188
+ }
189
+
190
+ /**
191
+ * A searchable command menu in a modal dialog, opened with a keyboard shortcut (⌘K / Ctrl+K by default).
192
+ * The search input is a combobox: arrow keys, Home and End move through the matching commands,
193
+ * Enter runs the highlighted one, Esc closes and returns focus.
194
+ *
195
+ * @example
196
+ * ```tsx
197
+ * <CommandPalette
198
+ * commands={[
199
+ * { id: 'new', label: 'New file', group: 'File', shortcut: 'Mod+N', onSelect: createFile },
200
+ * { id: 'theme', label: 'Toggle dark mode', group: 'View', keywords: ['theme'], onSelect: toggleTheme },
201
+ * ]}
202
+ * />
203
+ * ```
204
+ */
205
+ type BodyProps = Pick<
206
+ CommandPaletteProps,
207
+ 'commands' | 'inputValue' | 'onInputValueChange' | 'filter' | 'placeholder' | 'label' | 'emptyMessage'
208
+ > & { apple: boolean; choose: (command: CommandPaletteCommand) => void };
209
+
210
+ function CommandPaletteBody({
211
+ commands,
212
+ inputValue,
213
+ onInputValueChange,
214
+ filter = true,
215
+ placeholder = 'Search commands…',
216
+ label = 'Command palette',
217
+ emptyMessage = 'No results',
218
+ apple,
219
+ choose,
220
+ }: BodyProps) {
221
+ const [text, setText] = useControllable(inputValue, '', onInputValueChange);
222
+ const [activeIndex, setActiveIndex] = useState<number | null>(null);
223
+ const idPrefix = useId();
224
+ const listId = `${idPrefix}-list`;
225
+ const optionId = (i: number) => `${idPrefix}-option-${i}`;
226
+
227
+ const match = filter === true ? contains : filter === false ? () => true : filter;
228
+ const sections: { heading?: string; commands: CommandPaletteCommand[] }[] = [];
229
+ for (const command of commands) {
230
+ if (!match(command, text)) continue;
231
+ let section = sections.find((s) => s.heading === command.group);
232
+ if (!section) sections.push((section = { heading: command.group, commands: [] }));
233
+ section.commands.push(command);
234
+ }
235
+ const visible = sections.flatMap((s) => s.commands);
236
+ const shown = activeIndex === null || activeIndex >= visible.length ? firstEnabled(visible) : activeIndex;
237
+
238
+ useEffect(() => {
239
+ if (shown != null) document.getElementById(`${idPrefix}-option-${shown}`)?.scrollIntoView({ block: 'nearest' });
240
+ }, [shown, idPrefix]);
241
+
242
+ function onKeyDown(event: KeyboardEvent<HTMLInputElement>) {
243
+ if (visible.length === 0) return;
244
+ let next: number | null;
245
+ if (event.key === 'ArrowDown') next = firstEnabled(visible, shown == null ? 0 : shown + 1);
246
+ else if (event.key === 'ArrowUp') next = firstEnabled(visible, shown == null ? visible.length - 1 : shown - 1, -1);
247
+ else if (event.key === 'Home') next = firstEnabled(visible);
248
+ else if (event.key === 'End') next = firstEnabled(visible, visible.length - 1, -1);
249
+ else if (event.key === 'Enter') {
250
+ event.preventDefault();
251
+ const command = shown != null ? visible[shown] : undefined;
252
+ if (command) choose(command);
253
+ return;
254
+ } else return;
255
+ event.preventDefault();
256
+ setActiveIndex(next);
257
+ }
258
+
259
+ let index = 0;
260
+
261
+ return (
262
+ <div className="yarcl-command-palette-content">
263
+ <div className="yarcl-command-palette-search">
264
+ <SearchIcon />
265
+ <input
266
+ className="yarcl-command-palette-input"
267
+ type="text"
268
+ role="combobox"
269
+ aria-label={label}
270
+ aria-autocomplete="list"
271
+ aria-expanded={visible.length > 0}
272
+ aria-controls={visible.length > 0 ? listId : undefined}
273
+ aria-activedescendant={shown != null ? optionId(shown) : undefined}
274
+ autoComplete="off"
275
+ autoCorrect="off"
276
+ spellCheck={false}
277
+ placeholder={placeholder}
278
+ value={text}
279
+ onChange={(event: ChangeEvent<HTMLInputElement>) => {
280
+ setText(event.target.value);
281
+ setActiveIndex(null);
282
+ }}
283
+ onKeyDown={onKeyDown}
284
+ />
285
+ </div>
286
+ {visible.length > 0 && (
287
+ <div id={listId} role="listbox" aria-label={label} className="yarcl-listbox yarcl-command-palette-list">
288
+ {sections.map((section, s) => {
289
+ const headingId = `${idPrefix}-group-${s}`;
290
+ const options = section.commands.map((command) => {
291
+ const i = index++;
292
+ const isActive = i === shown;
293
+ return (
294
+ <div
295
+ key={command.id}
296
+ id={optionId(i)}
297
+ role="option"
298
+ aria-selected={isActive}
299
+ aria-disabled={command.disabled || undefined}
300
+ aria-keyshortcuts={command.shortcut ? ariaShortcut(command.shortcut, apple) : undefined}
301
+ data-active={isActive || undefined}
302
+ className="yarcl-option"
303
+ onMouseDown={(event) => event.preventDefault()}
304
+ onMouseMove={() => {
305
+ if (!command.disabled && i !== shown) setActiveIndex(i);
306
+ }}
307
+ onClick={() => choose(command)}
308
+ >
309
+ <span className="yarcl-command-palette-item">
310
+ {command.icon}
311
+ <span className="yarcl-option-label">{command.label}</span>
312
+ </span>
313
+ {command.shortcut && <ShortcutHint shortcut={command.shortcut} apple={apple} />}
314
+ </div>
315
+ );
316
+ });
317
+ return (
318
+ <div
319
+ key={section.heading ?? ''}
320
+ role="group"
321
+ aria-labelledby={section.heading ? headingId : undefined}
322
+ className="yarcl-command-palette-group"
323
+ >
324
+ {section.heading && (
325
+ <div id={headingId} className="yarcl-command-palette-heading" aria-hidden="true">
326
+ {section.heading}
327
+ </div>
328
+ )}
329
+ {options}
330
+ </div>
331
+ );
332
+ })}
333
+ </div>
334
+ )}
335
+ <div role="status" className="yarcl-listbox-message yarcl-command-palette-empty">
336
+ {visible.length === 0 ? emptyMessage : null}
337
+ </div>
338
+ </div>
339
+ );
340
+ }
341
+
342
+ /**
343
+ * A searchable command menu in a modal dialog, opened with a keyboard shortcut (⌘K or Ctrl+K by default).
344
+ * The search input is a combobox: arrow keys, Home and End move through the matching commands,
345
+ * Enter runs the highlighted one, Esc closes the palette and returns focus.
346
+ *
347
+ * @example
348
+ * ```tsx
349
+ * <CommandPalette
350
+ * commands={[
351
+ * { id: 'new', label: 'New file', group: 'File', shortcut: 'Mod+N', onSelect: createFile },
352
+ * { id: 'theme', label: 'Toggle dark mode', group: 'View', keywords: ['theme'], onSelect: toggleTheme },
353
+ * ]}
354
+ * />
355
+ * ```
356
+ */
357
+ export function CommandPalette({
358
+ commands,
359
+ open: openProp,
360
+ defaultOpen = false,
361
+ onOpenChange,
362
+ shortcut = 'Mod+K',
363
+ trigger,
364
+ onSelect,
365
+ inputValue,
366
+ onInputValueChange,
367
+ filter,
368
+ placeholder,
369
+ label = 'Command palette',
370
+ emptyMessage,
371
+ size,
372
+ radius,
373
+ color,
374
+ className,
375
+ onClick,
376
+ onClose,
377
+ ...props
378
+ }: CommandPaletteProps) {
379
+ const own = useDefaults('CommandPalette');
380
+ const config = useConfig();
381
+ const apple = useIsApple();
382
+ const [open, setOpen] = useControllable(openProp, defaultOpen, onOpenChange);
383
+ const dialog = useModalDialog(open, setOpen);
384
+
385
+ useEffect(() => {
386
+ if (!shortcut) return;
387
+ function onKeyDown(event: globalThis.KeyboardEvent) {
388
+ if (event.defaultPrevented || !shortcut || !matchesShortcut(event, shortcut, detectApple())) return;
389
+ event.preventDefault();
390
+ setOpen(!open);
391
+ }
392
+ document.addEventListener('keydown', onKeyDown);
393
+ return () => document.removeEventListener('keydown', onKeyDown);
394
+ }, [shortcut, open, setOpen]);
395
+
396
+ function choose(command: CommandPaletteCommand) {
397
+ if (command.disabled) return;
398
+ setOpen(false);
399
+ command.onSelect?.();
400
+ onSelect?.(command);
401
+ }
402
+
403
+ const resolvedSize = size ?? own.size;
404
+
405
+ return (
406
+ <>
407
+ {trigger &&
408
+ cloneElement(trigger, {
409
+ onClick(event: MouseEvent) {
410
+ trigger.props.onClick?.(event);
411
+ setOpen(true);
412
+ },
413
+ ...(shortcut ? { 'aria-keyshortcuts': ariaShortcut(shortcut, apple) } : {}),
414
+ })}
415
+ <dialog
416
+ aria-label={label}
417
+ {...props}
418
+ ref={dialog.ref}
419
+ className={cx(
420
+ 'yarcl-modal yarcl-dialog yarcl-command-palette',
421
+ `yarcl-modal-size-${config.defaults.modalSize}`,
422
+ sizeClass(resolvedSize),
423
+ radiusClass(radius ?? own.radius, resolvedSize),
424
+ colorClass(color ?? own.color),
425
+ className,
426
+ )}
427
+ onClose={(event) => {
428
+ dialog.onClose(event);
429
+ onClose?.(event);
430
+ }}
431
+ onClick={(event) => {
432
+ dialog.onClick(event);
433
+ onClick?.(event);
434
+ }}
435
+ >
436
+ {open && (
437
+ <CommandPaletteBody
438
+ commands={commands}
439
+ inputValue={inputValue}
440
+ onInputValueChange={onInputValueChange}
441
+ filter={filter}
442
+ placeholder={placeholder}
443
+ label={label}
444
+ emptyMessage={emptyMessage}
445
+ apple={apple}
446
+ choose={choose}
447
+ />
448
+ )}
449
+ </dialog>
450
+ </>
451
+ );
452
+ }