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.
Files changed (23) hide show
  1. package/package.json +1 -1
  2. package/registry/components/primitives/modal/modal.tsx +52 -13
  3. package/registry/components/primitives/popover/popover-context.ts +1 -0
  4. package/registry/components/primitives/popover/popover.tsx +13 -2
  5. package/registry/components/primitives/slider/slider.module.css +62 -5
  6. package/registry/components/primitives/slider/slider.tsx +192 -34
  7. package/registry/components/primitives/slider/types.ts +56 -3
  8. package/registry/components/primitives/slider/utils.ts +9 -0
  9. package/registry/components/ui/data-table/data-table.module.css +124 -6
  10. package/registry/components/ui/data-table/data-table.tsx +176 -88
  11. package/registry/components/ui/data-table/pagination-bar.tsx +174 -71
  12. package/registry/components/ui/data-table/types.ts +12 -3
  13. package/registry/components/ui/input-fields/common/input-field-native.module.css +52 -42
  14. package/registry/components/ui/input-fields/custom-select/custom-select.module.css +21 -1
  15. package/registry/components/ui/input-fields/custom-select/custom-select.tsx +90 -9
  16. package/registry/components/ui/input-fields/input/input.tsx +58 -29
  17. package/registry/components/ui/input-fields/media-input/media-input.module.css +29 -0
  18. package/registry/components/ui/input-fields/media-input/media-preview.tsx +25 -7
  19. package/registry/components/ui/tabs/tabs.module.css +22 -8
  20. package/registry/components/ui/tabs/tabs.tsx +5 -1
  21. package/registry/hooks/useFocusOutside.tsx +21 -6
  22. package/registry/hooks/useHasMounted.ts +22 -0
  23. 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 className={s.pagination_bar}>
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 className={s.pagination_left}>
45
- <p className={s.pagination_count}>
46
- {rowsSelectable && selectedCount > 0 ? (
47
- <>
48
- <span>{selectedCount}</span> of <span>{total}</span> {rowLabel}(s)
49
- selected
50
- </>
51
- ) : (
52
- <>
53
- <span>{total}</span> {rowLabel}(s)
54
- </>
55
- )}
56
- </p>
57
- {extra}
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 className={s.pagination_controls}>
63
- {showPageSizeSelect && (
64
- <div className={s.page_size_wrap}>
65
- <span className={s.page_size_label}>{rowLabel}s per page</span>
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
- <p className={s.page_indicator}>
79
- Page <span>{pagination.pageIndex + 1}</span> of{" "}
80
- <span>{pageCount}</span>
81
- </p>
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 className={s.page_nav}>
84
- <Button
85
- variant="bordered"
86
- layout="box"
87
- onClick={() => goTo(0)}
88
- disabled={!canPrev}
89
- aria-label="First page"
90
- >
91
- <IconChevronsLeft size={14} stroke={2} />
92
- </Button>
93
- <Button
94
- variant="bordered"
95
- layout="box"
96
- onClick={() => goTo(pagination.pageIndex - 1)}
97
- disabled={!canPrev}
98
- aria-label="Previous page"
99
- >
100
- <IconChevronLeft size={14} stroke={2} />
101
- </Button>
102
- <Button
103
- variant="bordered"
104
- layout="box"
105
- onClick={() => goTo(pagination.pageIndex + 1)}
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, …) plugs in through the same
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<…>` without tripping tanstack's invariance on the
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 defalut input types)
4
- */
5
-
6
- .native_field_wrapper {
7
- /* input-specific overrides & browser fixes */
8
- input[type="search"]::-webkit-search-cancel-button {
9
- appearance: none;
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: 1.2rem;
53
- height: 1.2rem;
54
- border-radius: var(--radius-full);
55
- position: relative;
56
-
57
- &:checked::after {
58
- content: "";
59
- position: absolute;
60
- inset: 4px;
61
- border-radius: var(--radius-full);
62
- background-color: var(--color-primary);
63
- }
64
- }
65
-
66
- /* checkbox */
67
- input[type="checkbox"] {
68
- width: 1.2rem;
69
- height: 1.2rem;
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: 0.35rem;
84
- height: 0.7rem;
85
- border: solid var(--color-primary-foreground);
86
- border-width: 0 2.5px 2.5px 0;
87
- transform: translate(-50%, -50%) rotate(45deg);
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
- 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,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
- ? options.filter((option) =>
183
+ ? optionsWithSelected.filter((option) =>
157
184
  option.label.toLowerCase().includes(searchTerm.toLowerCase()),
158
185
  )
159
- : options,
160
- [options, searchable, searchTerm],
186
+ : optionsWithSelected,
187
+ [optionsWithSelected, searchable, searchTerm],
161
188
  );
162
189
 
163
- // Get all selected option data in one place
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 = options.find(
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: selectedOption?.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
- : selectedOption?.label,
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, options, placeholder, selectedValues, showDisplayIcon]);
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
- {filteredOptions.length === 0 ? (
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 &ldquo;{trimmedTerm}&rdquo;
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
  )}