@lotics/ui 45.7.0 → 45.8.1

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/docs/catalog.md CHANGED
@@ -165,14 +165,22 @@ in `actions`),
165
165
  the product's own knowledge docs use, not a raw textarea. It does NOT swap: the editor
166
166
  is mounted at rest and merely gains a draft on focus, so focusing moves nothing and
167
167
  the prose stays SELECTABLE rather than sitting under a button. Its toolbar is off for
168
- that reason — a band that appears with the edit pushes everything under it down.
168
+ that reason — a band that appears with the edit pushes everything under it down — and it
169
+ passes its **`variant`** THROUGH to the editor, which is what actually draws the box:
170
+ the frame paints no surface here, so a variant that stopped at it stopped at nothing and
171
+ every markdown field wore the document sheet, 4px right of the plain-text field above it.
169
172
  **`numberOfLines`** is the reserve, as everywhere else in the family); a
170
173
  READ-ONLY field in that same column uses `InlineStatic` (matches the editor box exactly, no
171
174
  input chrome, so it aligns pixel-for-pixel). **`MarkdownEditor`** is that editor standalone — a WYSIWYG whose value stays a plain
172
175
  markdown string, with a formatting toolbar as a SIBLING of the field (what the border
173
176
  encloses is the document; the controls that act on it sit outside it). Web is
174
177
  ProseMirror via `@lotics/markdown-editor`; native falls back to raw-text editing, and
175
- the platform-neutral `MarkdownEditorProps` is what stops the two drifting. Wrap it in a
178
+ the platform-neutral `MarkdownEditorProps` is what stops the two drifting. **`variant`**
179
+ is `"document"` (the default soft sheet) or the two FIELD states, `"framed"` / `"bare"`,
180
+ DERIVED from `InlineEditVariant` so they cannot drift from the rest of the family. A field
181
+ takes the control's corner and the control's text inset instead of the sheet's. The default
182
+ is NAMED rather than left as absence: an optional prop whose omission is a distinct third
183
+ mode reads exactly backwards to anyone who knows `TextInputField.variant`. Wrap it in a
176
184
  label with **`FormMarkdownEditor`**. Pass **`accessibilityLabel`** whenever the visible
177
185
  label is a sibling rather than a wrapping `FormField` — a `DetailRow`, an inline
178
186
  editor's frame — or the editable surface is an unnamed text box. A stack of labelled
@@ -827,13 +835,18 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
827
835
  rather than a value). Throws when the clipboard is unreachable — never resolve a write
828
836
  that did not happen, or the caller confirms a lie. Web-only (native has no
829
837
  `navigator.clipboard`).
830
- - **`back_button`** — `BackButton`: the one go-back control don't hand-roll it (an
831
- `arrow-left` `MenuButton` reads as another nav ITEM, not an exit). `onPress` +
832
- `accessibilityLabel` (defaults to `label`, else the locale's `nav.back` — "Back" /
833
- "Quay lại"). Bare, it's the chevron-left `IconButton` (lg, secondary) heading a
834
- screen/panel; pass **`label`** to name where back GOES ("Danh sách") — the SAME
835
- circular glyph with the destination beside it, ONE pressable over disc + words
836
- never an icon button beside unclickable prose.
838
+ - **`back_button`** — `BackButton`: the CIRCULAR go-back control that HEADS a screen or
839
+ panel — don't hand-roll it (an `arrow-left` `MenuButton` reads as another nav ITEM, not
840
+ an exit). `onPress` + `accessibilityLabel` (defaults to `label`, else the locale's
841
+ `nav.back` — "Back" / "Quay lại"). Bare it is the 40px chevron-left disc; pass
842
+ **`label`** to name where back GOES ("Danh sách") — the SAME disc with the destination
843
+ beside it, ONE pressable over disc + words, never an icon button beside unclickable
844
+ prose. It takes no size or colour: one purpose, one look, and both variants draw the
845
+ one disc so they cannot drift.
846
+ **A back affordance INSIDE a header row is not this** — beside a drawer's title a 40px
847
+ disc outweighs the name it belongs to, so that one is a small `IconButton` (see
848
+ `screen_router` below). The distinction is prominence: this HEADS a surface, that
849
+ annotates a title.
837
850
  - **`link`** — `Link`: the EXTERNAL hyperlink — fixed underline+blue + `role="link"`;
838
851
  `onPress` only (the consumer wires the opener).
839
852
  - **`text_link`** — `TextLink`: underlined text that NAVIGATES — `href` renders a real
@@ -32,7 +32,11 @@ in [the templates](./templates.md) (`examples/tpl_*.tsx`).
32
32
  **The right-input-per-field law.** A field gets the control its SHAPE wants, never a default
33
33
  text box — and on a record surface radio/checkbox render as PERSISTENT controls (the control
34
34
  is the best display of the value; prose-shaped values and registry picks stay inline
35
- editors). Rich `InlineSelect` options carry a description line (`renderOptionContent` — it
35
+ editors). **A MARKDOWN-typed field takes `InlineMarkdown`**, never `InlineTextInput`: the
36
+ plain input prints the syntax it stores, so a reader sees `**` where every other surface
37
+ showing that value renders it. It hides in the gap between two states of one field — the
38
+ value is formatted wherever it is read-only and raw only where it is EDITABLE, which is one
39
+ row, and looks like a quirk of that row rather than the wrong control. Rich `InlineSelect` options carry a description line (`renderOptionContent` — it
36
40
  shows in the resting row too; the `data` generic types option payloads). A DEPENDENT field
37
41
  renders only while its parent value makes it real — never a disabled ghost row. Worked rows:
38
42
  `tpl_record`'s Classification group.
@@ -145,6 +149,24 @@ over the first. **When "the layout must not move" is a requirement rather than a
145
149
  element is the only thing that delivers it** — a swap can be made to look stable, never to be it. So
146
150
  the editor is mounted at rest and gains a draft on focus, and the per-field cost is paid on purpose.
147
151
  If a screen ever holds dozens of these, the answer is fewer markdown fields on it, not a swap.
152
+
153
+ **`variant` has to reach whatever actually DRAWS the box.** `InlineEditFrame` paints no surface on
154
+ the path this field takes — it has no `actions` — so the editor's own frame is the field's frame,
155
+ and a `variant` that stops at the wrapper stops at nothing: `bare` bought no change at all, and
156
+ every markdown field wore the document sheet whatever the caller asked for. So `MarkdownEditor`
157
+ takes the same `"framed" | "bare"` axis as `TextInputField`, and `InlineMarkdown` passes it through.
158
+
159
+ **The half that is easy to miss is the INSET, not the border.** A document sheet insets its prose
160
+ further than a control does (13px against `CONTROL_TEXT_INSET`'s 9), so an editor serving as a
161
+ field lands 4px right of the plain-text field stacked directly above it — a ragged left edge inside
162
+ one card, with nothing on screen naming the cause. A field variant is therefore what makes the
163
+ editor a FIELD: it takes the control's corner (`CONTROL_RADIUS`) and the control's inset with it.
164
+ The third value, `"document"`, is the default and every standalone surface in the product — and it
165
+ is a NAMED value rather than an absence, because an optional prop whose omission is a distinct
166
+ third mode reads backwards to anyone who knows `TextInputField.variant`, where omitting it gives
167
+ you `"framed"`. `seamless` is the different case and not this one: that surrenders the surface to a
168
+ host that draws it, and here there is no host — `InlineEditFrame` paints one only when it also owns
169
+ the verbs, so with none the CHILD owns the surface.
148
170
  **A LINK does not swap**: marking a URL changes its ink, not its characters, so `InlineTextInput`
149
171
  takes a **`link`** treatment on its single `<input>` rather than swapping in a `TextLink`. Pressing
150
172
  the field still edits — an `Open` `InlineButton` in `actions` is how you reach the destination,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "45.7.0",
3
+ "version": "45.8.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -1,9 +1,8 @@
1
- import { IconButton } from "./icon_button";
2
1
  import { View } from "react-native";
3
2
  import { Icon } from "./icon";
4
3
  import { Text } from "./text";
5
4
  import { colors } from "./colors";
6
- import { CURSOR_ACTION } from "./control_surface";
5
+ import { CONTROL_HEIGHT, CURSOR_ACTION } from "./control_surface";
7
6
  import { PressableHighlight } from "./pressable_highlight";
8
7
  import { useLoticsLocale } from "./locale";
9
8
 
@@ -18,26 +17,66 @@ interface BackButtonProps {
18
17
  accessibilityLabel?: string;
19
18
  }
20
19
 
20
+ /**
21
+ * THE DISC, drawn ONCE for both variants and pressable in neither.
22
+ *
23
+ * It used to exist twice: the bare variant delegated to an `IconButton`, the
24
+ * labelled one hand-rolled the identical circle and restated that component's
25
+ * spec in a comment — "mirrors IconButton lg/secondary (40px, zinc-100, 20px
26
+ * zinc-900 glyph)". A spec copied into a comment is a spec that drifts: change
27
+ * `secondary` and one variant of ONE component follows while the other does not.
28
+ *
29
+ * Delegating both to `IconButton` is not the fix and never was. The labelled
30
+ * form is ONE pressable over the disc AND the words — an `IconButton` inside it
31
+ * is a button nested in a button, which is why the copy got written in the first
32
+ * place. So the shared thing is the SURFACE, not the control: inert here, and
33
+ * whichever pressable wraps it owns the gesture.
34
+ *
35
+ * IT BORROWS NOTHING FROM ANOTHER CONTROL. An earlier pass took the glyph ink
36
+ * from `getButtonIconColor("secondary")`, which reads as sharing until you say it
37
+ * out loud: this is not a secondary BUTTON, and tying its ink to that ladder
38
+ * means a change to the button palette silently restyles the back affordance for
39
+ * a reason no one would connect. The ink is `zinc-900` because that is what this
40
+ * glyph is.
41
+ *
42
+ * `CONTROL_HEIGHT` stays, and is not the same kind of borrowing: it is the
43
+ * SYSTEM's band height — the promise every 40px control aligns to — not another
44
+ * component's opinion. Typing `40` here would be the drift, not the reuse.
45
+ */
46
+ const GLYPH = 20;
47
+
48
+ function Disc() {
49
+ return (
50
+ <View
51
+ style={{
52
+ width: CONTROL_HEIGHT,
53
+ height: CONTROL_HEIGHT,
54
+ borderRadius: 999,
55
+ backgroundColor: colors.zinc[100],
56
+ alignItems: "center",
57
+ justifyContent: "center",
58
+ }}
59
+ >
60
+ <Icon name="chevron-left" size={GLYPH} color={colors.zinc[900]} />
61
+ </View>
62
+ );
63
+ }
64
+
65
+ /**
66
+ * THE go-back control: a circular glyph that leaves the current screen or panel,
67
+ * optionally naming where it goes.
68
+ *
69
+ * It is one purpose, so it takes no size or colour — a caller reaching for those
70
+ * wants a different control. In particular a drawer header's small inline arrow
71
+ * is NOT this: that sits beside a title inside a header row, where a 40px disc
72
+ * would outweigh the name it belongs to. That one is an `IconButton`, and the
73
+ * distinction is prominence — this one HEADS a surface, that one annotates a
74
+ * title.
75
+ */
21
76
  export function BackButton(props: BackButtonProps) {
22
77
  const locale = useLoticsLocale();
23
78
  const { onPress, label, accessibilityLabel = label ?? locale.nav.back } = props;
24
- // Bare — the circular glyph IS the control.
25
- if (label === undefined) {
26
- return (
27
- <IconButton
28
- icon="chevron-left"
29
- size="lg"
30
- color="secondary"
31
- accessibilityLabel={accessibilityLabel}
32
- onPress={onPress}
33
- style={{ alignSelf: "flex-start" }}
34
- />
35
- );
36
- }
37
- // Labelled — the SAME circular glyph, the destination named beside it, ONE
38
- // pressable pill over both (words you can't press are a lie). The disc
39
- // mirrors IconButton lg/secondary (40px, zinc-100, 20px zinc-900 glyph); the
40
- // pill takes PressableHighlight's default hover/press wash + focus ring.
79
+
41
80
  return (
42
81
  <PressableHighlight
43
82
  focusRing
@@ -45,16 +84,28 @@ export function BackButton(props: BackButtonProps) {
45
84
  accessibilityLabel={accessibilityLabel}
46
85
  userSelect="none"
47
86
  onPress={onPress}
48
- // Going back ACTS, exactly as the bare variant's `IconButton` does
49
- // without this, one component shows two cursors decided by a label.
50
- style={{ cursor: CURSOR_ACTION, flexDirection: "row", alignItems: "center", gap: 8, alignSelf: "flex-start", borderRadius: 999, paddingRight: 14 }}
87
+ // Going back ACTS. One cursor for both variants a label must not decide
88
+ // whether the same control looks pressable.
89
+ //
90
+ // The pill's own padding exists only when there are words to seat: bare,
91
+ // the disc IS the target and any padding would inflate it past the 40px
92
+ // every other control in the band is aligned to.
93
+ style={{
94
+ cursor: CURSOR_ACTION,
95
+ flexDirection: "row",
96
+ alignItems: "center",
97
+ gap: 8,
98
+ alignSelf: "flex-start",
99
+ borderRadius: 999,
100
+ paddingRight: label === undefined ? 0 : 14,
101
+ }}
51
102
  >
52
- <View style={{ width: 40, height: 40, borderRadius: 999, backgroundColor: colors.zinc[100], alignItems: "center", justifyContent: "center" }}>
53
- <Icon name="chevron-left" size={20} color={colors.zinc[900]} />
54
- </View>
55
- <Text size="sm" weight="medium" color="muted" numberOfLines={1}>
56
- {label}
57
- </Text>
103
+ <Disc />
104
+ {label === undefined ? null : (
105
+ <Text size="sm" weight="medium" color="muted" numberOfLines={1}>
106
+ {label}
107
+ </Text>
108
+ )}
58
109
  </PressableHighlight>
59
110
  );
60
111
  }
@@ -81,6 +81,12 @@ export function InlineMarkdown(props: InlineMarkdownProps) {
81
81
  onFocus={edit.begin}
82
82
  onBlur={() => void edit.commit()}
83
83
  toolbar={false}
84
+ // THE FIELD'S FRAME IS THIS EDITOR'S OWN. `InlineEditFrame` draws no
85
+ // surface on the path this field takes (it has no `actions`), so the
86
+ // caller's `variant` has to reach the editor or it reaches nothing —
87
+ // which is what made a `bare` markdown field draw a document sheet
88
+ // anyway, 4px right of the plain-text field stacked above it.
89
+ variant={variant ?? "framed"}
84
90
  placeholder={placeholder}
85
91
  numberOfLines={numberOfLines}
86
92
  disabled={disabled}
@@ -37,6 +37,46 @@
37
37
  opacity: 0.6;
38
38
  }
39
39
 
40
+ /* THE TWO FIELD VARIANTS. Everything above is the `document` default and stays
41
+ the default: a standalone editor is a page's subject, and there are far more
42
+ of those than there are markdown FIELDS.
43
+
44
+ Both field variants are named explicitly rather than matched as "has a
45
+ variant at all". `document` is a real value that reaches the DOM too, so a
46
+ bare `[data-variant]` selector would catch the sheet and re-inset it — the
47
+ attribute says WHICH of three, never whether. */
48
+ .md-editor[data-variant="framed"],
49
+ .md-editor[data-variant="bare"] {
50
+ border-radius: 10px;
51
+ }
52
+
53
+ .md-editor[data-variant="framed"] > .md-editor-content,
54
+ .md-editor[data-variant="bare"] > .md-editor-content {
55
+ padding-left: 8px;
56
+ padding-right: 8px;
57
+ }
58
+
59
+ /* The two numbers the field variants move are the KIT's, not this file's:
60
+ `CONTROL_RADIUS` (10) above replaces the sheet's 16 — a control corner, not a
61
+ page corner — and the text inset drops to `CONTROL_TEXT_INSET` (9) = a 1px
62
+ border plus 8px of padding, replacing the sheet's 13.
63
+
64
+ That second one is the whole visible defect this exists for: a document
65
+ insets its prose further than a control does, so an editor serving as a field
66
+ while keeping the sheet's inset lands 4px right of the plain-text field
67
+ stacked directly above it in the same card.
68
+
69
+ Vertical padding is untouched — a control's 40px height is governed by
70
+ `minHeight` elsewhere, and prose here is multi-line by definition.
71
+
72
+ BARE additionally has no ground and no edge at REST. The hover border and the
73
+ focus ring above still apply: a bare field draws its own frame, it just does
74
+ not draw it until you reach for it. */
75
+ .md-editor[data-variant="bare"] {
76
+ border-color: transparent;
77
+ background-color: transparent;
78
+ }
79
+
40
80
  .md-editor-content {
41
81
  flex: 1 1 auto;
42
82
  /* position:relative anchors the absolutely-positioned gap cursor. */
@@ -7,7 +7,7 @@ import type { MarkdownEditorProps } from "./markdown_editor_props";
7
7
  * edited as raw text.
8
8
  */
9
9
  export function MarkdownEditor(props: MarkdownEditorProps) {
10
- const { value, onChangeText, onBlur, onFocus, placeholder, numberOfLines, disabled, autoFocus, accessibilityLabel, testID } =
10
+ const { value, onChangeText, onBlur, onFocus, variant, placeholder, numberOfLines, disabled, autoFocus, accessibilityLabel, testID } =
11
11
  props;
12
12
 
13
13
  return (
@@ -19,6 +19,12 @@ export function MarkdownEditor(props: MarkdownEditorProps) {
19
19
  onFocus={onFocus}
20
20
  placeholder={placeholder}
21
21
  numberOfLines={numberOfLines ?? 6}
22
+ // The two FIELD variants pass straight through — they are this input's
23
+ // own prop and mean the same thing here as on web. `"document"` has no
24
+ // native counterpart: the soft sheet is a web-only treatment and there is
25
+ // no raw-text equivalent of it, so a document editor falls back to this
26
+ // input's own default frame rather than inventing one.
27
+ variant={variant === "document" ? undefined : variant}
22
28
  multiline
23
29
  autoGrow
24
30
  disabled={disabled}
@@ -15,7 +15,7 @@ import { MarkdownToolbar } from "./markdown_toolbar.web";
15
15
  * plain markdown string. The ProseMirror engine lives in `@lotics/markdown-editor`.
16
16
  */
17
17
  export function MarkdownEditor(props: MarkdownEditorProps) {
18
- const { value, onChangeText, onBlur, onFocus, toolbar = true, placeholder, numberOfLines, disabled, autoFocus, accessibilityLabel, testID } =
18
+ const { value, onChangeText, onBlur, onFocus, toolbar = true, variant = "document", placeholder, numberOfLines, disabled, autoFocus, accessibilityLabel, testID } =
19
19
  props;
20
20
 
21
21
  const hostRef = useRef<HTMLDivElement>(null);
@@ -82,6 +82,7 @@ export function MarkdownEditor(props: MarkdownEditorProps) {
82
82
  className="md-editor"
83
83
  data-testid={testID}
84
84
  data-disabled={disabled ? "true" : undefined}
85
+ data-variant={variant}
85
86
  style={{ minHeight }}
86
87
  />
87
88
  </>
@@ -1,3 +1,22 @@
1
+ import type { InlineEditVariant } from "./inline_edit";
2
+
3
+ /**
4
+ * What this editor IS, which decides the box it draws.
5
+ *
6
+ * DERIVED from `InlineEditVariant` rather than spelled out, so the two field
7
+ * states cannot drift from the rest of the `Inline*` family — this editor draws
8
+ * its own surface for the same reason every other no-verbs inline control does
9
+ * (`InlineEditFrame` paints one only when it also owns the ✓/✕ verbs), so it has
10
+ * to answer the same question in the same words.
11
+ *
12
+ * `"document"` is the third state and the DEFAULT, and it is named rather than
13
+ * left as absence on purpose: an optional two-value prop whose omission is a
14
+ * distinct third mode is a trap — nothing in the type says so, and the reader
15
+ * who knows `TextInputField.variant` (where omitting it means `"framed"`) reads
16
+ * it exactly backwards.
17
+ */
18
+ export type MarkdownEditorVariant = "document" | InlineEditVariant;
19
+
1
20
  /**
2
21
  * Shared prop contract for the markdown editor. Declared platform-neutrally so
3
22
  * the web (`markdown_editor.web.tsx`) and native (`markdown_editor.tsx`)
@@ -17,6 +36,24 @@ export interface MarkdownEditorProps {
17
36
  * always there and shifts nothing, so it stays on.
18
37
  */
19
38
  toolbar?: boolean;
39
+ /**
40
+ * `"document"` (the default) is a soft 16px sheet — a knowledge doc, a
41
+ * settings screen, anywhere the editor is the page's subject.
42
+ *
43
+ * `"framed"` and `"bare"` make it a FIELD, and mean there what they mean on
44
+ * every other inline control: an edge at rest, or no edge until hover. Both
45
+ * also pull the text inset in to `CONTROL_TEXT_INSET`, which is the half
46
+ * that is easy to miss — a document insets its prose further than a control
47
+ * does, so an editor serving as a field while keeping the sheet's inset sits
48
+ * 4px right of the plain-text field stacked above it.
49
+ *
50
+ * The editor draws this itself rather than taking it from a host, because
51
+ * `InlineEditFrame` paints a surface only when it also owns the ✓/✕ verbs;
52
+ * with none, the CHILD owns the surface. That is the same contract
53
+ * `TextInputField` answers with its own `variant`, and the reason `seamless`
54
+ * is the wrong lever here — nothing is drawing a box to surrender to.
55
+ */
56
+ variant?: MarkdownEditorVariant;
20
57
  placeholder?: string;
21
58
  numberOfLines?: number;
22
59
  disabled?: boolean;