@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,202 @@
|
|
|
1
|
+
import { useMemo, useRef, useState } from "react";
|
|
2
|
+
import { Pressable, ScrollView, StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
|
|
3
|
+
import { colors } from "./colors";
|
|
4
|
+
import { Text } from "./text";
|
|
5
|
+
import { Icon } from "./icon";
|
|
6
|
+
import { TextInputField } from "./text_input_field";
|
|
7
|
+
import { MenuButton } from "./menu_button";
|
|
8
|
+
import { Popover, PopoverContent } from "./popover";
|
|
9
|
+
|
|
10
|
+
export interface TagOption {
|
|
11
|
+
value: string;
|
|
12
|
+
label: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface TagInputProps {
|
|
16
|
+
/** Selected tags. */
|
|
17
|
+
value: TagOption[];
|
|
18
|
+
/** The suggestible set. */
|
|
19
|
+
options: TagOption[];
|
|
20
|
+
onChange: (next: TagOption[]) => void;
|
|
21
|
+
/** Offer a "Create …" row when the query matches no option. Default false. */
|
|
22
|
+
allowCreate?: boolean;
|
|
23
|
+
placeholder?: string;
|
|
24
|
+
accessibilityLabel?: string;
|
|
25
|
+
style?: StyleProp<ViewStyle>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Multi-select tag field. The CONTROL is a bordered box of removable chips plus
|
|
30
|
+
* an Add affordance; the typing/searching/creating happens in a POPOVER (a
|
|
31
|
+
* searchable, checkable list + an optional create row) — never an inline token
|
|
32
|
+
* input squeezed among the chips. So the field always reads as a clean container
|
|
33
|
+
* that matches its border, instead of a text box fighting the chips. Distinct
|
|
34
|
+
* from `Combobox multi` (a search field whose selection renders as in-input chips):
|
|
35
|
+
* reach for `TagInput` when the field's resting state should be a tidy chip box.
|
|
36
|
+
*/
|
|
37
|
+
export function TagInput(props: TagInputProps) {
|
|
38
|
+
const { value, options, onChange, allowCreate = false, placeholder = "Add tags", accessibilityLabel, style } = props;
|
|
39
|
+
const [open, setOpen] = useState(false);
|
|
40
|
+
const [query, setQuery] = useState("");
|
|
41
|
+
const anchorRef = useRef<View>(null);
|
|
42
|
+
|
|
43
|
+
const selected = useMemo(() => new Set(value.map((t) => t.value)), [value]);
|
|
44
|
+
|
|
45
|
+
const filtered = useMemo(() => {
|
|
46
|
+
const q = query.trim().toLowerCase();
|
|
47
|
+
return q ? options.filter((o) => o.label.toLowerCase().includes(q)) : options;
|
|
48
|
+
}, [options, query]);
|
|
49
|
+
|
|
50
|
+
const exact = useMemo(() => {
|
|
51
|
+
const q = query.trim().toLowerCase();
|
|
52
|
+
return q.length > 0 && [...options, ...value].some((o) => o.label.toLowerCase() === q);
|
|
53
|
+
}, [options, value, query]);
|
|
54
|
+
|
|
55
|
+
const showCreate = allowCreate && query.trim().length > 0 && !exact;
|
|
56
|
+
|
|
57
|
+
const toggle = (o: TagOption) =>
|
|
58
|
+
onChange(selected.has(o.value) ? value.filter((t) => t.value !== o.value) : [...value, o]);
|
|
59
|
+
|
|
60
|
+
const create = () => {
|
|
61
|
+
const label = query.trim();
|
|
62
|
+
if (!label) return;
|
|
63
|
+
const v = label.toLowerCase().replaceAll(" ", "_");
|
|
64
|
+
if (!selected.has(v)) onChange([...value, { value: v, label }]);
|
|
65
|
+
setQuery("");
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<>
|
|
70
|
+
<View ref={anchorRef} style={[styles.box, style]} accessibilityLabel={accessibilityLabel}>
|
|
71
|
+
{value.map((t) => (
|
|
72
|
+
<View key={t.value} style={styles.chip}>
|
|
73
|
+
<Text size="sm">{t.label}</Text>
|
|
74
|
+
<Pressable
|
|
75
|
+
onPress={() => onChange(value.filter((x) => x.value !== t.value))}
|
|
76
|
+
accessibilityRole="button"
|
|
77
|
+
accessibilityLabel={`Remove ${t.label}`}
|
|
78
|
+
style={styles.chipRemove}
|
|
79
|
+
hitSlop={6}
|
|
80
|
+
>
|
|
81
|
+
<Icon name="x" size={12} color={colors.zinc[500]} />
|
|
82
|
+
</Pressable>
|
|
83
|
+
</View>
|
|
84
|
+
))}
|
|
85
|
+
<Pressable
|
|
86
|
+
onPress={() => setOpen(true)}
|
|
87
|
+
accessibilityRole="button"
|
|
88
|
+
accessibilityLabel={accessibilityLabel ?? "Add tag"}
|
|
89
|
+
style={styles.add}
|
|
90
|
+
>
|
|
91
|
+
<Icon name="plus" size={14} color={colors.zinc[500]} />
|
|
92
|
+
<Text size="sm" color="muted">
|
|
93
|
+
{value.length === 0 ? placeholder : "Add"}
|
|
94
|
+
</Text>
|
|
95
|
+
</Pressable>
|
|
96
|
+
</View>
|
|
97
|
+
|
|
98
|
+
<Popover
|
|
99
|
+
open={open}
|
|
100
|
+
onOpenChange={(o) => {
|
|
101
|
+
setOpen(o);
|
|
102
|
+
if (!o) setQuery("");
|
|
103
|
+
}}
|
|
104
|
+
triggerRef={anchorRef}
|
|
105
|
+
side="bottom"
|
|
106
|
+
align="start"
|
|
107
|
+
offset={4}
|
|
108
|
+
inheritTriggerWidth
|
|
109
|
+
>
|
|
110
|
+
<PopoverContent>
|
|
111
|
+
<View style={styles.menu}>
|
|
112
|
+
<TextInputField
|
|
113
|
+
value={query}
|
|
114
|
+
onChangeText={setQuery}
|
|
115
|
+
icon="search"
|
|
116
|
+
placeholder={allowCreate ? "Search or create…" : "Search…"}
|
|
117
|
+
autoFocus
|
|
118
|
+
autoCapitalize="none"
|
|
119
|
+
autoCorrect={false}
|
|
120
|
+
onKeyPress={(e: { nativeEvent: { key: string }; preventDefault: () => void }) => {
|
|
121
|
+
if (e.nativeEvent.key === "Enter") {
|
|
122
|
+
e.preventDefault();
|
|
123
|
+
if (showCreate) create();
|
|
124
|
+
else if (filtered.length === 1) toggle(filtered[0]);
|
|
125
|
+
}
|
|
126
|
+
}}
|
|
127
|
+
/>
|
|
128
|
+
<ScrollView style={styles.list} keyboardShouldPersistTaps="handled">
|
|
129
|
+
{filtered.map((o) => (
|
|
130
|
+
<MenuButton
|
|
131
|
+
key={o.value}
|
|
132
|
+
title={o.label}
|
|
133
|
+
right={selected.has(o.value) ? <Icon name="check" size={16} color={colors.zinc[900]} /> : undefined}
|
|
134
|
+
onPress={() => toggle(o)}
|
|
135
|
+
/>
|
|
136
|
+
))}
|
|
137
|
+
{showCreate ? <MenuButton title={`Create “${query.trim()}”`} icon="plus" onPress={create} /> : null}
|
|
138
|
+
{filtered.length === 0 && !showCreate ? (
|
|
139
|
+
<View style={styles.empty}>
|
|
140
|
+
<Text size="sm" color="muted">
|
|
141
|
+
No tags found
|
|
142
|
+
</Text>
|
|
143
|
+
</View>
|
|
144
|
+
) : null}
|
|
145
|
+
</ScrollView>
|
|
146
|
+
</View>
|
|
147
|
+
</PopoverContent>
|
|
148
|
+
</Popover>
|
|
149
|
+
</>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const styles = StyleSheet.create({
|
|
154
|
+
box: {
|
|
155
|
+
flexDirection: "row",
|
|
156
|
+
flexWrap: "wrap",
|
|
157
|
+
alignItems: "center",
|
|
158
|
+
gap: 6,
|
|
159
|
+
minHeight: 40,
|
|
160
|
+
paddingHorizontal: 6,
|
|
161
|
+
paddingVertical: 5,
|
|
162
|
+
borderWidth: 1,
|
|
163
|
+
borderColor: colors.border,
|
|
164
|
+
borderRadius: 8,
|
|
165
|
+
backgroundColor: colors.background,
|
|
166
|
+
},
|
|
167
|
+
chip: {
|
|
168
|
+
flexDirection: "row",
|
|
169
|
+
alignItems: "center",
|
|
170
|
+
gap: 4,
|
|
171
|
+
paddingLeft: 8,
|
|
172
|
+
paddingRight: 4,
|
|
173
|
+
paddingVertical: 3,
|
|
174
|
+
borderRadius: 6,
|
|
175
|
+
backgroundColor: colors.zinc[100],
|
|
176
|
+
},
|
|
177
|
+
chipRemove: {
|
|
178
|
+
padding: 2,
|
|
179
|
+
borderRadius: 4,
|
|
180
|
+
cursor: "pointer",
|
|
181
|
+
},
|
|
182
|
+
add: {
|
|
183
|
+
flexDirection: "row",
|
|
184
|
+
alignItems: "center",
|
|
185
|
+
gap: 4,
|
|
186
|
+
paddingHorizontal: 6,
|
|
187
|
+
paddingVertical: 4,
|
|
188
|
+
cursor: "pointer",
|
|
189
|
+
},
|
|
190
|
+
menu: {
|
|
191
|
+
gap: 6,
|
|
192
|
+
padding: 6,
|
|
193
|
+
minWidth: 240,
|
|
194
|
+
},
|
|
195
|
+
list: {
|
|
196
|
+
maxHeight: 240,
|
|
197
|
+
},
|
|
198
|
+
empty: {
|
|
199
|
+
paddingVertical: 12,
|
|
200
|
+
alignItems: "center",
|
|
201
|
+
},
|
|
202
|
+
});
|
package/src/text.tsx
CHANGED
|
@@ -47,7 +47,7 @@ export interface TextProps {
|
|
|
47
47
|
|
|
48
48
|
export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
|
|
49
49
|
|
|
50
|
-
type TextSize = "xs" | "sm" | "md" | "lg" | "xl" | "xxl";
|
|
50
|
+
export type TextSize = "xs" | "sm" | "md" | "lg" | "xl" | "xxl";
|
|
51
51
|
type TextAlign = "left" | "right" | "center";
|
|
52
52
|
type TextDecorationLine = "underline" | "lineThrough" | "underline lineThrough";
|
|
53
53
|
type TextWeight = "regular" | "medium" | "semibold";
|
package/src/time_picker.tsx
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
|
+
import type { KeyboardEvent } from "react";
|
|
1
2
|
import { colors } from "@lotics/ui/colors";
|
|
2
3
|
import { fontFamilyRegular, inputTextStyleWeb } from "@lotics/ui/text_utils";
|
|
3
4
|
export interface TimePickerProps {
|
|
4
5
|
value?: string;
|
|
5
6
|
onValueChange: (value: string) => void;
|
|
7
|
+
onBlur?: () => void;
|
|
8
|
+
/** Web key handler — e.g. an inline editor committing on Enter / reverting on Escape. */
|
|
9
|
+
onKeyDown?: (e: KeyboardEvent<HTMLInputElement>) => void;
|
|
10
|
+
autoFocus?: boolean;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
accessibilityLabel?: string;
|
|
6
13
|
}
|
|
7
14
|
|
|
8
15
|
export function TimePicker(props: TimePickerProps) {
|
|
9
|
-
const { value, onValueChange } = props;
|
|
16
|
+
const { value, onValueChange, onBlur, onKeyDown, autoFocus, disabled, accessibilityLabel } = props;
|
|
10
17
|
|
|
11
18
|
return (
|
|
12
19
|
<input
|
|
@@ -15,10 +22,15 @@ export function TimePicker(props: TimePickerProps) {
|
|
|
15
22
|
onValueChange(e.target.value);
|
|
16
23
|
}}
|
|
17
24
|
type="time"
|
|
25
|
+
onBlur={onBlur}
|
|
26
|
+
onKeyDown={onKeyDown}
|
|
27
|
+
autoFocus={autoFocus}
|
|
28
|
+
disabled={disabled}
|
|
29
|
+
aria-label={accessibilityLabel}
|
|
18
30
|
style={{
|
|
19
31
|
height: 40,
|
|
20
|
-
paddingLeft:
|
|
21
|
-
paddingRight:
|
|
32
|
+
paddingLeft: 8,
|
|
33
|
+
paddingRight: 8,
|
|
22
34
|
borderRadius: 8,
|
|
23
35
|
borderWidth: 1,
|
|
24
36
|
borderStyle: "solid",
|
package/src/tooltip.tsx
CHANGED
|
@@ -189,6 +189,9 @@ export function useTooltip(options?: string | UseTooltipOptions) {
|
|
|
189
189
|
const text = typeof options === "string" ? options : options?.text;
|
|
190
190
|
const side = typeof options === "string" ? "top" : (options?.side ?? "top");
|
|
191
191
|
const offset = typeof options === "string" ? undefined : options?.offset;
|
|
192
|
+
// True while THIS instance owns the visible tooltip — so the unmount cleanup
|
|
193
|
+
// only dismisses a tooltip it actually opened.
|
|
194
|
+
const shown = useRef(false);
|
|
192
195
|
|
|
193
196
|
const showFor = useCallback(
|
|
194
197
|
(target: unknown) => {
|
|
@@ -200,10 +203,23 @@ export function useTooltip(options?: string | UseTooltipOptions) {
|
|
|
200
203
|
if (!(target instanceof HTMLElement)) return;
|
|
201
204
|
const rect = target.getBoundingClientRect();
|
|
202
205
|
context.show(text, rect, side, offset);
|
|
206
|
+
shown.current = true;
|
|
203
207
|
},
|
|
204
208
|
[text, context, side, offset],
|
|
205
209
|
);
|
|
206
210
|
|
|
211
|
+
// Dismiss on unmount. An element hovered/focused when it disappears — a button
|
|
212
|
+
// that commits and re-renders away — never fires mouseLeave/blur, so its
|
|
213
|
+
// tooltip would otherwise orphan on screen.
|
|
214
|
+
useEffect(() => {
|
|
215
|
+
return () => {
|
|
216
|
+
if (shown.current) {
|
|
217
|
+
shown.current = false;
|
|
218
|
+
context?.hide();
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
}, [context]);
|
|
222
|
+
|
|
207
223
|
const onMouseEnter = useCallback(
|
|
208
224
|
(e: { currentTarget: unknown }) => {
|
|
209
225
|
showFor(e.currentTarget);
|
|
@@ -213,11 +229,13 @@ export function useTooltip(options?: string | UseTooltipOptions) {
|
|
|
213
229
|
|
|
214
230
|
const onMouseLeave = useCallback(() => {
|
|
215
231
|
if (!context) return;
|
|
232
|
+
shown.current = false;
|
|
216
233
|
context.hide();
|
|
217
234
|
}, [context]);
|
|
218
235
|
|
|
219
236
|
const onMouseDown = useCallback(() => {
|
|
220
237
|
if (!context) return;
|
|
238
|
+
shown.current = false;
|
|
221
239
|
context.hide();
|
|
222
240
|
}, [context]);
|
|
223
241
|
|
|
@@ -238,6 +256,7 @@ export function useTooltip(options?: string | UseTooltipOptions) {
|
|
|
238
256
|
|
|
239
257
|
const onBlur = useCallback(() => {
|
|
240
258
|
if (!context) return;
|
|
259
|
+
shown.current = false;
|
|
241
260
|
context.hide();
|
|
242
261
|
}, [context]);
|
|
243
262
|
|