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.
Files changed (44) hide show
  1. package/package.json +1 -1
  2. package/registry/components/primitives/icon/icon.tsx +4 -6
  3. package/registry/components/primitives/modal/modal.module.css +5 -0
  4. package/registry/components/primitives/modal/modal.tsx +32 -34
  5. package/registry/components/primitives/popover/popover-context.ts +33 -29
  6. package/registry/components/primitives/popover/popover.module.css +4 -2
  7. package/registry/components/primitives/popover/popover.tsx +150 -37
  8. package/registry/components/ui/accordion/accordion.module.css +52 -0
  9. package/registry/components/ui/accordion/accordion.tsx +97 -25
  10. package/registry/components/ui/avatar/avatar.tsx +2 -2
  11. package/registry/components/ui/breadcrumbs/breadcrumbs.module.css +9 -9
  12. package/registry/components/ui/button/button.module.css +43 -5
  13. package/registry/components/ui/button/button.tsx +2 -1
  14. package/registry/components/ui/copy-button/copy-button.module.css +2 -0
  15. package/registry/components/ui/copy-button/copy-button.tsx +5 -0
  16. package/registry/components/ui/data-table/data-table.module.css +786 -710
  17. package/registry/components/ui/data-table/data-table.tsx +1238 -1211
  18. package/registry/components/ui/data-table/filter-modal.tsx +86 -0
  19. package/registry/components/ui/data-table/filters.tsx +21 -0
  20. package/registry/components/ui/data-table/pagination-bar.tsx +31 -17
  21. package/registry/components/ui/data-table/types.ts +19 -1
  22. package/registry/components/ui/data-table/useDataTable.tsx +2 -2
  23. package/registry/components/ui/data-table/utils.ts +30 -4
  24. package/registry/components/ui/input-fields/common/input-field-common.module.css +8 -2
  25. package/registry/components/ui/input-fields/common/picker-popover-common.module.css +4 -0
  26. package/registry/components/ui/input-fields/custom-select/custom-select.module.css +118 -76
  27. package/registry/components/ui/input-fields/custom-select/custom-select.tsx +552 -484
  28. package/registry/components/ui/input-fields/date-picker/date-picker.tsx +936 -933
  29. package/registry/components/ui/input-fields/date-picker/types.ts +2 -0
  30. package/registry/components/ui/input-fields/emoji-picker/emoji-picker.tsx +8 -0
  31. package/registry/components/ui/input-fields/input/input.tsx +348 -348
  32. package/registry/components/ui/input-fields/media-input/media-input.tsx +3 -1
  33. package/registry/components/ui/input-fields/media-input/types.ts +8 -2
  34. package/registry/components/ui/input-fields/media-input/useMediaUpload.tsx +5 -5
  35. package/registry/components/ui/input-fields/media-input/utils.ts +18 -5
  36. package/registry/components/ui/scroll-rail/scroll-rail.module.css +104 -0
  37. package/registry/components/ui/scroll-rail/scroll-rail.tsx +159 -0
  38. package/registry/components/ui/tabs/tabs.module.css +8 -12
  39. package/registry/components/ui/tabs/tabs.tsx +94 -50
  40. package/registry/components/ui/tooltip/tooltip.module.css +8 -3
  41. package/registry/components/ui/tooltip/tooltip.tsx +16 -3
  42. package/registry/hooks/useMediaQuery.ts +38 -0
  43. package/registry/lib/string.ts +3 -3
  44. package/registry/registry.json +54 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aplance-ui",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "The aplance-ui CLI - scaffold the design system and copy components into your project as source.",
5
5
  "keywords": [
6
6
  "react",
@@ -31,12 +31,10 @@ export const Icon: FC<IconProps> = ({
31
31
  className={clsx(
32
32
  s.wrapper,
33
33
  s[size],
34
- {
35
- [s.disabled]: disabled,
36
- [s.clickable]: !!onClick || clickable,
37
- [s.rotate]: rotate,
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
- nestedTriggers.push(child);
218
- } else {
219
- dialogChildren.push(child);
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: setShouldRender(true) → node mounts → useLayoutEffect below fires → isVisible=true → open transition
250
- // Close: setIsVisible(false) immediately → close transition plays → setTimeout unmounts after duration
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
- setShouldRender(true);
254
- } else {
255
- setIsVisible(false);
256
- const t = setTimeout(() => {
257
- setShouldRender(false);
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 after the node is painted for the first time.
265
- // Force-reads offsetHeight to flush the browser's style recalc so it
266
- // registers the closed CSS state before we flip to open - same trick
267
- // Popover uses with isMeasured + ResizeObserver.
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(isOpen);
270
+ setIsVisible(true);
272
271
  }
273
- // eslint-disable-next-line react-hooks/exhaustive-deps
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 (focusable.length === 0) {
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
- {childrenSnapshot.current}
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="ghost"
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
- hoverTimeoutRef: RefObject<ReturnType<typeof setTimeout> | null>;
17
- renderOnPortal: boolean;
18
- mounted: boolean;
19
- };
20
-
21
- export const PopoverContext = createContext<PopoverContextType | undefined>(
22
- undefined,
23
- );
24
-
25
- export const usePopover = () => {
26
- const ctx = useContext(PopoverContext);
27
- if (!ctx) throw new Error("usePopover must be used within Popover");
28
- return ctx;
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
- min-width: fit-content;
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 (position) {
181
+ switch (nextPosition) {
106
182
  case "top":
107
- top = triggerRect.top - menuRect.height - offset;
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 - menuRect.width - offset;
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 (position) {
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 - menuRect.width;
127
- else left = triggerRect.left + (triggerRect.width - menuRect.width) / 2;
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 - menuRect.height;
208
+ else if (align === "end") top = triggerRect.bottom - measuredHeight;
133
209
  else
134
- top = triggerRect.top + triggerRect.height / 2 - menuRect.height / 2;
210
+ top = triggerRect.top + triggerRect.height / 2 - measuredHeight / 2;
135
211
  break;
136
212
  }
137
213
 
138
- // Collision detection
139
- const PADDING = 8;
140
- top = Math.max(
141
- PADDING,
142
- Math.min(top, innerHeight - menuRect.height - PADDING),
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
- useLayoutEffect(() => {
174
- if (shouldRender && menuRef.current) {
175
- calculatePosition();
176
-
177
- const observer = new ResizeObserver(() => calculatePosition());
178
- observer.observe(menuRef.current);
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
- position,
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 menuStyles: CSSProperties = {
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
- ...(matchTriggerWidth && { maxWidth: triggerWidth }),
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(position, isMeasured, isOpen),
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;