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
@@ -9,6 +9,7 @@ import {
9
9
  import clsx from "clsx";
10
10
  import { IconChevronDown } from "@tabler/icons-react";
11
11
  import { useResizeObserver } from "../../../hooks/useResizeObserver";
12
+ import { Button } from "../button/button";
12
13
  import s from "./accordion.module.css";
13
14
 
14
15
  export type AccordionVariant = "bordered" | "minimal";
@@ -17,12 +18,23 @@ export type AccordionMode = "single" | "multiple";
17
18
 
18
19
  export type AccordionSize = "sm" | "md" | "lg";
19
20
 
21
+ /**
22
+ * How an item opens. icon: the whole heading, with a chevron in it. button:
23
+ * the heading too, with a bordered chevron button at its end, as a table row
24
+ * ends in its menu button.
25
+ */
26
+ export type AccordionToggle = "icon" | "button";
27
+
20
28
  export type AccordionItem = {
21
29
  id: string;
22
30
  title: ReactNode;
23
31
  content: ReactNode;
24
32
  disabled?: boolean;
25
33
  defaultOpen?: boolean;
34
+ /** Controls beside the heading, outside the button that opens it: a row's menu. */
35
+ actions?: ReactNode;
36
+ /** This item's own class, beside `itemClassName`: one marked apart from the rest. */
37
+ className?: string;
26
38
  };
27
39
 
28
40
  type AccordionBaseProps = {
@@ -35,6 +47,7 @@ type AccordionBaseProps = {
35
47
  size?: AccordionSize;
36
48
  icon?: ReactNode;
37
49
  iconRotation?: number;
50
+ toggle?: AccordionToggle;
38
51
  };
39
52
 
40
53
  type AccordionSingleProps = {
@@ -80,6 +93,7 @@ type ItemViewProps = {
80
93
  size: AccordionSize;
81
94
  variant: AccordionVariant;
82
95
  icon?: ReactNode;
96
+ toggle: AccordionToggle;
83
97
  itemClassName?: string;
84
98
  triggerClassName?: string;
85
99
  contentClassName?: string;
@@ -114,6 +128,7 @@ const AccordionItemView = ({
114
128
  size,
115
129
  variant,
116
130
  icon,
131
+ toggle,
117
132
  itemClassName,
118
133
  triggerClassName,
119
134
  contentClassName,
@@ -138,6 +153,13 @@ const AccordionItemView = ({
138
153
 
139
154
  useResizeObserver(contentRef, updateHeight, { enabled: isOpen });
140
155
 
156
+ const chevron = icon ?? (
157
+ <IconChevronDown size={size === "sm" ? 16 : size === "lg" ? 20 : 18} />
158
+ );
159
+ // With actions or a toggle button, the heading is a row: the button that
160
+ // opens the item, then the controls beside it, outside that button.
161
+ const row = toggle === "button" || item.actions !== undefined;
162
+
141
163
  return (
142
164
  <section
143
165
  className={clsx(
@@ -146,42 +168,90 @@ const AccordionItemView = ({
146
168
  isOpen && s.item_open,
147
169
  item.disabled && s.item_disabled,
148
170
  itemClassName,
171
+ item.className,
149
172
  )}
150
173
  data-state={isOpen ? "open" : "closed"}
151
174
  >
152
- <button
153
- id={triggerId}
154
- type="button"
155
- disabled={item.disabled}
156
- aria-expanded={isOpen}
157
- aria-controls={panelId}
158
- onClick={onToggle}
159
- className={clsx(
160
- s.trigger,
161
- s[`trigger_${size}`],
162
- s[`trigger_${variant}`],
163
- isOpen && s.trigger_open,
164
- triggerClassName,
165
- )}
166
- >
167
- <span className={clsx(s.title, s[`title_${size}`])}>{item.title}</span>
168
- <span
169
- className={clsx(s.icon, isOpen && s.icon_open)}
170
- aria-hidden="true"
175
+ {row ? (
176
+ <div
177
+ className={clsx(
178
+ s.header,
179
+ s[`trigger_${size}`],
180
+ s[`header_${variant}`],
181
+ isOpen && s.trigger_open,
182
+ )}
171
183
  >
172
- {icon ?? (
173
- <IconChevronDown
174
- size={size === "sm" ? 16 : size === "lg" ? 20 : 18}
175
- />
184
+ <button
185
+ id={triggerId}
186
+ type="button"
187
+ disabled={item.disabled}
188
+ aria-expanded={isOpen}
189
+ aria-controls={panelId}
190
+ onClick={onToggle}
191
+ className={clsx(s.trigger, s.header_trigger, triggerClassName)}
192
+ >
193
+ <span className={clsx(s.title, s[`title_${size}`])}>{item.title}</span>
194
+ {toggle === "icon" ? (
195
+ <span
196
+ className={clsx(s.icon, isOpen && s.icon_open)}
197
+ aria-hidden="true"
198
+ >
199
+ {chevron}
200
+ </span>
201
+ ) : null}
202
+ </button>
203
+ {item.actions !== undefined ? (
204
+ <div className={s.actions}>{item.actions}</div>
205
+ ) : null}
206
+ {toggle === "button" ? (
207
+ <Button
208
+ type="button"
209
+ variant="bordered"
210
+ size="sm"
211
+ layout="box"
212
+ aria-label={isOpen ? "Collapse section" : "Expand section"}
213
+ aria-expanded={isOpen}
214
+ aria-controls={panelId}
215
+ disabled={item.disabled}
216
+ onClick={onToggle}
217
+ className={clsx(s.toggle, isOpen && s.toggle_open)}
218
+ >
219
+ {chevron}
220
+ </Button>
221
+ ) : null}
222
+ </div>
223
+ ) : (
224
+ <button
225
+ id={triggerId}
226
+ type="button"
227
+ disabled={item.disabled}
228
+ aria-expanded={isOpen}
229
+ aria-controls={panelId}
230
+ onClick={onToggle}
231
+ className={clsx(
232
+ s.trigger,
233
+ s[`trigger_${size}`],
234
+ s[`trigger_${variant}`],
235
+ isOpen && s.trigger_open,
236
+ triggerClassName,
176
237
  )}
177
- </span>
178
- </button>
238
+ >
239
+ <span className={clsx(s.title, s[`title_${size}`])}>{item.title}</span>
240
+ <span
241
+ className={clsx(s.icon, isOpen && s.icon_open)}
242
+ aria-hidden="true"
243
+ >
244
+ {chevron}
245
+ </span>
246
+ </button>
247
+ )}
179
248
 
180
249
  <div
181
250
  id={panelId}
182
251
  role="region"
183
252
  aria-labelledby={triggerId}
184
253
  aria-hidden={!isOpen}
254
+ inert={!isOpen}
185
255
  className={clsx(s.panel, isOpen && s.panel_open)}
186
256
  style={{ height: `${panelHeight}px` }}
187
257
  >
@@ -212,6 +282,7 @@ export const Accordion = ({
212
282
  size = "md",
213
283
  icon,
214
284
  iconRotation = 180,
285
+ toggle = "icon",
215
286
  ...variantProps
216
287
  }: AccordionProps) => {
217
288
  const uid = useId();
@@ -317,6 +388,7 @@ export const Accordion = ({
317
388
  size={size}
318
389
  variant={variant}
319
390
  icon={icon}
391
+ toggle={toggle}
320
392
  itemClassName={itemClassName}
321
393
  triggerClassName={triggerClassName}
322
394
  contentClassName={contentClassName}
@@ -17,10 +17,10 @@ const COLORS = [
17
17
  { bg: "oklch(0.92 0.06 85)", fg: "oklch(0.4 0.15 85)" },
18
18
  { bg: "oklch(0.92 0.05 25)", fg: "oklch(0.4 0.15 25)" },
19
19
  { bg: "oklch(0.92 0.05 330)", fg: "oklch(0.4 0.15 330)" },
20
- ];
20
+ ] as const;
21
21
 
22
22
  export function Avatar({ name, size = "md", className }: AvatarProps) {
23
- const color = COLORS[hashName(name) % COLORS.length];
23
+ const color = COLORS[hashName(name) % COLORS.length] ?? COLORS[0];
24
24
  const initials = getInitials(name) || "?";
25
25
  return (
26
26
  <div
@@ -9,8 +9,7 @@
9
9
  --breadcrumbs-color-current: var(--color-foreground);
10
10
  --breadcrumbs-color-separator: var(--color-muted-foreground);
11
11
  --breadcrumbs-font-size: var(--font-size-sm);
12
- --breadcrumbs-gap: var(--spacing-3);
13
- --breadcrumbs-padding: var(--spacing-2) var(--spacing-3);
12
+ --breadcrumbs-gap: var(--spacing-5);
14
13
 
15
14
  display: block;
16
15
  width: 100%;
@@ -22,8 +21,7 @@
22
21
 
23
22
  .size_sm {
24
23
  --breadcrumbs-font-size: var(--font-size-xs);
25
- --breadcrumbs-gap: var(--spacing-2);
26
- --breadcrumbs-padding: var(--spacing-1) var(--spacing-2);
24
+ --breadcrumbs-gap: var(--spacing-4);
27
25
  }
28
26
 
29
27
  /* ─── List ───────────────────────────────────────────────────────────────── */
@@ -45,15 +43,18 @@
45
43
  min-width: 0;
46
44
  }
47
45
 
48
- /* ─── Crumb (link / button / span) ───────────────────────────────────────── */
46
+ /* ─── Crumb (link / button / span) ───────────────────────────────────────────
47
+ A text link, not a button: no padding or hover fill, so every crumb sits on
48
+ the same edge as the content under it. Hover darkens the text.
49
+ ─────────────────────────────────────────────────────────────────────────── */
49
50
 
50
51
  .crumb {
51
52
  display: inline-flex;
52
53
  align-items: center;
53
54
  gap: var(--spacing-2);
54
55
  min-width: 0;
55
- padding: var(--breadcrumbs-padding);
56
- border-radius: var(--radius-md);
56
+ padding: 0;
57
+ border-radius: var(--radius-sm);
57
58
  color: inherit;
58
59
  background-color: transparent;
59
60
  font: inherit;
@@ -62,7 +63,7 @@
62
63
  white-space: nowrap;
63
64
  cursor: default;
64
65
  transition: var(--transition-fast);
65
- transition-property: background-color, color;
66
+ transition-property: color;
66
67
  }
67
68
 
68
69
  .crumb_link {
@@ -71,7 +72,6 @@
71
72
 
72
73
  &:hover {
73
74
  color: var(--breadcrumbs-color-hover);
74
- background-color: var(--color-muted);
75
75
  }
76
76
 
77
77
  &:focus-visible {
@@ -1,4 +1,4 @@
1
- .base {
1
+ .base {
2
2
  --button-fg: var(--color-foreground);
3
3
  --button-placeholder: var(--button-fg);
4
4
  --button-icon: var(--button-fg);
@@ -11,7 +11,9 @@
11
11
  min-height: var(--spacing-11);
12
12
  border-radius: var(--radius-md);
13
13
  display: flex;
14
- min-width: fit-content;
14
+ /* Never narrower than its label, unless the host sets the variable (a
15
+ full-width field trigger sets 0, so its label truncates). */
16
+ min-width: var(--button-min-width, fit-content);
15
17
  align-items: center;
16
18
  justify-content: space-between;
17
19
  gap: var(--spacing-4);
@@ -24,7 +26,7 @@
24
26
  outline: none;
25
27
  border: none;
26
28
 
27
- line-height: 1.125;
29
+ line-height: 1.125;
28
30
 
29
31
  svg {
30
32
  cursor: pointer;
@@ -36,12 +38,16 @@
36
38
  font-size: var(--font-size-xs);
37
39
  }
38
40
 
39
- &.box {
41
+ /* An icon button: as wide as it is high, its own size and never its
42
+ content's (the base keeps a button at least as wide as its content), so
43
+ a round one stays round; the icon sits in its middle. */
44
+ &.box {
40
45
  width: var(--spacing-11);
46
+ min-width: 0;
41
47
  min-height: unset;
42
48
  justify-content: center;
43
49
  height: var(--spacing-11);
44
- padding: var(--spacing-2);
50
+ padding: 0;
45
51
 
46
52
  &.sm {
47
53
  width: var(--spacing-10);
@@ -183,6 +189,38 @@
183
189
  }
184
190
  }
185
191
 
192
+ /* Same pattern - danger is the soft red a Badge's error tone uses, for an
193
+ action that cannot be taken back: deleting, purging, removing. It reads in
194
+ both themes from the error tokens alone. */
195
+ :is(.danger, .tone_danger) {
196
+ --button-fg: var(--color-error-foreground);
197
+ --button-placeholder: var(--color-error-foreground);
198
+ --button-icon: var(--color-error-foreground);
199
+ }
200
+
201
+ .danger {
202
+ background-color: var(--color-error-subtle);
203
+ border: 1px solid
204
+ color-mix(in oklch, var(--color-error-foreground) 24%, var(--color-border));
205
+ color: var(--button-fg);
206
+ box-shadow: var(--shadow-sm);
207
+
208
+ svg {
209
+ color: var(--button-icon);
210
+ }
211
+
212
+ &:hover:not(.disabled),
213
+ &:active:not(.disabled),
214
+ &.input_focus:focus,
215
+ &.active {
216
+ background-color: color-mix(
217
+ in oklch,
218
+ var(--color-error-foreground) 14%,
219
+ var(--color-error-subtle)
220
+ );
221
+ }
222
+ }
223
+
186
224
  /* Same pattern - ghost is transparent background; tone alias lets composing
187
225
  components inherit these vars without applying the full ghost layout rules. */
188
226
  :is(.ghost, .tone_ghost) {
@@ -12,7 +12,8 @@ export type ButtonVariant =
12
12
  | "secondary"
13
13
  | "bordered"
14
14
  | "border_b"
15
- | "ghost";
15
+ | "ghost"
16
+ | "danger";
16
17
  export type ButtonSize = "default" | "sm";
17
18
  export type ButtonLayout = "default" | "box";
18
19
  export type AsProp = "button" | "div" | "span" | "link";
@@ -1,6 +1,8 @@
1
+ /* A fixed control: long text beside it, as in a table cell, never squeezes it. */
1
2
  .base {
2
3
  display: inline-flex;
3
4
  align-items: center;
5
+ flex-shrink: 0;
4
6
  }
5
7
 
6
8
  .icon {
@@ -181,6 +181,12 @@
181
181
  scrollbar-gutter: auto;
182
182
  }
183
183
 
184
+ /* The columns fit (`columnsFit` in data-table.tsx): nothing to scroll to
185
+ sideways, so a sub-pixel overhang can't raise a horizontal scrollbar. */
186
+ .table_body_wrap[data-fits="true"] {
187
+ overflow-x: hidden;
188
+ }
189
+
184
190
  /* ─────────────────────────────────────────────
185
191
  Shared <table> - header wrap + body wrap.
186
192
  table-layout:fixed + identical widths = perfect column alignment.
@@ -209,6 +215,16 @@
209
215
  tbody tr {
210
216
  transition: background var(--transition-fast);
211
217
 
218
+ /* The last row's line is a shadow, not a border. A shadow never adds to
219
+ the body's scroll height, and the body clips it at its own edge: with
220
+ space left under the rows it closes them, and where the rows reach the
221
+ body's border it is cut off instead of drawing a second line there.
222
+ Placed before the drop target, which wins on a last row. */
223
+ &:last-child td {
224
+ border-bottom: none;
225
+ box-shadow: 0 var(--border-1) 0 var(--color-border);
226
+ }
227
+
212
228
  &:hover td {
213
229
  background: var(--color-secondary);
214
230
  }
@@ -589,55 +605,59 @@
589
605
  flex-shrink: 0;
590
606
  margin-top: auto;
591
607
  width: 100%;
608
+ min-width: 0;
592
609
  display: flex;
610
+ /* The count and the controls share a row while both fit; past that the
611
+ controls take a row of their own below. Nothing scrolls or clips. */
612
+ flex-wrap: wrap;
593
613
  align-items: center;
594
614
  justify-content: space-between;
595
- gap: var(--spacing-8);
596
- min-width: 0;
597
- /* When the count + controls can't fit, the WHOLE bar scrolls horizontally as
598
- one strip rather than wrapping or clipping. The outlet hides X overflow, so
599
- letting a cluster shrink would just clip the nav buttons - scroll the bar
600
- instead. `space-between` still spreads them when there IS room. The vertical
601
- padding gives the buttons' focus rings room so `overflow-x: auto` (which also
602
- clips the Y axis) doesn't crop them. */
603
- overflow-x: auto;
604
- scrollbar-width: thin;
605
- scrollbar-color: var(--color-border) transparent;
606
- padding-block: var(--spacing-2);
607
-
608
- > * {
609
- flex-shrink: 0;
610
- }
615
+ column-gap: var(--spacing-8);
616
+ row-gap: var(--spacing-4);
611
617
  }
612
618
 
613
619
  @media (--sm) {
614
620
  .pagination_bar {
615
- gap: var(--spacing-5);
621
+ column-gap: var(--spacing-5);
616
622
  }
617
623
  }
618
624
 
619
- .pagination_bar_loading {
620
- flex-wrap: wrap;
621
- row-gap: var(--spacing-4);
622
- }
623
-
624
625
  .loading_select_skeleton {
625
626
  border-radius: var(--radius-sm);
626
627
  flex-shrink: 0;
627
628
  }
628
629
 
630
+ /* The count, then what a page adds beside it, like the actions on ticked
631
+ rows. The block sizes from nothing and never below its count, so the
632
+ controls wrap only once the count alone and they cannot share a row; on
633
+ their row it takes what the controls leave, and its extra scrolls there. */
629
634
  .pagination_left {
630
635
  display: flex;
631
636
  align-items: center;
632
637
  gap: var(--spacing-8);
633
- min-width: 0;
638
+ flex: 1 1 0;
639
+ min-width: min-content;
634
640
  }
635
641
 
636
642
  .pagination_left_loading {
637
643
  overflow: hidden;
638
644
  }
639
645
 
646
+ /* The extra slot, a ScrollRail: it takes what the count leaves on the row
647
+ and scrolls across on its own, with no scrollbar, so the bar never changes
648
+ height. Its items claim no width of the row (inline-size containment):
649
+ however many there are, the controls keep their place beside the count. */
650
+ .pagination_extra {
651
+ flex: 1 1 0;
652
+ contain: inline-size;
653
+ }
654
+
655
+ .pagination_extra_items {
656
+ gap: var(--spacing-4);
657
+ }
658
+
640
659
  .pagination_count {
660
+ flex-shrink: 0;
641
661
  font-size: var(--font-size-sm);
642
662
  color: var(--color-muted-foreground);
643
663
  white-space: nowrap;
@@ -650,14 +670,37 @@
650
670
 
651
671
  .pagination_controls {
652
672
  display: flex;
673
+ flex-wrap: wrap;
653
674
  align-items: center;
654
- gap: var(--spacing-8);
675
+ justify-content: flex-end;
676
+ column-gap: var(--spacing-8);
677
+ row-gap: var(--spacing-4);
655
678
  min-width: 0;
656
679
  }
657
680
 
681
+ /* A phone gives the controls a row of their own: the page size at its start,
682
+ the pages at its end. The size keeps its label for screen readers only, and
683
+ the first/last buttons go: prev/next and the page number say as much. */
658
684
  @media (--sm) {
659
685
  .pagination_controls {
660
- gap: var(--spacing-5);
686
+ flex-basis: 100%;
687
+ column-gap: var(--spacing-5);
688
+ }
689
+
690
+ .page_size_wrap {
691
+ margin-inline-end: auto;
692
+ }
693
+
694
+ .page_size_label {
695
+ position: absolute;
696
+ width: 1px;
697
+ height: 1px;
698
+ overflow: hidden;
699
+ clip-path: inset(50%);
700
+ }
701
+
702
+ .page_nav_edge {
703
+ display: none;
661
704
  }
662
705
  }
663
706