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.
- package/lib/module/components/bottom-sheet/index.js +90 -17
- package/lib/module/components/bottom-sheet/index.js.map +1 -1
- package/lib/module/components/button/index.js +13 -0
- package/lib/module/components/button/index.js.map +1 -1
- package/lib/module/components/button-group/index.js +3 -0
- package/lib/module/components/button-group/index.js.map +1 -1
- package/lib/module/components/image-generation/dot-field.js +210 -0
- package/lib/module/components/image-generation/dot-field.js.map +1 -0
- package/lib/module/components/image-generation/index.js +417 -0
- package/lib/module/components/image-generation/index.js.map +1 -0
- package/lib/module/components/kpi/index.js +2 -4
- package/lib/module/components/kpi/index.js.map +1 -1
- package/lib/module/components/menu/index.js +8 -16
- package/lib/module/components/menu/index.js.map +1 -1
- package/lib/module/components/panelside/index.js +340 -33
- package/lib/module/components/panelside/index.js.map +1 -1
- package/lib/module/components/planner/index.js +446 -32
- package/lib/module/components/planner/index.js.map +1 -1
- package/lib/module/components/planner/planner-entries.js +36 -0
- package/lib/module/components/planner/planner-entries.js.map +1 -1
- package/lib/module/components/planner/planner-grid-navigation.js +34 -2
- package/lib/module/components/planner/planner-grid-navigation.js.map +1 -1
- package/lib/module/components/planner/planner-weeks.js +95 -0
- package/lib/module/components/planner/planner-weeks.js.map +1 -0
- package/lib/module/components/select/index.js +127 -23
- package/lib/module/components/select/index.js.map +1 -1
- package/lib/module/components/slide-button/index.js +610 -0
- package/lib/module/components/slide-button/index.js.map +1 -0
- package/lib/module/components/slide-button/slide-button-track.js +98 -0
- package/lib/module/components/slide-button/slide-button-track.js.map +1 -0
- package/lib/module/icons/index.js +553 -1934
- package/lib/module/icons/index.js.map +1 -1
- package/lib/module/index.js +3 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/primitives/portal.js +15 -1
- package/lib/module/primitives/portal.js.map +1 -1
- package/lib/module/utils/date.js +10 -3
- package/lib/module/utils/date.js.map +1 -1
- package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
- package/lib/typescript/src/components/button/index.d.ts +12 -0
- package/lib/typescript/src/components/button/index.d.ts.map +1 -1
- package/lib/typescript/src/components/button-group/index.d.ts +9 -0
- package/lib/typescript/src/components/button-group/index.d.ts.map +1 -1
- package/lib/typescript/src/components/image-generation/dot-field.d.ts +61 -0
- package/lib/typescript/src/components/image-generation/dot-field.d.ts.map +1 -0
- package/lib/typescript/src/components/image-generation/index.d.ts +126 -0
- package/lib/typescript/src/components/image-generation/index.d.ts.map +1 -0
- package/lib/typescript/src/components/kpi/index.d.ts.map +1 -1
- package/lib/typescript/src/components/menu/index.d.ts.map +1 -1
- package/lib/typescript/src/components/panelside/index.d.ts +194 -10
- package/lib/typescript/src/components/panelside/index.d.ts.map +1 -1
- package/lib/typescript/src/components/planner/index.d.ts +65 -4
- package/lib/typescript/src/components/planner/index.d.ts.map +1 -1
- package/lib/typescript/src/components/planner/planner-entries.d.ts +18 -0
- package/lib/typescript/src/components/planner/planner-entries.d.ts.map +1 -1
- package/lib/typescript/src/components/planner/planner-grid-navigation.d.ts +7 -1
- package/lib/typescript/src/components/planner/planner-grid-navigation.d.ts.map +1 -1
- package/lib/typescript/src/components/planner/planner-weeks.d.ts +50 -0
- package/lib/typescript/src/components/planner/planner-weeks.d.ts.map +1 -0
- package/lib/typescript/src/components/select/index.d.ts +75 -2
- package/lib/typescript/src/components/select/index.d.ts.map +1 -1
- package/lib/typescript/src/components/slide-button/index.d.ts +317 -0
- package/lib/typescript/src/components/slide-button/index.d.ts.map +1 -0
- package/lib/typescript/src/components/slide-button/slide-button-track.d.ts +70 -0
- package/lib/typescript/src/components/slide-button/slide-button-track.d.ts.map +1 -0
- package/lib/typescript/src/icons/index.d.ts +136 -189
- package/lib/typescript/src/icons/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +5 -3
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/primitives/portal.d.ts.map +1 -1
- package/lib/typescript/src/utils/date.d.ts +1 -1
- package/lib/typescript/src/utils/date.d.ts.map +1 -1
- package/package.json +3 -2
- package/src/components/bottom-sheet/index.tsx +97 -17
- package/src/components/button/index.tsx +10 -2
- package/src/components/button-group/index.tsx +1 -0
- package/src/components/image-generation/dot-field.ts +226 -0
- package/src/components/image-generation/index.tsx +488 -0
- package/src/components/kpi/index.tsx +2 -4
- package/src/components/menu/index.tsx +10 -15
- package/src/components/panelside/index.tsx +498 -33
- package/src/components/planner/index.tsx +516 -38
- package/src/components/planner/planner-entries.ts +33 -0
- package/src/components/planner/planner-grid-navigation.ts +37 -2
- package/src/components/planner/planner-weeks.ts +90 -0
- package/src/components/select/index.tsx +158 -31
- package/src/components/slide-button/index.tsx +681 -0
- package/src/components/slide-button/slide-button-track.ts +100 -0
- package/src/icons/index.tsx +338 -1327
- package/src/index.ts +30 -0
- package/src/primitives/portal.tsx +15 -1
- 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
|
-
|
|
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.
|
|
229
|
-
*
|
|
230
|
-
*
|
|
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):
|
|
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))
|
|
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
|
-
|
|
242
|
-
|
|
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=
|
|
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
|
-
|
|
519
|
-
|
|
520
|
-
)
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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=
|
|
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
|