@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.
- package/README.md +3 -3
- package/package.json +5 -5
- package/src/components/data/calendar/calendar.tsx +1 -1
- package/src/components/data/toggle/index.tsx +1 -1
- package/src/components/forms/button/index.tsx +6 -1
- package/src/components/forms/checkbox/index.tsx +1 -1
- package/src/components/forms/filter-button/index.tsx +145 -0
- package/src/components/forms/filter-menu/index.tsx +203 -0
- package/src/components/forms/mask-input/index.tsx +1 -1
- package/src/components/forms/segmented-input/index.tsx +1 -1
- package/src/components/forms/tags-input/index.tsx +1 -1
- package/src/components/index.ts +6 -0
- package/src/components/layout/filter-bar/index.tsx +48 -0
- package/src/components/navigation/command/index.tsx +19 -40
- package/src/components/navigation/tabs/index.tsx +102 -17
- package/src/components/navigation/tabs/use-tab-indicator.ts +68 -0
- package/src/components/overlay/drawer/index.tsx +113 -15
- package/src/components/overlay/popover/index.tsx +8 -2
- package/src/components/overlay/side-panel/index.tsx +77 -8
- package/src/components/select/combobox-async.tsx +26 -32
- package/src/components/select/combobox.tsx +36 -41
- package/src/components/select/country-select.tsx +21 -9
- package/src/components/select/language-select.tsx +21 -9
- package/src/components/select/multi-select-pro-async.tsx +3 -2
- package/src/components/select/multi-select-pro.tsx +3 -2
- package/src/components/select/multi-select.tsx +9 -30
- package/src/components/select/select.tsx +1 -1
- package/src/components/select/trigger.ts +23 -0
- package/src/hooks/dom/index.ts +1 -0
- package/src/hooks/dom/useHighlight.ts +68 -0
- package/src/styles/css/presets/dense.css +12 -0
- package/src/styles/css/presets/ios.css +12 -0
- package/src/styles/css/presets/soft.css +12 -0
- package/src/styles/css/utilities/filter-bar.css +61 -0
- package/src/styles/css/utilities/overlay.css +78 -0
- package/src/styles/css/utilities/tabs.css +53 -0
- 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
|
-
|
|
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` —
|
|
222
|
-
* - `
|
|
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
|
-
*
|
|
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
|
-
:
|
|
244
|
-
|
|
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
|
|
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={
|
|
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
|
|
298
|
-
"
|
|
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
|
|
309
|
-
"text-
|
|
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]:
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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(
|
|
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?:
|
|
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
|
-
<
|
|
66
|
+
<DrawerRootContext.Provider value={rootValue}>
|
|
31
67
|
<DrawerPrimitive.Root
|
|
32
68
|
shouldScaleBackground={resolvedScale}
|
|
33
69
|
direction={direction}
|
|
34
|
-
{...
|
|
70
|
+
{...rootProps}
|
|
35
71
|
/>
|
|
36
|
-
</
|
|
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
|
-
|
|
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
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
{
|
|
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
|
|
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
|
|
9
|
-
*
|
|
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={
|
|
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<
|
|
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={
|
|
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',
|