aplance-ui 1.2.0 → 1.2.2
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/dist/api.d.ts +2 -0
- package/dist/api.js +4 -0
- package/dist/api.js.map +1 -1
- package/dist/index.js +18 -1
- package/dist/index.js.map +1 -1
- package/package.json +63 -63
- package/registry/components/primitives/modal/modal.tsx +46 -13
- package/registry/components/primitives/popover/popover.tsx +6 -0
- package/registry/components/ui/data-table/data-table.module.css +89 -20
- package/registry/components/ui/data-table/data-table.tsx +193 -111
- 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.tsx +76 -10
- package/registry/components/ui/input-fields/input/input.tsx +69 -51
- 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/hooks/useFocusOutside.tsx +21 -6
- package/registry/registry.json +2 -1
|
@@ -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"] {
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
useState,
|
|
10
10
|
} from "react";
|
|
11
11
|
import clsx from "clsx";
|
|
12
|
+
import { IconPlus } from "@tabler/icons-react";
|
|
12
13
|
import { Input } from "../input/input";
|
|
13
14
|
import {
|
|
14
15
|
Popover,
|
|
@@ -21,7 +22,10 @@ import sPickerCommon from "../common/picker-popover-common.module.css";
|
|
|
21
22
|
import { Button, type ButtonVariant } from "../../button/button";
|
|
22
23
|
import s from "./custom-select.module.css";
|
|
23
24
|
import { FieldMessage } from "../common/field-message/field-message";
|
|
24
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
SelectButton,
|
|
27
|
+
type SelectButtonProps,
|
|
28
|
+
} from "../common/select-button/select-button";
|
|
25
29
|
import { Icon } from "../../../primitives/icon/icon";
|
|
26
30
|
import type { BaseInputFieldProps } from "../common/types";
|
|
27
31
|
|
|
@@ -50,6 +54,13 @@ export type CustomSelectProps = Omit<
|
|
|
50
54
|
onChange?: (value: string | string[]) => void;
|
|
51
55
|
multiple?: boolean;
|
|
52
56
|
searchable?: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Lets the user create a new option from the search term when nothing in
|
|
59
|
+
* `options` matches it. Requires `searchable`. The created value is emitted
|
|
60
|
+
* through `onChange` (and added to `selectedValues`); the consumer is
|
|
61
|
+
* responsible for persisting it / pushing it into the next `options` list.
|
|
62
|
+
*/
|
|
63
|
+
allowCreate?: boolean;
|
|
53
64
|
showDisplayText?: boolean;
|
|
54
65
|
showDisplayIcon?: boolean;
|
|
55
66
|
onClear?: () => void;
|
|
@@ -91,6 +102,7 @@ export const CustomSelect = ({
|
|
|
91
102
|
onChange,
|
|
92
103
|
multiple = false,
|
|
93
104
|
searchable = false,
|
|
105
|
+
allowCreate = false,
|
|
94
106
|
showDisplayIcon = true,
|
|
95
107
|
showDisplayText = true,
|
|
96
108
|
disabled = false,
|
|
@@ -149,35 +161,65 @@ export const CustomSelect = ({
|
|
|
149
161
|
[actualValue],
|
|
150
162
|
);
|
|
151
163
|
|
|
164
|
+
// A selected value may not exist in `options`, for example when `allowCreate` is
|
|
165
|
+
// on and the consumer just stores the value without pushing it back into
|
|
166
|
+
// the options list. Synthesize a "ghost" entry so the trigger displays it,
|
|
167
|
+
// the menu shows it (multi-select can uncheck), and the "Create" check
|
|
168
|
+
// doesn't redundantly offer something already selected.
|
|
169
|
+
const optionsWithSelected = useMemo(() => {
|
|
170
|
+
if (selectedValues.length === 0) return options;
|
|
171
|
+
const known = new Set(options.map((option) => option.value));
|
|
172
|
+
const ghosts = selectedValues
|
|
173
|
+
.filter((value) => !known.has(value))
|
|
174
|
+
.map<CustomSelectOption>((value) => ({ value, label: value }));
|
|
175
|
+
return ghosts.length > 0 ? [...ghosts, ...options] : options;
|
|
176
|
+
}, [options, selectedValues]);
|
|
177
|
+
|
|
152
178
|
// Filter options based on search
|
|
153
179
|
const filteredOptions = useMemo(
|
|
154
180
|
() =>
|
|
155
181
|
searchable && searchTerm
|
|
156
|
-
?
|
|
182
|
+
? optionsWithSelected.filter((option) =>
|
|
157
183
|
option.label.toLowerCase().includes(searchTerm.toLowerCase()),
|
|
158
184
|
)
|
|
159
|
-
:
|
|
160
|
-
[
|
|
185
|
+
: optionsWithSelected,
|
|
186
|
+
[optionsWithSelected, searchable, searchTerm],
|
|
161
187
|
);
|
|
162
188
|
|
|
163
|
-
//
|
|
189
|
+
// "Create '<term>'" appears when allowCreate is on and the typed term
|
|
190
|
+
// doesn't match any existing option (by label or value, case-insensitive).
|
|
191
|
+
const trimmedTerm = searchTerm.trim();
|
|
192
|
+
const showCreateOption =
|
|
193
|
+
allowCreate &&
|
|
194
|
+
searchable &&
|
|
195
|
+
trimmedTerm.length > 0 &&
|
|
196
|
+
!optionsWithSelected.some(
|
|
197
|
+
(option) =>
|
|
198
|
+
option.label.toLowerCase() === trimmedTerm.toLowerCase() ||
|
|
199
|
+
option.value.toLowerCase() === trimmedTerm.toLowerCase(),
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
// Get all selected option data in one place. `optionsWithSelected` is used
|
|
203
|
+
// for the lookup so ghost entries (selected-but-not-in-options) resolve
|
|
204
|
+
// back to a displayable label.
|
|
164
205
|
const selectedOptionData = useMemo(() => {
|
|
165
206
|
const hasValue = selectedValues.length > 0;
|
|
166
|
-
const selectedOption =
|
|
207
|
+
const selectedOption = optionsWithSelected.find(
|
|
167
208
|
(opt) => opt.value === selectedValues[0],
|
|
168
209
|
);
|
|
169
210
|
const isMultipleSelection = multiple && selectedValues.length > 1;
|
|
211
|
+
const singleLabel = selectedOption?.label ?? selectedValues[0];
|
|
170
212
|
|
|
171
213
|
return {
|
|
172
214
|
hasValue,
|
|
173
215
|
option: selectedOption,
|
|
174
|
-
label:
|
|
216
|
+
label: singleLabel,
|
|
175
217
|
icon: selectedOption?.icon,
|
|
176
218
|
displayText: !hasValue
|
|
177
219
|
? placeholder
|
|
178
220
|
: isMultipleSelection
|
|
179
221
|
? `${selectedValues.length} selected`
|
|
180
|
-
:
|
|
222
|
+
: singleLabel,
|
|
181
223
|
shouldShowIcon:
|
|
182
224
|
showDisplayIcon && selectedOption?.icon && selectedValues.length === 1,
|
|
183
225
|
appliedClassName: selectedOption?.applyToSelector
|
|
@@ -187,7 +229,13 @@ export const CustomSelect = ({
|
|
|
187
229
|
? selectedOption.optionStyle
|
|
188
230
|
: undefined,
|
|
189
231
|
};
|
|
190
|
-
}, [
|
|
232
|
+
}, [
|
|
233
|
+
multiple,
|
|
234
|
+
optionsWithSelected,
|
|
235
|
+
placeholder,
|
|
236
|
+
selectedValues,
|
|
237
|
+
showDisplayIcon,
|
|
238
|
+
]);
|
|
191
239
|
|
|
192
240
|
const popoverOnClose = popover?.onClose;
|
|
193
241
|
const handlePopoverClose = useCallback(() => {
|
|
@@ -361,7 +409,25 @@ export const CustomSelect = ({
|
|
|
361
409
|
className={clsx(s.options_container, optionsContainerClassName)}
|
|
362
410
|
style={optionsContainerStyle}
|
|
363
411
|
>
|
|
364
|
-
{
|
|
412
|
+
{showCreateOption && (
|
|
413
|
+
<Button
|
|
414
|
+
as="div"
|
|
415
|
+
variant="ghost"
|
|
416
|
+
className={s.option}
|
|
417
|
+
onClick={() => {
|
|
418
|
+
handleSelect({ value: trimmedTerm, label: trimmedTerm });
|
|
419
|
+
setSearchTerm("");
|
|
420
|
+
}}
|
|
421
|
+
>
|
|
422
|
+
<Icon size="sm" aria-hidden="true">
|
|
423
|
+
<IconPlus />
|
|
424
|
+
</Icon>
|
|
425
|
+
<span className={clsx(s.option_label, sCommon.truncate_text)}>
|
|
426
|
+
Create “{trimmedTerm}”
|
|
427
|
+
</span>
|
|
428
|
+
</Button>
|
|
429
|
+
)}
|
|
430
|
+
{filteredOptions.length === 0 && !showCreateOption ? (
|
|
365
431
|
<p className={s.no_options}>No options found</p>
|
|
366
432
|
) : (
|
|
367
433
|
filteredOptions.map((option) => (
|