@adecore/ui 0.14.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 (318) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +83 -0
  3. package/dist/AccentSwatches.d.ts +15 -0
  4. package/dist/AccentSwatches.js +21 -0
  5. package/dist/Banner.d.ts +12 -0
  6. package/dist/Banner.js +18 -0
  7. package/dist/Button.d.ts +9 -0
  8. package/dist/Button.js +28 -0
  9. package/dist/ButtonGroup.d.ts +3 -0
  10. package/dist/ButtonGroup.js +11 -0
  11. package/dist/Checkbox.d.ts +11 -0
  12. package/dist/Checkbox.js +9 -0
  13. package/dist/ChoiceCards.d.ts +23 -0
  14. package/dist/ChoiceCards.js +21 -0
  15. package/dist/CloseButton.d.ts +15 -0
  16. package/dist/CloseButton.js +11 -0
  17. package/dist/ColorSwatch.d.ts +14 -0
  18. package/dist/ColorSwatch.js +28 -0
  19. package/dist/ColumnResizeHandle.d.ts +9 -0
  20. package/dist/ColumnResizeHandle.js +12 -0
  21. package/dist/DisabledReason.d.ts +6 -0
  22. package/dist/DisabledReason.js +8 -0
  23. package/dist/DockShell.d.ts +9 -0
  24. package/dist/DockShell.js +49 -0
  25. package/dist/EmptyState.d.ts +16 -0
  26. package/dist/EmptyState.js +11 -0
  27. package/dist/ErrorBoundary.d.ts +24 -0
  28. package/dist/ErrorBoundary.js +47 -0
  29. package/dist/Field.d.ts +17 -0
  30. package/dist/Field.js +48 -0
  31. package/dist/FileIcon.d.ts +8 -0
  32. package/dist/FileIcon.js +15 -0
  33. package/dist/Icon.d.ts +9 -0
  34. package/dist/Icon.js +12 -0
  35. package/dist/IconButton.d.ts +23 -0
  36. package/dist/IconButton.js +25 -0
  37. package/dist/IconPicker.d.ts +17 -0
  38. package/dist/IconPicker.js +93 -0
  39. package/dist/Input.d.ts +14 -0
  40. package/dist/Input.js +25 -0
  41. package/dist/Kbd.d.ts +23 -0
  42. package/dist/Kbd.js +20 -0
  43. package/dist/ListRow.d.ts +10 -0
  44. package/dist/ListRow.js +20 -0
  45. package/dist/Meter.d.ts +12 -0
  46. package/dist/Meter.js +7 -0
  47. package/dist/PanelEmpty.d.ts +16 -0
  48. package/dist/PanelEmpty.js +7 -0
  49. package/dist/PanelHeader.d.ts +6 -0
  50. package/dist/PanelHeader.js +20 -0
  51. package/dist/Pill.d.ts +27 -0
  52. package/dist/Pill.js +25 -0
  53. package/dist/ProjectSwitcher.d.ts +30 -0
  54. package/dist/ProjectSwitcher.js +18 -0
  55. package/dist/PromptDialog.d.ts +40 -0
  56. package/dist/PromptDialog.js +47 -0
  57. package/dist/SectionLabel.d.ts +3 -0
  58. package/dist/SectionLabel.js +16 -0
  59. package/dist/SegmentBar.d.ts +18 -0
  60. package/dist/SegmentBar.js +44 -0
  61. package/dist/Segmented.d.ts +17 -0
  62. package/dist/Segmented.js +7 -0
  63. package/dist/Select.d.ts +27 -0
  64. package/dist/Select.js +34 -0
  65. package/dist/Separator.d.ts +7 -0
  66. package/dist/Separator.js +8 -0
  67. package/dist/ShortcutHints.d.ts +1 -0
  68. package/dist/ShortcutHints.js +94 -0
  69. package/dist/Skeleton.d.ts +6 -0
  70. package/dist/Skeleton.js +6 -0
  71. package/dist/SlidingColumn.d.ts +19 -0
  72. package/dist/SlidingColumn.js +41 -0
  73. package/dist/Spinner.d.ts +6 -0
  74. package/dist/Spinner.js +7 -0
  75. package/dist/Stepper.d.ts +14 -0
  76. package/dist/Stepper.js +18 -0
  77. package/dist/Surface.d.ts +3 -0
  78. package/dist/Surface.js +16 -0
  79. package/dist/Switch.d.ts +10 -0
  80. package/dist/Switch.js +7 -0
  81. package/dist/TextMenu.d.ts +5 -0
  82. package/dist/TextMenu.js +31 -0
  83. package/dist/Tile.d.ts +11 -0
  84. package/dist/Tile.js +10 -0
  85. package/dist/Toasts.d.ts +9 -0
  86. package/dist/Toasts.js +58 -0
  87. package/dist/Tooltip.d.ts +16 -0
  88. package/dist/Tooltip.js +21 -0
  89. package/dist/UIProvider.d.ts +9 -0
  90. package/dist/UIProvider.js +26 -0
  91. package/dist/Waveform.d.ts +16 -0
  92. package/dist/Waveform.js +123 -0
  93. package/dist/Wipe.d.ts +12 -0
  94. package/dist/Wipe.js +47 -0
  95. package/dist/ZoomControls.d.ts +30 -0
  96. package/dist/ZoomControls.js +16 -0
  97. package/dist/class-name.d.ts +2 -0
  98. package/dist/class-name.js +3 -0
  99. package/dist/clipboard.d.ts +2 -0
  100. package/dist/clipboard.js +16 -0
  101. package/dist/context-menu/index.parts.d.ts +1 -0
  102. package/dist/context-menu/index.parts.js +2 -0
  103. package/dist/dialog/index.parts.d.ts +1 -0
  104. package/dist/dialog/index.parts.js +1 -0
  105. package/dist/dialog/parts.d.ts +36 -0
  106. package/dist/dialog/parts.js +55 -0
  107. package/dist/dialog-layer.d.ts +1 -0
  108. package/dist/dialog-layer.js +27 -0
  109. package/dist/error-boundary.d.ts +4 -0
  110. package/dist/error-boundary.js +34 -0
  111. package/dist/error-message.d.ts +1 -0
  112. package/dist/error-message.js +4 -0
  113. package/dist/field-context.d.ts +7 -0
  114. package/dist/field-context.js +4 -0
  115. package/dist/file-icon.d.ts +4 -0
  116. package/dist/file-icon.js +46 -0
  117. package/dist/floating.d.ts +5 -0
  118. package/dist/floating.js +6 -0
  119. package/dist/format/datetime.d.ts +11 -0
  120. package/dist/format/datetime.js +50 -0
  121. package/dist/format/duration.d.ts +5 -0
  122. package/dist/format/duration.js +76 -0
  123. package/dist/format/index.d.ts +6 -0
  124. package/dist/format/index.js +7 -0
  125. package/dist/format/locale.d.ts +17 -0
  126. package/dist/format/locale.js +105 -0
  127. package/dist/format/number.d.ts +8 -0
  128. package/dist/format/number.js +72 -0
  129. package/dist/format/regions.d.ts +6 -0
  130. package/dist/format/regions.js +15 -0
  131. package/dist/format/time-zone.d.ts +1 -0
  132. package/dist/format/time-zone.js +2 -0
  133. package/dist/icon-button-size.d.ts +3 -0
  134. package/dist/icon-button-size.js +3 -0
  135. package/dist/icon-picker.d.ts +23 -0
  136. package/dist/icon-picker.js +80 -0
  137. package/dist/index.d.ts +74 -0
  138. package/dist/index.js +74 -0
  139. package/dist/lazy.d.ts +12 -0
  140. package/dist/lazy.js +104 -0
  141. package/dist/locales/en.json +59 -0
  142. package/dist/locales/nl.json +59 -0
  143. package/dist/locales.d.ts +4 -0
  144. package/dist/locales.js +17 -0
  145. package/dist/menu/index.parts.d.ts +1 -0
  146. package/dist/menu/index.parts.js +1 -0
  147. package/dist/menu/parts.d.ts +67 -0
  148. package/dist/menu/parts.js +113 -0
  149. package/dist/merge-refs.d.ts +2 -0
  150. package/dist/merge-refs.js +11 -0
  151. package/dist/modality.d.ts +1 -0
  152. package/dist/modality.js +29 -0
  153. package/dist/platform.d.ts +2 -0
  154. package/dist/platform.js +9 -0
  155. package/dist/popover/index.parts.d.ts +1 -0
  156. package/dist/popover/index.parts.js +1 -0
  157. package/dist/popover/parts.d.ts +26 -0
  158. package/dist/popover/parts.js +28 -0
  159. package/dist/prefetch.d.ts +21 -0
  160. package/dist/prefetch.js +99 -0
  161. package/dist/preview-card/index.parts.d.ts +1 -0
  162. package/dist/preview-card/index.parts.js +1 -0
  163. package/dist/project-switcher-row.d.ts +6 -0
  164. package/dist/project-switcher-row.js +16 -0
  165. package/dist/selection.d.ts +2 -0
  166. package/dist/selection.js +22 -0
  167. package/dist/seti-icons.d.ts +5 -0
  168. package/dist/seti-icons.js +445 -0
  169. package/dist/settings/ConfirmDialog.d.ts +9 -0
  170. package/dist/settings/ConfirmDialog.js +9 -0
  171. package/dist/settings/DetailHeader.d.ts +10 -0
  172. package/dist/settings/DetailHeader.js +6 -0
  173. package/dist/settings/MasterDetail.d.ts +24 -0
  174. package/dist/settings/MasterDetail.js +16 -0
  175. package/dist/settings/SettingsDialog.d.ts +46 -0
  176. package/dist/settings/SettingsDialog.js +96 -0
  177. package/dist/settings/SettingsRow.d.ts +22 -0
  178. package/dist/settings/SettingsRow.js +30 -0
  179. package/dist/settings/SettingsSection.d.ts +14 -0
  180. package/dist/settings/SettingsSection.js +8 -0
  181. package/dist/settings/index.d.ts +7 -0
  182. package/dist/settings/index.js +8 -0
  183. package/dist/settings/target.d.ts +7 -0
  184. package/dist/settings/target.js +3 -0
  185. package/dist/shortcut-hints.d.ts +38 -0
  186. package/dist/shortcut-hints.js +81 -0
  187. package/dist/shortcut.d.ts +32 -0
  188. package/dist/shortcut.js +137 -0
  189. package/dist/testing/dedupe.d.ts +1 -0
  190. package/dist/testing/dedupe.js +73 -0
  191. package/dist/testing/fake-source.d.ts +8 -0
  192. package/dist/testing/fake-source.js +15 -0
  193. package/dist/testing/index.d.ts +1 -0
  194. package/dist/testing/index.js +2 -0
  195. package/dist/theme.css +562 -0
  196. package/dist/toast-store.d.ts +38 -0
  197. package/dist/toast-store.js +61 -0
  198. package/dist/useAsyncAction.d.ts +8 -0
  199. package/dist/useAsyncAction.js +23 -0
  200. package/dist/useColumnResize.d.ts +14 -0
  201. package/dist/useColumnResize.js +49 -0
  202. package/dist/useContentSize.d.ts +5 -0
  203. package/dist/useContentSize.js +21 -0
  204. package/dist/useMeasuredWidth.d.ts +1 -0
  205. package/dist/useMeasuredWidth.js +6 -0
  206. package/dist/useNow.d.ts +3 -0
  207. package/dist/useNow.js +41 -0
  208. package/dist/wipe-split.d.ts +7 -0
  209. package/dist/wipe-split.js +20 -0
  210. package/dist/zoom.d.ts +3 -0
  211. package/dist/zoom.js +6 -0
  212. package/package.json +92 -0
  213. package/src/AccentSwatches.tsx +73 -0
  214. package/src/Banner.tsx +43 -0
  215. package/src/Button.tsx +55 -0
  216. package/src/ButtonGroup.tsx +14 -0
  217. package/src/Checkbox.tsx +44 -0
  218. package/src/ChoiceCards.tsx +108 -0
  219. package/src/CloseButton.tsx +21 -0
  220. package/src/ColorSwatch.tsx +41 -0
  221. package/src/ColumnResizeHandle.tsx +23 -0
  222. package/src/DisabledReason.tsx +23 -0
  223. package/src/DockShell.tsx +76 -0
  224. package/src/EmptyState.tsx +48 -0
  225. package/src/ErrorBoundary.tsx +116 -0
  226. package/src/Field.tsx +116 -0
  227. package/src/FileIcon.tsx +27 -0
  228. package/src/Icon.tsx +22 -0
  229. package/src/IconButton.tsx +84 -0
  230. package/src/IconPicker.tsx +257 -0
  231. package/src/Input.tsx +78 -0
  232. package/src/Kbd.tsx +67 -0
  233. package/src/ListRow.tsx +26 -0
  234. package/src/Meter.tsx +41 -0
  235. package/src/PanelEmpty.tsx +37 -0
  236. package/src/PanelHeader.tsx +31 -0
  237. package/src/Pill.tsx +61 -0
  238. package/src/ProjectSwitcher.tsx +127 -0
  239. package/src/PromptDialog.tsx +168 -0
  240. package/src/SectionLabel.tsx +20 -0
  241. package/src/SegmentBar.tsx +128 -0
  242. package/src/Segmented.tsx +51 -0
  243. package/src/Select.tsx +135 -0
  244. package/src/Separator.tsx +15 -0
  245. package/src/ShortcutHints.tsx +118 -0
  246. package/src/Skeleton.tsx +13 -0
  247. package/src/SlidingColumn.tsx +91 -0
  248. package/src/Spinner.tsx +32 -0
  249. package/src/Stepper.tsx +67 -0
  250. package/src/Surface.tsx +20 -0
  251. package/src/Switch.tsx +32 -0
  252. package/src/TextMenu.tsx +59 -0
  253. package/src/Tile.tsx +49 -0
  254. package/src/Toasts.tsx +151 -0
  255. package/src/Tooltip.tsx +70 -0
  256. package/src/UIProvider.tsx +41 -0
  257. package/src/Waveform.tsx +199 -0
  258. package/src/Wipe.tsx +99 -0
  259. package/src/ZoomControls.tsx +99 -0
  260. package/src/class-name.ts +8 -0
  261. package/src/clipboard.ts +17 -0
  262. package/src/context-menu/index.parts.ts +21 -0
  263. package/src/dialog/index.parts.ts +10 -0
  264. package/src/dialog/parts.tsx +112 -0
  265. package/src/dialog-layer.ts +31 -0
  266. package/src/error-boundary.ts +40 -0
  267. package/src/error-message.ts +5 -0
  268. package/src/field-context.ts +12 -0
  269. package/src/file-icon.ts +52 -0
  270. package/src/floating.ts +9 -0
  271. package/src/format/datetime.ts +63 -0
  272. package/src/format/duration.ts +83 -0
  273. package/src/format/index.ts +20 -0
  274. package/src/format/locale.ts +153 -0
  275. package/src/format/number.ts +87 -0
  276. package/src/format/regions.ts +20 -0
  277. package/src/format/time-zone.ts +2 -0
  278. package/src/icon-button-size.ts +6 -0
  279. package/src/icon-picker.ts +112 -0
  280. package/src/index.ts +103 -0
  281. package/src/lazy.tsx +124 -0
  282. package/src/locales/en.json +59 -0
  283. package/src/locales/nl.json +59 -0
  284. package/src/locales.ts +21 -0
  285. package/src/menu/index.parts.ts +20 -0
  286. package/src/menu/parts.tsx +296 -0
  287. package/src/merge-refs.ts +14 -0
  288. package/src/modality.ts +37 -0
  289. package/src/platform.ts +10 -0
  290. package/src/popover/index.parts.ts +8 -0
  291. package/src/popover/parts.tsx +119 -0
  292. package/src/prefetch.ts +113 -0
  293. package/src/preview-card/index.parts.ts +1 -0
  294. package/src/project-switcher-row.tsx +51 -0
  295. package/src/selection.ts +24 -0
  296. package/src/seti-icons.ts +451 -0
  297. package/src/settings/ConfirmDialog.tsx +30 -0
  298. package/src/settings/DetailHeader.tsx +25 -0
  299. package/src/settings/MasterDetail.tsx +67 -0
  300. package/src/settings/SettingsDialog.tsx +342 -0
  301. package/src/settings/SettingsRow.tsx +89 -0
  302. package/src/settings/SettingsSection.tsx +46 -0
  303. package/src/settings/index.ts +16 -0
  304. package/src/settings/target.ts +12 -0
  305. package/src/shortcut-hints.ts +117 -0
  306. package/src/shortcut.ts +170 -0
  307. package/src/testing/dedupe.ts +93 -0
  308. package/src/testing/fake-source.ts +21 -0
  309. package/src/testing/index.ts +3 -0
  310. package/src/theme.css +562 -0
  311. package/src/toast-store.ts +120 -0
  312. package/src/useAsyncAction.ts +32 -0
  313. package/src/useColumnResize.ts +75 -0
  314. package/src/useContentSize.ts +27 -0
  315. package/src/useMeasuredWidth.ts +7 -0
  316. package/src/useNow.ts +49 -0
  317. package/src/wipe-split.ts +24 -0
  318. package/src/zoom.ts +9 -0
package/src/Select.tsx ADDED
@@ -0,0 +1,135 @@
1
+ import type { ReactNode, Ref } from 'react';
2
+ import { Select as BaseSelect } from '@base-ui-components/react/select';
3
+ import clsx from 'clsx';
4
+ import { ChevronDown } from 'lucide-react';
5
+ import { useTranslation } from 'react-i18next';
6
+ import { Icon } from './Icon.tsx';
7
+ import { MENU_LABEL, MenuCheck } from './menu/parts.tsx';
8
+
9
+ export interface SelectItem<T extends string> {
10
+ value: T;
11
+ label: string;
12
+ /* A second line under the label, for what the choice does. */
13
+ description?: string;
14
+ icon?: ReactNode;
15
+ disabled?: boolean;
16
+ }
17
+
18
+ export interface SelectGroup<T extends string> {
19
+ label: string;
20
+ items: SelectItem<T>[];
21
+ }
22
+
23
+ export interface SelectProps<T extends string> {
24
+ value: T | null;
25
+ onValueChange(value: T): void;
26
+ items: SelectItem<T>[] | SelectGroup<T>[];
27
+ /* The accessible name of the trigger; the row above it usually carries the visible one. */
28
+ label: string;
29
+ /* What the trigger shows while nothing is picked. */
30
+ placeholder?: string;
31
+ size?: 'sm' | 'md';
32
+ variant?: 'outlined' | 'ghost';
33
+ disabled?: boolean;
34
+ align?: 'start' | 'end';
35
+ truncateValue?: boolean;
36
+ className?: string;
37
+ ref?: Ref<HTMLButtonElement>;
38
+ }
39
+
40
+ const isGroup = <T extends string>(entry: SelectItem<T> | SelectGroup<T>): entry is SelectGroup<T> => Array.isArray((entry as SelectGroup<T>).items);
41
+
42
+ const TRIGGER_SIZE = {
43
+ sm: 'h-7 gap-1 px-2',
44
+ md: 'h-8 gap-1.5 px-2'
45
+ } as const;
46
+
47
+ const TRIGGER_VARIANT = {
48
+ outlined: 'max-w-56 rounded-lg border border-border bg-surface-raised text-text',
49
+ ghost: 'rounded-md text-text-muted hover:bg-surface-hover hover:text-text data-[popup-open]:bg-surface-active data-[popup-open]:text-text data-disabled:hover:bg-transparent data-disabled:hover:text-text-muted'
50
+ } as const;
51
+
52
+ /* The dimmed look `Switch` and `Button` wear when disabled, so a select that cannot open reads as such. */
53
+ const TRIGGER_DISABLED = 'data-disabled:cursor-default data-disabled:opacity-50';
54
+
55
+ /* A description makes the row two lines high; the box keeps the icon on the label's line. */
56
+ function Row<T extends string>({ item, picked }: { item: SelectItem<T>; picked: boolean }) {
57
+ return (
58
+ <BaseSelect.Item className={clsx('menu-item', item.description && 'items-start')} value={item.value} label={item.label} disabled={item.disabled}>
59
+ <MenuCheck kind="radio" checked={picked} />
60
+ {item.icon && <span className="flex h-lh shrink-0 items-center">{item.icon}</span>}
61
+ <span className="flex min-w-0 flex-col">
62
+ <BaseSelect.ItemText>{item.label}</BaseSelect.ItemText>
63
+ {item.description && <span className="text-xs text-text-faint">{item.description}</span>}
64
+ </span>
65
+ </BaseSelect.Item>
66
+ );
67
+ }
68
+
69
+ /* The one select. A Base UI `Select` in the popup style the menus use, so a settings field and a
70
+ picker in a toolbar read the same. Base UI brings the keyboard along (arrows,
71
+ Home and End, typeahead, Enter, Escape), which a hand-rolled listbox would have to repeat. */
72
+ export function Select<T extends string>({
73
+ value,
74
+ onValueChange,
75
+ items,
76
+ label,
77
+ placeholder,
78
+ size = 'md',
79
+ variant = 'outlined',
80
+ disabled,
81
+ align = 'start',
82
+ truncateValue = true,
83
+ className,
84
+ ref
85
+ }: SelectProps<T>) {
86
+ const { t } = useTranslation('ui');
87
+ const groups = items.length > 0 && isGroup(items[0]!) ? (items as SelectGroup<T>[]) : null;
88
+ const flat = groups ? groups.flatMap((group) => group.items) : (items as SelectItem<T>[]);
89
+ const selected = flat.find((item) => item.value === value) ?? null;
90
+
91
+ return (
92
+ <BaseSelect.Root
93
+ items={flat.map((item) => ({ value: item.value, label: item.label }))}
94
+ value={value}
95
+ disabled={disabled}
96
+ onValueChange={(next) => onValueChange(next as T)}
97
+ >
98
+ <BaseSelect.Trigger
99
+ ref={ref}
100
+ aria-label={label}
101
+ className={clsx(
102
+ 'flex shrink-0 items-center whitespace-nowrap text-xs',
103
+ TRIGGER_SIZE[size],
104
+ TRIGGER_VARIANT[variant],
105
+ TRIGGER_DISABLED,
106
+ className
107
+ )}
108
+ >
109
+ {selected?.icon}
110
+ <BaseSelect.Value className={clsx(truncateValue && 'truncate')}>{() => selected?.label ?? placeholder ?? t('action.select')}</BaseSelect.Value>
111
+ <BaseSelect.Icon className="ml-auto flex shrink-0 pl-1">
112
+ <Icon icon={ChevronDown} size={14} />
113
+ </BaseSelect.Icon>
114
+ </BaseSelect.Trigger>
115
+ <BaseSelect.Portal>
116
+ <BaseSelect.Positioner className="popup-positioner" side="bottom" align={align} sideOffset={6} alignItemWithTrigger={false}>
117
+ <BaseSelect.Popup className="menu-popup">
118
+ <BaseSelect.List className="max-h-80 overflow-auto">
119
+ {groups
120
+ ? groups.map((group) => (
121
+ <BaseSelect.Group key={group.label}>
122
+ <BaseSelect.GroupLabel className={MENU_LABEL}>{group.label}</BaseSelect.GroupLabel>
123
+ {group.items.map((item) => (
124
+ <Row key={item.value} item={item} picked={item.value === value} />
125
+ ))}
126
+ </BaseSelect.Group>
127
+ ))
128
+ : flat.map((item) => <Row key={item.value} item={item} picked={item.value === value} />)}
129
+ </BaseSelect.List>
130
+ </BaseSelect.Popup>
131
+ </BaseSelect.Positioner>
132
+ </BaseSelect.Portal>
133
+ </BaseSelect.Root>
134
+ );
135
+ }
@@ -0,0 +1,15 @@
1
+ import type { Ref } from 'react';
2
+ import clsx from 'clsx';
3
+
4
+ export interface SeparatorProps {
5
+ orientation?: 'vertical' | 'horizontal';
6
+ className?: string;
7
+ ref?: Ref<HTMLSpanElement>;
8
+ }
9
+
10
+ /* A hairline between two groups of controls in a toolbar-like row. It carries no margin of its own.
11
+ The row's gap puts 8px on either side of it, which is what makes the line read as a divider and
12
+ not as a group of its own. Between two groups of menu rows it is `Menu.Separator`. */
13
+ export function Separator({ orientation = 'vertical', className, ref }: SeparatorProps) {
14
+ return <span ref={ref} aria-hidden className={clsx(orientation === 'vertical' ? 'h-4 w-px' : 'h-px w-full', 'shrink-0 bg-border', className)} />;
15
+ }
@@ -0,0 +1,118 @@
1
+ import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import { isApplePlatform } from './platform.ts';
4
+ import { formatShortcut } from './shortcut.ts';
5
+ import { ModifierHold, placeHint, shortcutHintTargets } from './shortcut-hints.ts';
6
+
7
+ interface PlacedHint {
8
+ id: number;
9
+ text: string;
10
+ style: CSSProperties;
11
+ }
12
+
13
+ /* Whether a person can see the button now: on screen, not faded out, and not under a dialog or another node. */
14
+ const isSeen = (element: Element, rect: DOMRect): boolean => {
15
+ if (rect.width === 0 || rect.height === 0 || rect.bottom <= 0 || rect.right <= 0 || rect.top >= window.innerHeight || rect.left >= window.innerWidth) {
16
+ return false;
17
+ }
18
+ if (!element.checkVisibility({ opacityProperty: true, visibilityProperty: true })) {
19
+ return false;
20
+ }
21
+ const hit = document.elementFromPoint((rect.left + rect.right) / 2, (rect.top + rect.bottom) / 2);
22
+ return hit !== null && element.contains(hit);
23
+ };
24
+
25
+ const measureHints = (apple: boolean): PlacedHint[] => {
26
+ const viewport = { width: window.innerWidth, height: window.innerHeight };
27
+ const hints: PlacedHint[] = [];
28
+ for (const [element, target] of shortcutHintTargets()) {
29
+ const rect = element.getBoundingClientRect();
30
+ if (!isSeen(element, rect)) {
31
+ continue;
32
+ }
33
+ const placement = placeHint(rect, viewport);
34
+ hints.push({
35
+ id: target.id,
36
+ text: formatShortcut(target.shortcut, apple),
37
+ style: { left: placement.left, right: placement.right, top: placement.top, translate: `${placement.translateX} ${placement.translateY}` }
38
+ });
39
+ }
40
+ return hints;
41
+ };
42
+
43
+ /*
44
+ * Every visible button's shortcut, under the button, for as long as the modifier is held on its own.
45
+ * Mounted once; the listener sits on the window in the capture phase so a terminal that keeps a key to
46
+ * itself still takes the hints down. An embedded page (`<webview>`) has the keyboard to itself and shows none.
47
+ */
48
+ export function ShortcutHints() {
49
+ const [shown, setShown] = useState(false);
50
+ const [hints, setHints] = useState<PlacedHint[]>([]);
51
+ const holdRef = useRef<ModifierHold | null>(null);
52
+
53
+ useEffect(() => {
54
+ const hold = new ModifierHold(isApplePlatform(), setShown);
55
+ holdRef.current = hold;
56
+ const onKeyDown = (e: KeyboardEvent): void => hold.keyDown(e);
57
+ const onKeyUp = (e: KeyboardEvent): void => hold.keyUp(e);
58
+ // A click or a zoom with the modifier down is a gesture, not someone looking for a shortcut.
59
+ const cancel = (): void => hold.cancel();
60
+ window.addEventListener('keydown', onKeyDown, true);
61
+ window.addEventListener('keyup', onKeyUp, true);
62
+ window.addEventListener('pointerdown', cancel, true);
63
+ window.addEventListener('wheel', cancel, { capture: true, passive: true });
64
+ window.addEventListener('blur', cancel);
65
+ return () => {
66
+ window.removeEventListener('keydown', onKeyDown, true);
67
+ window.removeEventListener('keyup', onKeyUp, true);
68
+ window.removeEventListener('pointerdown', cancel, true);
69
+ window.removeEventListener('wheel', cancel, true);
70
+ window.removeEventListener('blur', cancel);
71
+ hold.cancel();
72
+ };
73
+ }, []);
74
+
75
+ // Measured before paint and then every frame, so a hint never shows where its button was.
76
+ useLayoutEffect(() => {
77
+ if (!shown) {
78
+ return;
79
+ }
80
+ const apple = isApplePlatform();
81
+ let frame = 0;
82
+ let last = '';
83
+ const measure = (): void => {
84
+ // Focus that moved into a page takes the key-up with it, so the embedder never hears the modifier let go.
85
+ if (document.activeElement?.tagName === 'WEBVIEW') {
86
+ holdRef.current?.cancel();
87
+ return;
88
+ }
89
+ const next = measureHints(apple);
90
+ const signature = JSON.stringify(next);
91
+ if (signature !== last) {
92
+ last = signature;
93
+ setHints(next);
94
+ }
95
+ frame = requestAnimationFrame(measure);
96
+ };
97
+ measure();
98
+ return () => cancelAnimationFrame(frame);
99
+ }, [shown]);
100
+
101
+ if (!shown) {
102
+ return null;
103
+ }
104
+ return createPortal(
105
+ <div aria-hidden className="pointer-events-none fixed inset-0 z-(--z-shortcut-hints)">
106
+ {hints.map((hint) => (
107
+ <kbd
108
+ key={hint.id}
109
+ className="absolute rounded-md bg-text px-1.5 font-sans text-xs/tight font-medium whitespace-nowrap text-surface shadow-float"
110
+ style={hint.style}
111
+ >
112
+ {hint.text}
113
+ </kbd>
114
+ ))}
115
+ </div>,
116
+ document.body
117
+ );
118
+ }
@@ -0,0 +1,13 @@
1
+ import type { Ref } from 'react';
2
+ import clsx from 'clsx';
3
+
4
+ export interface SkeletonProps {
5
+ /* The width, and a height other than the one line of 16px. */
6
+ className?: string;
7
+ ref?: Ref<HTMLSpanElement>;
8
+ }
9
+
10
+ /* A bar where a value will be, while the answer is still on its way. */
11
+ export function Skeleton({ className, ref }: SkeletonProps) {
12
+ return <span ref={ref} className={clsx('block h-4 animate-pulse rounded bg-surface-sunken', className)} aria-hidden />;
13
+ }
@@ -0,0 +1,91 @@
1
+ import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, type Ref, type RefObject } from 'react';
2
+ import clsx from 'clsx';
3
+ import { ColumnResizeHandle } from './ColumnResizeHandle.tsx';
4
+ import { mergeRefs } from './merge-refs.ts';
5
+ import { useColumnResize } from './useColumnResize.ts';
6
+
7
+ // How long the open and close motion takes; the same number as `.sliding-column` in `theme.css`.
8
+ const TRANSITION_MS = 200;
9
+
10
+ export interface SlidingColumnProps {
11
+ open: boolean;
12
+ /* Already clamped by the caller, which knows what has to stay beside it (`clampColumnSize`). */
13
+ width: number;
14
+ bounds: { min: number; max(): number };
15
+ onWidthChange(width: number): void;
16
+ /*
17
+ * The width lands without the motion, for a column whose place is being restored as the page
18
+ * loads; only what a person does afterwards should animate.
19
+ */
20
+ instant?: boolean;
21
+ /*
22
+ * The column takes the keyboard itself, for a panel with shortcuts of its own. It is focusable
23
+ * by script alone, so opening something in it hands it the keys without a click first.
24
+ */
25
+ body?: {
26
+ ref: RefObject<HTMLDivElement | null>;
27
+ onKeyDown(event: ReactKeyboardEvent<HTMLDivElement>): void;
28
+ };
29
+ className?: string;
30
+ /* The outer column, for bounds that measure what is beside it rather than the whole window. */
31
+ ref?: Ref<HTMLElement>;
32
+ children?: ReactNode;
33
+ }
34
+
35
+ /*
36
+ * A panel that slides in and out along the right edge. The outer column is 0 wide and inert while
37
+ * closed; the contents sit in an inner column of the stored width, so they do not reflow while it
38
+ * moves, and they stay mounted until the slide is over, so a close plays out. The left edge is the
39
+ * drag handle.
40
+ */
41
+ export function SlidingColumn({ open, width, bounds, onWidthChange, instant = false, body, className, ref, children }: SlidingColumnProps) {
42
+ /* Closed and done animating. Until then the contents stay mounted, so a close plays out. */
43
+ const [settled, setSettled] = useState(!open);
44
+ /* A width that lands without a transition fires no `transitionend`, so the motion it would have
45
+ ended is over in the same commit that starts it. */
46
+ if (instant && settled !== !open) {
47
+ setSettled(!open);
48
+ }
49
+ const present = open || !settled;
50
+ const column = useRef<HTMLElement>(null);
51
+ const { startResize } = useColumnResize(column, { ...bounds, size: width, from: 'right', onSize: onWidthChange });
52
+
53
+ useEffect(() => {
54
+ if (open || settled) {
55
+ return;
56
+ }
57
+ // Reduced motion and a hidden tab paint no width change, so no `transitionend` arrives.
58
+ const timer = window.setTimeout(() => setSettled(true), TRANSITION_MS + 50);
59
+ return () => {
60
+ window.clearTimeout(timer);
61
+ };
62
+ }, [open, settled]);
63
+
64
+ return (
65
+ <aside
66
+ ref={mergeRefs(column, ref)}
67
+ inert={!open}
68
+ data-instant={instant ? '' : undefined}
69
+ className={clsx('sliding-column flex h-full shrink-0 justify-end overflow-hidden', className)}
70
+ style={{ width: open ? width : 0 }}
71
+ onTransitionEnd={(event) => {
72
+ if (event.propertyName === 'width' && event.target === event.currentTarget) {
73
+ setSettled(!open);
74
+ }
75
+ }}
76
+ >
77
+ {present && (
78
+ <div
79
+ ref={body?.ref}
80
+ tabIndex={body ? -1 : undefined}
81
+ className="relative flex h-full shrink-0 flex-col border-l border-border bg-surface outline-none"
82
+ style={{ width }}
83
+ onKeyDown={body?.onKeyDown}
84
+ >
85
+ {open && <ColumnResizeHandle from="right" onPointerDown={startResize} />}
86
+ {children}
87
+ </div>
88
+ )}
89
+ </aside>
90
+ );
91
+ }
@@ -0,0 +1,32 @@
1
+ import type { ComponentProps, CSSProperties } from 'react';
2
+ import clsx from 'clsx';
3
+
4
+ const DOTS = [0, 1, 2];
5
+
6
+ export interface SpinnerProps extends Omit<ComponentProps<'span'>, 'children'> {
7
+ /* In pixels, on the steps of an icon: 12, 14, 16 or 20. */
8
+ size?: number;
9
+ /* What runs, read by a screen reader. Left out where the words beside it already say so, which hides the spinner as an icon is hidden. */
10
+ label?: string;
11
+ }
12
+
13
+ /* Work in progress: three dots that leap over each other in the color of the text around them. With reduced motion they stand still. */
14
+ export function Spinner({ size = 16, label, className, style, ref, ...props }: SpinnerProps) {
15
+ return (
16
+ <span
17
+ ref={ref}
18
+ role={label === undefined ? undefined : 'img'}
19
+ aria-label={label}
20
+ aria-hidden={label === undefined ? true : undefined}
21
+ {...props}
22
+ className={clsx('spinner', className)}
23
+ style={{ ...style, '--spinner-size': `${size}px` } as CSSProperties}
24
+ >
25
+ {DOTS.map((dot) => (
26
+ <span key={dot} className="spinner-hop" style={{ '--spinner-step': dot } as CSSProperties}>
27
+ <span className="spinner-dot" />
28
+ </span>
29
+ ))}
30
+ </span>
31
+ );
32
+ }
@@ -0,0 +1,67 @@
1
+ import type { Ref } from 'react';
2
+ import clsx from 'clsx';
3
+ import { Minus, Plus } from 'lucide-react';
4
+ import { useTranslation } from 'react-i18next';
5
+ import { ButtonGroup } from './ButtonGroup.tsx';
6
+ import { formatFixed } from './format/number.ts';
7
+ import { IconButton } from './IconButton.tsx';
8
+
9
+ export interface StepperProps {
10
+ value: number;
11
+ onValueChange(value: number): void;
12
+ min: number;
13
+ max: number;
14
+ step: number;
15
+ /* Places after the separator, always shown: `1,0`, `1,5`. Defaults to the places `step` is written with. */
16
+ decimals?: number;
17
+ /* Printed right after the number, such as `px` or `%`. */
18
+ unit?: string;
19
+ label: string;
20
+ className?: string;
21
+ ref?: Ref<HTMLDivElement>;
22
+ }
23
+
24
+ const placesOf = (step: number): number => String(step).split('.')[1]?.length ?? 0;
25
+
26
+ /* A number between minus and plus, in one sunken group so the three read as a single control. */
27
+ export function Stepper({ value, onValueChange, min, max, step, decimals = placesOf(step), unit, label, className, ref }: StepperProps) {
28
+ const { t } = useTranslation('ui');
29
+ const reading = (amount: number) => (
30
+ <>
31
+ {formatFixed(amount, decimals)}
32
+ {unit && <span className="text-text-muted">{unit}</span>}
33
+ </>
34
+ );
35
+ const nudge = (direction: -1 | 1): void => {
36
+ const next = Math.round((value + direction * step) * 100) / 100;
37
+ onValueChange(Math.min(max, Math.max(min, next)));
38
+ };
39
+ return (
40
+ <ButtonGroup ref={ref} className={clsx('shrink-0 rounded-lg bg-surface-sunken p-0.5', className)} role="group" aria-label={label}>
41
+ <IconButton
42
+ icon={Minus}
43
+ size="sm"
44
+ label={t('controls.stepper.smallerFor', { label })}
45
+ tooltip={t('controls.stepper.smaller')}
46
+ disabled={value <= min}
47
+ onClick={() => nudge(-1)}
48
+ />
49
+ {/* The ends sit invisible in the same cell, so the widest of them sets the width and a step never moves the buttons. */}
50
+ <span className="grid min-w-10 px-1 text-center text-xs text-text tabular-nums">
51
+ <span className="invisible col-start-1 row-start-1">{reading(min)}</span>
52
+ <span className="invisible col-start-1 row-start-1">{reading(max)}</span>
53
+ <span className="col-start-1 row-start-1" aria-live="polite">
54
+ {reading(value)}
55
+ </span>
56
+ </span>
57
+ <IconButton
58
+ icon={Plus}
59
+ size="sm"
60
+ label={t('controls.stepper.largerFor', { label })}
61
+ tooltip={t('controls.stepper.larger')}
62
+ disabled={value >= max}
63
+ onClick={() => nudge(1)}
64
+ />
65
+ </ButtonGroup>
66
+ );
67
+ }
@@ -0,0 +1,20 @@
1
+ import clsx from 'clsx';
2
+ import { useRender } from '@base-ui-components/react/use-render';
3
+
4
+ const FLOAT = 'border border-border bg-[color-mix(in_srgb,var(--surface-raised)_88%,transparent)] shadow-float backdrop-blur-[14px]';
5
+
6
+ export type SurfaceProps = useRender.ComponentProps<'div'>;
7
+
8
+ /*
9
+ * The glass card that floats over the content under it: a dock, a banner, a toast, a find bar, a
10
+ * chip over a scrolling thread. It brings the ground, the border and the shadow; the caller brings
11
+ * the shape (radius, padding, height) and the position.
12
+ */
13
+ export function Surface({ render, className, ref, ...props }: SurfaceProps) {
14
+ return useRender({
15
+ render,
16
+ ref,
17
+ defaultTagName: 'div',
18
+ props: { ...props, className: clsx(FLOAT, className) }
19
+ });
20
+ }
package/src/Switch.tsx ADDED
@@ -0,0 +1,32 @@
1
+ import type { Ref } from 'react';
2
+ import clsx from 'clsx';
3
+ import { Switch as BaseSwitch } from '@base-ui-components/react/switch';
4
+
5
+ export interface SwitchProps {
6
+ checked: boolean;
7
+ onCheckedChange(checked: boolean): void;
8
+ /* The accessible name; the row it sits in usually carries the visible one. */
9
+ label: string;
10
+ disabled?: boolean;
11
+ className?: string;
12
+ ref?: Ref<HTMLButtonElement>;
13
+ }
14
+
15
+ /* On or off, as a switch rather than a checkbox: a setting that takes effect the moment it flips. */
16
+ export function Switch({ checked, onCheckedChange, label, disabled, className, ref }: SwitchProps) {
17
+ return (
18
+ <BaseSwitch.Root
19
+ ref={ref}
20
+ checked={checked}
21
+ onCheckedChange={(next) => onCheckedChange(next)}
22
+ aria-label={label}
23
+ disabled={disabled}
24
+ className={clsx(
25
+ 'relative h-5 w-9 shrink-0 rounded-full bg-border-strong p-0.5 transition-colors data-checked:bg-accent data-disabled:opacity-50',
26
+ className
27
+ )}
28
+ >
29
+ <BaseSwitch.Thumb className="block h-4 w-4 rounded-full bg-surface-raised shadow-raised transition-transform data-checked:translate-x-4" />
30
+ </BaseSwitch.Root>
31
+ );
32
+ }
@@ -0,0 +1,59 @@
1
+ import { useRef, useState, type ComponentProps, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from 'react';
2
+ import { Copy, Scan } from 'lucide-react';
3
+ import { useTranslation } from 'react-i18next';
4
+ import { copyText } from './clipboard.ts';
5
+ import { Icon } from './Icon.tsx';
6
+ import { Kbd } from './Kbd.tsx';
7
+ import { mergeRefs } from './merge-refs.ts';
8
+ import { ContextMenuRoot, ContextMenuTrigger, MenuItem, MenuPopup, MenuSeparator } from './menu/parts.tsx';
9
+ import { isApplePlatform } from './platform.ts';
10
+ import { selectAllWithin, selectionWithin } from './selection.ts';
11
+ import { EDIT_SHORTCUTS, matchesShortcut } from './shortcut.ts';
12
+
13
+ export type TextMenuProps = ComponentProps<'div'> & {
14
+ /* What the surface itself can be asked, under a line of their own: `ContextMenu.Item`s. */
15
+ items?: ReactNode;
16
+ };
17
+
18
+ /*
19
+ * A block of text and the menu that belongs to it: Copy for what is selected inside it, Select all
20
+ * for the whole of it, on Cmd+A as well. Anywhere text can be selected a right-click has to offer
21
+ * to copy it, and an app that draws every menu itself offers nothing at all without one of these.
22
+ */
23
+ export function TextMenu({ children, items, ref, ...rest }: TextMenuProps) {
24
+ const { t } = useTranslation('ui');
25
+ const host = useRef<HTMLDivElement>(null);
26
+ // Read when the menu opens: a selection made after that is not the one the click was about.
27
+ const [selection, setSelection] = useState('');
28
+
29
+ const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>): void => {
30
+ if (!matchesShortcut(EDIT_SHORTCUTS.selectAll, event, isApplePlatform())) {
31
+ return;
32
+ }
33
+ event.preventDefault();
34
+ selectAllWithin(host.current);
35
+ };
36
+
37
+ return (
38
+ <ContextMenuRoot onOpenChange={(open) => setSelection(open ? selectionWithin(host.current) : '')}>
39
+ <ContextMenuTrigger {...rest} ref={mergeRefs(host, ref)} onKeyDown={onKeyDown}>
40
+ {children}
41
+ </ContextMenuTrigger>
42
+ <MenuPopup>
43
+ <MenuItem disabled={selection === ''} onClick={() => copyText(selection)}>
44
+ <Icon icon={Copy} size={14} /> {t('action.copy')} <Kbd shortcut={EDIT_SHORTCUTS.copy} />
45
+ </MenuItem>
46
+ <MenuSeparator />
47
+ <MenuItem onClick={() => selectAllWithin(host.current)}>
48
+ <Icon icon={Scan} size={14} /> {t('action.selectAll')} <Kbd shortcut={EDIT_SHORTCUTS.selectAll} />
49
+ </MenuItem>
50
+ {items !== undefined && (
51
+ <>
52
+ <MenuSeparator />
53
+ {items}
54
+ </>
55
+ )}
56
+ </MenuPopup>
57
+ </ContextMenuRoot>
58
+ );
59
+ }
package/src/Tile.tsx ADDED
@@ -0,0 +1,49 @@
1
+ import type { ComponentProps, ReactNode } from 'react';
2
+ import clsx from 'clsx';
3
+ import { Kbd } from './Kbd.tsx';
4
+ import type { Shortcut } from './shortcut.ts';
5
+
6
+ export interface TileProps extends Omit<ComponentProps<'button'>, 'title'> {
7
+ icon: ReactNode;
8
+ title: string;
9
+ /* One short line under the title: where it acts, or why it waits. */
10
+ description?: ReactNode;
11
+ shortcut?: Shortcut;
12
+ /* The one tile a screen leads with. */
13
+ primary?: boolean;
14
+ /* `sm` for a narrow column like the sidebar. */
15
+ size?: 'md' | 'sm';
16
+ }
17
+
18
+ /*
19
+ * One thing to start with, as a card that is a button: a start screen and the empty places of an app
20
+ * use it, so a place to begin looks the same wherever it is offered.
21
+ */
22
+ export function Tile({ icon, title, description, shortcut, primary = false, size = 'md', className, type = 'button', ...rest }: TileProps) {
23
+ return (
24
+ <button
25
+ type={type}
26
+ className={clsx(
27
+ 'flex min-w-0 items-center border bg-surface text-left hover:bg-surface-hover disabled:opacity-50 disabled:hover:bg-surface',
28
+ size === 'md' ? 'gap-3 rounded-xl p-3' : 'gap-2 rounded-lg px-2 py-1.5',
29
+ primary ? 'border-accent' : 'border-border',
30
+ className
31
+ )}
32
+ {...rest}
33
+ >
34
+ <span
35
+ className={clsx(
36
+ 'grid size-8 shrink-0 place-items-center rounded-lg',
37
+ primary ? 'bg-accent text-accent-text' : 'bg-surface-sunken text-text-muted'
38
+ )}
39
+ >
40
+ {icon}
41
+ </span>
42
+ <span className="flex min-w-0 grow flex-col">
43
+ <span className="truncate text-sm font-medium text-text">{title}</span>
44
+ {description && <span className="truncate text-xs text-text-muted">{description}</span>}
45
+ </span>
46
+ {shortcut && <Kbd shortcut={shortcut} variant="inline" className="shrink-0" />}
47
+ </button>
48
+ );
49
+ }