@lotics/ui 8.0.0 → 9.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 +171 -68
- package/examples/tpl_allocate.tsx +2 -2
- package/examples/tpl_attendance.tsx +2 -2
- package/examples/tpl_calendar.tsx +1 -1
- package/examples/tpl_dashboard.tsx +1 -1
- package/examples/tpl_item_list.tsx +1015 -124
- package/examples/tpl_pick.tsx +3 -3
- package/examples/tpl_pivot.tsx +1 -1
- package/examples/tpl_record.tsx +1354 -0
- package/examples/tpl_report.tsx +7 -7
- package/examples/tpl_rollup.tsx +6 -6
- package/examples/tpl_shifts.tsx +2 -2
- package/examples/tpl_statements.tsx +221 -0
- package/examples/tpl_stock.tsx +7 -7
- package/examples/tpl_task_board.tsx +16 -13
- package/examples/tpl_tasks.tsx +15 -28
- package/examples/tpl_tower.tsx +2 -2
- package/package.json +8 -1
- package/src/capture_row.tsx +59 -0
- package/src/checklist.tsx +104 -0
- package/src/chip.tsx +12 -3
- package/src/detail_row.tsx +137 -10
- package/src/inline_date_picker.tsx +8 -3
- package/src/inline_edit.tsx +40 -10
- package/src/inline_member_select.tsx +3 -0
- package/src/inline_number_input.tsx +5 -2
- package/src/inline_select.tsx +8 -3
- package/src/inline_tag_select.tsx +140 -0
- package/src/inline_text_input.tsx +5 -2
- package/src/inline_time_picker.tsx +5 -2
- package/src/ledger.tsx +220 -0
- package/src/locale.tsx +21 -0
- package/src/progress_bar.tsx +32 -1
- package/src/record_summary.tsx +101 -0
- package/src/section_heading.tsx +16 -8
- package/src/suggestion_chip.tsx +47 -0
- package/src/use_section_nav.test.ts +69 -0
- package/src/use_section_nav.ts +59 -0
- package/examples/tpl_billing.tsx +0 -344
- package/examples/tpl_detail.tsx +0 -232
- package/examples/tpl_directory.tsx +0 -260
- package/examples/tpl_intake.tsx +0 -206
- package/examples/tpl_order.tsx +0 -482
- package/examples/tpl_quick.tsx +0 -211
- package/examples/tpl_record_plain.tsx +0 -259
- package/examples/tpl_settings.tsx +0 -178
- package/examples/tpl_timeline.tsx +0 -244
- package/examples/tpl_wizard.tsx +0 -223
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { StyleSheet, View } from "react-native";
|
|
2
|
+
import { Button } from "./button";
|
|
3
|
+
import { colors } from "./colors";
|
|
4
|
+
import { useLoticsLocale } from "./locale";
|
|
5
|
+
import { TextInputField } from "./text_input_field";
|
|
6
|
+
|
|
7
|
+
export interface CaptureRowProps {
|
|
8
|
+
/** The draft text — controlled by the host list. */
|
|
9
|
+
value: string;
|
|
10
|
+
onChangeText: (next: string) => void;
|
|
11
|
+
/** Commit the draft (Enter, or the Save button that appears on type). The
|
|
12
|
+
* host trims/validates and clears `value`. */
|
|
13
|
+
onSubmit: () => void;
|
|
14
|
+
/** The invitation ("Add a task…"). */
|
|
15
|
+
placeholder?: string;
|
|
16
|
+
/** Save button title. Defaults to the locale's inline Save. */
|
|
17
|
+
saveLabel?: string;
|
|
18
|
+
accessibilityLabel: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The add-an-item row that closes an editable list: a dashed empty ring where
|
|
23
|
+
* the item's leading control will be, a borderless input on the same inset as
|
|
24
|
+
* the item titles above it, and a primary Save that appears once there's text.
|
|
25
|
+
* Enter commits too. One shape wherever a list grows in place — task
|
|
26
|
+
* checklists, simple item lists — so the affordance reads the same everywhere.
|
|
27
|
+
*/
|
|
28
|
+
export function CaptureRow(props: CaptureRowProps) {
|
|
29
|
+
const { value, onChangeText, onSubmit, placeholder, saveLabel, accessibilityLabel } = props;
|
|
30
|
+
const labels = useLoticsLocale().inline;
|
|
31
|
+
return (
|
|
32
|
+
<View style={styles.row}>
|
|
33
|
+
<View style={styles.emptyRing} />
|
|
34
|
+
<View style={styles.field}>
|
|
35
|
+
<View style={styles.grow}>
|
|
36
|
+
<TextInputField
|
|
37
|
+
value={value}
|
|
38
|
+
onChangeText={onChangeText}
|
|
39
|
+
onSubmitEditing={onSubmit}
|
|
40
|
+
placeholder={placeholder}
|
|
41
|
+
style={styles.input}
|
|
42
|
+
accessibilityLabel={accessibilityLabel}
|
|
43
|
+
/>
|
|
44
|
+
</View>
|
|
45
|
+
{value.trim() !== "" ? <Button title={saveLabel ?? labels.save} color="primary" onPress={onSubmit} /> : null}
|
|
46
|
+
</View>
|
|
47
|
+
</View>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const styles = StyleSheet.create({
|
|
52
|
+
row: { flexDirection: "row", alignItems: "center", gap: 12, minHeight: 32 },
|
|
53
|
+
emptyRing: { width: 20, height: 20, borderRadius: 10, borderWidth: 1.5, borderColor: colors.zinc[300], borderStyle: "dashed" },
|
|
54
|
+
field: { flex: 1, flexDirection: "row", alignItems: "center", gap: 8 },
|
|
55
|
+
grow: { flex: 1 },
|
|
56
|
+
// Borderless, matching an inline title's resting inset so the invitation
|
|
57
|
+
// lines up with the item titles above.
|
|
58
|
+
input: { borderWidth: 0, backgroundColor: "transparent", paddingVertical: 0 },
|
|
59
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
2
|
+
import { StyleSheet, View } from "react-native";
|
|
3
|
+
import { ActionMenu, type ActionMenuItem } from "./action_menu";
|
|
4
|
+
|
|
5
|
+
// The record-scoped CHECKLIST — the compound that owns the list's GEOMETRY
|
|
6
|
+
// (row height, ring/title alignment, one trailing column width) while the
|
|
7
|
+
// CONTENT stays composed: a `CheckCircle` (live or read-only) in `control`,
|
|
8
|
+
// a struck `InlineTextInput` or plain `Text` as the title, an
|
|
9
|
+
// `InlineMemberSelect` or nothing in `trailing`, and `menu` for the row's
|
|
10
|
+
// ⋯ options (Delete lives BEHIND the menu — indirection that prevents
|
|
11
|
+
// accidental destructive taps). Suggested tasks are NOT rows: offer the record
|
|
12
|
+
// type's commons as `SuggestionChip`s under the list (tap = materialize,
|
|
13
|
+
// ✕ = dismiss) — a pill can't be mistaken for a task. `CaptureRow` closes the
|
|
14
|
+
// list as its add affordance. There is deliberately NO monolithic Task
|
|
15
|
+
// component — richer task-management rows (expand affordances, tag/clip meta,
|
|
16
|
+
// board cards) compose their own anatomy directly.
|
|
17
|
+
//
|
|
18
|
+
// <Checklist trailingWidth={140}>
|
|
19
|
+
// <ChecklistRow control={<CheckCircle …/>} trailing={<InlineMemberSelect …/>}
|
|
20
|
+
// menu={{ items: [{ key: "delete", label: "Delete task", danger: true, … }],
|
|
21
|
+
// accessibilityLabel: "Task options: …" }}>
|
|
22
|
+
// <InlineTextInput background="transparent" struck={done} … />
|
|
23
|
+
// </ChecklistRow>
|
|
24
|
+
// <SuggestionChip label="Verify the tax ID" onAdd={materialize} onDismiss={dismiss} />
|
|
25
|
+
// <CaptureRow … />
|
|
26
|
+
// </Checklist>
|
|
27
|
+
|
|
28
|
+
interface ChecklistContextValue {
|
|
29
|
+
trailingWidth?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const ChecklistContext = createContext<ChecklistContextValue>({});
|
|
33
|
+
|
|
34
|
+
export interface ChecklistProps {
|
|
35
|
+
/** Fixed width of every row's trailing cell (an assignee select) — set it
|
|
36
|
+
* once so the column lines up; omit when rows carry no trailing. */
|
|
37
|
+
trailingWidth?: number;
|
|
38
|
+
children: ReactNode;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function Checklist(props: ChecklistProps) {
|
|
42
|
+
const { trailingWidth, children } = props;
|
|
43
|
+
return (
|
|
44
|
+
<ChecklistContext.Provider value={{ trailingWidth }}>
|
|
45
|
+
<View style={styles.list}>{children}</View>
|
|
46
|
+
</ChecklistContext.Provider>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface ChecklistRowBaseProps {
|
|
51
|
+
/** The leading toggle — a `CheckCircle` (omit `onChange` for a read-only ring). */
|
|
52
|
+
control: ReactNode;
|
|
53
|
+
/** The title — a struck transparent `InlineTextInput`, or plain `Text`. */
|
|
54
|
+
children: ReactNode;
|
|
55
|
+
/** The row's ⋯ options menu (destructive items last, `danger: true`) —
|
|
56
|
+
* Delete lives HERE, never as a bare ✕ on the row, so a stray tap can't
|
|
57
|
+
* destroy a task. Name the task in the label ("Task options: Book the
|
|
58
|
+
* carrier"). The ⋯ column aligns only when every row in the list carries
|
|
59
|
+
* a menu — keep its presence uniform per list. */
|
|
60
|
+
menu?: { items: ActionMenuItem[]; accessibilityLabel: string };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** `trailing` (wide surfaces) and `meta` (narrow) are exclusive by type. */
|
|
64
|
+
export type ChecklistRowProps = ChecklistRowBaseProps &
|
|
65
|
+
(
|
|
66
|
+
| {
|
|
67
|
+
/** The trailing cell (an `InlineMemberSelect`); sized by the list's `trailingWidth`. */
|
|
68
|
+
trailing?: ReactNode;
|
|
69
|
+
meta?: never;
|
|
70
|
+
}
|
|
71
|
+
| {
|
|
72
|
+
/** SECOND line under the title (indented past the ring) for a NARROW
|
|
73
|
+
* surface — a drawer or popover checklist: put the assignee/due
|
|
74
|
+
* editors here so the title keeps the full width and stays readable. */
|
|
75
|
+
meta?: ReactNode;
|
|
76
|
+
trailing?: never;
|
|
77
|
+
}
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
/** One task line: control · title (flex) · the aligned trailing cell · ⋯,
|
|
81
|
+
* with an optional indented `meta` line below for narrow surfaces. */
|
|
82
|
+
export function ChecklistRow(props: ChecklistRowProps) {
|
|
83
|
+
const { control, children, trailing, menu, meta } = props;
|
|
84
|
+
const { trailingWidth } = useContext(ChecklistContext);
|
|
85
|
+
return (
|
|
86
|
+
<View>
|
|
87
|
+
<View style={styles.row}>
|
|
88
|
+
{control}
|
|
89
|
+
<View style={styles.title}>{children}</View>
|
|
90
|
+
{trailing != null ? <View style={trailingWidth != null ? { width: trailingWidth } : null}>{trailing}</View> : null}
|
|
91
|
+
{menu != null ? <ActionMenu items={menu.items} accessibilityLabel={menu.accessibilityLabel} /> : null}
|
|
92
|
+
</View>
|
|
93
|
+
{meta != null ? <View style={styles.meta}>{meta}</View> : null}
|
|
94
|
+
</View>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const styles = StyleSheet.create({
|
|
99
|
+
list: { gap: 4 },
|
|
100
|
+
row: { flexDirection: "row", alignItems: "center", gap: 12, minHeight: 32 },
|
|
101
|
+
title: { flex: 1 },
|
|
102
|
+
// Indent past the ring (20) + the row gap (12) so meta aligns with the title.
|
|
103
|
+
meta: { paddingLeft: 32, paddingBottom: 4, flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 8, rowGap: 2 },
|
|
104
|
+
});
|
package/src/chip.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Ref } from "react";
|
|
2
2
|
import { IconButton } from "./icon_button";
|
|
3
|
+
import { useLoticsLocale } from "./locale";
|
|
3
4
|
import { PressableHighlight } from "./pressable_highlight";
|
|
4
5
|
import { chipSurfaceStyle } from "./control_surface";
|
|
5
6
|
import { StyleSheet, View } from "react-native";
|
|
@@ -8,19 +9,27 @@ interface ChipProps {
|
|
|
8
9
|
testID?: string;
|
|
9
10
|
children: React.ReactNode;
|
|
10
11
|
onPress?: () => void;
|
|
12
|
+
/** Accessible name of the press target. The pressable pill announces as a
|
|
13
|
+
* button either way — its name derives from the children when they are
|
|
14
|
+
* self-describing text; pass this when they aren't (icon-led content). */
|
|
15
|
+
accessibilityLabel?: string;
|
|
11
16
|
onDismiss?: () => void;
|
|
12
17
|
dismissTooltip?: string;
|
|
13
18
|
ref?: Ref<View>;
|
|
14
19
|
}
|
|
15
20
|
|
|
16
21
|
export function Chip(props: ChipProps) {
|
|
17
|
-
const { testID, children, onPress, onDismiss, dismissTooltip, ref } = props;
|
|
22
|
+
const { testID, children, onPress, accessibilityLabel, onDismiss, dismissTooltip, ref } = props;
|
|
23
|
+
const labels = useLoticsLocale().chip;
|
|
18
24
|
|
|
19
25
|
return (
|
|
20
26
|
<View ref={ref}>
|
|
21
27
|
{onPress ? (
|
|
22
28
|
<PressableHighlight
|
|
23
|
-
focusRing
|
|
29
|
+
focusRing
|
|
30
|
+
testID={testID}
|
|
31
|
+
accessibilityRole="button"
|
|
32
|
+
accessibilityLabel={accessibilityLabel}
|
|
24
33
|
onPress={onPress}
|
|
25
34
|
style={(state) => [chipSurfaceStyle(state), styles.pillLayout, onDismiss && styles.pillWithDismiss]}
|
|
26
35
|
>
|
|
@@ -34,7 +43,7 @@ export function Chip(props: ChipProps) {
|
|
|
34
43
|
<IconButton
|
|
35
44
|
icon="x"
|
|
36
45
|
tooltip={dismissTooltip}
|
|
37
|
-
accessibilityLabel={dismissTooltip ??
|
|
46
|
+
accessibilityLabel={dismissTooltip ?? labels.remove}
|
|
38
47
|
onPress={onDismiss}
|
|
39
48
|
testID={testID ? `${testID}-remove` : undefined}
|
|
40
49
|
/>
|
package/src/detail_row.tsx
CHANGED
|
@@ -1,24 +1,107 @@
|
|
|
1
|
-
import { ReactNode } from "react";
|
|
2
|
-
import { StyleSheet, View } from "react-native";
|
|
1
|
+
import { createContext, ReactNode, useContext, useState } from "react";
|
|
2
|
+
import { StyleProp, StyleSheet, View, ViewStyle } from "react-native";
|
|
3
|
+
import { INLINE_CONTROL_HEIGHT } from "./inline_edit";
|
|
3
4
|
import { Text } from "./text";
|
|
4
5
|
|
|
6
|
+
interface DetailTableColumns {
|
|
7
|
+
labelWidth: number;
|
|
8
|
+
trailingWidth?: number;
|
|
9
|
+
minHeight: number;
|
|
10
|
+
/** Container too narrow for the side-by-side columns — rows render STACKED
|
|
11
|
+
* (label above a full-width value row). */
|
|
12
|
+
stacked: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const DetailTableContext = createContext<DetailTableColumns | null>(null);
|
|
16
|
+
|
|
17
|
+
/** The value cell must keep at least this width before the table gives up its
|
|
18
|
+
* side-by-side columns and stacks — below it, editors get crushed. */
|
|
19
|
+
const MIN_VALUE_WIDTH = 160;
|
|
20
|
+
|
|
21
|
+
export interface DetailTableProps {
|
|
22
|
+
/** Label column width (px). Default 130. */
|
|
23
|
+
labelWidth?: number;
|
|
24
|
+
/** Reserved width (px) for the trailing column. Set it when ANY row carries a
|
|
25
|
+
* trailing action/unit/badge: EVERY row then reserves the column, so all
|
|
26
|
+
* value cells share ONE width and the trailing items align at one x — the
|
|
27
|
+
* table look. Omit when no row has a trailing (values fill to the edge). */
|
|
28
|
+
trailingWidth?: number;
|
|
29
|
+
/** Min row height. Default 40 (`INLINE_CONTROL_HEIGHT`) so editor, static,
|
|
30
|
+
* and custom rows all sit on the inline-control baseline grid. */
|
|
31
|
+
minHeight?: number;
|
|
32
|
+
/** The usable width the value cell must keep before the table STACKS
|
|
33
|
+
* (default 160). Raise it when a cell holds MORE than one editor (an
|
|
34
|
+
* amount + method pair) — side-by-side columns that technically fit can
|
|
35
|
+
* still crush a multi-editor cell. */
|
|
36
|
+
minValueWidth?: number;
|
|
37
|
+
/** `DetailRow`s (each inherits the table's columns; row props override). */
|
|
38
|
+
children: ReactNode;
|
|
39
|
+
style?: StyleProp<ViewStyle>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The column grid for a stack of `DetailRow`s — label · value · trailing laid
|
|
44
|
+
* out like a TABLE: one label width, one value width, one trailing width, set
|
|
45
|
+
* ONCE on the parent instead of repeated (and drifting) per row. Rows without a
|
|
46
|
+
* trailing still reserve the trailing column, so a `Copy` action on one row
|
|
47
|
+
* never makes its editor narrower than its neighbours'.
|
|
48
|
+
*
|
|
49
|
+
* <DetailTable labelWidth={130} trailingWidth={96}>
|
|
50
|
+
* <DetailRow label="Reference" trailing={<Button title="Copy" … />}>
|
|
51
|
+
* <InlineTextInput … />
|
|
52
|
+
* </DetailRow>
|
|
53
|
+
* <DetailRow label="Priority"><InlineSelect … /></DetailRow>
|
|
54
|
+
* </DetailTable>
|
|
55
|
+
*
|
|
56
|
+
* It owns the 6px row gap (zinc-50 editor chips need visible separation) and
|
|
57
|
+
* the 40px row height of the inline-control grid.
|
|
58
|
+
*
|
|
59
|
+
* RESPONSIVE: the table measures its own container (not the viewport — a table
|
|
60
|
+
* inside a narrow Drawer on a wide screen must adapt too). When the columns
|
|
61
|
+
* would crush the value cell below a usable width, every row STACKS: label
|
|
62
|
+
* above a full-width value row (trailing stays beside the value). No prop —
|
|
63
|
+
* the switch is automatic.
|
|
64
|
+
*/
|
|
65
|
+
export function DetailTable(props: DetailTableProps) {
|
|
66
|
+
const { labelWidth = 130, trailingWidth, minHeight = INLINE_CONTROL_HEIGHT, minValueWidth = MIN_VALUE_WIDTH, children, style } = props;
|
|
67
|
+
const [width, setWidth] = useState<number | null>(null);
|
|
68
|
+
const stacked =
|
|
69
|
+
width != null && width < labelWidth + (trailingWidth ?? 0) + minValueWidth + 24;
|
|
70
|
+
return (
|
|
71
|
+
<DetailTableContext.Provider value={{ labelWidth, trailingWidth, minHeight, stacked }}>
|
|
72
|
+
<View
|
|
73
|
+
onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
|
|
74
|
+
// Measure-then-REVEAL: the unmeasured first frame renders invisible
|
|
75
|
+
// (opacity 0), so the first PAINT the user sees is already in the
|
|
76
|
+
// right mode — no table→stacked reshuffle as a drawer opens.
|
|
77
|
+
style={[stacked ? styles.tableStacked : styles.table, width == null ? styles.unmeasured : null, style]}
|
|
78
|
+
>
|
|
79
|
+
{children}
|
|
80
|
+
</View>
|
|
81
|
+
</DetailTableContext.Provider>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
5
85
|
export interface DetailRowProps {
|
|
6
86
|
/** The field label — rendered `sm muted`. */
|
|
7
87
|
label: ReactNode;
|
|
8
88
|
/** The value — any node (a `Text`, a `Badge`, a stack). */
|
|
9
89
|
children: ReactNode;
|
|
10
90
|
/** An optional right-side slot, rendered as the LAST child of the row (after
|
|
11
|
-
* `children`) — a small action `Button`, a status `Badge`,
|
|
12
|
-
*
|
|
13
|
-
*
|
|
91
|
+
* `children`) — a small action `Button`, a status `Badge`, an `IconButton`.
|
|
92
|
+
* NOT for units: "kg" / "₫" belong IN the value (`InlineNumberInput format`).
|
|
93
|
+
* In form mode (`labelWidth` set) the value column already fills
|
|
94
|
+
* the row, so the trailing pins to the right edge automatically. Inside a
|
|
95
|
+
* `DetailTable` with `trailingWidth` it sits in the reserved trailing column. */
|
|
14
96
|
trailing?: ReactNode;
|
|
15
97
|
/** Fixed label-column width (px) to align labels in a left column — for a peek
|
|
16
98
|
* or form-like stack. Omit for a spread row: the label takes the slack and the
|
|
17
|
-
* value sits at the right edge (the default drawer-detail look).
|
|
99
|
+
* value sits at the right edge (the default drawer-detail look). Inside a
|
|
100
|
+
* `DetailTable` the table's `labelWidth` applies; this overrides per row. */
|
|
18
101
|
labelWidth?: number;
|
|
19
102
|
/** Label text size — `xs` for a compact peek, `sm` (default) for a drawer. */
|
|
20
103
|
labelSize?: "xs" | "sm";
|
|
21
|
-
/** Min row height. Default 28. */
|
|
104
|
+
/** Min row height. Default 28 (or the `DetailTable`'s `minHeight`). */
|
|
22
105
|
minHeight?: number;
|
|
23
106
|
}
|
|
24
107
|
|
|
@@ -27,23 +110,61 @@ export interface DetailRowProps {
|
|
|
27
110
|
* `PressableRow` / `TableRow`). Spread by default (muted label left, value pushed
|
|
28
111
|
* to the right edge); pass `labelWidth` to align labels in a fixed left column —
|
|
29
112
|
* the value column then FILLS the rest of the row, so a stack of inline editors
|
|
30
|
-
* all span the same width (none jumps wider on edit).
|
|
113
|
+
* all span the same width (none jumps wider on edit). For a stack of rows, wrap
|
|
114
|
+
* them in a `DetailTable`: the label/trailing column widths live ONCE on the
|
|
115
|
+
* parent and every row aligns.
|
|
31
116
|
*/
|
|
32
117
|
export function DetailRow(props: DetailRowProps) {
|
|
33
|
-
const
|
|
118
|
+
const table = useContext(DetailTableContext);
|
|
119
|
+
const { label, children, trailing, labelSize = "sm" } = props;
|
|
120
|
+
const labelWidth = props.labelWidth ?? table?.labelWidth;
|
|
121
|
+
const minHeight = props.minHeight ?? table?.minHeight ?? 28;
|
|
34
122
|
const form = labelWidth != null;
|
|
123
|
+
if (table?.stacked) {
|
|
124
|
+
// Stacked mode wears the FORM grammar: the label renders exactly like a
|
|
125
|
+
// `FormField` label (medium, default ink), so a narrow record surface
|
|
126
|
+
// reads as one vocabulary with forms. The COMPONENTS stay separate — a
|
|
127
|
+
// FormField wraps a draft control validated and committed together, an
|
|
128
|
+
// inline editor self-persists — only the look converges.
|
|
129
|
+
return (
|
|
130
|
+
<View style={styles.stackedRow}>
|
|
131
|
+
<Text weight="medium" numberOfLines={1}>
|
|
132
|
+
{label}
|
|
133
|
+
</Text>
|
|
134
|
+
<View style={[styles.stackedValueRow, { minHeight }]}>
|
|
135
|
+
<View style={styles.value}>{children}</View>
|
|
136
|
+
{trailing != null ? trailing : null}
|
|
137
|
+
</View>
|
|
138
|
+
</View>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
35
141
|
return (
|
|
36
142
|
<View style={[styles.row, { minHeight }]}>
|
|
37
143
|
<Text size={labelSize} color="muted" style={form ? { width: labelWidth } : styles.flexLabel}>
|
|
38
144
|
{label}
|
|
39
145
|
</Text>
|
|
40
146
|
{form ? <View style={styles.value}>{children}</View> : children}
|
|
41
|
-
{
|
|
147
|
+
{table?.trailingWidth != null ? (
|
|
148
|
+
<View style={[styles.trailingCell, { width: table.trailingWidth }]}>{trailing}</View>
|
|
149
|
+
) : trailing != null ? (
|
|
150
|
+
trailing
|
|
151
|
+
) : null}
|
|
42
152
|
</View>
|
|
43
153
|
);
|
|
44
154
|
}
|
|
45
155
|
|
|
46
156
|
const styles = StyleSheet.create({
|
|
157
|
+
// 6px between rows — the zinc-50 editor chips need visible separation.
|
|
158
|
+
table: { gap: 6 },
|
|
159
|
+
// Stacked (narrow) mode: label-over-value blocks need more air between rows.
|
|
160
|
+
tableStacked: { gap: 14 },
|
|
161
|
+
unmeasured: { opacity: 0 },
|
|
162
|
+
stackedRow: { gap: 4 },
|
|
163
|
+
stackedValueRow: {
|
|
164
|
+
flexDirection: "row",
|
|
165
|
+
alignItems: "center",
|
|
166
|
+
gap: 12,
|
|
167
|
+
},
|
|
47
168
|
row: {
|
|
48
169
|
flexDirection: "row",
|
|
49
170
|
alignItems: "center",
|
|
@@ -56,4 +177,10 @@ const styles = StyleSheet.create({
|
|
|
56
177
|
// child fills it edge-to-edge. A status Badge or action is NOT a value — pass
|
|
57
178
|
// it as `trailing` (pins right); one placed here would stretch full-width.
|
|
58
179
|
value: { flex: 1 },
|
|
180
|
+
// The reserved trailing column: every row renders it (empty or not) so value
|
|
181
|
+
// cells share one width and trailing items align at one x, like a table.
|
|
182
|
+
trailingCell: {
|
|
183
|
+
flexDirection: "row",
|
|
184
|
+
alignItems: "center",
|
|
185
|
+
},
|
|
59
186
|
});
|
|
@@ -7,7 +7,8 @@ import { DatePickerPanel } from "./date_picker";
|
|
|
7
7
|
import { isoHasTime } from "./date_picker_value";
|
|
8
8
|
import { formatDate } from "./format_date";
|
|
9
9
|
import { ActivityIndicator } from "./activity_indicator";
|
|
10
|
-
import { InlineEditView } from "./inline_edit";
|
|
10
|
+
import { type InlineEditBackground, InlineEditView } from "./inline_edit";
|
|
11
|
+
import { useLoticsLocale } from "./locale";
|
|
11
12
|
|
|
12
13
|
export interface InlineDatePickerProps {
|
|
13
14
|
/** ISO date (`2026-05-22`) or datetime (`2026-05-22T14:30`). */
|
|
@@ -23,6 +24,8 @@ export interface InlineDatePickerProps {
|
|
|
23
24
|
locale?: string;
|
|
24
25
|
disabled?: boolean;
|
|
25
26
|
accessibilityLabel?: string;
|
|
27
|
+
/** Resting surface — "tint" (default, the zinc-50 chip) or "transparent". */
|
|
28
|
+
background?: InlineEditBackground;
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
/**
|
|
@@ -33,7 +36,8 @@ export interface InlineDatePickerProps {
|
|
|
33
36
|
* dismissing without a change reverts.
|
|
34
37
|
*/
|
|
35
38
|
export function InlineDatePicker(props: InlineDatePickerProps) {
|
|
36
|
-
const { value, onSave, format = "date", optionalTime, placeholder, locale, disabled, accessibilityLabel } = props;
|
|
39
|
+
const { value, onSave, format = "date", optionalTime, placeholder, locale, disabled, accessibilityLabel , background } = props;
|
|
40
|
+
const labels = useLoticsLocale().inline;
|
|
37
41
|
const [open, setOpen] = useState(false);
|
|
38
42
|
const [draft, setDraft] = useState<string | null>(value);
|
|
39
43
|
const [saving, setSaving] = useState(false);
|
|
@@ -50,7 +54,7 @@ export function InlineDatePicker(props: InlineDatePickerProps) {
|
|
|
50
54
|
try {
|
|
51
55
|
await onSave(next);
|
|
52
56
|
} catch (e) {
|
|
53
|
-
setError(e instanceof Error && e.message ? e.message :
|
|
57
|
+
setError(e instanceof Error && e.message ? e.message : labels.saveError);
|
|
54
58
|
} finally {
|
|
55
59
|
setSaving(false);
|
|
56
60
|
}
|
|
@@ -81,6 +85,7 @@ export function InlineDatePicker(props: InlineDatePickerProps) {
|
|
|
81
85
|
<Popover open={open && !disabled} onOpenChange={onOpenChange} side="bottom" align="start">
|
|
82
86
|
<PopoverTrigger>
|
|
83
87
|
<InlineEditView
|
|
88
|
+
background={background}
|
|
84
89
|
display={display}
|
|
85
90
|
placeholder={placeholder}
|
|
86
91
|
disabled={disabled}
|
package/src/inline_edit.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { useCallback, useRef, useState, type ReactNode, type Ref } from "react";
|
|
|
2
2
|
import { View, StyleSheet, type GestureResponderEvent, type TextStyle } from "react-native";
|
|
3
3
|
import { Text } from "./text";
|
|
4
4
|
import { IconButton } from "./icon_button";
|
|
5
|
+
import { useLoticsLocale } from "./locale";
|
|
5
6
|
import { ActivityIndicator } from "./activity_indicator";
|
|
6
7
|
import { FocusRingPressable } from "./focus_ring_pressable";
|
|
7
8
|
import { colors } from "./colors";
|
|
@@ -28,6 +29,7 @@ export function useInlineEdit<T>(opts: {
|
|
|
28
29
|
equals?: (a: T, b: T) => boolean;
|
|
29
30
|
}) {
|
|
30
31
|
const { value, onSave, equals } = opts;
|
|
32
|
+
const labels = useLoticsLocale().inline;
|
|
31
33
|
const [editing, setEditing] = useState(false);
|
|
32
34
|
const [draft, setDraft] = useState<T>(value);
|
|
33
35
|
const [saving, setSaving] = useState(false);
|
|
@@ -69,7 +71,7 @@ export function useInlineEdit<T>(opts: {
|
|
|
69
71
|
setEditing(false);
|
|
70
72
|
} catch (e) {
|
|
71
73
|
// Stay in edit mode so the entry isn't lost — show the error, re-arm.
|
|
72
|
-
setError(e instanceof Error && e.message ? e.message :
|
|
74
|
+
setError(e instanceof Error && e.message ? e.message : labels.saveError);
|
|
73
75
|
active.current = true;
|
|
74
76
|
} finally {
|
|
75
77
|
setSaving(false);
|
|
@@ -81,6 +83,13 @@ export function useInlineEdit<T>(opts: {
|
|
|
81
83
|
return { editing, draft, setDraft, saving, error, begin, cancel, commit };
|
|
82
84
|
}
|
|
83
85
|
|
|
86
|
+
/** The resting surface of an inline editor. "tint" (default) is the zinc-50
|
|
87
|
+
* chip — THE editability affordance on surfaces that MIX editable and static
|
|
88
|
+
* values. "transparent" drops it for DENSE, uniformly-editable surfaces (a
|
|
89
|
+
* task list/board where every cell edits — the chip repeated everywhere is
|
|
90
|
+
* noise, and hover/focus still reveal the input). */
|
|
91
|
+
export type InlineEditBackground = "tint" | "transparent";
|
|
92
|
+
|
|
84
93
|
interface InlineEditFrameProps {
|
|
85
94
|
editing: boolean;
|
|
86
95
|
/** The formatted current value, shown in view mode. Empty → placeholder. */
|
|
@@ -101,6 +110,8 @@ interface InlineEditFrameProps {
|
|
|
101
110
|
accessibilityLabel?: string;
|
|
102
111
|
/** Strike + mute the resting value (a completed item that stays editable). */
|
|
103
112
|
struck?: boolean;
|
|
113
|
+
/** Resting surface — see {@link InlineEditBackground}. Default "tint". */
|
|
114
|
+
background?: InlineEditBackground;
|
|
104
115
|
}
|
|
105
116
|
|
|
106
117
|
interface InlineEditViewProps {
|
|
@@ -119,20 +130,23 @@ interface InlineEditViewProps {
|
|
|
119
130
|
active?: boolean;
|
|
120
131
|
/** Strike + mute the resting value (a completed/superseded item that stays editable). */
|
|
121
132
|
struck?: boolean;
|
|
133
|
+
/** Resting surface — see {@link InlineEditBackground}. Default "tint". */
|
|
134
|
+
background?: InlineEditBackground;
|
|
122
135
|
ref?: Ref<View>;
|
|
123
136
|
}
|
|
124
137
|
|
|
125
138
|
/**
|
|
126
139
|
* The view-mode box of an inline-editable field: the value as plain text in a
|
|
127
140
|
* `FocusRingPressable` (an input, so it hovers via its BORDER — the transparent
|
|
128
|
-
* edge reveals on hover, never a grey wash; the
|
|
129
|
-
*
|
|
141
|
+
* edge reveals on hover, never a grey wash; the cursor stays the default arrow —
|
|
142
|
+
* the zinc-50 chip + hover-border are the affordance, a pointer would read as a
|
|
143
|
+
* button), at the kit's control height with a transparent border — so
|
|
130
144
|
* swapping to an input, or floating a dropdown above it, never shifts the
|
|
131
145
|
* layout. Used by `InlineEditFrame`, and as the overlay trigger for the
|
|
132
146
|
* select/date inline editors (it forwards ref + onPress to a `PopoverTrigger`).
|
|
133
147
|
*/
|
|
134
148
|
export function InlineEditView(props: InlineEditViewProps) {
|
|
135
|
-
const { display, placeholder, onPress, disabled, accessibilityLabel, trailing, active, struck, ref } = props;
|
|
149
|
+
const { display, placeholder, onPress, disabled, accessibilityLabel, trailing, active, struck, background = "tint", ref } = props;
|
|
136
150
|
// Stop the press here so an inline editor nested in a pressable row (a task
|
|
137
151
|
// row that expands on press) edits the field instead of triggering the row.
|
|
138
152
|
const handlePress = onPress
|
|
@@ -152,7 +166,7 @@ export function InlineEditView(props: InlineEditViewProps) {
|
|
|
152
166
|
// An inline-edit field is an input — it hovers via its BORDER, not a grey
|
|
153
167
|
// wash: the transparent edge reveals on hover (the kit's hover-border),
|
|
154
168
|
// then fills + rings when open. Suppressed while open (the ring leads).
|
|
155
|
-
style={(state) => [styles.view, CONTROL_TRANSITION, active && styles.viewActive, !active && state.hovered && !disabled && styles.viewHovered]}
|
|
169
|
+
style={(state) => [styles.view, (background === "transparent" || disabled) && styles.viewTransparent, CONTROL_TRANSITION, active && styles.viewActive, !active && state.hovered && !disabled && styles.viewHovered]}
|
|
156
170
|
>
|
|
157
171
|
{typeof display === "string" ? (
|
|
158
172
|
<Text numberOfLines={1} style={[viewTextStyle, display ? null : styles.placeholder, struck ? styles.struck : null]}>
|
|
@@ -174,6 +188,7 @@ export function InlineEditView(props: InlineEditViewProps) {
|
|
|
174
188
|
* directly as a `Popover` trigger instead.)
|
|
175
189
|
*/
|
|
176
190
|
export function InlineEditFrame(props: InlineEditFrameProps) {
|
|
191
|
+
const labels = useLoticsLocale().inline;
|
|
177
192
|
const {
|
|
178
193
|
editing,
|
|
179
194
|
display,
|
|
@@ -188,11 +203,13 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
|
|
|
188
203
|
disabled,
|
|
189
204
|
accessibilityLabel,
|
|
190
205
|
struck,
|
|
206
|
+
background,
|
|
191
207
|
} = props;
|
|
192
208
|
|
|
193
209
|
if (!editing) {
|
|
194
210
|
return (
|
|
195
211
|
<InlineEditView
|
|
212
|
+
background={background}
|
|
196
213
|
display={display}
|
|
197
214
|
placeholder={placeholder}
|
|
198
215
|
onPress={onBegin}
|
|
@@ -218,8 +235,8 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
|
|
|
218
235
|
</View>
|
|
219
236
|
{controls === "buttons" ? (
|
|
220
237
|
<View style={styles.buttons}>
|
|
221
|
-
<IconButton icon="check" color="primary" size="sm" tooltip=
|
|
222
|
-
<IconButton icon="x" color="secondary" size="sm" tooltip=
|
|
238
|
+
<IconButton icon="check" color="primary" size="sm" tooltip={labels.save} onPress={onCommit} disabled={saving} />
|
|
239
|
+
<IconButton icon="x" color="secondary" size="sm" tooltip={labels.cancel} onPress={onCancel} disabled={saving} />
|
|
223
240
|
</View>
|
|
224
241
|
) : null}
|
|
225
242
|
</View>
|
|
@@ -247,24 +264,37 @@ export const inlineValueTextStyle: TextStyle = {
|
|
|
247
264
|
const viewTextStyle = [inlineValueTextStyle, { flex: 1 }];
|
|
248
265
|
|
|
249
266
|
const styles = StyleSheet.create({
|
|
267
|
+
// At REST an editor sits on a zinc-50 chip — THE editability affordance: a
|
|
268
|
+
// read-only `InlineStatic` stays flat, so editable and static values are
|
|
269
|
+
// distinguishable without hovering (they were pixel-identical before, and
|
|
270
|
+
// users could not discover what was editable).
|
|
250
271
|
view: {
|
|
251
272
|
minHeight: INLINE_CONTROL_HEIGHT,
|
|
252
273
|
borderRadius: CONTROL_RADIUS,
|
|
253
274
|
borderWidth: 1,
|
|
254
275
|
borderColor: "transparent",
|
|
276
|
+
backgroundColor: colors.zinc[50],
|
|
255
277
|
paddingHorizontal: 8,
|
|
256
278
|
flexDirection: "row",
|
|
257
279
|
alignItems: "center",
|
|
258
280
|
gap: 6,
|
|
281
|
+
// An inline editor is an INPUT, not a button — keep the default arrow
|
|
282
|
+
// cursor (the chip + hover-border carry the affordance; a pointer reads
|
|
283
|
+
// as a button press). Overrides the Pressable's pointer default.
|
|
284
|
+
cursor: "auto",
|
|
259
285
|
},
|
|
286
|
+
// The transparent opt-out — dense, uniformly-editable surfaces.
|
|
287
|
+
viewTransparent: { backgroundColor: "transparent" },
|
|
260
288
|
// Open (popover showing): the transparent edge fills in to the kit's 1px border
|
|
261
|
-
// under the 2px active ring — identical to a focused input
|
|
289
|
+
// under the 2px active ring — identical to a focused input; the chip clears to
|
|
290
|
+
// the input's white surface.
|
|
262
291
|
viewActive: {
|
|
263
292
|
borderColor: colors.border,
|
|
293
|
+
backgroundColor: colors.white,
|
|
264
294
|
boxShadow: FOCUS_RING,
|
|
265
295
|
},
|
|
266
|
-
// Hover (not open): reveal the kit hover-border on the
|
|
267
|
-
//
|
|
296
|
+
// Hover (not open): reveal the kit hover-border on the chip — the input-family
|
|
297
|
+
// affordance.
|
|
268
298
|
viewHovered: {
|
|
269
299
|
borderColor: HOVER_BORDER,
|
|
270
300
|
},
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useCallback, useMemo } from "react";
|
|
2
|
+
import type { InlineEditBackground } from "./inline_edit";
|
|
2
3
|
import { InlineSelect } from "./inline_select";
|
|
3
4
|
import { MemberChip } from "./member_chip";
|
|
4
5
|
import type { MemberSelectMember } from "./member_select";
|
|
@@ -18,6 +19,8 @@ interface InlineMemberSelectProps {
|
|
|
18
19
|
placeholder?: string;
|
|
19
20
|
disabled?: boolean;
|
|
20
21
|
accessibilityLabel?: string;
|
|
22
|
+
/** Resting surface — "tint" (default, the zinc-50 chip) or "transparent". */
|
|
23
|
+
background?: InlineEditBackground;
|
|
21
24
|
/** Show an in-menu search box to filter the roster; default false. */
|
|
22
25
|
searchable?: boolean;
|
|
23
26
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
2
|
import type { KeyboardEvent } from "react";
|
|
3
3
|
import { NumberInput } from "./number_input";
|
|
4
|
-
import { InlineEditFrame, useInlineEdit, type InlineEditControls } from "./inline_edit";
|
|
4
|
+
import { type InlineEditBackground, InlineEditFrame, useInlineEdit, type InlineEditControls } from "./inline_edit";
|
|
5
5
|
|
|
6
6
|
export interface InlineNumberInputProps {
|
|
7
7
|
value: number | null;
|
|
@@ -15,6 +15,8 @@ export interface InlineNumberInputProps {
|
|
|
15
15
|
controls?: InlineEditControls;
|
|
16
16
|
disabled?: boolean;
|
|
17
17
|
accessibilityLabel?: string;
|
|
18
|
+
/** Resting surface — "tint" (default, the zinc-50 chip) or "transparent". */
|
|
19
|
+
background?: InlineEditBackground;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
/**
|
|
@@ -23,7 +25,7 @@ export interface InlineNumberInputProps {
|
|
|
23
25
|
* the bare numeric field at the same height, so the form never reflows.
|
|
24
26
|
*/
|
|
25
27
|
export function InlineNumberInput(props: InlineNumberInputProps) {
|
|
26
|
-
const { value, onSave, format, placeholder, min, max, controls = "blur", disabled, accessibilityLabel } = props;
|
|
28
|
+
const { value, onSave, format, placeholder, min, max, controls = "blur", disabled, accessibilityLabel , background } = props;
|
|
27
29
|
const edit = useInlineEdit<number | null>({ value, onSave });
|
|
28
30
|
|
|
29
31
|
const onKeyDown = useCallback(
|
|
@@ -43,6 +45,7 @@ export function InlineNumberInput(props: InlineNumberInputProps) {
|
|
|
43
45
|
|
|
44
46
|
return (
|
|
45
47
|
<InlineEditFrame
|
|
48
|
+
background={background}
|
|
46
49
|
editing={edit.editing}
|
|
47
50
|
display={display}
|
|
48
51
|
placeholder={placeholder}
|