@lotics/ui 3.6.0 → 4.1.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 +352 -0
- package/examples/app_orders.tsx +405 -0
- package/examples/tpl_allocate.tsx +120 -0
- package/examples/tpl_approvals.tsx +375 -0
- package/examples/tpl_attendance.tsx +355 -0
- package/examples/tpl_batch.tsx +234 -0
- package/examples/tpl_billing.tsx +344 -0
- package/examples/tpl_calendar.tsx +288 -0
- package/examples/tpl_callsheet.tsx +481 -0
- package/examples/tpl_convert.tsx +490 -0
- package/examples/tpl_crm_desk.tsx +541 -0
- package/examples/tpl_dashboard.tsx +554 -0
- package/examples/tpl_detail.tsx +232 -0
- package/examples/tpl_directory.tsx +263 -0
- package/examples/tpl_dispatch.tsx +289 -0
- package/examples/tpl_dossier.tsx +431 -0
- package/examples/tpl_intake.tsx +206 -0
- package/examples/tpl_inventory.tsx +299 -0
- package/examples/tpl_order.tsx +483 -0
- package/examples/tpl_pick.tsx +240 -0
- package/examples/tpl_quick.tsx +210 -0
- package/examples/tpl_reconcile.tsx +275 -0
- package/examples/tpl_record.tsx +301 -0
- package/examples/tpl_record_plain.tsx +154 -0
- package/examples/tpl_rollup.tsx +300 -0
- package/examples/tpl_run.tsx +235 -0
- package/examples/tpl_settings.tsx +178 -0
- package/examples/tpl_shifts.tsx +421 -0
- package/examples/tpl_stock.tsx +387 -0
- package/examples/tpl_timeline.tsx +244 -0
- package/examples/tpl_tower.tsx +356 -0
- package/examples/tpl_wizard.tsx +223 -0
- package/package.json +12 -2
- package/src/bar_chart.tsx +5 -0
- package/src/combobox.tsx +33 -8
- package/src/control_surface.ts +8 -0
- package/src/form_date_picker.tsx +2 -0
- package/src/form_picker.tsx +1 -0
- package/src/form_switch.tsx +1 -0
- package/src/form_text_input.tsx +2 -0
- package/src/icon.tsx +2 -0
- package/src/icon_button.tsx +5 -2
- package/src/index.css +6 -3
- package/src/inline_date_picker.tsx +111 -0
- package/src/inline_edit.tsx +238 -0
- package/src/inline_number_input.tsx +70 -0
- package/src/inline_select.tsx +92 -0
- package/src/inline_text_input.tsx +71 -0
- package/src/inline_time_picker.tsx +64 -0
- package/src/line_chart.tsx +4 -0
- package/src/link.tsx +32 -0
- package/src/list_item.tsx +5 -0
- package/src/number_input.tsx +12 -1
- package/src/page_content.tsx +5 -0
- package/src/picker.tsx +4 -1
- package/src/popover.tsx +10 -1
- package/src/pressable_row.tsx +4 -1
- package/src/radio_picker.tsx +3 -1
- package/src/section_heading.tsx +43 -29
- package/src/segmented_control.tsx +3 -2
- package/src/tabs.tsx +4 -2
- package/src/tag_input.tsx +202 -0
- package/src/text.tsx +1 -1
- package/src/time_picker.tsx +15 -3
- package/src/tooltip.tsx +19 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import { Icon } from "./icon";
|
|
4
|
+
import { Text } from "./text";
|
|
5
|
+
import { colors } from "./colors";
|
|
6
|
+
import { Popover, PopoverTrigger, PopoverContent } from "./popover";
|
|
7
|
+
import { PickerMenu } from "./picker_menu";
|
|
8
|
+
import type { PickerOption } from "./picker";
|
|
9
|
+
import { ActivityIndicator } from "./activity_indicator";
|
|
10
|
+
import { InlineEditView } from "./inline_edit";
|
|
11
|
+
|
|
12
|
+
export interface InlineSelectProps<T extends string> {
|
|
13
|
+
value: T | null;
|
|
14
|
+
onSave: (next: T) => void | Promise<void>;
|
|
15
|
+
options: PickerOption<T>[];
|
|
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 `PickerMenu`. */
|
|
18
|
+
renderOptionContent?: (option: PickerOption<T>) => React.ReactNode;
|
|
19
|
+
placeholder?: string;
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
accessibilityLabel?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* An inline-editable single-select. The value reads as plain text; clicking it
|
|
26
|
+
* floats the option list (a `PickerMenu`) in a popover anchored to the view, so
|
|
27
|
+
* the row never changes height. Picking commits; dismissing reverts. Pass
|
|
28
|
+
* `renderOptionContent` for rich options, or omit it for a plain list — the
|
|
29
|
+
* standard and custom-rendered pickers are both supported here.
|
|
30
|
+
*/
|
|
31
|
+
export function InlineSelect<T extends string>(props: InlineSelectProps<T>) {
|
|
32
|
+
const { value, onSave, options, renderOptionContent, placeholder, disabled, accessibilityLabel } = props;
|
|
33
|
+
const [open, setOpen] = useState(false);
|
|
34
|
+
const [saving, setSaving] = useState(false);
|
|
35
|
+
const [error, setError] = useState<string | null>(null);
|
|
36
|
+
|
|
37
|
+
const selected = options.find((o) => o.value === value);
|
|
38
|
+
|
|
39
|
+
const pick = useCallback(
|
|
40
|
+
async (next: T) => {
|
|
41
|
+
setOpen(false);
|
|
42
|
+
if (next === value) return;
|
|
43
|
+
setSaving(true);
|
|
44
|
+
setError(null);
|
|
45
|
+
try {
|
|
46
|
+
await onSave(next);
|
|
47
|
+
} catch (e) {
|
|
48
|
+
setError(e instanceof Error && e.message ? e.message : "Couldn't save. Try again.");
|
|
49
|
+
} finally {
|
|
50
|
+
setSaving(false);
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
[value, onSave],
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<View>
|
|
58
|
+
<Popover open={open && !disabled} onOpenChange={setOpen} side="bottom" align="start" inheritTriggerWidth>
|
|
59
|
+
<PopoverTrigger>
|
|
60
|
+
<InlineEditView
|
|
61
|
+
display={selected?.label ?? ""}
|
|
62
|
+
placeholder={placeholder}
|
|
63
|
+
disabled={disabled}
|
|
64
|
+
active={open && !disabled}
|
|
65
|
+
accessibilityLabel={accessibilityLabel}
|
|
66
|
+
trailing={
|
|
67
|
+
saving ? (
|
|
68
|
+
<ActivityIndicator size={16} color={colors.zinc[400]} />
|
|
69
|
+
) : (
|
|
70
|
+
<Icon name="chevron-down" size={18} color={colors.zinc[400]} />
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
/>
|
|
74
|
+
</PopoverTrigger>
|
|
75
|
+
<PopoverContent>
|
|
76
|
+
<PickerMenu
|
|
77
|
+
options={options}
|
|
78
|
+
value={value}
|
|
79
|
+
onValueChange={(next) => void pick(next)}
|
|
80
|
+
onRequestClose={() => setOpen(false)}
|
|
81
|
+
renderOptionContent={renderOptionContent}
|
|
82
|
+
/>
|
|
83
|
+
</PopoverContent>
|
|
84
|
+
</Popover>
|
|
85
|
+
{error ? (
|
|
86
|
+
<Text size="xs" color="danger" style={{ marginTop: 4 }}>
|
|
87
|
+
{error}
|
|
88
|
+
</Text>
|
|
89
|
+
) : null}
|
|
90
|
+
</View>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import type { NativeSyntheticEvent, TextInputKeyPressEventData } from "react-native";
|
|
3
|
+
import { TextInputField } from "./text_input_field";
|
|
4
|
+
import { InlineEditFrame, useInlineEdit, type InlineEditControls } from "./inline_edit";
|
|
5
|
+
|
|
6
|
+
export interface InlineTextInputProps {
|
|
7
|
+
value: string;
|
|
8
|
+
/** Persist the new value. May be async — the field shows a saving state and
|
|
9
|
+
* surfaces a thrown error inline, staying in edit mode so nothing is lost. */
|
|
10
|
+
onSave: (next: string) => void | Promise<void>;
|
|
11
|
+
placeholder?: string;
|
|
12
|
+
/** "blur" (default): clicking away or Enter saves; Escape reverts. "buttons":
|
|
13
|
+
* an explicit ✓ saves and ✕ reverts. */
|
|
14
|
+
controls?: InlineEditControls;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
accessibilityLabel?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* An inline-editable single-line text value: reads as plain text, reveals an
|
|
21
|
+
* edit affordance on hover, and swaps in-place to a text input on click — at the
|
|
22
|
+
* same height, so the form never reflows. The preferred control for editing a
|
|
23
|
+
* value in a dense record / detail surface.
|
|
24
|
+
*/
|
|
25
|
+
export function InlineTextInput(props: InlineTextInputProps) {
|
|
26
|
+
const { value, onSave, placeholder, controls = "blur", disabled, accessibilityLabel } = props;
|
|
27
|
+
const edit = useInlineEdit<string>({ value, onSave });
|
|
28
|
+
|
|
29
|
+
const onKeyPress = useCallback(
|
|
30
|
+
(e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
|
|
31
|
+
const key = e.nativeEvent.key;
|
|
32
|
+
if (key === "Escape") edit.cancel();
|
|
33
|
+
else if (key === "Enter") void edit.commit();
|
|
34
|
+
},
|
|
35
|
+
[edit],
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
// Commit on blur (the ergonomic default for moving through a form). In
|
|
39
|
+
// "buttons" mode the ✓/✕ own the exit, so a stray blur is ignored. The hook's
|
|
40
|
+
// `active` guard makes the trailing blur after Enter/Escape a no-op.
|
|
41
|
+
const onBlur = useCallback(() => {
|
|
42
|
+
if (controls === "buttons") return;
|
|
43
|
+
void edit.commit();
|
|
44
|
+
}, [controls, edit]);
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<InlineEditFrame
|
|
48
|
+
editing={edit.editing}
|
|
49
|
+
display={value}
|
|
50
|
+
placeholder={placeholder}
|
|
51
|
+
onBegin={edit.begin}
|
|
52
|
+
controls={controls}
|
|
53
|
+
onCommit={() => void edit.commit()}
|
|
54
|
+
onCancel={edit.cancel}
|
|
55
|
+
saving={edit.saving}
|
|
56
|
+
error={edit.error}
|
|
57
|
+
disabled={disabled}
|
|
58
|
+
accessibilityLabel={accessibilityLabel}
|
|
59
|
+
>
|
|
60
|
+
<TextInputField
|
|
61
|
+
value={edit.draft}
|
|
62
|
+
onChangeText={edit.setDraft}
|
|
63
|
+
onBlur={onBlur}
|
|
64
|
+
onKeyPress={onKeyPress}
|
|
65
|
+
autoFocus
|
|
66
|
+
placeholder={placeholder}
|
|
67
|
+
accessibilityLabel={accessibilityLabel}
|
|
68
|
+
/>
|
|
69
|
+
</InlineEditFrame>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import type { KeyboardEvent } from "react";
|
|
3
|
+
import { TimePicker } from "./time_picker";
|
|
4
|
+
import { InlineEditFrame, useInlineEdit, type InlineEditControls } from "./inline_edit";
|
|
5
|
+
|
|
6
|
+
export interface InlineTimePickerProps {
|
|
7
|
+
/** Canonical 24-hour "HH:mm", "" when empty. */
|
|
8
|
+
value: string;
|
|
9
|
+
onSave: (next: string) => void | Promise<void>;
|
|
10
|
+
placeholder?: string;
|
|
11
|
+
/** "blur" (default): clicking away or Enter saves; Escape reverts. "buttons":
|
|
12
|
+
* an explicit ✓ saves and ✕ reverts. */
|
|
13
|
+
controls?: InlineEditControls;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
accessibilityLabel?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* An inline-editable time of day — the `InlineTextInput` pattern over
|
|
20
|
+
* `TimePicker`. The value reads as plain text; clicking swaps in the native time
|
|
21
|
+
* field at the same height, so the form never reflows.
|
|
22
|
+
*/
|
|
23
|
+
export function InlineTimePicker(props: InlineTimePickerProps) {
|
|
24
|
+
const { value, onSave, placeholder, controls = "blur", disabled, accessibilityLabel } = props;
|
|
25
|
+
const edit = useInlineEdit<string>({ value, onSave });
|
|
26
|
+
|
|
27
|
+
const onKeyDown = useCallback(
|
|
28
|
+
(e: KeyboardEvent<HTMLInputElement>) => {
|
|
29
|
+
if (e.key === "Escape") edit.cancel();
|
|
30
|
+
else if (e.key === "Enter") void edit.commit();
|
|
31
|
+
},
|
|
32
|
+
[edit],
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const onBlur = useCallback(() => {
|
|
36
|
+
if (controls === "buttons") return;
|
|
37
|
+
void edit.commit();
|
|
38
|
+
}, [controls, edit]);
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<InlineEditFrame
|
|
42
|
+
editing={edit.editing}
|
|
43
|
+
display={value}
|
|
44
|
+
placeholder={placeholder}
|
|
45
|
+
onBegin={edit.begin}
|
|
46
|
+
controls={controls}
|
|
47
|
+
onCommit={() => void edit.commit()}
|
|
48
|
+
onCancel={edit.cancel}
|
|
49
|
+
saving={edit.saving}
|
|
50
|
+
error={edit.error}
|
|
51
|
+
disabled={disabled}
|
|
52
|
+
accessibilityLabel={accessibilityLabel}
|
|
53
|
+
>
|
|
54
|
+
<TimePicker
|
|
55
|
+
value={edit.draft}
|
|
56
|
+
onValueChange={edit.setDraft}
|
|
57
|
+
onBlur={onBlur}
|
|
58
|
+
onKeyDown={onKeyDown}
|
|
59
|
+
autoFocus
|
|
60
|
+
accessibilityLabel={accessibilityLabel}
|
|
61
|
+
/>
|
|
62
|
+
</InlineEditFrame>
|
|
63
|
+
);
|
|
64
|
+
}
|
package/src/line_chart.tsx
CHANGED
|
@@ -23,6 +23,10 @@ const defaultFormatNumber = (n: number): string =>
|
|
|
23
23
|
|
|
24
24
|
const defaultFormatXLabel = (x: string | number): string => String(x);
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* The canonical SVG line chart over `points: { x, y }[]` — `formatNumber`/`formatXLabel` for the
|
|
28
|
+
* axes, `lineColor`, `height`. (No recharts.) For a tiny inline trend use `Sparkline`.
|
|
29
|
+
*/
|
|
26
30
|
export function LineChart(props: LineChartProps) {
|
|
27
31
|
const {
|
|
28
32
|
points: data,
|
package/src/link.tsx
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { Pressable } from "react-native";
|
|
3
|
+
import { Text, type TextSize } from "./text";
|
|
4
|
+
import { colors } from "./colors";
|
|
5
|
+
|
|
6
|
+
export interface LinkProps {
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
/** Opening the link is the consumer's job (a URL via the app SDK's openExternal,
|
|
9
|
+
* an in-app route, …) — Link is pure presentation + the press target. */
|
|
10
|
+
onPress: () => void;
|
|
11
|
+
size?: TextSize;
|
|
12
|
+
accessibilityLabel?: string;
|
|
13
|
+
testID?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* An EXTERNAL hyperlink — underline + blue, the universal "this opens somewhere
|
|
18
|
+
* else" signal (a document URL, a record, an invoice). The deliberate counterpart
|
|
19
|
+
* to `LinkButton`: `LinkButton` is a QUIET IN-APP ACTION (a ghost button — "Clear",
|
|
20
|
+
* "Show more") and intentionally NOT underlined; `Link` is a link OUT and IS
|
|
21
|
+
* underlined-blue so it reads as a destination, not an action. The consumer wires
|
|
22
|
+
* `onPress` to its opener.
|
|
23
|
+
*/
|
|
24
|
+
export function Link({ children, onPress, size = "sm", accessibilityLabel, testID }: LinkProps) {
|
|
25
|
+
return (
|
|
26
|
+
<Pressable onPress={onPress} accessibilityRole="link" accessibilityLabel={accessibilityLabel} testID={testID}>
|
|
27
|
+
<Text size={size} decoration="underline" style={{ color: colors.blue[600] }}>
|
|
28
|
+
{children}
|
|
29
|
+
</Text>
|
|
30
|
+
</Pressable>
|
|
31
|
+
);
|
|
32
|
+
}
|
package/src/list_item.tsx
CHANGED
|
@@ -17,6 +17,11 @@ export interface ListItemProps {
|
|
|
17
17
|
testID?: string;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* A settings / detail ROW — optional `left` (icon/avatar), `title` + `description`, a `right`
|
|
22
|
+
* control, optional `onPress`/`selected`. For settings lists and detail rows; NOT a data register
|
|
23
|
+
* row (`PressableRow` / `Table`) and NOT a menu/listbox row (`MenuListItem`).
|
|
24
|
+
*/
|
|
20
25
|
export function ListItem(props: ListItemProps) {
|
|
21
26
|
const { ref, left, title, description, right, onPress, selected, disabled, style, testID } =
|
|
22
27
|
props;
|
package/src/number_input.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { KeyboardEvent } from "react";
|
|
1
2
|
import { colors } from "./colors";
|
|
2
3
|
import { fontFamilyRegular, inputTextStyleWeb } from "./text_utils";
|
|
3
4
|
import { useFormField } from "./form_field";
|
|
@@ -8,13 +9,21 @@ export interface NumberInputProps {
|
|
|
8
9
|
min?: number;
|
|
9
10
|
max?: number;
|
|
10
11
|
onBlur?: () => void;
|
|
12
|
+
/** Web key handler — e.g. an inline editor committing on Enter / reverting on Escape. */
|
|
13
|
+
onKeyDown?: (e: KeyboardEvent<HTMLInputElement>) => void;
|
|
14
|
+
autoFocus?: boolean;
|
|
11
15
|
disabled?: boolean;
|
|
12
16
|
testID?: string;
|
|
13
17
|
accessibilityLabel?: string;
|
|
14
18
|
}
|
|
15
19
|
|
|
20
|
+
/**
|
|
21
|
+
* A bare numeric input (`type="number"`, `value: number | null`) for free numeric entry — wrap in
|
|
22
|
+
* `FormField` for a labeled field. For a small bounded count use `Counter` (− N +); to edit a
|
|
23
|
+
* number in place on a record use `InlineNumberInput`.
|
|
24
|
+
*/
|
|
16
25
|
export function NumberInput(props: NumberInputProps) {
|
|
17
|
-
const { value, onValueChange, min, max, disabled, onBlur, testID, accessibilityLabel } = props;
|
|
26
|
+
const { value, onValueChange, min, max, disabled, onBlur, onKeyDown, autoFocus, testID, accessibilityLabel } = props;
|
|
18
27
|
const binding = useFormField();
|
|
19
28
|
const describedBy = [binding?.descriptionId, binding?.errorId].filter(Boolean).join(" ") || undefined;
|
|
20
29
|
|
|
@@ -35,6 +44,8 @@ export function NumberInput(props: NumberInputProps) {
|
|
|
35
44
|
type="number"
|
|
36
45
|
disabled={disabled}
|
|
37
46
|
onBlur={onBlur}
|
|
47
|
+
onKeyDown={onKeyDown}
|
|
48
|
+
autoFocus={autoFocus}
|
|
38
49
|
style={{
|
|
39
50
|
height: 40,
|
|
40
51
|
paddingLeft: 8,
|
package/src/page_content.tsx
CHANGED
|
@@ -16,6 +16,11 @@ interface PageContentProps {
|
|
|
16
16
|
fullscreen?: boolean;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* The page shell — a centered, width-capped column (`size` sm|md|lg) with an optional title band
|
|
21
|
+
* (`title` + `titleRight` + `description`), `header`/`footer` slots, and `fullscreen`. Wrap a
|
|
22
|
+
* screen's body for consistent gutters; cards float inside it.
|
|
23
|
+
*/
|
|
19
24
|
export function PageContent(props: PageContentProps) {
|
|
20
25
|
const { children, title, titleRight, description, header, footer, size, fullscreen } = props;
|
|
21
26
|
const screenSize = useScreenSize();
|
package/src/picker.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { Picker as RNPicker } from "@react-native-picker/picker";
|
|
|
2
2
|
import { StyleSheet, View, Pressable, StyleProp, ViewStyle, TextStyle } from "react-native";
|
|
3
3
|
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
|
|
4
4
|
import { colors } from "./colors";
|
|
5
|
+
import { ACTIVE_RING } from "./control_surface";
|
|
5
6
|
import { Text } from "./text";
|
|
6
7
|
import { fontFamilyRegular, getInputTextStyle } from "./text_utils";
|
|
7
8
|
import { Icon } from "./icon";
|
|
@@ -328,7 +329,9 @@ const styles = StyleSheet.create({
|
|
|
328
329
|
height: 40,
|
|
329
330
|
},
|
|
330
331
|
opened: {
|
|
331
|
-
|
|
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,
|
|
332
335
|
},
|
|
333
336
|
disabled: {
|
|
334
337
|
opacity: 0.5,
|
package/src/popover.tsx
CHANGED
|
@@ -254,8 +254,17 @@ export function PopoverContent(props: PopoverContentProps) {
|
|
|
254
254
|
|
|
255
255
|
return () => {
|
|
256
256
|
cancelAnimationFrame(focusFrame);
|
|
257
|
-
const
|
|
257
|
+
const prior = returnFocusRef.current;
|
|
258
258
|
returnFocusRef.current = null;
|
|
259
|
+
// Return focus to the TRIGGER (WAI-ARIA: focus returns to the element that
|
|
260
|
+
// invoked the popup). `previouslyFocused` is an unreliable proxy — RN-Web's
|
|
261
|
+
// Pressable triggers don't take focus on mouse press, so for a mouse-opened
|
|
262
|
+
// popover (a Picker/InlineSelect/menu) it is <body>; restoring there strands
|
|
263
|
+
// focus and the next Tab jumps to the page's first focusable. Fall back to
|
|
264
|
+
// the prior element only if the trigger is gone (e.g. a hover-revealed menu
|
|
265
|
+
// button that unmounted under the overlay).
|
|
266
|
+
const trigger = triggerRef.current;
|
|
267
|
+
const target = trigger && trigger.isConnected ? trigger : prior;
|
|
259
268
|
if (target && typeof target.focus === "function") {
|
|
260
269
|
target.focus();
|
|
261
270
|
}
|
package/src/pressable_row.tsx
CHANGED
|
@@ -55,7 +55,10 @@ export function PressableRow(props: PressableRowProps) {
|
|
|
55
55
|
return (
|
|
56
56
|
<Pressable
|
|
57
57
|
onPress={onPress}
|
|
58
|
-
focusable
|
|
58
|
+
// Non-focusable surface: tabIndex, NOT focusable — RN-Web's Pressable
|
|
59
|
+
// ignores `focusable` and writes its own tabIndex (the keyboard target is
|
|
60
|
+
// the nested "Open …" button, not this row).
|
|
61
|
+
tabIndex={-1}
|
|
59
62
|
{...mouseProps}
|
|
60
63
|
style={({ pressed }) => [
|
|
61
64
|
styles.row,
|
package/src/radio_picker.tsx
CHANGED
|
@@ -128,7 +128,9 @@ function RadioOption<T extends string | number | symbol>(
|
|
|
128
128
|
accessibilityLabel={description ? `${label}, ${description}` : label}
|
|
129
129
|
aria-checked={selected}
|
|
130
130
|
// Roving tabindex: exactly one radio is the tab-stop. Arrow keys cycle.
|
|
131
|
-
focusable
|
|
131
|
+
// tabIndex, NOT focusable — RN-Web's Pressable ignores `focusable` and
|
|
132
|
+
// writes its own tabIndex.
|
|
133
|
+
tabIndex={isTabStop ? 0 : -1}
|
|
132
134
|
onKeyDown={onKeyDown}
|
|
133
135
|
>
|
|
134
136
|
<View
|
package/src/section_heading.tsx
CHANGED
|
@@ -1,41 +1,55 @@
|
|
|
1
|
-
import { View } from "react-native";
|
|
2
|
-
import { Text, HeadingLevel } from "./text";
|
|
3
|
-
import { Icon, IconName } from "./icon";
|
|
1
|
+
import { View, type StyleProp, type ViewStyle } from "react-native";
|
|
2
|
+
import { Text, type HeadingLevel } from "./text";
|
|
3
|
+
import { Icon, type IconName } from "./icon";
|
|
4
|
+
|
|
5
|
+
// The card-less section header — the bare-canvas sibling of `CardHeader`, built
|
|
6
|
+
// the same compound way. A title (+ optional leading icon / description) on one
|
|
7
|
+
// centered row, with optional actions that sit at the right because the title
|
|
8
|
+
// grows. It owns NO outer margin: spacing comes from the parent's `gap`, so it
|
|
9
|
+
// composes cleanly in a flowed column (a baked-in margin would stack on the gap).
|
|
10
|
+
//
|
|
11
|
+
// <SectionHeading>
|
|
12
|
+
// <SectionHeadingTitle description?>Line items</SectionHeadingTitle>
|
|
13
|
+
// <Button title="Add line" … /> {/* pushed right by the grow */}
|
|
14
|
+
// </SectionHeading>
|
|
4
15
|
|
|
5
16
|
export interface SectionHeadingProps {
|
|
6
|
-
|
|
7
|
-
|
|
17
|
+
children: React.ReactNode;
|
|
18
|
+
style?: StyleProp<ViewStyle>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function SectionHeading(props: SectionHeadingProps) {
|
|
22
|
+
return (
|
|
23
|
+
<View style={[{ flexDirection: "row", alignItems: "center", gap: 12 }, props.style]}>
|
|
24
|
+
{props.children}
|
|
25
|
+
</View>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SectionHeadingTitleProps {
|
|
30
|
+
children: React.ReactNode;
|
|
8
31
|
description?: string;
|
|
9
|
-
|
|
32
|
+
icon?: IconName;
|
|
10
33
|
/** Heading rank. Defaults to 2 — typical for page-level section titles. */
|
|
11
34
|
level?: HeadingLevel;
|
|
12
35
|
}
|
|
13
36
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
37
|
+
/** The title block — grows to push any sibling actions to the right edge. */
|
|
38
|
+
export function SectionHeadingTitle(props: SectionHeadingTitleProps) {
|
|
39
|
+
const { children, description, icon, level = 2 } = props;
|
|
17
40
|
return (
|
|
18
|
-
<View
|
|
19
|
-
style={{
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
>
|
|
25
|
-
<View style={{ flex: 1 }}>
|
|
26
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
|
|
27
|
-
{icon && <Icon name={icon} size={18} />}
|
|
28
|
-
<Text level={level} weight="medium" size="md">
|
|
29
|
-
{title}
|
|
30
|
-
</Text>
|
|
31
|
-
</View>
|
|
32
|
-
{!!description && (
|
|
33
|
-
<Text color="zinc-500" size="sm">
|
|
34
|
-
{description}
|
|
35
|
-
</Text>
|
|
36
|
-
)}
|
|
41
|
+
<View style={{ flex: 1, gap: 2 }}>
|
|
42
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
43
|
+
{icon ? <Icon name={icon} size={18} /> : null}
|
|
44
|
+
<Text level={level} weight="medium" size="md">
|
|
45
|
+
{children}
|
|
46
|
+
</Text>
|
|
37
47
|
</View>
|
|
38
|
-
{
|
|
48
|
+
{description ? (
|
|
49
|
+
<Text color="zinc-500" size="sm">
|
|
50
|
+
{description}
|
|
51
|
+
</Text>
|
|
52
|
+
) : null}
|
|
39
53
|
</View>
|
|
40
54
|
);
|
|
41
55
|
}
|
|
@@ -152,8 +152,9 @@ function Segment<T extends string>(props: SegmentProps<T>) {
|
|
|
152
152
|
aria-checked={selected}
|
|
153
153
|
aria-disabled={disabled}
|
|
154
154
|
// Roving tabindex: only the selected segment (or the fallback) is a tab
|
|
155
|
-
// stop; the rest are reached with arrow keys.
|
|
156
|
-
focusable
|
|
155
|
+
// stop; the rest are reached with arrow keys. tabIndex, NOT focusable —
|
|
156
|
+
// RN-Web's Pressable ignores `focusable` and writes its own tabIndex.
|
|
157
|
+
tabIndex={isTabStop && !disabled ? 0 : -1}
|
|
157
158
|
>
|
|
158
159
|
{(state) => {
|
|
159
160
|
const hovered = (state as { hovered?: boolean }).hovered;
|
package/src/tabs.tsx
CHANGED
|
@@ -125,8 +125,10 @@ function TabButton<T extends string>(props: TabButtonProps<T>) {
|
|
|
125
125
|
// Roving tabindex: the selected tab is the tab-stop, others are reachable
|
|
126
126
|
// via arrow keys. When no tab matches the current selection (props.value
|
|
127
127
|
// is stale), the first tab is the fallback so the group stays keyboard-
|
|
128
|
-
// reachable.
|
|
129
|
-
focusable
|
|
128
|
+
// reachable. Drive it with tabIndex, NOT focusable: RN-Web's Pressable
|
|
129
|
+
// ignores `focusable` (it writes its own tabIndex), so a focusable-based
|
|
130
|
+
// roving model silently leaves every tab a tab-stop.
|
|
131
|
+
tabIndex={isTabStop ? 0 : -1}
|
|
130
132
|
>
|
|
131
133
|
{(state) => {
|
|
132
134
|
const { pressed } = state;
|