@lotics/ui 3.5.0 → 4.0.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 (55) hide show
  1. package/AGENTS.md +323 -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_calendar.tsx +288 -0
  8. package/examples/tpl_callsheet.tsx +481 -0
  9. package/examples/tpl_convert.tsx +490 -0
  10. package/examples/tpl_crm_desk.tsx +541 -0
  11. package/examples/tpl_dashboard.tsx +554 -0
  12. package/examples/tpl_detail.tsx +232 -0
  13. package/examples/tpl_directory.tsx +263 -0
  14. package/examples/tpl_dispatch.tsx +289 -0
  15. package/examples/tpl_dossier.tsx +431 -0
  16. package/examples/tpl_intake.tsx +206 -0
  17. package/examples/tpl_inventory.tsx +299 -0
  18. package/examples/tpl_order.tsx +483 -0
  19. package/examples/tpl_pick.tsx +240 -0
  20. package/examples/tpl_quick.tsx +210 -0
  21. package/examples/tpl_reconcile.tsx +275 -0
  22. package/examples/tpl_record.tsx +301 -0
  23. package/examples/tpl_record_plain.tsx +154 -0
  24. package/examples/tpl_rollup.tsx +300 -0
  25. package/examples/tpl_run.tsx +235 -0
  26. package/examples/tpl_settings.tsx +178 -0
  27. package/examples/tpl_shifts.tsx +421 -0
  28. package/examples/tpl_stock.tsx +387 -0
  29. package/examples/tpl_timeline.tsx +244 -0
  30. package/examples/tpl_tower.tsx +356 -0
  31. package/examples/tpl_wizard.tsx +223 -0
  32. package/package.json +11 -2
  33. package/src/bar_chart.tsx +5 -0
  34. package/src/callout.tsx +50 -17
  35. package/src/combobox.tsx +22 -6
  36. package/src/form_date_picker.tsx +2 -0
  37. package/src/form_picker.tsx +1 -0
  38. package/src/form_switch.tsx +1 -0
  39. package/src/form_text_input.tsx +2 -0
  40. package/src/icon.tsx +2 -0
  41. package/src/icon_button.tsx +5 -2
  42. package/src/inline_date_picker.tsx +110 -0
  43. package/src/inline_edit.tsx +228 -0
  44. package/src/inline_number_input.tsx +70 -0
  45. package/src/inline_select.tsx +91 -0
  46. package/src/inline_text_input.tsx +71 -0
  47. package/src/inline_time_picker.tsx +64 -0
  48. package/src/line_chart.tsx +4 -0
  49. package/src/list_item.tsx +5 -0
  50. package/src/number_input.tsx +12 -1
  51. package/src/page_content.tsx +5 -0
  52. package/src/section_heading.tsx +43 -29
  53. package/src/tag_input.tsx +202 -0
  54. package/src/time_picker.tsx +15 -3
  55. package/src/tooltip.tsx +19 -0
@@ -0,0 +1,91 @@
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
+ accessibilityLabel={accessibilityLabel}
65
+ trailing={
66
+ saving ? (
67
+ <ActivityIndicator size={16} color={colors.zinc[400]} />
68
+ ) : (
69
+ <Icon name="chevron-down" size={18} color={colors.zinc[400]} />
70
+ )
71
+ }
72
+ />
73
+ </PopoverTrigger>
74
+ <PopoverContent>
75
+ <PickerMenu
76
+ options={options}
77
+ value={value}
78
+ onValueChange={(next) => void pick(next)}
79
+ onRequestClose={() => setOpen(false)}
80
+ renderOptionContent={renderOptionContent}
81
+ />
82
+ </PopoverContent>
83
+ </Popover>
84
+ {error ? (
85
+ <Text size="xs" color="danger" style={{ marginTop: 4 }}>
86
+ {error}
87
+ </Text>
88
+ ) : null}
89
+ </View>
90
+ );
91
+ }
@@ -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/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();
@@ -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
  }
@@ -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
+ });
@@ -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