aplance-ui 1.2.1 → 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.
- package/package.json +1 -1
- package/registry/components/primitives/modal/modal.tsx +52 -13
- package/registry/components/primitives/popover/popover-context.ts +1 -0
- package/registry/components/primitives/popover/popover.tsx +13 -2
- 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/data-table/data-table.module.css +124 -6
- package/registry/components/ui/data-table/data-table.tsx +176 -88
- package/registry/components/ui/data-table/pagination-bar.tsx +174 -71
- package/registry/components/ui/data-table/types.ts +12 -3
- package/registry/components/ui/input-fields/common/input-field-native.module.css +52 -42
- package/registry/components/ui/input-fields/custom-select/custom-select.module.css +21 -1
- package/registry/components/ui/input-fields/custom-select/custom-select.tsx +90 -9
- package/registry/components/ui/input-fields/input/input.tsx +58 -29
- package/registry/components/ui/input-fields/media-input/media-input.module.css +29 -0
- package/registry/components/ui/input-fields/media-input/media-preview.tsx +25 -7
- package/registry/components/ui/tabs/tabs.module.css +22 -8
- package/registry/components/ui/tabs/tabs.tsx +5 -1
- package/registry/hooks/useFocusOutside.tsx +21 -6
- package/registry/hooks/useHasMounted.ts +22 -0
- package/registry/registry.json +21 -3
|
@@ -4,17 +4,26 @@ import {
|
|
|
4
4
|
IconChevronsLeft,
|
|
5
5
|
IconChevronsRight,
|
|
6
6
|
} from "@tabler/icons-react";
|
|
7
|
+
import clsx from "clsx";
|
|
7
8
|
import { Button } from "../button/button";
|
|
8
9
|
import { CustomSelect } from "../input-fields/custom-select/custom-select";
|
|
10
|
+
import { Loader } from "../loader/loader";
|
|
9
11
|
import type { PaginationBarProps } from "./types";
|
|
10
12
|
import s from "./data-table.module.css";
|
|
11
13
|
|
|
14
|
+
const getSkeletonWidth = (
|
|
15
|
+
characters: number,
|
|
16
|
+
minCharacters: number,
|
|
17
|
+
maxCharacters: number,
|
|
18
|
+
) => `${Math.min(maxCharacters, Math.max(minCharacters, characters))}ch`;
|
|
19
|
+
|
|
12
20
|
export const PaginationBar = <TData,>({
|
|
13
21
|
table,
|
|
14
22
|
pagination,
|
|
15
23
|
onPaginationChange,
|
|
16
24
|
pageSizeOptions,
|
|
17
25
|
showPageSizeSelect,
|
|
26
|
+
isLoading,
|
|
18
27
|
totalCount,
|
|
19
28
|
rowLabel,
|
|
20
29
|
rowsSelectable,
|
|
@@ -30,6 +39,36 @@ export const PaginationBar = <TData,>({
|
|
|
30
39
|
onPaginationChange({ ...pagination, pageIndex: i });
|
|
31
40
|
const setSize = (size: number) =>
|
|
32
41
|
onPaginationChange({ pageIndex: 0, pageSize: size });
|
|
42
|
+
const pageNavItems = [
|
|
43
|
+
{
|
|
44
|
+
key: "first",
|
|
45
|
+
label: "First page",
|
|
46
|
+
icon: <IconChevronsLeft size={14} stroke={2} />,
|
|
47
|
+
onClick: () => goTo(0),
|
|
48
|
+
disabled: !canPrev,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
key: "previous",
|
|
52
|
+
label: "Previous page",
|
|
53
|
+
icon: <IconChevronLeft size={14} stroke={2} />,
|
|
54
|
+
onClick: () => goTo(pagination.pageIndex - 1),
|
|
55
|
+
disabled: !canPrev,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
key: "next",
|
|
59
|
+
label: "Next page",
|
|
60
|
+
icon: <IconChevronRight size={14} stroke={2} />,
|
|
61
|
+
onClick: () => goTo(pagination.pageIndex + 1),
|
|
62
|
+
disabled: !canNext,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
key: "last",
|
|
66
|
+
label: "Last page",
|
|
67
|
+
icon: <IconChevronsRight size={14} stroke={2} />,
|
|
68
|
+
onClick: () => goTo(pageCount - 1),
|
|
69
|
+
disabled: !canNext,
|
|
70
|
+
},
|
|
71
|
+
];
|
|
33
72
|
|
|
34
73
|
// Always show all page size options - current pageSize is guaranteed in the list.
|
|
35
74
|
const sizeOptions = Array.from(
|
|
@@ -38,85 +77,149 @@ export const PaginationBar = <TData,>({
|
|
|
38
77
|
.sort((a, b) => a - b)
|
|
39
78
|
.map((n) => ({ value: String(n), label: String(n) }));
|
|
40
79
|
|
|
80
|
+
const resolvedPageCount =
|
|
81
|
+
pageCount > 0
|
|
82
|
+
? pageCount
|
|
83
|
+
: totalCount === undefined
|
|
84
|
+
? Math.max(1, pagination.pageIndex + 1)
|
|
85
|
+
: Math.max(1, Math.ceil(totalCount / pagination.pageSize));
|
|
86
|
+
const countSkeletonWidth = getSkeletonWidth(
|
|
87
|
+
String(total).length +
|
|
88
|
+
rowLabel.length +
|
|
89
|
+
(rowsSelectable && selectedCount > 0
|
|
90
|
+
? String(selectedCount).length + 14
|
|
91
|
+
: 4),
|
|
92
|
+
7,
|
|
93
|
+
14,
|
|
94
|
+
);
|
|
95
|
+
const pageSizeLabelSkeletonWidth = getSkeletonWidth(
|
|
96
|
+
rowLabel.length + "s per page".length,
|
|
97
|
+
8,
|
|
98
|
+
12,
|
|
99
|
+
);
|
|
100
|
+
const pageSizeSelectSkeletonWidth = `calc(${getSkeletonWidth(
|
|
101
|
+
String(pagination.pageSize).length,
|
|
102
|
+
2,
|
|
103
|
+
3,
|
|
104
|
+
)} + var(--spacing-11))`;
|
|
105
|
+
const pageIndicatorSkeletonWidth = getSkeletonWidth(
|
|
106
|
+
String(pagination.pageIndex + 1).length +
|
|
107
|
+
String(resolvedPageCount).length +
|
|
108
|
+
"Page of ".length,
|
|
109
|
+
8,
|
|
110
|
+
11,
|
|
111
|
+
);
|
|
112
|
+
|
|
41
113
|
return (
|
|
42
|
-
<div
|
|
114
|
+
<div
|
|
115
|
+
className={clsx(s.pagination_bar, isLoading && s.pagination_bar_loading)}
|
|
116
|
+
aria-busy={isLoading || undefined}
|
|
117
|
+
>
|
|
43
118
|
{/* left - count + optional bulk-action extra slot */}
|
|
44
|
-
<div
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
119
|
+
<div
|
|
120
|
+
className={clsx(
|
|
121
|
+
s.pagination_left,
|
|
122
|
+
isLoading && s.pagination_left_loading,
|
|
123
|
+
)}
|
|
124
|
+
>
|
|
125
|
+
{isLoading ? (
|
|
126
|
+
<Loader
|
|
127
|
+
variant="skeleton"
|
|
128
|
+
className={s.pagination_text_skeleton}
|
|
129
|
+
style={{ width: countSkeletonWidth }}
|
|
130
|
+
/>
|
|
131
|
+
) : (
|
|
132
|
+
<>
|
|
133
|
+
<p className={s.pagination_count}>
|
|
134
|
+
{rowsSelectable && selectedCount > 0 ? (
|
|
135
|
+
<>
|
|
136
|
+
<span>{selectedCount}</span> of <span>{total}</span>{" "}
|
|
137
|
+
{rowLabel}(s) selected
|
|
138
|
+
</>
|
|
139
|
+
) : (
|
|
140
|
+
<>
|
|
141
|
+
<span>{total}</span> {rowLabel}(s)
|
|
142
|
+
</>
|
|
143
|
+
)}
|
|
144
|
+
</p>
|
|
145
|
+
{extra}
|
|
146
|
+
</>
|
|
147
|
+
)}
|
|
58
148
|
</div>
|
|
59
149
|
|
|
60
150
|
{/* right - rows-per-page + page x of y + nav buttons */}
|
|
61
|
-
{pageCount > 0 && (
|
|
62
|
-
<div
|
|
63
|
-
{
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
<CustomSelect
|
|
67
|
-
options={sizeOptions}
|
|
68
|
-
value={String(pagination.pageSize)}
|
|
69
|
-
popover={{ position: "top" }}
|
|
70
|
-
onChange={(val) =>
|
|
71
|
-
setSize(Number(Array.isArray(val) ? val[0] : val))
|
|
72
|
-
}
|
|
73
|
-
selectButton={{ showChevron: true, showClear: false }}
|
|
74
|
-
/>
|
|
75
|
-
</div>
|
|
151
|
+
{(isLoading || pageCount > 0) && (
|
|
152
|
+
<div
|
|
153
|
+
className={clsx(
|
|
154
|
+
s.pagination_controls,
|
|
155
|
+
isLoading && s.pagination_controls_loading,
|
|
76
156
|
)}
|
|
157
|
+
>
|
|
158
|
+
{showPageSizeSelect &&
|
|
159
|
+
(isLoading ? (
|
|
160
|
+
<div className={s.page_size_wrap}>
|
|
161
|
+
<Loader
|
|
162
|
+
variant="skeleton"
|
|
163
|
+
className={s.pagination_text_skeleton}
|
|
164
|
+
style={{ width: pageSizeLabelSkeletonWidth }}
|
|
165
|
+
/>
|
|
166
|
+
<Loader
|
|
167
|
+
variant="skeleton"
|
|
168
|
+
className={s.pagination_select_skeleton}
|
|
169
|
+
style={{ width: pageSizeSelectSkeletonWidth }}
|
|
170
|
+
/>
|
|
171
|
+
</div>
|
|
172
|
+
) : (
|
|
173
|
+
<div className={s.page_size_wrap}>
|
|
174
|
+
<span className={s.page_size_label}>{rowLabel}s per page</span>
|
|
175
|
+
<CustomSelect
|
|
176
|
+
options={sizeOptions}
|
|
177
|
+
value={String(pagination.pageSize)}
|
|
178
|
+
popover={{ position: "top" }}
|
|
179
|
+
onChange={(val) =>
|
|
180
|
+
setSize(Number(Array.isArray(val) ? val[0] : val))
|
|
181
|
+
}
|
|
182
|
+
selectButton={{ showChevron: true, showClear: false }}
|
|
183
|
+
/>
|
|
184
|
+
</div>
|
|
185
|
+
))}
|
|
77
186
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
187
|
+
{isLoading ? (
|
|
188
|
+
<Loader
|
|
189
|
+
variant="skeleton"
|
|
190
|
+
className={s.pagination_text_skeleton}
|
|
191
|
+
style={{ width: pageIndicatorSkeletonWidth }}
|
|
192
|
+
/>
|
|
193
|
+
) : (
|
|
194
|
+
<p className={s.page_indicator}>
|
|
195
|
+
Page <span>{pagination.pageIndex + 1}</span> of{" "}
|
|
196
|
+
<span>{pageCount}</span>
|
|
197
|
+
</p>
|
|
198
|
+
)}
|
|
82
199
|
|
|
83
|
-
<div
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
disabled={!canNext}
|
|
107
|
-
aria-label="Next page"
|
|
108
|
-
>
|
|
109
|
-
<IconChevronRight size={14} stroke={2} />
|
|
110
|
-
</Button>
|
|
111
|
-
<Button
|
|
112
|
-
variant="bordered"
|
|
113
|
-
layout="box"
|
|
114
|
-
onClick={() => goTo(pageCount - 1)}
|
|
115
|
-
disabled={!canNext}
|
|
116
|
-
aria-label="Last page"
|
|
117
|
-
>
|
|
118
|
-
<IconChevronsRight size={14} stroke={2} />
|
|
119
|
-
</Button>
|
|
200
|
+
<div
|
|
201
|
+
className={clsx(s.page_nav, isLoading && s.page_nav_loading)}
|
|
202
|
+
>
|
|
203
|
+
{isLoading
|
|
204
|
+
? pageNavItems.map((item) => (
|
|
205
|
+
<Loader
|
|
206
|
+
key={`${item.key}-skeleton`}
|
|
207
|
+
variant="skeleton"
|
|
208
|
+
className={s.pagination_button_skeleton}
|
|
209
|
+
/>
|
|
210
|
+
))
|
|
211
|
+
: pageNavItems.map((item) => (
|
|
212
|
+
<Button
|
|
213
|
+
key={item.key}
|
|
214
|
+
variant="bordered"
|
|
215
|
+
layout="box"
|
|
216
|
+
onClick={item.onClick}
|
|
217
|
+
disabled={item.disabled}
|
|
218
|
+
aria-label={item.label}
|
|
219
|
+
>
|
|
220
|
+
{item.icon}
|
|
221
|
+
</Button>
|
|
222
|
+
))}
|
|
120
223
|
</div>
|
|
121
224
|
</div>
|
|
122
225
|
)}
|
|
@@ -43,7 +43,7 @@ declare module "@tanstack/react-table" {
|
|
|
43
43
|
|
|
44
44
|
// ─── Filter fields ────────────────────────────────────────────────────────────
|
|
45
45
|
// Two headless primitives - nothing else. Every concrete filter (search,
|
|
46
|
-
// select, date picker, custom slider,
|
|
46
|
+
// select, date picker, custom slider, etc.) plugs in through the same
|
|
47
47
|
// `value`/`onChange` render-prop contract, so the data-table has zero
|
|
48
48
|
// compile-time dependency on any specific input component.
|
|
49
49
|
|
|
@@ -59,7 +59,7 @@ export interface FilterInputField {
|
|
|
59
59
|
}) => ReactNode;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
/** Any non-value slot - button, menu, custom control,
|
|
62
|
+
/** Any non-value slot - button, menu, custom control, etc. */
|
|
63
63
|
export interface FilterAction {
|
|
64
64
|
type: "action";
|
|
65
65
|
/** Grid column width template. Default: `'auto'` (hugs content). */
|
|
@@ -154,6 +154,7 @@ export interface PaginationBarProps<TData> {
|
|
|
154
154
|
onPaginationChange: (next: PaginationState) => void;
|
|
155
155
|
pageSizeOptions: number[];
|
|
156
156
|
showPageSizeSelect: boolean;
|
|
157
|
+
isLoading: boolean;
|
|
157
158
|
totalCount: number | undefined;
|
|
158
159
|
rowLabel: string;
|
|
159
160
|
rowsSelectable: boolean;
|
|
@@ -220,6 +221,14 @@ export interface DataTableProps<TData extends { id: string }> {
|
|
|
220
221
|
rowLabel?: string;
|
|
221
222
|
|
|
222
223
|
rowsSelectable?: boolean;
|
|
224
|
+
/**
|
|
225
|
+
* Optional per-row gate for selection. When provided, takes precedence
|
|
226
|
+
* over `rowsSelectable` for the TanStack `enableRowSelection` setting
|
|
227
|
+
* (the boolean still controls whether the checkbox column is mounted at
|
|
228
|
+
* all). Returning false disables the per-row checkbox via the standard
|
|
229
|
+
* `row.getCanSelect()` flow.
|
|
230
|
+
*/
|
|
231
|
+
getRowCanSelect?: (row: Row<TData>) => boolean;
|
|
223
232
|
rowSelection?: RowSelectionState;
|
|
224
233
|
onRowSelectionChange?: OnChangeFn<RowSelectionState>;
|
|
225
234
|
|
|
@@ -585,7 +594,7 @@ export interface ApplyPersistedTarget {
|
|
|
585
594
|
* Source for resolving the runtime column id from a raw `ColumnDef`. Mirrors
|
|
586
595
|
* how tanstack derives ids: explicit `id` first, then `accessorKey`. Typed
|
|
587
596
|
* structurally on the two fields actually read so callers can pass any
|
|
588
|
-
* concrete `ColumnDef
|
|
597
|
+
* concrete `ColumnDef<...>` without tripping tanstack's invariance on the
|
|
589
598
|
* value-type parameter.
|
|
590
599
|
*/
|
|
591
600
|
export interface ColumnIdSource {
|
|
@@ -1,12 +1,21 @@
|
|
|
1
|
-
/* uses input-field-common (.container and all)
|
|
2
|
-
+ extends .wrapper (from input-field-common) with
|
|
3
|
-
.native_field_wrapper (styling
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
.native_field_wrapper {
|
|
7
|
-
/*
|
|
8
|
-
|
|
9
|
-
|
|
1
|
+
/* uses input-field-common (.container and all)
|
|
2
|
+
+ extends .wrapper (from input-field-common) with
|
|
3
|
+
.native_field_wrapper (styling default input types)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
.native_field_wrapper {
|
|
7
|
+
/* Compact-control sizing contract (checkbox/radio). Consumers can override
|
|
8
|
+
these vars from a parent scope when needed (e.g. DataTable select column). */
|
|
9
|
+
--native-control-size: var(--spacing-9, 1.25rem);
|
|
10
|
+
--native-control-mark-stroke: var(--border-2, 2px);
|
|
11
|
+
--native-control-check-width: calc(var(--native-control-size) / 3.5);
|
|
12
|
+
--native-control-check-height: calc(var(--native-control-size) / 1.75);
|
|
13
|
+
--native-radio-dot-fill-stop: 48%;
|
|
14
|
+
--native-radio-dot-clear-stop: 50%;
|
|
15
|
+
|
|
16
|
+
/* input-specific overrides & browser fixes */
|
|
17
|
+
input[type="search"]::-webkit-search-cancel-button {
|
|
18
|
+
appearance: none;
|
|
10
19
|
display: none;
|
|
11
20
|
}
|
|
12
21
|
|
|
@@ -47,29 +56,29 @@
|
|
|
47
56
|
}
|
|
48
57
|
}
|
|
49
58
|
|
|
50
|
-
/* radio */
|
|
51
|
-
input[type="radio"] {
|
|
52
|
-
width:
|
|
53
|
-
height:
|
|
54
|
-
border-radius: var(--radius-full);
|
|
55
|
-
position: relative;
|
|
56
|
-
|
|
57
|
-
&:checked
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/* checkbox */
|
|
67
|
-
input[type="checkbox"] {
|
|
68
|
-
width:
|
|
69
|
-
height:
|
|
70
|
-
border-radius: var(--radius-sm);
|
|
71
|
-
position: relative;
|
|
72
|
-
appearance: none;
|
|
59
|
+
/* radio */
|
|
60
|
+
input[type="radio"] {
|
|
61
|
+
width: var(--native-control-size);
|
|
62
|
+
height: var(--native-control-size);
|
|
63
|
+
border-radius: var(--radius-full);
|
|
64
|
+
position: relative;
|
|
65
|
+
|
|
66
|
+
&:checked {
|
|
67
|
+
background-image: radial-gradient(
|
|
68
|
+
circle at center,
|
|
69
|
+
var(--color-primary) 0 var(--native-radio-dot-fill-stop),
|
|
70
|
+
transparent var(--native-radio-dot-clear-stop)
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/* checkbox */
|
|
76
|
+
input[type="checkbox"] {
|
|
77
|
+
width: var(--native-control-size);
|
|
78
|
+
height: var(--native-control-size);
|
|
79
|
+
border-radius: var(--radius-sm);
|
|
80
|
+
position: relative;
|
|
81
|
+
appearance: none;
|
|
73
82
|
|
|
74
83
|
&:checked {
|
|
75
84
|
background-color: var(--color-primary);
|
|
@@ -77,16 +86,17 @@
|
|
|
77
86
|
|
|
78
87
|
&:checked::after {
|
|
79
88
|
content: "";
|
|
80
|
-
position: absolute;
|
|
81
|
-
left: 50%;
|
|
82
|
-
top: 45%;
|
|
83
|
-
width:
|
|
84
|
-
height:
|
|
85
|
-
border: solid var(--color-primary-foreground);
|
|
86
|
-
border-width: 0
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
89
|
+
position: absolute;
|
|
90
|
+
left: 50%;
|
|
91
|
+
top: 45%;
|
|
92
|
+
width: var(--native-control-check-width);
|
|
93
|
+
height: var(--native-control-check-height);
|
|
94
|
+
border: solid var(--color-primary-foreground);
|
|
95
|
+
border-width: 0 var(--native-control-mark-stroke)
|
|
96
|
+
var(--native-control-mark-stroke) 0;
|
|
97
|
+
transform: translate(-50%, -50%) rotate(45deg);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
90
100
|
|
|
91
101
|
/* number */
|
|
92
102
|
input[type="number"] {
|
|
@@ -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,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,6 +9,7 @@ import {
|
|
|
9
9
|
useState,
|
|
10
10
|
} from "react";
|
|
11
11
|
import clsx from "clsx";
|
|
12
|
+
import { IconPlus, IconTrash } from "@tabler/icons-react";
|
|
12
13
|
import { Input } from "../input/input";
|
|
13
14
|
import {
|
|
14
15
|
Popover,
|
|
@@ -34,6 +35,10 @@ export type CustomSelectOption = {
|
|
|
34
35
|
optionButtonVariant?: ButtonVariant;
|
|
35
36
|
applyToSelector?: boolean;
|
|
36
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;
|
|
37
42
|
};
|
|
38
43
|
|
|
39
44
|
export type CustomSelectProps = Omit<
|
|
@@ -50,6 +55,13 @@ export type CustomSelectProps = Omit<
|
|
|
50
55
|
onChange?: (value: string | string[]) => void;
|
|
51
56
|
multiple?: boolean;
|
|
52
57
|
searchable?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Lets the user create a new option from the search term when nothing in
|
|
60
|
+
* `options` matches it. Requires `searchable`. The created value is emitted
|
|
61
|
+
* through `onChange` (and added to `selectedValues`); the consumer is
|
|
62
|
+
* responsible for persisting it / pushing it into the next `options` list.
|
|
63
|
+
*/
|
|
64
|
+
allowCreate?: boolean;
|
|
53
65
|
showDisplayText?: boolean;
|
|
54
66
|
showDisplayIcon?: boolean;
|
|
55
67
|
onClear?: () => void;
|
|
@@ -91,6 +103,7 @@ export const CustomSelect = ({
|
|
|
91
103
|
onChange,
|
|
92
104
|
multiple = false,
|
|
93
105
|
searchable = false,
|
|
106
|
+
allowCreate = false,
|
|
94
107
|
showDisplayIcon = true,
|
|
95
108
|
showDisplayText = true,
|
|
96
109
|
disabled = false,
|
|
@@ -149,35 +162,65 @@ export const CustomSelect = ({
|
|
|
149
162
|
[actualValue],
|
|
150
163
|
);
|
|
151
164
|
|
|
165
|
+
// A selected value may not exist in `options` - e.g. when `allowCreate` is
|
|
166
|
+
// on and the consumer just stores the value without pushing it back into
|
|
167
|
+
// the options list. Synthesize a "ghost" entry so the trigger displays it,
|
|
168
|
+
// the menu shows it (multi-select can uncheck), and the "Create" check
|
|
169
|
+
// doesn't redundantly offer something already selected.
|
|
170
|
+
const optionsWithSelected = useMemo(() => {
|
|
171
|
+
if (selectedValues.length === 0) return options;
|
|
172
|
+
const known = new Set(options.map((option) => option.value));
|
|
173
|
+
const ghosts = selectedValues
|
|
174
|
+
.filter((value) => !known.has(value))
|
|
175
|
+
.map<CustomSelectOption>((value) => ({ value, label: value }));
|
|
176
|
+
return ghosts.length > 0 ? [...ghosts, ...options] : options;
|
|
177
|
+
}, [options, selectedValues]);
|
|
178
|
+
|
|
152
179
|
// Filter options based on search
|
|
153
180
|
const filteredOptions = useMemo(
|
|
154
181
|
() =>
|
|
155
182
|
searchable && searchTerm
|
|
156
|
-
?
|
|
183
|
+
? optionsWithSelected.filter((option) =>
|
|
157
184
|
option.label.toLowerCase().includes(searchTerm.toLowerCase()),
|
|
158
185
|
)
|
|
159
|
-
:
|
|
160
|
-
[
|
|
186
|
+
: optionsWithSelected,
|
|
187
|
+
[optionsWithSelected, searchable, searchTerm],
|
|
161
188
|
);
|
|
162
189
|
|
|
163
|
-
//
|
|
190
|
+
// "Create '<term>'" appears when allowCreate is on and the typed term
|
|
191
|
+
// doesn't match any existing option (by label or value, case-insensitive).
|
|
192
|
+
const trimmedTerm = searchTerm.trim();
|
|
193
|
+
const showCreateOption =
|
|
194
|
+
allowCreate &&
|
|
195
|
+
searchable &&
|
|
196
|
+
trimmedTerm.length > 0 &&
|
|
197
|
+
!optionsWithSelected.some(
|
|
198
|
+
(option) =>
|
|
199
|
+
option.label.toLowerCase() === trimmedTerm.toLowerCase() ||
|
|
200
|
+
option.value.toLowerCase() === trimmedTerm.toLowerCase(),
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
// Get all selected option data in one place. `optionsWithSelected` is used
|
|
204
|
+
// for the lookup so ghost entries (selected-but-not-in-options) resolve
|
|
205
|
+
// back to a displayable label.
|
|
164
206
|
const selectedOptionData = useMemo(() => {
|
|
165
207
|
const hasValue = selectedValues.length > 0;
|
|
166
|
-
const selectedOption =
|
|
208
|
+
const selectedOption = optionsWithSelected.find(
|
|
167
209
|
(opt) => opt.value === selectedValues[0],
|
|
168
210
|
);
|
|
169
211
|
const isMultipleSelection = multiple && selectedValues.length > 1;
|
|
212
|
+
const singleLabel = selectedOption?.label ?? selectedValues[0];
|
|
170
213
|
|
|
171
214
|
return {
|
|
172
215
|
hasValue,
|
|
173
216
|
option: selectedOption,
|
|
174
|
-
label:
|
|
217
|
+
label: singleLabel,
|
|
175
218
|
icon: selectedOption?.icon,
|
|
176
219
|
displayText: !hasValue
|
|
177
220
|
? placeholder
|
|
178
221
|
: isMultipleSelection
|
|
179
222
|
? `${selectedValues.length} selected`
|
|
180
|
-
:
|
|
223
|
+
: singleLabel,
|
|
181
224
|
shouldShowIcon:
|
|
182
225
|
showDisplayIcon && selectedOption?.icon && selectedValues.length === 1,
|
|
183
226
|
appliedClassName: selectedOption?.applyToSelector
|
|
@@ -187,7 +230,7 @@ export const CustomSelect = ({
|
|
|
187
230
|
? selectedOption.optionStyle
|
|
188
231
|
: undefined,
|
|
189
232
|
};
|
|
190
|
-
}, [multiple,
|
|
233
|
+
}, [multiple, optionsWithSelected, placeholder, selectedValues, showDisplayIcon]);
|
|
191
234
|
|
|
192
235
|
const popoverOnClose = popover?.onClose;
|
|
193
236
|
const handlePopoverClose = useCallback(() => {
|
|
@@ -361,7 +404,27 @@ export const CustomSelect = ({
|
|
|
361
404
|
className={clsx(s.options_container, optionsContainerClassName)}
|
|
362
405
|
style={optionsContainerStyle}
|
|
363
406
|
>
|
|
364
|
-
{
|
|
407
|
+
{showCreateOption && (
|
|
408
|
+
<Button
|
|
409
|
+
as="div"
|
|
410
|
+
variant="ghost"
|
|
411
|
+
className={s.option}
|
|
412
|
+
onClick={() => {
|
|
413
|
+
handleSelect({ value: trimmedTerm, label: trimmedTerm });
|
|
414
|
+
setSearchTerm("");
|
|
415
|
+
}}
|
|
416
|
+
>
|
|
417
|
+
<Icon size="sm" aria-hidden="true">
|
|
418
|
+
<IconPlus />
|
|
419
|
+
</Icon>
|
|
420
|
+
<span
|
|
421
|
+
className={clsx(s.option_label, sCommon.truncate_text)}
|
|
422
|
+
>
|
|
423
|
+
Create “{trimmedTerm}”
|
|
424
|
+
</span>
|
|
425
|
+
</Button>
|
|
426
|
+
)}
|
|
427
|
+
{filteredOptions.length === 0 && !showCreateOption ? (
|
|
365
428
|
<p className={s.no_options}>No options found</p>
|
|
366
429
|
) : (
|
|
367
430
|
filteredOptions.map((option) => (
|
|
@@ -389,6 +452,24 @@ export const CustomSelect = ({
|
|
|
389
452
|
>
|
|
390
453
|
{option.label}
|
|
391
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}
|
|
392
473
|
</Button>
|
|
393
474
|
))
|
|
394
475
|
)}
|