aplance-ui 1.3.0 → 1.4.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aplance-ui",
3
- "version": "1.3.0",
3
+ "version": "1.4.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",
@@ -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
+ };
@@ -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,37 +195,37 @@ 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`
@@ -170,12 +246,17 @@ 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(() => {
249
+ useIsomorphicLayoutEffect(() => {
174
250
  if (shouldRender && menuRef.current) {
175
251
  calculatePosition();
176
252
 
253
+ if (typeof ResizeObserver === "undefined") {
254
+ return () => setIsMeasured(false);
255
+ }
256
+
177
257
  const observer = new ResizeObserver(() => calculatePosition());
178
258
  observer.observe(menuRef.current);
259
+ if (popoverRef.current) observer.observe(popoverRef.current);
179
260
  return () => {
180
261
  observer.disconnect();
181
262
  setIsMeasured(false);
@@ -268,6 +349,10 @@ export const Popover: FC<PopoverProps> = ({
268
349
  hoverTimeoutRef,
269
350
  renderOnPortal,
270
351
  position,
352
+ resolvedPosition,
353
+ availableWidth: availableSize.width,
354
+ availableHeight: availableSize.height,
355
+ autoPosition: shouldAutoPosition,
271
356
  mounted,
272
357
  }}
273
358
  >
@@ -372,6 +457,11 @@ export type PopoverMenuProps = {
372
457
  matchTriggerWidth?: boolean;
373
458
  };
374
459
 
460
+ type PopoverMenuStyle = CSSProperties & {
461
+ "--popover-available-width"?: string;
462
+ "--popover-available-height"?: string;
463
+ };
464
+
375
465
  export const PopoverMenu: FC<PopoverMenuProps> = ({
376
466
  children,
377
467
  className,
@@ -388,10 +478,13 @@ export const PopoverMenu: FC<PopoverMenuProps> = ({
388
478
  animationDuration,
389
479
  trigger,
390
480
  setIsOpen,
391
- position,
481
+ resolvedPosition,
392
482
  hoverTimeoutRef,
393
483
  renderOnPortal,
394
484
  mounted,
485
+ availableWidth,
486
+ availableHeight,
487
+ autoPosition,
395
488
  } = usePopover();
396
489
 
397
490
  const handleMouseEnter = () => {
@@ -439,14 +532,34 @@ export const PopoverMenu: FC<PopoverMenuProps> = ({
439
532
  return transforms[position];
440
533
  };
441
534
 
442
- const menuStyles: CSSProperties = {
535
+ const constrainedMaxWidth =
536
+ autoPosition && availableWidth !== undefined
537
+ ? matchTriggerWidth && triggerWidth > 0
538
+ ? Math.min(availableWidth, triggerWidth)
539
+ : availableWidth
540
+ : matchTriggerWidth && triggerWidth > 0
541
+ ? triggerWidth
542
+ : undefined;
543
+
544
+ const menuStyles: PopoverMenuStyle = {
443
545
  position: "fixed",
444
546
  top: coords.top,
445
547
  left: coords.left,
446
548
  zIndex: "var(--z-index-popover)",
447
- ...(matchTriggerWidth && { maxWidth: triggerWidth }),
549
+ ...(constrainedMaxWidth !== undefined && { maxWidth: constrainedMaxWidth }),
550
+ ...(autoPosition &&
551
+ availableHeight !== undefined && { maxHeight: availableHeight }),
552
+ ...(autoPosition &&
553
+ availableWidth !== undefined && {
554
+ "--popover-available-width": `${availableWidth}px`,
555
+ }),
556
+ ...(autoPosition &&
557
+ availableHeight !== undefined && {
558
+ "--popover-available-height": `${availableHeight}px`,
559
+ }),
560
+ ...(autoPosition && { overflow: "auto" }),
448
561
  opacity: isVisible ? 1 : 0,
449
- transform: getTransform(position, isMeasured, isOpen),
562
+ transform: getTransform(resolvedPosition, isMeasured, isOpen),
450
563
  transition: animationDuration
451
564
  ? `opacity ${animationDuration}ms cubic-bezier(0.4, 0, 0.2, 1), transform ${animationDuration}ms cubic-bezier(0.4, 0, 0.2, 1)`
452
565
  : "opacity var(--transition-base), transform var(--transition-base)",
@@ -464,6 +577,7 @@ export const PopoverMenu: FC<PopoverMenuProps> = ({
464
577
  // `useFocusOutside({ ignoreSelector })` so clicking a menu option
465
578
  // doesn't dismiss the host.
466
579
  data-popover-menu=""
580
+ data-position={resolvedPosition}
467
581
  onMouseEnter={handleMouseEnter}
468
582
  onMouseLeave={handleMouseLeave}
469
583
  style={menuStyles}
@@ -18,6 +18,9 @@ export type CopyButtonProps = {
18
18
  layout?: ButtonLayout;
19
19
  preview?: CopyPreview;
20
20
  previewMaxLength?: number;
21
+ /** Called with the value after a successful copy (e.g. to toast, or clear a
22
+ * one-time reveal). Not called if the copy fails. */
23
+ onCopy?: (value: string) => void;
21
24
  };
22
25
 
23
26
  const maskToken = (raw: string): string => {
@@ -44,6 +47,7 @@ export function CopyButton({
44
47
  layout,
45
48
  preview = "none",
46
49
  previewMaxLength = 24,
50
+ onCopy,
47
51
  }: CopyButtonProps) {
48
52
  const [copied, setCopied] = useState(false);
49
53
  const timeoutRef = useRef<number | null>(null);
@@ -82,6 +86,7 @@ export function CopyButton({
82
86
  const success = await copyWithFallback(value);
83
87
  if (!success) return;
84
88
 
89
+ onCopy?.(value);
85
90
  setCopied(true);
86
91
  if (timeoutRef.current !== null) {
87
92
  window.clearTimeout(timeoutRef.current);