@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
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The active-row state for a cmdk list inside a popover, and the scroll that
|
|
7
|
+
* has to accompany it.
|
|
8
|
+
*
|
|
9
|
+
* Two things are going on, and neither works alone:
|
|
10
|
+
*
|
|
11
|
+
* 1. **The highlight is controlled.** cmdk otherwise activates the FIRST row,
|
|
12
|
+
* so a picker with a selection opens showing the top of the list rather than
|
|
13
|
+
* the value it holds. `onValueChange` is mandatory with a controlled value —
|
|
14
|
+
* cmdk then stores nothing itself and delegates every move to the caller, so
|
|
15
|
+
* omitting it freezes the arrow keys.
|
|
16
|
+
*
|
|
17
|
+
* 2. **Opening has to scroll.** cmdk schedules its own `scrollIntoView` when
|
|
18
|
+
* the value changes, but the popover keeps its list MOUNTED between opens
|
|
19
|
+
* (`sameNodeAcrossOpens: true`), so on the second open nothing re-mounts and
|
|
20
|
+
* nothing re-scrolls; on the first, the value is set before 252 rows have
|
|
21
|
+
* finished registering and there is nothing to scroll to yet. Measured both
|
|
22
|
+
* ways: selection at index 84, `scrollTop` 0. So the scroll is driven from
|
|
23
|
+
* the OPEN transition, after a paint, rather than left to the library.
|
|
24
|
+
*
|
|
25
|
+
* Ongoing arrow-key movement stays cmdk's own job; this only handles the jump
|
|
26
|
+
* that opening owes the user.
|
|
27
|
+
*
|
|
28
|
+
* ```tsx
|
|
29
|
+
* const highlight = useHighlight(open, value)
|
|
30
|
+
* <Command value={highlight.value} onValueChange={highlight.setValue}>
|
|
31
|
+
* <CommandList ref={highlight.listRef}>…</CommandList>
|
|
32
|
+
* </Command>
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export function useHighlight(open: boolean, selected: string | null | undefined) {
|
|
36
|
+
const [value, setValue] = React.useState("")
|
|
37
|
+
const listRef = React.useRef<HTMLDivElement | null>(null)
|
|
38
|
+
|
|
39
|
+
// `selected` is read through a ref so that changing the selection does not
|
|
40
|
+
// re-run the open effect — only the open transition itself should scroll.
|
|
41
|
+
const selectedRef = React.useRef(selected)
|
|
42
|
+
selectedRef.current = selected
|
|
43
|
+
|
|
44
|
+
React.useEffect(() => {
|
|
45
|
+
if (!open) return
|
|
46
|
+
setValue(selectedRef.current ?? "")
|
|
47
|
+
}, [open])
|
|
48
|
+
|
|
49
|
+
// Deliberately NOT `requestAnimationFrame`: a background tab never runs one,
|
|
50
|
+
// so a popover opened in an inactive window would stay parked at the top
|
|
51
|
+
// (this is how the bug first showed up — `document.hidden` and no scroll).
|
|
52
|
+
// A layout effect keyed on the resolved highlight runs as soon as React has
|
|
53
|
+
// committed the rows, in any tab, and before the browser paints.
|
|
54
|
+
React.useLayoutEffect(() => {
|
|
55
|
+
if (!open || !value) return
|
|
56
|
+
const list = listRef.current
|
|
57
|
+
if (!list) return
|
|
58
|
+
|
|
59
|
+
// The row is found by VALUE rather than by `aria-selected`, which cmdk sets
|
|
60
|
+
// in its own scheduled pass — by then this effect has already run.
|
|
61
|
+
const row = list.querySelector(
|
|
62
|
+
`[cmdk-item][data-value="${CSS.escape(value)}"]`
|
|
63
|
+
)
|
|
64
|
+
row?.scrollIntoView({ block: 'nearest' })
|
|
65
|
+
}, [open, value])
|
|
66
|
+
|
|
67
|
+
return { value, setValue, listRef }
|
|
68
|
+
}
|
|
@@ -15,6 +15,18 @@
|
|
|
15
15
|
--radius-2xl: calc(0.25rem + 8px);
|
|
16
16
|
--radius-3xl: calc(0.25rem + 12px);
|
|
17
17
|
--radius-4xl: calc(0.25rem + 16px);
|
|
18
|
+
/* Type scale — a notch tighter than every other preset, which is the whole
|
|
19
|
+
point of this one. It previously set radii and colours only, so "dense"
|
|
20
|
+
rendered text at exactly the same size as the rest: the name promised a
|
|
21
|
+
density the tokens never delivered.
|
|
22
|
+
In `:root` only; a size does not change with the theme. */
|
|
23
|
+
--font-size-base: 0.75rem;
|
|
24
|
+
--font-size-sm: 0.6875rem;
|
|
25
|
+
--font-size-xs: 0.625rem;
|
|
26
|
+
--font-size-lg: 0.875rem;
|
|
27
|
+
--font-size-xl: 1rem;
|
|
28
|
+
--line-height-base: 1.4;
|
|
29
|
+
|
|
18
30
|
}
|
|
19
31
|
|
|
20
32
|
.dark {
|
|
@@ -57,6 +57,18 @@
|
|
|
57
57
|
--radius-2xl: calc(0.75rem + 8px);
|
|
58
58
|
--radius-3xl: calc(0.75rem + 12px);
|
|
59
59
|
--radius-4xl: calc(0.75rem + 16px);
|
|
60
|
+
/* Type scale. Lives in `:root` only — a size is not a colour and does not
|
|
61
|
+
change with the theme; `macos`/`windows` duplicate it into `.dark`, which
|
|
62
|
+
is two places to edit for one value.
|
|
63
|
+
Without these a preset silently inherits base.css (14px body / 13px
|
|
64
|
+
`text-sm`) while `macos` renders 13/12, so the same component measured a
|
|
65
|
+
pixel apart depending on which preset an app loaded. */
|
|
66
|
+
--font-size-base: 0.8125rem;
|
|
67
|
+
--font-size-sm: 0.75rem;
|
|
68
|
+
--font-size-xs: 0.6875rem;
|
|
69
|
+
--font-size-lg: 0.9375rem;
|
|
70
|
+
--font-size-xl: 1.0625rem;
|
|
71
|
+
|
|
60
72
|
}
|
|
61
73
|
|
|
62
74
|
.dark {
|
|
@@ -23,6 +23,18 @@
|
|
|
23
23
|
--radius-2xl: calc(1rem + 8px);
|
|
24
24
|
--radius-3xl: calc(1rem + 12px);
|
|
25
25
|
--radius-4xl: calc(1rem + 16px);
|
|
26
|
+
/* Type scale. Lives in `:root` only — a size is not a colour and does not
|
|
27
|
+
change with the theme; `macos`/`windows` duplicate it into `.dark`, which
|
|
28
|
+
is two places to edit for one value.
|
|
29
|
+
Without these a preset silently inherits base.css (14px body / 13px
|
|
30
|
+
`text-sm`) while `macos` renders 13/12, so the same component measured a
|
|
31
|
+
pixel apart depending on which preset an app loaded. */
|
|
32
|
+
--font-size-base: 0.8125rem;
|
|
33
|
+
--font-size-sm: 0.75rem;
|
|
34
|
+
--font-size-xs: 0.6875rem;
|
|
35
|
+
--font-size-lg: 0.9375rem;
|
|
36
|
+
--font-size-xl: 1.0625rem;
|
|
37
|
+
|
|
26
38
|
}
|
|
27
39
|
|
|
28
40
|
.dark {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A row of filter controls, and the pills in it.
|
|
3
|
+
*
|
|
4
|
+
* The row owns the height. Every control in it — a pill, a select, a sort menu —
|
|
5
|
+
* reads `--filter-control-h`, which is what stops a caller restyling somebody
|
|
6
|
+
* else's component to make one row line up. Both product frontends were doing
|
|
7
|
+
* exactly that: one overrode `SelectTrigger`, the other `Button size="sm"`.
|
|
8
|
+
*/
|
|
9
|
+
.filter-bar {
|
|
10
|
+
/* Named so a host can set the row's density once, here, rather than per
|
|
11
|
+
control. 38px is the height both frontends independently arrived at. */
|
|
12
|
+
--filter-control-h: 2.375rem;
|
|
13
|
+
|
|
14
|
+
container-type: inline-size;
|
|
15
|
+
container-name: filter-bar;
|
|
16
|
+
|
|
17
|
+
display: flex;
|
|
18
|
+
flex-wrap: wrap;
|
|
19
|
+
align-items: center;
|
|
20
|
+
gap: 0.5rem;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/* Horizontal scroll instead of wrapping, for a row that must stay one line. */
|
|
24
|
+
.filter-bar[data-overflow="scroll"] {
|
|
25
|
+
flex-wrap: nowrap;
|
|
26
|
+
overflow-x: auto;
|
|
27
|
+
overscroll-behavior-x: contain;
|
|
28
|
+
scrollbar-width: none;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.filter-bar[data-overflow="scroll"]::-webkit-scrollbar {
|
|
32
|
+
display: none;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.filter-bar[data-overflow="scroll"] > * {
|
|
36
|
+
flex: none;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The label inside a pill, and the rule that hides it.
|
|
41
|
+
*
|
|
42
|
+
* It collapses when the ROW runs out of room, not when the window does: a
|
|
43
|
+
* toolbar in a narrow column on a wide monitor has the same problem as a phone,
|
|
44
|
+
* and a viewport media query answers neither. Hiding it is `display: none`
|
|
45
|
+
* rather than the `clip-path: inset(50%)` both projects used — that trick keeps
|
|
46
|
+
* the text in the accessibility tree while removing it from sight, which reads
|
|
47
|
+
* fine and measures wrong. The button carries an `aria-label` instead, so the
|
|
48
|
+
* name survives the collapse by design rather than by accident.
|
|
49
|
+
*
|
|
50
|
+
* A pill showing a CHOSEN value keeps its label: "SUV" is the answer, and an
|
|
51
|
+
* icon alone cannot say which question it answered.
|
|
52
|
+
*/
|
|
53
|
+
@container filter-bar (max-width: 32rem) {
|
|
54
|
+
.filter-pill:not([data-answered="true"]) .filter-pill-label {
|
|
55
|
+
display: none;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.filter-pill:not([data-answered="true"]) {
|
|
59
|
+
padding-inline: 0.625rem;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -22,3 +22,81 @@
|
|
|
22
22
|
backdrop-filter: blur(var(--overlay-blur, 0px));
|
|
23
23
|
-webkit-backdrop-filter: blur(var(--overlay-blur, 0px));
|
|
24
24
|
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The scrolling list inside a popover — a combobox's options, a command menu's
|
|
28
|
+
* results, any panel long enough to need a limit.
|
|
29
|
+
*
|
|
30
|
+
* It is a ROLE, not a size. The 300px ceiling is what reads comfortably; the
|
|
31
|
+
* real constraint is the gap the popover has to the edge of the window, which
|
|
32
|
+
* Radix publishes as `--radix-popover-content-available-height` on the content
|
|
33
|
+
* element. Taking the smaller of the two is what keeps a list opened near the
|
|
34
|
+
* bottom of the screen from flipping above its trigger and being clipped by the
|
|
35
|
+
* viewport — the failure this class exists to prevent.
|
|
36
|
+
*
|
|
37
|
+
* Outside a popover the Radix variable is unset and the fallback leaves the
|
|
38
|
+
* plain ceiling, so the class is safe anywhere.
|
|
39
|
+
*
|
|
40
|
+
* Override `--popover-scroll-max` (per theme, or on one instance) to raise or
|
|
41
|
+
* lower the ceiling; the viewport clamp always still applies.
|
|
42
|
+
*
|
|
43
|
+
* It must sit on the element that ACTUALLY SCROLLS — for cmdk that is
|
|
44
|
+
* `CommandList`, whose highlight moves by `scrollIntoView({block:'nearest'})`
|
|
45
|
+
* and therefore acts on the nearest scrollable ancestor. Capping a wrapper
|
|
46
|
+
* instead hands the scrolling to a container cmdk cannot see, and the arrow
|
|
47
|
+
* keys walk the highlight out of view.
|
|
48
|
+
*
|
|
49
|
+
* Plain CSS rather than a JIT-scanned utility, for the same reason as
|
|
50
|
+
* `.bg-overlay` above: it must compile no matter whose source the Tailwind
|
|
51
|
+
* content scan covers.
|
|
52
|
+
*/
|
|
53
|
+
.popover-scroll-region {
|
|
54
|
+
/* The VIEWPORT clamp deliberately lives on the panel (`.popover-panel`), not
|
|
55
|
+
here: this element is only part of the panel, and capping it alone lets the
|
|
56
|
+
siblings above it — a search input, a header — push the panel past the edge
|
|
57
|
+
anyway. Measured: available 193px, list 193px, panel 236px, top −35px.
|
|
58
|
+
Here the job is only the comfortable ceiling plus the actual scrolling;
|
|
59
|
+
`min-height: 0` is what lets the flex parent shrink it below its content
|
|
60
|
+
when the viewport, not the ceiling, is the binding constraint. */
|
|
61
|
+
max-height: var(--popover-scroll-max, 18.75rem);
|
|
62
|
+
min-height: 0;
|
|
63
|
+
overflow-y: auto;
|
|
64
|
+
overflow-x: hidden;
|
|
65
|
+
overscroll-behavior: contain;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The part of a snapped drawer that is actually on screen.
|
|
70
|
+
*
|
|
71
|
+
* With snap points the panel is FULL height and vaul slides it down to the
|
|
72
|
+
* active point, so everything below that point sits under the bottom edge of
|
|
73
|
+
* the window. A footer pinned with `mt-auto` therefore pins itself to the
|
|
74
|
+
* bottom of the PANEL — measured 344px below the viewport, unreachable even
|
|
75
|
+
* with the body scrolled to its end, which is what made a drawer's submit
|
|
76
|
+
* button impossible to reach.
|
|
77
|
+
*
|
|
78
|
+
* `--snap-point-height` is vaul's offset — how much of the panel is hidden —
|
|
79
|
+
* so the visible height is what remains. Constraining the content column to it
|
|
80
|
+
* puts the footer back at the edge the reader can see, at every snap point,
|
|
81
|
+
* with no JavaScript following the drag.
|
|
82
|
+
*/
|
|
83
|
+
.drawer-snap-viewport {
|
|
84
|
+
display: flex;
|
|
85
|
+
flex-direction: column;
|
|
86
|
+
height: calc(100% - var(--snap-point-height, 0px));
|
|
87
|
+
min-height: 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A popover panel that must never grow past the window.
|
|
92
|
+
*
|
|
93
|
+
* `PopoverContent` carries this by default. The height Radix publishes already
|
|
94
|
+
* accounts for `collisionPadding`, so the panel keeps its gap from the edge,
|
|
95
|
+
* and the column layout lets a `.popover-scroll-region` child absorb whatever
|
|
96
|
+
* is left after fixed siblings (search field, footer) have taken their share.
|
|
97
|
+
*/
|
|
98
|
+
.popover-panel {
|
|
99
|
+
display: flex;
|
|
100
|
+
flex-direction: column;
|
|
101
|
+
max-height: var(--radix-popover-content-available-height, none);
|
|
102
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sliding indicator behind a segmented tab list.
|
|
3
|
+
*
|
|
4
|
+
* One pill that travels to the selected tab, rather than a highlight that
|
|
5
|
+
* disappears from one tab and reappears on the next. The movement is what
|
|
6
|
+
* carries the meaning: these options are mutually exclusive, and the pill is
|
|
7
|
+
* the single thing being moved between them.
|
|
8
|
+
*
|
|
9
|
+
* Position and size arrive as `--tab-indicator-x` / `--tab-indicator-w`, which
|
|
10
|
+
* the list measures from the active trigger — tabs here are labelled with real
|
|
11
|
+
* words and are never equal width, so a fixed `--step` (the usual CSS-only
|
|
12
|
+
* trick) would drift the moment a label changed. Only `transform` and `width`
|
|
13
|
+
* animate, so the pill stays on the compositor.
|
|
14
|
+
*
|
|
15
|
+
* The indicator is a pseudo-element on the LIST rather than a background on the
|
|
16
|
+
* trigger: a background cannot animate between two different elements, which is
|
|
17
|
+
* exactly why the default variant popped instead of sliding.
|
|
18
|
+
*/
|
|
19
|
+
.tabs-sliding {
|
|
20
|
+
position: relative;
|
|
21
|
+
isolation: isolate;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.tabs-sliding::before {
|
|
25
|
+
content: "";
|
|
26
|
+
position: absolute;
|
|
27
|
+
z-index: -1;
|
|
28
|
+
top: var(--tab-indicator-inset, 0.25rem);
|
|
29
|
+
bottom: var(--tab-indicator-inset, 0.25rem);
|
|
30
|
+
left: 0;
|
|
31
|
+
width: var(--tab-indicator-w, 0);
|
|
32
|
+
transform: translate3d(var(--tab-indicator-x, 0), 0, 0);
|
|
33
|
+
border-radius: var(--tab-indicator-radius, var(--radius-sm));
|
|
34
|
+
background: var(--tab-indicator-bg, var(--background));
|
|
35
|
+
box-shadow: var(--tab-indicator-shadow, 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1));
|
|
36
|
+
transition:
|
|
37
|
+
transform 260ms cubic-bezier(0.32, 0.72, 0, 1),
|
|
38
|
+
width 260ms cubic-bezier(0.32, 0.72, 0, 1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* Before the first measurement the pill has no width, so there is nothing to
|
|
42
|
+
see travelling in from the left edge on mount. */
|
|
43
|
+
.tabs-sliding[data-tab-indicator="idle"]::before {
|
|
44
|
+
opacity: 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/* A reader who has asked for less motion gets the destination, not the
|
|
48
|
+
journey — the pill still marks the right tab, it simply stops sliding. */
|
|
49
|
+
@media (prefers-reduced-motion: reduce) {
|
|
50
|
+
.tabs-sliding::before {
|
|
51
|
+
transition: none;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
@import './utilities/controls.css';
|
|
13
13
|
@import './utilities/datetime-field.css';
|
|
14
14
|
@import './utilities/step.css';
|
|
15
|
+
@import './utilities/filter-bar.css';
|
|
16
|
+
@import './utilities/tabs.css';
|
|
15
17
|
@import './utilities/animations.css';
|
|
16
18
|
@import './utilities/glass.css';
|
|
17
19
|
@import './utilities/marquee.css';
|