aplance-ui 1.2.2 → 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.
@@ -29,6 +29,18 @@
29
29
  }
30
30
  }
31
31
 
32
+ /* On tablet/phone the configured multi-column row can't fit. Stack every field
33
+ full width instead of overflowing the page or scrolling (a scroll container
34
+ would clip the inputs' focus rings). `!important` beats the inline
35
+ grid-template-columns the FilterBar sets per consumer; `stretch` makes each
36
+ field fill the row. */
37
+ @media (--md) {
38
+ .filter_bar {
39
+ grid-template-columns: 1fr !important;
40
+ justify-items: stretch;
41
+ }
42
+ }
43
+
32
44
  /* ─────────────────────────────────────────────
33
45
  Content wrap
34
46
  Grows to fill all space below the filter bar.
@@ -113,12 +125,25 @@
113
125
  min-height: 0;
114
126
  overflow-x: auto;
115
127
  overflow-y: auto;
128
+ /* Reserve the scrollbar gutter by DEFAULT so clientWidth is stable: the
129
+ vertical scrollbar (when rows overflow) sits in the reserved gutter instead
130
+ of stealing ~15px and triggering an unexpected horizontal scrollbar. This
131
+ default also covers the first paint before JS measures. */
132
+ scrollbar-gutter: stable;
116
133
  border: var(--border-1) solid var(--color-border);
117
134
  border-radius: var(--radius-md);
118
135
  background: var(--color-background);
119
136
  position: relative;
120
137
  }
121
138
 
139
+ /* Once JS confirms the body does NOT overflow vertically (no scrollbar to make
140
+ room for), release the gutter so the content fills to the border - otherwise
141
+ a few-row table shows a blank ~15px strip on the right. `data-has-scroll` is
142
+ set from `scrollHeight > clientHeight` in syncHeaderMetrics. */
143
+ .table_body_wrap[data-has-scroll="false"] {
144
+ scrollbar-gutter: auto;
145
+ }
146
+
122
147
  /* ─────────────────────────────────────────────
123
148
  Shared <table> - header wrap + body wrap.
124
149
  table-layout:fixed + identical widths = perfect column alignment.
@@ -536,6 +561,26 @@
536
561
  justify-content: space-between;
537
562
  gap: var(--spacing-8);
538
563
  min-width: 0;
564
+ /* When the count + controls can't fit, the WHOLE bar scrolls horizontally as
565
+ one strip rather than wrapping or clipping. The outlet hides X overflow, so
566
+ letting a cluster shrink would just clip the nav buttons - scroll the bar
567
+ instead. `space-between` still spreads them when there IS room. The vertical
568
+ padding gives the buttons' focus rings room so `overflow-x: auto` (which also
569
+ clips the Y axis) doesn't crop them. */
570
+ overflow-x: auto;
571
+ scrollbar-width: thin;
572
+ scrollbar-color: var(--color-border) transparent;
573
+ padding-block: var(--spacing-2);
574
+
575
+ > * {
576
+ flex-shrink: 0;
577
+ }
578
+ }
579
+
580
+ @media (--sm) {
581
+ .pagination_bar {
582
+ gap: var(--spacing-5);
583
+ }
539
584
  }
540
585
 
541
586
  .pagination_bar_loading {
@@ -548,18 +593,17 @@
548
593
  flex-shrink: 0;
549
594
  }
550
595
 
551
- .pagination_left {
552
- display: flex;
553
- align-items: center;
554
- gap: var(--spacing-8);
555
- min-width: 0;
556
- flex-shrink: 1;
557
- }
558
-
559
- .pagination_left_loading {
560
- overflow: hidden;
561
- }
562
-
596
+ .pagination_left {
597
+ display: flex;
598
+ align-items: center;
599
+ gap: var(--spacing-8);
600
+ min-width: 0;
601
+ }
602
+
603
+ .pagination_left_loading {
604
+ overflow: hidden;
605
+ }
606
+
563
607
  .pagination_count {
564
608
  font-size: var(--font-size-sm);
565
609
  color: var(--color-muted-foreground);
@@ -576,23 +620,28 @@
576
620
  align-items: center;
577
621
  gap: var(--spacing-8);
578
622
  min-width: 0;
579
- flex-shrink: 1;
580
623
  }
581
624
 
582
- .pagination_controls_loading {
583
- flex-direction: row;
584
- flex-wrap: wrap;
585
- align-items: center;
586
- justify-content: flex-end;
587
- max-width: 100%;
588
- row-gap: var(--spacing-4);
589
- }
590
-
591
- .pagination_controls_loading > * {
592
- flex: 0 1 auto;
593
- min-width: 0;
594
- max-width: 100%;
595
- }
625
+ @media (--sm) {
626
+ .pagination_controls {
627
+ gap: var(--spacing-5);
628
+ }
629
+ }
630
+
631
+ .pagination_controls_loading {
632
+ flex-direction: row;
633
+ flex-wrap: wrap;
634
+ align-items: center;
635
+ justify-content: flex-end;
636
+ max-width: 100%;
637
+ row-gap: var(--spacing-4);
638
+ }
639
+
640
+ .pagination_controls_loading > * {
641
+ flex: 0 1 auto;
642
+ min-width: 0;
643
+ max-width: 100%;
644
+ }
596
645
 
597
646
  .pagination_text_skeleton {
598
647
  height: 1em;
@@ -616,14 +665,14 @@
616
665
  flex: 0 0 auto;
617
666
  }
618
667
 
619
- .page_size_wrap {
620
- display: flex;
621
- align-items: center;
622
- gap: var(--spacing-5);
623
- min-width: 0;
624
- flex-shrink: 1;
625
- max-width: 100%;
626
- }
668
+ .page_size_wrap {
669
+ display: flex;
670
+ align-items: center;
671
+ gap: var(--spacing-5);
672
+ min-width: 0;
673
+ flex-shrink: 1;
674
+ max-width: 100%;
675
+ }
627
676
 
628
677
  .page_size_label {
629
678
  font-size: var(--font-size-sm);
@@ -644,18 +693,18 @@
644
693
  }
645
694
  }
646
695
 
647
- .page_nav {
648
- display: flex;
649
- align-items: center;
650
- gap: var(--spacing-2);
651
- flex-shrink: 0;
652
- }
653
-
654
- .page_nav_loading {
655
- flex-wrap: wrap;
656
- justify-content: flex-end;
657
- row-gap: var(--spacing-2);
658
- min-width: 0;
659
- max-width: 100%;
660
- flex-shrink: 1;
661
- }
696
+ .page_nav {
697
+ display: flex;
698
+ align-items: center;
699
+ gap: var(--spacing-2);
700
+ flex-shrink: 0;
701
+ }
702
+
703
+ .page_nav_loading {
704
+ flex-wrap: wrap;
705
+ justify-content: flex-end;
706
+ row-gap: var(--spacing-2);
707
+ min-width: 0;
708
+ max-width: 100%;
709
+ flex-shrink: 1;
710
+ }
@@ -345,11 +345,11 @@ export const DataTable = <TData extends { id: string }>({
345
345
  // ── Sync header/body scrolling ──────────────────────────────────────────────
346
346
  // The header lives in its own `overflow:hidden` container; we drive its
347
347
  // scrollLeft from the body's scrollLeft on every scroll event so pinned
348
- // columns stay perfectly aligned with their body counterparts.
349
- const headerRef = useRef<HTMLDivElement>(null);
350
- const bodyRef = useRef<HTMLDivElement>(null);
351
- const bodyTableRef = useRef<HTMLTableElement>(null);
352
- const skeletonRowRef = useRef<HTMLTableRowElement>(null);
348
+ // columns stay perfectly aligned with their body counterparts.
349
+ const headerRef = useRef<HTMLDivElement>(null);
350
+ const bodyRef = useRef<HTMLDivElement>(null);
351
+ const bodyTableRef = useRef<HTMLTableElement>(null);
352
+ const skeletonRowRef = useRef<HTMLTableRowElement>(null);
353
353
  const rows = table.getRowModel().rows;
354
354
  const headerCount = table.getFlatHeaders().length;
355
355
  const totalWidth = table.getTotalSize();
@@ -402,6 +402,9 @@ export const DataTable = <TData extends { id: string }>({
402
402
  paddingRight,
403
403
  );
404
404
  header.parentElement?.setAttribute("data-has-scroll", hasScroll.toString());
405
+ // Mirror onto the body wrap so CSS can release the reserved scrollbar gutter
406
+ // when there's no vertical overflow (no blank strip on a few-row table).
407
+ body.setAttribute("data-has-scroll", hasScroll.toString());
405
408
 
406
409
  // Re-sync horizontal scroll position after width changes so pinned
407
410
  // columns stay perfectly aligned with their body counterparts.
@@ -465,7 +468,7 @@ export const DataTable = <TData extends { id: string }>({
465
468
  }, []);
466
469
 
467
470
  // ── Derived UI state ────────────────────────────────────────────────────────
468
- // Defer the empty state by a tick after a loading-to-idle transition so the
471
+ // Defer the empty state by a tick after a loading-to-idle transition so the
469
472
  // loader doesn't flicker into "no results" for one paint before rows land.
470
473
  // Genuinely empty results (no in-flight load) commit immediately on mount.
471
474
  const isRowsEmpty = rows.length === 0;
@@ -474,6 +477,9 @@ export const DataTable = <TData extends { id: string }>({
474
477
  );
475
478
  const wasLoadingRef = useRef(isLoading);
476
479
 
480
+ // wasLoadingRef is intentionally omitted from the dep array: it's a ref used
481
+ // to track the previous value of isLoading across renders without triggering
482
+ // re-runs. React guarantees ref objects are stable across the component lifetime.
477
483
  useEffect(() => {
478
484
  const wasLoading = wasLoadingRef.current;
479
485
  wasLoadingRef.current = isLoading;
@@ -482,18 +488,19 @@ export const DataTable = <TData extends { id: string }>({
482
488
  setShowEmptyState(false);
483
489
  return;
484
490
  }
485
- if (wasLoading) {
486
- const id = window.setTimeout(
487
- () => setShowEmptyState(true),
488
- EMPTY_STATE_GRACE_MS,
489
- );
490
- return () => window.clearTimeout(id);
491
+ if (!wasLoading) {
492
+ setShowEmptyState(true);
493
+ return;
491
494
  }
492
- setShowEmptyState(true);
495
+ const id = window.setTimeout(
496
+ () => setShowEmptyState(true),
497
+ EMPTY_STATE_GRACE_MS,
498
+ );
499
+ return () => window.clearTimeout(id);
493
500
  }, [isLoading, isRowsEmpty]);
494
501
 
495
502
  // Single tab-like discriminator drives the body - loader, empty, or content.
496
- // While loading OR while bridging the loading-to-empty grace window, the
503
+ // While loading OR while bridging the loading-to-empty grace window, the
497
504
  // loader stays visible to avoid the brief "no results" flash.
498
505
  const view: "loader" | "empty" | "content" =
499
506
  isLoading || (isRowsEmpty && !showEmptyState)
@@ -525,20 +532,20 @@ export const DataTable = <TData extends { id: string }>({
525
532
  const selectedCount = rowsSelectable
526
533
  ? table.getSelectedRowModel().flatRows.length
527
534
  : 0;
528
- const headerGroups = table.getHeaderGroups();
529
- const visibleLeafColumns = table.getVisibleLeafColumns();
530
-
531
- const skeletonRowCount =
532
- bodyViewportHeight > 0 && skeletonRowHeight > 0
533
- ? Math.max(
535
+ const headerGroups = table.getHeaderGroups();
536
+ const visibleLeafColumns = table.getVisibleLeafColumns();
537
+
538
+ const skeletonRowCount =
539
+ bodyViewportHeight > 0 && skeletonRowHeight > 0
540
+ ? Math.max(
534
541
  MIN_SKELETON_ROWS,
535
542
  Math.floor(bodyViewportHeight / skeletonRowHeight),
536
543
  )
537
544
  : MIN_SKELETON_ROWS;
538
545
 
539
- const columnExtraWidths = useMemo<Record<string, number>>(() => {
540
- const extraSpace = Math.max(0, bodyViewportWidth - totalWidth);
541
- if (extraSpace <= 0) return EMPTY_COLUMN_EXTRA_WIDTHS;
546
+ const columnExtraWidths = useMemo<Record<string, number>>(() => {
547
+ const extraSpace = Math.max(0, bodyViewportWidth - totalWidth);
548
+ if (extraSpace <= 0) return EMPTY_COLUMN_EXTRA_WIDTHS;
542
549
 
543
550
  // Never add responsive width onto pinned columns because tanstack computes
544
551
  // sticky offsets from base sizes; changing pinned visual width would desync
@@ -561,27 +568,27 @@ export const DataTable = <TData extends { id: string }>({
561
568
  remainder -= 1;
562
569
  }
563
570
  }
564
-
565
- return next;
566
- }, [bodyViewportWidth, totalWidth, visibleLeafColumns]);
567
-
568
- const tableWidth = Math.max(totalWidth, bodyViewportWidth);
569
-
570
- // Match the table element width to the measured column widths. This keeps
571
- // header, body, and skeleton rows on the same sizing contract during resize.
572
- const tableSizingStyle: CSSProperties = useMemo(
573
- () => ({ width: `${tableWidth}px`, minWidth: `${totalWidth}px` }),
574
- [tableWidth, totalWidth],
575
- );
576
-
577
- const bodySkeletonStyle: CSSProperties = {
578
- height: "var(--spacing-9)",
579
- };
580
-
581
- const selectSkeletonStyle: CSSProperties = {
582
- width: "var(--data-table-selection-control-size, var(--spacing-9))",
583
- height: "var(--data-table-selection-control-size, var(--spacing-9))",
584
- };
571
+
572
+ return next;
573
+ }, [bodyViewportWidth, totalWidth, visibleLeafColumns]);
574
+
575
+ const tableWidth = Math.max(totalWidth, bodyViewportWidth);
576
+
577
+ // Match the table element width to the measured column widths. This keeps
578
+ // header, body, and skeleton rows on the same sizing contract during resize.
579
+ const tableSizingStyle: CSSProperties = useMemo(
580
+ () => ({ width: `${tableWidth}px`, minWidth: `${totalWidth}px` }),
581
+ [tableWidth, totalWidth],
582
+ );
583
+
584
+ const bodySkeletonStyle: CSSProperties = {
585
+ height: "var(--spacing-9)",
586
+ };
587
+
588
+ const selectSkeletonStyle: CSSProperties = {
589
+ width: "var(--data-table-selection-control-size, var(--spacing-9))",
590
+ height: "var(--data-table-selection-control-size, var(--spacing-9))",
591
+ };
585
592
 
586
593
  const tableClass = clsx(
587
594
  s.table,
@@ -644,66 +651,65 @@ export const DataTable = <TData extends { id: string }>({
644
651
  {/* Scrollable body - only this element scrolls. The body is a
645
652
  single switch over `view`: at most one branch ever renders, the
646
653
  other two are unmounted entirely. */}
647
- <div
648
- className={s.table_body_wrap}
649
- style={{ pointerEvents: isLoading ? "none" : undefined }}
650
- ref={bodyRef}
651
- >
652
- {view === "loader" && (
653
- <table className={tableClass} style={tableSizingStyle}>
654
- <tbody>
655
- {Array.from({ length: skeletonRowCount }).map(
656
- (_, rowIndex) => (
657
- <tr
658
- key={`skeleton-${rowIndex}`}
659
- ref={rowIndex === 0 ? skeletonRowRef : undefined}
660
- >
661
- {visibleLeafColumns.map((column) => {
662
- const isPinned = column.getIsPinned();
663
- const isLastPinnedCol =
664
- isPinned && isLastPinned(column);
665
- const isSelectColumn =
666
- column.id === SELECT_COLUMN_ID;
667
-
668
- return (
669
- <td
670
- key={column.id}
671
- className={clsx(
672
- isSelectColumn && s.col_select,
673
- isPinned === "left" && s.col_pinned_left,
674
- isPinned === "right" && s.col_pinned_right,
675
- isLastPinnedCol && s.col_pinned_last,
676
- )}
677
- style={getColumnStyle(
678
- column,
679
- column.getSize(),
680
- columnExtraWidths[column.id] ?? 0,
681
- )}
682
- >
683
- <div className={s.td_inner}>
684
- <Loader
685
- variant="skeleton"
686
- className={
687
- isSelectColumn
688
- ? s.loading_select_skeleton
689
- : undefined
690
- }
691
- style={
692
- isSelectColumn
693
- ? selectSkeletonStyle
694
- : bodySkeletonStyle
695
- }
696
- />
697
- </div>
698
- </td>
699
- );
700
- })}
701
- </tr>
702
- ),
703
- )}
704
- </tbody>
705
- </table>
706
- )}
654
+ <div
655
+ className={s.table_body_wrap}
656
+ style={{ pointerEvents: isLoading ? "none" : undefined }}
657
+ ref={bodyRef}
658
+ >
659
+ {view === "loader" && (
660
+ <table className={tableClass} style={tableSizingStyle}>
661
+ <tbody>
662
+ {Array.from({ length: skeletonRowCount }).map(
663
+ (_, rowIndex) => (
664
+ <tr
665
+ key={`skeleton-${rowIndex}`}
666
+ ref={rowIndex === 0 ? skeletonRowRef : undefined}
667
+ >
668
+ {visibleLeafColumns.map((column) => {
669
+ const isPinned = column.getIsPinned();
670
+ const isLastPinnedCol =
671
+ isPinned && isLastPinned(column);
672
+ const isSelectColumn = column.id === SELECT_COLUMN_ID;
673
+
674
+ return (
675
+ <td
676
+ key={column.id}
677
+ className={clsx(
678
+ isSelectColumn && s.col_select,
679
+ isPinned === "left" && s.col_pinned_left,
680
+ isPinned === "right" && s.col_pinned_right,
681
+ isLastPinnedCol && s.col_pinned_last,
682
+ )}
683
+ style={getColumnStyle(
684
+ column,
685
+ column.getSize(),
686
+ columnExtraWidths[column.id] ?? 0,
687
+ )}
688
+ >
689
+ <div className={s.td_inner}>
690
+ <Loader
691
+ variant="skeleton"
692
+ className={
693
+ isSelectColumn
694
+ ? s.loading_select_skeleton
695
+ : undefined
696
+ }
697
+ style={
698
+ isSelectColumn
699
+ ? selectSkeletonStyle
700
+ : bodySkeletonStyle
701
+ }
702
+ />
703
+ </div>
704
+ </td>
705
+ );
706
+ })}
707
+ </tr>
708
+ ),
709
+ )}
710
+ </tbody>
711
+ </table>
712
+ )}
707
713
  {view === "empty" && (
708
714
  <div className={s.state_overlay}>
709
715
  <p className={s.empty_msg}>{emptyMessage}</p>
@@ -34,6 +34,9 @@
34
34
  /* Option button */
35
35
  .option {
36
36
  width: 100%;
37
+ /* button base is min-width: fit-content - a long label must truncate
38
+ inside the row, never widen it past the menu */
39
+ min-width: 0;
37
40
  /* button has justify-content: space-between */
38
41
  justify-content: flex-start;
39
42
  /* modifying padding of actual button variant for consistency */
@@ -42,8 +45,14 @@
42
45
 
43
46
  /* > icon */
44
47
 
48
+ /* The label owns the row's slack space so trailing affordances
49
+ (.option_delete) always sit at the row's end - layout-driven, no
50
+ auto-margins (those tie with the Icon primitive's own margin reset
51
+ and the winner would depend on CSS bundle order). */
45
52
  .option_label {
46
- max-width: 100% !important;
53
+ flex: 1 1 auto;
54
+ min-width: 0;
55
+ text-align: start;
47
56
  }
48
57
  }
49
58
  }
@@ -53,4 +62,15 @@
53
62
  max-width: 100% !important;
54
63
  }
55
64
 
65
+ /* Trailing per-option delete affordance (option.onDelete). The growing
66
+ .option_label pushes it to the row's end; muted until hovered, then
67
+ error-toned. Click is stopPropagation'd in the component so it never
68
+ selects the option. */
69
+ .option_delete {
70
+ flex-shrink: 0;
71
+ --icon-color: var(--color-muted-foreground);
56
72
 
73
+ &:hover {
74
+ --icon-color: var(--color-error-foreground);
75
+ }
76
+ }
@@ -9,7 +9,7 @@ import {
9
9
  useState,
10
10
  } from "react";
11
11
  import clsx from "clsx";
12
- import { IconPlus } from "@tabler/icons-react";
12
+ import { IconPlus, IconTrash } from "@tabler/icons-react";
13
13
  import { Input } from "../input/input";
14
14
  import {
15
15
  Popover,
@@ -22,10 +22,7 @@ import sPickerCommon from "../common/picker-popover-common.module.css";
22
22
  import { Button, type ButtonVariant } from "../../button/button";
23
23
  import s from "./custom-select.module.css";
24
24
  import { FieldMessage } from "../common/field-message/field-message";
25
- import {
26
- SelectButton,
27
- type SelectButtonProps,
28
- } from "../common/select-button/select-button";
25
+ import { SelectButton, type SelectButtonProps } from "../common/select-button/select-button";
29
26
  import { Icon } from "../../../primitives/icon/icon";
30
27
  import type { BaseInputFieldProps } from "../common/types";
31
28
 
@@ -38,6 +35,10 @@ export type CustomSelectOption = {
38
35
  optionButtonVariant?: ButtonVariant;
39
36
  applyToSelector?: boolean;
40
37
  disabled?: boolean;
38
+ /** Renders a trailing delete affordance on the option row. Clicking it does
39
+ * NOT select the option - the consumer typically opens a confirm dialog and
40
+ * performs the deletion (e.g. removing a folder/tag from its pool). */
41
+ onDelete?: () => void;
41
42
  };
42
43
 
43
44
  export type CustomSelectProps = Omit<
@@ -161,7 +162,7 @@ export const CustomSelect = ({
161
162
  [actualValue],
162
163
  );
163
164
 
164
- // A selected value may not exist in `options`, for example when `allowCreate` is
165
+ // A selected value may not exist in `options` - e.g. when `allowCreate` is
165
166
  // on and the consumer just stores the value without pushing it back into
166
167
  // the options list. Synthesize a "ghost" entry so the trigger displays it,
167
168
  // the menu shows it (multi-select can uncheck), and the "Create" check
@@ -229,13 +230,7 @@ export const CustomSelect = ({
229
230
  ? selectedOption.optionStyle
230
231
  : undefined,
231
232
  };
232
- }, [
233
- multiple,
234
- optionsWithSelected,
235
- placeholder,
236
- selectedValues,
237
- showDisplayIcon,
238
- ]);
233
+ }, [multiple, optionsWithSelected, placeholder, selectedValues, showDisplayIcon]);
239
234
 
240
235
  const popoverOnClose = popover?.onClose;
241
236
  const handlePopoverClose = useCallback(() => {
@@ -422,7 +417,9 @@ export const CustomSelect = ({
422
417
  <Icon size="sm" aria-hidden="true">
423
418
  <IconPlus />
424
419
  </Icon>
425
- <span className={clsx(s.option_label, sCommon.truncate_text)}>
420
+ <span
421
+ className={clsx(s.option_label, sCommon.truncate_text)}
422
+ >
426
423
  Create &ldquo;{trimmedTerm}&rdquo;
427
424
  </span>
428
425
  </Button>
@@ -455,6 +452,24 @@ export const CustomSelect = ({
455
452
  >
456
453
  {option.label}
457
454
  </span>
455
+ {option.onDelete && interactive ? (
456
+ <Icon
457
+ size="sm"
458
+ clickable
459
+ className={s.option_delete}
460
+ aria-label={`Delete ${option.label}`}
461
+ onClick={(e) => {
462
+ e.stopPropagation();
463
+ // Close the popover BEFORE handing off - the consumer
464
+ // opens a confirm dialog, which must not sit under
465
+ // the still-open popover in the stacking order.
466
+ setIsOpen(false);
467
+ option.onDelete?.();
468
+ }}
469
+ >
470
+ <IconTrash />
471
+ </Icon>
472
+ ) : null}
458
473
  </Button>
459
474
  ))
460
475
  )}