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.
@@ -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"] {
@@ -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 { SelectButton, type SelectButtonProps } from "../common/select-button/select-button";
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
- ? options.filter((option) =>
182
+ ? optionsWithSelected.filter((option) =>
157
183
  option.label.toLowerCase().includes(searchTerm.toLowerCase()),
158
184
  )
159
- : options,
160
- [options, searchable, searchTerm],
185
+ : optionsWithSelected,
186
+ [optionsWithSelected, searchable, searchTerm],
161
187
  );
162
188
 
163
- // Get all selected option data in one place
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 = options.find(
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: selectedOption?.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
- : selectedOption?.label,
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
- }, [multiple, options, placeholder, selectedValues, showDisplayIcon]);
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
- {filteredOptions.length === 0 ? (
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 &ldquo;{trimmedTerm}&rdquo;
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) => (