@adecore/ui 0.53.3 → 0.54.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 (180) hide show
  1. package/dist/Banner.d.ts +1 -1
  2. package/dist/ButtonGroup.js +1 -1
  3. package/dist/Checkbox.js +1 -1
  4. package/dist/ChoiceCards.d.ts +1 -1
  5. package/dist/ChoiceCards.js +2 -2
  6. package/dist/CloseButton.js +1 -1
  7. package/dist/ColorSwatch.d.ts +1 -1
  8. package/dist/ColorSwatch.js +1 -1
  9. package/dist/ColorWheelPicker.js +1 -1
  10. package/dist/CopyButton.js +1 -1
  11. package/dist/DockShell.js +1 -1
  12. package/dist/EmptyState.d.ts +1 -1
  13. package/dist/EmptyState.js +1 -1
  14. package/dist/ErrorBoundary.js +122 -112
  15. package/dist/Field.d.ts +1 -1
  16. package/dist/Field.js +2 -2
  17. package/dist/FileIcon.js +1 -1
  18. package/dist/Icon.d.ts +1 -1
  19. package/dist/IconButton.d.ts +3 -3
  20. package/dist/IconButton.js +2 -2
  21. package/dist/IconPicker.js +35 -32
  22. package/dist/Input.d.ts +1 -1
  23. package/dist/ListRow.js +1 -1
  24. package/dist/MiddleTruncate.js +2 -2
  25. package/dist/PanelEmpty.d.ts +1 -1
  26. package/dist/PanelHeader.d.ts +1 -1
  27. package/dist/PanelHeader.js +1 -1
  28. package/dist/ProjectSwitcher.js +1 -1
  29. package/dist/PromptDialog.d.ts +1 -1
  30. package/dist/ScrollArea.d.ts +12 -0
  31. package/dist/ScrollArea.js +144 -0
  32. package/dist/SectionLabel.js +1 -1
  33. package/dist/SegmentBar.js +1 -1
  34. package/dist/Segmented.d.ts +1 -1
  35. package/dist/Select.d.ts +1 -1
  36. package/dist/Select.js +22 -16
  37. package/dist/SelectSearch.js +49 -33
  38. package/dist/ShortcutHints.js +1 -1
  39. package/dist/Slider.js +2 -2
  40. package/dist/SlidingColumn.js +1 -1
  41. package/dist/SplitButton.js +1 -1
  42. package/dist/Surface.js +1 -1
  43. package/dist/Switch.js +1 -1
  44. package/dist/TextMenu.js +2 -2
  45. package/dist/Toasts.js +2 -2
  46. package/dist/Tooltip.d.ts +1 -1
  47. package/dist/Tooltip.js +1 -1
  48. package/dist/UIProvider.d.ts +1 -1
  49. package/dist/Waveform.js +1 -1
  50. package/dist/Wipe.js +2 -2
  51. package/dist/combobox/parts.d.ts +3 -3
  52. package/dist/combobox/parts.js +53 -32
  53. package/dist/command-palette/parts.d.ts +1 -1
  54. package/dist/command-palette/parts.js +22 -7
  55. package/dist/command-palette/virtual.js +4 -4
  56. package/dist/dialog/parts.d.ts +1 -1
  57. package/dist/dialog/parts.js +2 -2
  58. package/dist/file-tree/parts.d.ts +2 -2
  59. package/dist/file-tree/parts.js +3 -3
  60. package/dist/file-tree/use-file-tree-shift.d.ts +1 -1
  61. package/dist/file-tree/use-file-tree-shift.js +1 -1
  62. package/dist/index.d.ts +2 -0
  63. package/dist/index.js +2 -0
  64. package/dist/key-value-list/parts.js +1 -1
  65. package/dist/menu/parts.d.ts +3 -3
  66. package/dist/menu/parts.js +46 -30
  67. package/dist/popover/parts.d.ts +3 -3
  68. package/dist/popover/parts.js +110 -71
  69. package/dist/project-switcher-row.js +1 -1
  70. package/dist/scrollbar-geometry.d.ts +15 -0
  71. package/dist/scrollbar-geometry.js +48 -0
  72. package/dist/settings/MasterDetail.js +40 -30
  73. package/dist/settings/SettingsDialog.d.ts +1 -1
  74. package/dist/settings/SettingsDialog.js +27 -11
  75. package/dist/settings/SettingsRow.d.ts +1 -1
  76. package/dist/settings/SettingsRow.js +1 -1
  77. package/dist/settings/SettingsSection.d.ts +1 -1
  78. package/dist/tabs/parts.d.ts +1 -1
  79. package/dist/tabs/parts.js +3 -3
  80. package/dist/testing/dedupe.js +1 -1
  81. package/dist/theme.css +91 -1
  82. package/dist/tree/parts.js +2 -2
  83. package/dist/useOverlayScrollbars.d.ts +7 -0
  84. package/dist/useOverlayScrollbars.js +281 -0
  85. package/dist/workspace/PaneBar.js +2 -2
  86. package/dist/workspace/SplitView.d.ts +1 -1
  87. package/dist/workspace/SplitView.js +4 -4
  88. package/dist/workspace/TabStrip.d.ts +1 -1
  89. package/dist/workspace/TabStrip.js +2 -2
  90. package/dist/workspace/Workspace.d.ts +1 -1
  91. package/dist/workspace/Workspace.js +2 -2
  92. package/dist/workspace/WorkspaceRail.js +1 -1
  93. package/dist/workspace/WorkspaceTopRow.js +1 -1
  94. package/package.json +1 -1
  95. package/src/AccentSwatches.tsx +1 -1
  96. package/src/Banner.tsx +1 -1
  97. package/src/Button.tsx +1 -1
  98. package/src/ButtonGroup.tsx +1 -1
  99. package/src/Checkbox.tsx +2 -2
  100. package/src/ChoiceCards.tsx +3 -3
  101. package/src/CloseButton.tsx +2 -2
  102. package/src/ColorSwatch.tsx +2 -2
  103. package/src/ColorWheelPicker.tsx +1 -1
  104. package/src/ColumnResizeHandle.tsx +1 -1
  105. package/src/CopyButton.tsx +1 -1
  106. package/src/DockShell.tsx +1 -1
  107. package/src/EmptyState.tsx +2 -2
  108. package/src/ErrorBoundary.tsx +4 -3
  109. package/src/Field.tsx +2 -2
  110. package/src/FileIcon.tsx +1 -1
  111. package/src/Icon.tsx +2 -2
  112. package/src/IconButton.tsx +3 -3
  113. package/src/IconPicker.tsx +7 -6
  114. package/src/Input.tsx +2 -2
  115. package/src/Kbd.tsx +1 -1
  116. package/src/ListRow.tsx +1 -1
  117. package/src/Meter.tsx +1 -1
  118. package/src/MiddleTruncate.tsx +2 -2
  119. package/src/PanelEmpty.tsx +2 -2
  120. package/src/PanelHeader.tsx +2 -2
  121. package/src/Pill.tsx +1 -1
  122. package/src/ProjectSwitcher.tsx +2 -2
  123. package/src/PromptDialog.tsx +1 -1
  124. package/src/RelativeTime.tsx +1 -1
  125. package/src/ScrollArea.tsx +47 -0
  126. package/src/SectionLabel.tsx +1 -1
  127. package/src/SegmentBar.tsx +1 -1
  128. package/src/Segmented.tsx +1 -1
  129. package/src/Select.tsx +6 -2
  130. package/src/SelectSearch.tsx +19 -12
  131. package/src/Separator.tsx +1 -1
  132. package/src/ShortcutHints.tsx +1 -1
  133. package/src/Skeleton.tsx +1 -1
  134. package/src/Slider.tsx +2 -2
  135. package/src/SlidingColumn.tsx +1 -1
  136. package/src/Spinner.tsx +1 -1
  137. package/src/SplitButton.tsx +1 -1
  138. package/src/Stepper.tsx +1 -1
  139. package/src/Surface.tsx +1 -1
  140. package/src/Switch.tsx +2 -2
  141. package/src/TextMenu.tsx +2 -2
  142. package/src/Tile.tsx +1 -1
  143. package/src/Toasts.tsx +2 -2
  144. package/src/Tooltip.tsx +2 -2
  145. package/src/UIProvider.tsx +1 -1
  146. package/src/Waveform.tsx +1 -1
  147. package/src/Wipe.tsx +2 -2
  148. package/src/ZoomControls.tsx +1 -1
  149. package/src/combobox/parts.tsx +9 -4
  150. package/src/command-palette/parts.tsx +25 -19
  151. package/src/command-palette/virtual.tsx +4 -4
  152. package/src/dialog/parts.tsx +2 -2
  153. package/src/file-tree/parts.tsx +6 -6
  154. package/src/file-tree/use-empty-lines.ts +1 -1
  155. package/src/file-tree/use-file-tree-shift.tsx +2 -2
  156. package/src/file-tree/use-slots.ts +2 -2
  157. package/src/index.ts +2 -0
  158. package/src/key-value-list/parts.tsx +1 -1
  159. package/src/menu/parts.tsx +11 -6
  160. package/src/popover/parts.tsx +13 -5
  161. package/src/project-switcher-row.tsx +2 -2
  162. package/src/scrollbar-geometry.ts +67 -0
  163. package/src/select-shared.ts +1 -1
  164. package/src/settings/DetailHeader.tsx +1 -1
  165. package/src/settings/MasterDetail.tsx +9 -6
  166. package/src/settings/SettingsDialog.tsx +16 -9
  167. package/src/settings/SettingsRow.tsx +1 -1
  168. package/src/settings/SettingsSection.tsx +1 -1
  169. package/src/tabs/parts.tsx +4 -4
  170. package/src/testing/dedupe.ts +1 -1
  171. package/src/theme.css +91 -1
  172. package/src/tree/parts.tsx +2 -2
  173. package/src/useOverlayScrollbars.tsx +263 -0
  174. package/src/workspace/DropPreview.tsx +1 -1
  175. package/src/workspace/PaneBar.tsx +2 -2
  176. package/src/workspace/SplitView.tsx +5 -5
  177. package/src/workspace/TabStrip.tsx +3 -3
  178. package/src/workspace/Workspace.tsx +2 -2
  179. package/src/workspace/WorkspaceRail.tsx +1 -1
  180. package/src/workspace/WorkspaceTopRow.tsx +1 -1
@@ -1,16 +1,17 @@
1
- import { useContext, useLayoutEffect, useRef, useState, type ComponentProps, type ReactNode } from 'react';
2
- import { flushSync } from 'react-dom';
1
+ import { Autocomplete } from '@base-ui/react/autocomplete';
2
+ import { useRender } from '@base-ui/react/use-render';
3
3
  import clsx from 'clsx';
4
4
  import { Search } from 'lucide-react';
5
+ import { useContext, useLayoutEffect, useRef, useState, type ComponentProps, type ReactNode } from 'react';
6
+ import { flushSync } from 'react-dom';
5
7
  import { useTranslation } from 'react-i18next';
6
- import { Autocomplete } from '@base-ui/react/autocomplete';
7
- import { useRender } from '@base-ui/react/use-render';
8
8
  import { withClass } from '../class-name.ts';
9
9
  import { DialogPopup, DialogRoot, type DialogPopupProps } from '../dialog/parts.tsx';
10
10
  import { Icon } from '../Icon.tsx';
11
11
  import { Kbd } from '../Kbd.tsx';
12
12
  import { mergeRefs } from '../merge-refs.ts';
13
13
  import { KEY_SHORTCUTS } from '../shortcut.ts';
14
+ import { useOverlayScrollbars } from '../useOverlayScrollbars.tsx';
14
15
  import { PaletteRefsContext } from './context.ts';
15
16
 
16
17
  const NO_ITEMS: readonly never[] = [];
@@ -188,25 +189,30 @@ export function CommandPaletteInput({ leading, trailing, escapeHint = true, mono
188
189
  export function CommandPaletteList({ className, ref, onFocus, ...props }: ComponentProps<typeof Autocomplete.List>) {
189
190
  const { t } = useTranslation('ui');
190
191
  const refs = useContext(PaletteRefsContext);
192
+ const [list, setList] = useState<HTMLDivElement | null>(null);
193
+ const scrollbars = useOverlayScrollbars(list);
191
194
 
192
195
  return (
193
- <Autocomplete.List
194
- ref={mergeRefs(ref, refs?.list)}
195
- aria-label={t('palette.results')}
196
- className={withClass('max-h-[50vh] min-h-0 overflow-y-auto overscroll-contain scroll-py-1.5 not-empty:p-1.5', className)}
197
- {...props}
198
- onFocus={(event) => {
199
- onFocus?.(event);
200
- const row = event.target.closest('[role="option"]');
196
+ <div className="relative flex min-h-0 flex-col">
197
+ <Autocomplete.List
198
+ ref={mergeRefs(ref, refs?.list, setList)}
199
+ aria-label={t('palette.results')}
200
+ className={withClass('max-h-[50vh] min-h-0 overflow-y-auto overscroll-contain scroll-py-1.5 not-empty:p-1.5', className)}
201
+ {...props}
202
+ onFocus={(event) => {
203
+ onFocus?.(event);
204
+ const row = event.target.closest('[role="option"]');
201
205
 
202
- if (row === null || !event.currentTarget.contains(row) || refs?.input.current == null) {
203
- return;
204
- }
206
+ if (row === null || !event.currentTarget.contains(row) || refs?.input.current == null) {
207
+ return;
208
+ }
205
209
 
206
- refs.highlight(row);
207
- refs.input.current.focus({ preventScroll: true });
208
- }}
209
- />
210
+ refs.highlight(row);
211
+ refs.input.current.focus({ preventScroll: true });
212
+ }}
213
+ />
214
+ {scrollbars}
215
+ </div>
210
216
  );
211
217
  }
212
218
 
@@ -1,10 +1,10 @@
1
- import { Fragment, useLayoutEffect, useMemo, useRef, type ComponentProps, type ReactNode } from 'react';
2
- import { useVirtualizer } from '@tanstack/react-virtual';
3
1
  import { Autocomplete } from '@base-ui/react/autocomplete';
4
- import { CommandPaletteList } from './parts.tsx';
5
- import { useCommandPaletteVirtualScroll } from './context.ts';
2
+ import { useVirtualizer } from '@tanstack/react-virtual';
3
+ import { Fragment, useLayoutEffect, useMemo, useRef, type ComponentProps, type ReactNode } from 'react';
6
4
  import { MENU_LABEL } from '../menu/parts.tsx';
7
5
  import { mergeRefs } from '../merge-refs.ts';
6
+ import { useCommandPaletteVirtualScroll } from './context.ts';
7
+ import { CommandPaletteList } from './parts.tsx';
8
8
 
9
9
  export interface CommandPaletteVirtualItemProps {
10
10
  index: number;
@@ -1,7 +1,7 @@
1
- import { createContext, useContext, useState, type ComponentProps } from 'react';
2
- import clsx from 'clsx';
3
1
  import { Dialog as BaseDialog } from '@base-ui/react/dialog';
4
2
  import { useRender } from '@base-ui/react/use-render';
3
+ import clsx from 'clsx';
4
+ import { createContext, useContext, useState, type ComponentProps } from 'react';
5
5
  import { withClass } from '../class-name.ts';
6
6
  import { useDialogLayer } from '../dialog-layer.ts';
7
7
 
@@ -1,15 +1,15 @@
1
- import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode } from 'react';
2
- import { createPortal } from 'react-dom';
3
1
  import { useRender } from '@base-ui/react/use-render';
4
- import { FileTree as PierreFileTree, type FileTreeProps as PierreProps } from '@pierre/trees/react';
5
2
  import type { FileTree, FileTreeVisibleRow } from '@pierre/trees';
6
- import { useTranslation } from 'react-i18next';
7
- import type { LucideIcon } from 'lucide-react';
3
+ import { FileTree as PierreFileTree, type FileTreeProps as PierreProps } from '@pierre/trees/react';
8
4
  import clsx from 'clsx';
5
+ import type { LucideIcon } from 'lucide-react';
6
+ import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode } from 'react';
7
+ import { createPortal } from 'react-dom';
8
+ import { useTranslation } from 'react-i18next';
9
9
  import { Icon } from '../Icon.tsx';
10
10
  import { mergeRefs } from '../merge-refs.ts';
11
- import { directoryHandle, extendsSelection, followFocus, movesFocus, menuTargetsOf, rowPathOf, visibleRows } from './helpers.ts';
12
11
  import type { FileTreeEmptyLabel } from './empty-lines.ts';
12
+ import { directoryHandle, extendsSelection, followFocus, movesFocus, menuTargetsOf, rowPathOf, visibleRows } from './helpers.ts';
13
13
  import { useEmptyLines } from './use-empty-lines.ts';
14
14
  import { useFileTreeShift } from './use-file-tree-shift.tsx';
15
15
  import { useFileTreeSlots, type FileTreeDecorationOrder } from './use-slots.ts';
@@ -1,5 +1,5 @@
1
- import { useEffect, useLayoutEffect, useRef } from 'react';
2
1
  import type { FileTree } from '@pierre/trees';
2
+ import { useEffect, useLayoutEffect, useRef } from 'react';
3
3
  import { emptyLineChanges, isEmptyLine, pastEmptyLine, skipEmptyLine, type FileTreeEmptyLabel } from './empty-lines.ts';
4
4
  import { movesFocus, visibleRows } from './helpers.ts';
5
5
 
@@ -1,7 +1,7 @@
1
- import { type ReactElement } from 'react';
2
1
  import type { FileTree } from '@pierre/trees';
3
- import { useTreeShift, type TreeShiftSource } from '../tree/use-tree-shift.tsx';
2
+ import { type ReactElement } from 'react';
4
3
  import { shiftNeed } from '../tree/shift.ts';
4
+ import { useTreeShift, type TreeShiftSource } from '../tree/use-tree-shift.tsx';
5
5
 
6
6
  const ROWS = '[data-type="item"]:not([data-item-parked="true"])';
7
7
 
@@ -1,7 +1,7 @@
1
- import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
2
- import { flushSync } from 'react-dom';
3
1
  import type { FileTree, FileTreeVisibleRow } from '@pierre/trees';
4
2
  import type { LucideIcon } from 'lucide-react';
3
+ import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
4
+ import { flushSync } from 'react-dom';
5
5
  import { directoryOfEmptyLine, isEmptyLine, type FileTreeEmptyLabel } from './empty-lines.ts';
6
6
  import { pathOfRow, visibleRows, visibleRowsAround } from './helpers.ts';
7
7
  import { matchHighlight, rangesOfMatches } from './highlight.ts';
package/src/index.ts CHANGED
@@ -66,6 +66,7 @@ export { Pill, type PillProps } from './Pill.tsx';
66
66
  export { PromptDialog, type PromptDialogProps } from './PromptDialog.tsx';
67
67
  export { RelativeTime, type RelativeTimeProps } from './RelativeTime.tsx';
68
68
  export { ProjectSwitcher, ProjectSwitcherGroups, type ProjectSwitcherItem, type ProjectSwitcherProps } from './ProjectSwitcher.tsx';
69
+ export { ScrollArea, type ScrollAreaProps } from './ScrollArea.tsx';
69
70
  export { SectionLabel, type SectionLabelProps } from './SectionLabel.tsx';
70
71
  export { SegmentBar, type SegmentBarPart, type SegmentBarProps } from './SegmentBar.tsx';
71
72
  export { Segmented, type SegmentedOption, type SegmentedProps } from './Segmented.tsx';
@@ -124,6 +125,7 @@ export { clampColumnSize, useColumnResize, type ColumnEdge, type ColumnResize, t
124
125
  export { useContentSize, type ContentSize } from './useContentSize.ts';
125
126
  export { useMeasuredWidth } from './useMeasuredWidth.ts';
126
127
  export { useNow, useTickingText } from './useNow.ts';
128
+ export { useOverlayScrollbars, type OverlayScrollbarsOptions, type ScrollAreaAxis } from './useOverlayScrollbars.tsx';
127
129
 
128
130
  export { useFileTree, type FileTreeOptions } from './file-tree/use-file-tree.ts';
129
131
  export type { FileTreeRootProps } from './file-tree/parts.tsx';
@@ -1,5 +1,5 @@
1
- import clsx from 'clsx';
2
1
  import { useRender } from '@base-ui/react/use-render';
2
+ import clsx from 'clsx';
3
3
 
4
4
  export type KeyValueListRootProps = useRender.ComponentProps<'dl'> & {
5
5
  /* A hairline between rows, for a long list such as the headers of a message. Without it the rows sit close, for a few facts in a card. */
@@ -1,15 +1,17 @@
1
- import { createContext, useContext, type ComponentProps, type ReactNode, type Ref } from 'react';
2
- import clsx from 'clsx';
3
- import { Check, ChevronRight, type LucideIcon } from 'lucide-react';
4
1
  import { ContextMenu as BaseContextMenu } from '@base-ui/react/context-menu';
5
2
  import { Menu as BaseMenu } from '@base-ui/react/menu';
6
3
  import { Separator as BaseSeparator } from '@base-ui/react/separator';
7
4
  import { useRender } from '@base-ui/react/use-render';
5
+ import clsx from 'clsx';
6
+ import { Check, ChevronRight, type LucideIcon } from 'lucide-react';
7
+ import { createContext, useContext, useState, type ComponentProps, type ReactNode, type Ref } from 'react';
8
8
  import { withClass } from '../class-name.ts';
9
9
  import { Icon } from '../Icon.tsx';
10
- import { Tooltip } from '../Tooltip.tsx';
10
+ import { mergeRefs } from '../merge-refs.ts';
11
11
  import { splitPlacement, type PlacementKey } from '../placement.ts';
12
12
  import { renderPositioner } from '../popup-layer.ts';
13
+ import { Tooltip } from '../Tooltip.tsx';
14
+ import { useOverlayScrollbars } from '../useOverlayScrollbars.tsx';
13
15
 
14
16
  /* Where a popup opens from decides where it goes when a call site says nothing. */
15
17
  type MenuKind = 'menu' | 'context' | 'submenu';
@@ -66,9 +68,11 @@ export const ContextMenuTrigger = BaseContextMenu.Trigger;
66
68
 
67
69
  export type MenuPopupProps = ComponentProps<typeof BaseMenu.Popup> & Placement;
68
70
 
69
- export function MenuPopup({ className, ...props }: MenuPopupProps) {
71
+ export function MenuPopup({ className, ref, ...props }: MenuPopupProps) {
70
72
  const defaults = PLACEMENT[useContext(MenuKindContext)];
71
73
  const [placement, popupProps] = splitPlacement(props);
74
+ const [popup, setPopup] = useState<HTMLDivElement | null>(null);
75
+ const scrollbars = useOverlayScrollbars(popup);
72
76
 
73
77
  return (
74
78
  <BaseMenu.Portal>
@@ -81,7 +85,8 @@ export function MenuPopup({ className, ...props }: MenuPopupProps) {
81
85
  sideOffset={placement.sideOffset ?? defaults.sideOffset}
82
86
  alignOffset={placement.alignOffset ?? defaults.alignOffset}
83
87
  >
84
- <BaseMenu.Popup className={withClass('menu-popup', className)} {...popupProps} />
88
+ <BaseMenu.Popup ref={mergeRefs(setPopup, ref)} className={withClass('menu-popup', className)} {...popupProps} />
89
+ {scrollbars}
85
90
  </BaseMenu.Positioner>
86
91
  </BaseMenu.Portal>
87
92
  );
@@ -1,9 +1,11 @@
1
- import type { ComponentProps } from 'react';
2
1
  import { Popover as BasePopover } from '@base-ui/react/popover';
3
2
  import { PreviewCard as BasePreviewCard } from '@base-ui/react/preview-card';
3
+ import { useState, type ComponentProps } from 'react';
4
4
  import { withClass } from '../class-name.ts';
5
+ import { mergeRefs } from '../merge-refs.ts';
5
6
  import { splitPlacement, type PlacementKey } from '../placement.ts';
6
7
  import { renderPositioner } from '../popup-layer.ts';
8
+ import { useOverlayScrollbars } from '../useOverlayScrollbars.tsx';
7
9
 
8
10
  /* `picker` is a card whose content runs to its edges, `plain` leaves the surface to the caller. */
9
11
  const POPUP_VARIANT = {
@@ -31,13 +33,16 @@ export type PopoverPopupProps = ComponentProps<typeof BasePopover.Popup> &
31
33
  variant?: PopupVariant;
32
34
  };
33
35
 
34
- export function PopoverPopup({ variant = 'menu', side = 'bottom', align = 'start', sideOffset = 8, className, ...props }: PopoverPopupProps) {
36
+ export function PopoverPopup({ variant = 'menu', side = 'bottom', align = 'start', sideOffset = 8, className, ref, ...props }: PopoverPopupProps) {
35
37
  const [placement, popupProps] = splitPlacement(props);
38
+ const [popup, setPopup] = useState<HTMLDivElement | null>(null);
39
+ const scrollbars = useOverlayScrollbars(popup);
36
40
 
37
41
  return (
38
42
  <BasePopover.Portal>
39
43
  <BasePopover.Positioner className="popup-positioner" render={renderPositioner} {...placement} side={side} align={align} sideOffset={sideOffset}>
40
- <BasePopover.Popup className={withClass(POPUP_VARIANT[variant], className)} {...popupProps} />
44
+ <BasePopover.Popup ref={mergeRefs(setPopup, ref)} className={withClass(POPUP_VARIANT[variant], className)} {...popupProps} />
45
+ {scrollbars}
41
46
  </BasePopover.Positioner>
42
47
  </BasePopover.Portal>
43
48
  );
@@ -53,13 +58,16 @@ export type PreviewCardPopupProps = ComponentProps<typeof BasePreviewCard.Popup>
53
58
  };
54
59
 
55
60
  /* A card that opens while the pointer rests on its trigger, drawn like a popover. */
56
- export function PreviewCardPopup({ variant = 'menu', side = 'bottom', align = 'start', sideOffset = 8, className, ...props }: PreviewCardPopupProps) {
61
+ export function PreviewCardPopup({ variant = 'menu', side = 'bottom', align = 'start', sideOffset = 8, className, ref, ...props }: PreviewCardPopupProps) {
57
62
  const [placement, popupProps] = splitPlacement(props);
63
+ const [popup, setPopup] = useState<HTMLDivElement | null>(null);
64
+ const scrollbars = useOverlayScrollbars(popup);
58
65
 
59
66
  return (
60
67
  <BasePreviewCard.Portal>
61
68
  <BasePreviewCard.Positioner className="z-(--z-popup)" {...placement} side={side} align={align} sideOffset={sideOffset}>
62
- <BasePreviewCard.Popup className={withClass(POPUP_VARIANT[variant], className)} {...popupProps} />
69
+ <BasePreviewCard.Popup ref={mergeRefs(setPopup, ref)} className={withClass(POPUP_VARIANT[variant], className)} {...popupProps} />
70
+ {scrollbars}
63
71
  </BasePreviewCard.Positioner>
64
72
  </BasePreviewCard.Portal>
65
73
  );
@@ -1,7 +1,7 @@
1
- import type { MouseEvent } from 'react';
2
1
  import clsx from 'clsx';
3
- import { useTranslation } from 'react-i18next';
4
2
  import { MoreHorizontal } from 'lucide-react';
3
+ import type { MouseEvent } from 'react';
4
+ import { useTranslation } from 'react-i18next';
5
5
  import * as Menu from './menu/index.parts.ts';
6
6
  import type { ProjectSwitcherItem } from './ProjectSwitcher.tsx';
7
7
  import { Tooltip } from './Tooltip.tsx';
@@ -0,0 +1,67 @@
1
+ /* A thumb never gets shorter than this, so a long list keeps one a pointer can grab. */
2
+ export const MIN_THUMB = 24;
3
+
4
+ /* A press on the track moves this share of the visible length, so a line of the old view stays in sight. */
5
+ export const PAGE_SHARE = 0.9;
6
+
7
+ export interface ScrollMetrics {
8
+ /* The length of the bar the thumb moves along, in pixels. */
9
+ readonly track: number;
10
+
11
+ /* The visible length of the scroller (`clientHeight` or `clientWidth`). */
12
+ readonly view: number;
13
+
14
+ /* The full length of what scrolls (`scrollHeight` or `scrollWidth`). */
15
+ readonly content: number;
16
+ }
17
+
18
+ export interface Thumb {
19
+ readonly size: number;
20
+ readonly offset: number;
21
+ }
22
+
23
+ /* Whether an axis has anything to scroll; a pixel of rounding is not worth a bar. */
24
+ export function overflows(view: number, content: number): boolean {
25
+ return content - view > 1;
26
+ }
27
+
28
+ /* The thumb's length and its distance from the start of the track, in whole pixels. */
29
+ export function thumbOf({ track, view, content }: ScrollMetrics, position: number): Thumb {
30
+ const range = content - view;
31
+
32
+ if (range <= 0 || track <= 0) {
33
+ return { size: Math.max(0, Math.round(track)), offset: 0 };
34
+ }
35
+
36
+ const size = Math.min(Math.round(track), Math.max(MIN_THUMB, Math.round((track * view) / content)));
37
+ const progress = Math.min(1, Math.max(0, position / range));
38
+
39
+ return { size, offset: Math.round((track - size) * progress) };
40
+ }
41
+
42
+ /* The scroll position after the thumb moved `moved` pixels from where a drag started. */
43
+ export function scrollAfterDrag(metrics: ScrollMetrics, start: number, moved: number): number {
44
+ const range = metrics.content - metrics.view;
45
+ const free = metrics.track - thumbOf(metrics, start).size;
46
+
47
+ if (range <= 0 || free <= 0) {
48
+ return start;
49
+ }
50
+
51
+ return Math.min(range, Math.max(0, start + (moved * range) / free));
52
+ }
53
+
54
+ /* How far a press on the track at `pointer` scrolls: a page toward the pointer, nothing on the thumb itself. */
55
+ export function pageToward(pointer: number, thumbStart: number, thumbEnd: number, view: number): number {
56
+ const page = Math.round(view * PAGE_SHARE);
57
+
58
+ if (pointer < thumbStart) {
59
+ return -page;
60
+ }
61
+
62
+ if (pointer > thumbEnd) {
63
+ return page;
64
+ }
65
+
66
+ return 0;
67
+ }
@@ -1,5 +1,5 @@
1
- import type { ReactNode } from 'react';
2
1
  import clsx from 'clsx';
2
+ import type { ReactNode } from 'react';
3
3
 
4
4
  export interface SelectItem<T extends string> {
5
5
  value: T;
@@ -1,5 +1,5 @@
1
- import type { ReactNode, Ref } from 'react';
2
1
  import clsx from 'clsx';
2
+ import type { ReactNode, Ref } from 'react';
3
3
 
4
4
  export interface DetailHeaderProps {
5
5
  mark: ReactNode;
@@ -1,5 +1,6 @@
1
- import type { ReactNode, Ref } from 'react';
2
1
  import clsx from 'clsx';
2
+ import type { ReactNode, Ref } from 'react';
3
+ import { ScrollArea } from '../ScrollArea.tsx';
3
4
 
4
5
  const LIST_WIDTHS = { 280: 'w-70', 320: 'w-80', 340: 'w-85' } as const;
5
6
 
@@ -18,18 +19,20 @@ export interface MasterDetailProps {
18
19
  export function MasterDetail({ list, listWidth, listLabel, detail, className, ref }: MasterDetailProps) {
19
20
  return (
20
21
  <div ref={ref} className={clsx('flex min-h-0 min-w-0 grow border-t border-border max-[640px]:flex-col', className)}>
21
- <nav
22
+ <ScrollArea
23
+ render={<nav />}
22
24
  aria-label={listLabel}
23
25
  className={clsx(
24
- 'flex shrink-0 flex-col gap-0.5 overflow-y-auto border-r border-border p-2 max-[640px]:max-h-60 max-[640px]:w-auto max-[640px]:border-r-0 max-[640px]:border-b',
26
+ 'shrink-0 border-r border-border max-[640px]:max-h-60 max-[640px]:w-auto max-[640px]:border-r-0 max-[640px]:border-b',
25
27
  LIST_WIDTHS[listWidth]
26
28
  )}
29
+ viewportClassName="flex flex-col gap-0.5 p-2"
27
30
  >
28
31
  {list}
29
- </nav>
30
- <div className="min-h-0 min-w-0 grow overflow-y-auto">
32
+ </ScrollArea>
33
+ <ScrollArea className="grow">
31
34
  <div className="flex min-w-0 flex-col gap-7 px-8 pt-6 pb-10 max-[960px]:px-4">{detail}</div>
32
- </div>
35
+ </ScrollArea>
33
36
  </div>
34
37
  );
35
38
  }
@@ -1,18 +1,24 @@
1
- import { useEffect, useMemo, useRef, useState, type ComponentType, type ReactNode, type Ref } from 'react';
2
- import clsx from 'clsx';
3
- import { useTranslation } from 'react-i18next';
4
1
  import { Tabs } from '@base-ui/react/tabs';
2
+ import clsx from 'clsx';
5
3
  import { Search, X, type LucideIcon } from 'lucide-react';
4
+ import { useEffect, useMemo, useRef, useState, type ComponentProps, type ComponentType, type ReactNode, type Ref } from 'react';
5
+ import { useTranslation } from 'react-i18next';
6
6
  import { CloseButton } from '../CloseButton.tsx';
7
7
  import { DialogDescription, DialogPopup, DialogRoot, DialogTitle } from '../dialog/parts.tsx';
8
8
  import { ErrorBoundary } from '../ErrorBoundary.tsx';
9
9
  import { Icon } from '../Icon.tsx';
10
10
  import { IconButton } from '../IconButton.tsx';
11
- import { LazyBoundary } from '../LazyBoundary.tsx';
12
11
  import { Input } from '../Input.tsx';
12
+ import { LazyBoundary } from '../LazyBoundary.tsx';
13
+ import { ScrollArea } from '../ScrollArea.tsx';
13
14
  import { Select } from '../Select.tsx';
14
15
  import { SettingsTargetContext } from './target.ts';
15
16
 
17
+ /* A pane that is not split scrolls as one column: the panel's own props land on the viewport, which stays the tab panel. */
18
+ function renderScrollingPanel({ ref, className, ...props }: ComponentProps<'div'>) {
19
+ return <ScrollArea ref={ref} className="min-h-0 min-w-0 grow" viewportClassName={className} {...props} />;
20
+ }
21
+
16
22
  export interface SettingsSectionEntry {
17
23
  id: string;
18
24
  icon: LucideIcon;
@@ -125,7 +131,7 @@ function SettingsNav({ groups, footer, account }: Pick<SettingsDialogProps, 'gro
125
131
 
126
132
  return (
127
133
  <Tabs.List aria-label={t('settings.sections')} activateOnFocus className="flex min-h-0 grow flex-col">
128
- <div ref={list} className="scroll-fade-y flex min-h-0 flex-col gap-3.5 overflow-y-auto">
134
+ <ScrollArea viewportRef={list} viewportClassName="scroll-fade-y flex flex-col gap-3.5">
129
135
  {groups.map((group) => (
130
136
  <div key={group.sections[0]!.id} className="flex flex-col gap-0.5">
131
137
  {group.label !== null && <span className="px-2.5 pt-2.5 pb-1 text-xs font-medium text-text-faint">{group.label}</span>}
@@ -134,7 +140,7 @@ function SettingsNav({ groups, footer, account }: Pick<SettingsDialogProps, 'gro
134
140
  ))}
135
141
  </div>
136
142
  ))}
137
- </div>
143
+ </ScrollArea>
138
144
  {(footer?.length || account) && (
139
145
  <div className="mt-auto flex flex-col gap-3 pt-3.5">
140
146
  {footer?.map((section) => (
@@ -232,7 +238,7 @@ function SearchResults({ search, query, labelOf, onPick }: SearchResultsProps) {
232
238
  }
233
239
 
234
240
  return (
235
- <ul className="flex min-h-0 flex-col gap-0.5 overflow-y-auto" aria-label={t('settings.search.results')}>
241
+ <ScrollArea render={<ul />} viewportClassName="flex flex-col gap-0.5" aria-label={t('settings.search.results')}>
236
242
  {results.map((result) => (
237
243
  <li key={`${result.section}:${result.id ?? ''}`}>
238
244
  <button
@@ -245,7 +251,7 @@ function SearchResults({ search, query, labelOf, onPick }: SearchResultsProps) {
245
251
  </button>
246
252
  </li>
247
253
  ))}
248
- </ul>
254
+ </ScrollArea>
249
255
  );
250
256
  }
251
257
 
@@ -354,10 +360,11 @@ export function SettingsDialog({
354
360
  className={clsx(
355
361
  'flex min-h-0 min-w-0 grow flex-col outline-none',
356
362
  // A split pane scrolls each of its sides itself.
357
- !entry.split && 'gap-7 overflow-y-auto px-8 pb-10 max-[960px]:px-4',
363
+ !entry.split && 'gap-7 px-8 pb-10 max-[960px]:px-4',
358
364
  // Under a hero the bar's fill marks the edge; a fade would wipe the hero out behind the clear bar.
359
365
  !entry.split && !Hero && 'scroll-fade-top pt-1'
360
366
  )}
367
+ render={entry.split ? undefined : renderScrollingPanel}
361
368
  onScroll={(event) => {
362
369
  const top = event.currentTarget.scrollTop;
363
370
  event.currentTarget.toggleAttribute('data-fade-start', top > 0);
@@ -1,6 +1,6 @@
1
- import { useEffect, useRef, type ReactNode, type Ref } from 'react';
2
1
  import clsx from 'clsx';
3
2
  import type { LucideIcon } from 'lucide-react';
3
+ import { useEffect, useRef, type ReactNode, type Ref } from 'react';
4
4
  import { Icon } from '../Icon.tsx';
5
5
  import { mergeRefs } from '../merge-refs.ts';
6
6
  import { useSettingsTarget } from './target.ts';
@@ -1,6 +1,6 @@
1
- import type { ReactNode, Ref } from 'react';
2
1
  import clsx from 'clsx';
3
2
  import type { LucideIcon } from 'lucide-react';
3
+ import type { ReactNode, Ref } from 'react';
4
4
  import { Icon } from '../Icon.tsx';
5
5
 
6
6
  export interface SettingsSectionProps {
@@ -1,3 +1,7 @@
1
+ import { Tabs as BaseTabs } from '@base-ui/react/tabs';
2
+ import { useRender } from '@base-ui/react/use-render';
3
+ import clsx from 'clsx';
4
+ import { ChevronDown, X } from 'lucide-react';
1
5
  import {
2
6
  Children,
3
7
  createContext,
@@ -11,11 +15,7 @@ import {
11
15
  type ReactElement,
12
16
  type ReactNode
13
17
  } from 'react';
14
- import clsx from 'clsx';
15
- import { ChevronDown, X } from 'lucide-react';
16
18
  import { useTranslation } from 'react-i18next';
17
- import { Tabs as BaseTabs } from '@base-ui/react/tabs';
18
- import { useRender } from '@base-ui/react/use-render';
19
19
  import { withClass } from '../class-name.ts';
20
20
  import { formatNumber } from '../format/number.ts';
21
21
  import { Icon } from '../Icon.tsx';
@@ -1,6 +1,6 @@
1
+ import { Glob, plugin } from 'bun';
1
2
  import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
2
3
  import { dirname, join, sep } from 'node:path';
3
- import { Glob, plugin } from 'bun';
4
4
 
5
5
  /*
6
6
  * What `resolve.dedupe` does for Vite, for `bun test` in an app that links a checkout: a second React,
package/src/theme.css CHANGED
@@ -11,6 +11,11 @@
11
11
  --surface-raised: #ffffff;
12
12
  --surface-sunken: #ececef;
13
13
  --track: rgb(0 0 0 / 0.09);
14
+ /* The overlay scrollbars (`ScrollArea`): alphas, so a bar takes the color of whatever it floats over. */
15
+ --scrollbar-thumb: rgb(0 0 0 / 0.22);
16
+ --scrollbar-thumb-hover: rgb(0 0 0 / 0.38);
17
+ --scrollbar-thumb-active: rgb(0 0 0 / 0.5);
18
+ --scrollbar-track: rgb(0 0 0 / 0.04);
14
19
  /* A bar that is not the one in focus: between the sunken page behind it and a focused bar, so it
15
20
  still reads as a bar. The toolbar of a cell in the grid that does not have the keyboard. */
16
21
  --surface-idle: color-mix(in srgb, var(--surface) 50%, var(--surface-sunken));
@@ -122,6 +127,10 @@
122
127
  --surface-raised: #18181c;
123
128
  --surface-sunken: #0b0b0e;
124
129
  --track: rgb(255 255 255 / 0.11);
130
+ --scrollbar-thumb: rgb(255 255 255 / 0.22);
131
+ --scrollbar-thumb-hover: rgb(255 255 255 / 0.38);
132
+ --scrollbar-thumb-active: rgb(255 255 255 / 0.5);
133
+ --scrollbar-track: rgb(255 255 255 / 0.04);
125
134
  --surface-hover: #202024;
126
135
  --surface-active: #28282e;
127
136
  /* Derived, so an app that tints its surfaces tints its fields along. */
@@ -811,7 +820,7 @@
811
820
  position: absolute;
812
821
  inset-block: 0;
813
822
  border-radius: 999px;
814
- background: var(--text-faint);
823
+ background: var(--scrollbar-thumb);
815
824
  }
816
825
  @media (prefers-reduced-motion: reduce) {
817
826
  .adecore-tree-shift-track {
@@ -1083,3 +1092,84 @@
1083
1092
  /* The cut part stays in the text at no width, so a selection copies it and a find matches across the cut. */
1084
1093
  .middle-truncate-middle { display: inline-block; width: 0; overflow: hidden; vertical-align: top; }
1085
1094
  }
1095
+
1096
+ @layer components {
1097
+ /* `ScrollArea` and `useOverlayScrollbars`. The scroller stays native and hides its own bar; thin bars
1098
+ float over the content, hidden at rest and shown while it scrolls. */
1099
+ [data-overlay-scrollbars] { scrollbar-width: none; }
1100
+ [data-overlay-scrollbars]::-webkit-scrollbar { display: none; }
1101
+ /* A drag that strays over the content must not select it, nor may a long press on a bar. */
1102
+ [data-overlay-scrollbars][data-scrollbar-dragging], .ade-scrollbar { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
1103
+ .ade-scroll-area { position: relative; display: flex; flex-direction: column; min-width: 0; min-height: 0; }
1104
+ /* Its own stacking layer, so a sticky header with a z-index inside never covers the bars. */
1105
+ /* `contain` lets the box bounce at its own end; the page's `overscroll-behavior: none` would take the bounce of a scroll chained up to it. */
1106
+ .ade-scroll-viewport { flex: 1 1 auto; min-height: 0; overflow: auto; isolation: isolate; overscroll-behavior: contain; }
1107
+ .ade-scroll-viewport[data-axis="y"] { overflow-x: hidden; }
1108
+ .ade-scroll-viewport[data-axis="x"] { overflow-y: hidden; }
1109
+ .ade-scrollbar {
1110
+ --scrollbar-size: 12px;
1111
+ position: absolute;
1112
+ z-index: 1;
1113
+ display: none;
1114
+ opacity: 0;
1115
+ pointer-events: none;
1116
+ transition: opacity 240ms ease;
1117
+ }
1118
+ .ade-scrollbar[data-overflow] { display: block; }
1119
+ /* A popup's bars lie beside it in its positioner; the ends keep clear of its rounded corners. */
1120
+ :is(.menu-popup, .picker-popup) ~ .ade-scrollbar { --scrollbar-inset: 4px; }
1121
+ .ade-scrollbar[data-visible] { opacity: 1; pointer-events: auto; transition-duration: 120ms; }
1122
+ /* `coveredBottom` lifts the bars of its own area only, never those of a scroller inside it. */
1123
+ .ade-scroll-viewport { --scrollbar-covered-bottom: 0px; }
1124
+ /* The two bars meet without overlapping: each stops a bar's width short of the corner. */
1125
+ .ade-scrollbar[data-orientation="vertical"] { top: var(--scrollbar-inset, 2px); bottom: calc(var(--scrollbar-covered-bottom, 0px) + var(--scrollbar-inset, 2px)); right: 0; width: var(--scrollbar-size); }
1126
+ .ade-scrollbar[data-orientation="vertical"][data-corner] { bottom: calc(var(--scrollbar-covered-bottom, 0px) + var(--scrollbar-size)); }
1127
+ .ade-scrollbar[data-orientation="horizontal"] { left: var(--scrollbar-inset, 2px); right: var(--scrollbar-inset, 2px); bottom: var(--scrollbar-covered-bottom, 0px); height: var(--scrollbar-size); }
1128
+ .ade-scrollbar[data-orientation="horizontal"][data-corner] { right: var(--scrollbar-size); }
1129
+ .ade-scrollbar::before {
1130
+ content: "";
1131
+ position: absolute;
1132
+ border-radius: 999px;
1133
+ background: var(--scrollbar-track);
1134
+ opacity: 0;
1135
+ transition: opacity 120ms ease;
1136
+ }
1137
+ /* The track is as thick as the widened pill, also on touch where the hit area is wider. */
1138
+ .ade-scrollbar[data-orientation="vertical"]::before { top: 0; bottom: 0; right: 2px; width: 8px; }
1139
+ .ade-scrollbar[data-orientation="horizontal"]::before { left: 0; right: 0; bottom: 2px; height: 8px; }
1140
+ /* The thumb spans the whole hit area across, so a press beside the pill still grabs it; the pill is its `::before`. */
1141
+ .ade-scrollbar-thumb { position: absolute; top: 0; left: 0; touch-action: none; }
1142
+ .ade-scrollbar[data-orientation="vertical"] .ade-scrollbar-thumb { right: 0; }
1143
+ .ade-scrollbar[data-orientation="horizontal"] .ade-scrollbar-thumb { bottom: 0; }
1144
+ .ade-scrollbar-thumb::before {
1145
+ content: "";
1146
+ position: absolute;
1147
+ border-radius: 999px;
1148
+ background: var(--scrollbar-thumb);
1149
+ transition: background-color 120ms ease, width 120ms ease, height 120ms ease, right 120ms ease, bottom 120ms ease;
1150
+ }
1151
+ .ade-scrollbar[data-orientation="vertical"] .ade-scrollbar-thumb::before { top: 0; bottom: 0; right: 3px; width: 6px; }
1152
+ .ade-scrollbar[data-orientation="horizontal"] .ade-scrollbar-thumb::before { left: 0; right: 0; bottom: 3px; height: 6px; }
1153
+ .ade-scrollbar[data-dragging]::before { opacity: 1; }
1154
+ .ade-scrollbar[data-dragging] .ade-scrollbar-thumb::before { background: var(--scrollbar-thumb-active); }
1155
+ .ade-scrollbar[data-orientation="vertical"][data-dragging] .ade-scrollbar-thumb::before { right: 2px; width: 8px; }
1156
+ .ade-scrollbar[data-orientation="horizontal"][data-dragging] .ade-scrollbar-thumb::before { bottom: 2px; height: 8px; }
1157
+ @media (hover: hover) {
1158
+ .ade-scrollbar:hover::before { opacity: 1; }
1159
+ .ade-scrollbar:hover:not([data-dragging]) .ade-scrollbar-thumb::before { background: var(--scrollbar-thumb-hover); }
1160
+ .ade-scrollbar[data-orientation="vertical"]:hover .ade-scrollbar-thumb::before { right: 2px; width: 8px; }
1161
+ .ade-scrollbar[data-orientation="horizontal"]:hover .ade-scrollbar-thumb::before { bottom: 2px; height: 8px; }
1162
+ }
1163
+ /* A finger needs a wider target and a thicker pill. Only the thumb takes a touch, so a tap near the
1164
+ edge still reaches the row under the bar. */
1165
+ @media (pointer: coarse) {
1166
+ .ade-scrollbar { --scrollbar-size: 20px; }
1167
+ .ade-scrollbar[data-visible] { pointer-events: none; }
1168
+ .ade-scrollbar[data-visible] .ade-scrollbar-thumb { pointer-events: auto; }
1169
+ .ade-scrollbar[data-orientation="vertical"] .ade-scrollbar-thumb::before { right: 2px; width: 8px; }
1170
+ .ade-scrollbar[data-orientation="horizontal"] .ade-scrollbar-thumb::before { bottom: 2px; height: 8px; }
1171
+ }
1172
+ @media (prefers-reduced-motion: reduce) {
1173
+ .ade-scrollbar, .ade-scrollbar::before, .ade-scrollbar-thumb::before { transition: none; }
1174
+ }
1175
+ }