@lotics/ui 3.6.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.
- package/AGENTS.md +323 -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_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 +11 -2
- package/src/bar_chart.tsx +5 -0
- package/src/combobox.tsx +22 -6
- 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/inline_date_picker.tsx +110 -0
- package/src/inline_edit.tsx +228 -0
- package/src/inline_number_input.tsx +70 -0
- package/src/inline_select.tsx +91 -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/list_item.tsx +5 -0
- package/src/number_input.tsx +12 -1
- package/src/page_content.tsx +5 -0
- package/src/section_heading.tsx +43 -29
- package/src/tag_input.tsx +202 -0
- package/src/time_picker.tsx +15 -3
- package/src/tooltip.tsx +19 -0
|
@@ -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
|
+
}
|
package/src/line_chart.tsx
CHANGED
|
@@ -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;
|
package/src/number_input.tsx
CHANGED
|
@@ -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,
|
package/src/page_content.tsx
CHANGED
|
@@ -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();
|
package/src/section_heading.tsx
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
{
|
|
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
|
+
});
|
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
|
|