pixelize-design-library 2.4.2-beta.46 → 2.4.2-beta.48

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 (63) hide show
  1. package/CHANGELOG.md +76 -3
  2. package/COMPONENT-GOTCHAS.md +76 -7
  3. package/dist/Components/Common/chipTriggerSize.d.ts +10 -0
  4. package/dist/Components/Common/chipTriggerSize.js +15 -0
  5. package/dist/Components/Dropdown/DropDown.js +10 -8
  6. package/dist/Components/SearchSelect/SearchSelect.js +66 -20
  7. package/dist/Components/SearchSelect/SearchSelectProps.d.ts +5 -1
  8. package/dist/Components/SearchSelect/searchSelectSize.d.ts +0 -5
  9. package/dist/Components/SearchSelect/searchSelectSize.js +0 -12
  10. package/dist/Components/Select/SelectNative.d.ts +2 -0
  11. package/dist/Components/Select/SelectPopup.js +104 -43
  12. package/dist/Components/Select/SelectProps.d.ts +4 -0
  13. package/dist/Components/Select/optionRowInk.d.ts +4 -0
  14. package/dist/Components/Select/optionRowInk.js +12 -1
  15. package/dist/Components/SelectSearch/SelectSearch.js +8 -4
  16. package/dist/Components/SelectV2/SelectV2.d.ts +2 -9
  17. package/dist/Components/SelectV2/SelectV2.js +2 -10
  18. package/dist/Components/Switch/Switch.d.ts +1 -1
  19. package/dist/Components/Switch/Switch.js +4 -2
  20. package/dist/Components/Table/Table.js +6 -7
  21. package/dist/Components/Table/TableProps.d.ts +9 -1
  22. package/dist/Components/Table/components/Pagination.js +8 -5
  23. package/dist/Components/Table/components/TableBody.d.ts +2 -1
  24. package/dist/Components/Table/components/TableBody.js +82 -15
  25. package/dist/Components/Table/components/TableHeader.d.ts +1 -1
  26. package/dist/Components/Table/components/TableHeader.js +8 -9
  27. package/dist/Components/Table/components/rowMetrics.d.ts +0 -1
  28. package/dist/Components/Table/components/rowMetrics.js +1 -2
  29. package/dist/Components/Table/filters/CompactSelect.js +6 -3
  30. package/dist/Components/Table/hooks/useTable.d.ts +5 -1
  31. package/dist/Components/Table/hooks/useTable.js +43 -7
  32. package/dist/Theme/chakra/Menu.styles.d.ts +6 -0
  33. package/dist/Theme/chakra/Menu.styles.js +8 -0
  34. package/dist/Theme/chakra/componentStyles.d.ts +6 -0
  35. package/dist/Theme/tokens/builders/outlineRung.d.ts +2 -0
  36. package/dist/Theme/tokens/builders/outlineRung.js +14 -1
  37. package/dist/Theme/tokens/builders/rowStates.js +3 -4
  38. package/dist/Theme/tokens/primitives/optionRowSizes.d.ts +20 -0
  39. package/dist/Theme/tokens/primitives/optionRowSizes.js +29 -0
  40. package/dist/esm/Components/Common/chipTriggerSize.js +11 -0
  41. package/dist/esm/Components/Dropdown/DropDown.js +10 -8
  42. package/dist/esm/Components/SearchSelect/SearchSelect.js +66 -20
  43. package/dist/esm/Components/SearchSelect/searchSelectSize.js +0 -12
  44. package/dist/esm/Components/Select/SelectPopup.js +105 -44
  45. package/dist/esm/Components/Select/optionRowInk.js +9 -0
  46. package/dist/esm/Components/SelectSearch/SelectSearch.js +9 -5
  47. package/dist/esm/Components/SelectV2/SelectV2.js +2 -9
  48. package/dist/esm/Components/Switch/Switch.js +4 -2
  49. package/dist/esm/Components/Table/Table.js +6 -7
  50. package/dist/esm/Components/Table/components/Pagination.js +8 -5
  51. package/dist/esm/Components/Table/components/TableBody.js +81 -16
  52. package/dist/esm/Components/Table/components/TableHeader.js +8 -9
  53. package/dist/esm/Components/Table/components/rowMetrics.js +0 -1
  54. package/dist/esm/Components/Table/filters/CompactSelect.js +7 -4
  55. package/dist/esm/Components/Table/hooks/useTable.js +44 -8
  56. package/dist/esm/Theme/chakra/Menu.styles.js +8 -0
  57. package/dist/esm/Theme/tokens/builders/outlineRung.js +12 -0
  58. package/dist/esm/Theme/tokens/builders/rowStates.js +4 -5
  59. package/dist/esm/Theme/tokens/primitives/optionRowSizes.js +25 -0
  60. package/dist/esm/index.js +2 -0
  61. package/dist/index.d.ts +1 -0
  62. package/dist/index.js +9 -4
  63. package/package.json +1 -1
@@ -1,15 +1,17 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import React, { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import React, { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
3
3
  import { Box, Checkbox, FormControl, Input, Popover, PopoverAnchor, PopoverContent, Portal, Spinner, useDisclosure, useMergeRefs, } from "@chakra-ui/react";
4
4
  import { Check, ChevronDown, X } from "lucide-react";
5
5
  import { useCustomTheme } from "../../Theme/useCustomTheme.js";
6
6
  import { focusGlowShadow } from "../../Theme/chakra/focusRing.styles.js";
7
7
  import { listRowBackgrounds } from "../../Theme/tokens/builders/rowStates.js";
8
- import { optionRowInk } from "./optionRowInk.js";
8
+ import { optionRowDescriptionInk, optionRowInk, optionRowWeight } from "./optionRowInk.js";
9
+ import { optionRowSpec } from "../../Theme/tokens/primitives/optionRowSizes.js";
9
10
  import { scrollbarStyles } from "../../Theme/tokens/builders/scrollbar.js";
10
11
  import { CONTROL_SIZES, DEFAULT_CONTROL_SIZE } from "../../Theme/tokens/primitives/controlSizes.js";
11
12
  import { transitionFor } from "../../Theme/tokens/primitives/motion.js";
12
13
  import { reducedMotionSx } from "../../Theme/chakra/motion.js";
14
+ import { chipTriggerSizeStyles } from "../Common/chipTriggerSize.js";
13
15
  import { fieldControlStyles } from "../Common/fieldStyles.js";
14
16
  import { useFieldIds } from "../Common/useFieldIds.js";
15
17
  import FieldMessage from "../Common/FieldMessage.js";
@@ -21,17 +23,7 @@ const CHEVRON_TRANSITION = transitionFor(["transform"], "base");
21
23
  const PAGE_STEP = 8;
22
24
  const LOAD_MORE_THRESHOLD_PX = 8;
23
25
  const DEFAULT_MAX_VISIBLE_CHIPS = 6;
24
- // NOTE: approximate single-row height (px 2.5 + ~13px text); used only to window `virtualizeAfter`, never for layout.
25
- const VIRTUAL_ROW_HEIGHT_PX = 34;
26
26
  const VIRTUAL_OVERSCAN = 4;
27
- const CHIP_TAG_HEIGHT_PX = { xs: 20, sm: 24 };
28
- const controlHeightPx = (size) => parseFloat(CONTROL_SIZES[size].height) * 16;
29
- const chipRowPaddingY = (size, chipSize) => `${(controlHeightPx(size) - CHIP_TAG_HEIGHT_PX[chipSize]) / 2 / 16}rem`;
30
- const chipTriggerSizeStyles = (size, hasChips, chipSize = "sm") => {
31
- const spec = CONTROL_SIZES[size];
32
- const base = { minH: spec.height, fontSize: spec.fontSize, px: spec.paddingX };
33
- return hasChips ? { ...base, py: chipRowPaddingY(size, chipSize) } : { ...base, h: spec.height };
34
- };
35
27
  const asIdSet = (values) => new Set((values !== null && values !== void 0 ? values : []).map((v) => v.toString()));
36
28
  /** Regression-guarded id scheme — tickets e2e (`e2e/tests/helpers/selectV2.ts`) resolves it verbatim. */
37
29
  const optionDomId = (listboxId, id) => `${listboxId}-option-${id.toString().replace(/[^a-zA-Z0-9_-]/g, "-")}`;
@@ -39,11 +31,11 @@ const parseRemToPx = (value, fallbackPx) => {
39
31
  const match = /^(\d*\.?\d+)rem$/.exec(value);
40
32
  return match ? parseFloat(match[1]) * 16 : fallbackPx;
41
33
  };
42
- const OptionRow = React.memo(function OptionRow({ option, index, listboxId, isSelected, isActive, isMulti, theme, backgrounds, renderOption, onSelect, onActivate, }) {
34
+ const OptionRow = React.memo(function OptionRow({ option, index, listboxId, isSelected, isActive, isMulti, theme, backgrounds, spec, renderOption, onSelect, onActivate, }) {
43
35
  const isSingleSelected = isSelected && !isMulti;
44
36
  const selectedBg = isActive ? backgrounds.selectedActive : backgrounds.selected;
45
37
  const restBg = isActive ? backgrounds.active : "transparent";
46
- return (_jsxs(Box, { id: optionDomId(listboxId, option.id), "data-index": index, role: "option", "aria-selected": isSelected, "aria-disabled": option.isDisabled, onMouseEnter: () => onActivate(index), onClick: () => onSelect(option), mx: 1.5, px: 2.5, py: 1.5, borderRadius: "md", cursor: option.isDisabled ? "not-allowed" : "pointer", opacity: option.isDisabled ? 0.4 : 1, display: "flex", alignItems: "center", gap: 2, fontSize: "0.8125rem", fontWeight: isSingleSelected ? 600 : 400, color: optionRowInk(theme.colors, isSingleSelected), bg: isSingleSelected ? selectedBg : restBg, transition: ROW_TRANSITION, sx: reducedMotionSx(), children: [isMulti ? (_jsx(Checkbox, { id: `${optionDomId(listboxId, option.id)}-checkbox`, isChecked: isSelected, isDisabled: option.isDisabled, pointerEvents: "none", tabIndex: -1 })) : null, renderOption ? (_jsx(Box, { flex: 1, minW: 0, children: renderOption(option, { isSelected, isActive }) })) : (_jsxs(_Fragment, { children: [option.icon ? (_jsx(Box, { as: "span", display: "inline-flex", flexShrink: 0, children: option.icon })) : null, _jsxs(Box, { flex: 1, minW: 0, isTruncated: true, children: [_jsx(Box, { as: "span", isTruncated: true, children: option.label }), option.description ? (_jsx(Box, { as: "span", display: "block", fontSize: "0.75rem", color: theme.colors.textMuted, isTruncated: true, children: option.description })) : null] })] })), isSingleSelected ? (_jsx(Box, { as: Check, size: 16, color: theme.colors.accentText, flexShrink: 0 })) : null] }));
38
+ return (_jsxs(Box, { id: optionDomId(listboxId, option.id), "data-index": index, role: "option", "aria-selected": isSelected, "aria-disabled": option.isDisabled, onMouseEnter: () => onActivate(index), onClick: () => onSelect(option), mx: spec.mx, px: spec.px, py: spec.py, minH: spec.minH, borderRadius: "md", cursor: option.isDisabled ? "not-allowed" : "pointer", opacity: option.isDisabled ? 0.4 : 1, display: "flex", alignItems: "center", gap: spec.gap, fontSize: spec.fontSize, fontWeight: optionRowWeight(isSingleSelected), color: optionRowInk(theme.colors, isSingleSelected), bg: isSingleSelected ? selectedBg : restBg, transition: ROW_TRANSITION, sx: reducedMotionSx(), children: [isMulti ? (_jsx(Checkbox, { id: `${optionDomId(listboxId, option.id)}-checkbox`, isChecked: isSelected, isDisabled: option.isDisabled, pointerEvents: "none", tabIndex: -1 })) : null, renderOption ? (_jsx(Box, { flex: 1, minW: 0, children: renderOption(option, { isSelected, isActive }) })) : (_jsxs(_Fragment, { children: [option.icon ? (_jsx(Box, { as: "span", display: "inline-flex", flexShrink: 0, children: option.icon })) : null, _jsxs(Box, { flex: 1, minW: 0, isTruncated: true, children: [_jsx(Box, { as: "span", isTruncated: true, children: option.label }), option.description ? (_jsx(Box, { as: "span", display: "block", fontSize: spec.descriptionFontSize, color: optionRowDescriptionInk(theme.colors), isTruncated: true, children: option.description })) : null] })] })), isSingleSelected ? (_jsx(Box, { as: Check, size: spec.iconSize, color: theme.colors.accentText, flexShrink: 0 })) : null] }));
47
39
  });
48
40
  const buildGroupedRows = (options) => {
49
41
  const rows = [];
@@ -61,7 +53,7 @@ const buildGroupedRows = (options) => {
61
53
  /** The unified `Select`'s popup mode (`native={false}`) — absorbs SelectV2's behaviour, plus clear/create/groups/virtualization. */
62
54
  const SelectPopup = forwardRef(function SelectPopup(props, ref) {
63
55
  var _a, _b, _c, _d;
64
- const { options = [], id, name, placeholder = "Select Option", label, error = false, errorMessage, helperText, isDisabled = false, isReadOnly = false, isRequired = false, isInformation = false, informationMessage, size = DEFAULT_CONTROL_SIZE, isSearchable = true, width, formControlStyle, onBlur, onFocus, renderOption, renderValue, maxMenuHeight = "16rem", defaultOpen = false, isLoading = false, loadingText = "Loading...", onLoadMore, onSearchChange, searchValue, defaultSearchValue, searchDebounceMs = 0, isClearable = false, onClear, noOptionsMessage, onCreate, createLabel, virtualizeAfter, "data-testid": dataTestId, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, } = props;
56
+ const { options = [], id, name, placeholder = "Select Option", label, error = false, errorMessage, helperText, isDisabled = false, isReadOnly = false, isRequired = false, isInformation = false, informationMessage, size = DEFAULT_CONTROL_SIZE, isSearchable = true, width, formControlStyle, onBlur, onFocus, renderOption, renderValue, maxMenuHeight = "16rem", defaultOpen = false, isLoading = false, loadingText = "Loading...", onLoadMore, hasMore, onSearchChange, searchValue, defaultSearchValue, searchDebounceMs = 0, filterOptions, isClearable = false, onClear, noOptionsMessage, onCreate, createLabel, virtualizeAfter, "data-testid": dataTestId, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, } = props;
65
57
  const theme = useCustomTheme();
66
58
  const { colors } = theme;
67
59
  const { isOpen, onOpen, onClose } = useDisclosure({ defaultIsOpen: defaultOpen });
@@ -71,6 +63,8 @@ const SelectPopup = forwardRef(function SelectPopup(props, ref) {
71
63
  const mergedTriggerRef = useMergeRefs(triggerRef, ref);
72
64
  const filterInputRef = useRef(null);
73
65
  const listRef = useRef(null);
66
+ const lastActiveIndex = useRef(-1);
67
+ const wasVirtualized = useRef(false);
74
68
  const [triggerWidth, setTriggerWidth] = useState(0);
75
69
  const ids = useFieldIds(id, { error, errorMessage, helperText });
76
70
  const listboxId = `${ids.inputId}-listbox`;
@@ -88,13 +82,28 @@ const SelectPopup = forwardRef(function SelectPopup(props, ref) {
88
82
  const selectedIds = useMemo(() => props.isMulti
89
83
  ? asIdSet(currentValue)
90
84
  : asIdSet(currentValue !== undefined ? [currentValue] : undefined), [props.isMulti, currentValue]);
91
- const selectedOptions = useMemo(() => options.filter((option) => selectedIds.has(option.id.toString())), [options, selectedIds]);
85
+ const selectionCacheRef = useRef(new Map());
86
+ const selectedOptions = useMemo(() => {
87
+ const cache = selectionCacheRef.current;
88
+ options.forEach((option) => {
89
+ if (selectedIds.has(option.id.toString()))
90
+ cache.set(option.id.toString(), option);
91
+ });
92
+ Array.from(cache.keys()).forEach((cachedId) => {
93
+ if (!selectedIds.has(cachedId))
94
+ cache.delete(cachedId);
95
+ });
96
+ return Array.from(selectedIds)
97
+ .map((selectedId) => cache.get(selectedId))
98
+ .filter((option) => option !== undefined);
99
+ }, [options, selectedIds]);
100
+ const shouldFilterLocally = filterOptions !== null && filterOptions !== void 0 ? filterOptions : !onSearchChange;
92
101
  const filteredOptions = useMemo(() => {
93
- if (!isSearchable || !currentSearch.trim())
102
+ if (!isSearchable || !currentSearch.trim() || !shouldFilterLocally)
94
103
  return options;
95
104
  const needle = currentSearch.trim().toLowerCase();
96
105
  return options.filter((option) => option.label.toString().toLowerCase().includes(needle));
97
- }, [options, currentSearch, isSearchable]);
106
+ }, [options, currentSearch, isSearchable, shouldFilterLocally]);
98
107
  const hasGroups = useMemo(() => options.some((option) => option.group), [options]);
99
108
  const resetAndClose = useCallback((opts) => {
100
109
  if (props.isMulti && (opts === null || opts === void 0 ? void 0 : opts.keepOpenForMulti))
@@ -116,15 +125,19 @@ const SelectPopup = forwardRef(function SelectPopup(props, ref) {
116
125
  : currentValue;
117
126
  setActiveIndex(filteredOptions.findIndex((option) => option.id.toString() === (firstSelected === null || firstSelected === void 0 ? void 0 : firstSelected.toString())));
118
127
  };
128
+ const selectedIdsRef = useRef(selectedIds);
129
+ selectedIdsRef.current = selectedIds;
130
+ const selectedOptionsRef = useRef(selectedOptions);
131
+ selectedOptionsRef.current = selectedOptions;
119
132
  const handleSelect = useCallback((option) => {
120
133
  var _a, _b;
121
134
  if (option.isDisabled)
122
135
  return;
123
136
  if (props.isMulti) {
124
- const alreadySelected = selectedIds.has(option.id.toString());
137
+ const alreadySelected = selectedIdsRef.current.has(option.id.toString());
125
138
  const next = alreadySelected
126
- ? selectedOptions.filter((selected) => selected.id.toString() !== option.id.toString())
127
- : [...selectedOptions, option];
139
+ ? selectedOptionsRef.current.filter((selected) => selected.id.toString() !== option.id.toString())
140
+ : [...selectedOptionsRef.current, option];
128
141
  if (!isValueControlled)
129
142
  setUncontrolledValue(next.map((o) => o.id));
130
143
  props.onChange(next);
@@ -139,7 +152,7 @@ const SelectPopup = forwardRef(function SelectPopup(props, ref) {
139
152
  (_b = triggerRef.current) === null || _b === void 0 ? void 0 : _b.focus();
140
153
  },
141
154
  // eslint-disable-next-line react-hooks/exhaustive-deps
142
- [props.isMulti, props.onChange, selectedIds, selectedOptions, resetAndClose, isValueControlled]);
155
+ [props.isMulti, props.onChange, resetAndClose, isValueControlled]);
143
156
  const handleCreate = useCallback(() => {
144
157
  if (!onCreate)
145
158
  return;
@@ -177,27 +190,51 @@ const SelectPopup = forwardRef(function SelectPopup(props, ref) {
177
190
  const frame = requestAnimationFrame(() => { var _a; return (_a = filterInputRef.current) === null || _a === void 0 ? void 0 : _a.focus(); });
178
191
  return () => cancelAnimationFrame(frame);
179
192
  }, [isOpen, isSearchable]);
193
+ const spec = useMemo(() => optionRowSpec(size), [size]);
180
194
  const maxMenuHeightPx = useMemo(() => parseRemToPx(maxMenuHeight, 256), [maxMenuHeight]);
181
195
  const shouldVirtualize = !!virtualizeAfter && !hasGroups && filteredOptions.length > virtualizeAfter;
182
196
  const [scrollTop, setScrollTop] = useState(0);
183
- const visibleCount = Math.ceil(maxMenuHeightPx / VIRTUAL_ROW_HEIGHT_PX) + VIRTUAL_OVERSCAN * 2;
197
+ // A description, an avatar or a renderOption row outgrows the spec's single-line height.
198
+ const [rowHeightPx, setRowHeightPx] = useState(spec.virtualRowHeightPx);
199
+ const visibleCount = Math.ceil(maxMenuHeightPx / rowHeightPx) + VIRTUAL_OVERSCAN * 2;
184
200
  const startIndex = shouldVirtualize
185
- ? Math.max(0, Math.floor(scrollTop / VIRTUAL_ROW_HEIGHT_PX) - VIRTUAL_OVERSCAN)
201
+ ? Math.max(0, Math.floor(scrollTop / rowHeightPx) - VIRTUAL_OVERSCAN)
186
202
  : 0;
187
203
  const endIndex = shouldVirtualize
188
204
  ? Math.min(filteredOptions.length, startIndex + visibleCount)
189
205
  : filteredOptions.length;
190
206
  const renderedOptions = shouldVirtualize ? filteredOptions.slice(startIndex, endIndex) : filteredOptions;
191
- const topPadPx = shouldVirtualize ? startIndex * VIRTUAL_ROW_HEIGHT_PX : 0;
192
- const bottomPadPx = shouldVirtualize ? (filteredOptions.length - endIndex) * VIRTUAL_ROW_HEIGHT_PX : 0;
207
+ const topPadPx = shouldVirtualize ? startIndex * rowHeightPx : 0;
208
+ const bottomPadPx = shouldVirtualize ? (filteredOptions.length - endIndex) * rowHeightPx : 0;
209
+ useLayoutEffect(() => {
210
+ var _a;
211
+ if (!isOpen)
212
+ return;
213
+ const row = (_a = listRef.current) === null || _a === void 0 ? void 0 : _a.querySelector('[role="option"]');
214
+ if (!row)
215
+ return;
216
+ const measured = row.getBoundingClientRect().height;
217
+ if (measured > 0 && Math.abs(measured - rowHeightPx) > 0.5)
218
+ setRowHeightPx(measured);
219
+ }, [isOpen, rowHeightPx, filteredOptions.length]);
220
+ // Windowing starts mid-list, so the window must open where the user already is.
221
+ useLayoutEffect(() => {
222
+ var _a, _b;
223
+ if (shouldVirtualize && !wasVirtualized.current)
224
+ setScrollTop((_b = (_a = listRef.current) === null || _a === void 0 ? void 0 : _a.scrollTop) !== null && _b !== void 0 ? _b : 0);
225
+ wasVirtualized.current = shouldVirtualize;
226
+ }, [shouldVirtualize]);
193
227
  useEffect(() => {
194
228
  var _a, _b;
195
229
  if (activeIndex < 0 || !listRef.current)
196
230
  return;
231
+ if (lastActiveIndex.current === activeIndex)
232
+ return;
233
+ lastActiveIndex.current = activeIndex;
197
234
  const el = listRef.current;
198
235
  if (shouldVirtualize) {
199
- const targetTop = activeIndex * VIRTUAL_ROW_HEIGHT_PX;
200
- const targetBottom = targetTop + VIRTUAL_ROW_HEIGHT_PX;
236
+ const targetTop = activeIndex * rowHeightPx;
237
+ const targetBottom = targetTop + rowHeightPx;
201
238
  if (targetTop < el.scrollTop) {
202
239
  el.scrollTop = targetTop;
203
240
  setScrollTop(targetTop);
@@ -210,18 +247,44 @@ const SelectPopup = forwardRef(function SelectPopup(props, ref) {
210
247
  return;
211
248
  }
212
249
  (_b = (_a = el.querySelector(`[data-index="${activeIndex}"]`)) === null || _a === void 0 ? void 0 : _a.scrollIntoView) === null || _b === void 0 ? void 0 : _b.call(_a, { block: "nearest" });
213
- }, [activeIndex, shouldVirtualize, maxMenuHeightPx]);
250
+ }, [activeIndex, shouldVirtualize, maxMenuHeightPx, rowHeightPx]);
251
+ const loadMoreInFlightRef = useRef(false);
252
+ const prevOptionsLengthRef = useRef(options.length);
253
+ const prevIsLoadingRef = useRef(isLoading);
254
+ useEffect(() => {
255
+ if (options.length !== prevOptionsLengthRef.current)
256
+ loadMoreInFlightRef.current = false;
257
+ prevOptionsLengthRef.current = options.length;
258
+ }, [options.length]);
259
+ useEffect(() => {
260
+ if (prevIsLoadingRef.current && !isLoading)
261
+ loadMoreInFlightRef.current = false;
262
+ prevIsLoadingRef.current = isLoading;
263
+ }, [isLoading]);
264
+ const canLoadMore = !!onLoadMore && hasMore !== false;
265
+ const triggerLoadMore = useCallback(() => {
266
+ if (!onLoadMore || hasMore === false || isLoading || loadMoreInFlightRef.current)
267
+ return;
268
+ loadMoreInFlightRef.current = true;
269
+ onLoadMore();
270
+ }, [onLoadMore, hasMore, isLoading]);
214
271
  const moveActive = (delta) => {
215
272
  if (!filteredOptions.length)
216
273
  return;
217
- setActiveIndex((current) => {
218
- const next = current + delta;
219
- if (next < 0)
220
- return filteredOptions.length - 1;
221
- if (next >= filteredOptions.length)
222
- return 0;
223
- return next;
224
- });
274
+ const next = activeIndex + delta;
275
+ if (delta > 0 && next >= filteredOptions.length) {
276
+ if (canLoadMore) {
277
+ triggerLoadMore();
278
+ return;
279
+ }
280
+ setActiveIndex(0);
281
+ return;
282
+ }
283
+ if (next < 0) {
284
+ setActiveIndex(filteredOptions.length - 1);
285
+ return;
286
+ }
287
+ setActiveIndex(next);
225
288
  };
226
289
  const moveActiveBy = (delta) => {
227
290
  if (!filteredOptions.length)
@@ -308,10 +371,8 @@ const SelectPopup = forwardRef(function SelectPopup(props, ref) {
308
371
  const { scrollTop: nextScrollTop, clientHeight, scrollHeight } = event.currentTarget;
309
372
  if (shouldVirtualize)
310
373
  setScrollTop(nextScrollTop);
311
- if (!onLoadMore || isLoading)
312
- return;
313
374
  if (nextScrollTop + clientHeight >= scrollHeight - LOAD_MORE_THRESHOLD_PX)
314
- onLoadMore();
375
+ triggerLoadMore();
315
376
  };
316
377
  const handleFilterChange = (event) => {
317
378
  const nextText = event.target.value;
@@ -373,14 +434,14 @@ const SelectPopup = forwardRef(function SelectPopup(props, ref) {
373
434
  const selectedValueIds = selectedOptions.map((option) => option.id.toString());
374
435
  const hiddenValues = !name ? [] : props.isMulti ? selectedValueIds : [(_c = selectedValueIds[0]) !== null && _c !== void 0 ? _c : ""];
375
436
  const groupedRows = useMemo(() => (hasGroups ? buildGroupedRows(filteredOptions) : []), [hasGroups, filteredOptions]);
376
- return (_jsxs(FormControl, { isInvalid: error, isDisabled: isDisabled, isReadOnly: isReadOnly, style: formControlStyle, width: width, children: [label && (_jsx(TextLabel, { label: label, id: ids.inputId, isRequired: isRequired, isInformation: isInformation, informationMessage: informationMessage })), _jsxs(Popover, { isOpen: isOpen, onClose: () => resetAndClose(), placement: "bottom-start", autoFocus: false, returnFocusOnClose: false, gutter: 4, children: [_jsx(PopoverAnchor, { children: _jsxs(Box, { as: "div", ref: mergedTriggerRef, ...ids.controlProps, role: "combobox", tabIndex: isDisabled ? -1 : 0, "aria-haspopup": "listbox", "aria-expanded": isOpen, "aria-controls": listboxId, "aria-owns": `${listboxId} ${filterInputId}`, "aria-activedescendant": activeDescendantId, "aria-disabled": isDisabled || undefined, "data-readonly": isReadOnly || undefined, "aria-required": isRequired || undefined, "aria-label": triggerAriaLabel(), "aria-labelledby": ariaLabelledBy, onClick: handleOpen, onKeyDown: handleTriggerKeyDown, display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%", textAlign: "left", cursor: isDisabled ? "not-allowed" : isReadOnly ? "default" : "pointer", "data-testid": dataTestId, sx: triggerSx, children: [_jsx(Box, { as: "span", display: "inline-flex", flexWrap: showChips ? "wrap" : "nowrap", alignItems: "center", gap: showChips ? 1 : 1.5, minW: 0, flex: 1, isTruncated: !showChips, children: showChips ? (_jsxs(_Fragment, { children: [visibleChipOptions.map((option) => (_jsx(Tag, { label: String(option.label), size: chipSize, variant: "subtle", icon: isInteractive ? X : undefined, onIconClick: isInteractive ? () => handleSelect(option) : undefined }, option.id))), overflowChipCount > 0 && (_jsx(Tag, { label: `+${overflowChipCount}`, size: chipSize, variant: "subtle", colorScheme: "gray" }))] })) : (_jsxs(_Fragment, { children: [triggerIcon ? (_jsx(Box, { as: "span", display: "inline-flex", flexShrink: 0, children: triggerIcon })) : null, _jsx(Box, { as: "span", isTruncated: true, children: renderValue ? renderValue(selectedOptions) : defaultTriggerContent() })] })) }), showClear && (_jsx(Box, { as: "button", type: "button", "aria-label": "Clear selection", onClick: handleClear, display: "inline-flex", alignItems: "center", justifyContent: "center", flexShrink: 0, ml: 2, color: colors.textMuted, _hover: { color: colors.text[700] }, children: _jsx(X, { size: CONTROL_SIZES[size].iconSize }) })), _jsx(Box, { as: "span", display: "inline-flex", flexShrink: 0, ml: 2, transition: CHEVRON_TRANSITION, transform: isOpen ? "rotate(180deg)" : "rotate(0deg)", color: colors.textMuted, sx: reducedMotionSx(), children: _jsx(ChevronDown, { size: CONTROL_SIZES[size].iconSize }) })] }) }), _jsx(Portal, { children: _jsxs(PopoverContent, { width: "max-content", minWidth: triggerWidth ? `${triggerWidth}px` : undefined, maxWidth: "min(24rem, calc(100vw - 2rem))", borderRadius: "xl", boxShadow: theme.shadows.lg, overflow: "hidden", onKeyDown: handleListKeyDown, children: [_jsx(Box, { p: 2, borderBottom: `0.063rem solid ${colors.boxborder[200]}`, display: isSearchable ? undefined : "none", children: _jsx(Input, { ref: filterInputRef, id: filterInputId, size: "sm", placeholder: "Type to filter...", value: currentSearch, onChange: handleFilterChange, "aria-activedescendant": activeDescendantId, "aria-controls": listboxId, "aria-label": "Filter options", tabIndex: isSearchable ? undefined : -1 }) }), _jsxs(Box, { id: listboxId, ref: listRef, role: "listbox", "aria-label": (_d = label !== null && label !== void 0 ? label : ariaLabel) !== null && _d !== void 0 ? _d : placeholder, "aria-multiselectable": props.isMulti || undefined, "aria-busy": isLoading || undefined, maxH: maxMenuHeight, overflowY: "auto", py: 1, sx: listScrollStyles, onScroll: handleListScroll, children: [filteredOptions.length === 0 && !isLoading && !showCreateRow && (_jsx(Box, { px: 3, py: 2, fontSize: "0.8125rem", color: colors.textMuted, children: noOptionsMessage !== null && noOptionsMessage !== void 0 ? noOptionsMessage : "No Options" })), showCreateRow && (_jsx(Box, { id: createRowId, role: "option", "aria-selected": false, "data-index": -1, onClick: handleCreate, mx: 1.5, px: 2.5, py: 1.5, borderRadius: "md", cursor: "pointer", fontSize: "0.8125rem", fontWeight: 600, color: colors.primary[600], bg: activeDescendantId === createRowId ? rowBackgrounds.active : "transparent", children: createLabel ? createLabel(currentSearch.trim()) : `Create "${currentSearch.trim()}"` })), hasGroups
437
+ return (_jsxs(FormControl, { isInvalid: error, isDisabled: isDisabled, isReadOnly: isReadOnly, style: formControlStyle, width: width, children: [label && (_jsx(TextLabel, { label: label, id: ids.inputId, isRequired: isRequired, isInformation: isInformation, informationMessage: informationMessage })), _jsxs(Popover, { isOpen: isOpen, onClose: () => resetAndClose(), placement: "bottom-start", autoFocus: false, returnFocusOnClose: false, gutter: 4, children: [_jsx(PopoverAnchor, { children: _jsxs(Box, { as: "div", ref: mergedTriggerRef, ...ids.controlProps, role: "combobox", tabIndex: isDisabled ? -1 : 0, "aria-haspopup": "listbox", "aria-expanded": isOpen, "aria-controls": listboxId, "aria-owns": `${listboxId} ${filterInputId}`, "aria-activedescendant": activeDescendantId, "aria-disabled": isDisabled || undefined, "data-readonly": isReadOnly || undefined, "aria-required": isRequired || undefined, "aria-label": triggerAriaLabel(), "aria-labelledby": ariaLabelledBy, onClick: handleOpen, onKeyDown: handleTriggerKeyDown, display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%", textAlign: "left", cursor: isDisabled ? "not-allowed" : isReadOnly ? "default" : "pointer", "data-testid": dataTestId, sx: triggerSx, children: [_jsx(Box, { as: "span", display: "inline-flex", flexWrap: showChips ? "wrap" : "nowrap", alignItems: "center", gap: showChips ? 1 : 1.5, minW: 0, flex: 1, isTruncated: !showChips, children: showChips ? (_jsxs(_Fragment, { children: [visibleChipOptions.map((option) => (_jsx(Tag, { label: String(option.label), size: chipSize, variant: "subtle", icon: isInteractive ? X : undefined, onIconClick: isInteractive ? () => handleSelect(option) : undefined }, option.id))), overflowChipCount > 0 && (_jsx(Tag, { label: `+${overflowChipCount}`, size: chipSize, variant: "subtle", colorScheme: "gray" }))] })) : (_jsxs(_Fragment, { children: [triggerIcon ? (_jsx(Box, { as: "span", display: "inline-flex", flexShrink: 0, children: triggerIcon })) : null, _jsx(Box, { as: "span", isTruncated: true, children: renderValue ? renderValue(selectedOptions) : defaultTriggerContent() })] })) }), showClear && (_jsx(Box, { as: "button", type: "button", "aria-label": "Clear selection", onClick: handleClear, display: "inline-flex", alignItems: "center", justifyContent: "center", flexShrink: 0, ml: 2, color: colors.textMuted, _hover: { color: colors.text[700] }, children: _jsx(X, { size: CONTROL_SIZES[size].iconSize }) })), _jsx(Box, { as: "span", display: "inline-flex", flexShrink: 0, ml: 2, transition: CHEVRON_TRANSITION, transform: isOpen ? "rotate(180deg)" : "rotate(0deg)", color: colors.textMuted, sx: reducedMotionSx(), children: _jsx(ChevronDown, { size: CONTROL_SIZES[size].iconSize }) })] }) }), _jsx(Portal, { children: _jsxs(PopoverContent, { width: "max-content", minWidth: triggerWidth ? `${triggerWidth}px` : undefined, maxWidth: "min(24rem, calc(100vw - 2rem))", borderRadius: "xl", boxShadow: theme.shadows.lg, overflow: "hidden", onKeyDown: handleListKeyDown, children: [_jsx(Box, { p: spec.mx, borderBottom: `0.063rem solid ${colors.boxborder[200]}`, display: isSearchable ? undefined : "none", children: _jsx(Input, { ref: filterInputRef, id: filterInputId, size: size, px: spec.px, placeholder: "Type to filter...", value: currentSearch, onChange: handleFilterChange, "aria-activedescendant": activeDescendantId, "aria-controls": listboxId, "aria-label": "Filter options", tabIndex: isSearchable ? undefined : -1 }) }), _jsxs(Box, { id: listboxId, ref: listRef, role: "listbox", "aria-label": (_d = label !== null && label !== void 0 ? label : ariaLabel) !== null && _d !== void 0 ? _d : placeholder, "aria-multiselectable": props.isMulti || undefined, "aria-busy": isLoading || undefined, maxH: maxMenuHeight, overflowY: "auto", py: 1, sx: listScrollStyles, onScroll: handleListScroll, children: [filteredOptions.length === 0 && !isLoading && !showCreateRow && (_jsx(Box, { mx: spec.mx, px: spec.px, py: spec.py, fontSize: spec.fontSize, color: colors.textMuted, children: noOptionsMessage !== null && noOptionsMessage !== void 0 ? noOptionsMessage : "No Options" })), showCreateRow && (_jsx(Box, { id: createRowId, role: "option", "aria-selected": false, "data-index": -1, onClick: handleCreate, mx: spec.mx, px: spec.px, py: spec.py, minH: spec.minH, borderRadius: "md", cursor: "pointer", fontSize: spec.fontSize, fontWeight: 600, color: colors.primary[600], bg: activeDescendantId === createRowId ? rowBackgrounds.active : "transparent", children: createLabel ? createLabel(currentSearch.trim()) : `Create "${currentSearch.trim()}"` })), hasGroups
377
438
  ? groupedRows.map((row) => {
378
439
  var _a;
379
- return row.kind === "header" ? (_jsx(Box, { px: 3, pt: 2, pb: 1, fontSize: "0.6875rem", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.03em", color: colors.textMuted, children: row.group }, row.key)) : (_jsx(OptionRow, { option: row.option, index: row.index, listboxId: listboxId, isSelected: selectedIds.has(row.option.id.toString()), isActive: row.index === activeIndex, isMulti: (_a = props.isMulti) !== null && _a !== void 0 ? _a : false, theme: theme, backgrounds: rowBackgrounds, renderOption: renderOption, onSelect: handleSelect, onActivate: setActiveIndex }, row.key));
440
+ return row.kind === "header" ? (_jsx(Box, { mx: spec.mx, px: spec.px, pt: 2, pb: 1, fontSize: "0.6875rem", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.03em", color: colors.textMuted, children: row.group }, row.key)) : (_jsx(OptionRow, { option: row.option, index: row.index, listboxId: listboxId, isSelected: selectedIds.has(row.option.id.toString()), isActive: row.index === activeIndex, isMulti: (_a = props.isMulti) !== null && _a !== void 0 ? _a : false, theme: theme, backgrounds: rowBackgrounds, spec: spec, renderOption: renderOption, onSelect: handleSelect, onActivate: setActiveIndex }, row.key));
380
441
  })
381
442
  : (_jsxs(_Fragment, { children: [topPadPx > 0 && _jsx(Box, { height: `${topPadPx}px` }), renderedOptions.map((option, i) => {
382
443
  var _a;
383
- return (_jsx(OptionRow, { option: option, index: startIndex + i, listboxId: listboxId, isSelected: selectedIds.has(option.id.toString()), isActive: startIndex + i === activeIndex, isMulti: (_a = props.isMulti) !== null && _a !== void 0 ? _a : false, theme: theme, backgrounds: rowBackgrounds, renderOption: renderOption, onSelect: handleSelect, onActivate: setActiveIndex }, option.id));
384
- }), bottomPadPx > 0 && _jsx(Box, { height: `${bottomPadPx}px` })] })), isLoading && (_jsxs(Box, { px: 3, py: 2, display: "flex", alignItems: "center", gap: 2, fontSize: "0.8125rem", color: colors.textMuted, children: [_jsx(Spinner, { size: "xs" }), loadingText] }))] })] }) })] }), hiddenValues.map((value) => (_jsx("input", { type: "hidden", name: name, value: value }, value))), _jsx(FieldMessage, { error: error, errorMessage: errorMessage, helperText: helperText, helperId: ids.helperId, errorId: ids.errorId })] }));
444
+ return (_jsx(OptionRow, { option: option, index: startIndex + i, listboxId: listboxId, isSelected: selectedIds.has(option.id.toString()), isActive: startIndex + i === activeIndex, isMulti: (_a = props.isMulti) !== null && _a !== void 0 ? _a : false, theme: theme, backgrounds: rowBackgrounds, spec: spec, renderOption: renderOption, onSelect: handleSelect, onActivate: setActiveIndex }, option.id));
445
+ }), bottomPadPx > 0 && _jsx(Box, { height: `${bottomPadPx}px` })] })), isLoading && (_jsxs(Box, { mx: spec.mx, px: spec.px, py: spec.py, display: "flex", alignItems: "center", gap: spec.gap, fontSize: spec.fontSize, color: colors.textMuted, children: [_jsx(Spinner, { size: "xs" }), loadingText] }))] })] }) })] }), hiddenValues.map((value) => (_jsx("input", { type: "hidden", name: name, value: value }, value))), _jsx(FieldMessage, { error: error, errorMessage: errorMessage, helperText: helperText, helperId: ids.helperId, errorId: ids.errorId })] }));
385
446
  });
386
447
  export default SelectPopup;
@@ -1,2 +1,11 @@
1
+ import { AA_NORMAL_TEXT } from "../../Theme/tokens/builders/accentText.js";
2
+ import { liftToContrast } from "../../Theme/tokens/builders/outlineRung.js";
3
+ import { listRowBackgrounds } from "../../Theme/tokens/builders/rowStates.js";
1
4
  /** Selection is carried by weight and a trailing brand check, so the label itself stays neutral. */
2
5
  export const optionRowInk = (colors, isSingleSelected) => isSingleSelected ? colors.text[900] : colors.text[700];
6
+ // A 12px sub-line reached only 4.00:1 on the hovered selected row in dark.
7
+ export const optionRowDescriptionInk = (colors) => liftToContrast(colors.textMuted, listRowBackgrounds(colors).selectedActive, AA_NORMAL_TEXT);
8
+ export const OPTION_ROW_REST_WEIGHT = 400;
9
+ // NOTE: 600 beside a tinted fill and a check read as three signals for one state.
10
+ export const OPTION_ROW_SELECTED_WEIGHT = 500;
11
+ export const optionRowWeight = (isSingleSelected) => isSingleSelected ? OPTION_ROW_SELECTED_WEIGHT : OPTION_ROW_REST_WEIGHT;
@@ -8,21 +8,25 @@ import { useFieldIds } from "../Common/useFieldIds.js";
8
8
  import FieldMessage from "../Common/FieldMessage.js";
9
9
  import { transitionFor } from "../../Theme/tokens/primitives/motion.js";
10
10
  import { DEFAULT_CONTROL_SIZE } from "../../Theme/tokens/primitives/controlSizes.js";
11
+ import { optionRowSpec, OPTION_ROW_RADIUS } from "../../Theme/tokens/primitives/optionRowSizes.js";
11
12
  import { reducedMotionSx } from "../../Theme/chakra/motion.js";
12
13
  import { useCustomTheme } from "../../Theme/useCustomTheme.js";
13
14
  import { listRowBackgrounds } from "../../Theme/tokens/builders/rowStates.js";
15
+ import { optionRowDescriptionInk, optionRowInk, optionRowWeight } from "../Select/optionRowInk.js";
14
16
  import { useExclusiveOverlay } from "../../Hooks/useExclusiveOverlay.js";
15
- import { PlusIcon } from "lucide-react";
17
+ import { PlusIcon, Check } from "lucide-react";
16
18
  const ROW_TRANSITION = transitionFor(["background-color"]);
17
- const RenderOptions = ({ isOptionLoading, filteredOptions, loadingText, handleOptionClick, isProfile, isColorOptions, isOptionSelected, activeIndex, optionRefs, }) => {
19
+ const RenderOptions = ({ isOptionLoading, filteredOptions, loadingText, handleOptionClick, isProfile, isColorOptions, isOptionSelected, activeIndex, optionRefs, size, isMultipleSelect, }) => {
18
20
  const theme = useCustomTheme();
19
21
  const rowBackgrounds = useMemo(() => listRowBackgrounds(theme.colors), [theme.colors]);
22
+ const spec = optionRowSpec(size);
20
23
  if (isOptionLoading) {
21
24
  return (_jsxs(VStack, { py: 2, children: [_jsx(Spinner, { size: "sm", color: theme.colors.accentText }), _jsx(Text, { color: theme.colors.accentText, children: loadingText })] }));
22
25
  }
23
26
  if (filteredOptions === null || filteredOptions === void 0 ? void 0 : filteredOptions.length) {
24
27
  return (_jsx(_Fragment, { children: filteredOptions.map((option, index) => {
25
28
  const selected = isOptionSelected(option);
29
+ const isSingleSelected = selected && !isMultipleSelect;
26
30
  const isActive = index === activeIndex;
27
31
  const rowBg = selected
28
32
  ? isActive
@@ -33,14 +37,14 @@ const RenderOptions = ({ isOptionLoading, filteredOptions, loadingText, handleOp
33
37
  : "transparent";
34
38
  return (_jsxs(Box, { as: "button", type: "button", ref: (el) => {
35
39
  optionRefs.current[index] = el;
36
- }, role: "option", "aria-selected": selected, tabIndex: isActive ? 0 : -1, w: "100%", textAlign: "left", border: "none", fontFamily: "inherit", bg: rowBg, px: 4, py: 1.5, fontSize: "0.8125rem", cursor: "pointer", color: theme.colors.text[700], fontWeight: selected ? 600 : 400, transition: ROW_TRANSITION, sx: reducedMotionSx(), _hover: {
40
+ }, role: "option", "aria-selected": selected, tabIndex: isActive ? 0 : -1, w: `calc(100% - 2 * ${spec.mx})`, minH: spec.minH, textAlign: "left", border: "none", fontFamily: "inherit", bg: rowBg, mx: spec.mx, px: spec.px, py: spec.py, borderRadius: OPTION_ROW_RADIUS, fontSize: spec.fontSize, cursor: "pointer", color: optionRowInk(theme.colors, isSingleSelected), fontWeight: optionRowWeight(isSingleSelected), transition: ROW_TRANSITION, sx: reducedMotionSx(), _hover: {
37
41
  bg: selected ? rowBackgrounds.selectedActive : rowBackgrounds.active,
38
42
  }, onClick: () => handleOptionClick(option), onKeyDown: (e) => {
39
43
  if (e.key === "Enter" || e.key === " ") {
40
44
  e.preventDefault();
41
45
  handleOptionClick(option);
42
46
  }
43
- }, display: "flex", alignItems: "center", gap: "0.5rem", children: [isProfile && (_jsx(Box, { children: _jsx(Avatar, { size: "sm", name: option.label.toLocaleUpperCase(), src: option.profileUrl ? option.profileUrl : undefined }) })), isColorOptions && (_jsx(Box, { width: "0.8rem", height: "0.8rem", content: "", backgroundColor: option === null || option === void 0 ? void 0 : option.color, borderRadius: "50%" })), _jsxs(Box, { children: [_jsx(Box, { children: option.label }), option.email && (_jsx(Box, { fontSize: "0.75rem", color: theme.colors.textMuted, children: option.email }))] })] }, option.id));
47
+ }, display: "flex", alignItems: "center", gap: spec.gap, children: [isProfile && (_jsx(Box, { children: _jsx(Avatar, { size: spec.avatarSize, name: option.label.toLocaleUpperCase(), src: option.profileUrl ? option.profileUrl : undefined }) })), isColorOptions && (_jsx(Box, { width: "0.8rem", height: "0.8rem", content: "", backgroundColor: option === null || option === void 0 ? void 0 : option.color, borderRadius: "50%" })), _jsxs(Box, { flex: 1, minW: 0, children: [_jsx(Box, { children: option.label }), option.email && (_jsx(Box, { fontSize: spec.descriptionFontSize, color: optionRowDescriptionInk(theme.colors), children: option.email }))] }), isSingleSelected && (_jsx(Box, { as: Check, size: spec.iconSize, color: theme.colors.accentText, flexShrink: 0 }))] }, option.id));
44
48
  }) }));
45
49
  }
46
50
  return (_jsx(Text, { px: 4, py: 2, color: theme.colors.textMuted, children: "No options found" }));
@@ -224,7 +228,7 @@ export default function SelectSearch({ options, initialSelectedOption, onOptionS
224
228
  return;
225
229
  }
226
230
  openDropdown();
227
- }, onChange: (e) => handleInputChange(e.target.value), placeholder: placeholder, onKeyDown: handleKeyDown, name: name, role: "combobox", "aria-autocomplete": "list", "aria-haspopup": "listbox", "aria-expanded": isOpen, "aria-controls": listboxId, "aria-required": isRequired || undefined, cursor: "pointer", sx: { ...fieldControlStyles(theme.colors), ...controlSizeStyles(size), ...reducedMotionSx() }, style: inputStyle }), rightIcon && (_jsx(InputRightElement, { pointerEvents: "none", children: rightIcon, style: { ...rightElementStyle } }))] }), isOpen && (_jsxs(Box, { ref: dropdownRef, marginTop: "0.313rem", position: "absolute", top: position === "below" ? "100%" : "auto", bottom: position === "above" ? "100%" : "auto", left: 0, right: 0, border: "0.063rem solid", borderColor: theme.colors.boxborder[200], borderRadius: "xl", boxShadow: theme.shadows.lg, bg: theme.colors.surfaceRaised, maxHeight: "9.375rem", overflowY: "auto", zIndex: 10, children: [_jsx(Box, { id: listboxId, role: "listbox", "aria-label": label !== null && label !== void 0 ? label : placeholder, onKeyDown: handleListKeyDown, children: _jsx(RenderOptions, { isOptionLoading: isOptionLoading, filteredOptions: filteredOptions, loadingText: loadingText, handleOptionClick: handleOptionClick, isProfile: isProfile, isColorOptions: isColorOptions, isOptionSelected: isOptionSelected, activeIndex: activeIndex, optionRefs: optionRefs }) }), _jsx("hr", {}), isBottomIcon && (_jsxs(Box, { position: "sticky", bottom: 0, background: theme.colors.surfaceRaised, display: "flex", alignItems: "center", pl: "1rem", cursor: "pointer", onClick: handleBottomClick, borderColor: theme.colors.boxborder[200], zIndex: 1, borderTop: `1px solid ${theme.colors.boxborder[200]}`, children: [_jsx(Box, { children: BottomIcon ? BottomIcon : _jsx(PlusIcon, { size: "1rem" }) }), _jsx(Box, { fontSize: "0.875rem", color: BottomTextColor
231
+ }, onChange: (e) => handleInputChange(e.target.value), placeholder: placeholder, onKeyDown: handleKeyDown, name: name, role: "combobox", "aria-autocomplete": "list", "aria-haspopup": "listbox", "aria-expanded": isOpen, "aria-controls": listboxId, "aria-required": isRequired || undefined, cursor: "pointer", sx: { ...fieldControlStyles(theme.colors), ...controlSizeStyles(size), ...reducedMotionSx() }, style: inputStyle }), rightIcon && (_jsx(InputRightElement, { pointerEvents: "none", children: rightIcon, style: { ...rightElementStyle } }))] }), isOpen && (_jsxs(Box, { ref: dropdownRef, marginTop: "0.313rem", position: "absolute", top: position === "below" ? "100%" : "auto", bottom: position === "above" ? "100%" : "auto", left: 0, right: 0, border: "0.063rem solid", borderColor: theme.colors.boxborder[200], borderRadius: "xl", boxShadow: theme.shadows.lg, bg: theme.colors.surfaceRaised, maxHeight: "9.375rem", overflowY: "auto", zIndex: 10, children: [_jsx(Box, { id: listboxId, role: "listbox", "aria-label": label !== null && label !== void 0 ? label : placeholder, onKeyDown: handleListKeyDown, children: _jsx(RenderOptions, { isOptionLoading: isOptionLoading, filteredOptions: filteredOptions, loadingText: loadingText, handleOptionClick: handleOptionClick, isProfile: isProfile, isColorOptions: isColorOptions, isOptionSelected: isOptionSelected, activeIndex: activeIndex, optionRefs: optionRefs, size: size, isMultipleSelect: isMultipleSelect }) }), _jsx("hr", {}), isBottomIcon && (_jsxs(Box, { position: "sticky", bottom: 0, background: theme.colors.surfaceRaised, display: "flex", alignItems: "center", pl: "1rem", cursor: "pointer", onClick: handleBottomClick, borderColor: theme.colors.boxborder[200], zIndex: 1, borderTop: `1px solid ${theme.colors.boxborder[200]}`, children: [_jsx(Box, { children: BottomIcon ? BottomIcon : _jsx(PlusIcon, { size: "1rem" }) }), _jsx(Box, { fontSize: "0.875rem", color: BottomTextColor
228
232
  ? BottomTextColor
229
233
  : theme.colors.accentText, padding: "8px 8px", children: BottomText || "Add New" })] }))] })), _jsx(FieldMessage, { error: error, errorMessage: errorMessage, helperText: helperText, helperId: ids.helperId, errorId: ids.errorId })] }) }) }));
230
234
  }
@@ -1,15 +1,8 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { forwardRef } from "react";
3
3
  import Select from "../Select/Select.js";
4
- import { CONTROL_SIZES } from "../../Theme/tokens/primitives/controlSizes.js";
5
- const CHIP_TAG_HEIGHT_PX = { xs: 20, sm: 24 };
6
- const controlHeightPx = (size) => parseFloat(CONTROL_SIZES[size].height) * 16;
7
- const chipRowPaddingY = (size, chipSize) => `${(controlHeightPx(size) - CHIP_TAG_HEIGHT_PX[chipSize]) / 2 / 16}rem`;
8
- export const chipTriggerSizeStyles = (size, hasChips, chipSize = "sm") => {
9
- const spec = CONTROL_SIZES[size];
10
- const base = { minH: spec.height, fontSize: spec.fontSize, px: spec.paddingX };
11
- return hasChips ? { ...base, py: chipRowPaddingY(size, chipSize) } : { ...base, h: spec.height };
12
- };
4
+ import { chipTriggerSizeStyles } from "../Common/chipTriggerSize.js";
5
+ export { chipTriggerSizeStyles };
13
6
  /** @deprecated Use `Select` with `native={false}`. See MERGE-NOTES-select-deprecation.md. */
14
7
  const SelectV2 = forwardRef(function SelectV2(props, ref) {
15
8
  return _jsx(Select, { ...props, native: false, ref: ref });
@@ -1,11 +1,13 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box } from "@chakra-ui/react";
3
3
  import SegmentedControl from "../SegmentedControl/SegmentedControl.js";
4
- /** @deprecated Use `SegmentedControl` — Switch renders it at 12.5rem, growing when the labels need it. */
4
+ /** @deprecated Two labelled choices; for a single on/off field use `ToggleSwitch`. Renders `SegmentedControl` at 12.5rem when labelled. */
5
5
  export default function Switch({ leftText, rightText, leftIcon, rightIcon, onSwitchToggle, switchActive, surface, "aria-label": ariaLabel, }) {
6
6
  const options = [
7
7
  { value: "left", label: leftText, icon: leftIcon },
8
8
  { value: "right", label: rightText, icon: rightIcon },
9
9
  ];
10
- return (_jsx(Box, { w: "fit-content", minW: "12.5rem", children: _jsx(SegmentedControl, { options: options, value: switchActive, onChange: onSwitchToggle, variant: "brandSlide", surface: surface, isFullWidth: true, "aria-label": ariaLabel !== null && ariaLabel !== void 0 ? ariaLabel : `${leftText} or ${rightText}` }) }));
10
+ // Unlabelled segments have nothing to size to, so the 12.5rem floor only overflowed its cell.
11
+ const hasLabels = Boolean(leftText || rightText);
12
+ return (_jsx(Box, { w: "fit-content", maxW: "100%", minW: hasLabels ? "12.5rem" : undefined, children: _jsx(SegmentedControl, { options: options, value: switchActive, onChange: onSwitchToggle, variant: "brandSlide", surface: surface, isFullWidth: true, "aria-label": ariaLabel !== null && ariaLabel !== void 0 ? ariaLabel : `${leftText} or ${rightText}` }) }));
11
13
  }
@@ -77,7 +77,7 @@ export default function Table({ data, columns, onSelection, isLoading, isCheckbo
77
77
  }
78
78
  return {};
79
79
  }, [tablePreferencesData]);
80
- const { tableData, isContent, isLink, headerRefs, columnWidths, handleSort, handleCheckbox, filteredData, startRow, selection, columnsSort, currentPage, pages, rowsPerPage, handlePageSizeChange, setCurrentPage, columnsList, handleColumnPreferences, isSelecting, selectAllRowsRef, } = useTable({
80
+ const { tableData, isContent, isLink, headerRefs, columnWidths, slotRef, slotWidth, handleSort, handleCheckbox, filteredData, searchedData, startRow, selection, columnsSort, currentPage, pages, rowsPerPage, handlePageSizeChange, setCurrentPage, columnsList, handleColumnPreferences, isSelecting, selectAllRowsRef, } = useTable({
81
81
  data,
82
82
  isPagination,
83
83
  selections,
@@ -90,6 +90,7 @@ export default function Table({ data, columns, onSelection, isLoading, isCheckbo
90
90
  isServerPagination,
91
91
  onNoOfRowsPerPageChange,
92
92
  defaultVisibleColumns,
93
+ columnsSearch,
93
94
  });
94
95
  // Density + grouping are managed here so the Table Settings tabs can change +
95
96
  // persist them (seeded from the prop / saved preferences when they load).
@@ -172,9 +173,7 @@ export default function Table({ data, columns, onSelection, isLoading, isCheckbo
172
173
  setGroupByState(group);
173
174
  handleColumnPreferences(cols, { density: densityState, groupBy: group !== null && group !== void 0 ? group : null });
174
175
  };
175
- const _filteredData = useMemo(() => {
176
- return searchAndSortData(filteredData, columnsSearch);
177
- }, [columnsSearch, filteredData]);
176
+ const _filteredData = filteredData;
178
177
  // --- Monday-style grouping. Active whenever the user has picked a group column
179
178
  // (in Settings > Group). When active, the numbered pager / infinite scroll are
180
179
  // hidden and the body renders colored, collapsible groups over the loaded rows.
@@ -279,7 +278,7 @@ export default function Table({ data, columns, onSelection, isLoading, isCheckbo
279
278
  : "Showing all records";
280
279
  const tablePaginationText = isServerPagination
281
280
  ? `${startRow + 1} - ${Math.min(startRow + rowsPerPage, totalRecords)} of ${totalRecords}`
282
- : `${startRow + 1} - ${Math.min(startRow + rowsPerPage, tableData.length)} of ${tableData.length}`;
281
+ : `${startRow + 1} - ${Math.min(startRow + rowsPerPage, searchedData.length)} of ${searchedData.length}`;
283
282
  const rowHeight = rowHeightFor(densityState);
284
283
  const headerHeight = pxOf(variantTokens.header.height);
285
284
  const actualRows = isGrouped ? groupedSource.length : _filteredData.length;
@@ -400,7 +399,7 @@ export default function Table({ data, columns, onSelection, isLoading, isCheckbo
400
399
  headerActions && (_jsx(HeaderActions, { actions: headerActions, selections: selection }, "actions")),
401
400
  ]
402
401
  .filter(Boolean)
403
- .flatMap((tool, i) => (i === 0 ? [tool] : [_jsx(VeritcalDivider, {}, `divider-${i}`), tool])), _jsx(ActiveFilters, { columns: columnsList, columnsSearch: columnsSearch, setColumnsSearch: setColumnsSearch }), _jsxs(Box, { ml: "auto", display: "flex", alignItems: "center", gap: 2, children: [groupLoadMoreActive && loadMorePosition === "top" && (groupLoadMoreCaption || canGroupLoadMore) && (_jsxs(Box, { display: "flex", alignItems: "center", gap: 2, flex: "0 0 auto", children: [groupLoadMoreCaption && (_jsx(Box, { fontSize: "0.75rem", color: theme.colors.text[500], whiteSpace: "nowrap", children: groupLoadMoreCaption })), canGroupLoadMore && (_jsx(Button, { size: "xs", variant: "outline", colorScheme: "gray", isLoading: isLoadingMore, loadingText: loadMoreText, onClick: handleGroupLoadMore, label: loadMoreText }))] })), (isPagination || isServerPagination) && !isGrouped && !canInfinite && !isCompactHeader && (_jsx("div", { children: _jsx(Pagination, { currentPage: currentPage, setCurrentPage: setCurrentPage, rowsPerPage: rowsPerPage, pages: pages, paginationText: tablePaginationText, handlePageSizeChange: handlePageSizeChange, dataLength: tableData.length, isServerPagination: isServerPagination, paginationSelectOptions: paginationSelectOptions }) })), (isPagination || isServerPagination) && !isGrouped && !canInfinite && isCompactHeader && tableData.length > 0 && (_jsxs(Popover, { isOpen: isPaginationPopoverOpen, onClose: () => setIsPaginationPopoverOpen(false), placement: "bottom-end", children: [_jsx(PopoverTrigger, { children: _jsx(IconButton, { "aria-label": "Pagination", size: "sm", variant: "ghost", icon: _jsx(EllipsisVertical, { size: 18 }), color: theme.colors.text[500], _hover: {
402
+ .flatMap((tool, i) => (i === 0 ? [tool] : [_jsx(VeritcalDivider, {}, `divider-${i}`), tool])), _jsx(ActiveFilters, { columns: columnsList, columnsSearch: columnsSearch, setColumnsSearch: setColumnsSearch }), _jsxs(Box, { ml: "auto", display: "flex", alignItems: "center", gap: 2, children: [groupLoadMoreActive && loadMorePosition === "top" && (groupLoadMoreCaption || canGroupLoadMore) && (_jsxs(Box, { display: "flex", alignItems: "center", gap: 2, flex: "0 0 auto", children: [groupLoadMoreCaption && (_jsx(Box, { fontSize: "0.75rem", color: theme.colors.text[500], whiteSpace: "nowrap", children: groupLoadMoreCaption })), canGroupLoadMore && (_jsx(Button, { size: "xs", variant: "outline", colorScheme: "gray", isLoading: isLoadingMore, loadingText: loadMoreText, onClick: handleGroupLoadMore, label: loadMoreText }))] })), (isPagination || isServerPagination) && !isGrouped && !canInfinite && !isCompactHeader && (_jsx("div", { children: _jsx(Pagination, { currentPage: currentPage, setCurrentPage: setCurrentPage, rowsPerPage: rowsPerPage, pages: pages, paginationText: tablePaginationText, handlePageSizeChange: handlePageSizeChange, dataLength: tableData.length, isServerPagination: isServerPagination, paginationSelectOptions: paginationSelectOptions }) })), (isPagination || isServerPagination) && !isGrouped && !canInfinite && isCompactHeader && (_jsxs(Popover, { isOpen: isPaginationPopoverOpen, onClose: () => setIsPaginationPopoverOpen(false), placement: "bottom-end", children: [_jsx(PopoverTrigger, { children: _jsx(IconButton, { "aria-label": "Pagination", size: "sm", variant: "ghost", icon: _jsx(EllipsisVertical, { size: 18 }), color: theme.colors.text[500], _hover: {
404
403
  color: theme.colors.accentText,
405
404
  }, onClick: () => setIsPaginationPopoverOpen((open) => !open) }) }), _jsx(Portal, { children: _jsx(PopoverContent, { maxW: "22rem", p: 2, zIndex: 1600, bg: theme.colors.surfaceRaised, boxShadow: "lg", children: _jsx(PopoverBody, { p: 0, children: _jsx(Pagination, { currentPage: currentPage, setCurrentPage: setCurrentPage, rowsPerPage: rowsPerPage, pages: pages, paginationText: tablePaginationText, handlePageSizeChange: handlePageSizeChange, dataLength: tableData.length, isServerPagination: isServerPagination, paginationSelectOptions: paginationSelectOptions }) }) }) })] }))] })] }), _jsxs(TableContainer, { ref: tableContainerRef, position: "relative", h: isTableLoading ? tableMaxH : undefined, maxH: !isTableLoading ? tableMaxH : undefined, pb: isTableLoading ? 0 : compactScrollbarPadding, overflowY: isCompactRows ? "hidden" : "auto", overflowX: "auto", sx: {
406
405
  ...scrollShadowStyles(theme.colors.background[50]),
@@ -430,5 +429,5 @@ export default function Table({ data, columns, onSelection, isLoading, isCheckbo
430
429
  backgroundColor: "transparent",
431
430
  zIndex: 999,
432
431
  },
433
- }, children: [_jsx(Thead, { position: "sticky", top: 0, zIndex: 4, bg: variantTokens.header.bg, children: _jsx(TableHeader, { columns: columnsList, isCheckbox: isCheckbox, freezedTextColor: freezedTextColor !== null && freezedTextColor !== void 0 ? freezedTextColor : theme.colors.text[500], handleSort: handleSort, headerRefs: headerRefs, columnWidths: columnWidths, columnsSort: columnsSort, noBorders: noBorders, handleCheckbox: handleCheckbox, isLoading: isTableLoading, checked: headerChecked, isContent: isContent, isLink: isLink, isActionFreeze: isActionFreeze, setColumnsSearch: setColumnsSearch, columnsSearch: columnsSearch, isSelecting: isSelecting }) }), _jsx(Tbody, { children: _jsx(TableBody, { data: isGrouped ? groupedSource : _filteredData, groups: renderGroups, onAddItem: onAddItem, columns: columnsList, startRow: isGrouped ? 0 : startRow, scrollContainerRef: tableContainerRef, isCheckbox: isCheckbox, columnWidths: columnWidths, noBorders: noBorders, freezedBgColor: freezedBgColor !== null && freezedBgColor !== void 0 ? freezedBgColor : theme.colors.backgroundColor.secondary, freezedTextColor: freezedTextColor !== null && freezedTextColor !== void 0 ? freezedTextColor : theme.colors.text[500], handleCheckbox: handleCheckbox, selections: selection, isLoading: isTableLoading, loadingSkeletonRows: loadingSkeletonRows, onRowClick: onRowClick, isContent: isContent, isLink: isLink, isActionFreeze: isActionFreeze, density: densityState, stripe: stripe, emptyState: emptyState }) })] }), canInfinite && isLoadingMore && (_jsxs(Flex, { justify: "center", align: "center", py: 3, gap: 2, children: [_jsx(Spinner, { size: "sm", color: theme.colors.accentText }), _jsx(Box, { fontSize: "0.75rem", color: theme.colors.text[500], children: "Loading more\u2026" })] }))] }), groupLoadMoreActive && loadMorePosition === "bottom" && (groupLoadMoreCaption || canGroupLoadMore) && (_jsxs(Flex, { justify: "center", align: "center", gap: 3, py: 3, borderTop: `0.063rem solid ${theme.colors.boxborder[200]}`, children: [groupLoadMoreCaption && (_jsx(Box, { fontSize: "0.75rem", color: theme.colors.text[500], children: groupLoadMoreCaption })), canGroupLoadMore && (_jsx(Button, { size: "xs", variant: "outline", colorScheme: "gray", isLoading: isLoadingMore, loadingText: loadMoreText, onClick: handleGroupLoadMore, label: loadMoreText }))] }))] })] })] })] }));
432
+ }, children: [_jsx(Thead, { position: "sticky", top: 0, zIndex: 4, bg: variantTokens.header.bg, children: _jsx(TableHeader, { columns: columnsList, isCheckbox: isCheckbox, freezedTextColor: freezedTextColor !== null && freezedTextColor !== void 0 ? freezedTextColor : theme.colors.text[500], handleSort: handleSort, headerRefs: headerRefs, columnWidths: columnWidths, slotRef: slotRef, slotWidth: slotWidth, columnsSort: columnsSort, noBorders: noBorders, handleCheckbox: handleCheckbox, isLoading: isTableLoading, checked: headerChecked, isContent: isContent, isLink: isLink, isActionFreeze: isActionFreeze, setColumnsSearch: setColumnsSearch, columnsSearch: columnsSearch, isSelecting: isSelecting }) }), _jsx(Tbody, { children: _jsx(TableBody, { data: isGrouped ? groupedSource : _filteredData, groups: renderGroups, onAddItem: onAddItem, columns: columnsList, startRow: isGrouped ? 0 : startRow, rowsPerPage: rowsPerPage, scrollContainerRef: tableContainerRef, isCheckbox: isCheckbox, columnWidths: columnWidths, slotWidth: slotWidth, noBorders: noBorders, freezedBgColor: freezedBgColor !== null && freezedBgColor !== void 0 ? freezedBgColor : theme.colors.backgroundColor.secondary, freezedTextColor: freezedTextColor !== null && freezedTextColor !== void 0 ? freezedTextColor : theme.colors.text[500], handleCheckbox: handleCheckbox, selections: selection, isLoading: isTableLoading, loadingSkeletonRows: loadingSkeletonRows, onRowClick: onRowClick, isContent: isContent, isLink: isLink, isActionFreeze: isActionFreeze, density: densityState, stripe: stripe, emptyState: emptyState }) })] }), canInfinite && isLoadingMore && (_jsxs(Flex, { justify: "center", align: "center", py: 3, gap: 2, children: [_jsx(Spinner, { size: "sm", color: theme.colors.accentText }), _jsx(Box, { fontSize: "0.75rem", color: theme.colors.text[500], children: "Loading more\u2026" })] }))] }), groupLoadMoreActive && loadMorePosition === "bottom" && (groupLoadMoreCaption || canGroupLoadMore) && (_jsxs(Flex, { justify: "center", align: "center", gap: 3, py: 3, borderTop: `0.063rem solid ${theme.colors.boxborder[200]}`, children: [groupLoadMoreCaption && (_jsx(Box, { fontSize: "0.75rem", color: theme.colors.text[500], children: groupLoadMoreCaption })), canGroupLoadMore && (_jsx(Button, { size: "xs", variant: "outline", colorScheme: "gray", isLoading: isLoadingMore, loadingText: loadMoreText, onClick: handleGroupLoadMore, label: loadMoreText }))] }))] })] })] })] }));
434
433
  }
@@ -7,6 +7,8 @@ import { DEFAULT_PAGE_SIZE } from "../hooks/useTable.js";
7
7
  import { useCustomTheme } from "../../../Theme/useCustomTheme.js";
8
8
  import ToolTip from "../../ToolTip/ToolTip.js";
9
9
  import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, } from "lucide-react";
10
+ const STEP_SIZE = "1.5rem";
11
+ const STEP_ICON = 16;
10
12
  const Pagination = React.memo(({ paginationText, handlePageSizeChange, rowsPerPage, currentPage, pages, setCurrentPage, dataLength, isServerPagination, paginationSelectOptions, }) => {
11
13
  const computedOptions = useMemo(() => {
12
14
  if (paginationSelectOptions && paginationSelectOptions.length) {
@@ -18,10 +20,11 @@ const Pagination = React.memo(({ paginationText, handlePageSizeChange, rowsPerPa
18
20
  return [...sizes].sort((a, b) => a - b);
19
21
  }, [paginationSelectOptions, dataLength, rowsPerPage]);
20
22
  const theme = useCustomTheme();
21
- // const isLeftDisabled = currentPage === 0;
22
- // const isRightDisabled = currentPage >= pages - 1;
23
- const isLeftDisabled = dataLength === 0 || currentPage === 0;
24
- const isRightDisabled = dataLength === 0 || currentPage >= pages - 1;
25
- return (_jsxs(Flex, { justify: "flex-end", align: "center", flexWrap: "nowrap", children: [(!isServerPagination || paginationSelectOptions) && (_jsx(Select, { native: false, isSearchable: false, "aria-label": "Rows per page", onChange: (option) => option && handlePageSizeChange(Number(option.id)), value: rowsPerPage, size: "xs", width: "4.5rem", isDisabled: dataLength === 0, options: computedOptions.map((size) => ({ id: size, label: String(size) })) })), dataLength > 0 && (_jsxs(Flex, { w: "100%", children: [_jsx(ToolTip, { label: "First", placement: "top", children: _jsx(IconButton, { "aria-label": "first-page", color: isLeftDisabled ? theme.colors.textMuted : theme.colors.text[500], rounded: "full", variant: isLeftDisabled ? "plain" : "ghost", onClick: () => setCurrentPage(0), _hover: !isLeftDisabled ? { color: theme.colors.accentText } : {}, children: _jsx(ChevronsLeft, {}) }) }), _jsx(ToolTip, { label: "Previous", placement: "top", children: _jsx(IconButton, { "aria-label": "previous-page", color: isLeftDisabled ? theme.colors.textMuted : theme.colors.text[500], rounded: "full", variant: isLeftDisabled ? "plain" : "ghost", onClick: () => currentPage > 0 && setCurrentPage(currentPage - 1), _hover: !isLeftDisabled ? { color: theme.colors.accentText } : {}, children: _jsx(ChevronLeft, {}) }) }), _jsx(Text, { mt: 3, fontSize: 12, whiteSpace: "nowrap", color: theme.colors.text[500], children: paginationText }), _jsx(ToolTip, { label: "Next", placement: "top", children: _jsx(IconButton, { "aria-label": "next-page", color: isRightDisabled ? theme.colors.textMuted : theme.colors.text[500], rounded: "full", variant: isRightDisabled ? "plain" : "ghost", onClick: () => currentPage < pages - 1 && setCurrentPage(currentPage + 1), _hover: !isRightDisabled ? { color: theme.colors.accentText } : {}, children: _jsx(ChevronRight, {}) }) }), _jsx(ToolTip, { label: "Last", placement: "top", children: _jsx(IconButton, { "aria-label": "last-page", color: isRightDisabled ? theme.colors.textMuted : theme.colors.text[500], rounded: "full", variant: isRightDisabled ? "plain" : "ghost", onClick: () => setCurrentPage(pages - 1), _hover: !isRightDisabled ? { color: theme.colors.accentText } : {}, children: _jsx(ChevronsRight, {}) }) })] }))] }));
23
+ const lastPage = Math.max(0, pages - 1);
24
+ // Stepping back is how a user escapes an empty page, so it survives an empty result.
25
+ const isLeftDisabled = currentPage === 0;
26
+ const isRightDisabled = dataLength === 0 || currentPage >= lastPage;
27
+ const step = (label, ariaLabel, icon, isDisabled, onClick) => (_jsx(ToolTip, { label: label, placement: "top", children: _jsx(IconButton, { "aria-label": ariaLabel, icon: icon, variant: "ghost", size: "xs", minW: STEP_SIZE, h: STEP_SIZE, rounded: "full", color: isDisabled ? theme.colors.textMuted : theme.colors.text[500], isDisabled: isDisabled, onClick: onClick, _hover: isDisabled ? {} : { color: theme.colors.accentText } }) }));
28
+ return (_jsxs(Flex, { justify: "flex-end", align: "center", flexWrap: "nowrap", gap: "0.75rem", children: [(!isServerPagination || paginationSelectOptions) && (_jsx(Select, { native: false, isSearchable: false, "aria-label": "Rows per page", onChange: (option) => option && handlePageSizeChange(Number(option.id)), value: rowsPerPage, size: "xs", width: "3.75rem", isDisabled: dataLength === 0, options: computedOptions.map((size) => ({ id: size, label: String(size) })) })), (dataLength > 0 || currentPage > 0) && (_jsxs(Flex, { align: "center", gap: "0.125rem", children: [step("First", "first-page", _jsx(ChevronsLeft, { size: STEP_ICON }), isLeftDisabled, () => setCurrentPage(0)), step("Previous", "previous-page", _jsx(ChevronLeft, { size: STEP_ICON }), isLeftDisabled, () => setCurrentPage((page) => Math.max(0, page - 1))), _jsx(Text, { px: "0.375rem", fontSize: "0.75rem", lineHeight: STEP_SIZE, whiteSpace: "nowrap", color: theme.colors.text[500], sx: { fontVariantNumeric: "tabular-nums" }, children: paginationText }), step("Next", "next-page", _jsx(ChevronRight, { size: STEP_ICON }), isRightDisabled, () => setCurrentPage((page) => Math.min(lastPage, page + 1))), step("Last", "last-page", _jsx(ChevronsRight, { size: STEP_ICON }), isRightDisabled, () => setCurrentPage(lastPage))] }))] }));
26
29
  });
27
30
  export default Pagination;