panelui-native 0.80.0 → 0.82.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/lib/module/components/bottom-sheet/index.js +90 -17
  2. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  3. package/lib/module/components/button/index.js +13 -0
  4. package/lib/module/components/button/index.js.map +1 -1
  5. package/lib/module/components/button-group/index.js +3 -0
  6. package/lib/module/components/button-group/index.js.map +1 -1
  7. package/lib/module/components/image-generation/dot-field.js +210 -0
  8. package/lib/module/components/image-generation/dot-field.js.map +1 -0
  9. package/lib/module/components/image-generation/index.js +417 -0
  10. package/lib/module/components/image-generation/index.js.map +1 -0
  11. package/lib/module/components/kpi/index.js +2 -4
  12. package/lib/module/components/kpi/index.js.map +1 -1
  13. package/lib/module/components/menu/index.js +8 -16
  14. package/lib/module/components/menu/index.js.map +1 -1
  15. package/lib/module/components/panelside/index.js +340 -33
  16. package/lib/module/components/panelside/index.js.map +1 -1
  17. package/lib/module/components/planner/index.js +446 -32
  18. package/lib/module/components/planner/index.js.map +1 -1
  19. package/lib/module/components/planner/planner-entries.js +36 -0
  20. package/lib/module/components/planner/planner-entries.js.map +1 -1
  21. package/lib/module/components/planner/planner-grid-navigation.js +34 -2
  22. package/lib/module/components/planner/planner-grid-navigation.js.map +1 -1
  23. package/lib/module/components/planner/planner-weeks.js +95 -0
  24. package/lib/module/components/planner/planner-weeks.js.map +1 -0
  25. package/lib/module/components/select/index.js +127 -23
  26. package/lib/module/components/select/index.js.map +1 -1
  27. package/lib/module/components/slide-button/index.js +610 -0
  28. package/lib/module/components/slide-button/index.js.map +1 -0
  29. package/lib/module/components/slide-button/slide-button-track.js +98 -0
  30. package/lib/module/components/slide-button/slide-button-track.js.map +1 -0
  31. package/lib/module/icons/index.js +553 -1934
  32. package/lib/module/icons/index.js.map +1 -1
  33. package/lib/module/index.js +3 -1
  34. package/lib/module/index.js.map +1 -1
  35. package/lib/module/primitives/portal.js +15 -1
  36. package/lib/module/primitives/portal.js.map +1 -1
  37. package/lib/module/utils/date.js +10 -3
  38. package/lib/module/utils/date.js.map +1 -1
  39. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  40. package/lib/typescript/src/components/button/index.d.ts +12 -0
  41. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  42. package/lib/typescript/src/components/button-group/index.d.ts +9 -0
  43. package/lib/typescript/src/components/button-group/index.d.ts.map +1 -1
  44. package/lib/typescript/src/components/image-generation/dot-field.d.ts +61 -0
  45. package/lib/typescript/src/components/image-generation/dot-field.d.ts.map +1 -0
  46. package/lib/typescript/src/components/image-generation/index.d.ts +126 -0
  47. package/lib/typescript/src/components/image-generation/index.d.ts.map +1 -0
  48. package/lib/typescript/src/components/kpi/index.d.ts.map +1 -1
  49. package/lib/typescript/src/components/menu/index.d.ts.map +1 -1
  50. package/lib/typescript/src/components/panelside/index.d.ts +194 -10
  51. package/lib/typescript/src/components/panelside/index.d.ts.map +1 -1
  52. package/lib/typescript/src/components/planner/index.d.ts +65 -4
  53. package/lib/typescript/src/components/planner/index.d.ts.map +1 -1
  54. package/lib/typescript/src/components/planner/planner-entries.d.ts +18 -0
  55. package/lib/typescript/src/components/planner/planner-entries.d.ts.map +1 -1
  56. package/lib/typescript/src/components/planner/planner-grid-navigation.d.ts +7 -1
  57. package/lib/typescript/src/components/planner/planner-grid-navigation.d.ts.map +1 -1
  58. package/lib/typescript/src/components/planner/planner-weeks.d.ts +50 -0
  59. package/lib/typescript/src/components/planner/planner-weeks.d.ts.map +1 -0
  60. package/lib/typescript/src/components/select/index.d.ts +75 -2
  61. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  62. package/lib/typescript/src/components/slide-button/index.d.ts +317 -0
  63. package/lib/typescript/src/components/slide-button/index.d.ts.map +1 -0
  64. package/lib/typescript/src/components/slide-button/slide-button-track.d.ts +70 -0
  65. package/lib/typescript/src/components/slide-button/slide-button-track.d.ts.map +1 -0
  66. package/lib/typescript/src/icons/index.d.ts +136 -189
  67. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  68. package/lib/typescript/src/index.d.ts +5 -3
  69. package/lib/typescript/src/index.d.ts.map +1 -1
  70. package/lib/typescript/src/primitives/portal.d.ts.map +1 -1
  71. package/lib/typescript/src/utils/date.d.ts +1 -1
  72. package/lib/typescript/src/utils/date.d.ts.map +1 -1
  73. package/package.json +3 -2
  74. package/src/components/bottom-sheet/index.tsx +97 -17
  75. package/src/components/button/index.tsx +10 -2
  76. package/src/components/button-group/index.tsx +1 -0
  77. package/src/components/image-generation/dot-field.ts +226 -0
  78. package/src/components/image-generation/index.tsx +488 -0
  79. package/src/components/kpi/index.tsx +2 -4
  80. package/src/components/menu/index.tsx +10 -15
  81. package/src/components/panelside/index.tsx +498 -33
  82. package/src/components/planner/index.tsx +516 -38
  83. package/src/components/planner/planner-entries.ts +33 -0
  84. package/src/components/planner/planner-grid-navigation.ts +37 -2
  85. package/src/components/planner/planner-weeks.ts +90 -0
  86. package/src/components/select/index.tsx +158 -31
  87. package/src/components/slide-button/index.tsx +681 -0
  88. package/src/components/slide-button/slide-button-track.ts +100 -0
  89. package/src/icons/index.tsx +338 -1327
  90. package/src/index.ts +30 -0
  91. package/src/primitives/portal.tsx +15 -1
  92. package/src/utils/date.ts +13 -3
@@ -153,3 +153,36 @@ export function summariseMonth<T extends PlannerDatedEntry>(
153
153
  })),
154
154
  };
155
155
  }
156
+
157
+ /**
158
+ * Minutes since midnight, or `null` for an entry that lands exactly on it.
159
+ *
160
+ * The grid has only ever read an entry's day, so a date with no time set is
161
+ * midnight — and that is indistinguishable from something genuinely scheduled
162
+ * at 00:00. Reading it as all-day is the right way round: a set of entries
163
+ * built without times renders as a list of things happening that day, which is
164
+ * what it is, rather than as a column of midnights.
165
+ */
166
+ export function entryTimeMinutes(date: Date): number | null {
167
+ const minutes = date.getHours() * 60 + date.getMinutes();
168
+ return minutes === 0 ? null : minutes;
169
+ }
170
+
171
+ /**
172
+ * All-day first, then by time, keeping the caller's order within each.
173
+ *
174
+ * All-day leads because it frames the rest of the day rather than falling at a
175
+ * point in it, and a list that opens with "All day" reads as a heading for
176
+ * what follows.
177
+ */
178
+ export function sortByTime<T extends PlannerDatedEntry>(entries: readonly T[]): T[] {
179
+ return entries
180
+ .map((entry, index) => ({ entry, index, at: entryTimeMinutes(entry.date) }))
181
+ .sort((a, b) => {
182
+ if (a.at === null && b.at === null) return a.index - b.index;
183
+ if (a.at === null) return -1;
184
+ if (b.at === null) return 1;
185
+ return a.at === b.at ? a.index - b.index : a.at - b.at;
186
+ })
187
+ .map(({ entry }) => entry);
188
+ }
@@ -6,40 +6,75 @@ export type PlannerGridNavigationKey =
6
6
  | 'Home'
7
7
  | 'End';
8
8
 
9
+ /**
10
+ * Whether a cell can take focus. Some looks leave the days either side of the
11
+ * month blank, and a blank has nothing to focus — so the caller says which
12
+ * indices are real and movement steps over the rest.
13
+ */
14
+ export type PlannerGridNavigable = (index: number) => boolean;
15
+
9
16
  /** Returns the next visible cell for a bounded, row-major month grid. */
10
17
  export function plannerGridTarget(
11
18
  key: string,
12
19
  index: number,
13
20
  count: number,
14
- columns = 7
21
+ columns = 7,
22
+ navigable?: PlannerGridNavigable
15
23
  ): number | null {
16
24
  if (!Number.isInteger(index) || index < 0 || index >= count || columns < 1) {
17
25
  return null;
18
26
  }
19
27
 
20
28
  let target: number;
29
+ /*
30
+ * Which way to keep going when the first landing is blank. The arrows carry
31
+ * on away from where you were; Home and End start at the edge of the row and
32
+ * come back inward, because the cell they want is the outermost real one and
33
+ * anything past it is off the row entirely.
34
+ */
35
+ let step: number;
21
36
  switch (key as PlannerGridNavigationKey) {
22
37
  case 'ArrowLeft':
23
38
  target = index - 1;
39
+ step = -1;
24
40
  break;
25
41
  case 'ArrowRight':
26
42
  target = index + 1;
43
+ step = 1;
27
44
  break;
28
45
  case 'ArrowUp':
29
46
  target = index - columns;
47
+ step = -columns;
30
48
  break;
31
49
  case 'ArrowDown':
32
50
  target = index + columns;
51
+ step = columns;
33
52
  break;
34
53
  case 'Home':
35
54
  target = index - (index % columns);
55
+ step = 1;
36
56
  break;
37
57
  case 'End':
38
58
  target = index + (columns - 1 - (index % columns));
59
+ step = -1;
39
60
  break;
40
61
  default:
41
62
  return null;
42
63
  }
43
64
 
44
- return target >= 0 && target < count ? target : index;
65
+ if (target < 0 || target >= count) return index;
66
+ if (!navigable) return target;
67
+
68
+ /*
69
+ * Stepping stops at the cell you started from rather than running past it.
70
+ * That covers both directions at once: an arrow that finds nothing before
71
+ * the grid ends stays put, and Home or End on a row whose edge is blank
72
+ * walks in until it reaches a real day or arrives back where it began.
73
+ */
74
+ while (target !== index && !navigable(target)) {
75
+ target += step;
76
+ if (target < 0 || target >= count) return index;
77
+ }
78
+
79
+ return target;
45
80
  }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The arithmetic behind a calendar that scrolls instead of paging: which weeks
3
+ * are reachable, where a week starts, and which month a week belongs to.
4
+ *
5
+ * Nothing here imports anything, for the same reason as `planner-entries`: the
6
+ * tests run this file through `node --test` with type stripping rather than a
7
+ * bundler, so an extensionless import of a sibling does not resolve.
8
+ *
9
+ * A week is seven days whatever calendar is in force, so all of this is plain
10
+ * day arithmetic. Naming the month a week falls in is not, and is left to the
11
+ * caller, which has the calendar system.
12
+ */
13
+
14
+ const DAY_MS = 24 * 60 * 60 * 1000;
15
+
16
+ /** Local midnight, so two dates on the same day compare equal. */
17
+ function atMidnight(date: Date): Date {
18
+ const copy = new Date(date);
19
+ copy.setHours(0, 0, 0, 0);
20
+ return copy;
21
+ }
22
+
23
+ /**
24
+ * Days added by the calendar rather than by milliseconds.
25
+ *
26
+ * A day is not always 24 hours — the ones a daylight-saving change falls on are
27
+ * 23 or 25 — so stepping by `DAY_MS` drifts across a transition and lands a
28
+ * week row an hour before or after midnight, which then reads as the wrong day.
29
+ */
30
+ export function addDaysLocal(date: Date, days: number): Date {
31
+ const copy = new Date(date);
32
+ copy.setDate(copy.getDate() + days);
33
+ return atMidnight(copy);
34
+ }
35
+
36
+ /** The first day of the week `date` falls in, for a week starting on `weekStartsOn`. */
37
+ export function startOfWeek(date: Date, weekStartsOn: number): Date {
38
+ const start = ((weekStartsOn % 7) + 7) % 7;
39
+ const midnight = atMidnight(date);
40
+ const shift = (midnight.getDay() - start + 7) % 7;
41
+ return addDaysLocal(midnight, -shift);
42
+ }
43
+
44
+ /**
45
+ * The day a week is named after: its midpoint.
46
+ *
47
+ * A week straddling a month boundary belongs to whichever month holds most of
48
+ * it, and the fourth day is the cheapest way to ask that — it is on the heavier
49
+ * side by definition. Naming the week after its first day would put the last
50
+ * week of January under December for as little as one day's overlap.
51
+ */
52
+ export function weekAnchor(weekStart: Date): Date {
53
+ return addDaysLocal(weekStart, 3);
54
+ }
55
+
56
+ /** The seven days of a week, in order. */
57
+ export function weekDays(weekStart: Date): Date[] {
58
+ return Array.from({ length: 7 }, (_unused, day) => addDaysLocal(weekStart, day));
59
+ }
60
+
61
+ /**
62
+ * Every week start from `weeksBefore` before the anchor's week to `weeksAfter`
63
+ * after it, in order.
64
+ *
65
+ * A bounded range rather than an endless one: a scroller needs to know how tall
66
+ * it is to place a scrollbar and to jump to a month without rendering its way
67
+ * there, and neither is possible over an infinite list.
68
+ */
69
+ export function weekRange(anchor: Date, weekStartsOn: number, weeksBefore: number, weeksAfter: number): Date[] {
70
+ const first = startOfWeek(anchor, weekStartsOn);
71
+ const before = Math.max(0, Math.trunc(weeksBefore));
72
+ const after = Math.max(0, Math.trunc(weeksAfter));
73
+ return Array.from({ length: before + after + 1 }, (_unused, step) =>
74
+ addDaysLocal(first, (step - before) * 7)
75
+ );
76
+ }
77
+
78
+ /**
79
+ * Where `date`'s week sits in a range, or `-1`.
80
+ *
81
+ * Computed from the span rather than searched for, so jumping to a month costs
82
+ * the same whether it is one screen away or a year.
83
+ */
84
+ export function weekIndex(weeks: readonly Date[], date: Date, weekStartsOn: number): number {
85
+ const first = weeks[0];
86
+ if (!first) return -1;
87
+ const target = startOfWeek(date, weekStartsOn);
88
+ const index = Math.round((target.getTime() - first.getTime()) / (7 * DAY_MS));
89
+ return index >= 0 && index < weeks.length ? index : -1;
90
+ }
@@ -81,6 +81,11 @@ const selectVariants = tv({
81
81
  triggerLabel: 'flex-1 text-base font-medium text-foreground',
82
82
  placeholder: 'flex-1 text-base text-muted-foreground',
83
83
  list: 'overflow-hidden rounded-xl border border-border bg-popover p-2 shadow-sm',
84
+ // The block of options inside a sheet. The sheet is the surface there, so
85
+ // this is what `listClassName` has to reach instead of `list`.
86
+ options: 'gap-1 pb-2',
87
+ search: 'pb-2',
88
+ empty: 'px-3 py-6 text-center text-sm text-muted-foreground',
84
89
  item: 'flex-row items-center gap-2 rounded-lg px-3 py-3',
85
90
  itemLabel: 'flex-1 text-base font-medium text-foreground',
86
91
  itemIndicator: 'h-5 w-5 items-center justify-center',
@@ -113,13 +118,51 @@ export type SelectPresentation = 'sheet' | 'inline' | 'overlay';
113
118
  interface SelectContextValue {
114
119
  value: string | undefined;
115
120
  onSelect: (value: string) => void;
121
+ query: string;
122
+ setQuery: (query: string) => void;
116
123
  }
117
124
 
118
125
  const SelectContext = createContext<SelectContextValue | null>(null);
119
126
 
127
+ /**
128
+ * The filter field's text, from inside an open Select.
129
+ *
130
+ * Select can only filter the options it renders itself. A caller who hands it
131
+ * a virtualized list is rendering their own rows, from their own data, and
132
+ * this is how they get the query to filter that data with — `Select.Item`
133
+ * still works wherever those rows put it, because selection travels by
134
+ * context rather than by position.
135
+ *
136
+ * ```tsx
137
+ * function Options() {
138
+ * const { query } = useSelectSearch();
139
+ * const rows = useMemo(() => filter(timezones, query), [query]);
140
+ * return (
141
+ * <FlashList
142
+ * data={rows}
143
+ * renderItem={({ item }) => <Select.Item value={item.id} label={item.name} />}
144
+ * />
145
+ * );
146
+ * }
147
+ * ```
148
+ *
149
+ * `setQuery` is there for a caller who wants to clear or seed the field.
150
+ */
151
+ export function useSelectSearch(): { query: string; setQuery: (query: string) => void } {
152
+ const context = useContext(SelectContext);
153
+ if (!context) {
154
+ throw new Error('useSelectSearch must be used within a <Select>');
155
+ }
156
+ return { query: context.query, setQuery: context.setQuery };
157
+ }
158
+
120
159
  export interface SelectItemProps {
121
160
  value: string;
122
161
  label: string;
162
+ /** Extra classes for the option row. */
163
+ className?: string;
164
+ /** Extra classes for the option's label. */
165
+ labelClassName?: string;
123
166
  /**
124
167
  * Shows the option but refuses it — a plan above the current tier, a region
125
168
  * with nothing in stock. Kept in the list rather than dropped from it, because
@@ -129,7 +172,7 @@ export interface SelectItemProps {
129
172
  }
130
173
 
131
174
  /** Declarative option. Rendered inside whichever surface is presenting. */
132
- function SelectItem({ value, label, disabled }: SelectItemProps) {
175
+ function SelectItem({ value, label, disabled, className, labelClassName }: SelectItemProps) {
133
176
  const context = useContext(SelectContext);
134
177
  if (!context) {
135
178
  throw new Error('Select.Item must be used within a <Select>');
@@ -148,9 +191,9 @@ function SelectItem({ value, label, disabled }: SelectItemProps) {
148
191
  accessibilityState={{ selected, disabled: !!disabled }}
149
192
  disabled={disabled}
150
193
  onPress={() => context.onSelect(value)}
151
- className={item()}
194
+ className={item({ className })}
152
195
  >
153
- <Text className={itemLabel()}>{label}</Text>
196
+ <Text className={itemLabel({ className: labelClassName })}>{label}</Text>
154
197
  <View className={itemIndicator()}>
155
198
  {selected ? (
156
199
  <CheckIcon
@@ -220,37 +263,62 @@ function eachOption(children: ReactNode, visit: (option: SelectItemProps) => voi
220
263
  });
221
264
  }
222
265
 
266
+ /** What a pass of the filter left, and how much there was to filter. */
267
+ interface FilterResult {
268
+ /** The children to render. */
269
+ kept: ReactNode[];
270
+ /** How many `Select.Item`s the pass saw, at any depth it can reach. */
271
+ seen: number;
272
+ }
273
+
223
274
  /**
224
275
  * The children a query leaves standing.
225
276
  *
226
277
  * A group is rebuilt around whatever survives inside it and dropped when that
227
278
  * is nothing — a heading with no options under it reads as a section that
228
- * failed to load rather than one the filter emptied. Anything that is neither
229
- * an item nor a group is left alone: a caption or a divider the caller put in
230
- * the list is not something a filter has an opinion about.
279
+ * failed to load rather than one the filter emptied.
280
+ *
281
+ * Anything that is neither an item nor a group is left alone: a caption or a
282
+ * divider the caller put in the list is not something a filter has an opinion
283
+ * about, and neither is a list component rendering its own rows. Dropping
284
+ * those was how a `Select.Item` inside a virtualized list disappeared the
285
+ * moment anybody typed — the list was not an item, so nothing kept it.
286
+ *
287
+ * `seen` is what tells an empty result from an unfilterable one. Zero items
288
+ * seen means the caller is rendering their own rows and filtering them
289
+ * themselves through `useSelectSearch`, so there is nothing to call empty.
231
290
  */
232
- function filterOptions(children: ReactNode, needle: string): ReactNode[] {
291
+ function filterOptions(children: ReactNode, needle: string): FilterResult {
233
292
  const kept: ReactNode[] = [];
293
+ let seen = 0;
234
294
 
235
295
  Children.forEach(children, (child) => {
236
- if (!isValidElement(child)) return;
296
+ if (!isValidElement(child)) {
297
+ if (child !== null && child !== undefined && child !== false) kept.push(child);
298
+ return;
299
+ }
237
300
 
238
301
  if (child.type === SelectGroup) {
239
302
  const props = child.props as SelectGroupProps;
240
303
  const inner = filterOptions(props.children, needle);
241
- if (inner.length) {
242
- kept.push(cloneElement(child as ReactElement<SelectGroupProps>, {}, inner));
304
+ seen += inner.seen;
305
+ if (inner.kept.length) {
306
+ kept.push(cloneElement(child as ReactElement<SelectGroupProps>, {}, inner.kept));
243
307
  }
244
308
  return;
245
309
  }
246
310
 
247
311
  if (child.type === SelectItem) {
312
+ seen += 1;
248
313
  const { label } = child.props as SelectItemProps;
249
314
  if (label.toLowerCase().includes(needle)) kept.push(child);
315
+ return;
250
316
  }
317
+
318
+ kept.push(child);
251
319
  });
252
320
 
253
- return kept;
321
+ return { kept, seen };
254
322
  }
255
323
 
256
324
  /** Trigger frame in window coordinates, measured when the list opens. */
@@ -262,11 +330,52 @@ interface Anchor {
262
330
  }
263
331
 
264
332
  export interface SelectProps {
333
+ /**
334
+ * Extra classes for the wrapper around the trigger — the box the select
335
+ * occupies in your layout, which is where margins and widths belong. To
336
+ * restyle the field itself, use `triggerClassName`.
337
+ */
265
338
  className?: string;
339
+ /** The selected option's `value`. Leave unset for the placeholder. */
266
340
  value?: string;
341
+ /**
342
+ * What the trigger shows for the current `value`.
343
+ *
344
+ * Select reads the label off its `Select.Item` children, which it cannot do
345
+ * when a list component renders those rows — the elements do not exist until
346
+ * the list decides to draw them, and the selected one may be scrolled far
347
+ * out of view. Pass the label yourself in that case; otherwise leave it
348
+ * unset and the trigger will find it.
349
+ */
350
+ valueLabel?: string;
351
+ /** Called with the `value` of the option that was picked. */
267
352
  onValueChange: (value: string) => void;
353
+ /** Shown on the trigger while nothing is selected. */
268
354
  placeholder?: string;
355
+ /** Refuses the trigger and dims it. The options cannot be opened. */
269
356
  disabled?: boolean;
357
+ /** Extra classes for the trigger — the field you press to open the list. */
358
+ triggerClassName?: string;
359
+ /** Extra classes for the selected option's text on the trigger. */
360
+ valueClassName?: string;
361
+ /** Extra classes for the placeholder text on the trigger. */
362
+ placeholderClassName?: string;
363
+ /**
364
+ * Extra classes for the surface the options sit on. In `sheet` the sheet is
365
+ * that surface, so this reaches the block of options inside it instead.
366
+ */
367
+ listClassName?: string;
368
+ /** Extra classes for the row the filter field sits in. `searchable` only. */
369
+ searchClassName?: string;
370
+ /** Extra classes for the filter field itself. `searchable` only. */
371
+ searchInputClassName?: string;
372
+ /**
373
+ * Extra classes for the box drawn around the filter field — its fill, border
374
+ * and radius. `searchable` only.
375
+ */
376
+ searchContainerClassName?: string;
377
+ /** Extra classes for the message shown when the filter matches nothing. */
378
+ emptyClassName?: string;
270
379
  /**
271
380
  * Where the options appear. `sheet` takes the bottom of the screen, `inline`
272
381
  * expands the list in layout flow, `overlay` floats it above the page
@@ -318,9 +427,18 @@ export interface SelectProps {
318
427
  function SelectRoot({
319
428
  className,
320
429
  value,
430
+ valueLabel,
321
431
  onValueChange,
322
432
  placeholder = 'Select an option',
323
433
  disabled,
434
+ triggerClassName,
435
+ valueClassName,
436
+ placeholderClassName,
437
+ listClassName,
438
+ searchClassName,
439
+ searchInputClassName,
440
+ searchContainerClassName,
441
+ emptyClassName,
324
442
  presentation = 'sheet',
325
443
  title,
326
444
  contentWidth = 'trigger',
@@ -357,8 +475,8 @@ function SelectRoot({
357
475
  native && nativeSelectSupportsOptions(options) ? getNativeUI() : null;
358
476
 
359
477
  const selectedLabel = useMemo(
360
- () => options.find((option) => option.value === value)?.label,
361
- [options, value]
478
+ () => valueLabel ?? options.find((option) => option.value === value)?.label,
479
+ [options, value, valueLabel]
362
480
  );
363
481
 
364
482
  /*
@@ -374,6 +492,14 @@ function SelectRoot({
374
492
  return filterOptions(children, needle);
375
493
  }, [children, query, searchable]);
376
494
 
495
+ /*
496
+ * Nothing left, out of something there was. A caller rendering their own
497
+ * rows filters them themselves, so `seen` is zero and their list stands —
498
+ * saying "no matches" over the top of it would be Select claiming to know an
499
+ * answer it was never shown.
500
+ */
501
+ const noMatches = filtered !== null && filtered.seen > 0 && filtered.kept.length === 0;
502
+
377
503
  const close = useCallback(() => {
378
504
  chevron.value = withTiming(0, { duration: 160 });
379
505
  setOpen(false);
@@ -420,8 +546,10 @@ function SelectRoot({
420
546
  onValueChange(next);
421
547
  close();
422
548
  },
549
+ query,
550
+ setQuery,
423
551
  }),
424
- [value, onValueChange, close]
552
+ [value, onValueChange, close, query]
425
553
  );
426
554
 
427
555
  const slots = selectVariants({ disabled: !!disabled, presentation });
@@ -468,12 +596,12 @@ function SelectRoot({
468
596
  accessibilityState={{ disabled: !!disabled, expanded: open }}
469
597
  disabled={disabled}
470
598
  onPress={toggle}
471
- className={slots.trigger()}
599
+ className={slots.trigger({ className: triggerClassName })}
472
600
  >
473
601
  {selectedLabel ? (
474
- <Text className={slots.triggerLabel()}>{selectedLabel}</Text>
602
+ <Text className={slots.triggerLabel({ className: valueClassName })}>{selectedLabel}</Text>
475
603
  ) : (
476
- <Text className={slots.placeholder()}>{placeholder}</Text>
604
+ <Text className={slots.placeholder({ className: placeholderClassName })}>{placeholder}</Text>
477
605
  )}
478
606
  <Animated.View style={chevronStyle}>
479
607
  <ChevronDownIcon
@@ -490,7 +618,7 @@ function SelectRoot({
490
618
  * what it is.
491
619
  */
492
620
  const search = searchable ? (
493
- <View className="pb-2">
621
+ <View className={slots.search({ className: searchClassName })}>
494
622
  <InputGroup>
495
623
  <InputGroup.Prefix isDecorative>
496
624
  <SearchIcon
@@ -501,6 +629,8 @@ function SelectRoot({
501
629
  <InputGroup.Input
502
630
  variant="filled"
503
631
  size="sm"
632
+ className={searchInputClassName}
633
+ containerClassName={searchContainerClassName}
504
634
  value={query}
505
635
  onChangeText={setQuery}
506
636
  placeholder={searchPlaceholder}
@@ -514,16 +644,13 @@ function SelectRoot({
514
644
  </View>
515
645
  ) : null;
516
646
 
517
- const optionList =
518
- filtered === null ? (
519
- textChildren(children)
520
- ) : filtered.length ? (
521
- filtered
522
- ) : (
523
- <Text className="px-3 py-6 text-center text-sm text-muted-foreground">
524
- {emptyMessage}
525
- </Text>
526
- );
647
+ const optionList = noMatches ? (
648
+ <Text className={slots.empty({ className: emptyClassName })}>{emptyMessage}</Text>
649
+ ) : filtered === null ? (
650
+ textChildren(children)
651
+ ) : (
652
+ filtered.kept
653
+ );
527
654
 
528
655
  if (presentation === 'sheet') {
529
656
  return (
@@ -556,7 +683,7 @@ function SelectRoot({
556
683
  keyboardDismissMode="on-drag"
557
684
  keyboardShouldPersistTaps="handled"
558
685
  >
559
- <View className="gap-1 pb-2">{optionList}</View>
686
+ <View className={slots.options({ className: listClassName })}>{optionList}</View>
560
687
  </ScrollView>
561
688
  </SelectContext.Provider>
562
689
  </BottomSheet.Content>
@@ -574,7 +701,7 @@ function SelectRoot({
574
701
  <Animated.View
575
702
  entering={FadeIn.duration(140)}
576
703
  exiting={FadeOut.duration(120)}
577
- className={slots.list()}
704
+ className={slots.list({ className: listClassName })}
578
705
  >
579
706
  {search}
580
707
  {optionList}
@@ -651,7 +778,7 @@ function SelectRoot({
651
778
  * has to say it itself.
652
779
  */
653
780
  accessibilityViewIsModal
654
- className={slots.list()}
781
+ className={slots.list({ className: listClassName })}
655
782
  >
656
783
  {search}
657
784
  <ScrollView