aplance-ui 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/registry/components/primitives/icon/icon.tsx +4 -6
- package/registry/components/primitives/modal/modal.module.css +5 -0
- package/registry/components/primitives/modal/modal.tsx +32 -34
- package/registry/components/primitives/popover/popover-context.ts +33 -29
- package/registry/components/primitives/popover/popover.module.css +4 -2
- package/registry/components/primitives/popover/popover.tsx +150 -37
- package/registry/components/ui/accordion/accordion.module.css +52 -0
- package/registry/components/ui/accordion/accordion.tsx +97 -25
- package/registry/components/ui/avatar/avatar.tsx +2 -2
- package/registry/components/ui/breadcrumbs/breadcrumbs.module.css +9 -9
- package/registry/components/ui/button/button.module.css +43 -5
- package/registry/components/ui/button/button.tsx +2 -1
- package/registry/components/ui/copy-button/copy-button.module.css +2 -0
- package/registry/components/ui/copy-button/copy-button.tsx +5 -0
- package/registry/components/ui/data-table/data-table.module.css +786 -710
- package/registry/components/ui/data-table/data-table.tsx +1238 -1211
- package/registry/components/ui/data-table/filter-modal.tsx +86 -0
- package/registry/components/ui/data-table/filters.tsx +21 -0
- package/registry/components/ui/data-table/pagination-bar.tsx +31 -17
- package/registry/components/ui/data-table/types.ts +19 -1
- package/registry/components/ui/data-table/useDataTable.tsx +2 -2
- package/registry/components/ui/data-table/utils.ts +30 -4
- package/registry/components/ui/input-fields/common/input-field-common.module.css +8 -2
- package/registry/components/ui/input-fields/common/picker-popover-common.module.css +4 -0
- package/registry/components/ui/input-fields/custom-select/custom-select.module.css +118 -76
- package/registry/components/ui/input-fields/custom-select/custom-select.tsx +552 -484
- package/registry/components/ui/input-fields/date-picker/date-picker.tsx +936 -933
- package/registry/components/ui/input-fields/date-picker/types.ts +2 -0
- package/registry/components/ui/input-fields/emoji-picker/emoji-picker.tsx +8 -0
- package/registry/components/ui/input-fields/input/input.tsx +348 -348
- package/registry/components/ui/input-fields/media-input/media-input.tsx +3 -1
- package/registry/components/ui/input-fields/media-input/types.ts +8 -2
- package/registry/components/ui/input-fields/media-input/useMediaUpload.tsx +5 -5
- package/registry/components/ui/input-fields/media-input/utils.ts +18 -5
- package/registry/components/ui/scroll-rail/scroll-rail.module.css +104 -0
- package/registry/components/ui/scroll-rail/scroll-rail.tsx +159 -0
- package/registry/components/ui/tabs/tabs.module.css +8 -12
- package/registry/components/ui/tabs/tabs.tsx +94 -50
- package/registry/components/ui/tooltip/tooltip.module.css +8 -3
- package/registry/components/ui/tooltip/tooltip.tsx +16 -3
- package/registry/hooks/useMediaQuery.ts +38 -0
- package/registry/lib/string.ts +3 -3
- package/registry/registry.json +54 -4
package/package.json
CHANGED
|
@@ -31,12 +31,10 @@ export const Icon: FC<IconProps> = ({
|
|
|
31
31
|
className={clsx(
|
|
32
32
|
s.wrapper,
|
|
33
33
|
s[size],
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
[s.rotate_sub]: rotateSub,
|
|
39
|
-
},
|
|
34
|
+
disabled && s.disabled,
|
|
35
|
+
(!!onClick || clickable) && s.clickable,
|
|
36
|
+
rotate && s.rotate,
|
|
37
|
+
rotateSub && s.rotate_sub,
|
|
40
38
|
className,
|
|
41
39
|
)}
|
|
42
40
|
style={style}
|
|
@@ -21,6 +21,11 @@
|
|
|
21
21
|
opacity: 0;
|
|
22
22
|
transition: opacity var(--modal-animation-duration) var(--ease-in-out);
|
|
23
23
|
|
|
24
|
+
/* Closing, it fades out without taking clicks: the page under it answers. */
|
|
25
|
+
&[data-state='closed'] {
|
|
26
|
+
pointer-events: none;
|
|
27
|
+
}
|
|
28
|
+
|
|
24
29
|
&[data-state='open'] {
|
|
25
30
|
opacity: 1;
|
|
26
31
|
}
|
|
@@ -207,23 +207,23 @@ export const Modal: FC<ModalProps> = ({
|
|
|
207
207
|
const contentRef = useRef<HTMLDivElement>(null);
|
|
208
208
|
const modalId = useId();
|
|
209
209
|
|
|
210
|
+
// Freeze dialog content during close animation so conditional content
|
|
211
|
+
// (e.g. {editAgent && <form>}) doesn't collapse before the animation ends:
|
|
212
|
+
// open, the dialog follows its children; closed, it keeps the last ones.
|
|
213
|
+
const [openChildren, setOpenChildren] = useState(children);
|
|
214
|
+
if (isOpen && openChildren !== children) setOpenChildren(children);
|
|
215
|
+
|
|
210
216
|
// Split children: <ModalTrigger> siblings render outside the dialog so they
|
|
211
217
|
// stay visible while closed; everything else is dialog content. Order is
|
|
212
218
|
// preserved within each bucket, which is all consumers care about.
|
|
213
219
|
const nestedTriggers: ReactNode[] = [];
|
|
214
|
-
const dialogChildren: ReactNode[] = [];
|
|
215
220
|
Children.forEach(children, (child) => {
|
|
216
|
-
if (isValidElement(child) && child.type === ModalTrigger)
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
+
if (isValidElement(child) && child.type === ModalTrigger) nestedTriggers.push(child);
|
|
222
|
+
});
|
|
223
|
+
const dialogChildren: ReactNode[] = [];
|
|
224
|
+
Children.forEach(isOpen ? children : openChildren, (child) => {
|
|
225
|
+
if (!(isValidElement(child) && child.type === ModalTrigger)) dialogChildren.push(child);
|
|
221
226
|
});
|
|
222
|
-
|
|
223
|
-
// Freeze dialog content during close animation so conditional content
|
|
224
|
-
// (e.g. {editAgent && <form>}) doesn't collapse before the animation ends.
|
|
225
|
-
const childrenSnapshot = useRef<ReactNode>(dialogChildren);
|
|
226
|
-
if (isOpen) childrenSnapshot.current = dialogChildren;
|
|
227
227
|
|
|
228
228
|
// Stash callbacks in refs so identity changes on the parent don't retrigger
|
|
229
229
|
// the open/close effect (which would leak timers and re-fire onOpen).
|
|
@@ -246,32 +246,30 @@ export const Modal: FC<ModalProps> = ({
|
|
|
246
246
|
const close = useCallback(() => setIsOpen(false), [setIsOpen]);
|
|
247
247
|
const toggle = useCallback(() => setIsOpen(!isOpen), [isOpen, setIsOpen]);
|
|
248
248
|
|
|
249
|
-
// Open:
|
|
250
|
-
// Close:
|
|
249
|
+
// Open: the node mounts in this render → useLayoutEffect below fires → isVisible=true → open transition
|
|
250
|
+
// Close: isVisible=false in this render → close transition plays → setTimeout unmounts after duration
|
|
251
|
+
if (isOpen && !shouldRender) setShouldRender(true);
|
|
252
|
+
if (!isOpen && isVisible) setIsVisible(false);
|
|
251
253
|
useEffect(() => {
|
|
252
|
-
if (isOpen)
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
onCloseRef.current?.();
|
|
259
|
-
}, animationDuration);
|
|
260
|
-
return () => clearTimeout(t);
|
|
261
|
-
}
|
|
254
|
+
if (isOpen) return undefined;
|
|
255
|
+
const t = setTimeout(() => {
|
|
256
|
+
setShouldRender(false);
|
|
257
|
+
onCloseRef.current?.();
|
|
258
|
+
}, animationDuration);
|
|
259
|
+
return () => clearTimeout(t);
|
|
262
260
|
}, [isOpen, animationDuration]);
|
|
263
261
|
|
|
264
|
-
// Fires synchronously
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
262
|
+
// Fires synchronously once the node is painted, and again when it is opened
|
|
263
|
+
// while still mounted (reopened during its close transition, which would
|
|
264
|
+
// otherwise stay closed). Force-reads offsetHeight to flush the browser's
|
|
265
|
+
// style recalc so it registers the closed CSS state before we flip to open -
|
|
266
|
+
// same trick Popover uses with isMeasured + ResizeObserver.
|
|
268
267
|
useLayoutEffect(() => {
|
|
269
|
-
if (shouldRender && mounted && contentRef.current) {
|
|
268
|
+
if (shouldRender && mounted && isOpen && contentRef.current) {
|
|
270
269
|
void contentRef.current.offsetHeight; // flush reflow
|
|
271
|
-
setIsVisible(
|
|
270
|
+
setIsVisible(true);
|
|
272
271
|
}
|
|
273
|
-
|
|
274
|
-
}, [shouldRender, mounted]); // only run when node mounts or mounted state changes
|
|
272
|
+
}, [shouldRender, mounted, isOpen]);
|
|
275
273
|
|
|
276
274
|
useEffect(() => {
|
|
277
275
|
if (isOpen) onOpenRef.current?.();
|
|
@@ -313,7 +311,7 @@ export const Modal: FC<ModalProps> = ({
|
|
|
313
311
|
|
|
314
312
|
const trap = (e: globalThis.KeyboardEvent) => {
|
|
315
313
|
if (e.key !== "Tab") return;
|
|
316
|
-
if (
|
|
314
|
+
if (!first || !last) {
|
|
317
315
|
e.preventDefault();
|
|
318
316
|
return;
|
|
319
317
|
}
|
|
@@ -388,7 +386,7 @@ export const Modal: FC<ModalProps> = ({
|
|
|
388
386
|
style={style}
|
|
389
387
|
>
|
|
390
388
|
{/* children render ONLY here - never outside the dialog */}
|
|
391
|
-
{
|
|
389
|
+
{dialogChildren}
|
|
392
390
|
</div>
|
|
393
391
|
</div>
|
|
394
392
|
) : null;
|
|
@@ -547,7 +545,7 @@ export const ModalHeader: FC<ComponentProps<"div">> = ({
|
|
|
547
545
|
{showCloseButton && (
|
|
548
546
|
<Button
|
|
549
547
|
as="button"
|
|
550
|
-
variant="
|
|
548
|
+
variant="bordered"
|
|
551
549
|
layout="box"
|
|
552
550
|
size="sm"
|
|
553
551
|
className={s.close_button}
|
|
@@ -1,29 +1,33 @@
|
|
|
1
|
-
import { createContext, useContext, type RefObject } from "react";
|
|
2
|
-
import type { Coords, Position, Trigger } from "./popover";
|
|
3
|
-
|
|
4
|
-
export type PopoverContextType = {
|
|
5
|
-
isOpen: boolean;
|
|
6
|
-
setIsOpen: (open: boolean) => void;
|
|
7
|
-
popoverRef: RefObject<HTMLDivElement | null>;
|
|
8
|
-
menuRef: RefObject<HTMLDivElement | null>;
|
|
9
|
-
coords: Coords;
|
|
10
|
-
isMeasured: boolean;
|
|
11
|
-
shouldRender: boolean;
|
|
12
|
-
animationDuration?: number;
|
|
13
|
-
triggerWidth: number;
|
|
14
|
-
trigger: Trigger;
|
|
15
|
-
position: Position;
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
export const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
1
|
+
import { createContext, useContext, type RefObject } from "react";
|
|
2
|
+
import type { Coords, Position, Trigger } from "./popover";
|
|
3
|
+
|
|
4
|
+
export type PopoverContextType = {
|
|
5
|
+
isOpen: boolean;
|
|
6
|
+
setIsOpen: (open: boolean) => void;
|
|
7
|
+
popoverRef: RefObject<HTMLDivElement | null>;
|
|
8
|
+
menuRef: RefObject<HTMLDivElement | null>;
|
|
9
|
+
coords: Coords;
|
|
10
|
+
isMeasured: boolean;
|
|
11
|
+
shouldRender: boolean;
|
|
12
|
+
animationDuration?: number;
|
|
13
|
+
triggerWidth: number;
|
|
14
|
+
trigger: Trigger;
|
|
15
|
+
position: Position;
|
|
16
|
+
resolvedPosition: Position;
|
|
17
|
+
availableWidth?: number;
|
|
18
|
+
availableHeight?: number;
|
|
19
|
+
autoPosition: boolean;
|
|
20
|
+
hoverTimeoutRef: RefObject<ReturnType<typeof setTimeout> | null>;
|
|
21
|
+
renderOnPortal: boolean;
|
|
22
|
+
mounted: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const PopoverContext = createContext<PopoverContextType | undefined>(
|
|
26
|
+
undefined,
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
export const usePopover = () => {
|
|
30
|
+
const ctx = useContext(PopoverContext);
|
|
31
|
+
if (!ctx) throw new Error("usePopover must be used within Popover");
|
|
32
|
+
return ctx;
|
|
33
|
+
};
|
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
justify-content: center;
|
|
8
8
|
|
|
9
9
|
.trigger {
|
|
10
|
-
width: 100%;
|
|
11
|
-
|
|
10
|
+
width: 100%;
|
|
11
|
+
/* At least as wide as its content, unless the host sets the variable (a
|
|
12
|
+
form field sets 0, so a long value truncates). */
|
|
13
|
+
min-width: var(--popover-trigger-min-width, fit-content);
|
|
12
14
|
display: flex;
|
|
13
15
|
align-items: center;
|
|
14
16
|
|
|
@@ -26,6 +26,24 @@ export type Coords = {
|
|
|
26
26
|
left: number;
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
+
const DEFAULT_COLLISION_PADDING = 8;
|
|
30
|
+
|
|
31
|
+
const oppositePosition: Record<Position, Position> = {
|
|
32
|
+
top: "bottom",
|
|
33
|
+
bottom: "top",
|
|
34
|
+
left: "right",
|
|
35
|
+
right: "left",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const useIsomorphicLayoutEffect =
|
|
39
|
+
typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
40
|
+
|
|
41
|
+
const isVerticalPosition = (position: Position): boolean =>
|
|
42
|
+
position === "top" || position === "bottom";
|
|
43
|
+
|
|
44
|
+
const clamp = (value: number, min: number, max: number): number =>
|
|
45
|
+
Math.min(Math.max(value, min), Math.max(min, max));
|
|
46
|
+
|
|
29
47
|
// Popover Component
|
|
30
48
|
export type PopoverProps = {
|
|
31
49
|
children?: ReactNode;
|
|
@@ -44,6 +62,10 @@ export type PopoverProps = {
|
|
|
44
62
|
trigger?: Trigger;
|
|
45
63
|
// Useful when using in isolated environments like Iframe
|
|
46
64
|
renderOnPortal?: boolean;
|
|
65
|
+
/** Auto-position the menu so it stays within the viewport. Default: true. */
|
|
66
|
+
autoPosition?: boolean;
|
|
67
|
+
/** Viewport padding used by auto positioning. Default: 8. */
|
|
68
|
+
collisionPadding?: number;
|
|
47
69
|
};
|
|
48
70
|
|
|
49
71
|
export const Popover: FC<PopoverProps> = ({
|
|
@@ -62,6 +84,8 @@ export const Popover: FC<PopoverProps> = ({
|
|
|
62
84
|
onClose,
|
|
63
85
|
trigger = "click",
|
|
64
86
|
renderOnPortal = true,
|
|
87
|
+
autoPosition,
|
|
88
|
+
collisionPadding = DEFAULT_COLLISION_PADDING,
|
|
65
89
|
}) => {
|
|
66
90
|
const readLocationKey = () =>
|
|
67
91
|
`${window.location.pathname}${window.location.search}${window.location.hash}`;
|
|
@@ -84,11 +108,18 @@ export const Popover: FC<PopoverProps> = ({
|
|
|
84
108
|
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
85
109
|
|
|
86
110
|
const mounted = useHasMounted();
|
|
111
|
+
const shouldAutoPosition = autoPosition ?? true;
|
|
87
112
|
|
|
88
113
|
const [coords, setCoords] = useState<Coords>({ top: 0, left: 0 });
|
|
89
114
|
const [shouldRender, setShouldRender] = useState(false);
|
|
90
115
|
const [isMeasured, setIsMeasured] = useState(false);
|
|
91
116
|
const [triggerWidth, setTriggerWidth] = useState(0);
|
|
117
|
+
const [resolvedPosition, setResolvedPosition] =
|
|
118
|
+
useState<Position>(position);
|
|
119
|
+
const [availableSize, setAvailableSize] = useState<{
|
|
120
|
+
width?: number;
|
|
121
|
+
height?: number;
|
|
122
|
+
}>({});
|
|
92
123
|
|
|
93
124
|
// Calculate Menu Position
|
|
94
125
|
const calculatePosition = useCallback(() => {
|
|
@@ -97,21 +128,66 @@ export const Popover: FC<PopoverProps> = ({
|
|
|
97
128
|
const triggerRect = popoverRef.current.getBoundingClientRect();
|
|
98
129
|
const menuRect = menuRef.current.getBoundingClientRect();
|
|
99
130
|
const { innerWidth, innerHeight } = window;
|
|
131
|
+
const padding = Number.isFinite(collisionPadding)
|
|
132
|
+
? Math.max(0, collisionPadding)
|
|
133
|
+
: DEFAULT_COLLISION_PADDING;
|
|
100
134
|
|
|
101
135
|
let top = 0;
|
|
102
136
|
let left = 0;
|
|
137
|
+
let nextPosition = position;
|
|
138
|
+
|
|
139
|
+
const availableByPosition: Record<Position, number> = {
|
|
140
|
+
top: Math.max(0, triggerRect.top - padding - offset),
|
|
141
|
+
bottom: Math.max(0, innerHeight - triggerRect.bottom - padding - offset),
|
|
142
|
+
left: Math.max(0, triggerRect.left - padding - offset),
|
|
143
|
+
right: Math.max(0, innerWidth - triggerRect.right - padding - offset),
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
if (shouldAutoPosition) {
|
|
147
|
+
const preferredSpace = availableByPosition[position];
|
|
148
|
+
const opposite = oppositePosition[position];
|
|
149
|
+
const oppositeSpace = availableByPosition[opposite];
|
|
150
|
+
const requiredSpace = isVerticalPosition(position)
|
|
151
|
+
? menuRect.height
|
|
152
|
+
: menuRect.width;
|
|
153
|
+
|
|
154
|
+
if (preferredSpace < requiredSpace && oppositeSpace > preferredSpace) {
|
|
155
|
+
nextPosition = opposite;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const viewportWidth = Math.max(0, innerWidth - padding * 2);
|
|
160
|
+
const viewportHeight = Math.max(0, innerHeight - padding * 2);
|
|
161
|
+
const availableWidth = shouldAutoPosition
|
|
162
|
+
? isVerticalPosition(nextPosition)
|
|
163
|
+
? viewportWidth
|
|
164
|
+
: availableByPosition[nextPosition]
|
|
165
|
+
: undefined;
|
|
166
|
+
const availableHeight = shouldAutoPosition
|
|
167
|
+
? isVerticalPosition(nextPosition)
|
|
168
|
+
? availableByPosition[nextPosition]
|
|
169
|
+
: viewportHeight
|
|
170
|
+
: undefined;
|
|
171
|
+
const measuredWidth =
|
|
172
|
+
availableWidth === undefined
|
|
173
|
+
? menuRect.width
|
|
174
|
+
: Math.min(menuRect.width, availableWidth);
|
|
175
|
+
const measuredHeight =
|
|
176
|
+
availableHeight === undefined
|
|
177
|
+
? menuRect.height
|
|
178
|
+
: Math.min(menuRect.height, availableHeight);
|
|
103
179
|
|
|
104
180
|
// Main axis positioning (offset applies here)
|
|
105
|
-
switch (
|
|
181
|
+
switch (nextPosition) {
|
|
106
182
|
case "top":
|
|
107
|
-
top = triggerRect.top -
|
|
183
|
+
top = triggerRect.top - measuredHeight - offset;
|
|
108
184
|
|
|
109
185
|
break;
|
|
110
186
|
case "bottom":
|
|
111
187
|
top = triggerRect.bottom + offset;
|
|
112
188
|
break;
|
|
113
189
|
case "left":
|
|
114
|
-
left = triggerRect.left -
|
|
190
|
+
left = triggerRect.left - measuredWidth - offset;
|
|
115
191
|
break;
|
|
116
192
|
case "right":
|
|
117
193
|
left = triggerRect.right + offset;
|
|
@@ -119,48 +195,48 @@ export const Popover: FC<PopoverProps> = ({
|
|
|
119
195
|
}
|
|
120
196
|
|
|
121
197
|
// Cross axis positioning (align applies here)
|
|
122
|
-
switch (
|
|
198
|
+
switch (nextPosition) {
|
|
123
199
|
case "top":
|
|
124
200
|
case "bottom":
|
|
125
201
|
if (align === "start") left = triggerRect.left;
|
|
126
|
-
else if (align === "end") left = triggerRect.right -
|
|
127
|
-
else left = triggerRect.left + (triggerRect.width -
|
|
202
|
+
else if (align === "end") left = triggerRect.right - measuredWidth;
|
|
203
|
+
else left = triggerRect.left + (triggerRect.width - measuredWidth) / 2;
|
|
128
204
|
break;
|
|
129
205
|
case "left":
|
|
130
206
|
case "right":
|
|
131
207
|
if (align === "start") top = triggerRect.top;
|
|
132
|
-
else if (align === "end") top = triggerRect.bottom -
|
|
208
|
+
else if (align === "end") top = triggerRect.bottom - measuredHeight;
|
|
133
209
|
else
|
|
134
|
-
top = triggerRect.top + triggerRect.height / 2 -
|
|
210
|
+
top = triggerRect.top + triggerRect.height / 2 - measuredHeight / 2;
|
|
135
211
|
break;
|
|
136
212
|
}
|
|
137
213
|
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
);
|
|
144
|
-
left = Math.max(
|
|
145
|
-
PADDING,
|
|
146
|
-
Math.min(left, innerWidth - menuRect.width - PADDING),
|
|
147
|
-
);
|
|
214
|
+
// Keep the resolved menu position inside the viewport padding.
|
|
215
|
+
if (shouldAutoPosition) {
|
|
216
|
+
top = clamp(top, padding, innerHeight - measuredHeight - padding);
|
|
217
|
+
left = clamp(left, padding, innerWidth - measuredWidth - padding);
|
|
218
|
+
}
|
|
148
219
|
|
|
149
220
|
setCoords({ top, left });
|
|
150
221
|
setTriggerWidth(triggerRect.width);
|
|
222
|
+
setResolvedPosition(nextPosition);
|
|
223
|
+
setAvailableSize({
|
|
224
|
+
width: availableWidth,
|
|
225
|
+
height: availableHeight,
|
|
226
|
+
});
|
|
151
227
|
setIsMeasured(true);
|
|
152
|
-
}, [position, align, offset]);
|
|
228
|
+
}, [position, align, offset, shouldAutoPosition, collisionPadding]);
|
|
153
229
|
|
|
154
230
|
// Open / Close Handling
|
|
155
231
|
// Visibility (opacity / pointer-events) is derived from `isOpen && isMeasured`
|
|
156
232
|
// in PopoverMenu, so we DON'T reset isMeasured here on close - that would lose
|
|
157
233
|
// the position cache and break re-opens that interrupt the exit animation.
|
|
158
234
|
// isMeasured is reset only when the menu actually unmounts (cleanup below).
|
|
235
|
+
// Open mounts the menu in this render; close unmounts it once the exit
|
|
236
|
+
// animation has played.
|
|
237
|
+
if (isOpen && !shouldRender) setShouldRender(true);
|
|
159
238
|
useEffect(() => {
|
|
160
|
-
if (isOpen)
|
|
161
|
-
setShouldRender(true);
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
239
|
+
if (isOpen) return;
|
|
164
240
|
const timer = setTimeout(() => {
|
|
165
241
|
setShouldRender(false);
|
|
166
242
|
if (onClose) onClose();
|
|
@@ -170,17 +246,21 @@ export const Popover: FC<PopoverProps> = ({
|
|
|
170
246
|
|
|
171
247
|
// Measure after menu is rendered. Cleanup resets isMeasured so the next mount
|
|
172
248
|
// re-runs the entry animation from a known starting state.
|
|
173
|
-
|
|
174
|
-
if (shouldRender
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
return () => {
|
|
180
|
-
observer.disconnect();
|
|
181
|
-
setIsMeasured(false);
|
|
182
|
-
};
|
|
249
|
+
useIsomorphicLayoutEffect(() => {
|
|
250
|
+
if (!shouldRender || !menuRef.current) return;
|
|
251
|
+
calculatePosition();
|
|
252
|
+
|
|
253
|
+
if (typeof ResizeObserver === "undefined") {
|
|
254
|
+
return () => setIsMeasured(false);
|
|
183
255
|
}
|
|
256
|
+
|
|
257
|
+
const observer = new ResizeObserver(() => calculatePosition());
|
|
258
|
+
observer.observe(menuRef.current);
|
|
259
|
+
if (popoverRef.current) observer.observe(popoverRef.current);
|
|
260
|
+
return () => {
|
|
261
|
+
observer.disconnect();
|
|
262
|
+
setIsMeasured(false);
|
|
263
|
+
};
|
|
184
264
|
}, [shouldRender, mounted, calculatePosition]);
|
|
185
265
|
|
|
186
266
|
// Clear any pending hover-close timer when the Popover unmounts so the
|
|
@@ -268,6 +348,10 @@ export const Popover: FC<PopoverProps> = ({
|
|
|
268
348
|
hoverTimeoutRef,
|
|
269
349
|
renderOnPortal,
|
|
270
350
|
position,
|
|
351
|
+
resolvedPosition,
|
|
352
|
+
availableWidth: availableSize.width,
|
|
353
|
+
availableHeight: availableSize.height,
|
|
354
|
+
autoPosition: shouldAutoPosition,
|
|
271
355
|
mounted,
|
|
272
356
|
}}
|
|
273
357
|
>
|
|
@@ -372,6 +456,11 @@ export type PopoverMenuProps = {
|
|
|
372
456
|
matchTriggerWidth?: boolean;
|
|
373
457
|
};
|
|
374
458
|
|
|
459
|
+
type PopoverMenuStyle = CSSProperties & {
|
|
460
|
+
"--popover-available-width"?: string;
|
|
461
|
+
"--popover-available-height"?: string;
|
|
462
|
+
};
|
|
463
|
+
|
|
375
464
|
export const PopoverMenu: FC<PopoverMenuProps> = ({
|
|
376
465
|
children,
|
|
377
466
|
className,
|
|
@@ -388,10 +477,13 @@ export const PopoverMenu: FC<PopoverMenuProps> = ({
|
|
|
388
477
|
animationDuration,
|
|
389
478
|
trigger,
|
|
390
479
|
setIsOpen,
|
|
391
|
-
|
|
480
|
+
resolvedPosition,
|
|
392
481
|
hoverTimeoutRef,
|
|
393
482
|
renderOnPortal,
|
|
394
483
|
mounted,
|
|
484
|
+
availableWidth,
|
|
485
|
+
availableHeight,
|
|
486
|
+
autoPosition,
|
|
395
487
|
} = usePopover();
|
|
396
488
|
|
|
397
489
|
const handleMouseEnter = () => {
|
|
@@ -439,14 +531,34 @@ export const PopoverMenu: FC<PopoverMenuProps> = ({
|
|
|
439
531
|
return transforms[position];
|
|
440
532
|
};
|
|
441
533
|
|
|
442
|
-
const
|
|
534
|
+
const constrainedMaxWidth =
|
|
535
|
+
autoPosition && availableWidth !== undefined
|
|
536
|
+
? matchTriggerWidth && triggerWidth > 0
|
|
537
|
+
? Math.min(availableWidth, triggerWidth)
|
|
538
|
+
: availableWidth
|
|
539
|
+
: matchTriggerWidth && triggerWidth > 0
|
|
540
|
+
? triggerWidth
|
|
541
|
+
: undefined;
|
|
542
|
+
|
|
543
|
+
const menuStyles: PopoverMenuStyle = {
|
|
443
544
|
position: "fixed",
|
|
444
545
|
top: coords.top,
|
|
445
546
|
left: coords.left,
|
|
446
547
|
zIndex: "var(--z-index-popover)",
|
|
447
|
-
...(
|
|
548
|
+
...(constrainedMaxWidth !== undefined && { maxWidth: constrainedMaxWidth }),
|
|
549
|
+
...(autoPosition &&
|
|
550
|
+
availableHeight !== undefined && { maxHeight: availableHeight }),
|
|
551
|
+
...(autoPosition &&
|
|
552
|
+
availableWidth !== undefined && {
|
|
553
|
+
"--popover-available-width": `${availableWidth}px`,
|
|
554
|
+
}),
|
|
555
|
+
...(autoPosition &&
|
|
556
|
+
availableHeight !== undefined && {
|
|
557
|
+
"--popover-available-height": `${availableHeight}px`,
|
|
558
|
+
}),
|
|
559
|
+
...(autoPosition && { overflow: "auto" }),
|
|
448
560
|
opacity: isVisible ? 1 : 0,
|
|
449
|
-
transform: getTransform(
|
|
561
|
+
transform: getTransform(resolvedPosition, isMeasured, isOpen),
|
|
450
562
|
transition: animationDuration
|
|
451
563
|
? `opacity ${animationDuration}ms cubic-bezier(0.4, 0, 0.2, 1), transform ${animationDuration}ms cubic-bezier(0.4, 0, 0.2, 1)`
|
|
452
564
|
: "opacity var(--transition-base), transform var(--transition-base)",
|
|
@@ -464,6 +576,7 @@ export const PopoverMenu: FC<PopoverMenuProps> = ({
|
|
|
464
576
|
// `useFocusOutside({ ignoreSelector })` so clicking a menu option
|
|
465
577
|
// doesn't dismiss the host.
|
|
466
578
|
data-popover-menu=""
|
|
579
|
+
data-position={resolvedPosition}
|
|
467
580
|
onMouseEnter={handleMouseEnter}
|
|
468
581
|
onMouseLeave={handleMouseLeave}
|
|
469
582
|
style={menuStyles}
|
|
@@ -110,6 +110,58 @@
|
|
|
110
110
|
padding: var(--spacing-5) var(--spacing-6);
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
/* A heading with controls beside its button (an item's actions, a toggle
|
|
114
|
+
button): the row takes the heading's padding and its shades, and the
|
|
115
|
+
button inside it takes what is left. */
|
|
116
|
+
.header {
|
|
117
|
+
display: flex;
|
|
118
|
+
align-items: center;
|
|
119
|
+
gap: var(--spacing-3);
|
|
120
|
+
width: 100%;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.header_trigger {
|
|
124
|
+
flex: 1;
|
|
125
|
+
min-width: 0;
|
|
126
|
+
padding: 0;
|
|
127
|
+
background: transparent;
|
|
128
|
+
border-radius: var(--radius-sm);
|
|
129
|
+
|
|
130
|
+
&:focus-visible {
|
|
131
|
+
box-shadow: var(--shadow-ring);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.header_bordered,
|
|
136
|
+
.header_minimal {
|
|
137
|
+
&:has(> .header_trigger:hover:not(:disabled)) {
|
|
138
|
+
background: var(--color-muted);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.header_minimal {
|
|
143
|
+
border-radius: inherit;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.actions {
|
|
147
|
+
flex-shrink: 0;
|
|
148
|
+
display: flex;
|
|
149
|
+
align-items: center;
|
|
150
|
+
gap: var(--spacing-2);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.toggle {
|
|
154
|
+
flex-shrink: 0;
|
|
155
|
+
|
|
156
|
+
& svg {
|
|
157
|
+
transition: transform var(--transition-base);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
&.toggle_open svg {
|
|
161
|
+
transform: rotate(var(--accordion-icon-rotation, 180deg));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
113
165
|
.title {
|
|
114
166
|
flex: 1;
|
|
115
167
|
min-width: 0;
|