@lotics/ui 5.8.0 → 5.10.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/AGENTS.md +184 -56
- package/examples/tpl_approvals.tsx +3 -3
- package/examples/tpl_convert.tsx +11 -23
- package/examples/tpl_dossier.tsx +5 -4
- package/examples/tpl_order.tsx +1 -1
- package/examples/tpl_pipeline.tsx +400 -0
- package/examples/tpl_ratedesk.tsx +1 -1
- package/examples/tpl_record.tsx +6 -5
- package/examples/tpl_shifts.tsx +7 -5
- package/examples/tpl_task_board.tsx +473 -0
- package/examples/tpl_tasks.tsx +440 -0
- package/package.json +19 -6
- package/src/accordion.tsx +1 -1
- package/src/agent_progress.tsx +1 -1
- package/src/agent_run.tsx +1 -1
- package/src/breakdown.tsx +2 -2
- package/src/button.tsx +12 -3
- package/src/calendar/calendar_view.tsx +10 -10
- package/src/calendar/month_view.tsx +30 -11
- package/src/calendar/time_grid_view.tsx +25 -7
- package/src/card_select_item.tsx +11 -13
- package/src/check_circle.tsx +88 -0
- package/src/checkbox_input.tsx +5 -1
- package/src/chip.tsx +1 -1
- package/src/chip_group.tsx +1 -1
- package/src/column_filter.tsx +3 -2
- package/src/combobox.tsx +84 -219
- package/src/confidence.tsx +14 -3
- package/src/control_surface.ts +22 -7
- package/src/data_grid.tsx +159 -0
- package/src/date_calendar.tsx +46 -23
- package/src/date_field.tsx +10 -2
- package/src/date_filter.tsx +5 -9
- package/src/date_picker.tsx +78 -26
- package/src/date_picker_value.ts +11 -0
- package/src/date_range_filter_field.tsx +6 -4
- package/src/drawer.tsx +1 -1
- package/src/file_dropzone.tsx +5 -0
- package/src/file_gallery_modal.tsx +227 -139
- package/src/file_grid.tsx +184 -0
- package/src/file_preview.web.tsx +349 -12
- package/src/file_preview_types.ts +49 -0
- package/src/file_row.tsx +118 -0
- package/src/file_rows.tsx +109 -0
- package/src/file_thumbnail.tsx +132 -81
- package/src/file_thumbnail_grid.tsx +135 -55
- package/src/files_editor.tsx +232 -0
- package/src/filter_chip.tsx +25 -12
- package/src/focus_ring_pressable.tsx +34 -0
- package/src/form_picker.tsx +3 -3
- package/src/gantt/gantt_view.tsx +25 -7
- package/src/heatmap.tsx +5 -1
- package/src/icon_button.tsx +14 -3
- package/src/image_gallery.tsx +8 -14
- package/src/index.css +8 -12
- package/src/inline_date_picker.tsx +11 -11
- package/src/inline_edit.tsx +22 -6
- package/src/inline_member_select.tsx +1 -1
- package/src/inline_select.tsx +9 -8
- package/src/inline_text_input.tsx +4 -1
- package/src/link.tsx +11 -1
- package/src/link_button.tsx +1 -1
- package/src/list_item.tsx +2 -2
- package/src/member_select.tsx +9 -4
- package/src/menu_button.tsx +2 -2
- package/src/menu_list_item.tsx +2 -2
- package/src/number_input.tsx +11 -4
- package/src/option_list.tsx +211 -0
- package/src/pdfjs_worker.d.ts +5 -0
- package/src/peek.tsx +1 -1
- package/src/picker.tsx +24 -213
- package/src/pressable_highlight.tsx +42 -23
- package/src/radio_picker.tsx +2 -2
- package/src/range_slider.tsx +35 -7
- package/src/react_native.d.ts +3 -0
- package/src/scroll_to_bottom.tsx +1 -1
- package/src/section.tsx +7 -2
- package/src/segmented_control.tsx +2 -2
- package/src/select.tsx +299 -0
- package/src/sort_header.tsx +1 -1
- package/src/sources.tsx +1 -1
- package/src/spreadsheet_view.tsx +43 -17
- package/src/status_grid.tsx +4 -3
- package/src/stepper.tsx +1 -1
- package/src/switch.tsx +5 -1
- package/src/switch_button.tsx +1 -1
- package/src/switcher.tsx +1 -1
- package/src/table.tsx +5 -1
- package/src/tabs.tsx +2 -2
- package/src/text_input_field.tsx +15 -4
- package/src/time_picker.tsx +11 -4
- package/src/timeline.tsx +1 -1
- package/src/uploading_thumbnail.tsx +212 -0
- package/src/use_focus_ring.ts +80 -0
- package/src/use_hover.ts +26 -0
- package/src/use_list_keyboard_nav.test.ts +71 -0
- package/src/use_list_keyboard_nav.ts +52 -5
- package/src/use_option_list.test.ts +193 -0
- package/src/use_option_list.ts +354 -0
- package/src/command_menu.tsx +0 -205
- package/src/hover_action.tsx +0 -54
- package/src/picker_menu.tsx +0 -355
- package/src/tag_input.tsx +0 -203
- package/src/time_field.tsx +0 -297
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { StyleSheet, View, ScrollView, TextInput } from "react-native";
|
|
2
|
+
import { type ReactNode } from "react";
|
|
3
|
+
import { colors } from "./colors";
|
|
4
|
+
import { Text } from "./text";
|
|
5
|
+
import { Icon } from "./icon";
|
|
6
|
+
import { Checkbox } from "./checkbox";
|
|
7
|
+
import { MenuButton } from "./menu_button";
|
|
8
|
+
import { LinkButton } from "./link_button";
|
|
9
|
+
import { ActivityIndicator } from "./activity_indicator";
|
|
10
|
+
import { TextInputField } from "./text_input_field";
|
|
11
|
+
import { useScreenSize } from "./use_screen_size";
|
|
12
|
+
import { useOptionList, type UseOptionListParams } from "./use_option_list";
|
|
13
|
+
import type { PickerOption } from "./picker";
|
|
14
|
+
|
|
15
|
+
export interface OptionListProps<T extends string = string, MULTI extends boolean = false, D = unknown>
|
|
16
|
+
extends UseOptionListParams<T, MULTI, D> {
|
|
17
|
+
testID?: string;
|
|
18
|
+
/** Rich row content (a colour-dot badge, a member chip). Falls back to the label. */
|
|
19
|
+
renderOptionContent?: (option: PickerOption<T, D>) => ReactNode;
|
|
20
|
+
/** A single-line subtitle under the label (e.g. a code or company). */
|
|
21
|
+
getOptionDescription?: (option: PickerOption<T, D>) => string | undefined;
|
|
22
|
+
loading?: boolean;
|
|
23
|
+
emptyText?: string;
|
|
24
|
+
accessibilityLabel?: string;
|
|
25
|
+
selectAllLabel?: string;
|
|
26
|
+
deselectAllLabel?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The kit's option listbox body — the popover content every selector opens: a
|
|
31
|
+
* keyboard-navigable list of `MenuButton` rows with single/multi selection +
|
|
32
|
+
* select-all, an optional "create" row, and either native-`<select>` typeahead
|
|
33
|
+
* (`search.mode:"none"`) or its own search field (`"internal"`). Drives off
|
|
34
|
+
* `useOptionList`. `Select` (incl. its chip-box / tag-field form), the chat composer, and the in-cell
|
|
35
|
+
* editors render it; `Combobox` consumes `useOptionList` directly (its own input
|
|
36
|
+
* owns the keyboard). Don't hand-roll a search-list — reach for this.
|
|
37
|
+
*/
|
|
38
|
+
export function OptionList<T extends string, MULTI extends boolean = false, D = unknown>(
|
|
39
|
+
props: OptionListProps<T, MULTI, D>,
|
|
40
|
+
) {
|
|
41
|
+
const {
|
|
42
|
+
testID,
|
|
43
|
+
renderOptionContent,
|
|
44
|
+
getOptionDescription,
|
|
45
|
+
loading = false,
|
|
46
|
+
emptyText = "No results",
|
|
47
|
+
accessibilityLabel,
|
|
48
|
+
selectAllLabel = "Select all",
|
|
49
|
+
deselectAllLabel = "Deselect all",
|
|
50
|
+
search,
|
|
51
|
+
} = props;
|
|
52
|
+
const list = useOptionList(props);
|
|
53
|
+
const { small } = useScreenSize();
|
|
54
|
+
|
|
55
|
+
const onSearchKeyPress = (e: { nativeEvent: { key: string }; preventDefault: () => void }) => {
|
|
56
|
+
if (list.handleKey(e.nativeEvent.key)) e.preventDefault();
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<View testID={testID} style={[styles.container, small ? { flex: 1 } : { maxHeight: 480 }]}>
|
|
61
|
+
{search.mode === "internal" ? (
|
|
62
|
+
<TextInputField
|
|
63
|
+
icon="search"
|
|
64
|
+
value={list.query}
|
|
65
|
+
onChangeText={list.setQuery}
|
|
66
|
+
placeholder={search.placeholder ?? "Search…"}
|
|
67
|
+
autoFocus={!small}
|
|
68
|
+
autoCapitalize="none"
|
|
69
|
+
autoCorrect={false}
|
|
70
|
+
onKeyPress={onSearchKeyPress}
|
|
71
|
+
accessibilityLabel={accessibilityLabel}
|
|
72
|
+
role="combobox"
|
|
73
|
+
aria-expanded
|
|
74
|
+
aria-controls={list.listboxId}
|
|
75
|
+
aria-activedescendant={list.activeId}
|
|
76
|
+
aria-autocomplete="list"
|
|
77
|
+
/>
|
|
78
|
+
) : search.mode === "none" ? (
|
|
79
|
+
// Visually hidden but focusable: captures typeahead + arrow/enter keys
|
|
80
|
+
// while the popover is open, giving native-<select> behavior.
|
|
81
|
+
<TextInput
|
|
82
|
+
autoFocus={!small}
|
|
83
|
+
value=""
|
|
84
|
+
onChangeText={() => {}}
|
|
85
|
+
onKeyPress={onSearchKeyPress}
|
|
86
|
+
style={styles.typeaheadCapture}
|
|
87
|
+
/>
|
|
88
|
+
) : null}
|
|
89
|
+
|
|
90
|
+
<ScrollView
|
|
91
|
+
ref={list.scrollRef}
|
|
92
|
+
style={styles.optionsList}
|
|
93
|
+
nativeID={list.listboxId}
|
|
94
|
+
accessibilityLabel={accessibilityLabel}
|
|
95
|
+
keyboardShouldPersistTaps="handled"
|
|
96
|
+
// `listbox` is valid ARIA but absent from RN's Role enum; rn-web forwards it.
|
|
97
|
+
role={"listbox" as "list"}
|
|
98
|
+
>
|
|
99
|
+
{loading ? (
|
|
100
|
+
<View style={styles.statusRow}>
|
|
101
|
+
<ActivityIndicator />
|
|
102
|
+
</View>
|
|
103
|
+
) : list.rows.length === 0 ? (
|
|
104
|
+
<View style={styles.statusRow}>
|
|
105
|
+
<Text color="zinc-500" size="sm">
|
|
106
|
+
{emptyText}
|
|
107
|
+
</Text>
|
|
108
|
+
</View>
|
|
109
|
+
) : (
|
|
110
|
+
list.rows.map((row) => {
|
|
111
|
+
const isCustom = row.kind === "custom";
|
|
112
|
+
const opt = row.option;
|
|
113
|
+
const desc = row.kind === "option" ? getOptionDescription?.(opt) : undefined;
|
|
114
|
+
const label = opt.label ?? opt.value;
|
|
115
|
+
const title =
|
|
116
|
+
row.kind === "option" && renderOptionContent ? (
|
|
117
|
+
renderOptionContent(opt)
|
|
118
|
+
) : desc ? (
|
|
119
|
+
<View>
|
|
120
|
+
<Text userSelect="none" numberOfLines={1}>
|
|
121
|
+
{label}
|
|
122
|
+
</Text>
|
|
123
|
+
<Text size="xs" color="zinc-500" numberOfLines={1}>
|
|
124
|
+
{desc}
|
|
125
|
+
</Text>
|
|
126
|
+
</View>
|
|
127
|
+
) : (
|
|
128
|
+
<Text userSelect="none" numberOfLines={1} weight={isCustom ? "medium" : undefined}>
|
|
129
|
+
{label}
|
|
130
|
+
</Text>
|
|
131
|
+
);
|
|
132
|
+
return (
|
|
133
|
+
<MenuButton
|
|
134
|
+
key={`${row.kind}-${opt.value}`}
|
|
135
|
+
nativeID={row.nativeID}
|
|
136
|
+
testID={
|
|
137
|
+
isCustom ? "option-list-custom" : opt.testID || `picker-option-${opt.value}`
|
|
138
|
+
}
|
|
139
|
+
role="option"
|
|
140
|
+
accessibilityLabel={isCustom ? label : (opt.label ?? String(opt.value))}
|
|
141
|
+
icon={
|
|
142
|
+
isCustom ? (
|
|
143
|
+
<Icon name="plus" size={16} color={colors.zinc["600"]} />
|
|
144
|
+
) : props.multi && row.kind === "option" ? (
|
|
145
|
+
<Checkbox checked={row.selected} />
|
|
146
|
+
) : undefined
|
|
147
|
+
}
|
|
148
|
+
title={title}
|
|
149
|
+
right={
|
|
150
|
+
!props.multi && row.selected ? (
|
|
151
|
+
<Icon name="check" size={18} color={colors.zinc["950"]} />
|
|
152
|
+
) : undefined
|
|
153
|
+
}
|
|
154
|
+
focused={row.index === list.activeIndex}
|
|
155
|
+
disabled={opt.disabled}
|
|
156
|
+
onPress={() => list.pickRow(row.index)}
|
|
157
|
+
onHoverIn={() => list.setActiveIndex(row.index, false)}
|
|
158
|
+
style={styles.option}
|
|
159
|
+
/>
|
|
160
|
+
);
|
|
161
|
+
})
|
|
162
|
+
)}
|
|
163
|
+
</ScrollView>
|
|
164
|
+
|
|
165
|
+
{list.showSelectAll || list.showDeselectAll ? (
|
|
166
|
+
<View style={styles.selectAllContainer}>
|
|
167
|
+
{list.showSelectAll ? <LinkButton title={selectAllLabel} onPress={list.selectAll} /> : null}
|
|
168
|
+
{list.showDeselectAll ? (
|
|
169
|
+
<LinkButton title={deselectAllLabel} onPress={list.deselectAll} />
|
|
170
|
+
) : null}
|
|
171
|
+
</View>
|
|
172
|
+
) : null}
|
|
173
|
+
</View>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const styles = StyleSheet.create({
|
|
178
|
+
container: {
|
|
179
|
+
gap: 4,
|
|
180
|
+
},
|
|
181
|
+
optionsList: {
|
|
182
|
+
flex: 1,
|
|
183
|
+
gap: 4,
|
|
184
|
+
maxHeight: 320,
|
|
185
|
+
},
|
|
186
|
+
selectAllContainer: {
|
|
187
|
+
flexDirection: "row",
|
|
188
|
+
alignItems: "center",
|
|
189
|
+
gap: 16,
|
|
190
|
+
paddingVertical: 4,
|
|
191
|
+
// Matches a MenuButton option's 8px text inset so the bleeding-wash
|
|
192
|
+
// LinkButtons line up under the option labels above.
|
|
193
|
+
paddingHorizontal: 8,
|
|
194
|
+
borderTopWidth: 1,
|
|
195
|
+
borderTopColor: colors.border,
|
|
196
|
+
},
|
|
197
|
+
option: {
|
|
198
|
+
marginHorizontal: 0,
|
|
199
|
+
},
|
|
200
|
+
statusRow: {
|
|
201
|
+
alignItems: "center",
|
|
202
|
+
justifyContent: "center",
|
|
203
|
+
paddingVertical: 16,
|
|
204
|
+
},
|
|
205
|
+
typeaheadCapture: {
|
|
206
|
+
position: "absolute",
|
|
207
|
+
width: 1,
|
|
208
|
+
height: 1,
|
|
209
|
+
opacity: 0,
|
|
210
|
+
},
|
|
211
|
+
});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// The pdf.js worker entry is imported only for its side effect: it assigns
|
|
2
|
+
// `globalThis.pdfjsWorker`, which lets pdf.js run on the main thread (no Web
|
|
3
|
+
// Worker, no worker-URL resolution) — portable across Vite (apps) and Metro
|
|
4
|
+
// (frontend). The file ships no types; declare it as a side-effect module.
|
|
5
|
+
declare module "pdfjs-dist/build/pdf.worker.min.mjs";
|
package/src/peek.tsx
CHANGED
|
@@ -33,7 +33,7 @@ export function Peek(props: PeekProps) {
|
|
|
33
33
|
<Popover side={side} align={align}>
|
|
34
34
|
<PopoverTrigger>
|
|
35
35
|
<PressableHighlight
|
|
36
|
-
accessibilityRole="button"
|
|
36
|
+
focusRing accessibilityRole="button"
|
|
37
37
|
accessibilityLabel={accessibilityLabel}
|
|
38
38
|
style={styles.trigger}
|
|
39
39
|
// 32px visual, 40px touch target (32 + 2×4) — same as IconButton.
|
package/src/picker.tsx
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { Picker as RNPicker } from "@react-native-picker/picker";
|
|
2
|
-
import { StyleSheet, View,
|
|
3
|
-
import {
|
|
2
|
+
import { StyleSheet, View, StyleProp, ViewStyle, TextStyle } from "react-native";
|
|
3
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
4
4
|
import { colors } from "./colors";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { FOCUS_RING, CONTROL_RADIUS, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
|
|
6
|
+
import { useFocusRing } from "./use_focus_ring";
|
|
7
|
+
import { useHover } from "./use_hover";
|
|
7
8
|
import { fontFamilyRegular, getInputTextStyle } from "./text_utils";
|
|
8
9
|
import { Icon } from "./icon";
|
|
9
|
-
import { Popover, PopoverTrigger, PopoverContent } from "./popover";
|
|
10
|
-
import { PickerMenu } from "./picker_menu";
|
|
11
10
|
|
|
12
11
|
export interface PickerOption<T extends string = string, D = unknown> {
|
|
13
12
|
label?: string;
|
|
@@ -31,43 +30,30 @@ export type PickerOnClose<T extends string, MULTI extends boolean> = MULTI exten
|
|
|
31
30
|
? (value: T[]) => void
|
|
32
31
|
: (value: T) => void;
|
|
33
32
|
|
|
34
|
-
export interface PickerProps<T extends string = string
|
|
33
|
+
export interface PickerProps<T extends string = string> {
|
|
35
34
|
options?: (PickerOption<T> | undefined | false)[];
|
|
36
35
|
placeholder?: string;
|
|
37
36
|
/** Accessible name for the control. Required in spirit whenever the picker
|
|
38
37
|
* has no visible label next to it (e.g. an unlabeled toolbar filter) — the
|
|
39
38
|
* selected option's text describes the value, not the control. */
|
|
40
39
|
accessibilityLabel?: string;
|
|
41
|
-
renderOptionContent?: (option: PickerOption<T>) => React.ReactNode;
|
|
42
40
|
style?: StyleProp<ViewStyle>;
|
|
43
41
|
testID?: string;
|
|
44
42
|
disabled?: boolean;
|
|
45
43
|
autoFocus?: boolean;
|
|
46
|
-
enableSelectAll?: boolean;
|
|
47
|
-
multi?: MULTI;
|
|
48
44
|
includeEmptyOption?: boolean;
|
|
49
|
-
value?:
|
|
50
|
-
onValueChange?:
|
|
51
|
-
onClose?: PickerOnClose<T, MULTI>;
|
|
52
|
-
/** Label for the "select all" link in multi-select mode. Default: "Select all" */
|
|
53
|
-
selectAllLabel?: string;
|
|
54
|
-
/** Label for the "deselect all" link in multi-select mode. Default: "Deselect all" */
|
|
55
|
-
deselectAllLabel?: string;
|
|
45
|
+
value?: T | null;
|
|
46
|
+
onValueChange?: (value: T) => void;
|
|
56
47
|
}
|
|
57
48
|
|
|
58
49
|
/**
|
|
59
|
-
*
|
|
50
|
+
* A plain, label-only single select rendered as the native `<select>` (the OS
|
|
51
|
+
* dropdown, with native typeahead). The lightest "pick one from a known list"
|
|
52
|
+
* control. For RICH options (badges, member chips), multi-select, select-all, or
|
|
53
|
+
* a chip box (`renderSelected` → `Chip`), use `Select`; to SEARCH a large/remote/free
|
|
54
|
+
* set with the input AS the control use `Combobox`.
|
|
60
55
|
*/
|
|
61
|
-
export function Picker<T extends string
|
|
62
|
-
props: PickerProps<T, MULTI>,
|
|
63
|
-
) {
|
|
64
|
-
if (props.multi || props.renderOptionContent) {
|
|
65
|
-
return <CustomPicker {...props} />;
|
|
66
|
-
}
|
|
67
|
-
return <StandardPicker {...(props as PickerProps<T, false>)} />;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function StandardPicker<T extends string>(props: PickerProps<T, false>) {
|
|
56
|
+
export function Picker<T extends string>(props: PickerProps<T>) {
|
|
71
57
|
const {
|
|
72
58
|
testID,
|
|
73
59
|
options = [],
|
|
@@ -82,6 +68,9 @@ function StandardPicker<T extends string>(props: PickerProps<T, false>) {
|
|
|
82
68
|
} = props;
|
|
83
69
|
|
|
84
70
|
const pickerRef = useRef<RNPicker<string>>(null);
|
|
71
|
+
// The native <select> is a text-like control — ring on any focus (like the inputs).
|
|
72
|
+
const { focusVisible, focusProps } = useFocusRing({ always: true });
|
|
73
|
+
const { hovered, hoverProps } = useHover();
|
|
85
74
|
|
|
86
75
|
useEffect(() => {
|
|
87
76
|
if (autoFocus && !disabled) {
|
|
@@ -89,15 +78,15 @@ function StandardPicker<T extends string>(props: PickerProps<T, false>) {
|
|
|
89
78
|
}
|
|
90
79
|
}, [autoFocus, disabled]);
|
|
91
80
|
|
|
92
|
-
const
|
|
81
|
+
const handleChange = useCallback(
|
|
93
82
|
(newValue: string) => {
|
|
94
|
-
|
|
83
|
+
onValueChange?.(newValue as T);
|
|
95
84
|
},
|
|
96
85
|
[onValueChange],
|
|
97
86
|
);
|
|
98
87
|
|
|
99
88
|
return (
|
|
100
|
-
<View>
|
|
89
|
+
<View {...(hoverProps as object)}>
|
|
101
90
|
<View
|
|
102
91
|
style={{
|
|
103
92
|
position: "absolute",
|
|
@@ -112,12 +101,14 @@ function StandardPicker<T extends string>(props: PickerProps<T, false>) {
|
|
|
112
101
|
ref={pickerRef}
|
|
113
102
|
testID={testID}
|
|
114
103
|
accessibilityLabel={accessibilityLabel}
|
|
104
|
+
onFocus={focusProps.onFocus}
|
|
105
|
+
onBlur={focusProps.onBlur}
|
|
115
106
|
// Empty selection maps to "" (the placeholder option's value), never
|
|
116
107
|
// undefined — otherwise the native <select> goes uncontrolled and a
|
|
117
108
|
// programmatic reset to null leaves the prior DOM selection in place.
|
|
118
109
|
selectedValue={value?.toString() ?? ""}
|
|
119
|
-
onValueChange={
|
|
120
|
-
style={[styles.picker, getInputTextStyle(), style as StyleProp<TextStyle
|
|
110
|
+
onValueChange={handleChange}
|
|
111
|
+
style={[styles.picker, CONTROL_TRANSITION, getInputTextStyle(), style as StyleProp<TextStyle>, hovered && !disabled ? { borderColor: HOVER_BORDER } : null, focusVisible ? { boxShadow: FOCUS_RING } : null]}
|
|
121
112
|
placeholder={placeholder}
|
|
122
113
|
enabled={!disabled}
|
|
123
114
|
>
|
|
@@ -143,168 +134,6 @@ function StandardPicker<T extends string>(props: PickerProps<T, false>) {
|
|
|
143
134
|
);
|
|
144
135
|
}
|
|
145
136
|
|
|
146
|
-
function CustomPicker<T extends string, MULTI extends boolean = false>(
|
|
147
|
-
props: PickerProps<T, MULTI>,
|
|
148
|
-
) {
|
|
149
|
-
const {
|
|
150
|
-
testID,
|
|
151
|
-
options = [],
|
|
152
|
-
value,
|
|
153
|
-
placeholder,
|
|
154
|
-
accessibilityLabel,
|
|
155
|
-
multi = false as MULTI,
|
|
156
|
-
renderOptionContent,
|
|
157
|
-
includeEmptyOption,
|
|
158
|
-
style,
|
|
159
|
-
disabled = false,
|
|
160
|
-
autoFocus = false,
|
|
161
|
-
enableSelectAll = false,
|
|
162
|
-
onValueChange,
|
|
163
|
-
onClose,
|
|
164
|
-
selectAllLabel,
|
|
165
|
-
deselectAllLabel,
|
|
166
|
-
} = props;
|
|
167
|
-
|
|
168
|
-
const [open, setOpen] = useState(autoFocus);
|
|
169
|
-
|
|
170
|
-
const handleToggle = useCallback(() => {
|
|
171
|
-
if (!disabled) {
|
|
172
|
-
setOpen((prev) => !prev);
|
|
173
|
-
}
|
|
174
|
-
}, [disabled]);
|
|
175
|
-
|
|
176
|
-
const selectedItems = useSelectedItems(multi, value, options);
|
|
177
|
-
|
|
178
|
-
const handleClose = useCallback(() => {
|
|
179
|
-
setOpen(false);
|
|
180
|
-
}, []);
|
|
181
|
-
|
|
182
|
-
return (
|
|
183
|
-
<Popover
|
|
184
|
-
open={open && !disabled}
|
|
185
|
-
onOpenChange={setOpen}
|
|
186
|
-
side="bottom"
|
|
187
|
-
align="start"
|
|
188
|
-
inheritTriggerWidth={true}
|
|
189
|
-
>
|
|
190
|
-
<PopoverTrigger>
|
|
191
|
-
<PickerTrigger
|
|
192
|
-
testID={testID}
|
|
193
|
-
open={open}
|
|
194
|
-
style={style}
|
|
195
|
-
onPress={handleToggle}
|
|
196
|
-
renderOptionContent={renderOptionContent}
|
|
197
|
-
selectedItems={selectedItems}
|
|
198
|
-
placeholder={placeholder}
|
|
199
|
-
accessibilityLabel={accessibilityLabel}
|
|
200
|
-
disabled={disabled}
|
|
201
|
-
/>
|
|
202
|
-
</PopoverTrigger>
|
|
203
|
-
<PopoverContent>
|
|
204
|
-
<PickerMenu
|
|
205
|
-
options={options}
|
|
206
|
-
multi={multi}
|
|
207
|
-
value={value}
|
|
208
|
-
onValueChange={onValueChange}
|
|
209
|
-
onClose={onClose}
|
|
210
|
-
onRequestClose={handleClose}
|
|
211
|
-
renderOptionContent={renderOptionContent}
|
|
212
|
-
enableSelectAll={enableSelectAll}
|
|
213
|
-
includeEmptyOption={includeEmptyOption}
|
|
214
|
-
selectAllLabel={selectAllLabel}
|
|
215
|
-
deselectAllLabel={deselectAllLabel}
|
|
216
|
-
/>
|
|
217
|
-
</PopoverContent>
|
|
218
|
-
</Popover>
|
|
219
|
-
);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
function useSelectedItems<T extends string>(
|
|
223
|
-
multi: boolean,
|
|
224
|
-
value: T | T[] | undefined | null,
|
|
225
|
-
options: (PickerOption<T> | undefined | false)[],
|
|
226
|
-
): PickerOption<T>[] {
|
|
227
|
-
return useMemo(() => {
|
|
228
|
-
if (multi) {
|
|
229
|
-
const multiValue = Array.isArray(value) ? value : [];
|
|
230
|
-
const optionsMap = new Map(
|
|
231
|
-
options.filter((opt): opt is PickerOption<T> => !!opt).map((opt) => [opt.value, opt]),
|
|
232
|
-
);
|
|
233
|
-
return multiValue
|
|
234
|
-
.map((val) => optionsMap.get(val))
|
|
235
|
-
.filter((opt): opt is PickerOption<T> => !!opt);
|
|
236
|
-
} else {
|
|
237
|
-
const singleValue = Array.isArray(value) ? value[0] : value;
|
|
238
|
-
if (singleValue !== undefined && singleValue !== null) {
|
|
239
|
-
const selected = options.find((option) => option && option.value === singleValue);
|
|
240
|
-
return selected ? [selected] : [];
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
return [];
|
|
244
|
-
}, [multi, value, options]);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
function PickerTrigger<T extends string>({
|
|
248
|
-
ref,
|
|
249
|
-
testID,
|
|
250
|
-
open,
|
|
251
|
-
style,
|
|
252
|
-
onPress,
|
|
253
|
-
renderOptionContent,
|
|
254
|
-
selectedItems,
|
|
255
|
-
placeholder,
|
|
256
|
-
accessibilityLabel,
|
|
257
|
-
disabled = false,
|
|
258
|
-
}: {
|
|
259
|
-
ref?: React.Ref<View>;
|
|
260
|
-
testID?: string;
|
|
261
|
-
open: boolean;
|
|
262
|
-
style?: StyleProp<ViewStyle>;
|
|
263
|
-
onPress: () => void;
|
|
264
|
-
renderOptionContent?: (option: PickerOption<T>) => React.ReactNode;
|
|
265
|
-
selectedItems: PickerOption<T>[];
|
|
266
|
-
placeholder?: string;
|
|
267
|
-
accessibilityLabel?: string;
|
|
268
|
-
disabled?: boolean;
|
|
269
|
-
}) {
|
|
270
|
-
const hasSelection = selectedItems.length > 0;
|
|
271
|
-
|
|
272
|
-
return (
|
|
273
|
-
<Pressable
|
|
274
|
-
ref={ref}
|
|
275
|
-
testID={testID}
|
|
276
|
-
// Without a role this Pressable renders as an unfocusable <div> on web —
|
|
277
|
-
// the trigger drops out of the tab order and Enter/Space can't open it.
|
|
278
|
-
// `button` makes it tab-focusable and maps keyboard activation to onPress;
|
|
279
|
-
// `expanded` announces open/closed to assistive tech. The accessible name
|
|
280
|
-
// comes from `accessibilityLabel` when given (an unlabeled control), else
|
|
281
|
-
// the visible selection/placeholder text below.
|
|
282
|
-
accessibilityRole="button"
|
|
283
|
-
accessibilityLabel={accessibilityLabel}
|
|
284
|
-
accessibilityState={{ expanded: open, disabled }}
|
|
285
|
-
style={[styles.pressable, open && styles.opened, disabled && styles.disabled, style]}
|
|
286
|
-
onPress={!disabled ? onPress : undefined}
|
|
287
|
-
disabled={disabled}
|
|
288
|
-
>
|
|
289
|
-
{hasSelection ? (
|
|
290
|
-
renderOptionContent ? (
|
|
291
|
-
<View style={{ flexDirection: "row", gap: 4, flexWrap: "wrap" }}>
|
|
292
|
-
{selectedItems.map((item) => (
|
|
293
|
-
<View key={item.value}>{renderOptionContent(item)}</View>
|
|
294
|
-
))}
|
|
295
|
-
</View>
|
|
296
|
-
) : (
|
|
297
|
-
<Text userSelect="none">{selectedItems.map((s) => s.label).join(", ")}</Text>
|
|
298
|
-
)
|
|
299
|
-
) : (
|
|
300
|
-
<Text color="zinc-500" userSelect="none">
|
|
301
|
-
{placeholder}
|
|
302
|
-
</Text>
|
|
303
|
-
)}
|
|
304
|
-
</Pressable>
|
|
305
|
-
);
|
|
306
|
-
}
|
|
307
|
-
|
|
308
137
|
const styles = StyleSheet.create({
|
|
309
138
|
picker: {
|
|
310
139
|
height: 40,
|
|
@@ -318,22 +147,4 @@ const styles = StyleSheet.create({
|
|
|
318
147
|
backgroundColor: "transparent",
|
|
319
148
|
appearance: "none",
|
|
320
149
|
},
|
|
321
|
-
pressable: {
|
|
322
|
-
flexDirection: "row",
|
|
323
|
-
alignItems: "center",
|
|
324
|
-
paddingHorizontal: 6,
|
|
325
|
-
borderWidth: 1,
|
|
326
|
-
borderColor: colors.border,
|
|
327
|
-
borderRadius: CONTROL_RADIUS,
|
|
328
|
-
cursor: "auto",
|
|
329
|
-
height: 40,
|
|
330
|
-
},
|
|
331
|
-
opened: {
|
|
332
|
-
// Mouse-opened, so no `:focus-visible` — wear the same 2px zinc ring a
|
|
333
|
-
// focused input gets, over the unchanged 1px border (not a lone 1px edge).
|
|
334
|
-
boxShadow: ACTIVE_RING,
|
|
335
|
-
},
|
|
336
|
-
disabled: {
|
|
337
|
-
opacity: 0.5,
|
|
338
|
-
},
|
|
339
150
|
});
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
GestureResponderEvent,
|
|
3
3
|
Pressable,
|
|
4
4
|
PressableProps,
|
|
5
|
+
PressableStateCallbackType,
|
|
5
6
|
View,
|
|
6
7
|
StyleProp,
|
|
7
8
|
ViewStyle,
|
|
@@ -9,23 +10,40 @@ import {
|
|
|
9
10
|
import { Ref, useCallback } from "react";
|
|
10
11
|
import { TooltipSide, useTooltip } from "./tooltip";
|
|
11
12
|
import { colors } from "./colors";
|
|
13
|
+
import { FOCUS_RING } from "./control_surface";
|
|
14
|
+
import { composeHandler, useFocusRing } from "./use_focus_ring";
|
|
12
15
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
a(event);
|
|
21
|
-
b(event);
|
|
22
|
-
};
|
|
23
|
-
}
|
|
16
|
+
/** The pressable state, plus the web-only `hovered` flag react-native-web adds and
|
|
17
|
+
* the `focusVisible` flag PressableHighlight injects so a consumer's style-fn /
|
|
18
|
+
* children can paint their own focus treatment. */
|
|
19
|
+
export type PressableHighlightState = PressableStateCallbackType & {
|
|
20
|
+
hovered?: boolean;
|
|
21
|
+
focusVisible?: boolean;
|
|
22
|
+
};
|
|
24
23
|
|
|
25
24
|
export interface PressableHighlightProps extends PressableProps {
|
|
26
25
|
ref?: Ref<View>;
|
|
27
26
|
tooltip?: string;
|
|
28
27
|
tooltipSide?: TooltipSide;
|
|
28
|
+
/**
|
|
29
|
+
* Opt in to the kit `FOCUS_RING` on keyboard focus. Off by default: the focus
|
|
30
|
+
* treatment is the component's to OWN, not applied blindly — a compact control
|
|
31
|
+
* sets `focusRing`, while a control with its own ring (e.g. `CardSelectItem`, which
|
|
32
|
+
* rings on hover/press/focus) instead reads `state.focusVisible` in its `style` fn.
|
|
33
|
+
* EVERY interactive PressableHighlight must do one or the other; a focusable control
|
|
34
|
+
* with no focus treatment is a bug (the per-Pressable focus audit is the coverage
|
|
35
|
+
* gate). A surface that shouldn't ring is a non-control — give it `tabIndex={-1}`
|
|
36
|
+
* (like `PressableRow`), not a missing `focusRing`. The ring is a later `boxShadow`
|
|
37
|
+
* entry, so on a focused control it supersedes any resting box-shadow for the focus
|
|
38
|
+
* duration — read `state.focusVisible` and compose if both must show at once.
|
|
39
|
+
*/
|
|
40
|
+
focusRing?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Narrows the Pressable state-function `style` to `PressableHighlightState`, so a
|
|
43
|
+
* consumer's `style` fn sees `focusVisible` (and `hovered`) and can paint its own
|
|
44
|
+
* focus/hover treatment — the `state.focusVisible` contract referenced above.
|
|
45
|
+
*/
|
|
46
|
+
style?: StyleProp<ViewStyle> | ((state: PressableHighlightState) => StyleProp<ViewStyle>);
|
|
29
47
|
/**
|
|
30
48
|
* Web-only keyboard handler. `react-native-web`'s `Pressable` forwards this
|
|
31
49
|
* to the rendered DOM element; native platforms ignore it. Exposed
|
|
@@ -56,12 +74,14 @@ export function PressableHighlight(props: PressableHighlightProps) {
|
|
|
56
74
|
tooltipSide = "top",
|
|
57
75
|
onPress,
|
|
58
76
|
userSelect,
|
|
77
|
+
focusRing,
|
|
59
78
|
...restPressableProps
|
|
60
79
|
} = props;
|
|
61
80
|
const tooltipProps = useTooltip({
|
|
62
81
|
side: tooltipSide,
|
|
63
82
|
text: tooltip,
|
|
64
83
|
});
|
|
84
|
+
const { focusVisible, focusProps } = useFocusRing();
|
|
65
85
|
|
|
66
86
|
const handlePress = useCallback(
|
|
67
87
|
(event: GestureResponderEvent) => {
|
|
@@ -74,10 +94,8 @@ export function PressableHighlight(props: PressableHighlightProps) {
|
|
|
74
94
|
<Pressable
|
|
75
95
|
ref={ref}
|
|
76
96
|
disabled={disabled}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const { pressed } = state;
|
|
80
|
-
const hovered = (state as { hovered?: boolean }).hovered;
|
|
97
|
+
style={(state: PressableHighlightState) => {
|
|
98
|
+
const { pressed, hovered } = state;
|
|
81
99
|
return [
|
|
82
100
|
{
|
|
83
101
|
...({ touchAction: "manipulation", cursor: disabled ? "auto" : "pointer", transitionDuration: "0.1s", transitionProperty: "background-color", userSelect } as ViewStyle),
|
|
@@ -85,22 +103,23 @@ export function PressableHighlight(props: PressableHighlightProps) {
|
|
|
85
103
|
{
|
|
86
104
|
backgroundColor: pressed ? colors.zinc["200"] : hovered ? colors.zinc["100"] : null,
|
|
87
105
|
},
|
|
88
|
-
typeof style === "function" ? style(state) : style,
|
|
106
|
+
typeof style === "function" ? style({ ...state, focusVisible }) : style,
|
|
107
|
+
focusRing && focusVisible ? { boxShadow: FOCUS_RING } : null,
|
|
89
108
|
] as StyleProp<ViewStyle>;
|
|
90
109
|
}}
|
|
91
110
|
onPress={handlePress}
|
|
92
111
|
{...restPressableProps}
|
|
93
112
|
// `onFocus` / `onBlur` are part of `PressableProps`, so a trailing
|
|
94
113
|
// `{...tooltipProps}` spread would silently overwrite caller-provided
|
|
95
|
-
// handlers. Compose instead
|
|
96
|
-
// passed through on web) come along
|
|
114
|
+
// handlers. Compose instead — caller + tooltip + the focus-ring tracker.
|
|
115
|
+
// Mouse handlers (not in the RN types but passed through on web) come along
|
|
116
|
+
// with the tooltipProps spread.
|
|
97
117
|
{...tooltipProps}
|
|
98
|
-
onFocus={composeHandler(restPressableProps.onFocus ?? undefined, tooltipProps.onFocus)}
|
|
99
|
-
onBlur={composeHandler(restPressableProps.onBlur ?? undefined, tooltipProps.onBlur)}
|
|
118
|
+
onFocus={composeHandler(composeHandler(restPressableProps.onFocus ?? undefined, tooltipProps.onFocus), focusProps.onFocus)}
|
|
119
|
+
onBlur={composeHandler(composeHandler(restPressableProps.onBlur ?? undefined, tooltipProps.onBlur), focusProps.onBlur)}
|
|
100
120
|
>
|
|
101
|
-
{(state) => {
|
|
102
|
-
|
|
103
|
-
return <>{typeof children === "function" ? children({ ...state, hovered } as typeof state) : children}</>;
|
|
121
|
+
{(state: PressableHighlightState) => {
|
|
122
|
+
return <>{typeof children === "function" ? children({ ...state, focusVisible } as PressableStateCallbackType) : children}</>;
|
|
104
123
|
}}
|
|
105
124
|
</Pressable>
|
|
106
125
|
);
|
package/src/radio_picker.tsx
CHANGED