aplance-ui 1.2.2 → 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.
- package/package.json +1 -1
- package/registry/components/primitives/modal/modal.tsx +16 -10
- package/registry/components/primitives/popover/popover-context.ts +33 -28
- package/registry/components/primitives/popover/popover.tsx +145 -26
- package/registry/components/primitives/slider/slider.module.css +62 -5
- package/registry/components/primitives/slider/slider.tsx +192 -34
- package/registry/components/primitives/slider/types.ts +56 -3
- package/registry/components/primitives/slider/utils.ts +9 -0
- package/registry/components/ui/copy-button/copy-button.tsx +5 -0
- package/registry/components/ui/data-table/data-table.module.css +695 -613
- package/registry/components/ui/data-table/data-table.tsx +136 -112
- package/registry/components/ui/data-table/filter-modal.tsx +86 -0
- package/registry/components/ui/data-table/types.ts +8 -0
- package/registry/components/ui/data-table/useDataTable.tsx +1 -0
- package/registry/components/ui/data-table/utils.ts +26 -1
- package/registry/components/ui/input-fields/custom-select/custom-select.module.css +32 -1
- package/registry/components/ui/input-fields/custom-select/custom-select.tsx +500 -469
- package/registry/components/ui/input-fields/date-picker/date-picker.tsx +940 -933
- package/registry/components/ui/input-fields/date-picker/types.ts +2 -0
- package/registry/components/ui/input-fields/emoji-picker/emoji-picker.tsx +8 -0
- package/registry/components/ui/input-fields/input/input.tsx +80 -69
- package/registry/components/ui/tabs/tabs.module.css +22 -8
- package/registry/components/ui/tabs/tabs.tsx +29 -12
- package/registry/hooks/useHasMounted.ts +22 -0
- package/registry/hooks/useMediaQuery.ts +38 -0
- package/registry/registry.json +41 -4
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
} from "react";
|
|
25
25
|
import clsx from "clsx";
|
|
26
26
|
import { FilterBar } from "./filter-bar";
|
|
27
|
+
import { FilterModal } from "./filter-modal";
|
|
27
28
|
import { PaginationBar } from "./pagination-bar";
|
|
28
29
|
import { Loader } from "../loader/loader";
|
|
29
30
|
import type {
|
|
@@ -60,6 +61,12 @@ import {
|
|
|
60
61
|
} from "@tabler/icons-react";
|
|
61
62
|
import { getScrollbarWidth } from "../../../lib/dom";
|
|
62
63
|
import { Button } from "../button/button";
|
|
64
|
+
import { useMediaQuery } from "../../../hooks/useMediaQuery";
|
|
65
|
+
|
|
66
|
+
// Below this width the inline filter row can't fit its fields (matches the
|
|
67
|
+
// `--md` custom-media token in styles/media.css); the table collapses filters
|
|
68
|
+
// into a single "Filters" button + modal instead of stacking them full-height.
|
|
69
|
+
const FILTER_COMPACT_QUERY = "(max-width: 900px)";
|
|
63
70
|
|
|
64
71
|
const DEFAULT_PAGE_SIZE_OPTIONS = [10, 20, 50];
|
|
65
72
|
const DEFAULT_PAGINATION = { pageIndex: 0, pageSize: 10 };
|
|
@@ -143,6 +150,9 @@ export const DataTable = <TData extends { id: string }>({
|
|
|
143
150
|
paginationExtra,
|
|
144
151
|
className,
|
|
145
152
|
}: DataTableProps<TData>) => {
|
|
153
|
+
// Collapse the filter row into a button + modal once it no longer fits inline.
|
|
154
|
+
const filtersCompact = useMediaQuery(FILTER_COMPACT_QUERY);
|
|
155
|
+
|
|
146
156
|
// ── Table meta ──────────────────────────────────────────────────────────────
|
|
147
157
|
const meta: DataTableMeta<TData> = useMemo(
|
|
148
158
|
() => ({ editingRowId, updateData: onUpdateData }),
|
|
@@ -345,11 +355,11 @@ export const DataTable = <TData extends { id: string }>({
|
|
|
345
355
|
// ── Sync header/body scrolling ──────────────────────────────────────────────
|
|
346
356
|
// The header lives in its own `overflow:hidden` container; we drive its
|
|
347
357
|
// 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);
|
|
358
|
+
// columns stay perfectly aligned with their body counterparts.
|
|
359
|
+
const headerRef = useRef<HTMLDivElement>(null);
|
|
360
|
+
const bodyRef = useRef<HTMLDivElement>(null);
|
|
361
|
+
const bodyTableRef = useRef<HTMLTableElement>(null);
|
|
362
|
+
const skeletonRowRef = useRef<HTMLTableRowElement>(null);
|
|
353
363
|
const rows = table.getRowModel().rows;
|
|
354
364
|
const headerCount = table.getFlatHeaders().length;
|
|
355
365
|
const totalWidth = table.getTotalSize();
|
|
@@ -402,6 +412,9 @@ export const DataTable = <TData extends { id: string }>({
|
|
|
402
412
|
paddingRight,
|
|
403
413
|
);
|
|
404
414
|
header.parentElement?.setAttribute("data-has-scroll", hasScroll.toString());
|
|
415
|
+
// Mirror onto the body wrap so CSS can release the reserved scrollbar gutter
|
|
416
|
+
// when there's no vertical overflow (no blank strip on a few-row table).
|
|
417
|
+
body.setAttribute("data-has-scroll", hasScroll.toString());
|
|
405
418
|
|
|
406
419
|
// Re-sync horizontal scroll position after width changes so pinned
|
|
407
420
|
// columns stay perfectly aligned with their body counterparts.
|
|
@@ -465,7 +478,7 @@ export const DataTable = <TData extends { id: string }>({
|
|
|
465
478
|
}, []);
|
|
466
479
|
|
|
467
480
|
// ── Derived UI state ────────────────────────────────────────────────────────
|
|
468
|
-
// Defer the empty state by a tick after a loading-to-idle transition so the
|
|
481
|
+
// Defer the empty state by a tick after a loading-to-idle transition so the
|
|
469
482
|
// loader doesn't flicker into "no results" for one paint before rows land.
|
|
470
483
|
// Genuinely empty results (no in-flight load) commit immediately on mount.
|
|
471
484
|
const isRowsEmpty = rows.length === 0;
|
|
@@ -474,6 +487,9 @@ export const DataTable = <TData extends { id: string }>({
|
|
|
474
487
|
);
|
|
475
488
|
const wasLoadingRef = useRef(isLoading);
|
|
476
489
|
|
|
490
|
+
// wasLoadingRef is intentionally omitted from the dep array: it's a ref used
|
|
491
|
+
// to track the previous value of isLoading across renders without triggering
|
|
492
|
+
// re-runs. React guarantees ref objects are stable across the component lifetime.
|
|
477
493
|
useEffect(() => {
|
|
478
494
|
const wasLoading = wasLoadingRef.current;
|
|
479
495
|
wasLoadingRef.current = isLoading;
|
|
@@ -482,18 +498,19 @@ export const DataTable = <TData extends { id: string }>({
|
|
|
482
498
|
setShowEmptyState(false);
|
|
483
499
|
return;
|
|
484
500
|
}
|
|
485
|
-
if (wasLoading) {
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
EMPTY_STATE_GRACE_MS,
|
|
489
|
-
);
|
|
490
|
-
return () => window.clearTimeout(id);
|
|
501
|
+
if (!wasLoading) {
|
|
502
|
+
setShowEmptyState(true);
|
|
503
|
+
return;
|
|
491
504
|
}
|
|
492
|
-
|
|
505
|
+
const id = window.setTimeout(
|
|
506
|
+
() => setShowEmptyState(true),
|
|
507
|
+
EMPTY_STATE_GRACE_MS,
|
|
508
|
+
);
|
|
509
|
+
return () => window.clearTimeout(id);
|
|
493
510
|
}, [isLoading, isRowsEmpty]);
|
|
494
511
|
|
|
495
512
|
// Single tab-like discriminator drives the body - loader, empty, or content.
|
|
496
|
-
// While loading OR while bridging the loading-to-empty grace window, the
|
|
513
|
+
// While loading OR while bridging the loading-to-empty grace window, the
|
|
497
514
|
// loader stays visible to avoid the brief "no results" flash.
|
|
498
515
|
const view: "loader" | "empty" | "content" =
|
|
499
516
|
isLoading || (isRowsEmpty && !showEmptyState)
|
|
@@ -525,20 +542,20 @@ export const DataTable = <TData extends { id: string }>({
|
|
|
525
542
|
const selectedCount = rowsSelectable
|
|
526
543
|
? table.getSelectedRowModel().flatRows.length
|
|
527
544
|
: 0;
|
|
528
|
-
const headerGroups = table.getHeaderGroups();
|
|
529
|
-
const visibleLeafColumns = table.getVisibleLeafColumns();
|
|
530
|
-
|
|
531
|
-
const skeletonRowCount =
|
|
532
|
-
bodyViewportHeight > 0 && skeletonRowHeight > 0
|
|
533
|
-
? Math.max(
|
|
545
|
+
const headerGroups = table.getHeaderGroups();
|
|
546
|
+
const visibleLeafColumns = table.getVisibleLeafColumns();
|
|
547
|
+
|
|
548
|
+
const skeletonRowCount =
|
|
549
|
+
bodyViewportHeight > 0 && skeletonRowHeight > 0
|
|
550
|
+
? Math.max(
|
|
534
551
|
MIN_SKELETON_ROWS,
|
|
535
552
|
Math.floor(bodyViewportHeight / skeletonRowHeight),
|
|
536
553
|
)
|
|
537
554
|
: MIN_SKELETON_ROWS;
|
|
538
555
|
|
|
539
|
-
const columnExtraWidths = useMemo<Record<string, number>>(() => {
|
|
540
|
-
const extraSpace = Math.max(0, bodyViewportWidth - totalWidth);
|
|
541
|
-
if (extraSpace <= 0) return EMPTY_COLUMN_EXTRA_WIDTHS;
|
|
556
|
+
const columnExtraWidths = useMemo<Record<string, number>>(() => {
|
|
557
|
+
const extraSpace = Math.max(0, bodyViewportWidth - totalWidth);
|
|
558
|
+
if (extraSpace <= 0) return EMPTY_COLUMN_EXTRA_WIDTHS;
|
|
542
559
|
|
|
543
560
|
// Never add responsive width onto pinned columns because tanstack computes
|
|
544
561
|
// sticky offsets from base sizes; changing pinned visual width would desync
|
|
@@ -561,27 +578,27 @@ export const DataTable = <TData extends { id: string }>({
|
|
|
561
578
|
remainder -= 1;
|
|
562
579
|
}
|
|
563
580
|
}
|
|
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
|
-
};
|
|
581
|
+
|
|
582
|
+
return next;
|
|
583
|
+
}, [bodyViewportWidth, totalWidth, visibleLeafColumns]);
|
|
584
|
+
|
|
585
|
+
const tableWidth = Math.max(totalWidth, bodyViewportWidth);
|
|
586
|
+
|
|
587
|
+
// Match the table element width to the measured column widths. This keeps
|
|
588
|
+
// header, body, and skeleton rows on the same sizing contract during resize.
|
|
589
|
+
const tableSizingStyle: CSSProperties = useMemo(
|
|
590
|
+
() => ({ width: `${tableWidth}px`, minWidth: `${totalWidth}px` }),
|
|
591
|
+
[tableWidth, totalWidth],
|
|
592
|
+
);
|
|
593
|
+
|
|
594
|
+
const bodySkeletonStyle: CSSProperties = {
|
|
595
|
+
height: "var(--spacing-9)",
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
const selectSkeletonStyle: CSSProperties = {
|
|
599
|
+
width: "var(--data-table-selection-control-size, var(--spacing-9))",
|
|
600
|
+
height: "var(--data-table-selection-control-size, var(--spacing-9))",
|
|
601
|
+
};
|
|
585
602
|
|
|
586
603
|
const tableClass = clsx(
|
|
587
604
|
s.table,
|
|
@@ -590,14 +607,22 @@ export const DataTable = <TData extends { id: string }>({
|
|
|
590
607
|
|
|
591
608
|
return (
|
|
592
609
|
<div className={clsx(s.root, className)}>
|
|
593
|
-
{/*
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
610
|
+
{/* Filters - inline row on desktop; a "Filters" button + modal once the
|
|
611
|
+
row no longer fits (so phones aren't buried under stacked fields). */}
|
|
612
|
+
{filters.length > 0 &&
|
|
613
|
+
(filtersCompact ? (
|
|
614
|
+
<FilterModal
|
|
615
|
+
fields={filters}
|
|
616
|
+
values={filterValues}
|
|
617
|
+
onChange={onFilterChange}
|
|
618
|
+
/>
|
|
619
|
+
) : (
|
|
620
|
+
<FilterBar
|
|
621
|
+
fields={filters}
|
|
622
|
+
values={filterValues}
|
|
623
|
+
onChange={onFilterChange}
|
|
624
|
+
/>
|
|
625
|
+
))}
|
|
601
626
|
|
|
602
627
|
{/* Content wrap - grows to fill remaining height.
|
|
603
628
|
table_shell fills it; pagination pins to the bottom via margin-top:auto */}
|
|
@@ -644,66 +669,65 @@ export const DataTable = <TData extends { id: string }>({
|
|
|
644
669
|
{/* Scrollable body - only this element scrolls. The body is a
|
|
645
670
|
single switch over `view`: at most one branch ever renders, the
|
|
646
671
|
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
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
isPinned === "
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
column,
|
|
679
|
-
column.
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
)}
|
|
672
|
+
<div
|
|
673
|
+
className={s.table_body_wrap}
|
|
674
|
+
style={{ pointerEvents: isLoading ? "none" : undefined }}
|
|
675
|
+
ref={bodyRef}
|
|
676
|
+
>
|
|
677
|
+
{view === "loader" && (
|
|
678
|
+
<table className={tableClass} style={tableSizingStyle}>
|
|
679
|
+
<tbody>
|
|
680
|
+
{Array.from({ length: skeletonRowCount }).map(
|
|
681
|
+
(_, rowIndex) => (
|
|
682
|
+
<tr
|
|
683
|
+
key={`skeleton-${rowIndex}`}
|
|
684
|
+
ref={rowIndex === 0 ? skeletonRowRef : undefined}
|
|
685
|
+
>
|
|
686
|
+
{visibleLeafColumns.map((column) => {
|
|
687
|
+
const isPinned = column.getIsPinned();
|
|
688
|
+
const isLastPinnedCol =
|
|
689
|
+
isPinned && isLastPinned(column);
|
|
690
|
+
const isSelectColumn = column.id === SELECT_COLUMN_ID;
|
|
691
|
+
|
|
692
|
+
return (
|
|
693
|
+
<td
|
|
694
|
+
key={column.id}
|
|
695
|
+
className={clsx(
|
|
696
|
+
isSelectColumn && s.col_select,
|
|
697
|
+
isPinned === "left" && s.col_pinned_left,
|
|
698
|
+
isPinned === "right" && s.col_pinned_right,
|
|
699
|
+
isLastPinnedCol && s.col_pinned_last,
|
|
700
|
+
)}
|
|
701
|
+
style={getColumnStyle(
|
|
702
|
+
column,
|
|
703
|
+
column.getSize(),
|
|
704
|
+
columnExtraWidths[column.id] ?? 0,
|
|
705
|
+
)}
|
|
706
|
+
>
|
|
707
|
+
<div className={s.td_inner}>
|
|
708
|
+
<Loader
|
|
709
|
+
variant="skeleton"
|
|
710
|
+
className={
|
|
711
|
+
isSelectColumn
|
|
712
|
+
? s.loading_select_skeleton
|
|
713
|
+
: undefined
|
|
714
|
+
}
|
|
715
|
+
style={
|
|
716
|
+
isSelectColumn
|
|
717
|
+
? selectSkeletonStyle
|
|
718
|
+
: bodySkeletonStyle
|
|
719
|
+
}
|
|
720
|
+
/>
|
|
721
|
+
</div>
|
|
722
|
+
</td>
|
|
723
|
+
);
|
|
724
|
+
})}
|
|
725
|
+
</tr>
|
|
726
|
+
),
|
|
727
|
+
)}
|
|
728
|
+
</tbody>
|
|
729
|
+
</table>
|
|
730
|
+
)}
|
|
707
731
|
{view === "empty" && (
|
|
708
732
|
<div className={s.state_overlay}>
|
|
709
733
|
<p className={s.empty_msg}>{emptyMessage}</p>
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { IconFilter } from "@tabler/icons-react";
|
|
3
|
+
import {
|
|
4
|
+
Modal,
|
|
5
|
+
ModalBody,
|
|
6
|
+
ModalFooter,
|
|
7
|
+
ModalHeader,
|
|
8
|
+
ModalTitle,
|
|
9
|
+
} from "../../primitives/modal/modal";
|
|
10
|
+
import { Icon } from "../../primitives/icon/icon";
|
|
11
|
+
import { Button } from "../button/button";
|
|
12
|
+
import type { FilterBarProps } from "./types";
|
|
13
|
+
import s from "./data-table.module.css";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Small-screen counterpart to <FilterBar>. It keeps filtering live while moving
|
|
17
|
+
* the same field contract into a Modal bottom sheet.
|
|
18
|
+
*/
|
|
19
|
+
export const FilterModal = ({ fields, values, onChange }: FilterBarProps) => {
|
|
20
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
21
|
+
|
|
22
|
+
// Action slots (buttons/menus) render alongside the inputs; only input fields
|
|
23
|
+
// carry a value, so only they count toward the active badge + Clear-all reset.
|
|
24
|
+
const inputFields = fields.filter((field) => field.type === "field");
|
|
25
|
+
const activeCount = inputFields.filter(
|
|
26
|
+
(field) => (values[field.key] ?? "") !== "",
|
|
27
|
+
).length;
|
|
28
|
+
const clearAll = () => {
|
|
29
|
+
inputFields.forEach((field) => {
|
|
30
|
+
if ((values[field.key] ?? "") !== "") onChange(field.key, "");
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div className={s.filter_modal_bar}>
|
|
36
|
+
<Button
|
|
37
|
+
type="button"
|
|
38
|
+
variant="bordered"
|
|
39
|
+
className={s.filter_trigger}
|
|
40
|
+
onClick={() => setIsOpen(true)}
|
|
41
|
+
>
|
|
42
|
+
<Icon>
|
|
43
|
+
<IconFilter />
|
|
44
|
+
</Icon>
|
|
45
|
+
Filters
|
|
46
|
+
{activeCount > 0 && <span className={s.filter_count}>{activeCount}</span>}
|
|
47
|
+
</Button>
|
|
48
|
+
|
|
49
|
+
<Modal isOpen={isOpen} onOpenChange={setIsOpen} placement="bottom" size="md">
|
|
50
|
+
<ModalHeader>
|
|
51
|
+
<ModalTitle>Filters</ModalTitle>
|
|
52
|
+
</ModalHeader>
|
|
53
|
+
<ModalBody>
|
|
54
|
+
<div className={s.filter_modal_fields}>
|
|
55
|
+
{fields.map((field, i) => (
|
|
56
|
+
<div
|
|
57
|
+
className={s.filter_modal_field}
|
|
58
|
+
key={field.type === "action" ? `act:${i}` : `k:${field.key}`}
|
|
59
|
+
>
|
|
60
|
+
{field.type === "action"
|
|
61
|
+
? field.render()
|
|
62
|
+
: field.render({
|
|
63
|
+
value: values[field.key] ?? "",
|
|
64
|
+
onChange: (v) => onChange(field.key, v),
|
|
65
|
+
})}
|
|
66
|
+
</div>
|
|
67
|
+
))}
|
|
68
|
+
</div>
|
|
69
|
+
</ModalBody>
|
|
70
|
+
<ModalFooter>
|
|
71
|
+
<Button
|
|
72
|
+
type="button"
|
|
73
|
+
variant="ghost"
|
|
74
|
+
onClick={clearAll}
|
|
75
|
+
disabled={activeCount === 0}
|
|
76
|
+
>
|
|
77
|
+
Clear all
|
|
78
|
+
</Button>
|
|
79
|
+
<Button type="button" variant="primary" onClick={() => setIsOpen(false)}>
|
|
80
|
+
Done
|
|
81
|
+
</Button>
|
|
82
|
+
</ModalFooter>
|
|
83
|
+
</Modal>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
};
|
|
@@ -556,6 +556,8 @@ export interface PersistArgs {
|
|
|
556
556
|
setGrouping: (next: GroupingState) => void;
|
|
557
557
|
expanded: ExpandedState;
|
|
558
558
|
setExpanded: (next: ExpandedState) => void;
|
|
559
|
+
/** Resolved default expansion - used to skip persisting it when unchanged. */
|
|
560
|
+
defaultExpanded: ExpandedState;
|
|
559
561
|
}
|
|
560
562
|
|
|
561
563
|
// ─── Snapshot / apply helpers shape ───────────────────────────────────────────
|
|
@@ -573,6 +575,12 @@ export interface SnapshotInput {
|
|
|
573
575
|
columnSizing: ColumnSizingState;
|
|
574
576
|
grouping: GroupingState;
|
|
575
577
|
expanded: ExpandedState;
|
|
578
|
+
/**
|
|
579
|
+
* Resolved default expansion (the hook's `defaultExpanded ?? true`). Used to
|
|
580
|
+
* skip persisting `expanded` when it still matches the default - otherwise the
|
|
581
|
+
* default writes a noisy `?expanded=true` even when no group was touched.
|
|
582
|
+
*/
|
|
583
|
+
defaultExpanded: ExpandedState;
|
|
576
584
|
}
|
|
577
585
|
|
|
578
586
|
/** Setters used by `applyPersistedState` to push hydrated values back into state. */
|
|
@@ -334,6 +334,7 @@ export function useDataTable<TData extends { id: string }>({
|
|
|
334
334
|
setGrouping: setGroupingState,
|
|
335
335
|
expanded,
|
|
336
336
|
setExpanded: setExpandedState,
|
|
337
|
+
defaultExpanded: defaultExpanded ?? true,
|
|
337
338
|
});
|
|
338
339
|
|
|
339
340
|
// ── Derived data ────────────────────────────────────────────────────────────
|
|
@@ -147,6 +147,23 @@ export const RESERVED_PARAM_KEYS = new Set<string>(Object.values(PARAM_KEYS));
|
|
|
147
147
|
const isNonEmptyExpanded = (e: ExpandedState | undefined): boolean =>
|
|
148
148
|
e !== undefined && (e === true || Object.keys(e).length > 0);
|
|
149
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Structural equality for two `ExpandedState`s (each a boolean or an
|
|
152
|
+
* id -> boolean record). Lets the snapshot skip persisting `expanded` while it
|
|
153
|
+
* still matches the table's default.
|
|
154
|
+
*/
|
|
155
|
+
const expandedEquals = (
|
|
156
|
+
a: ExpandedState | undefined,
|
|
157
|
+
b: ExpandedState | undefined,
|
|
158
|
+
): boolean => {
|
|
159
|
+
if (a === b) return true;
|
|
160
|
+
if (typeof a === "boolean" || typeof b === "boolean") return false;
|
|
161
|
+
if (!a || !b) return false;
|
|
162
|
+
const aKeys = Object.keys(a);
|
|
163
|
+
if (aKeys.length !== Object.keys(b).length) return false;
|
|
164
|
+
return aKeys.every((k) => a[k] === b[k]);
|
|
165
|
+
};
|
|
166
|
+
|
|
150
167
|
/**
|
|
151
168
|
* Build a `PersistedState` snapshot from current state, omitting empty/default
|
|
152
169
|
* slices so the persisted blob (URL or storage) stays minimal.
|
|
@@ -183,7 +200,15 @@ export function snapshotPersistedState(input: SnapshotInput): PersistedState {
|
|
|
183
200
|
out.columnSizing = input.columnSizing;
|
|
184
201
|
}
|
|
185
202
|
if (input.grouping.length > 0) out.grouping = input.grouping;
|
|
186
|
-
|
|
203
|
+
// Persist expansion only when it deviates from the default - otherwise the
|
|
204
|
+
// default (`true` = all groups expanded) writes a noisy `?expanded=true` even
|
|
205
|
+
// when the user never touched a group.
|
|
206
|
+
if (
|
|
207
|
+
isNonEmptyExpanded(input.expanded) &&
|
|
208
|
+
!expandedEquals(input.expanded, input.defaultExpanded)
|
|
209
|
+
) {
|
|
210
|
+
out.expanded = input.expanded;
|
|
211
|
+
}
|
|
187
212
|
|
|
188
213
|
return out;
|
|
189
214
|
}
|
|
@@ -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
|
-
|
|
53
|
+
flex: 1 1 auto;
|
|
54
|
+
min-width: 0;
|
|
55
|
+
text-align: start;
|
|
47
56
|
}
|
|
48
57
|
}
|
|
49
58
|
}
|
|
@@ -53,4 +62,26 @@
|
|
|
53
62
|
max-width: 100% !important;
|
|
54
63
|
}
|
|
55
64
|
|
|
65
|
+
/* Trailing per-option meta (option.meta). The growing .option_label pushes it
|
|
66
|
+
to the row's end; consumers own the slot's visual styling. */
|
|
67
|
+
.option_meta {
|
|
68
|
+
flex-shrink: 0;
|
|
69
|
+
display: inline-flex;
|
|
70
|
+
align-items: center;
|
|
71
|
+
gap: var(--spacing-2);
|
|
72
|
+
margin-inline-start: var(--spacing-3);
|
|
73
|
+
white-space: nowrap;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/* Trailing per-option delete affordance (option.onDelete). The growing
|
|
77
|
+
.option_label pushes it to the row's end; muted until hovered, then
|
|
78
|
+
error-toned. Click is stopPropagation'd in the component so it never
|
|
79
|
+
selects the option. */
|
|
80
|
+
.option_delete {
|
|
81
|
+
flex-shrink: 0;
|
|
82
|
+
--icon-color: var(--color-muted-foreground);
|
|
56
83
|
|
|
84
|
+
&:hover {
|
|
85
|
+
--icon-color: var(--color-error-foreground);
|
|
86
|
+
}
|
|
87
|
+
}
|