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
package/CHANGELOG.md CHANGED
@@ -63,6 +63,59 @@ next major.
63
63
 
64
64
  ### Fixed
65
65
 
66
+ - **`Table` — a column search only ever matched the page you were looking at.** The per-column
67
+ search ran on `filteredData`, which `useTable` had already sliced to the current page, so a match
68
+ on page 3 was invisible from page 1 and the pager still counted every row. The search now runs
69
+ before the slice: page count, the `1 - 20 of N` readout and the rendered rows all come from the
70
+ matched set, and typing a new search returns to page 1. Grouped mode already searched the whole
71
+ source and is unchanged; server pagination still searches only the loaded page, since the client
72
+ cannot see the rest.
73
+ - **`Table` — an empty page was a dead end, and a page-size change left the body scrolled.** The
74
+ pager's step controls were hidden whenever the loaded rows were empty, so a server page that came
75
+ back with nothing stranded the user with no way back; stepping back now survives an empty result
76
+ and only stepping forward is disabled. A narrowing client-side result also clamps the current page
77
+ instead of leaving it past the end. Changing the page size at page 0 swaps the rows without moving
78
+ `startRow`, which used to leave the virtualized body scrolled into the old page — the scroll reset
79
+ now keys on the page size too.
80
+ - **`Table` — a full-bleed status cell ignored `columns[].align`.** `align` is documented as aligning
81
+ the header label and the cells together, but a bleed status cell hardcoded centre. It now follows
82
+ `align` when the column sets one, and still centres when it does not, so existing tables look the
83
+ same.
84
+ - **`Select` — the popup's filter field did not line up with the options under it.** Its wrapper
85
+ used a hardcoded `p={2}` while option rows inset by the size spec's `mx` (6px at `sm`, 4px at
86
+ `xs`), so the field's edges sat 2-4px inside the list's, and its text origin 2px further in. The
87
+ field was also fixed at `size="sm"` whatever the `Select`'s own size, so a `lg` picker got a small
88
+ search box. Both now follow the active size spec; measured edge- and text-aligned at every size.
89
+ - **`Select` — a multi-select with chips rendered 2px taller than every other field.** The chip row's
90
+ vertical padding was computed against the control height without allowing for the trigger's own
91
+ 1px rule top and bottom, which a border-box trigger adds on top. The sizing now lives in one
92
+ shared helper (`Common/chipTriggerSize`) that `Select` and the deprecated `SelectV2` both read.
93
+ - **`SearchSelect` — infinite-scroll paging could duplicate a page, loop forever on an
94
+ exact-multiple total, leak a pending fetch into a closed/unmounted menu, and never scrolled the
95
+ keyboard-highlighted row into view.** `handleScroll` now reads the live option count at fire
96
+ time instead of the value captured when its 200ms debounce was scheduled, and won't re-request a
97
+ `startIndex` it already asked for. `hasMore` takes new optional `pagination.hasMore` /
98
+ `pagination.totalRecords` overrides, defaults `limit` to 10 (it used to never update when `limit`
99
+ was omitted), and stops after one page that doesn't grow `options`. The pending page timer is
100
+ cleared on close/unmount, and the active row now scrolls into view with `block: "nearest"`.
101
+ - **`Table` — frozen columns sat at the wrong offset, and the pagination bar was misaligned.** The
102
+ sticky `left` was passed as a bare number, which Chakra reads off the space scale: a 48px offset
103
+ rendered as `12rem` (192px), and any cumulative frozen-column width landing on the scale jumped
104
+ 4x. It stayed hidden because the old `CHECKBOX_SLOT_OFFSET = 50` is not a scale value — but 50
105
+ also missed the real 48px select-all cell by 2px, leaving a slit that scrolled content bled
106
+ through, and was wrong outright for `variant="studio"`'s 56px slot. The slot is now measured with
107
+ a `ResizeObserver`, and both offsets interpolate explicit pixels. The pager is one 24px row: the
108
+ `mt={3}` that faked centring is gone, the steps are `size="xs"` with 16px icons instead of
109
+ Chakra's 40px default, and the page-size select no longer fights `justify="flex-end"` with
110
+ `w="100%"`.
111
+ - **`Table` — virtualized body jumped to the top on an appended page, and never scrolled on a real
112
+ page change below the virtualize threshold.** The scroll-reset effect now keys on `startRow`
113
+ actually changing instead of on the 30-row virtualize threshold flipping, so infinite-scroll and
114
+ grouped `groupLoadMore` appends no longer yank the user to the top, while a real page change
115
+ resets correctly at any size. A shrinking `data` set no longer strands the window past the end,
116
+ the offset-lookup binary-searches instead of scanning from 0, and measured row heights are
117
+ pruned per page instead of growing forever.
118
+
66
119
  - **`SegmentedControl` / `Switch` guessed the backdrop it was drawn on.** `brandSlide` painted an
67
120
  8% brand tint composited over `backgroundColor.main` and lifted its unselected-label ink against
68
121
  that same assumed canvas, so on a brand-filled bar the tint sank into the bar and the label
@@ -70,9 +123,29 @@ next major.
70
123
  on a `primary[700]` bar was unreadable. The track is now an opaque plate on every backdrop: rest
71
124
  ink 7.58–18.39:1, plate-vs-bar 6.31–19.0:1, with no change required at the call site. `surface`
72
125
  is now only a refinement for the plate's hairline edge, and a test gates the invariant.
73
- - **A selected `Select` option stacked brand ink on a brand-tinted row.** Bold `accentText` on
74
- `listRowBackgrounds().selected` read as muddy, and the row cleared the panel by only 1.16–1.29:1.
75
- The label is now `text[900]` with the brand carried by a trailing check, matching `OrgSwitcher`.
126
+ - **A selected `Select`/`SearchSelect`/`SelectSearch`/`CompactSelect` option stacked brand ink on a
127
+ brand-tinted row** — `CompactSelect` had no selected styling at all. The label is now `text[900]`
128
+ at weight 500 with the brand carried by a trailing check, matching `OrgSwitcher`. The row tint
129
+ itself is also settled to one perceptible step off the panel instead of taking the raw token
130
+ (`selected` 1.16–1.29:1 → 1.151–1.191:1; the pressed-and-selected state above it 1.34–1.52:1 →
131
+ 1.259–1.340:1), so a too-saturated tint is pulled down as reliably as a too-faint one is lifted.
132
+ - **Every dropdown row is now one size.** `Select`, `SearchSelect`, `SelectSearch`,
133
+ `CompactSelect`'s filter, `DropDown`'s menu items and every themed `Menu` item shared six
134
+ different paddings for the same row; `sm` is now 30px tall everywhere (`padding: 4px 10px`,
135
+ `font-size: 13px`) instead of `SearchSelect` alone rendering ~34px with a 16px font. Row
136
+ content — including a `SearchSelect` `option.view` or `Select` `renderOption` node — now inherits
137
+ its font size from the row, so consumer-rendered row content is no longer stuck at the page's
138
+ 16px body font.
139
+ - **A dropdown row's description sub-line fell under AA in dark mode.** Settling the selected rung
140
+ left `textMuted` at **4.00–4.22:1** on the hovered-selected row across 6 of 9 brands (dark
141
+ `surfaceRaised` carries only ~0.86 of headroom, so any lighter rung eats it). The sub-line ink is
142
+ now derived once per palette against that busiest rung, clearing 4.5:1 everywhere; light mode is
143
+ unchanged because it already cleared.
144
+ - **`Switch` no longer forces a 200px-wide track when it has no labels.** The width floor now only
145
+ applies once a label is set, so an unlabelled instance — the wrong component for a boolean field,
146
+ but one that shipped live in a 120px table cell — shrinks to fit instead of overflowing by 80px.
147
+ Use `ToggleSwitch` for an on/off field; swapping it in trades `role="radiogroup"` for
148
+ `role="switch"`, the same `getByRole` break the `InputSwitch` → `ToggleSwitch` migration caused.
76
149
 
77
150
  - **`SegmentedControl`'s pill bulged past the plate's corners.** The pill and the segments carried
78
151
  the track's own `lg` radius while sitting 2px inside it, so the plate's band stayed even along the
@@ -127,7 +127,7 @@ is now the library's own themed component, with a real dual-thumb mode and a `ma
127
127
  | One of 2-5 named options | `SegmentedControl` | `role="radiogroup"`, keyboard-navigable, supports per-option disabling |
128
128
  | Switching between views or panels | `Tabs` | `role="tablist"` + `aria-controls`, count badges, lazy panels, vertical orientation |
129
129
  | *(avoid)* | `InputSwitch` | `@deprecated` — now a thin wrapper that renders `ToggleSwitch` |
130
- | *(avoid)* | `Switch` | `@deprecated` — a mouse-only two-label segmented control (`leftText`/`rightText`); use `SegmentedControl` |
130
+ | *(avoid)* | `Switch` | `@deprecated` — a two-label segmented control (`leftText`/`rightText`), not a boolean; for a single on/off field use `ToggleSwitch` instead |
131
131
 
132
132
  **Apply:** "on vs off" is always `ToggleSwitch`; 2+ named options is always `SegmentedControl`.
133
133
 
@@ -434,6 +434,37 @@ set the required state on your own wrapping `FormControl`. Note the obvious one-
434
434
  component is not neutral: Chakra's `useFormControl` also emits native `required`, which would add
435
435
  browser validation to a *filter* input that was never submitted before.
436
436
 
437
+ ### `Select` with `onSearchChange` no longer filters locally (beta.48)
438
+
439
+ Passing `onSearchChange` means the server owns matching, so `Select` stops applying its own
440
+ substring filter to `options`. Before this, a fuzzy or ranked server result whose label did not
441
+ literally contain the typed text was fetched and then hidden. Pass `filterOptions={true}` to keep
442
+ the local pass on top, or `filterOptions={false}` to drop it even without `onSearchChange`.
443
+
444
+ **Apply:** if a server-backed `Select` suddenly shows more rows than it used to, that is this fix,
445
+ not a regression. Paged `Select`s should also pass `hasMore={false}` once the last page lands —
446
+ without it, `onLoadMore` fires again every time the user reaches the bottom.
447
+
448
+ ### `Select` with `isMulti` keeps picks that leave `options` (beta.48)
449
+
450
+ `onChange` used to be built from the options currently in the list, so replacing `options` with a
451
+ server-search result silently dropped every earlier pick the new page did not contain — the chips
452
+ vanished and the next selection emitted a truncated array. Selected options are now remembered
453
+ independently of `options`.
454
+
455
+ **Apply:** nothing to change. If you worked around this by merging the old selection back in
456
+ yourself, that workaround is now redundant.
457
+
458
+ ### `Table`'s column search covers every page (beta.48)
459
+
460
+ The per-column search box used to run after the page slice, so it matched only the rows on screen
461
+ while the pager still counted the whole data set. It now runs first: the page count, the
462
+ `1 - 20 of N` readout and the rows all come from the matched set, and a new search returns to page
463
+ 1. Under `paginationMode="server"` the client still only sees the loaded page — filter on the
464
+ server there.
465
+
466
+ **Apply:** a client-side table that looked like it had "no matches" past page 1 now finds them.
467
+
437
468
  ### `Select`'s `onChange` never fires for the built-in placeholder
438
469
 
439
470
  `Select` resolves the changed value against its `options` list and calls `onChange` only on a hit.
@@ -1219,14 +1250,16 @@ configured has no visible effect at all.
1219
1250
  **Apply:** to change the main field's height, change `size`; `height` only matters when
1220
1251
  `insideSelect` is also passed.
1221
1252
 
1222
- ### `SearchSelect`'s `pagination.limit` also drives a "has more" guess, not just page size
1253
+ ### `SearchSelect`'s `pagination` now takes an explicit "has more" signal — prefer it at an exact-multiple total
1223
1254
 
1224
- `hasMore` is computed as `options.length % pagination.limit === 0` — there is no real end-of-data
1225
- signal from the consumer. A total result count that happens to be an exact multiple of `limit`
1226
- looks indistinguishable from "more pages exist," firing one extra, empty `onScroll` call.
1255
+ Without `pagination.hasMore`/`pagination.totalRecords`, `hasMore` still falls back to guessing from
1256
+ `options.length % (pagination.limit ?? 10) === 0`. A total that's an exact multiple of `limit`
1257
+ (30 items, limit 10) still looks like "more pages exist" for the *first* extra scroll, firing one
1258
+ empty `onScroll` call — after that empty response, `SearchSelect` stops asking (it no longer loops
1259
+ forever the way earlier versions did).
1227
1260
 
1228
- **Apply:** if exact pagination boundaries matter, pad or report a real "no more results" state some
1229
- other way; don't rely on `hasMore` being accurate at a page boundary.
1261
+ **Apply:** pass `pagination.hasMore` (a boolean) or `pagination.totalRecords` (a count) so the field
1262
+ never fires that one avoidable empty request; both take precedence over the `limit` guess.
1230
1263
 
1231
1264
  ### `RolesPermission`'s `RoleData.permission`/`designation`/`makeSuperAdmin`/`automation`/`trackLocation`/`show_records` are dead weight in this component
1232
1265
 
@@ -1343,3 +1376,39 @@ Not just the columns — the select/edit/create header bar disappears too, since
1343
1376
  returns `null` before rendering anything.
1344
1377
 
1345
1378
  **Apply:** there's no prop for hiding only the board body while keeping header actions visible.
1379
+
1380
+ ### `Switch` was forced to 200px even with no labels — it overflowed a narrow cell
1381
+
1382
+ `Switch` (`leftText`/`rightText`) kept a 12.5rem minimum width regardless of whether any label was
1383
+ set. Used as a boolean toggle — the wrong component for that, but the mistake shipped live in
1384
+ tickets' project custom-fields page — an unlabelled instance rendered as two unlabelled brand-filled
1385
+ pills at 200px inside a 120px table cell, overflowing by 80px. The width floor now only applies once
1386
+ `leftText`/`rightText` is actually set; an unlabelled instance shrinks to fit (~58px measured).
1387
+
1388
+ **Apply:** for a single on/off field use `ToggleSwitch`, not `Switch`, even now that it no longer
1389
+ overflows — a two-option segmented control still announces as `role="radiogroup"`, not
1390
+ `role="switch"`. Swapping `Switch` for `ToggleSwitch` changes `getByRole("radio", …)` queries to
1391
+ `getByRole("switch", …)` — the same test break the `InputSwitch` → `ToggleSwitch` migration caused.
1392
+
1393
+ ### A `SearchSelect`/`Select` custom row node inherits the row's font size and padding — don't restate them
1394
+
1395
+ `SearchSelect`'s `option.view` and `Select`'s `renderOption` render straight inside the option row,
1396
+ which now carries its own `fontSize`/`px`/`py` so a plain-text row is legible with no extra markup.
1397
+ A custom node that also sets its own `px`/`py`/`fontSize` stacks on top of the row's rather than
1398
+ replacing it — this is what made rows in crm-frontend render 57–75px tall instead of the ~30px every
1399
+ other row uses.
1400
+
1401
+ **Apply:** size a custom `option.view`/`renderOption` node yourself only when it needs something
1402
+ *different* from the row default — inherit by default, don't restate it.
1403
+
1404
+ ### A custom option row's sub-line must not use `colors.textMuted` directly
1405
+
1406
+ A selected row sits on a tinted fill, and the hovered-selected rung is the busiest one in the
1407
+ ladder. In dark mode `surfaceRaised` carries only ~0.86 of contrast headroom above AA, so raw
1408
+ `textMuted` on that rung measures **4.00–4.22:1** in 6 of 9 brands — under the 4.5:1 a 12px
1409
+ sub-line needs. The library's own rows no longer use it.
1410
+
1411
+ **Apply:** in a `SearchSelect` `option.view` or `Select` `renderOption` node, read the sub-line ink
1412
+ from `optionRowDescriptionInk(theme.colors)` (exported from the package root) rather than
1413
+ `theme.colors.textMuted`. It is a no-op in light mode. The row's main label is
1414
+ `optionRowInk(theme.colors, isSelected)` with `optionRowWeight(isSelected)` — never brand ink.
@@ -0,0 +1,10 @@
1
+ import { type ControlSize } from "../../Theme/tokens/primitives/controlSizes";
2
+ export type ChipSize = "xs" | "sm";
3
+ /** Sizes a select trigger so a chip row lands on the same height as a plain one. */
4
+ export declare const chipTriggerSizeStyles: (size: ControlSize, hasChips: boolean, chipSize?: ChipSize) => {
5
+ minH: string;
6
+ fontSize: string;
7
+ px: string;
8
+ h?: string;
9
+ py?: string;
10
+ };
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.chipTriggerSizeStyles = void 0;
4
+ const controlSizes_1 = require("../../Theme/tokens/primitives/controlSizes");
5
+ const CHIP_TAG_HEIGHT_PX = { xs: 20, sm: 24 };
6
+ const CONTROL_BORDER_PX = 2;
7
+ const controlHeightPx = (size) => parseFloat(controlSizes_1.CONTROL_SIZES[size].height) * 16;
8
+ const chipRowPaddingY = (size, chipSize) => `${(controlHeightPx(size) - CHIP_TAG_HEIGHT_PX[chipSize] - CONTROL_BORDER_PX) / 2 / 16}rem`;
9
+ /** Sizes a select trigger so a chip row lands on the same height as a plain one. */
10
+ const chipTriggerSizeStyles = (size, hasChips, chipSize = "sm") => {
11
+ const spec = controlSizes_1.CONTROL_SIZES[size];
12
+ const base = { minH: spec.height, fontSize: spec.fontSize, px: spec.paddingX };
13
+ return hasChips ? { ...base, py: chipRowPaddingY(size, chipSize) } : { ...base, h: spec.height };
14
+ };
15
+ exports.chipTriggerSizeStyles = chipTriggerSizeStyles;
@@ -11,16 +11,17 @@ const useExclusiveOverlay_1 = require("../../Hooks/useExclusiveOverlay");
11
11
  const lucide_react_1 = require("lucide-react");
12
12
  const rafThrottle_1 = require("../../Utils/rafThrottle");
13
13
  const controlSizes_1 = require("../../Theme/tokens/primitives/controlSizes");
14
+ const optionRowSizes_1 = require("../../Theme/tokens/primitives/optionRowSizes");
14
15
  const motion_1 = require("../../Theme/tokens/primitives/motion");
15
16
  const motion_2 = require("../../Theme/chakra/motion");
16
17
  const focusRing_styles_1 = require("../../Theme/chakra/focusRing.styles");
17
18
  const MotionBox = (0, framer_motion_1.motion)((0, react_1.forwardRef)((props, ref) => ((0, jsx_runtime_1.jsx)(react_2.Box, { ref: ref, ...props }))));
18
19
  const SIZE_MAP = {
19
- xs: { btn: "xs", fontSize: controlSizes_1.CONTROL_SIZES.xs.fontSize, py: 1, px: 2.5, img: "1rem", chevron: controlSizes_1.CONTROL_SIZES.xs.iconSize, minW: "8rem", gap: 1.5 },
20
- sm: { btn: "sm", fontSize: controlSizes_1.CONTROL_SIZES.sm.fontSize, py: 1.5, px: 3, img: "1rem", chevron: controlSizes_1.CONTROL_SIZES.sm.iconSize, minW: "9rem", gap: 2 },
21
- md: { btn: "md", fontSize: controlSizes_1.CONTROL_SIZES.md.fontSize, py: 2, px: 3, img: "1.125rem", chevron: controlSizes_1.CONTROL_SIZES.md.iconSize, minW: "11rem", gap: 2 },
22
- lg: { btn: "lg", fontSize: controlSizes_1.CONTROL_SIZES.lg.fontSize, py: 2.5, px: 3.5, img: "1.25rem", chevron: controlSizes_1.CONTROL_SIZES.lg.iconSize, minW: "12rem", gap: 2.5 },
23
- xl: { btn: "lg", fontSize: "1.0625rem", py: 3, px: 4, img: "1.4rem", chevron: 20, minW: "13rem", gap: 3 },
20
+ xs: { btn: "xs", img: "1rem", chevron: controlSizes_1.CONTROL_SIZES.xs.iconSize, minW: "8rem", gap: 1.5, rowSize: "xs" },
21
+ sm: { btn: "sm", img: "1rem", chevron: controlSizes_1.CONTROL_SIZES.sm.iconSize, minW: "9rem", gap: 2, rowSize: "sm" },
22
+ md: { btn: "md", img: "1.125rem", chevron: controlSizes_1.CONTROL_SIZES.md.iconSize, minW: "11rem", gap: 2, rowSize: "md" },
23
+ lg: { btn: "lg", img: "1.25rem", chevron: controlSizes_1.CONTROL_SIZES.lg.iconSize, minW: "12rem", gap: 2.5, rowSize: "lg" },
24
+ xl: { btn: "lg", img: "1.4rem", chevron: 20, minW: "13rem", gap: 3, rowSize: "lg" },
24
25
  };
25
26
  const TRIGGER_TRANSITION = (0, motion_1.transitionFor)(["background-color", "border-color", "color", "box-shadow"]);
26
27
  const ROW_TRANSITION = (0, motion_1.transitionFor)(["background-color", "color"]);
@@ -37,6 +38,7 @@ const Dropdown = (0, react_1.forwardRef)(({ dropDownButtonStyle, ButtonText, opt
37
38
  const menuRef = (0, react_1.useRef)(null);
38
39
  const [menuPos, setMenuPos] = (0, react_1.useState)({ top: 0, left: 0, width: 0 });
39
40
  const s = SIZE_MAP[(_a = size !== null && size !== void 0 ? size : optionsSize) !== null && _a !== void 0 ? _a : "sm"];
41
+ const rowSpec = (0, optionRowSizes_1.optionRowSpec)(s.rowSize);
40
42
  const prefersReducedMotion = (0, framer_motion_1.useReducedMotion)();
41
43
  const menuId = (0, react_1.useId)();
42
44
  // Roving-tabindex keyboard nav for the menu (ARIA APG menu-button pattern):
@@ -232,7 +234,7 @@ const Dropdown = (0, react_1.forwardRef)(({ dropDownButtonStyle, ButtonText, opt
232
234
  const Chevron = isVisibleIconShow ? ((0, jsx_runtime_1.jsx)(react_2.Box, { as: "span", display: "inline-flex", transition: CHEVRON_TRANSITION, transform: isOpen ? "rotate(180deg)" : "rotate(0deg)", sx: (0, motion_2.reducedMotionSx)(), children: (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronDown, { size: s.chevron }) })) : undefined;
233
235
  const dropDownToggleOption = () => {
234
236
  const ButtonToggle = ((0, jsx_runtime_1.jsx)(react_2.Button, { ref: buttonTriggerRef, onClick: handleToggle, onKeyDown: handleTriggerKeyDown, size: s.btn, colorScheme: "primary", borderRadius: "lg", fontWeight: 600, "aria-haspopup": "menu", "aria-expanded": isOpen, "aria-controls": menuId, "aria-label": ariaLabel !== null && ariaLabel !== void 0 ? ariaLabel : (ButtonText ? undefined : "Open menu"), rightIcon: Chevron, sx: { transition: TRIGGER_TRANSITION, ...(0, motion_2.reducedMotionSx)(), ...dropDownButtonStyle }, ...buttonProps, children: (0, jsx_runtime_1.jsxs)(react_2.Box, { as: "span", display: "inline-flex", alignItems: "center", gap: DropdownIcon && ButtonText ? s.gap : 0, children: [DropdownIcon, ButtonText] }) }));
235
- const TextToggle = ((0, jsx_runtime_1.jsxs)(react_2.Flex, { ref: textTriggerRef, onClick: handleToggle, onKeyDown: handleTriggerKeyDown, align: "center", gap: 1.5, cursor: "pointer", fontSize: s.fontSize, fontWeight: 500, color: theme.colors.text[700], borderRadius: "md", transition: ROW_TRANSITION, _hover: { color: theme.colors.accentText }, sx: { ...(0, focusRing_styles_1.focusVisibleStyles)(theme.colors), ...(0, motion_2.reducedMotionSx)() }, style: dropDownButtonStyle, role: "button", tabIndex: 0, "aria-haspopup": "menu", "aria-expanded": isOpen, "aria-controls": menuId, "aria-label": ariaLabel !== null && ariaLabel !== void 0 ? ariaLabel : (text ? undefined : "Open menu"), children: [DropdownIcon, text, Chevron] }));
237
+ const TextToggle = ((0, jsx_runtime_1.jsxs)(react_2.Flex, { ref: textTriggerRef, onClick: handleToggle, onKeyDown: handleTriggerKeyDown, align: "center", gap: 1.5, cursor: "pointer", fontSize: rowSpec.fontSize, fontWeight: 500, color: theme.colors.text[700], borderRadius: "md", transition: ROW_TRANSITION, _hover: { color: theme.colors.accentText }, sx: { ...(0, focusRing_styles_1.focusVisibleStyles)(theme.colors), ...(0, motion_2.reducedMotionSx)() }, style: dropDownButtonStyle, role: "button", tabIndex: 0, "aria-haspopup": "menu", "aria-expanded": isOpen, "aria-controls": menuId, "aria-label": ariaLabel !== null && ariaLabel !== void 0 ? ariaLabel : (text ? undefined : "Open menu"), children: [DropdownIcon, text, Chevron] }));
236
238
  return dropdownType === "button" ? ButtonToggle : TextToggle;
237
239
  };
238
240
  const DropdownContentRender = () => {
@@ -242,7 +244,7 @@ const Dropdown = (0, react_1.forwardRef)(({ dropDownButtonStyle, ButtonText, opt
242
244
  }, role: "menuitem", tabIndex: index === activeIndex ? 0 : -1, onClick: () => {
243
245
  handleListItemClick(option.id, option.label);
244
246
  focusTrigger();
245
- }, onKeyDown: (e) => handleItemKeyDown(e, option.id, option.label), mx: 1.5, px: s.px, py: s.py, borderRadius: "md", display: "flex", alignItems: "center", gap: 2, cursor: "pointer", color: theme.colors.text[700], transition: ROW_TRANSITION, _hover: {
247
+ }, onKeyDown: (e) => handleItemKeyDown(e, option.id, option.label), mx: rowSpec.mx, px: rowSpec.px, py: rowSpec.py, minH: rowSpec.minH, borderRadius: optionRowSizes_1.OPTION_ROW_RADIUS, display: "flex", alignItems: "center", gap: rowSpec.gap, fontSize: rowSpec.fontSize, cursor: "pointer", color: theme.colors.text[700], transition: ROW_TRANSITION, _hover: {
246
248
  bg: hoverTint,
247
249
  color: theme.colors.accentText,
248
250
  }, _focus: {
@@ -255,7 +257,7 @@ const Dropdown = (0, react_1.forwardRef)(({ dropDownButtonStyle, ButtonText, opt
255
257
  boxShadow: `inset 0 0 0 2px ${theme.colors.focusRing.ring}`,
256
258
  }, borderBottom: divider
257
259
  ? `0.063rem solid ${theme.colors.boxborder[200]}`
258
- : undefined, sx: { "&:last-child": { borderBottom: "none" }, ...(0, motion_2.reducedMotionSx)() }, style: ItemStyle, fontWeight: 500, children: [option.image && ((0, jsx_runtime_1.jsx)(react_2.Box, { as: option.image, boxSize: s.img, flexShrink: 0, color: theme.colors.textMuted, style: ImageStyle })), (0, jsx_runtime_1.jsx)(react_2.Box, { as: "span", fontSize: s.fontSize, isTruncated: true, style: LabelStyle, children: option.label })] }, option.id))) }));
260
+ : undefined, sx: { "&:last-child": { borderBottom: "none" }, ...(0, motion_2.reducedMotionSx)() }, style: ItemStyle, fontWeight: 500, children: [option.image && ((0, jsx_runtime_1.jsx)(react_2.Box, { as: option.image, boxSize: s.img, flexShrink: 0, color: theme.colors.textMuted, style: ImageStyle })), (0, jsx_runtime_1.jsx)(react_2.Box, { as: "span", fontSize: rowSpec.fontSize, isTruncated: true, style: LabelStyle, children: option.label })] }, option.id))) }));
259
261
  };
260
262
  return ((0, jsx_runtime_1.jsxs)(react_2.Box, { ref: dropdownRef, position: "relative", display: "inline-block", children: [dropDownToggleOption(), isOpen && ((0, jsx_runtime_1.jsx)(react_2.Portal, { children: (0, jsx_runtime_1.jsx)(MotionBox, { ref: setMenuRef, position: "fixed", top: `${menuPos.top}px`, left: `${menuPos.left}px`, minW: `${menuPos.width}px`, zIndex: 1500, initial: prefersReducedMotion ? false : { opacity: 0, y: -6 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: -6 }, style: headStyle, children: DropdownContentRender() }) }))] }));
261
263
  });
@@ -44,6 +44,8 @@ const FormLabel_1 = require("../Common/FormLabel");
44
44
  const Tag_1 = __importDefault(require("../Tag/Tag"));
45
45
  const useCustomTheme_1 = require("../../Theme/useCustomTheme");
46
46
  const rowStates_1 = require("../../Theme/tokens/builders/rowStates");
47
+ const optionRowSizes_1 = require("../../Theme/tokens/primitives/optionRowSizes");
48
+ const optionRowInk_1 = require("../Select/optionRowInk");
47
49
  const useExclusiveOverlay_1 = require("../../Hooks/useExclusiveOverlay");
48
50
  const FieldMessage_1 = __importDefault(require("../Common/FieldMessage"));
49
51
  const useFieldIds_1 = require("../Common/useFieldIds");
@@ -75,6 +77,7 @@ const normalizeMultiValue = (val) => {
75
77
  const SearchSelect = ({ id, label, options, onChange, onSelect, onSearch, isOptionLoading = false, isLoading = false, loadingText = "Loading...", placeholder = "Select", value, searchQuery = "", isMultiple = false, isRequired = false, error = false, errorMessage, helperText, width = "100%", height, size = "sm", isDisabled = false, chip, information, isInformation, informationMessage, rightIcon, addNew, isSelectAll = false, pagination, insideSelect, isRemoveAllIcon, "aria-label": ariaLabel, }) => {
76
78
  var _a, _b, _c, _d, _e;
77
79
  const s = (0, searchSelectSize_1.getSearchSelectSizeStyles)(size);
80
+ const spec = (0, optionRowSizes_1.optionRowSpec)(size);
78
81
  const emitChange = onChange !== null && onChange !== void 0 ? onChange : onSelect;
79
82
  const insideSelectBoxHeight = height !== null && height !== void 0 ? height : s.insideSelectDefaultHeight;
80
83
  const [inputValue, setInputValue] = (0, react_1.useState)(searchQuery);
@@ -88,6 +91,9 @@ const SearchSelect = ({ id, label, options, onChange, onSelect, onSearch, isOpti
88
91
  const dropdownMenuRef = (0, react_1.useRef)(null);
89
92
  const [dropdownPos, setDropdownPos] = (0, react_1.useState)({ top: 0, left: 0, width: 0 });
90
93
  const scrollTimeoutRef = (0, react_1.useRef)(null);
94
+ const optionsLengthRef = (0, react_1.useRef)(options.length);
95
+ const lastPageStartRef = (0, react_1.useRef)(null);
96
+ const optionsListRef = (0, react_1.useRef)(null);
91
97
  const [focused, setFocused] = (0, react_1.useState)(false);
92
98
  const [customSelectOpen, setCustomSelectOpen] = (0, react_1.useState)(false);
93
99
  const customSelectRef = (0, react_1.useRef)(null);
@@ -138,10 +144,22 @@ const SearchSelect = ({ id, label, options, onChange, onSelect, onSearch, isOpti
138
144
  return () => clearTimeout(delayDebounce);
139
145
  }, [inputValue]);
140
146
  (0, react_1.useEffect)(() => {
141
- if (pagination === null || pagination === void 0 ? void 0 : pagination.limit) {
142
- setHasMore(options.length % pagination.limit === 0);
147
+ optionsLengthRef.current = options.length;
148
+ }, [options.length]);
149
+ (0, react_1.useEffect)(() => {
150
+ if ((pagination === null || pagination === void 0 ? void 0 : pagination.hasMore) !== undefined) {
151
+ setHasMore(pagination.hasMore);
152
+ return;
153
+ }
154
+ if ((pagination === null || pagination === void 0 ? void 0 : pagination.totalRecords) !== undefined) {
155
+ setHasMore(options.length < pagination.totalRecords);
156
+ return;
143
157
  }
144
- }, [options.length, pagination === null || pagination === void 0 ? void 0 : pagination.limit]);
158
+ if (!(pagination === null || pagination === void 0 ? void 0 : pagination.onScroll))
159
+ return;
160
+ const limit = pagination.limit || 10;
161
+ setHasMore(options.length % limit === 0);
162
+ }, [options.length, pagination === null || pagination === void 0 ? void 0 : pagination.hasMore, pagination === null || pagination === void 0 ? void 0 : pagination.totalRecords, pagination === null || pagination === void 0 ? void 0 : pagination.limit, pagination === null || pagination === void 0 ? void 0 : pagination.onScroll]);
145
163
  const handleSelect = (option) => {
146
164
  var _a;
147
165
  if (isMultiple && insideSelect) {
@@ -321,6 +339,13 @@ const SearchSelect = ({ id, label, options, onChange, onSelect, onSearch, isOpti
321
339
  }
322
340
  setActiveIndex((prev) => (prev >= 0 && prev < filteredOptions.length ? prev : -1));
323
341
  }, [isOpen, filteredOptions.length]);
342
+ (0, react_1.useEffect)(() => {
343
+ var _a, _b;
344
+ if (!isOpen || activeIndex < 0 || !optionsListRef.current)
345
+ return;
346
+ (_b = (_a = optionsListRef.current
347
+ .querySelector(`[data-index="${activeIndex}"]`)) === null || _a === void 0 ? void 0 : _a.scrollIntoView) === null || _b === void 0 ? void 0 : _b.call(_a, { block: "nearest" });
348
+ }, [isOpen, activeIndex]);
324
349
  // NOTE: only restores focus when a keyboard close actually unmounted Input and dropped focus to body.
325
350
  (0, react_1.useEffect)(() => {
326
351
  var _a, _b, _c, _d;
@@ -351,11 +376,30 @@ const SearchSelect = ({ id, label, options, onChange, onSelect, onSearch, isOpti
351
376
  clearTimeout(scrollTimeoutRef.current);
352
377
  scrollTimeoutRef.current = setTimeout(() => {
353
378
  var _a;
354
- const startIndex = options.length + 1;
379
+ scrollTimeoutRef.current = null;
380
+ const startIndex = optionsLengthRef.current + 1;
381
+ if (lastPageStartRef.current === startIndex) {
382
+ setHasMore(false);
383
+ return;
384
+ }
385
+ lastPageStartRef.current = startIndex;
355
386
  (_a = pagination.onScroll) === null || _a === void 0 ? void 0 : _a.call(pagination, startIndex, pagination.limit || 10, inputValue);
356
387
  }, 200);
357
388
  }
358
- }, [pagination, options.length, inputValue, hasMore]);
389
+ }, [pagination, inputValue, hasMore]);
390
+ (0, react_1.useEffect)(() => {
391
+ if (isOpen)
392
+ return;
393
+ if (scrollTimeoutRef.current) {
394
+ clearTimeout(scrollTimeoutRef.current);
395
+ scrollTimeoutRef.current = null;
396
+ }
397
+ lastPageStartRef.current = null;
398
+ }, [isOpen]);
399
+ (0, react_1.useEffect)(() => () => {
400
+ if (scrollTimeoutRef.current)
401
+ clearTimeout(scrollTimeoutRef.current);
402
+ }, []);
359
403
  (0, react_2.useOutsideClick)({
360
404
  ref: customSelectRef,
361
405
  handler: (e) => {
@@ -494,16 +538,14 @@ const SearchSelect = ({ id, label, options, onChange, onSelect, onSearch, isOpti
494
538
  }, children: [(0, jsx_runtime_1.jsxs)(react_2.Box, { minW: "4.5rem", cursor: "pointer", display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, width: insideSelect.width, height: insideSelectBoxHeight, pl: "0.5rem", pr: "0.375rem", children: [insideLabel ? ((0, jsx_runtime_1.jsx)(SelectTruncatedLabel_1.default, { label: insideLabel, maxWidth: insideSelect.width || "6rem", fontSize: s.fieldFontSize })) : ((0, jsx_runtime_1.jsx)(react_2.Text, { fontSize: s.fieldFontSize, fontWeight: 500, color: theme.colors.textMuted, isTruncated: true, maxW: insideSelect.width || "6rem", children: insidePlaceholder })), (0, jsx_runtime_1.jsx)(react_2.Box, { as: "span", display: "inline-flex", flexShrink: 0, color: customSelectOpen ? theme.colors.accentText : theme.colors.textMuted, transition: CHEVRON_TRANSITION, transform: customSelectOpen ? "rotate(180deg)" : "rotate(0deg)", children: (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronDown, { size: s.chevronSize }) })] }), customSelectOpen && ((0, jsx_runtime_1.jsx)(react_2.Portal, { children: (0, jsx_runtime_1.jsx)(react_2.Box, { ref: setInsideSelectMenuRef, id: insideSelectPortalId, position: "fixed", top: `${customSelectPos.top}px`, left: `${customSelectPos.left}px`, zIndex: 1510, bg: theme.colors.surfaceRaised, border: "0.063rem solid", borderColor: theme.colors.boxborder[200], borderRadius: "xl", boxShadow: theme.shadows.lg, minW: `${customSelectPos.width}px`, maxW: "20rem", py: 1, overflow: "hidden", children: (_c = insideSelect.option) === null || _c === void 0 ? void 0 : _c.map((item) => {
495
539
  var _a;
496
540
  const selected = ((_a = insideSelect.value) === null || _a === void 0 ? void 0 : _a.id) === item.id;
497
- return ((0, jsx_runtime_1.jsxs)(react_2.Box, { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, minH: s.optionRowMinH, py: s.optionRowPy, px: "0.75rem", fontSize: s.dropdownTextFontSize, fontWeight: selected ? 600 : 500, color: selected
498
- ? theme.colors.accentText
499
- : theme.colors.text[700], bg: selected ? rowBackgrounds.selected : "transparent", _hover: {
541
+ return ((0, jsx_runtime_1.jsxs)(react_2.Box, { display: "flex", alignItems: "center", justifyContent: "space-between", gap: spec.gap, minH: spec.minH, py: spec.py, px: spec.px, fontSize: spec.fontSize, fontWeight: (0, optionRowInk_1.optionRowWeight)(selected), color: (0, optionRowInk_1.optionRowInk)(theme.colors, selected), bg: selected ? rowBackgrounds.selected : "transparent", _hover: {
500
542
  bg: selected
501
543
  ? rowBackgrounds.selectedActive
502
544
  : rowBackgrounds.active,
503
545
  }, cursor: "pointer", onClick: (e) => {
504
546
  e.stopPropagation();
505
547
  handleCustomSelect(item);
506
- }, children: [(0, jsx_runtime_1.jsx)(react_2.Text, { isTruncated: true, children: item.label }), selected && ((0, jsx_runtime_1.jsx)(lucide_react_1.Check, { size: s.chevronSize - 2, color: theme.colors.accentText }))] }, item.id));
548
+ }, children: [(0, jsx_runtime_1.jsx)(react_2.Text, { isTruncated: true, children: item.label }), selected && ((0, jsx_runtime_1.jsx)(lucide_react_1.Check, { size: spec.iconSize, color: theme.colors.accentText }))] }, item.id));
507
549
  }) }) }))] }));
508
550
  })(), isMultiple
509
551
  ? selectedValues
@@ -535,16 +577,20 @@ const SearchSelect = ({ id, label, options, onChange, onSelect, onSearch, isOpti
535
577
  else {
536
578
  openMenu();
537
579
  }
538
- }, "aria-label": "toggle-dropdown", display: "flex", alignItems: "center", borderRadius: "sm", color: theme.colors.textMuted, cursor: isDisabled ? "not-allowed" : "pointer", sx: (0, focusRing_styles_1.focusVisibleStyles)(theme.colors), children: isOpen ? ((0, jsx_runtime_1.jsx)(lucide_react_1.ChevronUp, { size: s.chevronSize })) : ((0, jsx_runtime_1.jsx)(lucide_react_1.ChevronDown, { size: s.chevronSize })) }))] })] }) }), isOpen && ((0, jsx_runtime_1.jsx)(react_2.Portal, { children: (0, jsx_runtime_1.jsxs)(react_2.Box, { ref: setDropdownMenuRef, id: listboxId, role: "listbox", "aria-multiselectable": isMultiple || undefined, position: "fixed", top: `${dropdownPos.top}px`, left: `${dropdownPos.left}px`, width: `${dropdownPos.width}px`, zIndex: 1500, maxH: "20rem", borderWidth: 1, borderColor: theme.colors.boxborder[200], borderRadius: "xl", overflow: "hidden", bg: theme.colors.surfaceRaised, boxShadow: theme.shadows.lg, display: "flex", flexDirection: "column", children: [isMultiple && isSelectAll && ((0, jsx_runtime_1.jsxs)(react_2.Box, { py: s.optionRowPy, px: 3, cursor: "pointer", onClick: handleSelectAll, display: "flex", alignItems: "center", gap: 2, color: theme.colors.text[700], borderBottom: `0.063rem solid ${theme.colors.boxborder[200]}`, children: [(0, jsx_runtime_1.jsx)(Checkbox_1.default, { isChecked: allFilteredSelected, sx: { pointerEvents: "none" }, size: s.checkboxSize }), (0, jsx_runtime_1.jsx)(react_2.Text, { fontSize: s.dropdownTextFontSize, children: allFilteredSelected ? "Unselect All" : "Select All" })] })), (0, jsx_runtime_1.jsx)(react_2.Box, { flex: "1", overflowY: "auto", onScroll: handleScroll, maxH: "15rem", borderBottom: "0.063rem solid", borderColor: theme.colors.boxborder[200], children: isOptionLoading && filteredOptions.length === 0 ? ((0, jsx_runtime_1.jsxs)(react_2.Box, { py: s.optionRowPy, px: 3, display: "flex", alignItems: "center", gap: 2, minH: s.optionRowMinH, color: theme.colors.textMuted, children: [(0, jsx_runtime_1.jsx)(react_2.Spinner, { size: s.spinnerSize }), (0, jsx_runtime_1.jsx)(react_2.Text, { fontSize: s.dropdownTextFontSize, children: loadingText })] })) : !isOptionLoading && filteredOptions.length === 0 ? ((0, jsx_runtime_1.jsx)(react_2.Text, { p: 3, fontSize: s.dropdownTextFontSize, color: theme.colors.textMuted, children: "No results found" })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [filteredOptions.map((option, index) => ((0, jsx_runtime_1.jsxs)(react_2.Box, { id: getOptionId(index), role: "option", "aria-selected": isSelected(option.id), py: s.optionRowPy, px: 3, minH: s.optionRowMinH, cursor: "pointer", bg: isSelected(option.id)
539
- ? index === activeIndex
540
- ? rowBackgrounds.selectedActive
541
- : rowBackgrounds.selected
542
- : index === activeIndex
543
- ? rowBackgrounds.active
544
- : "transparent", _hover: {
545
- bg: isSelected(option.id)
546
- ? rowBackgrounds.selectedActive
547
- : rowBackgrounds.active,
548
- }, onClick: () => handleSelect(option), onMouseEnter: () => setActiveIndex(index), display: "flex", alignItems: "center", gap: 2, color: isSelected(option.id) ? theme.colors.accentText : theme.colors.text[700], fontWeight: isSelected(option.id) ? 600 : 400, borderBottom: `0.063rem solid ${theme.colors.boxborder[200]}`, children: [isMultiple && ((0, jsx_runtime_1.jsx)(Checkbox_1.default, { isChecked: isSelected(option.id), sx: { pointerEvents: "none" }, size: s.checkboxSize })), option.view ? ((0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: option.view })) : ((0, jsx_runtime_1.jsx)(react_2.Text, { fontSize: s.dropdownTextFontSize, children: option.label }))] }, option.id))), ((pagination === null || pagination === void 0 ? void 0 : pagination.scrollLoading) || (isOptionLoading && filteredOptions.length > 0)) && ((0, jsx_runtime_1.jsxs)(react_2.Box, { py: s.optionRowPy, px: 3, display: "flex", alignItems: "center", justifyContent: "center", gap: 2, minH: s.optionRowMinH, color: theme.colors.textMuted, children: [(0, jsx_runtime_1.jsx)(react_2.Spinner, { size: s.spinnerSize }), (0, jsx_runtime_1.jsx)(react_2.Text, { fontSize: s.dropdownTextFontSize, children: loadingText })] }))] })) }), (addNew === null || addNew === void 0 ? void 0 : addNew.enabled) && ((0, jsx_runtime_1.jsxs)(react_2.Box, { py: s.optionRowPy, px: 3, borderTop: "0.063rem solid", borderColor: theme.colors.boxborder[200], bg: theme.colors.surfaceRaised, display: "flex", alignItems: "center", gap: 2, color: theme.colors.accentText, fontWeight: 500, minH: s.optionRowMinH, cursor: "pointer", onClick: addNew.onClick, _hover: { bg: rowBackgrounds.active }, children: [addNew.icon || (0, jsx_runtime_1.jsx)(lucide_react_1.Plus, { size: s.addNewIconSize }), (0, jsx_runtime_1.jsx)(react_2.Text, { fontSize: s.dropdownTextFontSize, children: addNew.text || "Add New" })] }))] }) })), (0, jsx_runtime_1.jsx)(FieldMessage_1.default, { error: error, errorMessage: errorMessage, helperText: helperText, helperId: ids.helperId, errorId: ids.errorId })] }));
580
+ }, "aria-label": "toggle-dropdown", display: "flex", alignItems: "center", borderRadius: "sm", color: theme.colors.textMuted, cursor: isDisabled ? "not-allowed" : "pointer", sx: (0, focusRing_styles_1.focusVisibleStyles)(theme.colors), children: isOpen ? ((0, jsx_runtime_1.jsx)(lucide_react_1.ChevronUp, { size: s.chevronSize })) : ((0, jsx_runtime_1.jsx)(lucide_react_1.ChevronDown, { size: s.chevronSize })) }))] })] }) }), isOpen && ((0, jsx_runtime_1.jsx)(react_2.Portal, { children: (0, jsx_runtime_1.jsxs)(react_2.Box, { ref: setDropdownMenuRef, id: listboxId, role: "listbox", "aria-multiselectable": isMultiple || undefined, position: "fixed", top: `${dropdownPos.top}px`, left: `${dropdownPos.left}px`, width: `${dropdownPos.width}px`, zIndex: 1500, maxH: "20rem", borderWidth: 1, borderColor: theme.colors.boxborder[200], borderRadius: "xl", overflow: "hidden", bg: theme.colors.surfaceRaised, boxShadow: theme.shadows.lg, display: "flex", flexDirection: "column", children: [isMultiple && isSelectAll && ((0, jsx_runtime_1.jsxs)(react_2.Box, { py: spec.py, px: spec.px, minH: spec.minH, cursor: "pointer", onClick: handleSelectAll, display: "flex", alignItems: "center", gap: spec.gap, color: theme.colors.text[700], borderBottom: `0.063rem solid ${theme.colors.boxborder[200]}`, children: [(0, jsx_runtime_1.jsx)(Checkbox_1.default, { isChecked: allFilteredSelected, sx: { pointerEvents: "none" }, size: s.checkboxSize }), (0, jsx_runtime_1.jsx)(react_2.Text, { fontSize: spec.fontSize, children: allFilteredSelected ? "Unselect All" : "Select All" })] })), (0, jsx_runtime_1.jsx)(react_2.Box, { ref: optionsListRef, flex: "1", overflowY: "auto", onScroll: handleScroll, maxH: "15rem", borderBottom: "0.063rem solid", borderColor: theme.colors.boxborder[200], children: isOptionLoading && filteredOptions.length === 0 ? ((0, jsx_runtime_1.jsxs)(react_2.Box, { py: spec.py, px: spec.px, display: "flex", alignItems: "center", gap: spec.gap, minH: spec.minH, color: theme.colors.textMuted, children: [(0, jsx_runtime_1.jsx)(react_2.Spinner, { size: s.spinnerSize }), (0, jsx_runtime_1.jsx)(react_2.Text, { fontSize: spec.fontSize, children: loadingText })] })) : !isOptionLoading && filteredOptions.length === 0 ? ((0, jsx_runtime_1.jsx)(react_2.Text, { p: 3, fontSize: spec.fontSize, color: theme.colors.textMuted, children: "No results found" })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [filteredOptions.map((option, index) => {
581
+ const selected = isSelected(option.id);
582
+ const singleSelected = selected && !isMultiple;
583
+ return ((0, jsx_runtime_1.jsxs)(react_2.Box, { id: getOptionId(index), "data-index": index, role: "option", "aria-selected": selected, py: spec.py, px: spec.px, minH: spec.minH, cursor: "pointer", bg: selected
584
+ ? index === activeIndex
585
+ ? rowBackgrounds.selectedActive
586
+ : rowBackgrounds.selected
587
+ : index === activeIndex
588
+ ? rowBackgrounds.active
589
+ : "transparent", _hover: {
590
+ bg: selected ? rowBackgrounds.selectedActive : rowBackgrounds.active,
591
+ }, onClick: () => handleSelect(option), onMouseEnter: () => setActiveIndex(index), display: "flex", alignItems: "center", gap: spec.gap, color: (0, optionRowInk_1.optionRowInk)(theme.colors, singleSelected),
592
+ // `view` is consumer JSX with no size of its own; it inherited the 16px body font.
593
+ fontSize: spec.fontSize, fontWeight: (0, optionRowInk_1.optionRowWeight)(singleSelected), borderBottom: `0.063rem solid ${theme.colors.boxborder[200]}`, children: [isMultiple && ((0, jsx_runtime_1.jsx)(Checkbox_1.default, { isChecked: selected, sx: { pointerEvents: "none" }, size: s.checkboxSize })), option.view ? ((0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: option.view })) : ((0, jsx_runtime_1.jsx)(react_2.Text, { isTruncated: true, children: option.label })), singleSelected && ((0, jsx_runtime_1.jsx)(react_2.Box, { as: lucide_react_1.Check, size: spec.iconSize, color: theme.colors.accentText, flexShrink: 0 }))] }, option.id));
594
+ }), ((pagination === null || pagination === void 0 ? void 0 : pagination.scrollLoading) || (isOptionLoading && filteredOptions.length > 0)) && ((0, jsx_runtime_1.jsxs)(react_2.Box, { py: spec.py, px: spec.px, display: "flex", alignItems: "center", justifyContent: "center", gap: spec.gap, minH: spec.minH, color: theme.colors.textMuted, children: [(0, jsx_runtime_1.jsx)(react_2.Spinner, { size: s.spinnerSize }), (0, jsx_runtime_1.jsx)(react_2.Text, { fontSize: spec.fontSize, children: loadingText })] }))] })) }), (addNew === null || addNew === void 0 ? void 0 : addNew.enabled) && ((0, jsx_runtime_1.jsxs)(react_2.Box, { py: spec.py, px: spec.px, borderTop: "0.063rem solid", borderColor: theme.colors.boxborder[200], bg: theme.colors.surfaceRaised, display: "flex", alignItems: "center", gap: spec.gap, color: theme.colors.accentText, fontWeight: 500, minH: spec.minH, cursor: "pointer", onClick: addNew.onClick, _hover: { bg: rowBackgrounds.active }, children: [addNew.icon || (0, jsx_runtime_1.jsx)(lucide_react_1.Plus, { size: s.addNewIconSize }), (0, jsx_runtime_1.jsx)(react_2.Text, { fontSize: spec.fontSize, children: addNew.text || "Add New" })] }))] }) })), (0, jsx_runtime_1.jsx)(FieldMessage_1.default, { error: error, errorMessage: errorMessage, helperText: helperText, helperId: ids.helperId, errorId: ids.errorId })] }));
549
595
  };
550
596
  exports.default = SearchSelect;
@@ -81,10 +81,14 @@ export type SearchSelectProps = {
81
81
  pagination?: {
82
82
  /** startIndex is 1-based (current options.length + 1), not a 0-based offset. */
83
83
  onScroll?: (startIndex: number, limit: number, query: string) => void;
84
- /** Also used to guess whether more pages exist (options.length % limit === 0) — an exact-multiple total triggers one extra scroll fetch. */
84
+ /** Also used to guess whether more pages exist (options.length % limit === 0, refined once a page returns no new rows). Defaults to 10 when omitted. */
85
85
  limit?: number;
86
86
  /** Set this synchronously while a page request is in flight, or fast scrolling can re-fire onScroll for the same page. */
87
87
  scrollLoading?: boolean;
88
+ /** Explicit stop signal; overrides the limit-based heuristic when set. */
89
+ hasMore?: boolean;
90
+ /** Total row count; when set, hasMore is derived as options.length < totalRecords instead of the limit heuristic. */
91
+ totalRecords?: number;
88
92
  };
89
93
  /** Not implemented — the option list always renders unvirtualized; use `pagination` to page in large datasets instead. */
90
94
  virtualization?: boolean;
@@ -19,11 +19,6 @@ export type SearchSelectSizeStyles = {
19
19
  addNewIconSize: number;
20
20
  /** Offset for the nested select segment (aligns with field padding). */
21
21
  insideSelectMarginLeft: string;
22
- /** “Select all” / option row text in the dropdown. */
23
- dropdownTextFontSize: "xs" | "sm" | "md";
24
- /** Min row height for main option list items. */
25
- optionRowMinH: string;
26
- optionRowPy: number;
27
22
  /** Chakra `Checkbox` only supports sm | md | lg. */
28
23
  checkboxSize: "sm" | "md" | "lg";
29
24
  /** Chakra `Input` font token per `size` (unstyled still needs explicit fontSize). */
@@ -21,9 +21,6 @@ const SIZE_STYLES = {
21
21
  clearIconSize: 10,
22
22
  addNewIconSize: 14,
23
23
  insideSelectMarginLeft: "-0.5rem",
24
- dropdownTextFontSize: "xs",
25
- optionRowMinH: "1.5rem",
26
- optionRowPy: 1.5,
27
24
  checkboxSize: "sm",
28
25
  },
29
26
  sm: {
@@ -42,9 +39,6 @@ const SIZE_STYLES = {
42
39
  clearIconSize: 12,
43
40
  addNewIconSize: 16,
44
41
  insideSelectMarginLeft: "-0.625rem",
45
- dropdownTextFontSize: "sm",
46
- optionRowMinH: "2rem",
47
- optionRowPy: 2,
48
42
  checkboxSize: "sm",
49
43
  },
50
44
  md: {
@@ -63,9 +57,6 @@ const SIZE_STYLES = {
63
57
  clearIconSize: 12,
64
58
  addNewIconSize: 16,
65
59
  insideSelectMarginLeft: "-0.75rem",
66
- dropdownTextFontSize: "sm",
67
- optionRowMinH: "2.5rem",
68
- optionRowPy: 2,
69
60
  checkboxSize: "md",
70
61
  },
71
62
  lg: {
@@ -84,9 +75,6 @@ const SIZE_STYLES = {
84
75
  clearIconSize: 14,
85
76
  addNewIconSize: 18,
86
77
  insideSelectMarginLeft: "-0.875rem",
87
- dropdownTextFontSize: "md",
88
- optionRowMinH: "2.75rem",
89
- optionRowPy: 2.5,
90
78
  checkboxSize: "lg",
91
79
  },
92
80
  };
@@ -38,12 +38,14 @@ declare const SelectNative: React.ForwardRefExoticComponent<{
38
38
  defaultSearchValue?: string;
39
39
  onSearchChange?: (query: string) => void;
40
40
  searchDebounceMs?: number;
41
+ filterOptions?: boolean;
41
42
  isClearable?: boolean;
42
43
  onClear?: () => void;
43
44
  isLoading?: boolean;
44
45
  loadingText?: string;
45
46
  noOptionsMessage?: React.ReactNode;
46
47
  onLoadMore?: () => void;
48
+ hasMore?: boolean;
47
49
  renderOption?: (option: OptionProp, state: {
48
50
  isSelected: boolean;
49
51
  isActive: boolean;