aplance-ui 1.2.1 → 1.3.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 (23) hide show
  1. package/package.json +1 -1
  2. package/registry/components/primitives/modal/modal.tsx +52 -13
  3. package/registry/components/primitives/popover/popover-context.ts +1 -0
  4. package/registry/components/primitives/popover/popover.tsx +13 -2
  5. package/registry/components/primitives/slider/slider.module.css +62 -5
  6. package/registry/components/primitives/slider/slider.tsx +192 -34
  7. package/registry/components/primitives/slider/types.ts +56 -3
  8. package/registry/components/primitives/slider/utils.ts +9 -0
  9. package/registry/components/ui/data-table/data-table.module.css +124 -6
  10. package/registry/components/ui/data-table/data-table.tsx +176 -88
  11. package/registry/components/ui/data-table/pagination-bar.tsx +174 -71
  12. package/registry/components/ui/data-table/types.ts +12 -3
  13. package/registry/components/ui/input-fields/common/input-field-native.module.css +52 -42
  14. package/registry/components/ui/input-fields/custom-select/custom-select.module.css +21 -1
  15. package/registry/components/ui/input-fields/custom-select/custom-select.tsx +90 -9
  16. package/registry/components/ui/input-fields/input/input.tsx +58 -29
  17. package/registry/components/ui/input-fields/media-input/media-input.module.css +29 -0
  18. package/registry/components/ui/input-fields/media-input/media-preview.tsx +25 -7
  19. package/registry/components/ui/tabs/tabs.module.css +22 -8
  20. package/registry/components/ui/tabs/tabs.tsx +5 -1
  21. package/registry/hooks/useFocusOutside.tsx +21 -6
  22. package/registry/hooks/useHasMounted.ts +22 -0
  23. package/registry/registry.json +21 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aplance-ui",
3
- "version": "1.2.1",
3
+ "version": "1.3.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",
@@ -22,6 +22,7 @@ import {
22
22
  type ButtonLayout,
23
23
  } from "../../ui/button/button";
24
24
  import { useFocusOutside } from "../../../hooks/useFocusOutside";
25
+ import { useHasMounted } from "../../../hooks/useHasMounted";
25
26
  import { ModalContext, useModal } from "./modal-context";
26
27
  import s from "./modal.module.css";
27
28
  import { Icon } from "../icon/icon";
@@ -86,6 +87,20 @@ const releaseScrollLock = () => {
86
87
  }
87
88
  };
88
89
 
90
+ // Module-level stack of open modal ids. Only the topmost modal reacts to
91
+ // Escape / backdrop-click - without this, dismissing a child modal (media
92
+ // picker, upload, etc.) also closes the parent it was opened from.
93
+ const _modalStack: string[] = [];
94
+ const pushModal = (id: string) => {
95
+ _modalStack.push(id);
96
+ };
97
+ const popModal = (id: string) => {
98
+ const index = _modalStack.lastIndexOf(id);
99
+ if (index >= 0) _modalStack.splice(index, 1);
100
+ };
101
+ const isTopModal = (id: string): boolean =>
102
+ _modalStack[_modalStack.length - 1] === id;
103
+
89
104
  // ─── Shared button passthrough ────────────────────────────────────────────────
90
105
 
91
106
  export type ButtonPassthroughProps = {
@@ -187,6 +202,8 @@ export const Modal: FC<ModalProps> = ({
187
202
  // Always starts false so the element is in the DOM before the transition fires
188
203
  const [isVisible, setIsVisible] = useState(false);
189
204
 
205
+ const mounted = useHasMounted();
206
+
190
207
  const contentRef = useRef<HTMLDivElement>(null);
191
208
  const modalId = useId();
192
209
 
@@ -249,33 +266,39 @@ export const Modal: FC<ModalProps> = ({
249
266
  // registers the closed CSS state before we flip to open - same trick
250
267
  // Popover uses with isMeasured + ResizeObserver.
251
268
  useLayoutEffect(() => {
252
- if (shouldRender && contentRef.current) {
269
+ if (shouldRender && mounted && contentRef.current) {
253
270
  void contentRef.current.offsetHeight; // flush reflow
254
271
  setIsVisible(isOpen);
255
272
  }
256
273
  // eslint-disable-next-line react-hooks/exhaustive-deps
257
- }, [shouldRender]); // only run when node mounts, not on every isOpen change
274
+ }, [shouldRender, mounted]); // only run when node mounts or mounted state changes
258
275
 
259
276
  useEffect(() => {
260
277
  if (isOpen) onOpenRef.current?.();
261
278
  }, [isOpen]);
262
279
 
263
- // Body scroll lock - reference-counted across stacked modals
280
+ // Body scroll lock + open-modal stack registration (both reference-counted
281
+ // across stacked modals).
264
282
  useEffect(() => {
265
283
  if (!isOpen) return;
266
284
  acquireScrollLock();
267
- return releaseScrollLock;
268
- }, [isOpen]);
285
+ pushModal(modalId);
286
+ return () => {
287
+ releaseScrollLock();
288
+ popModal(modalId);
289
+ };
290
+ }, [isOpen, modalId]);
269
291
 
270
- // ESC key
292
+ // ESC key - only the topmost modal closes, so Escape inside a child modal
293
+ // doesn't also dismiss its parent.
271
294
  useEffect(() => {
272
295
  if (!closeOnEsc || !isOpen) return;
273
296
  const handler = (e: globalThis.KeyboardEvent) => {
274
- if (e.key === "Escape") close();
297
+ if (e.key === "Escape" && isTopModal(modalId)) close();
275
298
  };
276
299
  window.addEventListener("keydown", handler);
277
300
  return () => window.removeEventListener("keydown", handler);
278
- }, [isOpen, closeOnEsc, close]);
301
+ }, [isOpen, closeOnEsc, close, modalId]);
279
302
 
280
303
  // Focus trap
281
304
  useLayoutEffect(() => {
@@ -308,13 +331,25 @@ export const Modal: FC<ModalProps> = ({
308
331
  };
309
332
  el.addEventListener("keydown", trap);
310
333
  return () => el.removeEventListener("keydown", trap);
311
- }, [isOpen]);
334
+ }, [isOpen, mounted]);
312
335
 
313
336
  // Close when a click lands outside the dialog panel - mirrors Popover's
314
337
  // outside-click pattern so the component family stays consistent.
315
- useFocusOutside([contentRef], close, {
316
- enabled: closeOnOverlayClick && isOpen,
317
- });
338
+ // `ignoreSelector` exempts portaled popover menus (CustomSelect, DatePicker,
339
+ // RowActionsPopover, etc.): their menus portal to document.body so they're
340
+ // outside `contentRef`, but a click on a dropdown option must NOT dismiss
341
+ // the dialog it was opened from. Gated on being the topmost modal so a click
342
+ // that dismisses a child modal doesn't bubble into closing its parent.
343
+ useFocusOutside(
344
+ [contentRef],
345
+ () => {
346
+ if (isTopModal(modalId)) close();
347
+ },
348
+ {
349
+ enabled: closeOnOverlayClick && isOpen,
350
+ ignoreSelector: "[data-popover-menu]",
351
+ },
352
+ );
318
353
 
319
354
  // ── Dialog panel ──────────────────────────────────────────────────────────
320
355
  const dialog = shouldRender ? (
@@ -359,7 +394,11 @@ export const Modal: FC<ModalProps> = ({
359
394
  ) : null;
360
395
 
361
396
  const portal =
362
- renderOnPortal && dialog ? createPortal(dialog, document.body) : dialog;
397
+ renderOnPortal && dialog
398
+ ? mounted
399
+ ? createPortal(dialog, document.body)
400
+ : null
401
+ : dialog;
363
402
 
364
403
  // Hoisted triggers sit outside the portal so they stay visible while closed;
365
404
  // dialog content lives inside the portal.
@@ -15,6 +15,7 @@ export type PopoverContextType = {
15
15
  position: Position;
16
16
  hoverTimeoutRef: RefObject<ReturnType<typeof setTimeout> | null>;
17
17
  renderOnPortal: boolean;
18
+ mounted: boolean;
18
19
  };
19
20
 
20
21
  export const PopoverContext = createContext<PopoverContextType | undefined>(
@@ -14,6 +14,7 @@ import { createPortal } from "react-dom";
14
14
  import s from "./popover.module.css";
15
15
  import { PopoverContext, usePopover } from "./popover-context";
16
16
  import { useFocusOutside } from "../../../hooks/useFocusOutside";
17
+ import { useHasMounted } from "../../../hooks/useHasMounted";
17
18
 
18
19
  // Types
19
20
  export type Position = "top" | "bottom" | "left" | "right";
@@ -82,6 +83,8 @@ export const Popover: FC<PopoverProps> = ({
82
83
  // For hover trigger
83
84
  const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
84
85
 
86
+ const mounted = useHasMounted();
87
+
85
88
  const [coords, setCoords] = useState<Coords>({ top: 0, left: 0 });
86
89
  const [shouldRender, setShouldRender] = useState(false);
87
90
  const [isMeasured, setIsMeasured] = useState(false);
@@ -178,7 +181,7 @@ export const Popover: FC<PopoverProps> = ({
178
181
  setIsMeasured(false);
179
182
  };
180
183
  }
181
- }, [shouldRender, calculatePosition]);
184
+ }, [shouldRender, mounted, calculatePosition]);
182
185
 
183
186
  // Clear any pending hover-close timer when the Popover unmounts so the
184
187
  // delayed setIsOpen never fires on a stale instance.
@@ -265,6 +268,7 @@ export const Popover: FC<PopoverProps> = ({
265
268
  hoverTimeoutRef,
266
269
  renderOnPortal,
267
270
  position,
271
+ mounted,
268
272
  }}
269
273
  >
270
274
  <div
@@ -387,6 +391,7 @@ export const PopoverMenu: FC<PopoverMenuProps> = ({
387
391
  position,
388
392
  hoverTimeoutRef,
389
393
  renderOnPortal,
394
+ mounted,
390
395
  } = usePopover();
391
396
 
392
397
  const handleMouseEnter = () => {
@@ -453,6 +458,12 @@ export const PopoverMenu: FC<PopoverMenuProps> = ({
453
458
  <div
454
459
  ref={menuRef}
455
460
  className={clsx(s.menu_wrapper, className)}
461
+ // Stable marker for portaled-layer detection. A `Popover` menu portals
462
+ // to `document.body`, escaping its host's DOM subtree - hosts with
463
+ // outside-click dismissal (Modal/Drawer) match this selector via
464
+ // `useFocusOutside({ ignoreSelector })` so clicking a menu option
465
+ // doesn't dismiss the host.
466
+ data-popover-menu=""
456
467
  onMouseEnter={handleMouseEnter}
457
468
  onMouseLeave={handleMouseLeave}
458
469
  style={menuStyles}
@@ -461,5 +472,5 @@ export const PopoverMenu: FC<PopoverMenuProps> = ({
461
472
  </div>
462
473
  );
463
474
 
464
- return renderOnPortal ? createPortal(menu, document.body) : menu;
475
+ return renderOnPortal ? (mounted ? createPortal(menu, document.body) : null) : menu;
465
476
  };
@@ -4,11 +4,11 @@
4
4
  ─────────────────────────────────────────────────────────────────────────── */
5
5
 
6
6
  .container {
7
- --slider-nav-size: var(--spacing-12);
7
+ --slider-nav-size: var(--spacing-11);
8
8
  --slider-nav-bg: color-mix(in srgb, var(--color-foreground) 56%, transparent);
9
9
  --slider-nav-bg-hover: color-mix(in srgb, var(--color-foreground) 78%, transparent);
10
10
  --slider-nav-fg: var(--color-background);
11
- --slider-dot-size: var(--spacing-6);
11
+ --slider-dot-size: var(--spacing-5);
12
12
  --slider-dot-bg: color-mix(in srgb, var(--color-foreground) 28%, transparent);
13
13
  --slider-dot-bg-active: var(--color-primary);
14
14
 
@@ -39,6 +39,10 @@
39
39
  transition: height var(--transition-base);
40
40
  }
41
41
 
42
+ .viewport_coverflow {
43
+ perspective: 72rem;
44
+ }
45
+
42
46
  .track {
43
47
  display: flex;
44
48
  min-width: 0;
@@ -48,11 +52,21 @@
48
52
  }
49
53
 
50
54
  .track_fade,
51
- .track_scale {
55
+ .track_scale,
56
+ .track_flip {
52
57
  display: grid;
53
58
  transform: none;
54
59
  }
55
60
 
61
+ .track_flip {
62
+ perspective: 72rem;
63
+ transform-style: preserve-3d;
64
+ }
65
+
66
+ .track_coverflow {
67
+ transform-style: preserve-3d;
68
+ }
69
+
56
70
  .slide {
57
71
  min-width: 0;
58
72
  flex-grow: 0;
@@ -75,14 +89,45 @@
75
89
  }
76
90
  }
77
91
 
92
+ .slide_cards,
93
+ .slide_coverflow {
94
+ will-change: transform, opacity;
95
+ transition:
96
+ opacity var(--slider-transition-duration) var(--slider-transition-easing),
97
+ transform var(--slider-transition-duration) var(--slider-transition-easing);
98
+ }
99
+
100
+ .slide_cards {
101
+ transform:
102
+ translateY(var(--slider-slide-offset))
103
+ scale(var(--slider-slide-scale));
104
+ }
105
+
106
+ .slide_coverflow {
107
+ transform:
108
+ translateY(var(--slider-slide-offset))
109
+ translateZ(var(--slider-slide-depth))
110
+ rotateY(var(--slider-slide-rotate))
111
+ scale(var(--slider-slide-scale));
112
+ transform-style: preserve-3d;
113
+ }
114
+
115
+ .container_dragging,
116
+ .container_no_transition {
117
+ .slide_cards,
118
+ .slide_coverflow {
119
+ transition: none;
120
+ }
121
+ }
122
+
78
123
  .slide_overlay {
79
124
  grid-area: 1 / 1;
80
125
  width: 100%;
81
126
  opacity: 0;
82
127
  pointer-events: none;
83
128
  transition:
84
- opacity var(--transition-base),
85
- transform var(--transition-base);
129
+ opacity var(--slider-transition-duration) var(--slider-transition-easing),
130
+ transform var(--slider-transition-duration) var(--slider-transition-easing);
86
131
  }
87
132
 
88
133
  .track_fade .slide_overlay {
@@ -93,12 +138,22 @@
93
138
  transform: scale(0.96);
94
139
  }
95
140
 
141
+ .track_flip .slide_overlay {
142
+ backface-visibility: hidden;
143
+ transform: rotateY(90deg) scale(0.96);
144
+ transform-style: preserve-3d;
145
+ }
146
+
96
147
  .slide_active {
97
148
  opacity: 1;
98
149
  pointer-events: auto;
99
150
  transform: scale(1);
100
151
  }
101
152
 
153
+ .slide_overlay.slide_active {
154
+ transform: scale(1);
155
+ }
156
+
102
157
  /* ─── Built-in nav arrows ───────────────────────────────────────────────────
103
158
  `data-slider-no-drag` is set in TSX so `dragIgnoreSelector` does not start
104
159
  a track drag when the user clicks the chevron.
@@ -294,6 +349,8 @@
294
349
  @media (--motion-reduce) {
295
350
  .track,
296
351
  .slide_overlay,
352
+ .slide_cards,
353
+ .slide_coverflow,
297
354
  .progress_bar {
298
355
  transition: none;
299
356
  }