@lotics/ui 44.6.0 → 44.7.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/AGENTS.md +11 -0
- package/docs/catalog.md +16 -0
- package/docs/composition.md +32 -9
- package/docs/reviewing.md +10 -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/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_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
|
|
|
@@ -367,6 +376,13 @@ a dashboard. Card stat rails use `KPICard`.
|
|
|
367
376
|
## Numbers, money, dates
|
|
368
377
|
|
|
369
378
|
- Free-standing numerals: `<Text tabular>` — always.
|
|
379
|
+
- **A column states its unit ONCE — which is only possible if it holds ONE unit.** State it in the
|
|
380
|
+
header band and leave the cells bare. The corollary is the one that bites: a label/value list
|
|
381
|
+
whose rows are a date range, a sum of money and a COUNT has no single unit to state, so each
|
|
382
|
+
value carries its own — and a count sharing a column with a currency at the same size, weight
|
|
383
|
+
and right edge is unreadable however well the labels are worded, because the eye reads a column
|
|
384
|
+
of figures as one series. Where the mixed units are parallel across many rows, they are not one
|
|
385
|
+
column at all: give each its own, and the band names both.
|
|
370
386
|
- **Money**: `formatMoney(n)` from `@lotics/ui/format_money` (`compact` abbreviates values
|
|
371
387
|
≥ 1,000,000 for strip captions). **Warning:** the full-value path formats via `Intl`, whose
|
|
372
388
|
output separates the currency symbol with a non-breaking space (the compact path uses a plain
|
|
@@ -1214,6 +1230,13 @@ column of them. Dropped to `xs` and lightened past `muted` it stops being legibl
|
|
|
1214
1230
|
reading as a caption that lost its value — small type is not the same as quiet type. The active
|
|
1215
1231
|
SORTED column steps up in ink, never in weight; semibold belongs to the heading ladder.
|
|
1216
1232
|
|
|
1233
|
+
**A fixed column `width` is sized for the LONGEST locale, not the one you typed it in.** Every
|
|
1234
|
+
header is a translated string, and the same label runs half again as long in Vietnamese as in
|
|
1235
|
+
English — so a width measured against the source truncates the header in the language the app
|
|
1236
|
+
actually ships in, on a column the reader was told to check. It fails silently in review because
|
|
1237
|
+
the reviewer reads the source locale. Size against the longest translation, or give the column
|
|
1238
|
+
`flex` and let it take what it needs.
|
|
1239
|
+
|
|
1217
1240
|
**Sentence case, in the table header too.** The heading ramp already bans all-caps and names it
|
|
1218
1241
|
the #1 templated tell; `Table` and `SortHeader` were uppercasing every column label in every app,
|
|
1219
1242
|
which is the loudest possible version of the thing the rule exists to stop. Removing the case
|
package/docs/reviewing.md
CHANGED
|
@@ -222,6 +222,16 @@ spacing distinguishes them.
|
|
|
222
222
|
offsets is the signature. Fix by bounding the child that varies — never with a cross-axis knob
|
|
223
223
|
(it moves the content and leaves the chrome), and never by resizing the CONTAINER, which changes
|
|
224
224
|
both terms and leaves the spread.
|
|
225
|
+
- **A VARIABLE-width leading slot destroys the column behind it.** A row's lead is sized for an
|
|
226
|
+
icon; put a chip, a badge or a name in it and every row's title starts at a different x. Collect
|
|
227
|
+
one repeated cell's `left` down the rows — more than one distinct value and the column does not
|
|
228
|
+
exist, however aligned each row looks on its own. The spread is small enough to read as
|
|
229
|
+
sloppiness rather than as a defect, which is why it survives review; the fix is a fixed-width
|
|
230
|
+
lead, or a register, where columns make it impossible.
|
|
231
|
+
- **Every numeric column: is its unit named anywhere, and is it ONE unit?** Collect the leaf values
|
|
232
|
+
sharing a right edge, with their size and weight. Identical treatment across values that are not
|
|
233
|
+
the same KIND of thing — a count beside a currency — is the signature, and the labels beside them
|
|
234
|
+
do not rescue it. → [composition.md](./composition.md) §"Numbers, money, dates".
|
|
225
235
|
- **A slot holding another component's part must be sized by that component's constant** — a
|
|
226
236
|
24px mark in an 18px column sits 3px outside the surface.
|
|
227
237
|
- **A row beat belongs to the PAGE** — collect every row primitive's height on one screen before
|
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.1",
|
|
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/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_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
|
);
|