@lotics/ui 44.13.0 → 44.14.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 +1 -1
- package/docs/catalog.md +10 -2
- package/docs/data_entry.md +15 -3
- package/package.json +2 -1
- package/src/icon.tsx +8 -0
- package/src/inline_edit.tsx +14 -3
- package/src/inline_markdown.tsx +118 -0
- package/src/inline_text_input.tsx +27 -2
- package/src/select.tsx +17 -0
package/AGENTS.md
CHANGED
|
@@ -15,7 +15,7 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
|
|
|
15
15
|
| Doc | Read it for |
|
|
16
16
|
|---|---|
|
|
17
17
|
| [docs/catalog.md](./docs/catalog.md) | **The complete inventory** — Reach-by-role (each data role → the ONE canonical component) + every `@lotics/ui/<module>` entry point (incl. `@lotics/ui/vite`'s `loticsOptimizeDeps` + `loticsResolve()` — the pre-bundle list and the whole `resolve` block a custom-code app's `vite.config.ts` imports rather than hand-carries, dev-link included). Read before building any screen; reuse first. |
|
|
18
|
-
| [docs/data_entry.md](./docs/data_entry.md) | **a picker over a select field keeps the field's colours** (`optionPicker` — the hand-rolled `map` to `{value,label}` drops `color`, so a value reads coloured in a register and grey in the editor); a choice that GATES a form is usually one not to ask (two doors to one room is not a chooser; show the dominant path, offer the other beside its submit, and give a recovery link a quieter treatment than an alternative way in); Which editing pattern for which job — inline edit, fieldset forms, choosing a CHOICE control by option count (and why "no selection" is a PLACEHOLDER, never an option valued `""` — the picker reads that as nothing chosen and renders down to its chevron), browser-autofill suppression (search controls only), find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (`InlineFiles` for a record ROW — list + one CTA, `multiple` decides add-vs-replace; the `FilesEditor` COMPOUND for a whole section — root owns selection/gallery/confirm, you compose the bar, a HOST verb reads `useFilesEditorSelection` — plus the three-way file INTAKE: CTA + `FileDropTarget` + `usePasteFiles`), stage gates, whether a multiline value keeps its fixed reserve or `autoGrow`s (who decides the length — the field, or whoever is typing), the commit-on-blur vs action-press ordering law (the kit gates the press — `pending_commits`). |
|
|
18
|
+
| [docs/data_entry.md](./docs/data_entry.md) | **a picker over a select field keeps the field's colours** (`optionPicker` — the hand-rolled `map` to `{value,label}` drops `color`, so a value reads coloured in a register and grey in the editor); a choice that GATES a form is usually one not to ask (two doors to one room is not a chooser; show the dominant path, offer the other beside its submit, and give a recovery link a quieter treatment than an alternative way in); Which editing pattern for which job — inline edit, fieldset forms, choosing a CHOICE control by option count (and why "no selection" is a PLACEHOLDER, never an option valued `""` — the picker reads that as nothing chosen and renders down to its chevron), browser-autofill suppression (search controls only), find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (`InlineFiles` for a record ROW — list + one CTA, `multiple` decides add-vs-replace; the `FilesEditor` COMPOUND for a whole section — root owns selection/gallery/confirm, you compose the bar, a HOST verb reads `useFilesEditorSelection` — plus the three-way file INTAKE: CTA + `FileDropTarget` + `usePasteFiles`), stage gates, whether a multiline value keeps its fixed reserve or `autoGrow`s (who decides the length — the field, or whoever is typing); the swap rule for a NEW editor (same string ⇒ one element, different string ⇒ swap) worked through its two live cases — `InlineMarkdown` renders prose at rest and refuses to collapse when opened, while a URL takes `InlineTextInput`'s `link` TREATMENT because marking it changes its ink and not its characters, the commit-on-blur vs action-press ordering law (the kit gates the press — `pending_commits`). |
|
|
19
19
|
| [docs/ai_patterns.md](./docs/ai_patterns.md) | AI acts, the human stays in charge — composer, live run feed (`AgentRun`), the one law's split — it turns on WHO supplied the values (machine → a gate: a diff when something is being replaced, a full editable preview when records are being created from a document; human-typed → save-direct + the `ResultHeader` receipt), findings, provenance, confidence; **after the run** — a stored record that fills up from several writers (a person, a chat agent, an extraction, an automation) and remembers none of them: an unwritten value must not render like a written one, a machine's prose and a person's must not share a treatment, model markdown goes in `variant="embedded"`, and the read path must project every field the write paths set; the UI half of the SDK's [ai doc](../app-sdk/docs/ai.md), the whole run in a dialog (`AgentRunScope`/`AgentRunPane`/`AgentRunActions` — a parked question REPLACES the feed, actions in the footer, **Stop** while streaming), **stopping** (`cancel` stops the run, `abort` only stops listening — so closing a dialog must `cancel` or it keeps billing); **review surfaces compose from atoms** — `DiffValue` (a changed value, droppable in any cell/row/total), `DiffMark` (what happened to the row — ONE circular disc, every surface, its kind derived from before/after rather than a boolean "did this change" — an empty `before` is `added`, never `changed`), `useChangeSet` (accept/reject/undo bookkeeping, no layout) — see [MIGRATION.md](./MIGRATION.md) for the `ChangeReview` family they replace |
|
|
20
20
|
| [docs/composition.md](./docs/composition.md) | The design-language contract — **the form comes before the treatment** (name what the subject IS — register, rail, board, timeline, deviation queue — before reaching for parts; a subject flattened into rows is well-typed and says nothing, and reuse-first is about not duplicating primitives, not about a screen's shape; **a hierarchy in the DATA does not oblige one in the UI** — who MAINTAINS the structure decides, and a set the reader scans is a register whatever shape the data has), canvas + content column, heading altitude (incl. eyebrow vs group lead — a label is one or the other, and a section heading's own row never wraps so a control label that repeats the title can squeeze the title+description into a narrow ribbon at phone width), banded cards, register vs inset rows (incl. the register laws a row centres its cells by: every cell a FIXED height, a pressable cell on the shared hover token, a column sized by what it carries), the button ladder and **what underlined text may mean** (it GOES somewhere or REVEALS something — never mutates; blue leaves the surface, muted stays on it, and the one in-prose disclosure exception is scoped there), master-detail `Drawer` on a LIST screen vs a child collection's row EXPANDING inside a record, view controls, RECORD EXTENT (one page, sections scrolled to and never routed to), **the register's rhythm** (no hairline between rows — their own height and the hover wash separate them; the register's one line is the band capping the columns, because that is the break whitespace cannot state. Density is `TableRow`'s `minHeight`, never a mode), **a register that TRIAGES** (`TableGroup` — titled bands ordered by what needs the reader now, grouped by what implies a different ACTION rather than by a category already in a column, and what the screen can CLASSIFY kept as a separate list from what it RENDERS, so a value omitted on purpose does not resurface under *Unclassified* and one added later does not vanish), **the register's own craft** (a column header treated as CHROME a step lighter than row metadata, sentence case in the table header too, and a supporting line one RUNG BELOW the line it supports rather than a fixed size — plus the half that rule was missing: every SUBJECT on one row starts at the same rung, the subject leads by WEIGHT and its identity mark rather than by size, and whether the supporting line drops a rung is decided by what else separates the pair; plus the two ways a `columns` array and a rendered row disagree — a partial `priority` annotation, and a conditional column whose cell was not hidden with it), **identity marks** (a mark that looks the same on every row carries nothing — `Avatar` derives its hue from the name and takes circle/square for person vs organization; the palette is ordered by contrast, not spectrum, and never paints a person red or amber; a column of faces must not draw an UNBOUNDED audience as faces, and an overlapping stack separates with translucent INK rather than the surface colour, which a row's wash keeps changing out from under), **where the accent goes** ("which of these" — a selected row, an active filter; never DATA and never the focus ring, while a page-scale position marker like an active TAB takes `primary`, the ink the kit already spends on "this is on"; and selection must not share a wash with hover), **theming is an app-level ESCAPE HATCH, not a product surface** (`applyLoticsTheme({ … })` once at boot, before `mount`, writing the `--lotics-*` roles on the document root; nothing in the platform writes a theme, and `apps.theme.color` is the launcher icon only — the five `var()` roles plus the three font rungs ARE the whole mechanism, and wrapping a component cannot replace them because RN-Web writes `fontFamily` per element and background/border do not inherit), color discipline, **typography** (the ramp is DATA in `type_ramp`, one table both platforms derive from; leading and tracking are baked per rung and a hand-set `letterSpacing` is always a second copy of the curve), whitespace, **where a commit sits** (with its fields, never an overlay's footer — but a full-page editor is not a dialog, so a scrolling document keeps its commit pinned), and how to TEST an overlay component (a `Popover`-backed surface never mounts under jsdom). |
|
|
21
21
|
| [docs/reviewing.md](./docs/reviewing.md) | **Reviewing a screen you built** — the other docs say what good looks like; this one says how to find out whether you achieved it, because "it looks fine" is the same claim as "it should work" made with the same evidence. Measure, never eyeball: render it, extract computed values, let the table show the defect. Two GATES that outrank every treatment question (**SUBTRACT** — what question does this element answer, and count FACTS not elements; **RIGHT CONTROL** — name the species before treating it), then 11 probes each naming what to COLLECT and the SIGNATURE in the numbers: type inventory (range, singletons, the label/value shrink war), ground+border+radius across siblings, what each RULE separates, gap RATIOS, the state diff (paint may change, geometry may not), alignment drift (incl. a centred child that moves while its container measures identical), the surface walk (open it, press it, read the settled string), the DATA probe (what is really in the table — incl. grepping rendered text for internal ids), FIRST PAINT (an entry screen showing zero inputs is gated by something, usually a mode chooser), the ABSENCE pass that catches "bland", and microcopy. Plus the extraction snippets, what measurement cannot see, and how to act on a finding (fix the CLASS, and never resolve one with "the docs say X"). |
|
package/docs/catalog.md
CHANGED
|
@@ -154,9 +154,17 @@ then inserts a newline and the value commits on blur. Both halves matter: a fiel
|
|
|
154
154
|
goes multiline once focused hides the text from the reader who never clicks, and one that
|
|
155
155
|
reserves less than it opens to shoves the rest of the form down the page on every click.
|
|
156
156
|
**`autoGrow`** makes that budget a MINIMUM and fits the value instead — see
|
|
157
|
-
[data_entry.md §Open prose grows](./data_entry.md)
|
|
157
|
+
[data_entry.md §Open prose grows](./data_entry.md). **`link`** marks a URL value with
|
|
158
|
+
the navigation ink as a TREATMENT on the same single element — never a swapped-in
|
|
159
|
+
`TextLink`, which would reintroduce the sub-pixel jump this field stays one
|
|
160
|
+
`<input>` to avoid; pressing still edits, so pair it with an `Open` `InlineButton`
|
|
161
|
+
in `actions`),
|
|
158
162
|
`InlineNumberInput`, `InlineSelect` (single or
|
|
159
|
-
`multi`), `InlineMemberSelect`, `InlineDatePicker`, `InlineTimePicker
|
|
163
|
+
`multi`), `InlineMemberSelect`, `InlineDatePicker`, `InlineTimePicker`,
|
|
164
|
+
**`InlineMarkdown`** (markdown RENDERED at rest, raw source while editing — the
|
|
165
|
+
member that swaps because its two states show different strings; its editor never
|
|
166
|
+
opens shorter than the view it replaced, so pressing a long note cannot collapse
|
|
167
|
+
the box, and **`minLines`** sets the floor for a short one); a
|
|
160
168
|
READ-ONLY field in that same column uses `InlineStatic` (matches the editor box exactly, no
|
|
161
169
|
input chrome, so it aligns pixel-for-pixel). A stack of labelled field rows lives in
|
|
162
170
|
`DetailTable` + `DetailRow`; the record's identity band is `RecordSummary`; its money
|
package/docs/data_entry.md
CHANGED
|
@@ -131,6 +131,17 @@ FORMATTED and the editor shows something else (`1,250 kg` → `1250`, `31/07/202
|
|
|
131
131
|
field), the string changes anyway, so those editors do swap and should. **The rule for a new
|
|
132
132
|
editor: same string in both states ⇒ one element; different string ⇒ swap.**
|
|
133
133
|
|
|
134
|
+
Two worked cases sit on either side of it, and they look alike until you apply the rule.
|
|
135
|
+
**`InlineMarkdown` swaps**: `**bold**` and **bold** are different strings, so the quarter pixel is
|
|
136
|
+
invisible and rendering prose at rest is worth having. A swap editor is supposed to leave the box
|
|
137
|
+
where it was, which markdown cannot do exactly — so it does the half that matters and refuses to
|
|
138
|
+
COLLAPSE: the resting height becomes the editor's floor, because growing is tolerable where
|
|
139
|
+
shrinking pulls the text out from under the reader and jumps everything below it.
|
|
140
|
+
**A LINK does not swap**: marking a URL changes its ink, not its characters, so `InlineTextInput`
|
|
141
|
+
takes a **`link`** treatment on its single `<input>` rather than swapping in a `TextLink`. Pressing
|
|
142
|
+
the field still edits — an `Open` `InlineButton` in `actions` is how you reach the destination,
|
|
143
|
+
which is where a verb about the value belongs.
|
|
144
|
+
|
|
134
145
|
**Commit-on-blur would race the press that caused it — the kit closes that race for you.**
|
|
135
146
|
Pressing an action while an inline field is focused starts TWO things in ONE gesture: the
|
|
136
147
|
field's write (the blur, on mousedown) and the button's handler (on mouseup). A handler that
|
|
@@ -220,9 +231,10 @@ One per type:
|
|
|
220
231
|
field.
|
|
221
232
|
|
|
222
233
|
They split across two shared shells. The same-height **typed** editors (`InlineTextInput`,
|
|
223
|
-
`InlineNumberInput`) render **`InlineEditFrame`** driven by the
|
|
224
|
-
**`useInlineEdit`** hook (the draft buffer, async save, and — for the
|
|
225
|
-
toggle) — pass a custom input to that pair to join the family.
|
|
234
|
+
`InlineNumberInput`, `InlineMarkdown`) render **`InlineEditFrame`** driven by the
|
|
235
|
+
**`useInlineEdit`** hook (the draft buffer, async save, and — for the ones that swap — the view⇄edit
|
|
236
|
+
toggle) — pass a custom input to that pair to join the family. The frame's `display` takes a NODE as
|
|
237
|
+
well as a string, which is what lets a swapping editor rest as something other than text. On the frame, **`editing` means an
|
|
226
238
|
input is MOUNTED** (which element renders, and whether the surface drops its resting padding to
|
|
227
239
|
host one); an always-mounted editor passes it constant and passes the real state as **`editOpen`**,
|
|
228
240
|
which the ✓/✕ verbs read — they assert "there is an edit to accept or reject", so on a resting
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "44.
|
|
3
|
+
"version": "44.14.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./vite": {
|
|
@@ -169,6 +169,7 @@
|
|
|
169
169
|
"./inline_slot": "./src/inline_slot.tsx",
|
|
170
170
|
"./inline_static": "./src/inline_static.tsx",
|
|
171
171
|
"./inline_files": "./src/inline_files.tsx",
|
|
172
|
+
"./inline_markdown": "./src/inline_markdown.tsx",
|
|
172
173
|
"./inline_text_input": "./src/inline_text_input.tsx",
|
|
173
174
|
"./inline_number_input": "./src/inline_number_input.tsx",
|
|
174
175
|
"./inline_select": "./src/inline_select.tsx",
|
package/src/icon.tsx
CHANGED
|
@@ -132,8 +132,12 @@ import Maximize2 from "lucide-react-native/dist/esm/icons/maximize-2";
|
|
|
132
132
|
import Megaphone from "lucide-react-native/dist/esm/icons/megaphone";
|
|
133
133
|
import Mic from "lucide-react-native/dist/esm/icons/mic";
|
|
134
134
|
import Menu from "lucide-react-native/dist/esm/icons/menu";
|
|
135
|
+
import Facebook from "lucide-react-native/dist/esm/icons/facebook";
|
|
136
|
+
import Instagram from "lucide-react-native/dist/esm/icons/instagram";
|
|
137
|
+
import Linkedin from "lucide-react-native/dist/esm/icons/linkedin";
|
|
135
138
|
import MessageCircle from "lucide-react-native/dist/esm/icons/message-circle";
|
|
136
139
|
import MessageCircleQuestionMark from "lucide-react-native/dist/esm/icons/message-circle-question-mark";
|
|
140
|
+
import Twitter from "lucide-react-native/dist/esm/icons/twitter";
|
|
137
141
|
import MessageSquare from "lucide-react-native/dist/esm/icons/message-square";
|
|
138
142
|
import MessagesSquare from "lucide-react-native/dist/esm/icons/messages-square";
|
|
139
143
|
import Minimize2 from "lucide-react-native/dist/esm/icons/minimize-2";
|
|
@@ -328,8 +332,12 @@ const iconComponents = {
|
|
|
328
332
|
"maximize-2": Maximize2,
|
|
329
333
|
megaphone: Megaphone,
|
|
330
334
|
menu: Menu,
|
|
335
|
+
facebook: Facebook,
|
|
336
|
+
instagram: Instagram,
|
|
337
|
+
linkedin: Linkedin,
|
|
331
338
|
"message-circle": MessageCircle,
|
|
332
339
|
"message-circle-question-mark": MessageCircleQuestionMark,
|
|
340
|
+
twitter: Twitter,
|
|
333
341
|
"message-square": MessageSquare,
|
|
334
342
|
"messages-square": MessagesSquare,
|
|
335
343
|
mic: Mic,
|
package/src/inline_edit.tsx
CHANGED
|
@@ -167,8 +167,15 @@ interface InlineEditFrameProps {
|
|
|
167
167
|
* which is the truth for every editor whose input arrives with the edit.
|
|
168
168
|
*/
|
|
169
169
|
editOpen?: boolean;
|
|
170
|
-
/** The
|
|
171
|
-
|
|
170
|
+
/** The current value as the view shows it. Empty string → placeholder.
|
|
171
|
+
*
|
|
172
|
+
* A NODE renders as-is, which is what a SWAPPING editor needs when its
|
|
173
|
+
* resting form is not text — rendered markdown against its own source. That
|
|
174
|
+
* is admissible for the same reason number/date swap: the string genuinely
|
|
175
|
+
* CHANGES between the two states, so the sub-pixel drift an always-input
|
|
176
|
+
* exists to avoid is not perceptible. `InlineEditView` already renders both;
|
|
177
|
+
* this only stops the frame from narrowing what it forwards. */
|
|
178
|
+
display: string | ReactNode;
|
|
172
179
|
placeholder?: string;
|
|
173
180
|
/** Enter edit mode (the view is pressed). */
|
|
174
181
|
onBegin: () => void;
|
|
@@ -760,7 +767,11 @@ const styles = StyleSheet.create({
|
|
|
760
767
|
},
|
|
761
768
|
placeholder: { color: colors.zinc[400] },
|
|
762
769
|
struck: { textDecorationLine: "line-through", color: colors.zinc[500] },
|
|
763
|
-
|
|
770
|
+
// `textAlign` is not cosmetic here: the resting box is a button-role Pressable,
|
|
771
|
+
// so the UA stylesheet centres every string inside it. A STRING display never
|
|
772
|
+
// showed it — `Text` sets its own alignment — so the centring only surfaced
|
|
773
|
+
// once `display` accepted a node and rendered prose that inherited it.
|
|
774
|
+
viewNode: { flex: 1, minWidth: 0, textAlign: "left" },
|
|
764
775
|
editRow: { flexDirection: "row", alignItems: "flex-start", gap: 6 },
|
|
765
776
|
editControl: { flex: 1, position: "relative" },
|
|
766
777
|
// The input brings its own 40px height, so the surface must not add its resting
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { useCallback, useRef, useState, type ReactNode } from "react";
|
|
2
|
+
import { View, type LayoutChangeEvent, type NativeSyntheticEvent, type TextInputKeyPressEventData } from "react-native";
|
|
3
|
+
import { InlineEditFrame, useInlineEdit, type InlineEditVariant } from "./inline_edit";
|
|
4
|
+
import { Markdown } from "./markdown";
|
|
5
|
+
import { TextInputField } from "./text_input_field";
|
|
6
|
+
|
|
7
|
+
export interface InlineMarkdownProps {
|
|
8
|
+
/** The markdown SOURCE. Rendered at rest, edited raw. */
|
|
9
|
+
value: string;
|
|
10
|
+
/** Persist the new source. May be async — the field shows a saving state and
|
|
11
|
+
* surfaces a thrown error inline, staying in edit mode so nothing is lost. */
|
|
12
|
+
onSave: (next: string) => void | Promise<void>;
|
|
13
|
+
placeholder?: string;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
/** How much frame shows at rest — see {@link InlineEditVariant}. Default "framed". */
|
|
16
|
+
variant?: InlineEditVariant;
|
|
17
|
+
/** The editor's floor, in lines, before the resting height is taken into
|
|
18
|
+
* account. Keeps a short or empty value from opening a one-line slot. */
|
|
19
|
+
minLines?: number;
|
|
20
|
+
accessibilityLabel?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A short prose reserve, so an empty field is still obviously somewhere to write. */
|
|
24
|
+
const DEFAULT_MIN_LINES = 3;
|
|
25
|
+
const APPROX_LINE_HEIGHT = 22;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* An inline-editable MARKDOWN value: rendered at rest, raw source while editing.
|
|
29
|
+
*
|
|
30
|
+
* This one SWAPS, and it is allowed to for the same reason the number and date
|
|
31
|
+
* editors do: its resting display and its editor show genuinely different
|
|
32
|
+
* strings (`**bold**` against **bold**), so the sub-pixel drift that forced
|
|
33
|
+
* `InlineTextInput` to stay a single element is not perceptible here. That rule
|
|
34
|
+
* — same string ⇒ one element, different string ⇒ swap — is what decides which
|
|
35
|
+
* shape a new editor takes, and markdown is the clearest case of the second.
|
|
36
|
+
*
|
|
37
|
+
* **The editor never opens SHORTER than the view it replaced.** A swap editor
|
|
38
|
+
* is supposed to leave the box where it was; for markdown it cannot exactly,
|
|
39
|
+
* because rendered prose and its source are different lengths by nature. What it
|
|
40
|
+
* CAN do is refuse to collapse: the resting height is measured and becomes the
|
|
41
|
+
* input's floor, so pressing a long note opens a long editor. Growing is the
|
|
42
|
+
* tolerable direction — shrinking pulls the text the reader was just looking at
|
|
43
|
+
* out from under them, and everything below it jumps up.
|
|
44
|
+
*
|
|
45
|
+
* Reach for it wherever a field holds markdown a PERSON wrote. A model's output
|
|
46
|
+
* is read-only (you re-run it, you do not hand-edit it) and belongs in a plain
|
|
47
|
+
* `Markdown`.
|
|
48
|
+
*/
|
|
49
|
+
export function InlineMarkdown(props: InlineMarkdownProps) {
|
|
50
|
+
const { value, onSave, placeholder, disabled, variant, minLines, accessibilityLabel } = props;
|
|
51
|
+
const edit = useInlineEdit<string>({ value, onSave });
|
|
52
|
+
const [restingHeight, setRestingHeight] = useState(0);
|
|
53
|
+
// Not state: it must be current for the render that mounts the input, and a
|
|
54
|
+
// set during layout would arrive one frame late — as a visible jump.
|
|
55
|
+
const measured = useRef(0);
|
|
56
|
+
|
|
57
|
+
const onViewLayout = useCallback((e: LayoutChangeEvent) => {
|
|
58
|
+
measured.current = e.nativeEvent.layout.height;
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
const begin = useCallback(() => {
|
|
62
|
+
setRestingHeight(measured.current);
|
|
63
|
+
edit.begin();
|
|
64
|
+
}, [edit]);
|
|
65
|
+
|
|
66
|
+
const onKeyPress = useCallback(
|
|
67
|
+
(e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
|
|
68
|
+
// Enter is a PARAGRAPH BREAK here — this is prose, and `numberOfLines > 1`
|
|
69
|
+
// is what declares that everywhere else in the family. Blur commits.
|
|
70
|
+
if (e.nativeEvent.key === "Escape") edit.cancel();
|
|
71
|
+
},
|
|
72
|
+
[edit],
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const floor = Math.max((minLines ?? DEFAULT_MIN_LINES) * APPROX_LINE_HEIGHT, restingHeight);
|
|
76
|
+
|
|
77
|
+
const display: ReactNode = value === "" ? "" : (
|
|
78
|
+
<View onLayout={onViewLayout}>
|
|
79
|
+
<Markdown>{value}</Markdown>
|
|
80
|
+
</View>
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<InlineEditFrame
|
|
85
|
+
// Unlike `InlineTextInput`, `editing` here IS the mount: the input exists
|
|
86
|
+
// only during an edit, which is what makes the rendered view possible.
|
|
87
|
+
editing={edit.editing}
|
|
88
|
+
display={display}
|
|
89
|
+
placeholder={placeholder}
|
|
90
|
+
onBegin={begin}
|
|
91
|
+
controls="blur"
|
|
92
|
+
onCommit={() => void edit.commit()}
|
|
93
|
+
onCancel={edit.cancel}
|
|
94
|
+
saving={edit.saving}
|
|
95
|
+
error={edit.error}
|
|
96
|
+
disabled={disabled}
|
|
97
|
+
variant={variant}
|
|
98
|
+
accessibilityLabel={accessibilityLabel}
|
|
99
|
+
>
|
|
100
|
+
<TextInputField
|
|
101
|
+
value={edit.draft}
|
|
102
|
+
onChangeText={edit.setDraft}
|
|
103
|
+
onBlur={() => void edit.commit()}
|
|
104
|
+
onKeyPress={onKeyPress}
|
|
105
|
+
multiline
|
|
106
|
+
// The input ARRIVES with the edit, so it takes the caret on mount. This
|
|
107
|
+
// is the case `InlineTextInput` cannot serve — it is permanently mounted
|
|
108
|
+
// and deliberately has no `autoFocus`, so a revealed field there would
|
|
109
|
+
// cost the reader a second click.
|
|
110
|
+
autoFocus
|
|
111
|
+
placeholder={placeholder}
|
|
112
|
+
accessibilityLabel={accessibilityLabel}
|
|
113
|
+
disabled={disabled}
|
|
114
|
+
style={{ minHeight: floor }}
|
|
115
|
+
/>
|
|
116
|
+
</InlineEditFrame>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
@@ -18,6 +18,23 @@ export interface InlineTextInputProps {
|
|
|
18
18
|
* struck while being edited — the field is one element, so a treatment that
|
|
19
19
|
* dropped on focus would be exactly the jump this control exists to avoid. */
|
|
20
20
|
struck?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* The value is a URL — wear the navigation ink (`TextLink`'s underline +
|
|
23
|
+
* medium weight + blue).
|
|
24
|
+
*
|
|
25
|
+
* A TREATMENT, never a swapped-in `TextLink`, and that is the whole point:
|
|
26
|
+
* this field is one `<input>` in both states because a resting `Text` and an
|
|
27
|
+
* `<input>` cannot draw the same string identically (see the note on
|
|
28
|
+
* `editing` below). A link value's string does NOT change between reading and
|
|
29
|
+
* typing it — only its ink does — so swapping would reintroduce exactly the
|
|
30
|
+
* quarter-pixel jump that comment records, for no gain.
|
|
31
|
+
*
|
|
32
|
+
* Blue because a URL genuinely navigates, which is the same test `TextLink`
|
|
33
|
+
* applies to its own `href`. Pressing the field still EDITS: to open the
|
|
34
|
+
* destination, pass an `Open` `InlineButton` through `actions`, where a verb
|
|
35
|
+
* about the value belongs.
|
|
36
|
+
*/
|
|
37
|
+
link?: boolean;
|
|
21
38
|
/** How much frame shows at rest — see {@link InlineEditVariant}. Default "framed". */
|
|
22
39
|
variant?: InlineEditVariant;
|
|
23
40
|
/**
|
|
@@ -76,7 +93,7 @@ export interface InlineTextInputProps {
|
|
|
76
93
|
* implementation detail.
|
|
77
94
|
*/
|
|
78
95
|
export function InlineTextInput(props: InlineTextInputProps) {
|
|
79
|
-
const { value, onSave, placeholder, controls = "blur", disabled, struck, accessibilityLabel , variant, actions, numberOfLines, autoGrow } = props;
|
|
96
|
+
const { value, onSave, placeholder, controls = "blur", disabled, struck, link, accessibilityLabel , variant, actions, numberOfLines, autoGrow } = props;
|
|
80
97
|
// Growing implies wrapping: a field that grows on one line has nowhere to go.
|
|
81
98
|
const multiline = (numberOfLines ?? 1) > 1 || autoGrow === true;
|
|
82
99
|
// WHAT ENTER MEANS IS DECLARED BY `numberOfLines`, NOT BY WRAPPING. They were
|
|
@@ -195,7 +212,15 @@ export function InlineTextInput(props: InlineTextInputProps) {
|
|
|
195
212
|
// its title upright. Anything else the view used to render has to move the
|
|
196
213
|
// same way, or it goes the same way — quietly.
|
|
197
214
|
variant={variant}
|
|
198
|
-
style={
|
|
215
|
+
style={
|
|
216
|
+
struck
|
|
217
|
+
? { textDecorationLine: "line-through" as const, color: colors.zinc[500] }
|
|
218
|
+
: link && value !== ""
|
|
219
|
+
// `value`, not the draft: an emptied field is not a link, and the
|
|
220
|
+
// ink would otherwise sit on the placeholder.
|
|
221
|
+
? { textDecorationLine: "underline" as const, color: colors.blue[600], fontWeight: "500" as const }
|
|
222
|
+
: null
|
|
223
|
+
}
|
|
199
224
|
// With verbs on the field, the FRAME owns the surface and the ring.
|
|
200
225
|
seamless={actions != null}
|
|
201
226
|
/>
|
package/src/select.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { StyleSheet, View, type GestureResponderEvent, type StyleProp, type ViewStyle } from "react-native";
|
|
2
2
|
import { useState, useCallback, useMemo, type ReactNode, type Ref } from "react";
|
|
3
3
|
import { colors } from "./colors";
|
|
4
|
+
import { Icon } from "./icon";
|
|
4
5
|
import { FOCUS_RING, CONTROL_RADIUS, CONTROL_HEIGHT, CONTROL_PADDING_V, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
|
|
5
6
|
import { FocusRingPressable } from "./focus_ring_pressable";
|
|
6
7
|
import { Text } from "./text";
|
|
@@ -267,6 +268,7 @@ function SelectTrigger<T extends string, D = unknown>({
|
|
|
267
268
|
onPress={!disabled ? onPress : undefined}
|
|
268
269
|
disabled={disabled}
|
|
269
270
|
>
|
|
271
|
+
<View style={styles.triggerContent}>
|
|
270
272
|
{hasSelection ? (
|
|
271
273
|
renderSelected ? (
|
|
272
274
|
<View style={styles.selectionRow}>
|
|
@@ -297,11 +299,26 @@ function SelectTrigger<T extends string, D = unknown>({
|
|
|
297
299
|
{placeholder}
|
|
298
300
|
</Text>
|
|
299
301
|
)}
|
|
302
|
+
</View>
|
|
303
|
+
{/* The resting chevron says "this opens a list" — the promise `InlineSelect`
|
|
304
|
+
already makes, and it has to be the SAME on every surface or the two
|
|
305
|
+
controls are one field wearing two contracts. Without it this trigger is a
|
|
306
|
+
bordered box holding text, which beside a `SearchInput` in a filter band
|
|
307
|
+
reads as a second input rather than something that opens. */}
|
|
308
|
+
<Icon name="chevron-down" size={18} color={colors.zinc[400]} />
|
|
300
309
|
</FocusRingPressable>
|
|
301
310
|
);
|
|
302
311
|
}
|
|
303
312
|
|
|
304
313
|
const styles = StyleSheet.create({
|
|
314
|
+
triggerContent: {
|
|
315
|
+
// Grows so the chevron stays pinned to the right edge whatever the
|
|
316
|
+
// selection is — a chip box, a comma summary or a placeholder.
|
|
317
|
+
flex: 1,
|
|
318
|
+
flexDirection: "row",
|
|
319
|
+
alignItems: "center",
|
|
320
|
+
minWidth: 0,
|
|
321
|
+
},
|
|
305
322
|
pressable: {
|
|
306
323
|
flexDirection: "row",
|
|
307
324
|
alignItems: "center",
|