@lotics/ui 44.5.0 → 44.7.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 +11 -0
- package/docs/catalog.md +16 -0
- package/docs/composition.md +18 -9
- package/docs/reviewing.md +37 -0
- package/examples/tpl_record.tsx +62 -14
- package/package.json +4 -1
- package/src/brand_mark.tsx +79 -0
- package/src/clarify.tsx +3 -2
- package/src/eyebrow.tsx +42 -0
- package/src/funnel.tsx +3 -2
- package/src/group_lead.tsx +39 -0
- package/src/kpi_card.tsx +3 -2
- package/src/locale.tsx +8 -0
- package/src/progress_bar.tsx +3 -2
- package/src/record_summary.tsx +3 -2
- package/src/sources.tsx +3 -2
- package/src/stacked_progress_bar.tsx +3 -2
- package/src/step_progress.tsx +3 -2
- package/src/text_disclosure.tsx +3 -1
- package/src/text_input_field.tsx +20 -7
- package/src/use_auto_grow_height.ts +18 -0
package/AGENTS.md
CHANGED
|
@@ -75,6 +75,11 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
|
|
|
75
75
|
`DetailRow` — the three annotation slots carry the three urgency levels exactly. A binary
|
|
76
76
|
`error={overdue ? … }` is the tell: it makes a date due tomorrow read like one due in three
|
|
77
77
|
months. → [catalog.md §Dates & times](./docs/catalog.md).
|
|
78
|
+
- **A third-party channel takes `BrandMark`, our own glyphs take `Icon`.** `@lotics/ui/brand_mark`
|
|
79
|
+
draws Facebook / Zalo / LinkedIn / Google Meet as solid single-colour silhouettes — a reader
|
|
80
|
+
scanning a feed recognises the logo before the word. The names are an allowlist, so a channel
|
|
81
|
+
with no mark (a phone call, an event, a website) fails to typecheck and falls back to `Icon`
|
|
82
|
+
instead of borrowing another company's logo. → [catalog.md §Text & formatting](./docs/catalog.md).
|
|
78
83
|
- **The kit's fonts/colors/icons ARE the design system** — never a custom font, icon set, or
|
|
79
84
|
hand-picked palette shade; color is `solid`/`tint`/`ramp` with ONE accent per screen.
|
|
80
85
|
- **Hand-typed type is off-system — and it always lands too small.** Every run of language is
|
|
@@ -84,6 +89,12 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
|
|
|
84
89
|
the only way to get the letter-spacing curve and Inter's `cv11 ss01 ss03` alternates, the
|
|
85
90
|
"designed" tells you cannot type by hand. **Grep the diff: `fontSize:` / `lineHeight:` /
|
|
86
91
|
`letterSpacing:` outside `src/` is a bug.** → [composition.md §Typography](./docs/composition.md).
|
|
92
|
+
- **A label is an `Eyebrow` or a `GroupLead`, and the difference is WHAT IT LABELS.** A value
|
|
93
|
+
gets `<Eyebrow>` (`xs` muted medium, dense surfaces, never uppercase); a BLOCK someone acts on
|
|
94
|
+
gets `<GroupLead>` (`sm` medium, page ink). Neither takes `size`/`weight`/`transform` — the
|
|
95
|
+
omitted props are the rule. Hand-writing the `Text` yourself is how an act-on-it title ends up
|
|
96
|
+
whispering at 12px, which is also where stacked diacritics crowd. →
|
|
97
|
+
[composition.md §Eyebrow vs group lead](./docs/composition.md).
|
|
87
98
|
- **Pure primitives only** — no i18n, analytics, or domain types in `src/` (pass `labels`,
|
|
88
99
|
callbacks); Lotics-coupled UI belongs in `@lotics/ui-internal`.
|
|
89
100
|
- **A button never contains a control** — a row that presses open AND carries its own controls
|
package/docs/catalog.md
CHANGED
|
@@ -430,6 +430,22 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
430
430
|
numbers), `weight`, `color`, alignment, tabular numerals. **`leading="tight"`** sets the line
|
|
431
431
|
box for a STACKED PAIR rather than for prose — a subject over its supporting line, a value over
|
|
432
432
|
its annotation; see composition.md, which also covers the `gap` that goes with it.
|
|
433
|
+
- **`eyebrow`** — `Eyebrow`: the small quiet label above or beside a VALUE (`xs` muted medium) —
|
|
434
|
+
a metric caption, a field name in a cell, an artifact tag. Takes `color` (for a VERDICT word
|
|
435
|
+
like "Mismatch") and `align`, and deliberately takes no `size`, `weight` or `transform`:
|
|
436
|
+
all-caps is banned and a bigger eyebrow is a different rung. Calibrated for DENSE surfaces.
|
|
437
|
+
- **`group_lead`** — `GroupLead`: the label that titles a BLOCK (`sm` medium in the page's ink,
|
|
438
|
+
`size="md"` for a major sub-part) — "Delivery" over an address stack, "Next action" over the
|
|
439
|
+
paragraph someone acts on. No `weight`: semibold is the heading rung, so reach for
|
|
440
|
+
`SubsectionHeading` instead. Pick between this and `Eyebrow` by what is LABELLED — a block or a
|
|
441
|
+
value; composition.md § Eyebrow vs group lead has the table.
|
|
442
|
+
- **`brand_mark`** — `BrandMark`: an OUTSIDE brand's mark, naming the channel a touchpoint
|
|
443
|
+
happened on — `facebook`, `zalo`, `linkedin`, `google-meet`. Solid single-colour silhouettes in
|
|
444
|
+
each brand's own hex, not the full-colour logos: a byline draws these at 14–20px and a
|
|
445
|
+
five-colour logo turns to mud there. `size` (default 16) and `muted` (draw in the text colour).
|
|
446
|
+
The name list is an ALLOWLIST — a channel with no mark will not typecheck, so the call site
|
|
447
|
+
falls back to a generic `Icon` rather than rendering the wrong company's logo. Not for the app's
|
|
448
|
+
own glyphs; that is `icon`.
|
|
433
449
|
- **`markdown`** — `Markdown`: the single canonical markdown renderer for chat, apps, and
|
|
434
450
|
`AgentRun`; rich GFM markdown on web with copyable tables, plain-text on
|
|
435
451
|
native; takes a markdown `children` string. **`variant`** decides whose type
|
package/docs/composition.md
CHANGED
|
@@ -134,7 +134,12 @@ restyle a heading level per-page.
|
|
|
134
134
|
hand-roll `Text weight="semibold"` group leads inside a section, and never promote a subsection
|
|
135
135
|
to its own section-level heading just to separate it.
|
|
136
136
|
- **Eyebrow vs group lead — a label is one or the other, and the difference is what it
|
|
137
|
-
LABELS.**
|
|
137
|
+
LABELS.** Each is a COMPONENT — `@lotics/ui/eyebrow` and `@lotics/ui/group_lead` — so the call
|
|
138
|
+
site DECLARES which one it is rather than re-deriving a rung from feel, which is how a title
|
|
139
|
+
ends up whispering. They were hand-written `Text` and this section told you to keep it that
|
|
140
|
+
way; a shipped app then labelled three act-on-it blocks at the eyebrow's `xs` muted, which is
|
|
141
|
+
the failure the prose could not prevent. Neither takes a `size`, `weight` or `transform`: the
|
|
142
|
+
props a component omits are the rule it enforces.
|
|
138
143
|
|
|
139
144
|
| | Eyebrow | Group lead |
|
|
140
145
|
|---|---|---|
|
|
@@ -148,14 +153,18 @@ restyle a heading level per-page.
|
|
|
148
153
|
an afterthought: there are no siblings for it to be quiet among. A label that titles a block
|
|
149
154
|
someone must ACT on is a group lead, and `sm medium` is its floor.
|
|
150
155
|
|
|
151
|
-
- **Eyebrow** — `<
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
156
|
+
- **Eyebrow** — `<Eyebrow>` (`xs` muted medium), **sentence case, NEVER uppercase**: a small
|
|
157
|
+
quiet label above or beside a VALUE — an artifact tag ("Proposed", "Question", "Suggested
|
|
158
|
+
edit"), a field name, a metric caption, a minor one-line label. **All-caps is banned** — it
|
|
159
|
+
reads as shouting, and reflexively uppercasing every little label is the #1 thing that makes a
|
|
160
|
+
surface feel templated. There is no `transform` prop to reach for. (A COLORED status word — a
|
|
161
|
+
verdict like "Mismatch" / "Resolved" — keeps the rung and the weight and takes a status
|
|
162
|
+
`color`; colour here is what the word MEANS, never emphasis.)
|
|
163
|
+
- **Group lead** — `<GroupLead>` (`sm` medium in the page's own ink, `size="md"` where the block
|
|
164
|
+
is a major sub-part). The label over a BLOCK: "Deciding factor" over the fact it names,
|
|
165
|
+
"Delivery" over an address stack, "Next action" over the paragraph someone acts on. No
|
|
166
|
+
`weight` prop — semibold is the heading rung, and a group lead reaching for it is a heading
|
|
167
|
+
wearing the wrong name. Reach for `SubsectionHeading` instead.
|
|
159
168
|
- **Gate header** — a `Dialog` uses `DialogHeaderTitle`; a popover form uses
|
|
160
169
|
`Text size="sm" weight="semibold"` + an optional xs muted subtitle.
|
|
161
170
|
|
package/docs/reviewing.md
CHANGED
|
@@ -236,6 +236,13 @@ A resting register is the cheapest thing to screenshot and the least likely to h
|
|
|
236
236
|
(a feed, a thread) above a bounded one buries it, and the burial deepens with use.
|
|
237
237
|
- **Press every control** and look at the state you land in. Commonest miss: a value rendering as
|
|
238
238
|
a coloured chip in a register and as bare grey text in its own EDITOR.
|
|
239
|
+
- **TAB through it, then diff what a keyboard reaches against what LOOKS pressable.** A pointer
|
|
240
|
+
finds anything with an `onPress`; a keyboard finds only what carries a role and a tab stop, and
|
|
241
|
+
the two lists come apart silently — the screen looks identical either way, and the author
|
|
242
|
+
testing with a mouse never learns. The reliable producer is styled text: underline, a hover
|
|
243
|
+
wash, a "verb" colour applied to a plain container, which reads as a control to the eye while
|
|
244
|
+
announcing as prose. Anything the eye calls a control and the tab order omits is unreachable,
|
|
245
|
+
not merely awkward. Count both lists; a difference is the finding.
|
|
239
246
|
- **Diff a promoted field against where it came from.** Promoting means MOVING; a copy left behind
|
|
240
247
|
gives one field two editors.
|
|
241
248
|
- **Read every string in its SETTLED state**, not the streaming one.
|
|
@@ -473,6 +480,36 @@ receives `mouseenter`, because the pointer is already stationary over it — so
|
|
|
473
480
|
dies at the moment of the swap and reads as a flash. Keep one node across the swap rather than
|
|
474
481
|
restoring the state by hand.
|
|
475
482
|
|
|
483
|
+
### Reachability — what the eye calls a control against what the tab order holds
|
|
484
|
+
|
|
485
|
+
Two lists off one render. The first is what a keyboard can operate; the second is everything that
|
|
486
|
+
LOOKS operable — styled text is the producer, so underline is the cheapest thing to sweep for.
|
|
487
|
+
|
|
488
|
+
```js
|
|
489
|
+
() => {
|
|
490
|
+
const root = document.querySelector('[role="dialog"]') ?? document.body;
|
|
491
|
+
const operable = [...root.querySelectorAll(
|
|
492
|
+
'a[href], button, [role="button"], [role="link"], [tabindex]:not([tabindex="-1"]), input, select, textarea',
|
|
493
|
+
)];
|
|
494
|
+
const looksPressable = [...root.querySelectorAll("*")].filter((e) => {
|
|
495
|
+
if (e.children.length || !(e.textContent || "").trim()) return false;
|
|
496
|
+
const cs = getComputedStyle(e);
|
|
497
|
+
return cs.textDecorationLine.includes("underline") || cs.cursor === "pointer";
|
|
498
|
+
});
|
|
499
|
+
const unreachable = looksPressable.filter((e) => !e.closest(
|
|
500
|
+
'a[href], button, [role="button"], [role="link"], [tabindex]:not([tabindex="-1"])',
|
|
501
|
+
));
|
|
502
|
+
return {
|
|
503
|
+
operable: operable.length,
|
|
504
|
+
unreachable: unreachable.map((e) => (e.textContent || "").trim().slice(0, 40)),
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
`unreachable` must come back empty. A name in it is a control only a pointer can reach — and the
|
|
510
|
+
count beside it is the other half of the reading: a whole pane offering two or three tab stops is
|
|
511
|
+
usually not a minimal screen, it is a screen whose verbs are all prose.
|
|
512
|
+
|
|
476
513
|
Driving the kit's own anatomies — a `PressDoor` row, a portalled overlay, a custom pointer drag —
|
|
477
514
|
has three gotchas of its own: [testing.md](./testing.md).
|
|
478
515
|
|
package/examples/tpl_record.tsx
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Fragment, useEffect, useRef, useState, type ReactNode, useMemo } from "react";
|
|
2
2
|
import { Pressable, ScrollView, View } from "react-native";
|
|
3
3
|
import { Text } from "@lotics/ui/text";
|
|
4
|
+
import { GroupLead } from "@lotics/ui/group_lead";
|
|
5
|
+
import { BrandMark, type BrandName } from "@lotics/ui/brand_mark";
|
|
4
6
|
import { colors, solid, withAlpha } from "@lotics/ui/colors";
|
|
5
7
|
import { DRAWER_GUTTER } from "@lotics/ui/drawer";
|
|
6
8
|
import { Button } from "@lotics/ui/button";
|
|
@@ -409,6 +411,14 @@ interface ActivityEntry {
|
|
|
409
411
|
gist?: string;
|
|
410
412
|
/** The medium as a person would say it, not an enum: "Zalo", "Google Meet". */
|
|
411
413
|
over: string;
|
|
414
|
+
/**
|
|
415
|
+
* The channel's own mark, where the channel is an outside brand we can name.
|
|
416
|
+
* `kind` says WHAT happened and picks the fallback glyph; this says WHERE, and
|
|
417
|
+
* a reader scanning a feed recognises the logo before they read the word.
|
|
418
|
+
* Omit it for a phone call, an in-person event, a plain note — those have no
|
|
419
|
+
* brand, and the kind glyph is the honest mark for them.
|
|
420
|
+
*/
|
|
421
|
+
brand?: BrandName;
|
|
412
422
|
when: string;
|
|
413
423
|
/** An unanswered outreach is a real state and reads differently from a reply. */
|
|
414
424
|
awaiting?: boolean;
|
|
@@ -540,9 +550,25 @@ const ACTIVITY: ActivityEntry[] = [
|
|
|
540
550
|
kind: "video",
|
|
541
551
|
gist: "Demo — the reconciliation step is what sold it; pricing still open",
|
|
542
552
|
over: "Google Meet",
|
|
553
|
+
brand: "google-meet",
|
|
543
554
|
when: "Today, 10:15",
|
|
544
555
|
media: { src: "/sample-video.webm", label: "Demo recording" },
|
|
545
556
|
transcript: TRANSCRIPT,
|
|
557
|
+
// THE ENTRY THAT CARRIES EVERYTHING — a recording, its transcript, a machine
|
|
558
|
+
// reading of it, AND the files that changed hands. Four blocks, and the only
|
|
559
|
+
// thing keeping them one entry rather than a pile is that every file on the
|
|
560
|
+
// stack is drawn at the SAME size by the SAME component.
|
|
561
|
+
//
|
|
562
|
+
// `FileThumbnailGrid` has two sizing modes and the DEFAULT is not the small
|
|
563
|
+
// one: omit `itemSize` and it switches to FILL, deriving columns so the row
|
|
564
|
+
// spans the container — two files in a 500px column become two 250px tiles.
|
|
565
|
+
// Put that beside a sibling block at `THUMBNAIL_SIZE` (96) and the same file
|
|
566
|
+
// set reads as two unrelated things. Every file surface in this template and
|
|
567
|
+
// in chat passes `itemSize={THUMBNAIL_SIZE}` for exactly that reason.
|
|
568
|
+
files: [
|
|
569
|
+
{ id: "q1", filename: "quote-2026-0418.pdf", mimeType: "application/pdf", url: "#" },
|
|
570
|
+
{ id: "q2", filename: "rate-card.xlsx", mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", url: "#" },
|
|
571
|
+
],
|
|
546
572
|
// NO human note on this entry, deliberately. It carried "the person who
|
|
547
573
|
// maintains it was not in the room" — which the summary's own Risk line says
|
|
548
574
|
// better, and which the participants line was gesturing at too: one fact,
|
|
@@ -602,6 +628,7 @@ const ACTIVITY: ActivityEntry[] = [
|
|
|
602
628
|
kind: "message",
|
|
603
629
|
gist: "Asked in the forwarders' group who handles Japan customs paperwork",
|
|
604
630
|
over: "Facebook Group",
|
|
631
|
+
brand: "facebook",
|
|
605
632
|
when: "10 Aug, 21:30",
|
|
606
633
|
files: [
|
|
607
634
|
{ id: "s1", filename: "post.png", mimeType: "image/png", url: img("Post", "#3f3f46") },
|
|
@@ -719,7 +746,11 @@ function ActivityEntryRow({
|
|
|
719
746
|
backgroundColor: withAlpha(accent, 0.1),
|
|
720
747
|
}}
|
|
721
748
|
>
|
|
722
|
-
|
|
749
|
+
{a.brand ? (
|
|
750
|
+
<BrandMark name={a.brand} size={16} />
|
|
751
|
+
) : (
|
|
752
|
+
<Icon name={ACTIVITY_ICON[a.kind]} size={16} color={accent} />
|
|
753
|
+
)}
|
|
723
754
|
</View>
|
|
724
755
|
|
|
725
756
|
<View style={{ flex: 1, gap: 8 }}>
|
|
@@ -930,7 +961,7 @@ function ActivityEntryRow({
|
|
|
930
961
|
// above. Provenance is weighted by consequence: name the source where it
|
|
931
962
|
// is NOT on screen.
|
|
932
963
|
<Inset>
|
|
933
|
-
<
|
|
964
|
+
<GroupLead>AI summary</GroupLead>
|
|
934
965
|
<Markdown variant="embedded">{a.bodyByAi}</Markdown>
|
|
935
966
|
</Inset>
|
|
936
967
|
) : null}
|
|
@@ -3173,18 +3204,35 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
3173
3204
|
shape every comment UI converges on: a metadata header, the prose
|
|
3174
3205
|
under it, the row's actions at the TOP RIGHT beside the text they
|
|
3175
3206
|
act on, and heavy artifacts behind their own disclosure. */}
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3207
|
+
{/* SPACE AND A RULE, because an entry here is not a row.
|
|
3208
|
+
Between entries the beat is 32; INSIDE one the blocks sit at 8.
|
|
3209
|
+
Space alone was 20 against 8 — only 2.5x — and it was the same
|
|
3210
|
+
20 whether the neighbour was 110px or 756px, so the separation
|
|
3211
|
+
never scaled with the thing it separated. An entry carrying a
|
|
3212
|
+
recording, a transcript, a summary and its files runs close to a
|
|
3213
|
+
screenful, and past a screenful a gap reads as one more gap: the
|
|
3214
|
+
reader cannot tell "next touchpoint" from "next block of this
|
|
3215
|
+
one". That is the same LENGTH discriminator `SubsectionStack`
|
|
3216
|
+
takes `divided` for, and it is why the hairline is allowed here
|
|
3217
|
+
even though rules otherwise belong to the section altitude.
|
|
3218
|
+
Beating an 8px inner gap on space alone would need ~48, which
|
|
3219
|
+
then sets the one-line entries adrift. */}
|
|
3220
|
+
<View style={{ gap: 32 }}>
|
|
3221
|
+
{(showAllActivity ? activity : activity.slice(0, ACTIVITY_FOLD)).map((a, i, shown) => (
|
|
3222
|
+
<View key={a.key} style={{ gap: 32 }}>
|
|
3223
|
+
<ActivityEntryRow
|
|
3224
|
+
a={a}
|
|
3225
|
+
onEdit={(patch) =>
|
|
3226
|
+
setActivity((prev) =>
|
|
3227
|
+
prev.map((e) => (e.key === a.key ? { ...e, ...patch } : e)),
|
|
3228
|
+
)
|
|
3229
|
+
}
|
|
3230
|
+
onDelete={() => setActivity((prev) => prev.filter((e) => e.key !== a.key))}
|
|
3231
|
+
/>
|
|
3232
|
+
{/* Not after the last: a trailing rule reads as the start of
|
|
3233
|
+
whatever comes next, and what comes next is the fold. */}
|
|
3234
|
+
{i < shown.length - 1 ? <Divider /> : null}
|
|
3235
|
+
</View>
|
|
3188
3236
|
))}
|
|
3189
3237
|
</View>
|
|
3190
3238
|
{/* The tail FOLDS rather than paging. A record's feed is read
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "44.
|
|
3
|
+
"version": "44.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./vite": {
|
|
@@ -103,6 +103,8 @@
|
|
|
103
103
|
"./select": "./src/select.tsx",
|
|
104
104
|
"./option_list": "./src/option_list.tsx",
|
|
105
105
|
"./text": "./src/text.tsx",
|
|
106
|
+
"./eyebrow": "./src/eyebrow.tsx",
|
|
107
|
+
"./group_lead": "./src/group_lead.tsx",
|
|
106
108
|
"./activity_indicator": "./src/activity_indicator.tsx",
|
|
107
109
|
"./agent_run": "./src/agent_run.tsx",
|
|
108
110
|
"./agent_progress": "./src/agent_progress.tsx",
|
|
@@ -130,6 +132,7 @@
|
|
|
130
132
|
"./choice_list": "./src/choice_list.tsx",
|
|
131
133
|
"./sources": "./src/sources.tsx",
|
|
132
134
|
"./icon": "./src/icon.tsx",
|
|
135
|
+
"./brand_mark": "./src/brand_mark.tsx",
|
|
133
136
|
"./dynamic_icon": {
|
|
134
137
|
"react-native": "./src/dynamic_icon.tsx",
|
|
135
138
|
"default": "./src/dynamic_icon.web.tsx"
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Svg, Path } from "react-native-svg";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The channels we can name. Anything NOT on this list has no brand mark and
|
|
6
|
+
* takes a generic `Icon` — a phone call, an in-person event, a website, an
|
|
7
|
+
* email that is not tied to one provider. Inventing a mark for those is how a
|
|
8
|
+
* byline ends up claiming a channel the record does not carry.
|
|
9
|
+
*/
|
|
10
|
+
export type BrandName = "facebook" | "zalo" | "linkedin" | "google-meet";
|
|
11
|
+
|
|
12
|
+
interface Mark {
|
|
13
|
+
/** The brand's own colour. A mark drawn in anything else is not the mark. */
|
|
14
|
+
color: string;
|
|
15
|
+
path: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* SOLID SINGLE-COLOUR MARKS, not the full-colour logos.
|
|
20
|
+
*
|
|
21
|
+
* A byline draws these at 14–20px, and a five-colour Gmail envelope or a
|
|
22
|
+
* four-colour Meet camera turns to mud at that size — the shape stops being
|
|
23
|
+
* recognisable, which is the entire job. One silhouette in the brand's colour
|
|
24
|
+
* survives the size and reads at a glance in a list.
|
|
25
|
+
*
|
|
26
|
+
* Path data is Simple Icons (CC0), so the geometry carries no licence of its
|
|
27
|
+
* own; the marks themselves stay the trademarks of their owners and are used
|
|
28
|
+
* here only to NAME the channel a touchpoint happened on.
|
|
29
|
+
*/
|
|
30
|
+
const MARKS: Record<BrandName, Mark> = {
|
|
31
|
+
facebook: {
|
|
32
|
+
color: "#0866FF",
|
|
33
|
+
path: "M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z",
|
|
34
|
+
},
|
|
35
|
+
zalo: {
|
|
36
|
+
color: "#0068FF",
|
|
37
|
+
path: "M12.49 10.2722v-.4496h1.3467v6.3218h-.7704a.576.576 0 01-.5763-.5729l-.0006.0005a3.273 3.273 0 01-1.9372.6321c-1.8138 0-3.2844-1.4697-3.2844-3.2823 0-1.8125 1.4706-3.2822 3.2844-3.2822a3.273 3.273 0 011.9372.6321l.0006.0005zM6.9188 7.7896v.205c0 .3823-.051.6944-.2995 1.0605l-.03.0343c-.0542.0615-.1815.206-.2421.2843L2.024 14.8h4.8948v.7682a.5764.5764 0 01-.5767.5761H0v-.3622c0-.4436.1102-.6414.2495-.8476L4.8582 9.23H.1922V7.7896h6.7266zm8.5513 8.3548a.4805.4805 0 01-.4803-.4798v-7.875h1.4416v8.3548H15.47zM20.6934 9.6C22.52 9.6 24 11.0807 24 12.9044c0 1.8252-1.4801 3.306-3.3066 3.306-1.8264 0-3.3066-1.4808-3.3066-3.306 0-1.8237 1.4802-3.3044 3.3066-3.3044zm-10.1412 5.253c1.0675 0 1.9324-.8645 1.9324-1.9312 0-1.065-.865-1.9295-1.9324-1.9295s-1.9324.8644-1.9324 1.9295c0 1.0667.865 1.9312 1.9324 1.9312zm10.1412-.0033c1.0737 0 1.945-.8707 1.945-1.9453 0-1.073-.8713-1.9436-1.945-1.9436-1.0753 0-1.945.8706-1.945 1.9436 0 1.0746.8697 1.9453 1.945 1.9453z",
|
|
38
|
+
},
|
|
39
|
+
linkedin: {
|
|
40
|
+
color: "#0A66C2",
|
|
41
|
+
path: "M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z",
|
|
42
|
+
},
|
|
43
|
+
"google-meet": {
|
|
44
|
+
color: "#00897B",
|
|
45
|
+
path: "M5.53 2.13 0 7.75h5.53zm.398 0v5.62h7.608v3.65l5.47-4.45c-.014-1.22.031-2.25-.025-3.46-.148-1.09-1.287-1.47-2.236-1.36zM23.1 4.32c-.802.295-1.358.995-2.047 1.49-2.506 2.05-4.982 4.12-7.468 6.19 3.025 2.59 6.04 5.18 9.065 7.76 1.218.671 1.428-.814 1.328-1.64v-13a.828.828 0 0 0-.877-.825zM.038 8.15v7.7h5.53v-7.7zm13.577 8.1H6.008v5.62c3.864-.006 7.737.011 11.58-.009 1.02-.07 1.618-1.12 1.468-2.07v-2.51l-5.47-4.68v3.65zm-13.577 0c.02 1.44-.041 2.88.033 4.31.162.948 1.158 1.43 2.047 1.31h3.464v-5.62z",
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export interface BrandMarkProps {
|
|
50
|
+
name: BrandName;
|
|
51
|
+
/** Edge length in px. Defaults to 16 — the size that sits on a byline line. */
|
|
52
|
+
size?: number;
|
|
53
|
+
/**
|
|
54
|
+
* Draw in the current text colour instead of the brand's. For a surface where
|
|
55
|
+
* a run of coloured marks would compete with the content beside them.
|
|
56
|
+
*/
|
|
57
|
+
muted?: boolean;
|
|
58
|
+
testID?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A third-party channel's mark — the logo that says WHERE a touchpoint
|
|
63
|
+
* happened, at a glance, in a list of them.
|
|
64
|
+
*
|
|
65
|
+
* Not a general icon: `Icon` owns the curated glyph set and everything drawn in
|
|
66
|
+
* the app's own language. This is the narrow set of OUTSIDE brands we identify,
|
|
67
|
+
* and it is deliberately an allowlist — a name that is not in `MARKS` will not
|
|
68
|
+
* typecheck, so a channel with no mark falls back to a generic icon at the call
|
|
69
|
+
* site rather than silently rendering the wrong company's logo.
|
|
70
|
+
*/
|
|
71
|
+
export function BrandMark(props: BrandMarkProps) {
|
|
72
|
+
const { name, size = 16, muted, testID } = props;
|
|
73
|
+
const mark = MARKS[name];
|
|
74
|
+
return (
|
|
75
|
+
<Svg width={size} height={size} viewBox="0 0 24 24" testID={testID}>
|
|
76
|
+
<Path d={mark.path} fill={muted ? "currentColor" : mark.color} />
|
|
77
|
+
</Svg>
|
|
78
|
+
);
|
|
79
|
+
}
|
package/src/clarify.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { View } from "react-native";
|
|
2
2
|
import { Text } from "./text";
|
|
3
|
+
import { Eyebrow } from "./eyebrow";
|
|
3
4
|
import { ChoiceList } from "./choice_list";
|
|
4
5
|
|
|
5
6
|
export interface ClarifyOption {
|
|
@@ -44,9 +45,9 @@ export function Clarify(props: ClarifyProps) {
|
|
|
44
45
|
text, which sits on the `ChoiceList` rows' 8px inset (CONTROL_RADIUS grid). */}
|
|
45
46
|
<View style={{ gap: 4 }}>
|
|
46
47
|
{props.eyebrow ? (
|
|
47
|
-
<
|
|
48
|
+
<Eyebrow>
|
|
48
49
|
{props.eyebrow}
|
|
49
|
-
</
|
|
50
|
+
</Eyebrow>
|
|
50
51
|
) : null}
|
|
51
52
|
<Text size="sm" weight="medium">
|
|
52
53
|
{props.question}
|
package/src/eyebrow.tsx
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Text } from "./text";
|
|
3
|
+
import type { TextColor } from "./text_utils";
|
|
4
|
+
|
|
5
|
+
export interface EyebrowProps {
|
|
6
|
+
children?: React.ReactNode;
|
|
7
|
+
/**
|
|
8
|
+
* A VERDICT carries its judgement in ink — "Mismatch" in danger, "Resolved" in
|
|
9
|
+
* success — and keeps the rung and the weight. Everything else stays muted:
|
|
10
|
+
* colour here is what the word MEANS, never emphasis.
|
|
11
|
+
*/
|
|
12
|
+
color?: TextColor;
|
|
13
|
+
/** Alignment is layout, not rung — a right-aligned eyebrow is still an eyebrow. */
|
|
14
|
+
align?: "left" | "center" | "right";
|
|
15
|
+
numberOfLines?: number;
|
|
16
|
+
testID?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A small quiet label above or beside a VALUE — a metric's caption, a field name
|
|
21
|
+
* in a cell, an artifact tag ("Proposed", "Question"). `xs` muted medium.
|
|
22
|
+
*
|
|
23
|
+
* Calibrated for DENSE surfaces, where a 12px label sits among dozens of
|
|
24
|
+
* siblings and quiet is the point. A label that titles a BLOCK is a
|
|
25
|
+
* `GroupLead` instead, and picking by feel is how a title ends up whispering —
|
|
26
|
+
* which is why these are two components rather than two ways of writing `Text`.
|
|
27
|
+
*
|
|
28
|
+
* There is no `transform` prop, and that is the enforcement: all-caps reads as
|
|
29
|
+
* shouting, and reflexively uppercasing every little label is the single thing
|
|
30
|
+
* that makes a surface feel templated. Sentence case, always.
|
|
31
|
+
*
|
|
32
|
+
* There is no `weight` or `size` either. A bigger or heavier eyebrow is a
|
|
33
|
+
* different rung with a different name.
|
|
34
|
+
*/
|
|
35
|
+
export function Eyebrow(props: EyebrowProps) {
|
|
36
|
+
const { children, color = "muted", align, numberOfLines, testID } = props;
|
|
37
|
+
return (
|
|
38
|
+
<Text size="xs" color={color} weight="medium" align={align} numberOfLines={numberOfLines} testID={testID}>
|
|
39
|
+
{children}
|
|
40
|
+
</Text>
|
|
41
|
+
);
|
|
42
|
+
}
|
package/src/funnel.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { type ReactNode } from "react";
|
|
|
2
2
|
import { View, StyleSheet } from "react-native";
|
|
3
3
|
import { colors } from "./colors";
|
|
4
4
|
import { Text } from "./text";
|
|
5
|
+
import { Eyebrow } from "./eyebrow";
|
|
5
6
|
import { PressableHighlight } from "./pressable_highlight";
|
|
6
7
|
|
|
7
8
|
export interface FunnelStage {
|
|
@@ -104,9 +105,9 @@ export function Funnel(props: FunnelProps) {
|
|
|
104
105
|
};
|
|
105
106
|
|
|
106
107
|
const titleNode = title ? (
|
|
107
|
-
<
|
|
108
|
+
<Eyebrow>
|
|
108
109
|
{title}
|
|
109
|
-
</
|
|
110
|
+
</Eyebrow>
|
|
110
111
|
) : null;
|
|
111
112
|
|
|
112
113
|
if (orientation === "vertical") {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Text } from "./text";
|
|
3
|
+
|
|
4
|
+
export interface GroupLeadProps {
|
|
5
|
+
children?: React.ReactNode;
|
|
6
|
+
/**
|
|
7
|
+
* `sm` (the floor) or `md` where the block is a major sub-part. Nothing
|
|
8
|
+
* larger: past `md` the label stops titling a group and starts competing with
|
|
9
|
+
* the section heading above it.
|
|
10
|
+
*/
|
|
11
|
+
size?: "sm" | "md";
|
|
12
|
+
numberOfLines?: number;
|
|
13
|
+
testID?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The label that titles a BLOCK — a titled sub-part of a section. "Deciding
|
|
18
|
+
* factor" over the fact it names, "Delivery" over an address stack, "Next
|
|
19
|
+
* action" over the paragraph someone has to act on. `sm`–`md` medium, in the
|
|
20
|
+
* page's own ink.
|
|
21
|
+
*
|
|
22
|
+
* NOT an `Eyebrow`: that rung labels a VALUE and is calibrated for dense
|
|
23
|
+
* surfaces where quiet is the point. A block someone must act on has no
|
|
24
|
+
* siblings to be quiet among, so `xs` muted reads as an afterthought — and at
|
|
25
|
+
* 12px stacked diacritics crowd, which makes it worse in Vietnamese than the
|
|
26
|
+
* English it was measured in.
|
|
27
|
+
*
|
|
28
|
+
* There is no `weight` prop. Semibold is the heading rung; a group lead that
|
|
29
|
+
* reaches for it is a heading wearing the wrong name, and the fix is a real
|
|
30
|
+
* `SubsectionHeading` rather than a bolder label.
|
|
31
|
+
*/
|
|
32
|
+
export function GroupLead(props: GroupLeadProps) {
|
|
33
|
+
const { children, size = "sm", numberOfLines, testID } = props;
|
|
34
|
+
return (
|
|
35
|
+
<Text size={size} weight="medium" numberOfLines={numberOfLines} testID={testID}>
|
|
36
|
+
{children}
|
|
37
|
+
</Text>
|
|
38
|
+
);
|
|
39
|
+
}
|
package/src/kpi_card.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { View, StyleSheet, type ViewStyle, type StyleProp } from "react-native";
|
|
2
2
|
import { Text } from "./text";
|
|
3
|
+
import { Eyebrow } from "./eyebrow";
|
|
3
4
|
import { Metric, type MetricFormat, type MetricSize, type MetricTone } from "./metric";
|
|
4
5
|
import { TrendChip } from "./trend_chip";
|
|
5
6
|
import { InfoPopover } from "./info_popover";
|
|
@@ -57,9 +58,9 @@ export function KPICard(props: KPICardProps) {
|
|
|
57
58
|
return (
|
|
58
59
|
<View style={[styles.container, style]}>
|
|
59
60
|
<View style={styles.labelRow}>
|
|
60
|
-
<
|
|
61
|
+
<Eyebrow>
|
|
61
62
|
{label}
|
|
62
|
-
</
|
|
63
|
+
</Eyebrow>
|
|
63
64
|
{info ? <InfoPopover text={info} accessibilityLabel={pack.infoPopover.about(label)} /> : null}
|
|
64
65
|
</View>
|
|
65
66
|
<View style={styles.valueRow}>
|
package/src/locale.tsx
CHANGED
|
@@ -83,6 +83,12 @@ export interface LoticsLocale {
|
|
|
83
83
|
* a phase's rows. The kit owns the wording so one app does not say "Show" while
|
|
84
84
|
* the next says "Expand" for the same gesture. */
|
|
85
85
|
checklist: { expand: string; collapse: string };
|
|
86
|
+
/** `TextDisclosure` supplies the VERB so one app cannot say "Show" where the
|
|
87
|
+
* next says "View" for the same gesture — the same reason `checklist` above
|
|
88
|
+
* owns its pair. It takes the revealed thing as a noun and builds the phrase,
|
|
89
|
+
* because word order is not shared: English puts the verb first, and a
|
|
90
|
+
* language that does not would otherwise be stuck with a prefix. */
|
|
91
|
+
textDisclosure: { show: (label: string) => string; hide: (label: string) => string };
|
|
86
92
|
sectionHeading: { info: string };
|
|
87
93
|
/** `ErrorState`'s retry button. The kit owns the wording so "try again" is
|
|
88
94
|
* phrased identically everywhere instead of hand-written per app. */
|
|
@@ -288,6 +294,7 @@ export const en: LoticsLocale = {
|
|
|
288
294
|
ledger: { rowDetails: (label) => `${label} details` },
|
|
289
295
|
stepper: { complete: "Complete step", progress: "Progress" },
|
|
290
296
|
checklist: { expand: "Show", collapse: "Hide" },
|
|
297
|
+
textDisclosure: { show: (label) => `Show ${label}`, hide: (label) => `Hide ${label}` },
|
|
291
298
|
sectionHeading: { info: "About this data" },
|
|
292
299
|
errorState: { retry: "Try again" },
|
|
293
300
|
chip: { remove: "Remove" },
|
|
@@ -466,6 +473,7 @@ export const vi: LoticsLocale = {
|
|
|
466
473
|
ledger: { rowDetails: (label) => `Chi tiết ${label}` },
|
|
467
474
|
stepper: { complete: "Hoàn thành bước", progress: "Tiến trình" },
|
|
468
475
|
checklist: { expand: "Mở", collapse: "Thu gọn" },
|
|
476
|
+
textDisclosure: { show: (label) => `Xem ${label}`, hide: (label) => `Ẩn ${label}` },
|
|
469
477
|
sectionHeading: { info: "Giải thích dữ liệu" },
|
|
470
478
|
errorState: { retry: "Thử lại" },
|
|
471
479
|
chip: { remove: "Xóa" },
|
package/src/progress_bar.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { View, StyleSheet, type ViewStyle } from "react-native";
|
|
2
2
|
import { Text } from "./text";
|
|
3
|
+
import { Eyebrow } from "./eyebrow";
|
|
3
4
|
import { colors } from "./colors";
|
|
4
5
|
import { useLocaleTag } from "./locale";
|
|
5
6
|
|
|
@@ -112,9 +113,9 @@ export function ProgressBar(props: ProgressBarProps) {
|
|
|
112
113
|
{title || caption ? (
|
|
113
114
|
<View style={styles.header}>
|
|
114
115
|
{title ? (
|
|
115
|
-
<
|
|
116
|
+
<Eyebrow>
|
|
116
117
|
{title}
|
|
117
|
-
</
|
|
118
|
+
</Eyebrow>
|
|
118
119
|
) : null}
|
|
119
120
|
<View style={styles.spacer} />
|
|
120
121
|
{caption ? (
|
package/src/record_summary.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type ReactNode } from "react";
|
|
2
2
|
import { StyleSheet, View } from "react-native";
|
|
3
3
|
import { Text } from "./text";
|
|
4
|
+
import { Eyebrow } from "./eyebrow";
|
|
4
5
|
|
|
5
6
|
export interface RecordSummaryMetric {
|
|
6
7
|
/** Caption above the number ("Outstanding", "Total value"). */
|
|
@@ -75,9 +76,9 @@ export function RecordSummary(props: RecordSummaryProps) {
|
|
|
75
76
|
</View>
|
|
76
77
|
{metric ? (
|
|
77
78
|
<View style={styles.metric}>
|
|
78
|
-
<
|
|
79
|
+
<Eyebrow align="right">
|
|
79
80
|
{metric.label}
|
|
80
|
-
</
|
|
81
|
+
</Eyebrow>
|
|
81
82
|
{typeof metric.value === "string" ? (
|
|
82
83
|
<Text size="lg" weight="semibold" tabular align="right" color={metric.tone === "default" ? undefined : metric.tone}>
|
|
83
84
|
{metric.value}
|
package/src/sources.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { StyleSheet, View } from "react-native";
|
|
2
2
|
import { colors, solid, tint, type ColorName } from "./colors";
|
|
3
3
|
import { Text } from "./text";
|
|
4
|
+
import { Eyebrow } from "./eyebrow";
|
|
4
5
|
import { Icon, type IconName } from "./icon";
|
|
5
6
|
import { PressableHighlight } from "./pressable_highlight";
|
|
6
7
|
import { useLoticsLocale } from "./locale";
|
|
@@ -49,9 +50,9 @@ export function Sources(props: SourcesProps) {
|
|
|
49
50
|
return (
|
|
50
51
|
<View style={{ gap: 8 }}>
|
|
51
52
|
{props.label === null ? null : (
|
|
52
|
-
<
|
|
53
|
+
<Eyebrow>
|
|
53
54
|
{props.label ?? locale.sources.heading}
|
|
54
|
-
</
|
|
55
|
+
</Eyebrow>
|
|
55
56
|
)}
|
|
56
57
|
<View style={styles.row}>
|
|
57
58
|
{props.sources.map((s) =>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { View, StyleSheet } from "react-native";
|
|
2
2
|
import { colors } from "./colors";
|
|
3
3
|
import { Text } from "./text";
|
|
4
|
+
import { Eyebrow } from "./eyebrow";
|
|
4
5
|
|
|
5
6
|
interface Segment {
|
|
6
7
|
/** Identifier for the segment — used as a React key. */
|
|
@@ -61,9 +62,9 @@ export function StackedProgressBar(props: StackedProgressBarProps) {
|
|
|
61
62
|
<View style={styles.container}>
|
|
62
63
|
<View style={styles.header}>
|
|
63
64
|
{title ? (
|
|
64
|
-
<
|
|
65
|
+
<Eyebrow>
|
|
65
66
|
{title}
|
|
66
|
-
</
|
|
67
|
+
</Eyebrow>
|
|
67
68
|
) : null}
|
|
68
69
|
<View style={styles.spacer} />
|
|
69
70
|
{caption ? (
|
package/src/step_progress.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { View, type ViewStyle } from "react-native";
|
|
2
2
|
import { colors } from "./colors";
|
|
3
3
|
import { Text } from "./text";
|
|
4
|
+
import { Eyebrow } from "./eyebrow";
|
|
4
5
|
import { useTooltip } from "./tooltip";
|
|
5
6
|
|
|
6
7
|
export interface StepProgressProps {
|
|
@@ -97,9 +98,9 @@ export function StepProgress(props: StepProgressProps) {
|
|
|
97
98
|
return (
|
|
98
99
|
<View style={{ gap: 6, flex: 1 }}>
|
|
99
100
|
<View style={{ flexDirection: "row", alignItems: "baseline", gap: 12 }}>
|
|
100
|
-
<
|
|
101
|
+
<Eyebrow>
|
|
101
102
|
{title}
|
|
102
|
-
</
|
|
103
|
+
</Eyebrow>
|
|
103
104
|
<View style={{ flex: 1 }} />
|
|
104
105
|
{caption ? (
|
|
105
106
|
<Text size="xs" color={captionColor} weight={captionTone === "danger" ? "medium" : "regular"} tabular>
|
package/src/text_disclosure.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Pressable } from "react-native";
|
|
2
2
|
import { TextLink } from "./text_link";
|
|
3
|
+
import { useLoticsLocale } from "./locale";
|
|
3
4
|
import type { TextSize } from "./text";
|
|
4
5
|
|
|
5
6
|
export interface TextDisclosureProps {
|
|
@@ -52,6 +53,7 @@ export interface TextDisclosureProps {
|
|
|
52
53
|
*/
|
|
53
54
|
export function TextDisclosure(props: TextDisclosureProps) {
|
|
54
55
|
const { expanded, onToggle, label, size = "sm", accessibilityLabel, testID } = props;
|
|
56
|
+
const locale = useLoticsLocale();
|
|
55
57
|
return (
|
|
56
58
|
<Pressable
|
|
57
59
|
onPress={() => onToggle(!expanded)}
|
|
@@ -63,7 +65,7 @@ export function TextDisclosure(props: TextDisclosureProps) {
|
|
|
63
65
|
testID={testID}
|
|
64
66
|
>
|
|
65
67
|
<TextLink size={size} color="muted">
|
|
66
|
-
{expanded ?
|
|
68
|
+
{expanded ? locale.textDisclosure.hide(label) : locale.textDisclosure.show(label)}
|
|
67
69
|
</TextLink>
|
|
68
70
|
</Pressable>
|
|
69
71
|
);
|
package/src/text_input_field.tsx
CHANGED
|
@@ -9,7 +9,7 @@ import { colors } from "./colors";
|
|
|
9
9
|
import { CONTROL_RADIUS, FOCUS_RING, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
|
|
10
10
|
import { useFocusRing, composeHandler } from "./use_focus_ring";
|
|
11
11
|
import { useHover } from "./use_hover";
|
|
12
|
-
import { Ref, useCallback, useState } from "react";
|
|
12
|
+
import { Ref, useCallback, useEffect, useState } from "react";
|
|
13
13
|
import { Icon, IconName } from "./icon";
|
|
14
14
|
import { IconButton } from "./icon_button";
|
|
15
15
|
import { ShortcutBadge } from "./shortcut_badge";
|
|
@@ -111,13 +111,27 @@ export function TextInputField(props: TextInputFieldProps) {
|
|
|
111
111
|
minLines: numberOfLines ?? 1,
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
+
// A VALUE THAT ARRIVES FROM OUTSIDE HAS TO BE MEASURED TOO. `measure` runs on
|
|
115
|
+
// `onChangeText`, which only fires for what the USER types — so a field whose
|
|
116
|
+
// value is replaced by a refetch, a workflow, an agent or a form reset kept
|
|
117
|
+
// the height it was measured at, and any extra lines fell outside the box.
|
|
118
|
+
// Typing one character into it snapped it to the right height, which is the
|
|
119
|
+
// tell: the measurement was never wrong, it was never taken.
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
if (autoGrow) autoGrowResult.measure();
|
|
122
|
+
}, [value, autoGrow, autoGrowResult.measure]);
|
|
123
|
+
|
|
114
124
|
// `containerHeight` is the CONTENT's height, and the box is `border-box`, so
|
|
115
125
|
// growing to fit it means adding the padding AND the border. Adding only the
|
|
116
|
-
// padding
|
|
117
|
-
// `
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
126
|
+
// padding leaves an auto-grown field 2px short of its own text. The same trap
|
|
127
|
+
// `CONTROL_TEXT_INSET` documents on the horizontal axis: read the padding,
|
|
128
|
+
// forget the border, land 1px per side wrong.
|
|
129
|
+
//
|
|
130
|
+
// The box does NOT hide its overflow. It used to on the non-scrolling path,
|
|
131
|
+
// which meant every way the height could come out wrong ate text with no
|
|
132
|
+
// scrollbar to say so — the failure was invisible in exactly the case that
|
|
133
|
+
// matters, a long value. A correct height overflows by nothing, so hiding it
|
|
134
|
+
// buys nothing and can only conceal the next measurement bug.
|
|
121
135
|
const height = autoGrow
|
|
122
136
|
? Math.max(minHeight ?? 40, autoGrowResult.containerHeight + INPUT_PADDING_Y * 2 + INPUT_BORDER_WIDTH * 2)
|
|
123
137
|
: minHeight;
|
|
@@ -184,7 +198,6 @@ export function TextInputField(props: TextInputFieldProps) {
|
|
|
184
198
|
CONTROL_TRANSITION,
|
|
185
199
|
getInputTextStyle(),
|
|
186
200
|
height ? { height } : undefined,
|
|
187
|
-
autoGrow && !autoGrowResult.scrollEnabled && { overflow: "hidden" as const },
|
|
188
201
|
!editable && styles.disabled,
|
|
189
202
|
icon && styles.withIcon,
|
|
190
203
|
showClear && styles.withClear,
|
|
@@ -45,6 +45,7 @@ export function useAutoGrowHeight(options: UseAutoGrowHeightOptions = {}): UseAu
|
|
|
45
45
|
const maxHeight = maxLines ? maxLines * lineHeight : undefined;
|
|
46
46
|
const [containerHeight, setContainerHeight] = useState(minHeight);
|
|
47
47
|
const elementRef = useRef<HTMLTextAreaElement | null>(null);
|
|
48
|
+
const observerRef = useRef<ResizeObserver | null>(null);
|
|
48
49
|
|
|
49
50
|
const clamp = useCallback(
|
|
50
51
|
(height: number) => {
|
|
@@ -58,10 +59,27 @@ export function useAutoGrowHeight(options: UseAutoGrowHeightOptions = {}): UseAu
|
|
|
58
59
|
(node: TextInput | null) => {
|
|
59
60
|
if (Platform.OS !== "web") return;
|
|
60
61
|
const el = node as unknown as HTMLTextAreaElement | null;
|
|
62
|
+
observerRef.current?.disconnect();
|
|
63
|
+
observerRef.current = null;
|
|
61
64
|
elementRef.current = el;
|
|
62
65
|
if (!el) return;
|
|
63
66
|
const measured = measureScrollHeight(el);
|
|
64
67
|
if (measured !== null) setContainerHeight(clamp(measured));
|
|
68
|
+
|
|
69
|
+
// HOW MANY LINES THE VALUE TAKES IS A FUNCTION OF THE WIDTH, so the height
|
|
70
|
+
// measured at mount is only right until the box changes width — a drawer
|
|
71
|
+
// opening, a pane resizing, a column re-flowing, the window itself. Mount
|
|
72
|
+
// is also the WORST moment to measure: a field inside a flex row is often
|
|
73
|
+
// laid out wider than its final width for a frame, which measures fewer
|
|
74
|
+
// lines than the value really takes and leaves the box short of its own
|
|
75
|
+
// text. Without this the first correct width never reaches the hook.
|
|
76
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
77
|
+
const observer = new ResizeObserver(() => {
|
|
78
|
+
const next = measureScrollHeight(el);
|
|
79
|
+
if (next !== null) setContainerHeight(clamp(next));
|
|
80
|
+
});
|
|
81
|
+
observer.observe(el);
|
|
82
|
+
observerRef.current = observer;
|
|
65
83
|
},
|
|
66
84
|
[clamp],
|
|
67
85
|
);
|