@lotics/ui 7.9.0 → 7.11.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 CHANGED
@@ -69,7 +69,8 @@ Pick by capability, not by name. (→ the source file for the API.)
69
69
  via `TimePicker`.
70
70
  - **Edit a record's fields in place** — the `Inline*` family: `InlineTextInput` ·
71
71
  `InlineNumberInput` · `InlineSelect` · `InlineMemberSelect` · `InlineDatePicker` ·
72
- `InlineTimePicker` (see §Data entry).
72
+ `InlineTimePicker`; a READ-ONLY field in that same column uses `InlineStatic` (matches the
73
+ editor box exactly, no input chrome, so it aligns pixel-for-pixel) (see §Data entry).
73
74
  - **Tasks / to-dos** — there is NO Task component (a task row varies too much app-to-app to abstract
74
75
  well — a Reminders row and a Linear column-grid share almost nothing). Compose the row directly:
75
76
  `CheckCircle` (the completion ring — fills with a spring, distinct from the square `Checkbox`) + an
@@ -112,10 +113,11 @@ Pick by capability, not by name. (→ the source file for the API.)
112
113
  `CardFooter`), `Section` (the card-less twin of `Card`: bare gap-spaced region + `SectionHeading`/
113
114
  `SectionHeadingTitle`/`SectionHeadingMeta`; no body component — children are the body), `SectionCard`,
114
115
  `PageHeader` /
115
- `PageContent`, `Stack`, `Spacer`, `Divider`, `Accordion`, `Tabs`, `SegmentedControl`, `Stepper`.
116
+ `PageContent`, `Stack`, `Spacer`, `Divider`, `Accordion`, `Tabs`, `SegmentedControl`, `Stepper`,
117
+ `DangerZone` (the destructive section — delete/archive — set apart at the bottom of a record/settings surface).
116
118
  - **Rows & registers** — `PressableRow` (THE register row), `ListItem`, `MenuButton`,
117
- `MenuListItem`, `DetailRow` (label+value for drawers/peeks), `ActionMenu` (⋯), `FloatingActionBar`
118
- (bulk-select bar).
119
+ `MenuListItem`, `DetailRow` (label+value for drawers/peeks; optional `trailing` slot for a
120
+ right-side action/badge/unit), `ActionMenu` (⋯), `FloatingActionBar` (bulk-select bar).
119
121
  - **Filters & view controls** — `SearchInput`, `ChipGroup`, `FilterChip` (+ `RangeSlider`,
120
122
  `Counter`), `Chip`.
121
123
  - **Overlays** — `Dialog` (centered card over a scrim), `Modal` (+ `ModalHeader`/`ModalBody`/
@@ -188,15 +190,22 @@ hover reveals its border (it's a field — no grey tint, no pencil that shifts),
188
190
  reflow, the whole point), and it commits on blur (Enter saves, Escape reverts) or via
189
191
  `controls="buttons"` (✓ primary / ✕). One per type — `InlineTextInput` · `InlineNumberInput`
190
192
  (`format` for currency/units) · `InlineSelect` (plain options OR `renderOptionContent`; floats an
191
- `OptionList` in a popover so the row never grows; `renderSelected` renders the RESTING value as a
192
- node — a colored `OptionBadge`, not just a label) · `InlineMemberSelect` (a `MemberChip` at rest →
193
+ `OptionList` in a popover so the row never grows; the RESTING value renders like its option
194
+ (`renderOptionContent` by default; `renderSelected` overrides) — a colored `OptionBadge`, not just a label) · `InlineMemberSelect` (a `MemberChip` at rest →
193
195
  member picker; the inline twin of `MemberSelect`) · `InlineDatePicker` (`format="datetime"` for
194
196
  always-on time; `optionalTime` to let the user ADD/REMOVE a time — the value's own shape, date vs
195
197
  datetime, is the source of truth) · `InlineTimePicker` — all
196
198
  on `useInlineEdit` + `InlineEditView` (custom inputs join via those). `onSave` is async: the
197
199
  saving spinner sits INSIDE the control (never a sibling — that reflows); an error shows inline
198
- without losing the edit. Pair with `DetailRow` (label left, inline value right). Not every field
199
- is a same-height swap — a tag field, a status, or an attachment grid edit in place too (below).
200
+ without losing the edit. Pair with `DetailRow` (label left, inline value right). A READ-ONLY value
201
+ in the same column — a computed total, a system ID, a synced/locked field is `InlineStatic`: it
202
+ copies the editor box exactly (height, padding, 1px transparent border) but is non-interactive, so
203
+ it sits FLUSH with the editors instead of reading as a disabled input. To hang a right-side
204
+ action/badge/unit off a row, use `DetailRow`'s `trailing` slot (wrap the editor in a `flex:1` View
205
+ so it takes the slack and the trailing pins right) — see the "Row anatomy" section of
206
+ `tpl_record_plain`, which also reads top→bottom as a full drawer record (header → fields →
207
+ `DangerZone`). Not every field is a same-height swap — a tag field, a status, or an attachment grid
208
+ edit in place too (below).
200
209
 
201
210
  ### Fieldset form — fields lay out on a RESPONSIVE two-column grid
202
211
  Never hard-code columns, never 3-up. A fieldset is `flexDirection:row, flexWrap:wrap,
@@ -818,7 +827,7 @@ recipe for a screen JOB; copy and adapt). Pick by the job:
818
827
  transactional form: find-or-create + line items + attachment CRUD + 2-col grid) · `tpl_billing`
819
828
  (charges→invoice→collect: the invoice DOCUMENT is the unit — editable charge lines + method,
820
829
  live total, status, inline-gated issue; validated receipt; separate deposit) · `tpl_quick`
821
- (quick-capture log) · `tpl_wizard` (`Stepper` form + review) · `tpl_record` (inline edit + Stage
830
+ (quick-capture log) · `tpl_wizard` (`Stepper` form + review) · `tpl_record_plain` (inline edit + Stage
822
831
  disposition + `Select` tags + Documents grid; `tpl_record_plain` = card-less).
823
832
  - **Records & lookup** — `tpl_detail` (full record + tabs) · `tpl_directory` (searchable register) ·
824
833
  `tpl_timeline` (audit feed).
@@ -856,8 +865,9 @@ text_input_field · number_input · search_input · form_field · checkbox · ch
856
865
  radio_picker · counter · range_slider · date_picker · date_range_filter_field · time_picker ·
857
866
  inline_text_input · inline_number_input · inline_select · inline_member_select · inline_date_picker ·
858
867
  inline_time_picker (the Inline* family — per-field editors on `inline_edit`'s `useInlineEdit` +
859
- `InlineEditView`; `InlineSelect`/`InlineMemberSelect` take `renderSelected` for a chip/badge at rest) ·
860
- list · list_item · menu_button · menu_list_item · detail_row · pressable_row ·
868
+ `InlineEditView`; `InlineSelect`/`InlineMemberSelect` render the resting value like its option — `renderOptionContent` by default, `renderSelected` to override — a chip/badge at rest, not just text) ·
869
+ inline_static (InlineStatic a READ-ONLY value matching the Inline* box metrics EXACTLY (height, padding, 1px transparent border) so a non-editable field — a computed total, a system ID, a synced/locked value — aligns pixel-for-pixel in the same column; non-interactive, NOT a disabled input; `muted`/`tabular`/`align="right"` for a number column, `weight="medium"` to emphasise a total among plain rows) ·
870
+ list · list_item · menu_button · menu_list_item · detail_row (DetailRow — label+value row for drawer/peek detail; optional `trailing` slot renders a right-side action/badge/unit after the value) · danger_zone (DangerZone — the destructive section: a soft danger-tinted frame (`tint`/`solid`, never raw hex) + a danger heading + a description + a destructive action slot (children, e.g. a `danger` Button); sits APART at the bottom of a record/settings surface) · pressable_row ·
861
871
  check_circle (CheckCircle — the completion ring: an empty ring that springs to a filled check when done, distinct from the square checkbox; the task/to-do/checklist toggle. Compose task rows directly, no Task component) · action_menu ·
862
872
  floating_action_bar · filter_chip · column_filter (ColumnFilter — the typed per-column filter pill +
863
873
  columnFilterToConditions; for a register filtering on several columns) · chip_group · search_input ·
@@ -1,11 +1,14 @@
1
1
  import { useState } from "react";
2
2
  import { ScrollView, View } from "react-native";
3
3
  import { Text } from "@lotics/ui/text";
4
- import { colors } from "@lotics/ui/colors";
4
+ import { colors, type ColorName } from "@lotics/ui/colors";
5
5
  import { SectionHeading, SectionHeadingTitle } from "@lotics/ui/section_heading";
6
6
  import { Divider } from "@lotics/ui/divider";
7
7
  import { DetailRow } from "@lotics/ui/detail_row";
8
8
  import { Badge } from "@lotics/ui/badge";
9
+ import { Button } from "@lotics/ui/button";
10
+ import { DangerZone } from "@lotics/ui/danger_zone";
11
+ import { InlineStatic } from "@lotics/ui/inline_static";
9
12
  import { InlineTextInput } from "@lotics/ui/inline_text_input";
10
13
  import { InlineNumberInput } from "@lotics/ui/inline_number_input";
11
14
  import { InlineSelect } from "@lotics/ui/inline_select";
@@ -13,10 +16,17 @@ import { InlineDatePicker } from "@lotics/ui/inline_date_picker";
13
16
  import { InlineTimePicker } from "@lotics/ui/inline_time_picker";
14
17
 
15
18
  // ─────────────────────────────────────────────────────────────────────────────
16
- // Template · Inline record (plain) — the same in-place record edit as the card
17
- // variant, but CARD-LESS: sections are set off by a clear `SectionHeading` + a
18
- // hairline and generous vertical rhythm, not card chrome. The lighter look for
19
- // a single full-screen record where banded cards would be visual noise.
19
+ // Template · Inline record (plain) — a full record DRAWER, card-less. It reads
20
+ // top bottom like a real drawer: a HEADER surfacing the record's identity +
21
+ // status + key facts, then the in-place inline fields, then a DANGER ZONE for
22
+ // the destructive action. Sections are set off by a `SectionHeading` + hairline
23
+ // and generous rhythm, not card chrome — the lighter look for a single
24
+ // full-screen record where banded cards would be visual noise.
25
+ //
26
+ // It's also the reference for the THREE-PART ROW — label · inline editor ·
27
+ // optional `trailing` (an action / badge / unit) — and for read-only alignment:
28
+ // an `InlineStatic` value sits PIXEL-FOR-PIXEL in the same column as the editors
29
+ // (see the "Row anatomy" section).
20
30
  // ─────────────────────────────────────────────────────────────────────────────
21
31
 
22
32
  const PLAN = [
@@ -37,6 +47,20 @@ const HEALTH_DOT: Record<string, string> = {
37
47
  churning: colors.red[500],
38
48
  };
39
49
 
50
+ // The same health states as a Badge color family (the header's status chip).
51
+ const HEALTH_BADGE: Record<string, ColorName> = {
52
+ healthy: "emerald",
53
+ at_risk: "amber",
54
+ churning: "red",
55
+ };
56
+
57
+ const STAGE = [
58
+ { value: "lead", label: "Lead" },
59
+ { value: "qualified", label: "Qualified" },
60
+ { value: "proposal", label: "Proposal" },
61
+ { value: "won", label: "Won" },
62
+ ];
63
+
40
64
  function persist<T>(set: (v: T) => void) {
41
65
  return (v: T) =>
42
66
  new Promise<void>((resolve) => {
@@ -47,9 +71,9 @@ function persist<T>(set: (v: T) => void) {
47
71
  });
48
72
  }
49
73
 
50
- function Field({ label, children }: { label: string; children: React.ReactNode }) {
74
+ function Field({ label, children, trailing }: { label: string; children: React.ReactNode; trailing?: React.ReactNode }) {
51
75
  return (
52
- <DetailRow label={label} labelWidth={130} minHeight={40}>
76
+ <DetailRow label={label} labelWidth={130} minHeight={40} trailing={trailing}>
53
77
  <View style={{ flex: 1 }}>{children}</View>
54
78
  </DetailRow>
55
79
  );
@@ -67,6 +91,20 @@ function Section({ title, children }: { title: string; children: React.ReactNode
67
91
  );
68
92
  }
69
93
 
94
+ // A compact label-over-value pair for the header's key-facts strip.
95
+ function HeaderStat({ label, value }: { label: string; value: string }) {
96
+ return (
97
+ <View style={{ gap: 2 }}>
98
+ <Text size="xs" color="muted" transform="uppercase">
99
+ {label}
100
+ </Text>
101
+ <Text size="sm" weight="medium" tabular>
102
+ {value}
103
+ </Text>
104
+ </View>
105
+ );
106
+ }
107
+
70
108
  export function TplRecordPlain() {
71
109
  const [name, setName] = useState("Northwind Traders");
72
110
  const [contact, setContact] = useState("Mara Lindqvist");
@@ -79,21 +117,42 @@ export function TplRecordPlain() {
79
117
  const [renews, setRenews] = useState("2026-05-22");
80
118
  const [review, setReview] = useState("2026-04-10T14:30");
81
119
  const [opens, setOpens] = useState("09:00");
120
+ // "Row anatomy" fields.
121
+ const [taxId, setTaxId] = useState("SE556677-8899");
122
+ const [stage, setStage] = useState("proposal");
123
+ const [seats, setSeats] = useState<number | null>(25);
82
124
 
83
125
  const money = (v: number | null) => (v == null ? "" : `$${v.toLocaleString("en-US")}`);
126
+ const planLabel = PLAN.find((p) => p.value === plan)?.label ?? "—";
127
+ const healthLabel = HEALTH.find((h) => h.value === health)?.label ?? "";
128
+ // Read-only, system-owned values that still align with the editors above.
129
+ const accountId = "ACC-4821";
130
+ const lifetimeValue = 128400;
84
131
 
85
132
  return (
86
133
  <ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
87
134
  <View style={{ width: "100%", maxWidth: 560, alignSelf: "center", gap: 28 }}>
88
- <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
89
- <Text size="xl" weight="semibold">
90
- {name}
91
- </Text>
92
- <Badge label="Customer" color="blue" />
93
- <View style={{ flex: 1 }} />
94
- <Text size="xs" color="muted">
95
- Click any value to edit
135
+ {/* Record header identity + status + the facts you'd want at a glance,
136
+ composed in-place (not worth a primitive until a 2nd screen needs it). */}
137
+ <View style={{ gap: 12 }}>
138
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
139
+ <Text size="xl" weight="semibold">
140
+ {name}
141
+ </Text>
142
+ <Badge label={healthLabel} color={HEALTH_BADGE[health]} />
143
+ <View style={{ flex: 1 }} />
144
+ <Text size="xs" color="muted">
145
+ Click any value to edit
146
+ </Text>
147
+ </View>
148
+ <Text size="sm" color="muted">
149
+ {contact} · {email}
96
150
  </Text>
151
+ <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 28 }}>
152
+ <HeaderStat label="Plan" value={planLabel} />
153
+ <HeaderStat label="Annual value" value={money(value) || "—"} />
154
+ <HeaderStat label="City" value={city || "—"} />
155
+ </View>
97
156
  </View>
98
157
 
99
158
  <Section title="Contact">
@@ -148,6 +207,52 @@ export function TplRecordPlain() {
148
207
  <InlineTimePicker value={opens} onSave={persist(setOpens)} accessibilityLabel="Support opens" />
149
208
  </Field>
150
209
  </Section>
210
+
211
+ {/* Row anatomy — the three-part row (label · editor · trailing) and
212
+ read-only alignment, ALL in one 130px-label column so the editors and
213
+ the InlineStatic values line up to the pixel. Copy these shapes. */}
214
+ <Section title="Row anatomy">
215
+ {/* editor + a trailing ACTION */}
216
+ <Field
217
+ label="Tax ID"
218
+ trailing={<Button title="Verify" color="secondary" onPress={() => {}} />}
219
+ >
220
+ <InlineTextInput value={taxId} onSave={persist(setTaxId)} placeholder="Add tax ID…" accessibilityLabel="Tax ID" />
221
+ </Field>
222
+ {/* editor + a trailing STATUS dot */}
223
+ <Field
224
+ label="Pipeline stage"
225
+ trailing={<Badge variant="dot" color="emerald" label="Active" />}
226
+ >
227
+ <InlineSelect value={stage} onSave={persist(setStage)} options={STAGE} accessibilityLabel="Pipeline stage" />
228
+ </Field>
229
+ {/* editor + a trailing UNIT label */}
230
+ <Field
231
+ label="Licensed seats"
232
+ trailing={
233
+ <Text size="sm" color="muted">
234
+ seats
235
+ </Text>
236
+ }
237
+ >
238
+ <InlineNumberInput value={seats} onSave={persist(setSeats)} min={0} accessibilityLabel="Licensed seats" />
239
+ </Field>
240
+ {/* READ-ONLY value — aligns with the editors above, no input chrome */}
241
+ <Field label="Account ID">
242
+ <InlineStatic value={accountId} tabular />
243
+ </Field>
244
+ {/* READ-ONLY value + a trailing MARK */}
245
+ <Field label="Lifetime value" trailing={<Badge label="Computed" color="zinc" />}>
246
+ <InlineStatic value={money(lifetimeValue)} tabular />
247
+ </Field>
248
+ </Section>
249
+
250
+ <DangerZone
251
+ title="Delete account"
252
+ description="Permanently remove this account and all of its contacts, deals, and history. This can't be undone."
253
+ >
254
+ <Button title="Delete account" color="danger" onPress={() => {}} />
255
+ </DangerZone>
151
256
  </View>
152
257
  </ScrollView>
153
258
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "7.9.0",
3
+ "version": "7.11.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -122,6 +122,7 @@
122
122
  "./badge": "./src/badge.tsx",
123
123
  "./callout": "./src/callout.tsx",
124
124
  "./inline_edit": "./src/inline_edit.tsx",
125
+ "./inline_static": "./src/inline_static.tsx",
125
126
  "./inline_text_input": "./src/inline_text_input.tsx",
126
127
  "./inline_number_input": "./src/inline_number_input.tsx",
127
128
  "./inline_select": "./src/inline_select.tsx",
@@ -160,6 +161,7 @@
160
161
  "./table": "./src/table.tsx",
161
162
  "./data_grid": "./src/data_grid.tsx",
162
163
  "./detail_row": "./src/detail_row.tsx",
164
+ "./danger_zone": "./src/danger_zone.tsx",
163
165
  "./scan_field": "./src/scan_field.tsx",
164
166
  "./completion_state": "./src/completion_state.tsx",
165
167
  "./remainder_meter": "./src/remainder_meter.tsx",
@@ -0,0 +1,58 @@
1
+ import { type ReactNode } from "react";
2
+ import { View, StyleSheet } from "react-native";
3
+ import { Text } from "./text";
4
+ import { Icon } from "./icon";
5
+ import { solid, tint } from "./colors";
6
+
7
+ export interface DangerZoneProps {
8
+ /** Section heading. Default "Danger zone". */
9
+ title?: string;
10
+ /** A short line spelling out the consequence of the action (it's usually
11
+ * irreversible — say so here). */
12
+ description?: string;
13
+ /** The destructive action(s) — typically a `danger` `Button`. */
14
+ children: ReactNode;
15
+ }
16
+
17
+ /**
18
+ * A destructive section set visually APART — for the irreversible record actions
19
+ * (delete, archive, transfer ownership) that belong at the BOTTOM of a record or
20
+ * settings surface, away from the everyday fields. A soft danger-tinted frame +
21
+ * a danger heading mark it as a hazard without shouting (the kit's `tint`/`solid`
22
+ * discipline, never a raw hex); the consequence goes in `description`, and the
23
+ * action(s) go in `children`.
24
+ */
25
+ export function DangerZone(props: DangerZoneProps) {
26
+ const { title = "Danger zone", description, children } = props;
27
+ return (
28
+ <View style={styles.zone}>
29
+ <View style={styles.heading}>
30
+ <Icon name="triangle-alert" size={16} color={solid("red")} />
31
+ <Text size="sm" weight="semibold" color="danger">
32
+ {title}
33
+ </Text>
34
+ </View>
35
+ {description ? (
36
+ <Text size="sm" color="muted">
37
+ {description}
38
+ </Text>
39
+ ) : null}
40
+ <View style={styles.actions}>{children}</View>
41
+ </View>
42
+ );
43
+ }
44
+
45
+ const styles = StyleSheet.create({
46
+ // A soft danger frame: a low-alpha red hairline over the faintest red wash —
47
+ // the "set apart, not shouting" treatment via the kit's tint discipline.
48
+ zone: {
49
+ gap: 8,
50
+ padding: 16,
51
+ borderRadius: 10,
52
+ borderWidth: 1,
53
+ borderColor: tint("red", 0.3),
54
+ backgroundColor: tint("red", 0.03),
55
+ },
56
+ heading: { flexDirection: "row", alignItems: "center", gap: 6 },
57
+ actions: { flexDirection: "row", flexWrap: "wrap", gap: 8, marginTop: 4 },
58
+ });
@@ -7,6 +7,12 @@ export interface DetailRowProps {
7
7
  label: ReactNode;
8
8
  /** The value — any node (a `Text`, a `Badge`, a stack). */
9
9
  children: ReactNode;
10
+ /** An optional right-side slot, rendered as the LAST child of the row (after
11
+ * `children`) — a small action `Button`, a status `Badge`, a unit label, an
12
+ * `IconButton`. For it to sit at the right edge, wrap the value/editor in a
13
+ * `flex:1` View so the value takes the slack and the trailing pins right (the
14
+ * inline-record templates do this). */
15
+ trailing?: ReactNode;
10
16
  /** Fixed label-column width (px) to align labels in a left column — for a peek
11
17
  * or form-like stack. Omit for a spread row: the label takes the slack and the
12
18
  * value sits at the right edge (the default drawer-detail look). */
@@ -24,13 +30,14 @@ export interface DetailRowProps {
24
30
  * with the values flowing immediately after instead.
25
31
  */
26
32
  export function DetailRow(props: DetailRowProps) {
27
- const { label, children, labelWidth, labelSize = "sm", minHeight = 28 } = props;
33
+ const { label, children, trailing, labelWidth, labelSize = "sm", minHeight = 28 } = props;
28
34
  return (
29
35
  <View style={[styles.row, { minHeight }]}>
30
36
  <Text size={labelSize} color="muted" style={labelWidth != null ? { width: labelWidth } : styles.flexLabel}>
31
37
  {label}
32
38
  </Text>
33
39
  {children}
40
+ {trailing != null ? trailing : null}
34
41
  </View>
35
42
  );
36
43
  }
@@ -50,7 +50,7 @@ export interface DisplayFile {
50
50
  url: string;
51
51
  /** Optional thumbnail URL for images */
52
52
  thumbnailUrl?: string;
53
- /** Preview URL for legacy Excel files (.xls converted to .xlsx server-side) */
53
+ /** Optional local preview URL (e.g. an object URL for a file still uploading). */
54
54
  previewUrl?: string;
55
55
  }
56
56
 
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useRef, useState, type ReactNode, type Ref } from "react";
2
- import { View, StyleSheet, type GestureResponderEvent } from "react-native";
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
5
  import { ActivityIndicator } from "./activity_indicator";
@@ -232,9 +232,19 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
232
232
  );
233
233
  }
234
234
 
235
- // The view text reuses the input's own canonical metrics (font, size, spacing)
236
- // so the value reads identically and occupies the same box in both modes.
237
- const viewTextStyle = [getInputTextStyle(), { flex: 1, fontFamily: fontFamilyRegular, letterSpacing: -0.4 }];
235
+ // The canonical text treatment for an inline value AT REST — the input's own
236
+ // metrics (font, size, line height, letter spacing) so the value reads
237
+ // identically and occupies the same box in view and edit mode. A read-only
238
+ // `InlineStatic` reuses this so its baseline lines up with the editors.
239
+ export const inlineValueTextStyle: TextStyle = {
240
+ ...getInputTextStyle(),
241
+ fontFamily: fontFamilyRegular,
242
+ letterSpacing: -0.4,
243
+ };
244
+
245
+ // In the view ROW the value text takes the flex slot so the trailing adornment
246
+ // right-aligns; `InlineStatic` uses the base style above without this flex.
247
+ const viewTextStyle = [inlineValueTextStyle, { flex: 1 }];
238
248
 
239
249
  const styles = StyleSheet.create({
240
250
  view: {
@@ -17,8 +17,9 @@ export interface InlineSelectProps<T extends string> {
17
17
  * Omit for a plain label list — both render through the same `OptionList`. */
18
18
  renderOptionContent?: (option: PickerOption<T>) => React.ReactNode;
19
19
  /** Render the SELECTED value in the resting view as a node (an avatar chip, a
20
- * colored badge) instead of its plain label. Falls back to the label when
21
- * omitted; the empty state always shows the placeholder. */
20
+ * colored badge) instead of its plain label. Falls back to `renderOptionContent`
21
+ * (so the trigger renders like the options), then the plain label, when omitted;
22
+ * the empty state always shows the placeholder. */
22
23
  renderSelected?: (selected: PickerOption<T>) => React.ReactNode;
23
24
  placeholder?: string;
24
25
  disabled?: boolean;
@@ -28,11 +29,11 @@ export interface InlineSelectProps<T extends string> {
28
29
  }
29
30
 
30
31
  /**
31
- * An inline-editable single-select. The value reads as plain text; clicking it
32
- * floats the option list (an `OptionList`) in a popover anchored to the view, so
33
- * the row never changes height. Picking commits; dismissing reverts. Pass
34
- * `renderOptionContent` for rich options, or omit it for a plain list the
35
- * standard and custom-rendered pickers are both supported here.
32
+ * An inline-editable single-select. The selected value renders like its option
33
+ * (`renderOptionContent`) a badge stays a badge in the resting trigger, not just
34
+ * text; clicking it floats the option list (an `OptionList`) in a popover anchored
35
+ * to the view, so the row never changes height. Picking commits; dismissing reverts.
36
+ * Pass `renderOptionContent` for rich options, or omit it for a plain label list.
36
37
  */
37
38
  export function InlineSelect<T extends string>(props: InlineSelectProps<T>) {
38
39
  const { value, onSave, options, renderOptionContent, renderSelected, placeholder, disabled, accessibilityLabel, searchable = false } = props;
@@ -64,7 +65,7 @@ export function InlineSelect<T extends string>(props: InlineSelectProps<T>) {
64
65
  <Popover open={open && !disabled} onOpenChange={setOpen} side="bottom" align="start" inheritTriggerWidth>
65
66
  <PopoverTrigger>
66
67
  <InlineEditView
67
- display={selected ? (renderSelected ? renderSelected(selected) : (selected.label ?? "")) : ""}
68
+ display={selected ? (renderSelected ? renderSelected(selected) : renderOptionContent ? renderOptionContent(selected) : (selected.label ?? "")) : ""}
68
69
  placeholder={placeholder}
69
70
  disabled={disabled}
70
71
  active={open && !disabled}
@@ -0,0 +1,67 @@
1
+ import { StyleSheet, View } from "react-native";
2
+ import { Text } from "./text";
3
+ import { colors } from "./colors";
4
+ import { CONTROL_RADIUS } from "./control_surface";
5
+ import { INLINE_CONTROL_HEIGHT, inlineValueTextStyle } from "./inline_edit";
6
+
7
+ export interface InlineStaticProps {
8
+ /** The value to show. Empty → `placeholder` (default "—"). */
9
+ value: string;
10
+ /** Shown when `value` is empty. Default "—". */
11
+ placeholder?: string;
12
+ /** Mute the value (zinc-600) — a secondary or derived figure. */
13
+ muted?: boolean;
14
+ /** Fixed-width (tabular) numerals — for a value sitting in a NUMBER column so
15
+ * its digits line up with the `InlineNumberInput`s above and below it. */
16
+ tabular?: boolean;
17
+ /** Horizontal alignment — `right` for a number column. Default `left`. */
18
+ align?: "left" | "right";
19
+ /** Value weight — `medium` to emphasise a total or headline figure among plain
20
+ * rows. Default `regular` (reads as an ordinary value, not a heading). */
21
+ weight?: "regular" | "medium";
22
+ }
23
+
24
+ /**
25
+ * A READ-ONLY value that aligns PIXEL-FOR-PIXEL with the `Inline*` editors in a
26
+ * record: the same control height, horizontal padding, and a 1px TRANSPARENT
27
+ * border, so a non-editable field — a computed total, a system ID, a synced or
28
+ * locked value — sits flush in the same column as the editable rows. It is
29
+ * NON-interactive (no hover, no pointer, no focus ring): it reads as a plain
30
+ * value, NOT a disabled input. Pair with `DetailRow` exactly like the `Inline*`
31
+ * editors; wrap it in a `flex:1` View when the row also carries a `trailing`
32
+ * slot, so the value takes the slack and the trailing pins right.
33
+ */
34
+ export function InlineStatic(props: InlineStaticProps) {
35
+ const { value, placeholder, muted, tabular, align = "left", weight } = props;
36
+ const isEmpty = value.length === 0;
37
+ const display = isEmpty ? (placeholder ?? "—") : value;
38
+ return (
39
+ <View style={styles.box}>
40
+ <Text
41
+ numberOfLines={1}
42
+ tabular={tabular}
43
+ align={align}
44
+ weight={weight}
45
+ color={muted ? "muted" : "default"}
46
+ style={[inlineValueTextStyle, isEmpty ? styles.placeholder : null]}
47
+ >
48
+ {display}
49
+ </Text>
50
+ </View>
51
+ );
52
+ }
53
+
54
+ const styles = StyleSheet.create({
55
+ // Matches `inline_edit`'s view box exactly (height, radius, padding, 1px
56
+ // transparent border) so the value's baseline aligns with the editors — minus
57
+ // the FocusRingPressable, so it never reads as an interactive control.
58
+ box: {
59
+ minHeight: INLINE_CONTROL_HEIGHT,
60
+ borderRadius: CONTROL_RADIUS,
61
+ borderWidth: 1,
62
+ borderColor: "transparent",
63
+ paddingHorizontal: 8,
64
+ justifyContent: "center",
65
+ },
66
+ placeholder: { color: colors.zinc[400] },
67
+ });
package/src/mime.ts CHANGED
@@ -21,8 +21,6 @@ const EXCEL_MIME_TYPES = [
21
21
  "application/vnd.ms-excel",
22
22
  ];
23
23
 
24
- const LEGACY_EXCEL_MIME_TYPES = ["application/vnd.ms-excel", "application/wps-office.xls"];
25
-
26
24
  const DOCX_MIME_TYPES = [
27
25
  "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
28
26
  "application/wps-office.docx",
@@ -36,11 +34,6 @@ export function isExcelMimeType(mimeType: string): boolean {
36
34
  return EXCEL_MIME_TYPES.includes(mimeType.toLowerCase());
37
35
  }
38
36
 
39
- /** Legacy .xls (BIFF) — not parseable in-app; excluded from previewable. */
40
- export function isLegacyExcelMimeType(mimeType: string): boolean {
41
- return LEGACY_EXCEL_MIME_TYPES.includes(mimeType.toLowerCase());
42
- }
43
-
44
37
  export function isCsvMimeType(mimeType: string): boolean {
45
38
  const mt = mimeType.toLowerCase();
46
39
  return mt === "text/csv" || mt === "text/tab-separated-values";
@@ -50,10 +43,10 @@ export function isDocxMimeType(mimeType: string): boolean {
50
43
  return DOCX_MIME_TYPES.includes(mimeType.toLowerCase());
51
44
  }
52
45
 
53
- /** PDF, modern Excel (.xlsx), Word (.docx), or CSV — types FilePreview renders inline. */
46
+ /** PDF, Excel (.xlsx + legacy .xls), Word (.docx), or CSV — types FilePreview renders inline. */
54
47
  export function isPreviewableMimeType(mimeType: string): boolean {
55
48
  return (
56
- (isExcelMimeType(mimeType) && !isLegacyExcelMimeType(mimeType)) ||
49
+ isExcelMimeType(mimeType) ||
57
50
  isCsvMimeType(mimeType) ||
58
51
  isPdfMimeType(mimeType) ||
59
52
  isDocxMimeType(mimeType)
@@ -1,302 +0,0 @@
1
- import { useState } from "react";
2
- import { ScrollView, View } from "react-native";
3
- import { Text } from "@lotics/ui/text";
4
- import { colors, solid, type ColorName } from "@lotics/ui/colors";
5
- import { Card, CardHeader, CardHeaderTitle } from "@lotics/ui/card";
6
- import { DetailRow } from "@lotics/ui/detail_row";
7
- import { Badge } from "@lotics/ui/badge";
8
- import { Button } from "@lotics/ui/button";
9
- import { Popover, PopoverContent, PopoverTrigger } from "@lotics/ui/popover";
10
- import { MenuButton } from "@lotics/ui/menu_button";
11
- import { InlineTextInput } from "@lotics/ui/inline_text_input";
12
- import { InlineNumberInput } from "@lotics/ui/inline_number_input";
13
- import { InlineSelect } from "@lotics/ui/inline_select";
14
- import { InlineDatePicker } from "@lotics/ui/inline_date_picker";
15
- import { InlineTimePicker } from "@lotics/ui/inline_time_picker";
16
- import { Select } from "@lotics/ui/select";
17
- import { Chip } from "@lotics/ui/chip";
18
- import { FileDropzone } from "@lotics/ui/file_dropzone";
19
- import { FileThumbnailGrid } from "@lotics/ui/file_thumbnail_grid";
20
- import { FileGalleryModal } from "@lotics/ui/file_gallery_modal";
21
- import type { DisplayFile } from "@lotics/ui/file_thumbnail";
22
-
23
- // ─────────────────────────────────────────────────────────────────────────────
24
- // Template · Inline record — a record edited entirely IN PLACE. Every value
25
- // reads as plain text; hovering tints it, clicking swaps the field in at the
26
- // same height (no reflow), and it saves on its own. The data-capture
27
- // alternative to a form: no screen-wide edit mode, no one big Save — each field
28
- // is its own atomic edit. Banded cards over zinc-50, label-left / value-right.
29
- // ─────────────────────────────────────────────────────────────────────────────
30
-
31
- const PLAN = [
32
- { value: "starter", label: "Starter" },
33
- { value: "growth", label: "Growth" },
34
- { value: "enterprise", label: "Enterprise" },
35
- ];
36
-
37
- const HEALTH = [
38
- { value: "healthy", label: "Healthy" },
39
- { value: "at_risk", label: "At risk" },
40
- { value: "churning", label: "Churning" },
41
- ];
42
-
43
- const HEALTH_DOT: Record<string, string> = {
44
- healthy: colors.emerald[500],
45
- at_risk: colors.amber[500],
46
- churning: colors.red[500],
47
- };
48
-
49
- // Tags — an inline MULTI-select: chips render in the field, search to add, ✕ to
50
- // remove, free-entry to coin a new tag (`allowCustom`).
51
- const TAG_OPTIONS = [
52
- { value: "vip", label: "VIP" },
53
- { value: "priority", label: "Priority support" },
54
- { value: "renewal_risk", label: "Renewal risk" },
55
- { value: "expansion", label: "Expansion" },
56
- { value: "reference", label: "Referenceable" },
57
- ];
58
-
59
- // Seed the attachment grid with self-contained SVG data-URI "scans" so the
60
- // thumbnails AND the preview modal work offline; dropped files are real blobs.
61
- const scan = (id: string, label: string, bg: string): DisplayFile => ({
62
- id,
63
- filename: `${label.toLowerCase().replaceAll(" ", "-")}.svg`,
64
- mimeType: "image/svg+xml",
65
- url:
66
- "data:image/svg+xml," +
67
- encodeURIComponent(
68
- `<svg xmlns="http://www.w3.org/2000/svg" width="420" height="560"><rect width="100%" height="100%" fill="${bg}"/><text x="210" y="290" fill="#ffffff" font-family="sans-serif" font-size="30" text-anchor="middle">${label}</text></svg>`,
69
- ),
70
- });
71
-
72
- const SEED_DOCS: DisplayFile[] = [
73
- scan("doc_1", "Master agreement", "#1e3a8a"),
74
- scan("doc_2", "Tax certificate", "#065f46"),
75
- ];
76
-
77
- // Simulate an async persist so the saving state is visible.
78
- function persist<T>(set: (v: T) => void) {
79
- return (v: T) =>
80
- new Promise<void>((resolve) => {
81
- setTimeout(() => {
82
- set(v);
83
- resolve();
84
- }, 350);
85
- });
86
- }
87
-
88
- function Field({ label, children }: { label: string; children: React.ReactNode }) {
89
- return (
90
- <DetailRow label={label} labelWidth={130} minHeight={40}>
91
- <View style={{ flex: 1 }}>{children}</View>
92
- </DetailRow>
93
- );
94
- }
95
-
96
- function Group({ title, children }: { title: string; children: React.ReactNode }) {
97
- return (
98
- <Card style={{ padding: 0 }}>
99
- <CardHeader>
100
- <CardHeaderTitle>{title}</CardHeaderTitle>
101
- </CardHeader>
102
- <View style={{ paddingHorizontal: 16, paddingVertical: 8, gap: 2 }}>{children}</View>
103
- </Card>
104
- );
105
- }
106
-
107
- // ── Lifecycle disposition (NOT a plain select). "Lead" is the OPEN default;
108
- // "Closed"/"Lost" are terminal OUTCOMES the user decides. The control is
109
- // asymmetric by phase: while open it surfaces the two outcomes as ACTIONS;
110
- // once resolved it shows the colored STATE with a quiet, reversible Change
111
- // (reopen to Lead, or switch to the other outcome — valence lives on the badge,
112
- // not the buttons). States/colors/labels vary per domain, so it's a composition
113
- // (Badge + Button + Popover/MenuButton), not a shipped primitive.
114
- type Stage = "lead" | "closed" | "lost";
115
-
116
- const STAGE_META: Record<Stage, { label: string; color: ColorName }> = {
117
- lead: { label: "Lead", color: "blue" },
118
- closed: { label: "Closed", color: "emerald" },
119
- lost: { label: "Lost", color: "red" },
120
- };
121
-
122
- function StageField({ value, onChange }: { value: Stage; onChange: (next: Stage) => void }) {
123
- const [open, setOpen] = useState(false);
124
- const meta = STAGE_META[value];
125
-
126
- // Pending: the decision is the action.
127
- if (value === "lead") {
128
- return (
129
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
130
- <Badge variant="dot" label={meta.label} color={meta.color} />
131
- <View style={{ flex: 1 }} />
132
- <Button title="Mark closed" color="primary" onPress={() => onChange("closed")} />
133
- <Button title="Mark lost" color="danger-secondary" onPress={() => onChange("lost")} />
134
- </View>
135
- );
136
- }
137
-
138
- // Resolved: the badge is the state; Change opens a quiet, colored state
139
- // switcher (each option a dot + label, current marked) — a status control,
140
- // not a generic text menu.
141
- return (
142
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
143
- <Badge variant="dot" label={meta.label} color={meta.color} />
144
- <View style={{ flex: 1 }} />
145
- <Popover open={open} onOpenChange={setOpen} side="bottom" align="end">
146
- <PopoverTrigger>
147
- <Button title="Change" color="muted" onPress={() => setOpen(true)} />
148
- </PopoverTrigger>
149
- <PopoverContent>
150
- <View style={{ gap: 2, padding: 4, minWidth: 200 }}>
151
- {(["lead", "closed", "lost"] as Stage[]).map((s) => (
152
- <MenuButton
153
- key={s}
154
- icon={<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: solid(STAGE_META[s].color) }} />}
155
- title={s === value ? `${STAGE_META[s].label} · current` : STAGE_META[s].label}
156
- selected={s === value}
157
- disabled={s === value}
158
- onPress={() => { onChange(s); setOpen(false); }}
159
- />
160
- ))}
161
- </View>
162
- </PopoverContent>
163
- </Popover>
164
- </View>
165
- );
166
- }
167
-
168
- export function TplRecord() {
169
- const [name, setName] = useState("Northwind Traders");
170
- const [contact, setContact] = useState("Mara Lindqvist");
171
- const [email, setEmail] = useState("mara@northwind.co");
172
- const [phone, setPhone] = useState("");
173
- const [city, setCity] = useState("Gothenburg");
174
- const [value, setValue] = useState<number | null>(48000);
175
- const [plan, setPlan] = useState("growth");
176
- const [health, setHealth] = useState("healthy");
177
- const [renews, setRenews] = useState("2026-05-22");
178
- const [review, setReview] = useState("2026-04-10T14:30");
179
- const [opens, setOpens] = useState("09:00");
180
- const [stage, setStage] = useState<Stage>("lead");
181
- const [tags, setTags] = useState<string[]>([TAG_OPTIONS[0].value, TAG_OPTIONS[3].value]);
182
- const [docs, setDocs] = useState<DisplayFile[]>(SEED_DOCS);
183
- const [activeDoc, setActiveDoc] = useState<number | null>(null);
184
-
185
- const money = (v: number | null) => (v == null ? "" : `$${v.toLocaleString("en-US")}`);
186
-
187
- return (
188
- <ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
189
- <View style={{ width: "100%", maxWidth: 560, alignSelf: "center", gap: 16 }}>
190
- <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
191
- <Text size="xl" weight="semibold">
192
- {name}
193
- </Text>
194
- <Badge label="Customer" color="blue" />
195
- <View style={{ flex: 1 }} />
196
- <Text size="xs" color="muted">
197
- Click any value to edit
198
- </Text>
199
- </View>
200
-
201
- <Group title="Contact">
202
- <Field label="Account name">
203
- <InlineTextInput value={name} onSave={persist(setName)} accessibilityLabel="Account name" />
204
- </Field>
205
- <Field label="Primary contact">
206
- <InlineTextInput value={contact} onSave={persist(setContact)} accessibilityLabel="Primary contact" />
207
- </Field>
208
- <Field label="Email">
209
- <InlineTextInput value={email} onSave={persist(setEmail)} placeholder="Add email…" accessibilityLabel="Email" />
210
- </Field>
211
- <Field label="Phone">
212
- <InlineTextInput value={phone} onSave={persist(setPhone)} placeholder="Add phone…" accessibilityLabel="Phone" />
213
- </Field>
214
- <Field label="City">
215
- <InlineTextInput value={city} onSave={persist(setCity)} accessibilityLabel="City" />
216
- </Field>
217
- </Group>
218
-
219
- <Group title="Commercial">
220
- <Field label="Stage">
221
- <StageField value={stage} onChange={setStage} />
222
- </Field>
223
- <Field label="Annual value">
224
- <InlineNumberInput value={value} onSave={persist(setValue)} format={money} accessibilityLabel="Annual value" />
225
- </Field>
226
- <Field label="Plan">
227
- <InlineSelect value={plan} onSave={persist(setPlan)} options={PLAN} accessibilityLabel="Plan" />
228
- </Field>
229
- <Field label="Health">
230
- <InlineSelect
231
- value={health}
232
- onSave={persist(setHealth)}
233
- options={HEALTH}
234
- accessibilityLabel="Health"
235
- renderOptionContent={(o) => (
236
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
237
- <View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: HEALTH_DOT[o.value] }} />
238
- <Text size="sm">{o.label}</Text>
239
- </View>
240
- )}
241
- />
242
- </Field>
243
- <Field label="Tags">
244
- <Select multi searchable allowCustom value={tags} onValueChange={setTags} options={TAG_OPTIONS} renderSelected={(item, { remove }) => <Chip onDismiss={remove}><Text size="sm">{item.label}</Text></Chip>} placeholder="Add tags…" accessibilityLabel="Tags" />
245
- </Field>
246
- </Group>
247
-
248
- <Group title="Schedule">
249
- <Field label="Renews on">
250
- <InlineDatePicker value={renews} onSave={persist(setRenews)} locale="en-US" accessibilityLabel="Renews on" />
251
- </Field>
252
- <Field label="Next review">
253
- <InlineDatePicker value={review} onSave={persist(setReview)} format="datetime" locale="en-US" accessibilityLabel="Next review" />
254
- </Field>
255
- <Field label="Support opens">
256
- <InlineTimePicker value={opens} onSave={persist(setOpens)} accessibilityLabel="Support opens" />
257
- </Field>
258
- </Group>
259
-
260
- {/* Documents — a richer in-place field: drop to add, click a thumbnail to
261
- preview in the gallery, ✕ to delete. Not every inline field is a
262
- same-height swap; an attachment grid edits in place too. */}
263
- <Card style={{ padding: 0 }}>
264
- <CardHeader>
265
- <CardHeaderTitle>Documents</CardHeaderTitle>
266
- </CardHeader>
267
- <View style={{ paddingHorizontal: 16, paddingVertical: 12, gap: 12 }}>
268
- {docs.length > 0 ? (
269
- <FileThumbnailGrid
270
- files={docs}
271
- itemSize={84}
272
- onFilePress={(f) => setActiveDoc(docs.findIndex((x) => x.id === f.id))}
273
- onRemove={(id) => setDocs((prev) => prev.filter((x) => x.id !== id))}
274
- />
275
- ) : null}
276
- <FileDropzone
277
- height={96}
278
- label="Drop contracts or scans"
279
- hint="or click to browse · PDF, images"
280
- dropLabel="Release to attach"
281
- accept="application/pdf,image/*"
282
- accessibilityLabel="Attach documents"
283
- onFiles={(dropped) =>
284
- setDocs((prev) => [
285
- ...prev,
286
- ...dropped.map((f, i) => ({
287
- id: `${f.name}-${prev.length + i}`,
288
- filename: f.name,
289
- mimeType: f.type || "application/octet-stream",
290
- url: URL.createObjectURL(f),
291
- })),
292
- ])
293
- }
294
- />
295
- </View>
296
- </Card>
297
- </View>
298
-
299
- <FileGalleryModal files={docs} activeIndex={activeDoc} onIndexChange={setActiveDoc} />
300
- </ScrollView>
301
- );
302
- }