@lotics/ui 5.9.0 → 5.10.1
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 +131 -44
- package/examples/tpl_approvals.tsx +3 -3
- package/examples/tpl_dossier.tsx +5 -4
- package/examples/tpl_pipeline.tsx +400 -0
- package/examples/tpl_record.tsx +5 -4
- package/examples/tpl_shifts.tsx +7 -5
- package/examples/tpl_task_board.tsx +473 -0
- package/examples/tpl_tasks.tsx +456 -0
- package/package.json +10 -5
- 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 +43 -42
- package/src/file_preview.web.tsx +25 -11
- package/src/file_preview_types.ts +9 -0
- package/src/file_row.tsx +5 -1
- package/src/file_rows.tsx +6 -2
- package/src/file_thumbnail.tsx +13 -4
- package/src/file_thumbnail_grid.tsx +5 -0
- 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 +2 -2
- package/src/image_gallery.tsx +8 -10
- 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/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/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/picker_menu.tsx +0 -355
- package/src/tag_input.tsx +0 -203
- package/src/time_field.tsx +0 -297
package/src/inline_edit.tsx
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { useCallback, useRef, useState, type ReactNode, type Ref } from "react";
|
|
2
|
-
import { View, StyleSheet } from "react-native";
|
|
2
|
+
import { View, StyleSheet, type GestureResponderEvent } from "react-native";
|
|
3
3
|
import { Text } from "./text";
|
|
4
4
|
import { IconButton } from "./icon_button";
|
|
5
5
|
import { ActivityIndicator } from "./activity_indicator";
|
|
6
6
|
import { PressableHighlight } from "./pressable_highlight";
|
|
7
7
|
import { colors } from "./colors";
|
|
8
|
-
import {
|
|
8
|
+
import { FOCUS_RING, CONTROL_RADIUS } from "./control_surface";
|
|
9
9
|
import { fontFamilyRegular, getInputTextStyle } from "./text_utils";
|
|
10
10
|
|
|
11
11
|
/** The kit's standard control height (TextInputField, NumberInput, Picker, …).
|
|
@@ -99,6 +99,8 @@ interface InlineEditFrameProps {
|
|
|
99
99
|
disabled?: boolean;
|
|
100
100
|
/** Accessible name for the view button when there's no enclosing FormField. */
|
|
101
101
|
accessibilityLabel?: string;
|
|
102
|
+
/** Strike + mute the resting value (a completed item that stays editable). */
|
|
103
|
+
struck?: boolean;
|
|
102
104
|
}
|
|
103
105
|
|
|
104
106
|
interface InlineEditViewProps {
|
|
@@ -115,6 +117,8 @@ interface InlineEditViewProps {
|
|
|
115
117
|
/** True while the field's popover (select/date) is open: wears the 2px active
|
|
116
118
|
* ring so a mouse-opened trigger reads like a focused input (no `:focus-visible`). */
|
|
117
119
|
active?: boolean;
|
|
120
|
+
/** Strike + mute the resting value (a completed/superseded item that stays editable). */
|
|
121
|
+
struck?: boolean;
|
|
118
122
|
ref?: Ref<View>;
|
|
119
123
|
}
|
|
120
124
|
|
|
@@ -127,19 +131,28 @@ interface InlineEditViewProps {
|
|
|
127
131
|
* select/date inline editors (it forwards ref + onPress to a `PopoverTrigger`).
|
|
128
132
|
*/
|
|
129
133
|
export function InlineEditView(props: InlineEditViewProps) {
|
|
130
|
-
const { display, placeholder, onPress, disabled, accessibilityLabel, trailing, active, ref } = props;
|
|
134
|
+
const { display, placeholder, onPress, disabled, accessibilityLabel, trailing, active, struck, ref } = props;
|
|
135
|
+
// Stop the press here so an inline editor nested in a pressable row (a task
|
|
136
|
+
// row that expands on press) edits the field instead of triggering the row.
|
|
137
|
+
const handlePress = onPress
|
|
138
|
+
? (e: GestureResponderEvent) => {
|
|
139
|
+
e?.stopPropagation?.();
|
|
140
|
+
onPress();
|
|
141
|
+
}
|
|
142
|
+
: undefined;
|
|
131
143
|
return (
|
|
132
144
|
<PressableHighlight
|
|
145
|
+
focusRing
|
|
133
146
|
ref={ref}
|
|
134
147
|
disabled={disabled}
|
|
135
|
-
onPress={
|
|
148
|
+
onPress={handlePress}
|
|
136
149
|
accessibilityRole="button"
|
|
137
150
|
accessibilityLabel={accessibilityLabel}
|
|
138
151
|
userSelect="none"
|
|
139
152
|
style={[styles.view, active && styles.viewActive]}
|
|
140
153
|
>
|
|
141
154
|
{typeof display === "string" ? (
|
|
142
|
-
<Text numberOfLines={1} style={[viewTextStyle, display ? null : styles.placeholder]}>
|
|
155
|
+
<Text numberOfLines={1} style={[viewTextStyle, display ? null : styles.placeholder, struck ? styles.struck : null]}>
|
|
143
156
|
{display || placeholder || "—"}
|
|
144
157
|
</Text>
|
|
145
158
|
) : (
|
|
@@ -171,6 +184,7 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
|
|
|
171
184
|
error,
|
|
172
185
|
disabled,
|
|
173
186
|
accessibilityLabel,
|
|
187
|
+
struck,
|
|
174
188
|
} = props;
|
|
175
189
|
|
|
176
190
|
if (!editing) {
|
|
@@ -181,6 +195,7 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
|
|
|
181
195
|
onPress={onBegin}
|
|
182
196
|
disabled={disabled}
|
|
183
197
|
accessibilityLabel={accessibilityLabel}
|
|
198
|
+
struck={struck}
|
|
184
199
|
/>
|
|
185
200
|
);
|
|
186
201
|
}
|
|
@@ -233,9 +248,10 @@ const styles = StyleSheet.create({
|
|
|
233
248
|
// under the 2px active ring — identical to a focused input.
|
|
234
249
|
viewActive: {
|
|
235
250
|
borderColor: colors.border,
|
|
236
|
-
boxShadow:
|
|
251
|
+
boxShadow: FOCUS_RING,
|
|
237
252
|
},
|
|
238
253
|
placeholder: { color: colors.zinc[400] },
|
|
254
|
+
struck: { textDecorationLine: "line-through", color: colors.zinc[500] },
|
|
239
255
|
viewNode: { flex: 1, minWidth: 0 },
|
|
240
256
|
editRow: { flexDirection: "row", alignItems: "flex-start", gap: 6 },
|
|
241
257
|
editControl: { flex: 1, position: "relative" },
|
package/src/inline_select.tsx
CHANGED
|
@@ -4,7 +4,7 @@ import { Icon } from "./icon";
|
|
|
4
4
|
import { Text } from "./text";
|
|
5
5
|
import { colors } from "./colors";
|
|
6
6
|
import { Popover, PopoverTrigger, PopoverContent } from "./popover";
|
|
7
|
-
import {
|
|
7
|
+
import { OptionList } from "./option_list";
|
|
8
8
|
import type { PickerOption } from "./picker";
|
|
9
9
|
import { ActivityIndicator } from "./activity_indicator";
|
|
10
10
|
import { InlineEditView } from "./inline_edit";
|
|
@@ -14,12 +14,12 @@ export interface InlineSelectProps<T extends string> {
|
|
|
14
14
|
onSave: (next: T) => void | Promise<void>;
|
|
15
15
|
options: PickerOption<T>[];
|
|
16
16
|
/** Custom option content in the dropdown (icon + label, two-line, a badge…).
|
|
17
|
-
* Omit for a plain label list — both render through the same `
|
|
17
|
+
* Omit for a plain label list — both render through the same `OptionList`. */
|
|
18
18
|
renderOptionContent?: (option: PickerOption<T>) => React.ReactNode;
|
|
19
19
|
/** Render the SELECTED value in the resting view as a node (an avatar chip, a
|
|
20
20
|
* colored badge) instead of its plain label. Falls back to the label when
|
|
21
21
|
* omitted; the empty state always shows the placeholder. */
|
|
22
|
-
|
|
22
|
+
renderSelected?: (selected: PickerOption<T>) => React.ReactNode;
|
|
23
23
|
placeholder?: string;
|
|
24
24
|
disabled?: boolean;
|
|
25
25
|
accessibilityLabel?: string;
|
|
@@ -27,13 +27,13 @@ export interface InlineSelectProps<T extends string> {
|
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
29
|
* An inline-editable single-select. The value reads as plain text; clicking it
|
|
30
|
-
* floats the option list (
|
|
30
|
+
* floats the option list (an `OptionList`) in a popover anchored to the view, so
|
|
31
31
|
* the row never changes height. Picking commits; dismissing reverts. Pass
|
|
32
32
|
* `renderOptionContent` for rich options, or omit it for a plain list — the
|
|
33
33
|
* standard and custom-rendered pickers are both supported here.
|
|
34
34
|
*/
|
|
35
35
|
export function InlineSelect<T extends string>(props: InlineSelectProps<T>) {
|
|
36
|
-
const { value, onSave, options, renderOptionContent,
|
|
36
|
+
const { value, onSave, options, renderOptionContent, renderSelected, placeholder, disabled, accessibilityLabel } = props;
|
|
37
37
|
const [open, setOpen] = useState(false);
|
|
38
38
|
const [saving, setSaving] = useState(false);
|
|
39
39
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -62,7 +62,7 @@ export function InlineSelect<T extends string>(props: InlineSelectProps<T>) {
|
|
|
62
62
|
<Popover open={open && !disabled} onOpenChange={setOpen} side="bottom" align="start" inheritTriggerWidth>
|
|
63
63
|
<PopoverTrigger>
|
|
64
64
|
<InlineEditView
|
|
65
|
-
display={selected ? (
|
|
65
|
+
display={selected ? (renderSelected ? renderSelected(selected) : (selected.label ?? "")) : ""}
|
|
66
66
|
placeholder={placeholder}
|
|
67
67
|
disabled={disabled}
|
|
68
68
|
active={open && !disabled}
|
|
@@ -76,8 +76,9 @@ export function InlineSelect<T extends string>(props: InlineSelectProps<T>) {
|
|
|
76
76
|
}
|
|
77
77
|
/>
|
|
78
78
|
</PopoverTrigger>
|
|
79
|
-
<PopoverContent>
|
|
80
|
-
<
|
|
79
|
+
<PopoverContent disableBodyScroll>
|
|
80
|
+
<OptionList
|
|
81
|
+
search={{ mode: "none" }}
|
|
81
82
|
options={options}
|
|
82
83
|
value={value}
|
|
83
84
|
onValueChange={(next) => void pick(next)}
|
|
@@ -13,6 +13,8 @@ export interface InlineTextInputProps {
|
|
|
13
13
|
* an explicit ✓ saves and ✕ reverts. */
|
|
14
14
|
controls?: InlineEditControls;
|
|
15
15
|
disabled?: boolean;
|
|
16
|
+
/** Strike + mute the resting value (a completed item that stays editable). */
|
|
17
|
+
struck?: boolean;
|
|
16
18
|
accessibilityLabel?: string;
|
|
17
19
|
}
|
|
18
20
|
|
|
@@ -23,7 +25,7 @@ export interface InlineTextInputProps {
|
|
|
23
25
|
* value in a dense record / detail surface.
|
|
24
26
|
*/
|
|
25
27
|
export function InlineTextInput(props: InlineTextInputProps) {
|
|
26
|
-
const { value, onSave, placeholder, controls = "blur", disabled, accessibilityLabel } = props;
|
|
28
|
+
const { value, onSave, placeholder, controls = "blur", disabled, struck, accessibilityLabel } = props;
|
|
27
29
|
const edit = useInlineEdit<string>({ value, onSave });
|
|
28
30
|
|
|
29
31
|
const onKeyPress = useCallback(
|
|
@@ -55,6 +57,7 @@ export function InlineTextInput(props: InlineTextInputProps) {
|
|
|
55
57
|
saving={edit.saving}
|
|
56
58
|
error={edit.error}
|
|
57
59
|
disabled={disabled}
|
|
60
|
+
struck={struck}
|
|
58
61
|
accessibilityLabel={accessibilityLabel}
|
|
59
62
|
>
|
|
60
63
|
<TextInputField
|
package/src/link.tsx
CHANGED
|
@@ -2,6 +2,8 @@ import { type ReactNode } from "react";
|
|
|
2
2
|
import { Pressable } from "react-native";
|
|
3
3
|
import { Text, type TextSize } from "./text";
|
|
4
4
|
import { colors } from "./colors";
|
|
5
|
+
import { FOCUS_RING } from "./control_surface";
|
|
6
|
+
import { useFocusRing } from "./use_focus_ring";
|
|
5
7
|
|
|
6
8
|
export interface LinkProps {
|
|
7
9
|
children: ReactNode;
|
|
@@ -22,8 +24,16 @@ export interface LinkProps {
|
|
|
22
24
|
* `onPress` to its opener.
|
|
23
25
|
*/
|
|
24
26
|
export function Link({ children, onPress, size = "sm", accessibilityLabel, testID }: LinkProps) {
|
|
27
|
+
const { focusVisible, focusProps } = useFocusRing();
|
|
25
28
|
return (
|
|
26
|
-
<Pressable
|
|
29
|
+
<Pressable
|
|
30
|
+
onPress={onPress}
|
|
31
|
+
{...focusProps}
|
|
32
|
+
accessibilityRole="link"
|
|
33
|
+
accessibilityLabel={accessibilityLabel}
|
|
34
|
+
testID={testID}
|
|
35
|
+
style={focusVisible ? { boxShadow: FOCUS_RING, borderRadius: 2 } : undefined}
|
|
36
|
+
>
|
|
27
37
|
<Text size={size} decoration="underline" style={{ color: colors.blue[600] }}>
|
|
28
38
|
{children}
|
|
29
39
|
</Text>
|
package/src/link_button.tsx
CHANGED
|
@@ -23,7 +23,7 @@ export function LinkButton(props: LinkButtonProps) {
|
|
|
23
23
|
const { title, onPress, accessibilityLabel, testID } = props;
|
|
24
24
|
return (
|
|
25
25
|
<PressableHighlight
|
|
26
|
-
testID={testID}
|
|
26
|
+
focusRing testID={testID}
|
|
27
27
|
onPress={onPress}
|
|
28
28
|
accessibilityRole="button"
|
|
29
29
|
accessibilityLabel={accessibilityLabel ?? title}
|
package/src/list_item.tsx
CHANGED
package/src/member_select.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React, { useCallback, useMemo } from "react";
|
|
2
2
|
import { StyleProp, ViewStyle } from "react-native";
|
|
3
|
+
import { Select } from "./select";
|
|
3
4
|
import {
|
|
4
|
-
Picker,
|
|
5
5
|
type PickerOption,
|
|
6
6
|
type PickerValue,
|
|
7
7
|
type PickerOnValueChange,
|
|
@@ -33,15 +33,20 @@ interface MemberSelectProps<MULTI extends boolean = false> {
|
|
|
33
33
|
disabled?: boolean;
|
|
34
34
|
autoFocus?: boolean;
|
|
35
35
|
includeEmptyOption?: boolean;
|
|
36
|
+
enableSelectAll?: boolean;
|
|
37
|
+
/** Label for the "select all" link in multi-select mode. Pass a translated string. */
|
|
38
|
+
selectAllLabel?: string;
|
|
39
|
+
/** Label for the "deselect all" link in multi-select mode. Pass a translated string. */
|
|
40
|
+
deselectAllLabel?: string;
|
|
36
41
|
style?: StyleProp<ViewStyle>;
|
|
37
42
|
testID?: string;
|
|
38
43
|
accessibilityLabel?: string;
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
/**
|
|
42
|
-
* Choose member(s) — a {@link
|
|
47
|
+
* Choose member(s) — a {@link Select} whose every option renders as a
|
|
43
48
|
* {@link MemberChip} (avatar + name). The one member-picker shape, so an app
|
|
44
|
-
* never re-wires `
|
|
49
|
+
* never re-wires `Select` + `renderOptionContent` + a directory by hand. PURE:
|
|
45
50
|
* pass the candidate `members` (an app feeds `useMembers`, the product any
|
|
46
51
|
* roster); this fetches nothing. Single or multi via `multi`. Options are
|
|
47
52
|
* single-line by design — a picker row identifies a person by name + avatar;
|
|
@@ -72,7 +77,7 @@ export function MemberSelect<MULTI extends boolean = false>(props: MemberSelectP
|
|
|
72
77
|
);
|
|
73
78
|
|
|
74
79
|
return (
|
|
75
|
-
<
|
|
80
|
+
<Select<string, MULTI>
|
|
76
81
|
options={options}
|
|
77
82
|
renderOptionContent={renderOptionContent}
|
|
78
83
|
{...picker}
|
package/src/menu_button.tsx
CHANGED
package/src/menu_list_item.tsx
CHANGED
package/src/number_input.tsx
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { KeyboardEvent } from "react";
|
|
2
2
|
import { colors } from "./colors";
|
|
3
|
-
import { CONTROL_RADIUS } from "./control_surface";
|
|
3
|
+
import { CONTROL_RADIUS, FOCUS_RING, HOVER_BORDER } from "./control_surface";
|
|
4
4
|
import { fontFamilyRegular, inputTextStyleWeb } from "./text_utils";
|
|
5
5
|
import { useFormField } from "./form_field";
|
|
6
|
+
import { useFocusRing } from "./use_focus_ring";
|
|
7
|
+
import { useHover } from "./use_hover";
|
|
6
8
|
|
|
7
9
|
export interface NumberInputProps {
|
|
8
10
|
value: number | null;
|
|
@@ -27,6 +29,8 @@ export function NumberInput(props: NumberInputProps) {
|
|
|
27
29
|
const { value, onValueChange, min, max, disabled, onBlur, onKeyDown, autoFocus, testID, accessibilityLabel } = props;
|
|
28
30
|
const binding = useFormField();
|
|
29
31
|
const describedBy = [binding?.descriptionId, binding?.errorId].filter(Boolean).join(" ") || undefined;
|
|
32
|
+
const { focusVisible, focusProps } = useFocusRing({ always: true });
|
|
33
|
+
const { hovered, hoverProps } = useHover();
|
|
30
34
|
|
|
31
35
|
return (
|
|
32
36
|
<input
|
|
@@ -44,7 +48,9 @@ export function NumberInput(props: NumberInputProps) {
|
|
|
44
48
|
max={max}
|
|
45
49
|
type="number"
|
|
46
50
|
disabled={disabled}
|
|
47
|
-
|
|
51
|
+
{...hoverProps}
|
|
52
|
+
onFocus={focusProps.onFocus}
|
|
53
|
+
onBlur={() => { focusProps.onBlur(); onBlur?.(); }}
|
|
48
54
|
onKeyDown={onKeyDown}
|
|
49
55
|
autoFocus={autoFocus}
|
|
50
56
|
style={{
|
|
@@ -54,14 +60,15 @@ export function NumberInput(props: NumberInputProps) {
|
|
|
54
60
|
borderRadius: CONTROL_RADIUS,
|
|
55
61
|
borderWidth: 1,
|
|
56
62
|
borderStyle: "solid",
|
|
57
|
-
borderColor: colors.border,
|
|
63
|
+
borderColor: hovered && !disabled ? HOVER_BORDER : colors.border,
|
|
58
64
|
backgroundColor: colors.background,
|
|
59
65
|
fontFamily: fontFamilyRegular,
|
|
60
66
|
...inputTextStyleWeb,
|
|
61
67
|
letterSpacing: -0.4,
|
|
62
|
-
boxShadow: "none",
|
|
68
|
+
boxShadow: focusVisible ? FOCUS_RING : "none",
|
|
63
69
|
outline: "none",
|
|
64
70
|
boxSizing: "border-box",
|
|
71
|
+
transition: "border-color 0.12s, box-shadow 0.12s",
|
|
65
72
|
}}
|
|
66
73
|
/>
|
|
67
74
|
);
|
|
@@ -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
|
+
});
|
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.
|