@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.
Files changed (65) hide show
  1. package/AGENTS.md +352 -0
  2. package/examples/app_orders.tsx +405 -0
  3. package/examples/tpl_allocate.tsx +120 -0
  4. package/examples/tpl_approvals.tsx +375 -0
  5. package/examples/tpl_attendance.tsx +355 -0
  6. package/examples/tpl_batch.tsx +234 -0
  7. package/examples/tpl_billing.tsx +344 -0
  8. package/examples/tpl_calendar.tsx +288 -0
  9. package/examples/tpl_callsheet.tsx +481 -0
  10. package/examples/tpl_convert.tsx +490 -0
  11. package/examples/tpl_crm_desk.tsx +541 -0
  12. package/examples/tpl_dashboard.tsx +554 -0
  13. package/examples/tpl_detail.tsx +232 -0
  14. package/examples/tpl_directory.tsx +263 -0
  15. package/examples/tpl_dispatch.tsx +289 -0
  16. package/examples/tpl_dossier.tsx +431 -0
  17. package/examples/tpl_intake.tsx +206 -0
  18. package/examples/tpl_inventory.tsx +299 -0
  19. package/examples/tpl_order.tsx +483 -0
  20. package/examples/tpl_pick.tsx +240 -0
  21. package/examples/tpl_quick.tsx +210 -0
  22. package/examples/tpl_reconcile.tsx +275 -0
  23. package/examples/tpl_record.tsx +301 -0
  24. package/examples/tpl_record_plain.tsx +154 -0
  25. package/examples/tpl_rollup.tsx +300 -0
  26. package/examples/tpl_run.tsx +235 -0
  27. package/examples/tpl_settings.tsx +178 -0
  28. package/examples/tpl_shifts.tsx +421 -0
  29. package/examples/tpl_stock.tsx +387 -0
  30. package/examples/tpl_timeline.tsx +244 -0
  31. package/examples/tpl_tower.tsx +356 -0
  32. package/examples/tpl_wizard.tsx +223 -0
  33. package/package.json +12 -2
  34. package/src/bar_chart.tsx +5 -0
  35. package/src/combobox.tsx +33 -8
  36. package/src/control_surface.ts +8 -0
  37. package/src/form_date_picker.tsx +2 -0
  38. package/src/form_picker.tsx +1 -0
  39. package/src/form_switch.tsx +1 -0
  40. package/src/form_text_input.tsx +2 -0
  41. package/src/icon.tsx +2 -0
  42. package/src/icon_button.tsx +5 -2
  43. package/src/index.css +6 -3
  44. package/src/inline_date_picker.tsx +111 -0
  45. package/src/inline_edit.tsx +238 -0
  46. package/src/inline_number_input.tsx +70 -0
  47. package/src/inline_select.tsx +92 -0
  48. package/src/inline_text_input.tsx +71 -0
  49. package/src/inline_time_picker.tsx +64 -0
  50. package/src/line_chart.tsx +4 -0
  51. package/src/link.tsx +32 -0
  52. package/src/list_item.tsx +5 -0
  53. package/src/number_input.tsx +12 -1
  54. package/src/page_content.tsx +5 -0
  55. package/src/picker.tsx +4 -1
  56. package/src/popover.tsx +10 -1
  57. package/src/pressable_row.tsx +4 -1
  58. package/src/radio_picker.tsx +3 -1
  59. package/src/section_heading.tsx +43 -29
  60. package/src/segmented_control.tsx +3 -2
  61. package/src/tabs.tsx +4 -2
  62. package/src/tag_input.tsx +202 -0
  63. package/src/text.tsx +1 -1
  64. package/src/time_picker.tsx +15 -3
  65. 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
+ }
@@ -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;
@@ -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,
@@ -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
- borderColor: colors.zinc["900"],
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 target = returnFocusRef.current;
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
  }
@@ -55,7 +55,10 @@ export function PressableRow(props: PressableRowProps) {
55
55
  return (
56
56
  <Pressable
57
57
  onPress={onPress}
58
- focusable={false}
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,
@@ -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={isTabStop}
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
@@ -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
- icon?: IconName;
7
- title: string;
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
- right?: React.ReactNode;
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
- export function SectionHeading(props: SectionHeadingProps) {
15
- const { icon, title, description, right, level = 2 } = props;
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
- flexDirection: "row",
21
- alignItems: "center",
22
- paddingBottom: 16,
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
- {right}
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={isTabStop && !disabled}
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={isTabStop}
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;