@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,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";
@@ -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: 12,
21
- paddingRight: 12,
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