@djangocfg/ui-core 2.1.555 → 2.1.557
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 +2 -2
- 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/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 +4 -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 -40
- 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/components/select/use-highlight.ts +68 -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
|
@@ -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',
|
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList
|
|
12
12
|
} from '../navigation/command';
|
|
13
13
|
import { Popover, PopoverContent, PopoverTrigger } from '../overlay/popover';
|
|
14
|
+
import { SELECT_TRIGGER_CLASS } from './trigger';
|
|
15
|
+
import { useHighlight } from './use-highlight';
|
|
14
16
|
|
|
15
17
|
export interface ComboboxAsyncOption {
|
|
16
18
|
value: string
|
|
@@ -107,7 +109,7 @@ export function ComboboxAsync({
|
|
|
107
109
|
}: ComboboxAsyncProps) {
|
|
108
110
|
const t = useAppT()
|
|
109
111
|
const [open, setOpen] = React.useState(false)
|
|
110
|
-
const
|
|
112
|
+
const highlight = useHighlight(open, value)
|
|
111
113
|
|
|
112
114
|
const resolvedPlaceholder = placeholder ?? t('ui.select.placeholder')
|
|
113
115
|
const resolvedSearchPlaceholder = searchPlaceholder ?? t('ui.select.search')
|
|
@@ -125,19 +127,6 @@ export function ComboboxAsync({
|
|
|
125
127
|
)
|
|
126
128
|
}, [options, seedOptions, value])
|
|
127
129
|
|
|
128
|
-
React.useEffect(() => {
|
|
129
|
-
if (scrollRef.current && open) {
|
|
130
|
-
const el = scrollRef.current
|
|
131
|
-
el.style.cssText = `
|
|
132
|
-
max-height: 300px !important;
|
|
133
|
-
overflow-y: auto !important;
|
|
134
|
-
overflow-x: hidden !important;
|
|
135
|
-
-webkit-overflow-scrolling: touch !important;
|
|
136
|
-
overscroll-behavior: contain !important;
|
|
137
|
-
`
|
|
138
|
-
}
|
|
139
|
-
}, [open])
|
|
140
|
-
|
|
141
130
|
const handleSelect = React.useCallback(
|
|
142
131
|
(currentValue: string) => {
|
|
143
132
|
// Click again on the selected row → clear.
|
|
@@ -171,7 +160,7 @@ export function ComboboxAsync({
|
|
|
171
160
|
role="combobox"
|
|
172
161
|
aria-expanded={open}
|
|
173
162
|
className={cn(
|
|
174
|
-
|
|
163
|
+
SELECT_TRIGGER_CLASS,
|
|
175
164
|
!selectedOption && "text-muted-foreground",
|
|
176
165
|
className
|
|
177
166
|
)}
|
|
@@ -200,21 +189,26 @@ export function ComboboxAsync({
|
|
|
200
189
|
</Button>
|
|
201
190
|
</PopoverTrigger>
|
|
202
191
|
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
|
|
203
|
-
|
|
192
|
+
{/* Controlled so the highlight starts on the current selection rather
|
|
193
|
+
than the first row. `onValueChange` is mandatory with a controlled
|
|
194
|
+
`value`: cmdk then stores nothing itself and delegates every move to
|
|
195
|
+
it, so without it the arrow keys would not move the highlight. */}
|
|
196
|
+
<Command
|
|
197
|
+
shouldFilter={false}
|
|
198
|
+
value={highlight.value}
|
|
199
|
+
onValueChange={highlight.setValue}
|
|
200
|
+
className="flex flex-col"
|
|
201
|
+
>
|
|
204
202
|
<CommandInput
|
|
205
203
|
placeholder={resolvedSearchPlaceholder}
|
|
206
204
|
className="shrink-0"
|
|
207
205
|
value={searchValue}
|
|
208
206
|
onValueChange={onSearchChange}
|
|
209
207
|
/>
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
style={{ maxHeight: '300px' }}
|
|
215
|
-
onWheel={(e) => e.stopPropagation()}
|
|
216
|
-
>
|
|
217
|
-
<CommandList className="!max-h-none !overflow-visible" style={{ pointerEvents: 'auto' }}>
|
|
208
|
+
{/* CommandList is the scroller (it caps itself to the popover's
|
|
209
|
+
available height) — do not wrap it in a scrolling div: that hands
|
|
210
|
+
the scrolling to a container cmdk cannot see. */}
|
|
211
|
+
<CommandList ref={highlight.listRef}>
|
|
218
212
|
{isLoading && options.length === 0 ? (
|
|
219
213
|
<div className="flex items-center gap-2 px-3 py-6 text-sm text-muted-foreground">
|
|
220
214
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
@@ -223,7 +217,7 @@ export function ComboboxAsync({
|
|
|
223
217
|
) : options.length === 0 ? (
|
|
224
218
|
<CommandEmpty>{resolvedEmptyText}</CommandEmpty>
|
|
225
219
|
) : (
|
|
226
|
-
<CommandGroup
|
|
220
|
+
<CommandGroup>
|
|
227
221
|
{options.map((option) => (
|
|
228
222
|
<CommandItem
|
|
229
223
|
key={option.value}
|
|
@@ -233,12 +227,6 @@ export function ComboboxAsync({
|
|
|
233
227
|
}}
|
|
234
228
|
disabled={option.disabled}
|
|
235
229
|
>
|
|
236
|
-
<Check
|
|
237
|
-
className={cn(
|
|
238
|
-
"mr-2 h-4 w-4 shrink-0",
|
|
239
|
-
value === option.value ? "opacity-100" : "opacity-0"
|
|
240
|
-
)}
|
|
241
|
-
/>
|
|
242
230
|
{option.icon && <option.icon className="mr-2 h-4 w-4 shrink-0" />}
|
|
243
231
|
{renderOption ? (
|
|
244
232
|
renderOption(option)
|
|
@@ -259,12 +247,18 @@ export function ComboboxAsync({
|
|
|
259
247
|
)}
|
|
260
248
|
</div>
|
|
261
249
|
)}
|
|
250
|
+
|
|
251
|
+
{/* Trailing and only when selected: an always-rendered
|
|
252
|
+
`opacity-0` mark indents every row to hold space one
|
|
253
|
+
row at most ever uses. Absent it, the label gets the
|
|
254
|
+
full width; present, `shrink-0` stops the label's
|
|
255
|
+
truncation exactly at the mark. */}
|
|
256
|
+
{value === option.value && <Check className="ml-2 h-4 w-4 shrink-0" />}
|
|
262
257
|
</CommandItem>
|
|
263
258
|
))}
|
|
264
259
|
</CommandGroup>
|
|
265
260
|
)}
|
|
266
261
|
</CommandList>
|
|
267
|
-
</div>
|
|
268
262
|
</Command>
|
|
269
263
|
</PopoverContent>
|
|
270
264
|
</Popover>
|