@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.
- package/AGENTS.md +5 -0
- package/docs/catalog.md +78 -35
- package/docs/composition.md +61 -24
- package/docs/data_entry.md +19 -17
- package/docs/templates.md +71 -76
- package/examples/tpl_item_list.tsx +6 -6
- package/examples/tpl_record.tsx +131 -188
- package/examples/tpl_task_board.tsx +20 -22
- package/package.json +3 -2
- package/src/badge.tsx +26 -10
- package/src/checklist.tsx +1 -1
- package/src/choice_list.tsx +47 -36
- package/src/clarify.tsx +14 -24
- package/src/detail_row.tsx +24 -8
- package/src/form_date_picker.tsx +2 -1
- package/src/form_field.tsx +23 -3
- package/src/form_picker.tsx +2 -1
- package/src/form_switch.tsx +16 -2
- package/src/form_text_input.tsx +4 -4
- package/src/inline_date_picker.tsx +19 -9
- package/src/inline_edit.tsx +43 -24
- package/src/inline_member_select.tsx +43 -10
- package/src/inline_number_input.tsx +4 -4
- package/src/inline_select.tsx +193 -87
- package/src/inline_text_input.tsx +4 -4
- package/src/inline_time_picker.tsx +4 -4
- package/src/inset.tsx +38 -0
- package/src/linked_record_box.tsx +102 -0
- package/src/number_input.tsx +1 -1
- package/src/radio_picker.tsx +18 -28
- package/src/text_input_field.tsx +3 -3
- package/examples/tpl_tasks.tsx +0 -456
- package/src/inline_tag_select.tsx +0 -140
|
@@ -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
|
|
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
|
-
|
|
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 ,
|
|
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
|
-
|
|
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
|
+
});
|
package/src/number_input.tsx
CHANGED
|
@@ -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
|
|
package/src/radio_picker.tsx
CHANGED
|
@@ -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
|
|
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,
|
|
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}
|
|
108
|
+
ref={ref}
|
|
109
|
+
testID={testID}
|
|
120
110
|
style={{
|
|
121
111
|
flexDirection: "row",
|
|
122
112
|
alignItems: "center",
|
|
123
|
-
padding:
|
|
113
|
+
padding: 12,
|
|
124
114
|
borderRadius: CONTROL_RADIUS,
|
|
125
|
-
gap:
|
|
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:
|
|
140
|
-
height:
|
|
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={
|
|
139
|
+
{selected && <Icon name="check" size={24} color={getTextColor("inverted")} />}
|
|
150
140
|
</View>
|
|
151
|
-
{/* The text column is width-CONSTRAINED so a long
|
|
152
|
-
inside the pressable
|
|
153
|
-
the hover/press surface
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
});
|
package/src/text_input_field.tsx
CHANGED
|
@@ -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
|
|
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
|