@lotics/ui 12.1.1 → 13.7.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.
@@ -3,7 +3,7 @@ import type { KeyboardEvent } from "react";
3
3
  import { Icon } from "./icon";
4
4
  import { colors } from "./colors";
5
5
  import { TimePicker } from "./time_picker";
6
- import { type InlineEditBackground, InlineEditFrame, useInlineEdit, type InlineEditControls } from "./inline_edit";
6
+ import { type InlineEditVariant, InlineEditFrame, useInlineEdit, type InlineEditControls } from "./inline_edit";
7
7
 
8
8
  export interface InlineTimePickerProps {
9
9
  /** Canonical 24-hour "HH:mm", "" when empty. */
@@ -16,7 +16,7 @@ export interface InlineTimePickerProps {
16
16
  disabled?: boolean;
17
17
  accessibilityLabel?: string;
18
18
  /** Resting surface — "tint" (default, the zinc-50 chip) or "transparent". */
19
- background?: InlineEditBackground;
19
+ variant?: InlineEditVariant;
20
20
  }
21
21
 
22
22
  /**
@@ -25,7 +25,7 @@ export interface InlineTimePickerProps {
25
25
  * field at the same height, so the form never reflows.
26
26
  */
27
27
  export function InlineTimePicker(props: InlineTimePickerProps) {
28
- const { value, onSave, placeholder, controls = "blur", disabled, accessibilityLabel , background } = props;
28
+ const { value, onSave, placeholder, controls = "blur", disabled, accessibilityLabel , variant } = props;
29
29
  const edit = useInlineEdit<string>({ value, onSave });
30
30
 
31
31
  const onKeyDown = useCallback(
@@ -43,7 +43,7 @@ export function InlineTimePicker(props: InlineTimePickerProps) {
43
43
 
44
44
  return (
45
45
  <InlineEditFrame
46
- background={background}
46
+ variant={variant}
47
47
  editing={edit.editing}
48
48
  display={value}
49
49
  placeholder={placeholder}
package/src/inset.tsx ADDED
@@ -0,0 +1,38 @@
1
+ import { type ReactNode } from "react";
2
+ import { View, StyleSheet } from "react-native";
3
+ import { colors } from "./colors";
4
+
5
+ interface InsetProps {
6
+ /** The grouped content — a sub-form's fields, an inline fill editor, a nested block. */
7
+ children: ReactNode;
8
+ /** Test id on the container. */
9
+ testID?: string;
10
+ }
11
+
12
+ /**
13
+ * A tinted, recessed content surface — the "well" INSIDE a Card or Section for a grouped
14
+ * sub-form, an inline fill editor, or a nested detail block. It carries the kit's
15
+ * nested-surface tokens (zinc-50 fill, 10 radius, 14 padding) and a 12 gap so stacked
16
+ * fields keep the form rhythm.
17
+ *
18
+ * `Inset` is NOT a `Callout`. A Callout is a status BAND — it announces a message, and
19
+ * for `warning`/`error` it is an ARIA `alert`. Wrapping form fields in an alert is wrong:
20
+ * a screen reader announces the whole form as one time-sensitive alert. The law is:
21
+ * **content + fields go in an `Inset`; a message goes in a `Callout`.**
22
+ */
23
+ export function Inset({ children, testID }: InsetProps) {
24
+ return (
25
+ <View style={styles.inset} testID={testID}>
26
+ {children}
27
+ </View>
28
+ );
29
+ }
30
+
31
+ const styles = StyleSheet.create({
32
+ inset: {
33
+ backgroundColor: colors.zinc[50],
34
+ borderRadius: 10,
35
+ padding: 14,
36
+ gap: 12,
37
+ },
38
+ });
@@ -0,0 +1,102 @@
1
+ import { type ReactNode } from "react";
2
+ import { View, Pressable, StyleSheet } from "react-native";
3
+ import { PressableRow } from "./pressable_row";
4
+ import { Text } from "./text";
5
+ import { Icon, type IconName } from "./icon";
6
+ import { colors } from "./colors";
7
+ import { useFocusRing } from "./use_focus_ring";
8
+ import { FOCUS_RING } from "./control_surface";
9
+
10
+ interface LinkedRecordBoxProps {
11
+ /** The linked record's kind glyph (building-2 for a company, file-text for a record…). */
12
+ icon: IconName;
13
+ /** The linked record's display name. */
14
+ name: string;
15
+ /** A secondary identity line — a code, tax id, country, or "<desk> record". */
16
+ subtitle?: string;
17
+ /** Reference facts, stacked VERTICALLY (legible at any width, never squeezed). */
18
+ facts?: { label: string; value: string }[];
19
+ /** Width of the facts' label column (default 80). Widen for longer labels. */
20
+ factLabelWidth?: number;
21
+ /** Accessible name for opening the record (e.g. "Acme Corp — details"). */
22
+ doorLabel: string;
23
+ /** Opens the linked record's detail (drawer/page) — the WHOLE box presses this. */
24
+ onOpen: () => void;
25
+ /** The verbs, in a hairline-fenced footer at the bottom of the box, lifted above the door's
26
+ * hit area. Convention: destructive LEFT, go-to RIGHT (a `<View style={{ flex: 1 }} />`
27
+ * spacer between). The divider is drawn automatically when actions are present. Supplied by
28
+ * the consumer so the labels stay localized and the verb set fits the link. */
29
+ actions?: ReactNode;
30
+ }
31
+
32
+ /**
33
+ * A LINKED RECORD shown for reference — a bordered box scoping ANOTHER record's data
34
+ * (identity glyph · name · facts), the WHOLE box a keyboard-accessible door into that record's
35
+ * detail. Use it wherever one record points at another (a shipment's customer, an invoice's
36
+ * party, a case's sibling); the picker / empty-state that REASSIGNS the link is the consumer's,
37
+ * shown in place of the box.
38
+ *
39
+ * The a11y contract it enforces (the error-prone part, hence a primitive): a container with
40
+ * interactive descendants must NEVER be `role="button"` (invalid HTML). So the box is a
41
+ * role-less `PressableRow`, an empty absolutely-positioned door sibling carries the tab stop /
42
+ * accessible name / focus ring, and the interior verbs lift above it via `zIndex`.
43
+ */
44
+ export function LinkedRecordBox({ icon, name, subtitle, facts, factLabelWidth = 80, doorLabel, onOpen, actions }: LinkedRecordBoxProps) {
45
+ return (
46
+ <PressableRow onPress={onOpen} style={styles.box}>
47
+ <Door label={doorLabel} onPress={onOpen} />
48
+ <View style={styles.identity}>
49
+ <View style={styles.glyph}>
50
+ <Icon name={icon} size={17} color={colors.zinc[600]} />
51
+ </View>
52
+ <View style={{ flex: 1, minWidth: 0 }}>
53
+ <Text size="sm" weight="medium" numberOfLines={1}>{name}</Text>
54
+ {subtitle ? <Text size="xs" color="muted" numberOfLines={1}>{subtitle}</Text> : null}
55
+ </View>
56
+ </View>
57
+ {facts && facts.length > 0 ? (
58
+ <View style={{ gap: 6 }}>
59
+ {facts.map((f) => (
60
+ <View key={f.label} style={styles.fact}>
61
+ <Text size="sm" color="muted" style={{ width: factLabelWidth }}>{f.label}</Text>
62
+ <Text size="sm" style={{ flex: 1 }}>{f.value || "—"}</Text>
63
+ </View>
64
+ ))}
65
+ </View>
66
+ ) : null}
67
+ {actions ? (
68
+ <>
69
+ {/* full-bleed hairline fencing the verb footer off from the facts */}
70
+ <View style={styles.divider} />
71
+ <View style={styles.actions}>{actions}</View>
72
+ </>
73
+ ) : null}
74
+ </PressableRow>
75
+ );
76
+ }
77
+
78
+ // The keyboard DOOR: an empty absolutely-positioned sibling beneath the box content — a
79
+ // button must NOT wrap the box's interactive descendants, so the door alone carries the tab
80
+ // stop, accessible name, and focus ring; mouse presses ride the PressableRow surface.
81
+ function Door({ label, onPress }: { label: string; onPress: () => void }) {
82
+ const { focusVisible, focusProps } = useFocusRing();
83
+ return (
84
+ <Pressable
85
+ accessibilityRole="button"
86
+ accessibilityLabel={label}
87
+ onPress={onPress}
88
+ {...focusProps}
89
+ style={[styles.door, focusVisible ? { boxShadow: FOCUS_RING } : null]}
90
+ />
91
+ );
92
+ }
93
+
94
+ const styles = StyleSheet.create({
95
+ box: { flexDirection: "column", alignItems: "stretch", borderWidth: 1, borderColor: colors.zinc[200], borderRadius: 10, paddingHorizontal: 16, paddingTop: 14, paddingBottom: 10, gap: 12 },
96
+ door: { position: "absolute", top: 0, right: 0, bottom: 0, left: 0, borderRadius: 10 },
97
+ identity: { flexDirection: "row", alignItems: "center", gap: 10 },
98
+ glyph: { width: 34, height: 34, borderRadius: 8, backgroundColor: colors.zinc[100], alignItems: "center", justifyContent: "center" },
99
+ fact: { flexDirection: "row", gap: 10 },
100
+ divider: { height: 1, backgroundColor: colors.zinc[100], marginHorizontal: -16 },
101
+ actions: { flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 8, zIndex: 1 },
102
+ });
@@ -28,7 +28,7 @@ export interface NumberInputProps {
28
28
  export function NumberInput(props: NumberInputProps) {
29
29
  const { value, onValueChange, min, max, disabled, onBlur, onKeyDown, autoFocus, testID, accessibilityLabel } = props;
30
30
  const binding = useFormField();
31
- const describedBy = [binding?.descriptionId, binding?.errorId].filter(Boolean).join(" ") || undefined;
31
+ const describedBy = [binding?.descriptionId, binding?.warningId, binding?.errorId].filter(Boolean).join(" ") || undefined;
32
32
  const { focusVisible, focusProps } = useFocusRing({ always: true });
33
33
  const { hovered, hoverProps } = useHover();
34
34
 
@@ -23,15 +23,10 @@ export interface RadioPickerProps<T extends string | number | symbol> {
23
23
  options: RadioPickerOption<T>[];
24
24
  value: T;
25
25
  onValueChange: (value: T) => void;
26
- /** "column" (default) stacks full-width rows — right when options carry
27
- * descriptions. "row" wraps compact options inline — right for short,
28
- * description-less choices (a quick-entry form), where a six-option
29
- * column would push the rest of the form below the fold. */
30
- direction?: "column" | "row";
31
26
  }
32
27
 
33
28
  export function RadioPicker<T extends string | number | symbol>(props: RadioPickerProps<T>) {
34
- const { accessibilityLabel, options, value, onValueChange, direction = "column" } = props;
29
+ const { accessibilityLabel, options, value, onValueChange } = props;
35
30
  const itemRefs = useRef<Array<View | null>>([]);
36
31
 
37
32
  // Roving tabindex: arrow keys move focus between options and select, matching
@@ -71,11 +66,7 @@ export function RadioPicker<T extends string | number | symbol>(props: RadioPick
71
66
  const tabStopIndex = selectedIndex === -1 ? 0 : selectedIndex;
72
67
 
73
68
  return (
74
- <View
75
- accessibilityRole="radiogroup"
76
- accessibilityLabel={accessibilityLabel}
77
- style={direction === "row" ? { flexDirection: "row", flexWrap: "wrap", gap: 4 } : undefined}
78
- >
69
+ <View accessibilityRole="radiogroup" accessibilityLabel={accessibilityLabel}>
79
70
  {options.map((option, index) => (
80
71
  <RadioOption
81
72
  ref={(node: View | null) => {
@@ -86,7 +77,6 @@ export function RadioPicker<T extends string | number | symbol>(props: RadioPick
86
77
  value={option.value}
87
78
  description={option.description}
88
79
  testID={option.testID}
89
- compact={direction === "row"}
90
80
  selected={value === option.value}
91
81
  isTabStop={index === tabStopIndex}
92
82
  onSelect={() => onValueChange(option.value)}
@@ -100,14 +90,13 @@ export function RadioPicker<T extends string | number | symbol>(props: RadioPick
100
90
  function RadioOption<T extends string | number | symbol>(
101
91
  props: RadioPickerOption<T> & {
102
92
  ref: (node: View | null) => void;
103
- compact: boolean;
104
93
  selected: boolean;
105
94
  isTabStop: boolean;
106
95
  onSelect: () => void;
107
96
  onKeyDown: (event: { key: string; preventDefault?: () => void }) => void;
108
97
  },
109
98
  ) {
110
- const { ref, label, description, compact, selected, isTabStop, onSelect, value, testID, onKeyDown } = props;
99
+ const { ref, label, description, selected, isTabStop, onSelect, value, testID, onKeyDown } = props;
111
100
 
112
101
  const handlePress = useCallback(() => {
113
102
  onSelect();
@@ -116,13 +105,14 @@ function RadioOption<T extends string | number | symbol>(
116
105
  return (
117
106
  <PressableHighlight
118
107
  focusRing
119
- ref={ref} testID={testID}
108
+ ref={ref}
109
+ testID={testID}
120
110
  style={{
121
111
  flexDirection: "row",
122
112
  alignItems: "center",
123
- padding: compact ? 8 : 12,
113
+ padding: 12,
124
114
  borderRadius: CONTROL_RADIUS,
125
- gap: compact ? 8 : 16,
115
+ gap: 16,
126
116
  }}
127
117
  onPress={handlePress}
128
118
  accessibilityRole="radio"
@@ -136,8 +126,8 @@ function RadioOption<T extends string | number | symbol>(
136
126
  >
137
127
  <View
138
128
  style={{
139
- width: compact ? 20 : 28,
140
- height: compact ? 20 : 28,
129
+ width: 28,
130
+ height: 28,
141
131
  borderRadius: 999,
142
132
  borderWidth: 1,
143
133
  borderColor: colors.border,
@@ -146,15 +136,16 @@ function RadioOption<T extends string | number | symbol>(
146
136
  alignItems: "center",
147
137
  }}
148
138
  >
149
- {selected && <Icon name="check" size={compact ? 14 : 24} color={getTextColor("inverted")} />}
139
+ {selected && <Icon name="check" size={24} color={getTextColor("inverted")} />}
150
140
  </View>
151
- {/* The text column is width-CONSTRAINED so a long description WRAPS
152
- inside the pressable (unconstrained, its intrinsic width overflows
153
- the hover/press surface). Column rows take the full width; compact
154
- row chips keep hugging their content. */}
155
- <View style={compact ? styles.textCompact : styles.text}>
156
- <Text size={compact ? "sm" : undefined}>{label}</Text>
157
- {!!description && <Text color="muted">{description}</Text>}
141
+ {/* The text column is width-CONSTRAINED (flex:1, minWidth:0) so a long
142
+ description WRAPS inside the pressable unconstrained, its intrinsic
143
+ width would overflow the hover/press surface. */}
144
+ <View style={styles.text}>
145
+ <Text>{label}</Text>
146
+ {/* the annotation standard (FormField metrics): a step smaller and
147
+ lighter than the label, so the choice reads first */}
148
+ {!!description && <Text size="sm" color="zinc-500">{description}</Text>}
158
149
  </View>
159
150
  </PressableHighlight>
160
151
  );
@@ -162,5 +153,4 @@ function RadioOption<T extends string | number | symbol>(
162
153
 
163
154
  const styles = StyleSheet.create({
164
155
  text: { flex: 1, minWidth: 0 },
165
- textCompact: { flexShrink: 1, minWidth: 0 },
166
156
  });
@@ -80,9 +80,9 @@ export function TextInputField(props: TextInputFieldProps) {
80
80
  // Tracked on the wrapping View (RN-Web forwards mouse events there reliably).
81
81
  const { hovered, hoverProps } = useHover();
82
82
 
83
- // Describedby chains description and error so both are read. We join them
84
- // explicitly here because React Native Web does not flatten array attrs.
85
- const describedBy = [binding?.descriptionId, binding?.errorId].filter(Boolean).join(" ") || undefined;
83
+ // Describedby chains description, warning, and error so all are read. We join
84
+ // them explicitly here because React Native Web does not flatten array attrs.
85
+ const describedBy = [binding?.descriptionId, binding?.warningId, binding?.errorId].filter(Boolean).join(" ") || undefined;
86
86
 
87
87
  const minHeight =
88
88
  numberOfLines && numberOfLines > 1