@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
@@ -0,0 +1,84 @@
1
+ import type { ComponentProps, ReactElement, ReactNode } from 'react';
2
+ import clsx from 'clsx';
3
+ import type { LucideIcon } from 'lucide-react';
4
+ import { useRender } from '@base-ui-components/react/use-render';
5
+ import { Icon } from './Icon.tsx';
6
+ import type { Shortcut } from './shortcut.ts';
7
+ import { Spinner } from './Spinner.tsx';
8
+ import { Tooltip, type TooltipSide } from './Tooltip.tsx';
9
+
10
+ import { ICON_BUTTON_CLASS, ICON_BUTTON_ICON_SIZE, type IconButtonSize } from './icon-button-size.ts';
11
+
12
+ export type { IconButtonSize };
13
+
14
+ export interface IconButtonProps extends Omit<ComponentProps<'button'>, 'children'> {
15
+ /* Left out for a button that draws something other than one Lucide glyph, handed in as children. */
16
+ icon?: LucideIcon;
17
+ /* The accessible name, and the tooltip unless `tooltip` says otherwise. */
18
+ label: string;
19
+ /* A tooltip that says more than the name (a reason it is disabled), or `false` for none at all. */
20
+ tooltip?: ReactNode | false;
21
+ kbd?: Shortcut | string;
22
+ tooltipSide?: TooltipSide;
23
+ size?: IconButtonSize;
24
+ /* A toggle that is on, drawn as a pressed key. Use `aria-pressed` instead where the button is a real toggle. */
25
+ active?: boolean;
26
+ /* A `Spinner` in place of the icon, for a step that is running. */
27
+ busy?: boolean;
28
+ /** @deprecated Turns the icon; use `busy`, which draws a `Spinner` in its place. */
29
+ spin?: boolean;
30
+ /* Classes for the icon rather than the button: a tone of its own, a turn, a fill. The size stays the button's. */
31
+ iconClassName?: string;
32
+ /* Drawn after the icon: a count, or a word beside it together with `w-auto`. */
33
+ children?: ReactNode;
34
+ /* Another element to be the button, such as a menu trigger: `render={<Menu.Trigger />}`. */
35
+ render?: useRender.RenderProp;
36
+ }
37
+
38
+ /* Every button that is an icon and no word. A square, and the one place the icon inside it is sized. */
39
+ export function IconButton({
40
+ icon,
41
+ label,
42
+ tooltip,
43
+ kbd,
44
+ tooltipSide,
45
+ size = 'md',
46
+ active = false,
47
+ busy = false,
48
+ spin = false,
49
+ iconClassName,
50
+ render,
51
+ className,
52
+ ref,
53
+ children,
54
+ ...props
55
+ }: IconButtonProps) {
56
+ const button = useRender({
57
+ render: render ?? <button type="button" />,
58
+ ref,
59
+ props: {
60
+ 'aria-label': label,
61
+ 'data-active': active ? 'true' : undefined,
62
+ ...props,
63
+ className: clsx('icon-btn', ICON_BUTTON_CLASS[size], className),
64
+ children: (
65
+ <>
66
+ {busy ? (
67
+ <Spinner size={ICON_BUTTON_ICON_SIZE[size]} className={iconClassName} />
68
+ ) : (
69
+ icon !== undefined && <Icon icon={icon} size={ICON_BUTTON_ICON_SIZE[size]} className={clsx(spin && 'animate-spin', iconClassName)} />
70
+ )}
71
+ {children}
72
+ </>
73
+ )
74
+ }
75
+ });
76
+ if (tooltip === false) {
77
+ return button;
78
+ }
79
+ return (
80
+ <Tooltip label={tooltip ?? label} kbd={kbd} side={tooltipSide}>
81
+ {button as ReactElement<Record<string, unknown>>}
82
+ </Tooltip>
83
+ );
84
+ }
@@ -0,0 +1,257 @@
1
+ import { useId, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type Ref } from 'react';
2
+ import clsx from 'clsx';
3
+ import { Ban, Search, X } from 'lucide-react';
4
+ import { useTranslation } from 'react-i18next';
5
+ import { EmptyState } from './EmptyState.tsx';
6
+ import { formatNumber } from './format/number.ts';
7
+ import { Icon } from './Icon.tsx';
8
+ import { IconButton } from './IconButton.tsx';
9
+ import { cellForKey, escapeClearsSearch, filterSections, isGrouped, sectionsOf, tabStopOf, type IconPickerIcons } from './icon-picker.ts';
10
+ import { SectionLabel } from './SectionLabel.tsx';
11
+ import { Tooltip } from './Tooltip.tsx';
12
+
13
+ export type { IconPickerGroup } from './icon-picker.ts';
14
+
15
+ /* A row of cells is 28 high with a gap of 4. Under a heading of 28 the last row keeps 8 below it; without headings the grid has 6 above as well. */
16
+ const ROW_HEIGHT = 32;
17
+ const HEADED_EXTRA = 32;
18
+ const BARE_EXTRA = 10;
19
+
20
+ export interface IconPickerProps {
21
+ /* Every icon on offer by its name, in the order of the grid, or in groups under a heading each. The name is also its tooltip. */
22
+ icons: IconPickerIcons;
23
+ /* The name of the icon as it stands, or null for none. */
24
+ value: string | null;
25
+ onValueChange(name: string): void;
26
+ /* More words a search finds an icon by, beside its name and its group's label, by icon name. */
27
+ keywords?: Readonly<Record<string, readonly string[]>>;
28
+ /* A search field on top of the grid, in one frame with it. On by default for groups, off for a flat set. */
29
+ searchable?: boolean;
30
+ /* The grid as a scroll area this many rows high, with room for a heading. Without it the grid is as tall as its icons. */
31
+ rows?: number;
32
+ disabled?: boolean;
33
+ /* Clearing the icon. A surface that falls back to a default of its own offers that instead. */
34
+ onClear?: () => void;
35
+ /* The label above the grid. A surface that also takes an image calls this grid something else, or the two read as one list. */
36
+ label?: string;
37
+ className?: string;
38
+ ref?: Ref<HTMLDivElement>;
39
+ }
40
+
41
+ /* A heading is stuck while its group runs past the top of the scroll area, and only then draws a line under it. */
42
+ const markStuckHeadings = (area: HTMLElement): void => {
43
+ const top = area.scrollTop;
44
+ for (const heading of area.querySelectorAll<HTMLElement>('[data-icon-heading]')) {
45
+ const group = heading.parentElement;
46
+ const stuck = group !== null && top > 0 && group.offsetTop <= top && group.offsetTop + group.offsetHeight > top;
47
+ heading.toggleAttribute('data-stuck', stuck);
48
+ }
49
+ };
50
+
51
+ /*
52
+ * The mark a thing wears, one of a set of Lucide icons, as a grid under a label. With a search or a
53
+ * height in rows, the search and the grid share one frame. The grid is one tab stop: arrows move the
54
+ * focus and only Enter, Space or a click chooses, since a caller may save every choice at once.
55
+ */
56
+ export function IconPicker({
57
+ icons,
58
+ value,
59
+ onValueChange,
60
+ keywords,
61
+ searchable = isGrouped(icons),
62
+ rows,
63
+ disabled = false,
64
+ onClear,
65
+ label,
66
+ className,
67
+ ref
68
+ }: IconPickerProps) {
69
+ const { t } = useTranslation('ui');
70
+ const id = useId();
71
+ const area = useRef<HTMLDivElement>(null);
72
+ const input = useRef<HTMLInputElement>(null);
73
+ const [query, setQuery] = useState('');
74
+ const [focused, setFocused] = useState<string | null>(null);
75
+ const headed = isGrouped(icons);
76
+ const framed = searchable || rows !== undefined;
77
+ const sections = useMemo(() => sectionsOf(icons), [icons]);
78
+ const shown = useMemo(() => filterSections(sections, query, keywords), [sections, query, keywords]);
79
+ const tabStop = tabStopOf(
80
+ shown.flatMap((section) => section.icons.map(([name]) => name)),
81
+ focused,
82
+ value
83
+ );
84
+ const name = label ?? t('iconPicker.label');
85
+
86
+ // Opens with the chosen icon in the middle of the area: a person changing an icon often starts from the current one.
87
+ useLayoutEffect(() => {
88
+ const element = area.current;
89
+ const chosen = element?.querySelector<HTMLElement>('[aria-checked="true"]');
90
+ if (!element || !chosen || element.scrollHeight <= element.clientHeight) {
91
+ return;
92
+ }
93
+ const heading = element.querySelector<HTMLElement>('[data-icon-heading]')?.offsetHeight ?? 0;
94
+ element.scrollTop = Math.round(chosen.offsetTop - heading - (element.clientHeight - heading - chosen.offsetHeight) / 2);
95
+ }, []);
96
+
97
+ const search = (next: string): void => {
98
+ setQuery(next);
99
+ if (area.current) {
100
+ area.current.scrollTop = 0;
101
+ }
102
+ };
103
+
104
+ const moveFocus = (event: KeyboardEvent<HTMLDivElement>): void => {
105
+ if (event.metaKey || event.ctrlKey || event.altKey) {
106
+ return;
107
+ }
108
+ const cells = [...event.currentTarget.querySelectorAll<HTMLElement>('[role="radio"]')];
109
+ const from = cells.indexOf(event.target as HTMLElement);
110
+ if (from === -1) {
111
+ return;
112
+ }
113
+ const to = cellForKey(
114
+ event.key,
115
+ cells.map((cell) => cell.getBoundingClientRect()),
116
+ from
117
+ );
118
+ if (to === null) {
119
+ return;
120
+ }
121
+ event.preventDefault();
122
+ cells[to]?.focus();
123
+ };
124
+
125
+ const grid = (
126
+ <div
127
+ ref={area}
128
+ id={`${id}-grid`}
129
+ role="radiogroup"
130
+ aria-label={name}
131
+ className={clsx(framed && 'relative overflow-y-auto overscroll-contain', framed && (headed ? 'scroll-pt-7' : 'px-1.5 pt-1.5 pb-2'))}
132
+ style={rows === undefined ? undefined : { height: rows * ROW_HEIGHT + (headed ? HEADED_EXTRA : BARE_EXTRA) }}
133
+ onScroll={(event) => markStuckHeadings(event.currentTarget)}
134
+ onKeyDown={moveFocus}
135
+ >
136
+ {shown.length === 0 && query.trim() !== '' ? (
137
+ <EmptyState className="h-full">{t('iconPicker.noMatch', { query: query.trim() })}</EmptyState>
138
+ ) : (
139
+ shown.map((section, index) => {
140
+ const cells = (
141
+ <div
142
+ key={section.id}
143
+ className={clsx('grid grid-cols-[repeat(auto-fill,minmax(28px,1fr))] gap-1', framed && section.label !== null && 'px-1.5 pb-2')}
144
+ >
145
+ {section.icons.map(([icon, glyph]) => (
146
+ <Tooltip key={icon} label={icon} name>
147
+ <button
148
+ type="button"
149
+ role="radio"
150
+ aria-checked={value === icon}
151
+ tabIndex={icon === tabStop ? 0 : -1}
152
+ disabled={disabled}
153
+ className={clsx(
154
+ 'flex h-7 items-center justify-center rounded-md hover:bg-surface-hover disabled:opacity-50',
155
+ value === icon ? 'bg-surface-active text-text' : 'text-text-muted'
156
+ )}
157
+ onFocus={() => setFocused(icon)}
158
+ onClick={() => onValueChange(icon)}
159
+ >
160
+ <Icon icon={glyph} size={16} />
161
+ </button>
162
+ </Tooltip>
163
+ ))}
164
+ </div>
165
+ );
166
+ if (section.label === null) {
167
+ return cells;
168
+ }
169
+ const headingId = `${id}-group-${index}`;
170
+ return (
171
+ <div key={section.id} role="group" aria-labelledby={headingId}>
172
+ <SectionLabel
173
+ render={<div />}
174
+ data-icon-heading=""
175
+ className={clsx(
176
+ 'flex h-7 items-center gap-2',
177
+ framed ? 'sticky top-0 z-10 bg-surface px-2.5 data-stuck:shadow-[0_1px_0_var(--border)]' : 'mt-1'
178
+ )}
179
+ >
180
+ <span id={headingId} className="truncate">
181
+ {section.label}
182
+ </span>
183
+ <span className="ms-auto shrink-0">{formatNumber(section.icons.length)}</span>
184
+ </SectionLabel>
185
+ {cells}
186
+ </div>
187
+ );
188
+ })
189
+ )}
190
+ </div>
191
+ );
192
+
193
+ return (
194
+ <div ref={ref} className={className}>
195
+ <SectionLabel render={<div />} className="mb-1.5 flex items-center gap-2">
196
+ {name}
197
+ {onClear && (
198
+ <>
199
+ <span className="grow" />
200
+ <IconButton
201
+ icon={Ban}
202
+ size="sm"
203
+ label={t('iconPicker.none')}
204
+ className="-my-1"
205
+ disabled={disabled || value === null}
206
+ onClick={onClear}
207
+ />
208
+ </>
209
+ )}
210
+ </SectionLabel>
211
+ {framed ? (
212
+ // The frame wears the field's outline while its search has the focus; the search draws none of its own.
213
+ <div className="field h-auto overflow-hidden p-0 has-[input:focus-visible]:outline-2 has-[input:focus-visible]:-outline-offset-1 has-[input:focus-visible]:outline-accent">
214
+ {searchable && (
215
+ <div className="flex h-8 items-center gap-2 border-b border-border pr-1 pl-2.5">
216
+ <Icon icon={Search} size={14} className="shrink-0 text-text-faint" />
217
+ <input
218
+ ref={input}
219
+ type="text"
220
+ value={query}
221
+ placeholder={t('iconPicker.search')}
222
+ aria-label={t('iconPicker.search')}
223
+ aria-controls={`${id}-grid`}
224
+ spellCheck={false}
225
+ autoComplete="off"
226
+ disabled={disabled}
227
+ className="h-full min-w-0 flex-1 bg-transparent outline-none placeholder:text-text-faint"
228
+ onChange={(event) => search(event.target.value)}
229
+ onKeyDown={(event) => {
230
+ if (escapeClearsSearch(event.key, query)) {
231
+ event.stopPropagation();
232
+ search('');
233
+ }
234
+ }}
235
+ />
236
+ {query && (
237
+ <IconButton
238
+ icon={X}
239
+ size="sm"
240
+ label={t('iconPicker.clear')}
241
+ tooltip={false}
242
+ onClick={() => {
243
+ search('');
244
+ input.current?.focus();
245
+ }}
246
+ />
247
+ )}
248
+ </div>
249
+ )}
250
+ {grid}
251
+ </div>
252
+ ) : (
253
+ grid
254
+ )}
255
+ </div>
256
+ );
257
+ }
package/src/Input.tsx ADDED
@@ -0,0 +1,78 @@
1
+ import type { ComponentProps } from 'react';
2
+ import type { LucideIcon } from 'lucide-react';
3
+ import clsx from 'clsx';
4
+ import { useFieldControl } from './field-context.ts';
5
+ import { Icon } from './Icon.tsx';
6
+
7
+ export interface InputProps extends Omit<ComponentProps<'input'>, 'size'> {
8
+ /* `sm` is the height of a small button: a row of tools, or a name typed in place of the label it renames. */
9
+ size?: 'md' | 'sm';
10
+ /* A branch, a path or anything else read back letter by letter. */
11
+ mono?: boolean;
12
+ /* Drawn in front of the text, such as a magnifier on a search field. `className` then goes to the box around the icon and the input; `ref` stays on the input. */
13
+ icon?: LucideIcon;
14
+ }
15
+
16
+ /* The one text input: one border and one focus ring at the one height or the compact one. */
17
+ export function Input({ size = 'md', mono = false, icon, className, id, ...props }: InputProps) {
18
+ const field = useFieldControl();
19
+ const small = size === 'sm';
20
+ const control = {
21
+ id: id ?? field?.id,
22
+ 'aria-describedby': field?.describedBy,
23
+ 'aria-invalid': field?.invalid || undefined,
24
+ ...props
25
+ };
26
+
27
+ if (icon === undefined) {
28
+ return <input {...control} className={clsx('field', small && 'field-sm', mono && 'font-mono text-code', className)} />;
29
+ }
30
+
31
+ // The input fills the whole box and the icon lets clicks through, so a click on the icon still lands in the input.
32
+ return (
33
+ <span className={clsx('field relative flex p-0', small && 'field-sm', className)}>
34
+ <Icon
35
+ icon={icon}
36
+ size={small ? 12 : 14}
37
+ className={clsx('pointer-events-none absolute top-1/2 -translate-y-1/2 text-text-faint', small ? 'left-2' : 'left-2.5')}
38
+ />
39
+ <input
40
+ {...control}
41
+ className={clsx(
42
+ 'min-w-0 flex-1 bg-transparent outline-none placeholder:text-text-faint',
43
+ small ? 'pr-2 pl-7' : 'pr-2.5 pl-8',
44
+ mono && 'font-mono text-code'
45
+ )}
46
+ />
47
+ </span>
48
+ );
49
+ }
50
+
51
+ export interface TextAreaProps extends ComponentProps<'textarea'> {
52
+ /* `sm` is the type and the padding of a small input: a commit message or a note in a narrow panel. */
53
+ size?: 'md' | 'sm';
54
+ /* `vertical` gives it a handle to drag it taller, for a draft of a few sentences. */
55
+ resize?: 'none' | 'vertical';
56
+ /* A command or anything else read back letter by letter. */
57
+ mono?: boolean;
58
+ }
59
+
60
+ /* A field of a few lines: a commit message, the body of a pull request, a note. At least 64px tall; `rows` makes it taller. */
61
+ export function TextArea({ size = 'md', resize = 'none', mono = false, className, id, ...props }: TextAreaProps) {
62
+ const field = useFieldControl();
63
+ return (
64
+ <textarea
65
+ id={id ?? field?.id}
66
+ aria-describedby={field?.describedBy}
67
+ aria-invalid={field?.invalid || undefined}
68
+ {...props}
69
+ className={clsx(
70
+ 'field h-auto min-h-16 py-1.5',
71
+ size === 'sm' && 'field-sm',
72
+ resize === 'vertical' ? 'resize-y' : 'resize-none',
73
+ mono && 'font-mono text-code',
74
+ className
75
+ )}
76
+ />
77
+ );
78
+ }
package/src/Kbd.tsx ADDED
@@ -0,0 +1,67 @@
1
+ import type { ReactNode, Ref } from 'react';
2
+ import clsx from 'clsx';
3
+ import { isApplePlatform } from './platform.ts';
4
+ import { formatShortcut, shortcutParts, type Shortcut } from './shortcut.ts';
5
+
6
+ /* A shortcut chip on the surface it sits on: in a tooltip, in a palette's footer, in a line of help. */
7
+ const INLINE_KBD = 'rounded-sm bg-surface-sunken px-[5px] py-px font-sans text-xs/[inherit] text-text-muted';
8
+
9
+ /* One key, drawn as a cap. A settings page prints a shortcut key by key, so a gesture can stand beside it. */
10
+ const KEY_CAP =
11
+ 'inline-grid h-6 min-w-6 place-items-center rounded-md border border-b-2 border-border-strong bg-surface-hover bg-clip-padding px-1.5 font-sans text-xs text-text';
12
+
13
+ export interface KbdProps {
14
+ /* Printed the way this platform writes it. Without one, the children are printed as they are (`↑`, `Esc`). */
15
+ shortcut?: Shortcut;
16
+ children?: ReactNode;
17
+ /*
18
+ * Where the shortcut is printed. In a menu row the `.menu-item kbd` rule dresses it, as the
19
+ * quiet hint at the end of the row; anywhere else it is a chip on the surface it sits on.
20
+ */
21
+ variant?: 'menu' | 'inline';
22
+ className?: string;
23
+ ref?: Ref<HTMLElement>;
24
+ }
25
+
26
+ /* A shortcut as this platform prints it: `⌘K` on macOS, `Ctrl+K` elsewhere. */
27
+ export function Kbd({ shortcut, children, variant = 'menu', className, ref }: KbdProps) {
28
+ return (
29
+ <kbd ref={ref} className={clsx(variant === 'inline' && INLINE_KBD, className)}>
30
+ {shortcut !== undefined ? formatShortcut(shortcut, isApplePlatform()) : children}
31
+ </kbd>
32
+ );
33
+ }
34
+
35
+ export interface KeyCapProps {
36
+ children: ReactNode;
37
+ className?: string;
38
+ ref?: Ref<HTMLElement>;
39
+ }
40
+
41
+ export function KeyCap({ children, className, ref }: KeyCapProps) {
42
+ return (
43
+ <kbd ref={ref} className={clsx(KEY_CAP, className)}>
44
+ {children}
45
+ </kbd>
46
+ );
47
+ }
48
+
49
+ export interface KeysProps {
50
+ shortcut: Shortcut;
51
+ /* The pointer gesture the keys go with, such as "drag", as one more cap. */
52
+ then?: string;
53
+ className?: string;
54
+ ref?: Ref<HTMLSpanElement>;
55
+ }
56
+
57
+ /* A shortcut as one cap per key, `⌘` and `K`, plus the pointer gesture it goes with. */
58
+ export function Keys({ shortcut, then, className, ref }: KeysProps) {
59
+ const parts = [...shortcutParts(shortcut, isApplePlatform()), ...(then ? [then] : [])];
60
+ return (
61
+ <span ref={ref} className={clsx('flex items-center gap-1', className)}>
62
+ {parts.map((part, index) => (
63
+ <KeyCap key={`${part}-${index}`}>{part}</KeyCap>
64
+ ))}
65
+ </span>
66
+ );
67
+ }
@@ -0,0 +1,26 @@
1
+ import clsx from 'clsx';
2
+ import { useRender } from '@base-ui-components/react/use-render';
3
+
4
+ const VARIANT = {
5
+ /* A row of a list that reads as a table and fills the width of its panel: commits, processes,
6
+ branches. Square, so the rows stack into one block. It carries no padding; the caller pads 12px
7
+ beside text and 4px beside a button. */
8
+ flat: 'flex h-7 items-center',
9
+ /* A row of a list a person finds their way through: a sidebar, devices, recent files. Inset from
10
+ the edge and rounded, so each row reads as a place of its own. */
11
+ inset: 'flex h-8 items-center rounded-md px-2'
12
+ } as const;
13
+
14
+ export type ListRowProps = useRender.ComponentProps<'div'> & {
15
+ variant: keyof typeof VARIANT;
16
+ };
17
+
18
+ /* The height and the shape of one row of a list. What it does (a button, a link, a menu trigger) comes from `render`. */
19
+ export function ListRow({ variant, render, className, ref, ...props }: ListRowProps) {
20
+ return useRender({
21
+ render,
22
+ ref,
23
+ defaultTagName: 'div',
24
+ props: { ...props, className: clsx(VARIANT[variant], className) }
25
+ });
26
+ }
package/src/Meter.tsx ADDED
@@ -0,0 +1,41 @@
1
+ import type { Ref } from 'react';
2
+ import clsx from 'clsx';
3
+
4
+ export interface MeterProps {
5
+ /* In the unit of `min` and `max`, held to them. Null draws an empty track, for a level not measured yet. */
6
+ value: number | null;
7
+ min?: number;
8
+ max?: number;
9
+ /* Values drawn as lines across the track, such as a target or a limit. */
10
+ marks?: readonly number[];
11
+ label: string;
12
+ /* The value in words for a screen reader, such as "-18.2 LUFS". Left out, it reads the number. */
13
+ valueText?: string;
14
+ /* Its width, which it fills. */
15
+ className?: string;
16
+ ref?: Ref<HTMLDivElement>;
17
+ }
18
+
19
+ /* A level against a scale: how full it is, and where a target or a limit sits on the same scale. */
20
+ export function Meter({ value, min = 0, max = 100, marks = [], label, valueText, className, ref }: MeterProps) {
21
+ const fractionOf = (level: number): number => (max > min ? Math.min(1, Math.max(0, (level - min) / (max - min))) : 0);
22
+
23
+ return (
24
+ <div
25
+ ref={ref}
26
+ role="meter"
27
+ aria-label={label}
28
+ aria-valuemin={min}
29
+ aria-valuemax={max}
30
+ aria-valuenow={value === null ? min : Math.min(max, Math.max(min, value))}
31
+ aria-valuetext={valueText}
32
+ className={clsx('relative h-1.5 rounded-full bg-surface-sunken', className)}
33
+ >
34
+ {/* No transition on the width: a level that updates many times a second would lag and blur. */}
35
+ {value !== null && <span className="absolute inset-y-0 left-0 rounded-full bg-accent" style={{ width: `${fractionOf(value) * 100}%` }} />}
36
+ {marks.map((mark) => (
37
+ <span key={mark} className="absolute -inset-y-0.5 w-px bg-text-muted" style={{ left: `${fractionOf(mark) * 100}%` }} />
38
+ ))}
39
+ </div>
40
+ );
41
+ }
@@ -0,0 +1,37 @@
1
+ import type { ReactNode, Ref } from 'react';
2
+ import type { LucideIcon } from 'lucide-react';
3
+ import clsx from 'clsx';
4
+ import { EmptyState } from './EmptyState.tsx';
5
+
6
+ export interface PanelEmptyProps {
7
+ /* The panel's own header, kept above the sentence so a person can still act while nothing is listed. */
8
+ header?: ReactNode;
9
+ icon?: LucideIcon;
10
+ /* A `Spinner` in place of the icon, for a panel that is still loading. */
11
+ busy?: boolean;
12
+ /** @deprecated Turns the icon; use `busy`, which draws a `Spinner` in its place. */
13
+ spin?: boolean;
14
+ action?: ReactNode;
15
+ /* The sunken ground, for a box that has none behind it yet (a card on a canvas) rather than a panel that does. */
16
+ sunken?: boolean;
17
+ /*
18
+ * A panel is one track of a flex column and takes what is left of it; a card is a box with a
19
+ * height of its own, and `grow` means nothing to the block that lays it out.
20
+ */
21
+ fill?: 'grow' | 'full';
22
+ children: ReactNode;
23
+ className?: string;
24
+ ref?: Ref<HTMLDivElement>;
25
+ }
26
+
27
+ /* What a panel or a card shows while it holds nothing: a sentence, one icon, at most one button. */
28
+ export function PanelEmpty({ header, icon, busy = false, spin = false, action, sunken = false, fill = 'grow', children, className, ref }: PanelEmptyProps) {
29
+ return (
30
+ <div ref={ref} className={clsx('grid min-h-0 place-items-center', fill === 'full' ? 'h-full' : 'grow', sunken && 'bg-surface-sunken', className)}>
31
+ {header}
32
+ <EmptyState icon={icon} busy={busy} spin={spin} action={action}>
33
+ {children}
34
+ </EmptyState>
35
+ </div>
36
+ );
37
+ }
@@ -0,0 +1,31 @@
1
+ import type { ReactNode } from 'react';
2
+ import clsx from 'clsx';
3
+ import { useRender } from '@base-ui-components/react/use-render';
4
+ import { SectionLabel } from './SectionLabel.tsx';
5
+
6
+ export type PanelHeaderProps = useRender.ComponentProps<'header'> & {
7
+ /* The panel's name, as a section label first on the bar. Left out when the caller lays out the name itself. */
8
+ title?: ReactNode;
9
+ };
10
+
11
+ /*
12
+ * The bar across the top of a side panel, as tall as a 48px toolbar beside it: the panel's name,
13
+ * its own controls, and a close button last.
14
+ */
15
+ export function PanelHeader({ title, render, className, ref, children, ...props }: PanelHeaderProps) {
16
+ return useRender({
17
+ render,
18
+ ref,
19
+ defaultTagName: 'header',
20
+ props: {
21
+ ...props,
22
+ className: clsx('flex h-12 shrink-0 items-center gap-2 border-b border-border pr-2 pl-3', className),
23
+ children: (
24
+ <>
25
+ {title !== undefined && <SectionLabel className="shrink-0">{title}</SectionLabel>}
26
+ {children}
27
+ </>
28
+ )
29
+ }
30
+ });
31
+ }