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.
@@ -51,6 +51,8 @@ export type CommonDatePickerProps = Omit<
51
51
  timePickerPopover?: {
52
52
  position?: Position;
53
53
  align?: Align;
54
+ autoPosition?: boolean;
55
+ collisionPadding?: number;
54
56
  };
55
57
  /** Show popover footer actions (Clear/Done) in DatePicker. Default: true. */
56
58
  showActionFooter?: boolean;
@@ -23,6 +23,10 @@ export type EmojiPickerProps = {
23
23
  /** Popover placement. */
24
24
  popoverPosition?: Position;
25
25
  popoverAlign?: Align;
26
+ /** Auto-position the popover to stay within the viewport. Default: true. */
27
+ popoverAutoPosition?: boolean;
28
+ /** Viewport padding used by popover auto positioning. */
29
+ popoverCollisionPadding?: number;
26
30
  /**
27
31
  * Show the popover footer with a Done action so users can pick multiple
28
32
  * emojis before dismissing. Default: true. Set to `false` to hide the
@@ -51,6 +55,8 @@ export const EmojiPicker = ({
51
55
  columns = 9,
52
56
  popoverPosition = "bottom",
53
57
  popoverAlign = "end",
58
+ popoverAutoPosition,
59
+ popoverCollisionPadding,
54
60
  showActionFooter = true,
55
61
  disabled,
56
62
  className,
@@ -62,6 +68,8 @@ export const EmojiPicker = ({
62
68
  <Popover
63
69
  position={popoverPosition}
64
70
  align={popoverAlign}
71
+ autoPosition={popoverAutoPosition}
72
+ collisionPadding={popoverCollisionPadding}
65
73
  isOpen={isOpen}
66
74
  setIsOpen={setIsOpen}
67
75
  closeOnClickOutside
@@ -4,6 +4,7 @@ import {
4
4
  useEffect,
5
5
  useId,
6
6
  useCallback,
7
+ useLayoutEffect,
7
8
  type ReactNode,
8
9
  type ComponentProps,
9
10
  } from "react";
@@ -15,8 +16,13 @@ import s from "./tabs.module.css";
15
16
  export const variants = ["style1", "style2"] as const;
16
17
  export type Variant = (typeof variants)[number];
17
18
 
19
+ const useIsomorphicLayoutEffect =
20
+ typeof window === "undefined" ? useEffect : useLayoutEffect;
21
+
18
22
  export type TabItem = {
19
23
  id: string;
24
+ // Consumers can compose counts, badges, icons, etc. at the call site.
25
+ // Dynamic label changes re-measure the indicator without the slide animation.
20
26
  label: ReactNode;
21
27
  content?: ReactNode;
22
28
  disabled?: boolean;
@@ -49,32 +55,35 @@ export const Tabs = ({
49
55
 
50
56
  const activeId = isControlled ? controlledActive : internalActive;
51
57
 
52
- // Sliding indicator state
58
+ // `animate` separates deliberate tab switches from layout-driven re-fits.
53
59
  const listRef = useRef<HTMLDivElement>(null);
54
- const [indicator, setIndicator] = useState({ left: 0, width: 0 });
60
+ const [indicator, setIndicator] = useState({ left: 0, width: 0, animate: true });
55
61
 
56
- const updateIndicator = useCallback((id: string) => {
62
+ const updateIndicator = useCallback((id: string, animate: boolean) => {
57
63
  const list = listRef.current;
58
64
  if (!list) return;
59
65
  const btn = list.querySelector<HTMLButtonElement>(`[data-tab-id="${id}"]`);
60
66
  if (!btn) return;
61
67
  const listRect = list.getBoundingClientRect();
62
68
  const btnRect = btn.getBoundingClientRect();
63
- setIndicator({
64
- // `list` is a horizontal scroller and the indicator is absolutely
65
- // positioned inside it, so its `left` is measured in content coordinates.
66
- // Add scrollLeft, otherwise the indicator drifts away from the active tab
67
- // once the strip is scrolled.
68
- left: btnRect.left - listRect.left + list.scrollLeft,
69
- width: btnRect.width,
70
- });
69
+ // Measure in the scrollable list's content coordinates so the indicator
70
+ // stays aligned when the tab strip is horizontally scrolled.
71
+ const left = btnRect.left - listRect.left + list.scrollLeft;
72
+ const width = btnRect.width;
73
+ setIndicator((prev) =>
74
+ prev.left === left && prev.width === width ? prev : { left, width, animate },
75
+ );
71
76
  }, []);
72
77
 
73
- useEffect(() => {
74
- updateIndicator(activeId);
75
- }, [activeId, updateIndicator]);
78
+ const prevActiveRef = useRef<string | null>(null);
79
+ useIsomorphicLayoutEffect(() => {
80
+ const isSwitch = prevActiveRef.current !== activeId;
81
+ prevActiveRef.current = activeId;
82
+ updateIndicator(activeId, isSwitch);
83
+ }, [tabs, activeId, updateIndicator]);
76
84
 
77
- useResizeObserver(listRef, () => updateIndicator(activeId));
85
+ // Container and font reflows re-fit the indicator without animating.
86
+ useResizeObserver(listRef, () => updateIndicator(activeId, false));
78
87
  const handleSelect = (id: string) => {
79
88
  if (!isControlled) setInternalActive(id);
80
89
  onTabChange?.(id);
@@ -93,7 +102,11 @@ export const Tabs = ({
93
102
  {/* Animated indicator */}
94
103
  <span
95
104
  className={clsx(s.indicator, s[`indicator_${variant}`])}
96
- style={{ left: indicator.left, width: indicator.width }}
105
+ style={{
106
+ left: indicator.left,
107
+ width: indicator.width,
108
+ transition: indicator.animate ? undefined : "none",
109
+ }}
97
110
  aria-hidden="true"
98
111
  />
99
112
 
@@ -0,0 +1,38 @@
1
+ import { useCallback, useSyncExternalStore } from "react";
2
+
3
+ const noopSubscribe = (): (() => void) => () => {};
4
+
5
+ const canUseMatchMedia = (): boolean =>
6
+ typeof window !== "undefined" && typeof window.matchMedia === "function";
7
+
8
+ const getMatch = (query: string): boolean =>
9
+ canUseMatchMedia() ? window.matchMedia(query).matches : false;
10
+
11
+ /**
12
+ * Subscribe to a CSS media query; re-renders whenever it flips.
13
+ *
14
+ * SSR-safe via `useSyncExternalStore`: returns `false` on the server and on the
15
+ * first client render (so the hydrated markup matches the server HTML), then
16
+ * settles to the real `window.matchMedia` result on the next client tick - the
17
+ * same hydration split as `useHasMounted`. Keep the `query` string stable (a
18
+ * module-level const, not an inline template) so the subscription does not
19
+ * re-bind on every render.
20
+ */
21
+ export const useMediaQuery = (query: string): boolean => {
22
+ const subscribe = useCallback(
23
+ (onStoreChange: () => void) => {
24
+ if (!canUseMatchMedia()) return noopSubscribe();
25
+
26
+ const mql = window.matchMedia(query);
27
+ mql.addEventListener("change", onStoreChange);
28
+ return () => mql.removeEventListener("change", onStoreChange);
29
+ },
30
+ [query],
31
+ );
32
+
33
+ return useSyncExternalStore(
34
+ subscribe,
35
+ () => getMatch(query),
36
+ () => false,
37
+ );
38
+ };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://ui.aplance.com/registry.schema.json",
3
3
  "version": 4,
4
- "generated": "2026-06-15T07:36:30.820Z",
4
+ "generated": "2026-09-11T04:18:36.662Z",
5
5
  "units": [
6
6
  {
7
7
  "name": "accordion",
@@ -213,6 +213,7 @@
213
213
  "components/ui/data-table/data-table.module.css",
214
214
  "components/ui/data-table/data-table.tsx",
215
215
  "components/ui/data-table/filter-bar.tsx",
216
+ "components/ui/data-table/filter-modal.tsx",
216
217
  "components/ui/data-table/pagination-bar.tsx",
217
218
  "components/ui/data-table/types.ts",
218
219
  "components/ui/data-table/useDataTable.tsx",
@@ -221,6 +222,7 @@
221
222
  "clientFiles": [
222
223
  "components/ui/data-table/checkbox-column.tsx",
223
224
  "components/ui/data-table/data-table.tsx",
225
+ "components/ui/data-table/filter-modal.tsx",
224
226
  "components/ui/data-table/pagination-bar.tsx",
225
227
  "components/ui/data-table/types.ts",
226
228
  "components/ui/data-table/useDataTable.tsx",
@@ -229,10 +231,13 @@
229
231
  "deps": [
230
232
  "button",
231
233
  "custom-select",
234
+ "icon",
232
235
  "input",
233
236
  "lib-dom",
234
237
  "loader",
235
- "tooltip"
238
+ "modal",
239
+ "tooltip",
240
+ "useMediaQuery"
236
241
  ],
237
242
  "externalDeps": [
238
243
  "@tabler/icons-react",
@@ -800,6 +805,21 @@
800
805
  "react"
801
806
  ]
802
807
  },
808
+ {
809
+ "name": "useMediaQuery",
810
+ "kind": "hook",
811
+ "group": null,
812
+ "files": [
813
+ "hooks/useMediaQuery.ts"
814
+ ],
815
+ "clientFiles": [
816
+ "hooks/useMediaQuery.ts"
817
+ ],
818
+ "deps": [],
819
+ "externalDeps": [
820
+ "react"
821
+ ]
822
+ },
803
823
  {
804
824
  "name": "useResizeObserver",
805
825
  "kind": "hook",