@djangocfg/ui-core 2.1.556 → 2.1.558

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 (37) hide show
  1. package/README.md +3 -3
  2. package/package.json +5 -5
  3. package/src/components/data/calendar/calendar.tsx +1 -1
  4. package/src/components/data/toggle/index.tsx +1 -1
  5. package/src/components/forms/button/index.tsx +6 -1
  6. package/src/components/forms/checkbox/index.tsx +1 -1
  7. package/src/components/forms/filter-button/index.tsx +145 -0
  8. package/src/components/forms/filter-menu/index.tsx +203 -0
  9. package/src/components/forms/mask-input/index.tsx +1 -1
  10. package/src/components/forms/segmented-input/index.tsx +1 -1
  11. package/src/components/forms/tags-input/index.tsx +1 -1
  12. package/src/components/index.ts +6 -0
  13. package/src/components/layout/filter-bar/index.tsx +48 -0
  14. package/src/components/navigation/command/index.tsx +19 -40
  15. package/src/components/navigation/tabs/index.tsx +102 -17
  16. package/src/components/navigation/tabs/use-tab-indicator.ts +68 -0
  17. package/src/components/overlay/drawer/index.tsx +113 -15
  18. package/src/components/overlay/popover/index.tsx +8 -2
  19. package/src/components/overlay/side-panel/index.tsx +77 -8
  20. package/src/components/select/combobox-async.tsx +26 -32
  21. package/src/components/select/combobox.tsx +36 -41
  22. package/src/components/select/country-select.tsx +21 -9
  23. package/src/components/select/language-select.tsx +21 -9
  24. package/src/components/select/multi-select-pro-async.tsx +3 -2
  25. package/src/components/select/multi-select-pro.tsx +3 -2
  26. package/src/components/select/multi-select.tsx +9 -30
  27. package/src/components/select/select.tsx +1 -1
  28. package/src/components/select/trigger.ts +23 -0
  29. package/src/hooks/dom/index.ts +1 -0
  30. package/src/hooks/dom/useHighlight.ts +68 -0
  31. package/src/styles/css/presets/dense.css +12 -0
  32. package/src/styles/css/presets/ios.css +12 -0
  33. package/src/styles/css/presets/soft.css +12 -0
  34. package/src/styles/css/utilities/filter-bar.css +61 -0
  35. package/src/styles/css/utilities/overlay.css +78 -0
  36. package/src/styles/css/utilities/tabs.css +53 -0
  37. package/src/styles/css/utilities.css +2 -0
@@ -8,7 +8,9 @@ import * as TabsPrimitive from '@radix-ui/react-tabs';
8
8
  import { useIsMobile } from '../../../hooks';
9
9
  import { useStoredValue, type StorageType } from '../../../hooks';
10
10
  import { useAppT } from '@djangocfg/i18n';
11
+ import { useComposedRefs } from '../../../lib/compose-refs';
11
12
  import { cn } from '../../../lib/utils';
13
+ import { useTabIndicator } from './use-tab-indicator';
12
14
  import { Button } from '../../forms/button';
13
15
  import { ScrollArea, ScrollBar } from '../../layout/scroll-area';
14
16
  import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '../../overlay/sheet';
@@ -210,43 +212,110 @@ Tabs.displayName = "Tabs"
210
212
  // Tabs variant context — lets TabsTrigger inherit variant from TabsList
211
213
  // ─────────────────────────────────────────────────────────────────────────
212
214
 
213
- type TabsVariant = 'default' | 'underline'
214
- const TabsVariantContext = React.createContext<TabsVariant>('default')
215
+ type TabsVariant = 'default' | 'underline' | 'pill'
216
+ /** Control height. `auto` picks `sm` on a phone and `md` elsewhere. */
217
+ type TabsSize = 'sm' | 'md' | 'auto'
218
+
219
+ const TabsVariantContext = React.createContext<{
220
+ variant: TabsVariant
221
+ size: Exclude<TabsSize, 'auto'>
222
+ }>({ variant: 'default', size: 'md' })
215
223
 
216
224
  const TabsList = React.forwardRef<
217
225
  React.ElementRef<typeof TabsPrimitive.List>,
218
226
  React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> & {
219
227
  /**
220
228
  * Visual variant.
221
- * - `default` — pill/card style (bg-muted container)
222
- * - `underline` borderless, underline indicator on active tab
229
+ * - `default` — segmented control on a `bg-muted` track, rounded corners
230
+ * - `pill` the same control drawn as a capsule, with the active
231
+ * indicator SLIDING between tabs instead of popping
232
+ * - `underline` — borderless, a rule under the active tab
223
233
  * @default 'default'
224
234
  */
225
235
  variant?: TabsVariant
226
236
  /**
227
- * Full width mode - tabs will stretch equally to fill the container
237
+ * Control height.
238
+ * - `md` — 36px, the desktop default
239
+ * - `sm` — 28px, for a toolbar or a phone
240
+ * - `auto` — `sm` under the mobile breakpoint, `md` above it
241
+ *
242
+ * `auto` exists because consumers were already doing this by hand
243
+ * (`isSm ? 'h-7 p-0.5 gap-0.5' : 'h-9 p-0.5'` in a widget's own file),
244
+ * which puts a media query and a set of magic numbers in every caller.
245
+ * @default 'md'
246
+ */
247
+ size?: TabsSize
248
+ /**
249
+ * Stretch the tabs to fill their container.
250
+ *
251
+ * `'mobile'` does it only below the mobile breakpoint, which is the shape
252
+ * a toolbar actually wants: a switch that hugs its labels beside other
253
+ * controls on a desktop row, and spans the width once it is alone on a
254
+ * phone. Consumers were expressing this by setting `fullWidth` and then
255
+ * undoing it in CSS (`width: auto` plus a media query back to `100%`).
228
256
  * @default false
229
257
  */
230
- fullWidth?: boolean
258
+ fullWidth?: boolean | 'mobile'
231
259
  /**
232
260
  * Enable horizontal scrolling when tabs overflow
233
261
  * @default false
234
262
  */
235
263
  scrollable?: boolean
236
264
  }
237
- >(({ className, variant = 'default', fullWidth = false, scrollable = false, ...props }, ref) => {
265
+ >(({ className, variant = 'default', size = 'md', fullWidth = false, scrollable = false, ...props }, ref) => {
238
266
  const isUnderline = variant === 'underline'
267
+ const isPill = variant === 'pill'
268
+ const isMobile = useIsMobile()
269
+ const resolvedSize = size === 'auto' ? (isMobile ? 'sm' : 'md') : size
270
+ const resolvedFullWidth = fullWidth === 'mobile' ? isMobile : fullWidth
271
+
272
+ // Only the pill variant animates its indicator. The default variant keeps a
273
+ // background on the active trigger, which cannot travel between two
274
+ // elements — moving it is the whole reason `pill` draws the indicator on the
275
+ // list instead.
276
+ const indicator = useTabIndicator<HTMLDivElement>()
277
+ // Both refs always compose: hooks cannot be called conditionally, and the
278
+ // indicator ref is inert for the variants that do not draw one.
279
+ const listRef = useComposedRefs(ref, indicator.ref)
280
+
281
+ // `md` keeps the 36px the default variant has always been: this size scale
282
+ // was added for `pill`, and a taller default would silently reflow every
283
+ // existing tab strip in every consumer.
284
+ const sizing = resolvedSize === 'sm'
285
+ ? { track: 'h-8 p-0.5', gap: 'gap-0.5', inset: '0.125rem' }
286
+ : { track: 'h-9 p-1', gap: 'gap-1', inset: '0.25rem' }
239
287
 
240
288
  const listCls = cn(
241
289
  isUnderline
242
290
  ? "inline-flex items-end gap-0 bg-transparent p-0 h-auto rounded-none"
243
- : "inline-flex h-9 items-center justify-center rounded-[var(--radius)] bg-muted p-1 text-muted-foreground",
244
- fullWidth && "w-full flex",
291
+ : cn(
292
+ "inline-flex items-center justify-center bg-muted text-muted-foreground",
293
+ sizing.track,
294
+ sizing.gap,
295
+ isPill ? "rounded-full" : "rounded-[var(--radius)]",
296
+ isPill && "tabs-sliding",
297
+ ),
298
+ resolvedFullWidth && "w-full flex",
245
299
  className
246
300
  )
247
301
 
248
302
  const list = (
249
- <TabsPrimitive.List ref={ref} className={listCls} {...props} />
303
+ <TabsPrimitive.List
304
+ ref={listRef}
305
+ className={listCls}
306
+ // The pill is hidden until the first measurement lands, so it does not
307
+ // fly in from the left edge of the track on mount.
308
+ data-tab-indicator={isPill ? (indicator.ready ? 'ready' : 'idle') : undefined}
309
+ style={
310
+ isPill
311
+ ? ({
312
+ '--tab-indicator-inset': sizing.inset,
313
+ '--tab-indicator-radius': '9999px',
314
+ } as React.CSSProperties)
315
+ : undefined
316
+ }
317
+ {...props}
318
+ />
250
319
  )
251
320
 
252
321
  // For underline variant — native overflow-x-auto, full-width border via absolute element
@@ -265,8 +334,13 @@ const TabsList = React.forwardRef<
265
334
  </ScrollArea>
266
335
  ) : list
267
336
 
337
+ const contextValue = React.useMemo(
338
+ () => ({ variant, size: resolvedSize }),
339
+ [variant, resolvedSize]
340
+ )
341
+
268
342
  return (
269
- <TabsVariantContext.Provider value={variant}>
343
+ <TabsVariantContext.Provider value={contextValue}>
270
344
  {wrapped}
271
345
  </TabsVariantContext.Provider>
272
346
  )
@@ -284,8 +358,10 @@ const TabsTrigger = React.forwardRef<
284
358
  key?: React.Key
285
359
  }
286
360
  >(({ className, flexEqual = false, ...props }, ref) => {
287
- const variant = React.useContext(TabsVariantContext)
361
+ const { variant, size } = React.useContext(TabsVariantContext)
288
362
  const isUnderline = variant === 'underline'
363
+ const isPill = variant === 'pill'
364
+ const isSm = size === 'sm'
289
365
 
290
366
  return (
291
367
  <TabsPrimitive.Trigger
@@ -294,8 +370,8 @@ const TabsTrigger = React.forwardRef<
294
370
  isUnderline
295
371
  ? [
296
372
  "relative z-10 rounded-none bg-transparent shadow-none whitespace-nowrap",
297
- "px-3 py-2.5 mb-[-1px]",
298
- "text-sm font-medium text-muted-foreground/60",
373
+ isSm ? "px-2.5 py-2 text-xs" : "px-3 py-2.5 text-sm",
374
+ "font-medium text-muted-foreground/60",
299
375
  "border-b-2 border-transparent",
300
376
  "transition-all duration-150",
301
377
  "hover:text-foreground",
@@ -305,11 +381,20 @@ const TabsTrigger = React.forwardRef<
305
381
  "data-[state=active]:border-foreground data-[state=active]:shadow-none",
306
382
  ].join(" ")
307
383
  : [
308
- "inline-flex items-center justify-center whitespace-nowrap rounded-[var(--radius-sm)] px-3 py-1",
309
- "text-sm font-medium transition-all",
384
+ "inline-flex items-center justify-center gap-1.5 whitespace-nowrap",
385
+ isSm ? "px-2.5 text-xs" : "px-3 text-sm",
386
+ "h-full font-medium",
310
387
  "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
311
388
  "disabled:pointer-events-none disabled:opacity-50",
312
- "data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
389
+ "data-[state=active]:text-foreground",
390
+ // The pill variant's indicator lives on the list and travels, so
391
+ // the trigger carries no background of its own — one would sit on
392
+ // top of the sliding pill and give the movement away. Colour is
393
+ // all that changes here, and it changes fast enough not to lag
394
+ // behind the pill.
395
+ isPill
396
+ ? "relative rounded-full bg-transparent transition-colors duration-150 hover:text-foreground/80 data-[state=active]:bg-transparent data-[state=active]:shadow-none"
397
+ : "rounded-[var(--radius-sm)] transition-all data-[state=active]:bg-background data-[state=active]:shadow",
313
398
  ].join(" "),
314
399
  flexEqual && "flex-1",
315
400
  className
@@ -0,0 +1,68 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+
5
+ /**
6
+ * Keeps the sliding indicator under the active tab.
7
+ *
8
+ * Measurement rather than arithmetic: tabs here carry real words ("All",
9
+ * "Deals", "Body type"), so they are never equal width and the CSS-only trick
10
+ * of a fixed `--step` per index drifts as soon as a label changes or a
11
+ * translation lands. The numbers go out as custom properties and `tabs.css`
12
+ * does the animating, so React never runs a frame of it.
13
+ *
14
+ * It re-measures on the three things that actually move a tab: the selection
15
+ * changing, the list resizing (a breakpoint, a sidebar opening), and a font
16
+ * finishing loading — that last one is the subtle one, since a fallback face
17
+ * lays out at a different width and the pill would otherwise keep a stale one.
18
+ *
19
+ * The selection is read from the DOM (`data-state`) rather than taken as an
20
+ * argument, because that is the one source both controlled and uncontrolled
21
+ * Tabs agree on — a `value` prop is absent in the uncontrolled case, and
22
+ * `defaultValue` goes stale the moment the reader clicks.
23
+ */
24
+ export function useTabIndicator<T extends HTMLElement>() {
25
+ const ref = React.useRef<T | null>(null)
26
+ const [ready, setReady] = React.useState(false)
27
+
28
+ React.useLayoutEffect(() => {
29
+ const list = ref.current
30
+ if (!list) return
31
+
32
+ const measure = () => {
33
+ const tab = list.querySelector<HTMLElement>('[role="tab"][data-state="active"]')
34
+ if (!tab) return
35
+ list.style.setProperty('--tab-indicator-x', `${tab.offsetLeft}px`)
36
+ list.style.setProperty('--tab-indicator-w', `${tab.offsetWidth}px`)
37
+ setReady(true)
38
+ }
39
+
40
+ measure()
41
+
42
+ const resize = new ResizeObserver(measure)
43
+ resize.observe(list)
44
+ for (const tab of list.querySelectorAll('[role="tab"]')) resize.observe(tab)
45
+
46
+ // Radix flips `data-state` on the triggers; watching the attribute covers
47
+ // selection by click, by keyboard and by the parent changing `value`,
48
+ // without this hook needing to know which of the three happened.
49
+ const mutation = new MutationObserver(measure)
50
+ mutation.observe(list, {
51
+ subtree: true,
52
+ attributes: true,
53
+ attributeFilter: ['data-state'],
54
+ childList: true,
55
+ })
56
+
57
+ // `document.fonts` is absent in older WebViews; the indicator is simply
58
+ // measured once there instead of re-measured after the swap.
59
+ document.fonts?.ready.then(measure).catch(() => {})
60
+
61
+ return () => {
62
+ resize.disconnect()
63
+ mutation.disconnect()
64
+ }
65
+ }, [])
66
+
67
+ return { ref, ready }
68
+ }
@@ -6,18 +6,28 @@ import { Drawer as DrawerPrimitive } from 'vaul';
6
6
  import { cn } from '../../../lib/utils';
7
7
  import { useIsMobile } from '../../../hooks/media/useMobile';
8
8
 
9
- // Direction context lets <DrawerContent /> inherit the Root's direction without
10
- // callers having to pass it twice (a frequent rebase / refactor footgun).
11
- const DrawerDirectionContext = React.createContext<'bottom' | 'right' | 'left' | 'top' | undefined>(undefined);
9
+ type DrawerDirection = 'bottom' | 'right' | 'left' | 'top';
10
+
11
+ // Context lets <DrawerContent /> inherit what the Root was configured with,
12
+ // without callers having to pass it twice (a frequent rebase / refactor
13
+ // footgun). `snapPoints` matters to the content because the two own the panel's
14
+ // geometry differently — see the note where it is read.
15
+ const DrawerRootContext = React.createContext<{
16
+ direction?: DrawerDirection;
17
+ hasSnapPoints: boolean;
18
+ }>({ hasSnapPoints: false });
12
19
 
13
20
  /** @internal — used by DrawerContent to inherit `direction` from the Root. */
14
- export const useDrawerDirection = () => React.useContext(DrawerDirectionContext);
21
+ export const useDrawerDirection = () => React.useContext(DrawerRootContext).direction;
22
+
23
+ /** @internal — true when the Root was given `snapPoints`. */
24
+ const useDrawerHasSnapPoints = () => React.useContext(DrawerRootContext).hasSnapPoints;
15
25
 
16
26
  const Drawer = ({
17
27
  shouldScaleBackground,
18
28
  direction = 'bottom',
19
29
  ...props
20
- }: React.ComponentProps<typeof DrawerPrimitive.Root> & { direction?: 'bottom' | 'right' | 'left' | 'top' }) => {
30
+ }: React.ComponentProps<typeof DrawerPrimitive.Root> & { direction?: DrawerDirection }) => {
21
31
  // vaul's body-scale animation is a mobile-bottom-sheet effect by design.
22
32
  // Applying it to side drawers (right/left) keeps the <body> transformed for
23
33
  // ~300ms during open, which on heavy pages stalls the main thread *before*
@@ -26,14 +36,40 @@ const Drawer = ({
26
36
  // Default to enabled only for bottom sheets; callers can still override.
27
37
  const resolvedScale = shouldScaleBackground ?? direction === 'bottom';
28
38
 
39
+ const hasSnapPoints = Boolean(props.snapPoints?.length);
40
+ const rootValue = React.useMemo(
41
+ () => ({ direction, hasSnapPoints }),
42
+ [direction, hasSnapPoints]
43
+ );
44
+
45
+ // Dim and blur the page from the FIRST snap point, not the last.
46
+ //
47
+ // vaul hides the overlay entirely below `fadeFromIndex`, which defaults to
48
+ // the last snap point — so a sheet resting at 0.5 sat over a page that was
49
+ // neither dimmed nor blurred (`opacity: 0` on the overlay, while its own
50
+ // `backdrop-filter: blur(5px)` and 60% scrim were already correct and simply
51
+ // never visible). That reads as a panel floating over live content rather
52
+ // than a sheet in front of it. A caller that wants the page to stay legible
53
+ // until the sheet is expanded can still pass its own index.
54
+ // vaul types `fadeFromIndex` as a discriminated union against `snapPoints`
55
+ // (`number` with them, `never` without), which no spread can satisfy
56
+ // conditionally — hence one assembled object and a single cast at the seam.
57
+ const rootProps = {
58
+ ...props,
59
+ ...(hasSnapPoints &&
60
+ (props as { fadeFromIndex?: number }).fadeFromIndex === undefined
61
+ ? { fadeFromIndex: 0 }
62
+ : null),
63
+ } as React.ComponentProps<typeof DrawerPrimitive.Root>;
64
+
29
65
  return (
30
- <DrawerDirectionContext.Provider value={direction}>
66
+ <DrawerRootContext.Provider value={rootValue}>
31
67
  <DrawerPrimitive.Root
32
68
  shouldScaleBackground={resolvedScale}
33
69
  direction={direction}
34
- {...props}
70
+ {...rootProps}
35
71
  />
36
- </DrawerDirectionContext.Provider>
72
+ </DrawerRootContext.Provider>
37
73
  );
38
74
  };
39
75
  Drawer.displayName = "Drawer"
@@ -107,6 +143,19 @@ const directionStyles = {
107
143
  left: "inset-y-0 left-0 h-full border-r",
108
144
  } as const;
109
145
 
146
+ // The snap-point variant of the above. A snapped panel is FULL height and vaul
147
+ // slides it to the active point by transform, so the `max-h` and the top margin
148
+ // that keep an auto-height sheet on screen are exactly wrong here: they shorten
149
+ // the panel while vaul keeps translating by the full distance, and the sheet
150
+ // lands past the bottom edge. Height comes from the height:100% below, never
151
+ // from a preset.
152
+ const snapDirectionStyles = {
153
+ bottom: "inset-x-0 bottom-0 h-full rounded-t-lg border-t",
154
+ top: "inset-x-0 top-0 h-full rounded-b-lg border-b",
155
+ right: "inset-y-0 right-0 h-full border-l",
156
+ left: "inset-y-0 left-0 h-full border-r",
157
+ } as const;
158
+
110
159
  const toCssLength = (value: string | number | undefined): string | undefined => {
111
160
  if (value == null) return undefined;
112
161
  return typeof value === 'number' ? `${value}px` : value;
@@ -178,6 +227,7 @@ const DrawerContent = React.forwardRef<
178
227
  const isVertical = direction === 'bottom' || direction === 'top';
179
228
  const isMobile = useIsMobile();
180
229
  const resizeEnabled = resizable && (!resizableOnDesktopOnly || !isMobile);
230
+ const hasSnapPoints = useDrawerHasSnapPoints();
181
231
 
182
232
  const defaultMin = isVertical ? 200 : 280;
183
233
  const defaultMax = isVertical ? 800 : 960;
@@ -252,7 +302,12 @@ const DrawerContent = React.forwardRef<
252
302
  const rawWidth = !isVertical
253
303
  ? (currentPx != null ? `${currentPx}px` : (toCssLength(width) ?? horizontalSizePresets[size]))
254
304
  : undefined;
255
- const rawHeight = isVertical
305
+ // With snap points the PANEL is full-height and vaul positions it by
306
+ // transform, reading back how far it may travel. A height of our own makes
307
+ // the two disagree: at snap point 0.4 vaul translated a 360px panel by 532px
308
+ // and pushed it entirely off the bottom of the screen — the drawer opened
309
+ // into nothing. So when the Root has snap points, the height is vaul's.
310
+ const rawHeight = isVertical && !hasSnapPoints
256
311
  ? (currentPx != null ? `${currentPx}px` : (toCssLength(height) ?? verticalSizePresets[size]))
257
312
  : undefined;
258
313
  const resolvedWidth = rawWidth ? `min(100vw, ${rawWidth})` : undefined;
@@ -273,13 +328,26 @@ const DrawerContent = React.forwardRef<
273
328
  aria-describedby={undefined}
274
329
  className={cn(
275
330
  "fixed z-500 flex flex-col bg-background",
276
- directionStyles[direction],
331
+ hasSnapPoints ? snapDirectionStyles[direction] : directionStyles[direction],
277
332
  className
278
333
  )}
279
334
  style={{
280
- transition: dragStateRef.current
281
- ? 'none'
282
- : 'transform 300ms cubic-bezier(0.32, 0.72, 0, 1)',
335
+ // With snap points vaul drives `transform` AND the transition that
336
+ // animates it, writing both to this element imperatively. A
337
+ // `transition` of ours in the style object is re-applied by React on
338
+ // every render and wipes that inline `transform` with it, so the
339
+ // sheet stays parked at its closed offset: measured
340
+ // `--snap-point-height: 415.5px` (correct) against `transform:
341
+ // translate3d(0, 831px, 0)` (fully off-screen). The resize handle's
342
+ // own animation is what this exists for, and resize does not apply
343
+ // to a snapped sheet.
344
+ ...(hasSnapPoints
345
+ ? null
346
+ : {
347
+ transition: dragStateRef.current
348
+ ? 'none'
349
+ : 'transform 300ms cubic-bezier(0.32, 0.72, 0, 1)',
350
+ }),
283
351
  ...(resolvedWidth ? { width: resolvedWidth } : {}),
284
352
  ...(resolvedHeight ? { height: resolvedHeight } : {}),
285
353
  ...style,
@@ -300,7 +368,26 @@ const DrawerContent = React.forwardRef<
300
368
  )}
301
369
  />
302
370
  )}
303
- {isVertical && <div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />}
371
+ {/* Everything the reader can see lives inside this column when the
372
+ sheet is snapped: the panel itself extends past the bottom of the
373
+ window, so a footer pinned to the PANEL is pinned off-screen.
374
+ `.drawer-snap-viewport` is the visible slice; without snap points
375
+ there is no slice to take and the panel is the column. */}
376
+ <div className={hasSnapPoints ? "drawer-snap-viewport" : "contents"}>
377
+ {/* The grab handle. `bg-muted` is a SURFACE token — on a light theme it
378
+ is nearly the panel's own colour, so the handle read as a smudge.
379
+ `bg-border` is the token for a line drawn ON a surface and is the
380
+ one that keeps its contrast in both themes. Slimmer and narrower
381
+ than before, which is what the platform sheets use: the handle is an
382
+ affordance, not an element of the design.
383
+ `aria-hidden` because the drag it hints at is not a keyboard
384
+ affordance — Escape and the close button are. */}
385
+ {isVertical && (
386
+ <div
387
+ aria-hidden
388
+ className="mx-auto mt-3 h-1.5 w-10 shrink-0 rounded-full bg-border"
389
+ />
390
+ )}
304
391
  {/*
305
392
  Content padding lives HERE, not on each caller. `DialogContent` has
306
393
  its own; this did not, so every surface that renders as a Dialog on
@@ -316,10 +403,21 @@ const DrawerContent = React.forwardRef<
316
403
  It owns the SCROLL but never the height: `flex-1` here filled the
317
404
  panel and left the last row hundreds of pixels above the edge. Only
318
405
  the panel's own `max-h` decides how tall the drawer gets.
406
+
407
+ With snap points that reasoning inverts. The panel is full-height by
408
+ then and vaul slides it, so a scroller sized to its content would
409
+ leave the rest of the sheet empty and unscrollable — there `flex-1`
410
+ is what makes the body fill the snapped height.
319
411
  */}
320
- <div className="min-h-0 overflow-y-auto overscroll-contain px-5 pb-[max(1.25rem,env(safe-area-inset-bottom))]">
412
+ <div
413
+ className={cn(
414
+ "min-h-0 overflow-y-auto overscroll-contain px-5 pb-[max(1.25rem,env(safe-area-inset-bottom))]",
415
+ hasSnapPoints && "flex-1"
416
+ )}
417
+ >
321
418
  {children}
322
419
  </div>
420
+ </div>
323
421
  </DrawerPrimitive.Content>
324
422
  </DrawerPortal>
325
423
  );
@@ -15,7 +15,7 @@ const PopoverAnchor = PopoverPrimitive.Anchor
15
15
  const PopoverContent = React.forwardRef<
16
16
  React.ElementRef<typeof PopoverPrimitive.Content>,
17
17
  React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
18
- >(({ className, align = "center", sideOffset = 4, forceMount, ...props }, ref) => (
18
+ >(({ className, align = "center", sideOffset = 4, collisionPadding = 8, forceMount, ...props }, ref) => (
19
19
  // `forceMount` must reach BOTH halves, and that is the whole reason it is
20
20
  // destructured rather than swept up by `...props` (live 2026-08-15).
21
21
  //
@@ -44,8 +44,14 @@ const PopoverContent = React.forwardRef<
44
44
  aria-describedby={undefined}
45
45
  align={align}
46
46
  sideOffset={sideOffset}
47
+ // Radix defaults this to 0, which lets a popover that does not fit below
48
+ // its trigger flip above it and sit flush against the top edge of the
49
+ // window, first rows clipped by the viewport. The padding also feeds
50
+ // `--radix-popover-content-available-height`, which `.popover-panel`
51
+ // (in the class list below) caps the whole panel to.
52
+ collisionPadding={collisionPadding}
47
53
  className={cn(
48
- "z-[1400] w-72 rounded-[var(--radius-popover)] border bg-popover backdrop-blur-xl p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
54
+ "popover-panel z-[1400] w-72 rounded-[var(--radius-popover)] border bg-popover backdrop-blur-xl p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
49
55
  className
50
56
  )}
51
57
  {...props}
@@ -5,12 +5,16 @@
5
5
  *
6
6
  * Use for inspector panels, playgrounds, filters — anywhere you want a
7
7
  * slide-in surface that does NOT lock out the rest of the page. The
8
- * surrounding UI stays clickable and focusable; only Esc (optional) and
9
- * the close button dismiss the panel.
8
+ * surrounding UI stays clickable, focusable and scrollable.
9
+ *
10
+ * Esc, the close button and a click outside all dismiss it. Having no backdrop
11
+ * says the page stays usable; it does not say the click should do nothing —
12
+ * a reader who clicks beside the panel expects it to close, and with only Esc
13
+ * and a small `×` they had to hunt for the way out.
10
14
  *
11
15
  * Differences from the existing ``Drawer``:
12
16
  * - ``modal={false}`` — no focus trap, no scroll-lock on <body>.
13
- * - No backdrop overlay.
17
+ * - No backdrop overlay (the page behind stays legible and live).
14
18
  * - No ``shouldScaleBackground`` (vaul's iOS-style fancy scale looks
15
19
  * wrong for side panels — reserved for bottom sheets).
16
20
  * - Opinionated for ``right``/``left`` directions; vaul still drives
@@ -24,6 +28,7 @@ import * as React from 'react';
24
28
  import { Drawer as DrawerPrimitive } from 'vaul';
25
29
  import { X } from 'lucide-react';
26
30
 
31
+ import { useComposedRefs } from '../../../lib/compose-refs';
27
32
  import { cn } from '../../../lib/utils';
28
33
 
29
34
  // ─── Root ─────────────────────────────────────────────────────────────────────
@@ -37,6 +42,20 @@ export interface SidePanelProps {
37
42
  /** Close when the user presses Escape. Default ``true``. Disable when
38
43
  * the parent wants custom handling or Esc is bound to something else. */
39
44
  closeOnEsc?: boolean;
45
+ /**
46
+ * Close when the user clicks or taps outside the panel. Default ``true``.
47
+ *
48
+ * Having no backdrop is a statement about the PAGE — it stays readable and
49
+ * clickable — not about the click. Without this the only ways out were Esc
50
+ * and a small ``×``, so a reader who clicked beside the panel, as every
51
+ * other overlay has taught them to, got nothing and had to go hunting for
52
+ * the button.
53
+ *
54
+ * Turn it off for a panel the reader works ALONGSIDE — an inspector kept
55
+ * open while editing the page behind it, where a stray click on the canvas
56
+ * must not dismiss the thing they are reading from.
57
+ */
58
+ closeOnOutsideClick?: boolean;
40
59
  }
41
60
 
42
61
  const SidePanel: React.FC<SidePanelProps> & {
@@ -47,7 +66,11 @@ const SidePanel: React.FC<SidePanelProps> & {
47
66
  Body: typeof SidePanelBody;
48
67
  Footer: typeof SidePanelFooter;
49
68
  Close: typeof SidePanelClose;
50
- } = ({ open, onOpenChange, children, side = 'right', closeOnEsc = true }) => {
69
+ } = ({ open, onOpenChange, children, side = 'right', closeOnEsc = true, closeOnOutsideClick = true }) => {
70
+ // The panel element, so the outside-click listener can ask "was this click
71
+ // inside?" — there is no backdrop to catch it for us.
72
+ const contentRef = React.useRef<HTMLDivElement | null>(null);
73
+
51
74
  // Esc handling: vaul's built-in closes on Esc only when modal=true.
52
75
  // We're non-modal, so we install our own listener — gated on ``open``
53
76
  // to avoid swallowing Esc globally while the panel is closed.
@@ -60,6 +83,46 @@ const SidePanel: React.FC<SidePanelProps> & {
60
83
  return () => window.removeEventListener('keydown', handler);
61
84
  }, [open, closeOnEsc, onOpenChange]);
62
85
 
86
+ // Outside-click handling, for the same reason Esc is handled here: with
87
+ // `modal={false}` vaul installs no dismissal of its own.
88
+ //
89
+ // `pointerdown` rather than `click`, so a press that begins outside closes
90
+ // even if the pointer travels onto the panel before release. The listener
91
+ // is added on a later task, otherwise the very click that OPENED the panel
92
+ // finishes propagating into it and closes it again immediately.
93
+ React.useEffect(() => {
94
+ if (!open || !closeOnOutsideClick) return;
95
+
96
+ let armed = false;
97
+ const arm = () => { armed = true; };
98
+ const id = window.setTimeout(arm, 0);
99
+
100
+ const handler = (event: PointerEvent) => {
101
+ if (!armed) return;
102
+ const target = event.target as Node | null;
103
+ if (!target || contentRef.current?.contains(target)) return;
104
+ // A click inside any overlay the panel itself opened — a Select's
105
+ // popover, a dialog — lands outside the panel in the DOM, because
106
+ // both are portalled to <body>. Closing on those would dismiss the
107
+ // panel the moment a reader picked a value in it.
108
+ if (
109
+ target instanceof Element &&
110
+ target.closest('[data-slot="popover-content"],[role="dialog"],[role="listbox"],[role="menu"]')
111
+ ) {
112
+ return;
113
+ }
114
+ onOpenChange(false);
115
+ };
116
+
117
+ document.addEventListener('pointerdown', handler, true);
118
+ return () => {
119
+ window.clearTimeout(id);
120
+ document.removeEventListener('pointerdown', handler, true);
121
+ };
122
+ }, [open, closeOnOutsideClick, onOpenChange]);
123
+
124
+ const contextValue = React.useMemo(() => ({ side, contentRef }), [side]);
125
+
63
126
  return (
64
127
  <DrawerPrimitive.Root
65
128
  open={open}
@@ -77,7 +140,7 @@ const SidePanel: React.FC<SidePanelProps> & {
77
140
  disablePreventScroll
78
141
  dismissible
79
142
  >
80
- <SidePanelSideContext.Provider value={side}>
143
+ <SidePanelSideContext.Provider value={contextValue}>
81
144
  {children}
82
145
  </SidePanelSideContext.Provider>
83
146
  </DrawerPrimitive.Root>
@@ -86,7 +149,10 @@ const SidePanel: React.FC<SidePanelProps> & {
86
149
  SidePanel.displayName = 'SidePanel';
87
150
 
88
151
  // Carries the side down to Content so it can place border + transform correctly.
89
- const SidePanelSideContext = React.createContext<'right' | 'left'>('right');
152
+ const SidePanelSideContext = React.createContext<{
153
+ side: 'right' | 'left';
154
+ contentRef?: React.MutableRefObject<HTMLDivElement | null>;
155
+ }>({ side: 'right' });
90
156
 
91
157
  // ─── Content ──────────────────────────────────────────────────────────────────
92
158
 
@@ -101,14 +167,17 @@ const SidePanelContent = React.forwardRef<
101
167
  React.ElementRef<typeof DrawerPrimitive.Content>,
102
168
  SidePanelContentProps
103
169
  >(({ className, children, width = '440px', style, ...props }, ref) => {
104
- const side = React.useContext(SidePanelSideContext);
170
+ const { side, contentRef } = React.useContext(SidePanelSideContext);
171
+ // Both refs always compose: the caller's, and the panel's own, which the
172
+ // outside-click listener measures against.
173
+ const composedRef = useComposedRefs(ref, contentRef ?? null);
105
174
  const positioning =
106
175
  side === 'right' ? 'inset-y-0 right-0 border-l' : 'inset-y-0 left-0 border-r';
107
176
 
108
177
  return (
109
178
  <DrawerPrimitive.Portal>
110
179
  <DrawerPrimitive.Content
111
- ref={ref}
180
+ ref={composedRef}
112
181
  aria-describedby={undefined}
113
182
  className={cn(
114
183
  'fixed z-500 flex flex-col bg-background shadow-2xl shadow-black/20',