aplance-ui 1.4.0 → 1.5.1

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 (38) 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.module.css +4 -2
  6. package/registry/components/primitives/popover/popover.tsx +16 -17
  7. package/registry/components/primitives/scroll-rail/scroll-rail.module.css +148 -0
  8. package/registry/components/primitives/scroll-rail/scroll-rail.tsx +214 -0
  9. package/registry/components/ui/accordion/accordion.module.css +52 -0
  10. package/registry/components/ui/accordion/accordion.tsx +97 -25
  11. package/registry/components/ui/avatar/avatar.tsx +2 -2
  12. package/registry/components/ui/breadcrumbs/breadcrumbs.module.css +9 -9
  13. package/registry/components/ui/button/button.module.css +43 -5
  14. package/registry/components/ui/button/button.tsx +2 -1
  15. package/registry/components/ui/copy-button/copy-button.module.css +2 -0
  16. package/registry/components/ui/data-table/data-table.module.css +68 -25
  17. package/registry/components/ui/data-table/data-table.tsx +1238 -1229
  18. package/registry/components/ui/data-table/filters.tsx +21 -0
  19. package/registry/components/ui/data-table/pagination-bar.tsx +31 -17
  20. package/registry/components/ui/data-table/types.ts +11 -1
  21. package/registry/components/ui/data-table/useDataTable.tsx +1 -2
  22. package/registry/components/ui/data-table/utils.ts +4 -3
  23. package/registry/components/ui/input-fields/common/input-field-common.module.css +8 -2
  24. package/registry/components/ui/input-fields/common/picker-popover-common.module.css +4 -0
  25. package/registry/components/ui/input-fields/custom-select/custom-select.module.css +115 -84
  26. package/registry/components/ui/input-fields/custom-select/custom-select.tsx +63 -11
  27. package/registry/components/ui/input-fields/date-picker/date-picker.tsx +51 -55
  28. package/registry/components/ui/input-fields/input/input.tsx +348 -348
  29. package/registry/components/ui/input-fields/media-input/media-input.tsx +3 -1
  30. package/registry/components/ui/input-fields/media-input/types.ts +8 -2
  31. package/registry/components/ui/input-fields/media-input/useMediaUpload.tsx +5 -5
  32. package/registry/components/ui/input-fields/media-input/utils.ts +18 -5
  33. package/registry/components/ui/tabs/tabs.module.css +8 -12
  34. package/registry/components/ui/tabs/tabs.tsx +75 -44
  35. package/registry/components/ui/tooltip/tooltip.module.css +8 -3
  36. package/registry/components/ui/tooltip/tooltip.tsx +16 -3
  37. package/registry/lib/string.ts +3 -3
  38. package/registry/registry.json +32 -3
@@ -111,7 +111,9 @@ export const MediaInput = (props: MediaInputProps) => {
111
111
  (multiple ? "Drop files here or browse" : "Drop a file here or browse");
112
112
  const hintParts = [
113
113
  accept ? `Accepts ${accept}` : null,
114
- maxSize ? `Up to ${formatBytes(maxSize)} per file` : null,
114
+ typeof maxSize === "number" && maxSize > 0
115
+ ? `Up to ${formatBytes(maxSize)} per file`
116
+ : null,
115
117
  multiple && maxFiles ? `Max ${maxFiles} files` : null,
116
118
  ].filter(Boolean);
117
119
  const resolvedHint = dropzoneHint ?? hintParts.join(" - ");
@@ -58,6 +58,9 @@ export type MediaPreviewLayout = (typeof mediaPreviewLayouts)[number];
58
58
  export const mediaPreviewSizes = ["sm", "md", "lg"] as const;
59
59
  export type MediaPreviewSize = (typeof mediaPreviewSizes)[number];
60
60
 
61
+ /** A size cap for every file, or each file's own; undefined takes any size. */
62
+ export type MaxSize = number | ((file: File) => number | undefined);
63
+
61
64
  export type MediaInputProps = Omit<
62
65
  BaseInputFieldProps,
63
66
  "iconStart" | "iconEnd"
@@ -68,8 +71,11 @@ export type MediaInputProps = Omit<
68
71
  multiple?: boolean;
69
72
  /** Hard cap on accepted files (only enforced when `multiple`). */
70
73
  maxFiles?: number;
71
- /** Per-file size cap in bytes. */
72
- maxSize?: number;
74
+ /**
75
+ * Per-file size cap in bytes, or a function giving a file's own cap, for a
76
+ * field that takes kinds of file with caps of their own.
77
+ */
78
+ maxSize?: MaxSize;
73
79
 
74
80
  /**
75
81
  * Variant for the inner Browse button. Independent from `buttonStyle`
@@ -10,7 +10,7 @@ import {
10
10
  useRef,
11
11
  useState,
12
12
  } from "react";
13
- import type { MediaFile, MediaRejection } from "./types";
13
+ import type { MaxSize, MediaFile, MediaRejection } from "./types";
14
14
  import {
15
15
  revokePreviewUrl,
16
16
  validateIncomingFiles,
@@ -30,7 +30,7 @@ export type UseMediaUploadOptions = {
30
30
  accept?: string;
31
31
  multiple?: boolean;
32
32
  maxFiles?: number;
33
- maxSize?: number;
33
+ maxSize?: MaxSize;
34
34
 
35
35
  disabled?: boolean;
36
36
  readOnly?: boolean;
@@ -96,10 +96,10 @@ export function useMediaUpload({
96
96
  filesRef.current = isControlled ? value! : internal;
97
97
  }, [isControlled, value, internal]);
98
98
 
99
+ // Disabled or read-only ends a drag in progress, in the render that sees it.
100
+ if ((disabled || readOnly) && isDragging) setIsDragging(false);
99
101
  useEffect(() => {
100
- if (!disabled && !readOnly) return;
101
- dragCounter.current = 0;
102
- setIsDragging(false);
102
+ if (disabled || readOnly) dragCounter.current = 0;
103
103
  }, [disabled, readOnly]);
104
104
 
105
105
  // Revoke any preview URLs the hook created on unmount so blob memory is
@@ -1,4 +1,10 @@
1
- import type { MediaFile, MediaKind, MediaRejection, MediaRejectionCode } from "./types";
1
+ import type {
2
+ MaxSize,
3
+ MediaFile,
4
+ MediaKind,
5
+ MediaRejection,
6
+ MediaRejectionCode,
7
+ } from "./types";
2
8
 
3
9
  const SIZE_UNITS = ["B", "KB", "MB", "GB", "TB"] as const;
4
10
 
@@ -114,7 +120,7 @@ export const rejectionMessage = (
114
120
 
115
121
  export type ValidateOptions = {
116
122
  accept?: string;
117
- maxSize?: number;
123
+ maxSize?: MaxSize;
118
124
  maxFiles?: number;
119
125
  multiple: boolean;
120
126
  existing: MediaFile[];
@@ -142,10 +148,16 @@ export const validateIncomingFiles = (
142
148
  typeof options.maxFiles === "number" && Number.isFinite(options.maxFiles)
143
149
  ? Math.max(0, Math.floor(options.maxFiles))
144
150
  : undefined;
145
- const maxSize =
146
- typeof options.maxSize === "number" && Number.isFinite(options.maxSize)
147
- ? Math.max(0, options.maxSize)
151
+ // A file's own cap: the one for every file, or the one the function gives it.
152
+ const capOf = (file: File) => {
153
+ const cap =
154
+ typeof options.maxSize === "function"
155
+ ? options.maxSize(file)
156
+ : options.maxSize;
157
+ return typeof cap === "number" && Number.isFinite(cap)
158
+ ? Math.max(0, cap)
148
159
  : undefined;
160
+ };
149
161
  const remainingCapacity = options.multiple
150
162
  ? maxFiles !== undefined
151
163
  ? Math.max(0, maxFiles - options.existing.length)
@@ -161,6 +173,7 @@ export const validateIncomingFiles = (
161
173
  });
162
174
  continue;
163
175
  }
176
+ const maxSize = capOf(file);
164
177
  if (maxSize !== undefined && file.size > maxSize) {
165
178
  rejected.push({
166
179
  file,
@@ -10,23 +10,19 @@
10
10
 
11
11
  /* ─────────────────────────────────────────────
12
12
  Tab list
13
+ It sits in a ScrollRail, which scrolls it when the tabs don't fit (many
14
+ status tabs on a phone) with fades and chevrons at the ends that have
15
+ more. The rail's row is at least its width, so the list still spans it.
13
16
  ───────────────────────────────────────────── */
17
+ .rail_content {
18
+ min-width: 100%;
19
+ }
20
+
14
21
  .list {
15
22
  position: relative;
16
23
  display: inline-flex;
24
+ flex: 1 0 auto;
17
25
  align-items: center;
18
- /* Scroll horizontally when the tabs don't fit (e.g. many status tabs on a
19
- phone) instead of clipping/overflowing the page. The active indicator is
20
- absolutely positioned inside, so it scrolls with the tabs and stays aligned.
21
- Scrollbar hidden for a clean strip - the partially-shown edge tab signals
22
- scrollability. */
23
- max-width: 100%;
24
- overflow-x: auto;
25
- scrollbar-width: none;
26
-
27
- &::-webkit-scrollbar {
28
- display: none;
29
- }
30
26
 
31
27
  /* style1 - pill container */
32
28
  &.list_style1 {
@@ -10,6 +10,7 @@ import {
10
10
  } from "react";
11
11
  import clsx from "clsx";
12
12
  import { useResizeObserver } from "../../../hooks/useResizeObserver";
13
+ import { ScrollRail } from "../../primitives/scroll-rail/scroll-rail";
13
14
  import s from "./tabs.module.css";
14
15
 
15
16
  // Variants & Types
@@ -57,6 +58,8 @@ export const Tabs = ({
57
58
 
58
59
  // `animate` separates deliberate tab switches from layout-driven re-fits.
59
60
  const listRef = useRef<HTMLDivElement>(null);
61
+ // The rail's scroller, when the tabs are wider than their row.
62
+ const railRef = useRef<HTMLDivElement>(null);
60
63
  const [indicator, setIndicator] = useState({ left: 0, width: 0, animate: true });
61
64
 
62
65
  const updateIndicator = useCallback((id: string, animate: boolean) => {
@@ -66,21 +69,46 @@ export const Tabs = ({
66
69
  if (!btn) return;
67
70
  const listRect = list.getBoundingClientRect();
68
71
  const btnRect = btn.getBoundingClientRect();
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
+ // Measured against the list, which scrolls with its tabs inside the rail,
73
+ // so the indicator stays on its tab wherever the strip is scrolled.
74
+ const left = btnRect.left - listRect.left;
72
75
  const width = btnRect.width;
73
76
  setIndicator((prev) =>
74
77
  prev.left === left && prev.width === width ? prev : { left, width, animate },
75
78
  );
76
79
  }, []);
77
80
 
81
+ // The active tab in view: a strip wider than its row opens on its tab, and
82
+ // scrolls to each one chosen that sits past an edge. A tab already in view
83
+ // stays where it is.
84
+ const revealActive = useCallback((id: string, animate: boolean) => {
85
+ const rail = railRef.current;
86
+ const btn = listRef.current?.querySelector<HTMLButtonElement>(
87
+ `[data-tab-id="${id}"]`,
88
+ );
89
+ if (!rail || !btn) return;
90
+ const railRect = rail.getBoundingClientRect();
91
+ const btnRect = btn.getBoundingClientRect();
92
+ if (btnRect.left >= railRect.left && btnRect.right <= railRect.right) return;
93
+ const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
94
+ rail.scrollTo({
95
+ left:
96
+ rail.scrollLeft +
97
+ btnRect.left -
98
+ railRect.left -
99
+ (railRect.width - btnRect.width) / 2,
100
+ behavior: animate && !still ? "smooth" : "auto",
101
+ });
102
+ }, []);
103
+
78
104
  const prevActiveRef = useRef<string | null>(null);
79
105
  useIsomorphicLayoutEffect(() => {
80
106
  const isSwitch = prevActiveRef.current !== activeId;
107
+ const isFirst = prevActiveRef.current === null;
81
108
  prevActiveRef.current = activeId;
82
109
  updateIndicator(activeId, isSwitch);
83
- }, [tabs, activeId, updateIndicator]);
110
+ revealActive(activeId, !isFirst);
111
+ }, [tabs, activeId, updateIndicator, revealActive]);
84
112
 
85
113
  // Container and font reflows re-fit the indicator without animating.
86
114
  useResizeObserver(listRef, () => updateIndicator(activeId, false));
@@ -92,47 +120,50 @@ export const Tabs = ({
92
120
 
93
121
  return (
94
122
  <div className={clsx(s.root, className)} {...rest}>
95
- {/* Tab List */}
96
- <div
97
- ref={listRef}
98
- role="tablist"
99
- className={clsx(s.list, s[`list_${variant}`])}
100
- aria-orientation="horizontal"
101
- >
102
- {/* Animated indicator */}
103
- <span
104
- className={clsx(s.indicator, s[`indicator_${variant}`])}
105
- style={{
106
- left: indicator.left,
107
- width: indicator.width,
108
- transition: indicator.animate ? undefined : "none",
109
- }}
110
- aria-hidden="true"
111
- />
123
+ {/* Tab List - in a rail that scrolls it, with fades and chevrons,
124
+ when the tabs are wider than their row */}
125
+ <ScrollRail ref={railRef} contentClassName={s.rail_content}>
126
+ <div
127
+ ref={listRef}
128
+ role="tablist"
129
+ className={clsx(s.list, s[`list_${variant}`])}
130
+ aria-orientation="horizontal"
131
+ >
132
+ {/* Animated indicator */}
133
+ <span
134
+ className={clsx(s.indicator, s[`indicator_${variant}`])}
135
+ style={{
136
+ left: indicator.left,
137
+ width: indicator.width,
138
+ transition: indicator.animate ? undefined : "none",
139
+ }}
140
+ aria-hidden="true"
141
+ />
112
142
 
113
- {tabs.map((tab) => {
114
- const isActive = tab.id === activeId;
115
- return (
116
- <button
117
- key={tab.id}
118
- role="tab"
119
- id={`${uid}-tab-${tab.id}`}
120
- aria-controls={`${uid}-panel-${tab.id}`}
121
- aria-selected={isActive}
122
- disabled={tab.disabled}
123
- data-tab-id={tab.id}
124
- className={clsx(
125
- s.tab,
126
- s[`tab_${variant}`],
127
- isActive && s.tab_active,
128
- )}
129
- onClick={() => !tab.disabled && handleSelect(tab.id)}
130
- >
131
- {tab.label}
132
- </button>
133
- );
134
- })}
135
- </div>
143
+ {tabs.map((tab) => {
144
+ const isActive = tab.id === activeId;
145
+ return (
146
+ <button
147
+ key={tab.id}
148
+ role="tab"
149
+ id={`${uid}-tab-${tab.id}`}
150
+ aria-controls={`${uid}-panel-${tab.id}`}
151
+ aria-selected={isActive}
152
+ disabled={tab.disabled}
153
+ data-tab-id={tab.id}
154
+ className={clsx(
155
+ s.tab,
156
+ s[`tab_${variant}`],
157
+ isActive && s.tab_active,
158
+ )}
159
+ onClick={() => !tab.disabled && handleSelect(tab.id)}
160
+ >
161
+ {tab.label}
162
+ </button>
163
+ );
164
+ })}
165
+ </div>
166
+ </ScrollRail>
136
167
 
137
168
  {/* Tab Panel */}
138
169
  {activeContent !== undefined && (
@@ -2,10 +2,15 @@
2
2
  not including the popover selectors because it's not at all required
3
3
  */
4
4
 
5
+ /* The cap, applied together with the space the popover found (tooltip.tsx).
6
+ Zero specificity, so a class passed through `popoverMenu.className` can set
7
+ its own. */
8
+ :where(.container) {
9
+ --tooltip-max-width: 250px;
10
+ }
11
+
5
12
  .container {
6
- width: 100%;
7
- min-width: fit-content;
8
- max-width: 250px;
13
+ width: max-content;
9
14
  background-color: var(--color-secondary);
10
15
  color: var(--color-secondary-foreground);
11
16
  border-radius: var(--radius-sm);
@@ -1,4 +1,4 @@
1
- import { type ReactNode, type FC } from "react";
1
+ import { type CSSProperties, type ReactNode, type FC } from "react";
2
2
  import {
3
3
  Popover,
4
4
  PopoverTrigger,
@@ -26,6 +26,16 @@ export type TooltipProps = {
26
26
  popoverMenu?: ComponentProps<typeof PopoverMenu>;
27
27
  };
28
28
 
29
+ // The menu's auto positioning sizes it as a dropdown: `overflow: auto` (which
30
+ // turns the arrow, outside the box, into a scrollbar) and a `max-width` of the
31
+ // space it found (which would replace the tooltip's own cap). A tooltip never
32
+ // scrolls and hugs its text up to the smaller of its cap and that space.
33
+ const menuStyle: CSSProperties = {
34
+ overflow: "visible",
35
+ maxWidth:
36
+ "min(var(--tooltip-max-width), var(--popover-available-width, var(--tooltip-max-width)))",
37
+ };
38
+
29
39
  export const Tooltip: FC<TooltipProps> = ({
30
40
  content,
31
41
  children,
@@ -53,10 +63,13 @@ export const Tooltip: FC<TooltipProps> = ({
53
63
  <PopoverTrigger asChild {...popoverTrigger}>
54
64
  {children}
55
65
  </PopoverTrigger>
56
- {/* Using menu as a direct content wrapper */}
66
+ {/* Using menu as a direct content wrapper. Sized by its text, not by
67
+ what it points at. */}
57
68
  <PopoverMenu
58
- className={clsx(s.container, popoverMenu?.className)}
69
+ matchTriggerWidth={false}
59
70
  {...popoverMenu}
71
+ className={clsx(s.container, popoverMenu?.className)}
72
+ style={{ ...menuStyle, ...popoverMenu?.style }}
60
73
  >
61
74
  {showArrow && (
62
75
  <div
@@ -17,7 +17,7 @@ export function hashName(name: string): number {
17
17
  * Extract 1-2 letter initials from a name (e.g. "John Doe" -> "JD")
18
18
  */
19
19
  export function getInitials(name: string): string {
20
- const parts = name.trim().split(/\s+/);
21
- if (parts.length >= 2) return parts[0][0] + parts[1][0];
22
- return parts[0]?.substring(0, 2) || "?";
20
+ const [first = "", second = ""] = name.trim().split(/\s+/);
21
+ if (second) return first.charAt(0) + second.charAt(0);
22
+ return first.substring(0, 2) || "?";
23
23
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://ui.aplance.com/registry.schema.json",
3
3
  "version": 4,
4
- "generated": "2026-09-11T04:18:36.662Z",
4
+ "generated": "2026-11-11T12:39:14.314Z",
5
5
  "units": [
6
6
  {
7
7
  "name": "accordion",
@@ -15,6 +15,7 @@
15
15
  "components/ui/accordion/accordion.tsx"
16
16
  ],
17
17
  "deps": [
18
+ "button",
18
19
  "useResizeObserver"
19
20
  ],
20
21
  "externalDeps": [
@@ -214,6 +215,7 @@
214
215
  "components/ui/data-table/data-table.tsx",
215
216
  "components/ui/data-table/filter-bar.tsx",
216
217
  "components/ui/data-table/filter-modal.tsx",
218
+ "components/ui/data-table/filters.tsx",
217
219
  "components/ui/data-table/pagination-bar.tsx",
218
220
  "components/ui/data-table/types.ts",
219
221
  "components/ui/data-table/useDataTable.tsx",
@@ -223,6 +225,7 @@
223
225
  "components/ui/data-table/checkbox-column.tsx",
224
226
  "components/ui/data-table/data-table.tsx",
225
227
  "components/ui/data-table/filter-modal.tsx",
228
+ "components/ui/data-table/filters.tsx",
226
229
  "components/ui/data-table/pagination-bar.tsx",
227
230
  "components/ui/data-table/types.ts",
228
231
  "components/ui/data-table/useDataTable.tsx",
@@ -236,6 +239,7 @@
236
239
  "lib-dom",
237
240
  "loader",
238
241
  "modal",
242
+ "scroll-rail",
239
243
  "tooltip",
240
244
  "useMediaQuery"
241
245
  ],
@@ -566,6 +570,26 @@
566
570
  "react-dom"
567
571
  ]
568
572
  },
573
+ {
574
+ "name": "scroll-rail",
575
+ "kind": "component",
576
+ "group": "primitives",
577
+ "files": [
578
+ "components/primitives/scroll-rail/scroll-rail.module.css",
579
+ "components/primitives/scroll-rail/scroll-rail.tsx"
580
+ ],
581
+ "clientFiles": [
582
+ "components/primitives/scroll-rail/scroll-rail.tsx"
583
+ ],
584
+ "deps": [
585
+ "useResizeObserver"
586
+ ],
587
+ "externalDeps": [
588
+ "@tabler/icons-react",
589
+ "clsx",
590
+ "react"
591
+ ]
592
+ },
569
593
  {
570
594
  "name": "select",
571
595
  "kind": "component",
@@ -576,7 +600,9 @@
576
600
  "components/ui/input-fields/common/types.ts",
577
601
  "components/ui/input-fields/select/select.tsx"
578
602
  ],
579
- "clientFiles": [],
603
+ "clientFiles": [
604
+ "components/ui/input-fields/select/select.tsx"
605
+ ],
580
606
  "deps": [
581
607
  "button",
582
608
  "field-message",
@@ -664,7 +690,9 @@
664
690
  "components/ui/input-fields/switch/switch.module.css",
665
691
  "components/ui/input-fields/switch/switch.tsx"
666
692
  ],
667
- "clientFiles": [],
693
+ "clientFiles": [
694
+ "components/ui/input-fields/switch/switch.tsx"
695
+ ],
668
696
  "deps": [
669
697
  "button",
670
698
  "field-message"
@@ -686,6 +714,7 @@
686
714
  "components/ui/tabs/tabs.tsx"
687
715
  ],
688
716
  "deps": [
717
+ "scroll-rail",
689
718
  "useResizeObserver"
690
719
  ],
691
720
  "externalDeps": [