@lotics/ui 44.1.0 → 44.2.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 +3 -2
- package/docs/catalog.md +38 -1
- package/docs/composition.md +50 -0
- package/docs/reviewing.md +19 -1
- package/examples/tpl_task_board.tsx +9 -0
- package/package.json +3 -1
- package/src/locale.tsx +26 -1
- package/src/member_chip.tsx +72 -2
- package/src/member_peek.tsx +91 -0
- package/src/member_profile_card.tsx +261 -0
- package/src/record_summary.tsx +2 -1
package/AGENTS.md
CHANGED
|
@@ -27,8 +27,9 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
|
|
|
27
27
|
- **Reuse first; the catalog can lag `src/`.** Before hand-rolling ANY capability, `ls src/` and
|
|
28
28
|
grep for a match — a component in `src/` missing from the catalog is a doc bug to fix, not a
|
|
29
29
|
license to hand-roll.
|
|
30
|
-
- **One canonical component per data role** (member → `MemberChip`,
|
|
31
|
-
files → `FilePreview` family, …) —
|
|
30
|
+
- **One canonical component per data role** (member → `MemberChip`, who-is-this →
|
|
31
|
+
`MemberPeek`/`MemberProfileCard`, select → `OptionBadge`, files → `FilePreview` family, …) —
|
|
32
|
+
the catalog's Reach-by-role outranks neighboring code.
|
|
32
33
|
- **Responsive layout measures the CONTAINER, not the screen.** `useContainerSize()` reports the
|
|
33
34
|
nearest `SizeBoundary`; wrap one around any region whose width stops tracking its parent's, and
|
|
34
35
|
`Dialog`/`Drawer`/popover bodies already are boundaries so their contents get the panel
|
package/docs/catalog.md
CHANGED
|
@@ -78,6 +78,31 @@ inline: a picker option, an assignee, a `select_member` value. Pure: resolve the
|
|
|
78
78
|
your directory and pass `name` / `image`; never hand-roll `Avatar` + `Text`. (`MemberSelect`
|
|
79
79
|
renders these per option.)
|
|
80
80
|
|
|
81
|
+
**To answer "who IS this?" — `MemberPeek`**, the same chip made a door onto a
|
|
82
|
+
`MemberProfileCard`: a 72px avatar, what they sign in as, their role, their teams and when they
|
|
83
|
+
joined. The chip wears `marker` there — a pressable that paints nothing at rest is text, and its
|
|
84
|
+
hover wash does not exist on touch. Feed it
|
|
85
|
+
a member (an app's `ResolvedMember` already carries `groups`); `role` arrives ALREADY
|
|
86
|
+
TRANSLATED, never a raw enum — the kit holds no domain vocabulary. Use `MemberProfileCard`
|
|
87
|
+
alone wherever the card is not behind a press (a profile header, a drawer).
|
|
88
|
+
|
|
89
|
+
**Where a peek must NOT go**, both cases being one rule — *two destinations behind one
|
|
90
|
+
object*: a PICKER OPTION (`MemberSelect` / `InlineMemberSelect` render a chip per option, and
|
|
91
|
+
a card over the open menu fires exactly when the reader is choosing), and a ROW THAT ALREADY
|
|
92
|
+
PRESSES (the row opens the record; a chip inside it would open something else four pixels
|
|
93
|
+
away). Those keep the plain `MemberChip`. A peek belongs where nothing else on the line
|
|
94
|
+
presses — a person named in prose, a static header, a detail row, a log whose rows do not
|
|
95
|
+
navigate. **Judge the container, not its look:** a `DataGrid` group band names a person and
|
|
96
|
+
reads inert, but it is a button that collapses the group, so a peek there nests a button in a
|
|
97
|
+
button. A fully interactive screen often has NO valid spot — `examples/tpl_task_board.tsx`
|
|
98
|
+
names a person three times (collapsing band, inline editor, picker option) and correctly
|
|
99
|
+
peeks on none of them.
|
|
100
|
+
|
|
101
|
+
**`groups` distinguishes absent from empty, and the card renders the difference**: omitted
|
|
102
|
+
drops the row (this caller was never told — a public-app response, an older server), `[]`
|
|
103
|
+
keeps it and says "None". Passing `[]` for "we don't know" states as fact that a colleague is
|
|
104
|
+
on no team.
|
|
105
|
+
|
|
81
106
|
**Size is a RUNG, never a pixel count** — `sm | md | lg | xl` = 24 / 28 / 40 / 72
|
|
82
107
|
(`@lotics/ui/avatar_size`, shared by `Avatar`, `MemberChip` and `GroupAvatar`). Default `md`.
|
|
83
108
|
Pick by density:
|
|
@@ -545,7 +570,19 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
545
570
|
- **`member_chip`** — `MemberChip`: avatar + name; the universal person render. `size` scales
|
|
546
571
|
BOTH — `sm` gives a 24px avatar and a 12px name, every larger rung keeps the name at body
|
|
547
572
|
size. Reach for `sm` when the chip sits inside a sentence or a dense row, where a body-size
|
|
548
|
-
name would collide with the 12px text around it.
|
|
573
|
+
name would collide with the 12px text around it. Also exports `memberDisplayName(name,
|
|
574
|
+
unknown)` — the shared "a blank name is called X" rule, so a chip and the card it opens
|
|
575
|
+
cannot name the same nameless member two different things.
|
|
576
|
+
- **`member_profile_card`** — `MemberProfileCard`: WHO IS THIS — `xl` avatar, name, sign-in
|
|
577
|
+
identity, role, join date and groups, plus one optional `action`. Every row is conditional, so a
|
|
578
|
+
card told nothing is a header and nothing else. `role` comes in pre-translated (vocabulary only
|
|
579
|
+
the product can name); `joined` comes in as an ISO DATE and the card formats it to month + year
|
|
580
|
+
(a date has a canonical form the kit owns, so two hosts formatting it themselves would drift);
|
|
581
|
+
`groups` distinguishes absent (no row) from `[]` ("None"). `inactive` draws the departed state as
|
|
582
|
+
a `Badge` — a lifecycle state, not another muted line.
|
|
583
|
+
- **`member_peek`** — `MemberPeek`: `MemberChip` + `Peek` + the card above, with the trigger's
|
|
584
|
+
announced name taken from the locale. Off picker options and off rows that already press —
|
|
585
|
+
see the person-display section for why.
|
|
549
586
|
|
|
550
587
|
### Layout & surfaces
|
|
551
588
|
|
package/docs/composition.md
CHANGED
|
@@ -83,6 +83,17 @@ restyle a heading level per-page.
|
|
|
83
83
|
- **Card header** — a card's own title band (or separate banded cards): `CardHeader` +
|
|
84
84
|
`CardHeaderTitle` (`sm` semibold; pass `info` whenever the title alone doesn't define the
|
|
85
85
|
numbers) + optional `CardHeaderMeta` (a count/unit/period, xs muted tabular).
|
|
86
|
+
- **Panel identity band** — a self-contained OVERLAY (a peek, a profile card, a small drawer)
|
|
87
|
+
whose subject is an ENTITY rather than a dataset. The name takes `###` — `lg` semibold, the
|
|
88
|
+
lowest heading rank — and neither of the two constructs it sits between: `CardHeaderTitle`
|
|
89
|
+
(`sm`) labels a CONTAINER of data, whereas here the name IS the content; `RecordSummary`'s `#`
|
|
90
|
+
is a page/drawer rung, and a page rung inside a popover flattens the very altitude distinction
|
|
91
|
+
the ramp exists to keep. **The failure it prevents is measurable:** a body-size name leaves the
|
|
92
|
+
panel's subject separated from its own supporting line by WEIGHT alone, which is a separate
|
|
93
|
+
font file rather than a step, and the whole surface then measures ~1.17x — flatter than a
|
|
94
|
+
register with no page band. Its supporting line takes the rung that gives it SIBLINGS (the
|
|
95
|
+
other muted lines in the same block), which is what "one rung below" is serving; applied
|
|
96
|
+
literally against that it produces a singleton treatment instead.
|
|
86
97
|
- **Section title** — ONE construct: `Section` › `SectionHeading` › `SectionHeadingTitle`
|
|
87
98
|
(`##` — xl semibold, optional muted `description` and `info` popover), everything left-aligned
|
|
88
99
|
at the column edge; `SectionHeadingMeta`, any VIEW control, and the section's own ADD ride the
|
|
@@ -1045,6 +1056,29 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
|
|
|
1045
1056
|
**One verb set across every reference of a group** (a party wall's five links all read "Gỡ", not
|
|
1046
1057
|
a mix of "Đổi"/"Gỡ"): with a clearable link, changing = remove → the empty-state picker
|
|
1047
1058
|
re-prompts, so the destructive verb covers both required anchors and optional adds.
|
|
1059
|
+
- **A PERSON token peeks the same way a record reference does — but only where nothing else
|
|
1060
|
+
on the line presses.** `MemberPeek` makes a `MemberChip` a door onto `MemberProfileCard`
|
|
1061
|
+
(a `xl` avatar, the sign-in identity, role, teams), and it is the same drill-down grammar:
|
|
1062
|
+
identity on the surface, the facts one layer in. The constraint is where it may live, and
|
|
1063
|
+
it is the interior-verb lesson above generalised past the reference field: **a token inside
|
|
1064
|
+
something already pressable puts two destinations behind one object.** Two surfaces fail it
|
|
1065
|
+
and both keep the plain chip — a PICKER OPTION (a card over the open menu, fired at the one
|
|
1066
|
+
moment the reader is choosing rather than browsing) and a ROW THAT NAVIGATES (press the row,
|
|
1067
|
+
open the record; press four pixels left, open a card). What is left is where a peek is
|
|
1068
|
+
actually for: a person named in prose, a static header, a detail row, a log whose rows do
|
|
1069
|
+
not navigate. The test generalises to any token — ask what ELSE the press would have done,
|
|
1070
|
+
and if the answer is "something", the token stays inert and the surface it sits on carries
|
|
1071
|
+
the drill-down.
|
|
1072
|
+
**Judge the CONTAINER, not how inert it looks.** A grouped register's band header names a
|
|
1073
|
+
person, carries no visible control, and is a button — it collapses the group — so a token
|
|
1074
|
+
inside it nests a button in a button and splits one gesture in two. The corollary is worth
|
|
1075
|
+
stating because it reads as a failure and is not: a screen built entirely of editors,
|
|
1076
|
+
pickers and navigating rows has NOWHERE a peek belongs, and the right move there is no peek
|
|
1077
|
+
at all rather than the least-bad host.
|
|
1078
|
+
**A profile's rows are conditional, and absent is not empty**: a field nobody supplied has
|
|
1079
|
+
no row, while a field supplied as EMPTY keeps its row and says so. Collapsing the two turns
|
|
1080
|
+
"we were not told" into a confident claim about a colleague — the same distinction a
|
|
1081
|
+
resolved member cell already carries on the wire, and it must survive the render.
|
|
1048
1082
|
|
|
1049
1083
|
## The pointer cursor
|
|
1050
1084
|
|
|
@@ -1377,6 +1411,22 @@ default nobody chose — the giveaway is that the call site mentions no colour a
|
|
|
1377
1411
|
arrived from a component's fallback rather than from a decision. Either give it siblings or take it
|
|
1378
1412
|
back to a neutral.
|
|
1379
1413
|
|
|
1414
|
+
**SCALE may vary by altitude; INK may not.** Two surfaces rendering the same thing at different
|
|
1415
|
+
sizes is the ramp working — a dense chip and a panel legitimately put a person's name on different
|
|
1416
|
+
rungs. Two surfaces rendering it in different INKS is a defect, because an ink is a MEANING
|
|
1417
|
+
(supporting, departed, danger) while a rung is only a size. The signature is one fact measuring two
|
|
1418
|
+
colours on one screen: collect every element's `color` grouped by what it MEANS, and any meaning
|
|
1419
|
+
holding more than one value — or any value serving more than one meaning — is the finding, whatever
|
|
1420
|
+
the sizes are.
|
|
1421
|
+
|
|
1422
|
+
**Supporting text is `muted`, and a raw neutral in its place is the usual cause of the above.**
|
|
1423
|
+
`color="muted"` is the token that means "this supports something else"; a hand-written
|
|
1424
|
+
`color="zinc-500"` is a *different, lighter* neutral that typechecks, renders plausibly, and is
|
|
1425
|
+
already spoken for — components mute a DEPARTED or disabled subject to it. Reach for the raw shade
|
|
1426
|
+
and a supporting line ends up wearing the ink that means "this person has left", so on a departed
|
|
1427
|
+
row the mute and the metadata come out identical and the state stops signalling anything. Grep a
|
|
1428
|
+
component for a raw neutral on `Text` before trusting that it reads muted.
|
|
1429
|
+
|
|
1380
1430
|
**TEXT never wears `solid()`** — the 500 is a FILL shade (dots, series). A status word/number uses
|
|
1381
1431
|
`Text`'s valence tokens: `color="danger"` (red-900) / `"warning"` (amber-700) / `"success"`
|
|
1382
1432
|
(emerald-700) — all AA on white. Map a dynamic `ColorName` to a token at the call site; hue nuance
|
package/docs/reviewing.md
CHANGED
|
@@ -96,8 +96,26 @@ rule behind it lives in the area doc named beside it; this file never restates o
|
|
|
96
96
|
before meaning, so an unexplained difference reads as an accident however principled the reason.
|
|
97
97
|
- **Singletons.** A rung/weight/ink combination appearing ONCE either wants siblings or wants to
|
|
98
98
|
join an existing class. Pick the sibling set by STRUCTURAL ROLE, not visual resemblance.
|
|
99
|
+
- **Count DISTINCT rungs on the surface, then name the role each one carries.** More rungs than
|
|
100
|
+
roles means a role has been split across two sizes, and that is the defect a reader calls
|
|
101
|
+
"inconsistent" without being able to point at which element is wrong — because none of them is:
|
|
102
|
+
each was chosen defensibly, one at a time. This is the probe to re-run after EVERY fix, and the
|
|
103
|
+
reason is procedural rather than visual: a treatment fix aimed at the element someone pointed at
|
|
104
|
+
leaves its neighbours unaligned, so the next look finds a new mismatch and the surface never
|
|
105
|
+
converges. Fixing the SET is a different act from fixing an element, and only this count tells
|
|
106
|
+
the two apart. A small panel wants two or three rungs; if the tally exceeds the number of things
|
|
107
|
+
the surface has to SAY, the excess is the finding.
|
|
99
108
|
- **Pairs.** For every primary+supporting pair, divide. A spread in the RATIOS is the finding, not
|
|
100
|
-
a spread in the sizes.
|
|
109
|
+
a spread in the sizes. **A ratio of 1.00 is the loudest version** — a subject and its supporting
|
|
110
|
+
line on one rung are distinguishable only by ink, and if they also share a weight, only by ink.
|
|
111
|
+
- **Stacked pairs: measure the STACK against the type inside it.** Collect each line's `font-size`
|
|
112
|
+
and `line-height` and the pair's total height. Prose leading is ~1.7x the rung, so two stacked
|
|
113
|
+
lines carry roughly half a line of empty box between the glyphs and read as two objects rather
|
|
114
|
+
than one — the signature is a stack noticeably taller than the sum of its font sizes (14+14 in a
|
|
115
|
+
48px box), with the lines' own boxes TOUCHING, so no margin or gap appears anywhere to explain
|
|
116
|
+
it and every spacing probe passes. Beside a mark, the same pair also outgrows the mark it labels.
|
|
117
|
+
The fix is `leading="tight"` on BOTH lines, never a negative margin, and never on a lone line —
|
|
118
|
+
one line is not a pair, and tightening it changes the geometry of every single-line instance.
|
|
101
119
|
- **A label sharing a row with a flexible value: measure the LABEL across rows whose values differ
|
|
102
120
|
in LENGTH.** Whichever of the two cannot shrink forces the other to, so a value floored at its
|
|
103
121
|
longest word leaves the label absorbing every pixel and collapsing to a one-character column
|
|
@@ -322,6 +322,15 @@ export function TplTaskBoard() {
|
|
|
322
322
|
out = STATUS_ORDER.map((s) => ({ key: s, header: <OptionBadge value={STATUS[s]} variant="dot" />, items: pool.filter((t) => t.status === s) }));
|
|
323
323
|
} else if (groupBy === "assignee") {
|
|
324
324
|
out = [
|
|
325
|
+
// A PLAIN chip, and deliberately so. It is tempting to make the person
|
|
326
|
+
// here open their profile (`MemberPeek`), and it would be wrong: this
|
|
327
|
+
// band is itself a button — `DataGrid` wraps a group header in a
|
|
328
|
+
// pressable that collapses the group — so a card on the chip puts two
|
|
329
|
+
// destinations behind one object and nests a button inside a button.
|
|
330
|
+
// The same holds everywhere this screen names somebody: the assignee
|
|
331
|
+
// CELL is an editor, and the assignee FILTER is a picker option. A
|
|
332
|
+
// working board has no inert person on it, which is why the peek lives
|
|
333
|
+
// on static surfaces — a header that does nothing, a detail row, a log.
|
|
325
334
|
...MEMBERS.map((m) => ({ key: m.id, header: <MemberChip name={m.name} size="sm" />, items: pool.filter((t) => t.ownerId === m.id) })),
|
|
326
335
|
{ key: "none", header: <Text size="sm" color="muted">Unassigned</Text>, items: pool.filter((t) => !t.ownerId) },
|
|
327
336
|
];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "44.
|
|
3
|
+
"version": "44.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./vite": {
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
"./option_picker": "./src/option_picker.tsx",
|
|
17
17
|
"./option_badge": "./src/option_badge.tsx",
|
|
18
18
|
"./member_chip": "./src/member_chip.tsx",
|
|
19
|
+
"./member_profile_card": "./src/member_profile_card.tsx",
|
|
20
|
+
"./member_peek": "./src/member_peek.tsx",
|
|
19
21
|
"./member_select": "./src/member_select.tsx",
|
|
20
22
|
"./inline_member_select": "./src/inline_member_select.tsx",
|
|
21
23
|
"./mime": "./src/mime.ts",
|
package/src/locale.tsx
CHANGED
|
@@ -9,6 +9,7 @@ import { type DateRangeFilterFieldLabels } from "./date_range_filter_field";
|
|
|
9
9
|
import { type GalleryLabels } from "./file_preview_types";
|
|
10
10
|
import { type FindingLabels } from "./finding";
|
|
11
11
|
import { type DeadlineLabels } from "./deadline";
|
|
12
|
+
import { type MemberProfileLabels } from "./member_profile_card";
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* The kit's localizable strings, one slice per string-bearing component. A
|
|
@@ -125,8 +126,13 @@ export interface LoticsLocale {
|
|
|
125
126
|
* string cannot mean both. Agnostic on purpose: the kit does not know
|
|
126
127
|
* whether it is showing a recording, a clip or an attachment. */
|
|
127
128
|
mediaPlayer: { loadFailed: string };
|
|
128
|
-
/** `Avatar`: the fallback name (initials + a11y label) shown when no `name`.
|
|
129
|
+
/** `Avatar`: the fallback name (initials + a11y label) shown when no `name`.
|
|
130
|
+
* `MemberChip` resolves the same blank through it, so a person with no name
|
|
131
|
+
* is called one thing on both. */
|
|
129
132
|
avatar: { unknown: string };
|
|
133
|
+
/** `MemberProfileCard` + `MemberPeek`: the profile card's row labels, its
|
|
134
|
+
* "no groups" and departed notes, and the trigger's announced name. */
|
|
135
|
+
memberProfile: Required<MemberProfileLabels>;
|
|
130
136
|
/** `BackButton` + `PopoverNavHeader`: the back-chevron's a11y name. */
|
|
131
137
|
nav: { back: string };
|
|
132
138
|
/** `BarChart` / `LineChart` / `PieChart`: the empty-state caption and the
|
|
@@ -338,6 +344,14 @@ export const en: LoticsLocale = {
|
|
|
338
344
|
passwordProtected: "This file is password-protected and cannot be previewed",
|
|
339
345
|
},
|
|
340
346
|
avatar: { unknown: "Unknown" },
|
|
347
|
+
memberProfile: {
|
|
348
|
+
role: "Role",
|
|
349
|
+
groups: "Groups",
|
|
350
|
+
joined: "Joined",
|
|
351
|
+
noGroups: "None",
|
|
352
|
+
inactive: "No longer active",
|
|
353
|
+
profile: (name) => `Profile of ${name}`,
|
|
354
|
+
},
|
|
341
355
|
nav: { back: "Back" },
|
|
342
356
|
chart: { noData: "No data", total: "Total" },
|
|
343
357
|
composer: { send: "Send", stop: "Stop" },
|
|
@@ -510,6 +524,17 @@ export const vi: LoticsLocale = {
|
|
|
510
524
|
},
|
|
511
525
|
mediaPlayer: { loadFailed: "Không tải được nội dung" },
|
|
512
526
|
avatar: { unknown: "Không rõ" },
|
|
527
|
+
memberProfile: {
|
|
528
|
+
role: "Vai trò",
|
|
529
|
+
groups: "Nhóm",
|
|
530
|
+
joined: "Tham gia",
|
|
531
|
+
noGroups: "Không có",
|
|
532
|
+
inactive: "Đã nghỉ",
|
|
533
|
+
// "Thông tin của …", not "Hồ sơ của …": in a Vietnamese workspace a hồ sơ is
|
|
534
|
+
// a DOSSIER — an application file, a case — so announcing a colleague's card
|
|
535
|
+
// that way names the wrong object on the very surface that has no picture.
|
|
536
|
+
profile: (name) => `Thông tin của ${name}`,
|
|
537
|
+
},
|
|
513
538
|
nav: { back: "Quay lại" },
|
|
514
539
|
chart: { noData: "Không có dữ liệu", total: "Tổng" },
|
|
515
540
|
composer: { send: "Gửi", stop: "Dừng" },
|
package/src/member_chip.tsx
CHANGED
|
@@ -3,6 +3,20 @@ import { View, StyleSheet, StyleProp, ViewStyle } from "react-native";
|
|
|
3
3
|
import { Avatar } from "./avatar";
|
|
4
4
|
import { Text } from "./text";
|
|
5
5
|
import type { AvatarSize } from "./avatar_size";
|
|
6
|
+
import { useLoticsLocale } from "./locale";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What a member with no usable name is CALLED — one answer, shared by every
|
|
10
|
+
* surface that renders a person, so a chip and the profile card it opens cannot
|
|
11
|
+
* name the same blank differently.
|
|
12
|
+
*
|
|
13
|
+
* The word comes in as a parameter rather than out of a hook: the fallback is
|
|
14
|
+
* pure string logic, and keeping it that way lets the rule be tested without a
|
|
15
|
+
* provider and reused where no hook may run.
|
|
16
|
+
*/
|
|
17
|
+
export function memberDisplayName(name: string | null | undefined, unknown: string): string {
|
|
18
|
+
return name?.trim() || unknown;
|
|
19
|
+
}
|
|
6
20
|
|
|
7
21
|
interface MemberChipProps {
|
|
8
22
|
/** Display name. Empty/blank falls back to a neutral label — pass
|
|
@@ -20,6 +34,22 @@ interface MemberChipProps {
|
|
|
20
34
|
* than as a current assignment. The chip keeps its height, because its usual
|
|
21
35
|
* home is a fixed row: this is a colour change, never an extra line. */
|
|
22
36
|
inactive?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* This chip is a DOOR — something opens when it is pressed. Underlines the
|
|
39
|
+
* name in neutral ink, the kit's marker for "leads somewhere, and not away"
|
|
40
|
+
* (see `TextLink` with no `href`).
|
|
41
|
+
*
|
|
42
|
+
* It exists because a pressable that paints nothing at rest is text: with no
|
|
43
|
+
* ground, no border and no underline, its only affordance is a hover wash,
|
|
44
|
+
* which does not exist on touch and has not happened yet for anyone who has
|
|
45
|
+
* not already pointed at it. The chip then measures perfectly, carries a real
|
|
46
|
+
* focus ring, and is never pressed.
|
|
47
|
+
*
|
|
48
|
+
* Set it ONLY when the chip itself opens something. A chip inside a row that
|
|
49
|
+
* navigates must not wear it — the row is the door, and marking the chip
|
|
50
|
+
* promises a second destination that is not there.
|
|
51
|
+
*/
|
|
52
|
+
marker?: boolean;
|
|
23
53
|
style?: StyleProp<ViewStyle>;
|
|
24
54
|
}
|
|
25
55
|
|
|
@@ -45,9 +75,13 @@ export function MemberChip({
|
|
|
45
75
|
secondary,
|
|
46
76
|
size = "md",
|
|
47
77
|
inactive,
|
|
78
|
+
marker,
|
|
48
79
|
style,
|
|
49
80
|
}: MemberChipProps) {
|
|
50
|
-
|
|
81
|
+
// `Avatar` has always taken this word from the pack; the NAME beside it was a
|
|
82
|
+
// hardcoded "Unknown", so a Vietnamese workspace drew initials from "Không rõ"
|
|
83
|
+
// and then printed "Unknown" next to them. One resolver, one word.
|
|
84
|
+
const displayName = memberDisplayName(name, useLoticsLocale().avatar.unknown);
|
|
51
85
|
// THE TEXT FOLLOWS THE RUNG. `size` used to scale the avatar alone, so a chip
|
|
52
86
|
// asked for the dense rung got a 24px avatar beside a 14px name — half-dense,
|
|
53
87
|
// and the mismatch surfaces wherever the chip sits INSIDE a sentence: a 12px
|
|
@@ -58,6 +92,19 @@ export function MemberChip({
|
|
|
58
92
|
// Only `sm` steps down, because only `sm` claims to be dense; every larger
|
|
59
93
|
// rung keeps the body size, where a person's name belongs.
|
|
60
94
|
const textSize = size === "sm" ? "xs" : "sm";
|
|
95
|
+
// ONE RUNG BELOW the name. It used to share the name's rung, so a chip's two
|
|
96
|
+
// lines were the same size AND the same weight and differed only in ink — the
|
|
97
|
+
// flattest a subject-over-supporting pair can be, and unreadable as a pair at
|
|
98
|
+
// a glance. `xs` is the ramp's floor, which is why the dense `sm` chip's two
|
|
99
|
+
// lines sit level: there is nothing under it to step to.
|
|
100
|
+
const secondarySize = "xs";
|
|
101
|
+
// TIGHT ONLY WHEN THERE IS A PAIR. Prose leading puts a 24px box around 14px
|
|
102
|
+
// text, so two stacked lines carried ~10px of empty box between the glyphs and
|
|
103
|
+
// read as two separate things rather than one person — see `Text.leading`,
|
|
104
|
+
// which exists for exactly this and asks for BOTH lines of the pair. A lone
|
|
105
|
+
// name is not a pair, and leaving it on prose leading keeps every single-line
|
|
106
|
+
// chip — every picker option, every cell — pixel-identical to before.
|
|
107
|
+
const pairLeading = secondary ? ("tight" as const) : undefined;
|
|
61
108
|
return (
|
|
62
109
|
<View style={[styles.row, style]}>
|
|
63
110
|
<Avatar size={size} name={displayName} source={image ? { uri: image } : undefined} />
|
|
@@ -65,13 +112,36 @@ export function MemberChip({
|
|
|
65
112
|
<Text
|
|
66
113
|
userSelect="none"
|
|
67
114
|
size={textSize}
|
|
115
|
+
// MEDIUM, never semibold: a chip is a ROW SUBJECT, and semibold is the
|
|
116
|
+
// heading ladder's weight (`MemberProfileCard`'s name is a heading and
|
|
117
|
+
// takes it). At `regular` the name was indistinguishable from its own
|
|
118
|
+
// supporting line except by colour.
|
|
119
|
+
weight="medium"
|
|
120
|
+
leading={pairLeading}
|
|
121
|
+
// NEUTRAL underline, never the navigation blue: pressing this opens
|
|
122
|
+
// the person in place rather than travelling to them, and blue
|
|
123
|
+
// underlined text promises a destination.
|
|
124
|
+
decoration={marker ? "underline" : undefined}
|
|
68
125
|
color={inactive ? "zinc-500" : undefined}
|
|
69
126
|
numberOfLines={1}
|
|
70
127
|
>
|
|
71
128
|
{displayName}
|
|
72
129
|
</Text>
|
|
73
130
|
{secondary ? (
|
|
74
|
-
<Text
|
|
131
|
+
<Text
|
|
132
|
+
userSelect="none"
|
|
133
|
+
size={secondarySize}
|
|
134
|
+
leading={pairLeading}
|
|
135
|
+
// `muted`, the kit's supporting-text TOKEN, not the raw `zinc-500`
|
|
136
|
+
// this used to carry. Two things were wrong with the raw value: it
|
|
137
|
+
// is the minority spelling of a colour the kit names 137 times as
|
|
138
|
+
// `muted`, and `inactive` above resolves to zinc-500 too — so on a
|
|
139
|
+
// departed member the muted NAME and this line came out the same
|
|
140
|
+
// ink and the mute stopped signalling anything. An ink is a
|
|
141
|
+
// MEANING; two meanings may not share one.
|
|
142
|
+
color="muted"
|
|
143
|
+
numberOfLines={1}
|
|
144
|
+
>
|
|
75
145
|
{secondary}
|
|
76
146
|
</Text>
|
|
77
147
|
) : null}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { StyleProp, ViewStyle } from "react-native";
|
|
2
|
+
import type { AvatarSize } from "./avatar_size";
|
|
3
|
+
import { MemberChip, memberDisplayName } from "./member_chip";
|
|
4
|
+
import { MemberProfileCard, type MemberProfileCardProps } from "./member_profile_card";
|
|
5
|
+
import { Peek } from "./peek";
|
|
6
|
+
import type { PopoverAlign, PopoverSide } from "./popover";
|
|
7
|
+
import { useLoticsLocale } from "./locale";
|
|
8
|
+
|
|
9
|
+
export interface MemberPeekProps extends MemberProfileCardProps {
|
|
10
|
+
/** The chip's rung on the shared avatar scale — the TRIGGER's size, not the
|
|
11
|
+
* card's (the card is always a profile header). Default `md`. */
|
|
12
|
+
size?: AvatarSize;
|
|
13
|
+
/** Optional secondary line on the CHIP. The card carries the full detail, so
|
|
14
|
+
* reach for this only where the resting row needs it on its own. */
|
|
15
|
+
secondary?: string | null;
|
|
16
|
+
side?: PopoverSide;
|
|
17
|
+
align?: PopoverAlign;
|
|
18
|
+
/** Override the trigger's announced name. Defaults to the locale's
|
|
19
|
+
* `memberProfile.profile(name)` — "Profile of Nguyễn Thị An". */
|
|
20
|
+
accessibilityLabel?: string;
|
|
21
|
+
style?: StyleProp<ViewStyle>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A member chip that OPENS — press the person and their profile appears in
|
|
26
|
+
* place: bigger avatar, what they sign in as, their role, their teams.
|
|
27
|
+
*
|
|
28
|
+
* The composition is deliberately narrow (a `Peek` around a `MemberChip`, with
|
|
29
|
+
* `MemberProfileCard` as the body) and it is a named component anyway, because
|
|
30
|
+
* the three things a hand-rolled version gets wrong are invisible one at a
|
|
31
|
+
* time: the popover width, the announced name, and WHICH fields the card
|
|
32
|
+
* shows. Three call sites rolling their own give a workspace three different
|
|
33
|
+
* answers to "who is this".
|
|
34
|
+
*
|
|
35
|
+
* ### Where this must NOT go
|
|
36
|
+
*
|
|
37
|
+
* **Not inside a picker option.** `MemberSelect` and `InlineMemberSelect`
|
|
38
|
+
* render a `MemberChip` per option; a peek there pops an anchored popover over
|
|
39
|
+
* an anchored popover, at the one moment the reader is trying to CHOOSE rather
|
|
40
|
+
* than to browse. Option rows keep the plain chip.
|
|
41
|
+
*
|
|
42
|
+
* **Not inside a row that is itself pressable.** A `PressableRow` that opens a
|
|
43
|
+
* record, holding a chip that opens a card, puts two destinations behind one
|
|
44
|
+
* object — press the row, navigate; press four pixels left, peek — and the kit
|
|
45
|
+
* has removed that shape once already (see the reference field's interior
|
|
46
|
+
* verb, `composition.md`). Where a row already presses, let it press: the
|
|
47
|
+
* profile belongs on the record it opens.
|
|
48
|
+
*
|
|
49
|
+
* **Test the container, not its look.** "It is only a header" is not an answer:
|
|
50
|
+
* a `DataGrid` group band NAMES a person and reads perfectly inert, and it is a
|
|
51
|
+
* button that collapses the group — a peek there nests a button inside a button.
|
|
52
|
+
* Ask what the press would otherwise have done; if the answer is anything, the
|
|
53
|
+
* chip stays plain.
|
|
54
|
+
*
|
|
55
|
+
* That leaves the surfaces a peek is actually for: a person named in prose, a
|
|
56
|
+
* static header that does nothing, a detail row, a log or register whose ROWS do
|
|
57
|
+
* not navigate. In practice a fully interactive screen has none of them, and
|
|
58
|
+
* that is the correct outcome rather than a gap to work around.
|
|
59
|
+
*
|
|
60
|
+
* PURE — pass the member's fields in; this fetches nothing.
|
|
61
|
+
*
|
|
62
|
+
* ```tsx
|
|
63
|
+
* <MemberPeek name={m.name} image={m.image} identity={m.email} groups={m.groups} />
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
export function MemberPeek(props: MemberPeekProps) {
|
|
67
|
+
const locale = useLoticsLocale();
|
|
68
|
+
const { size = "md", secondary, side, align, accessibilityLabel, style, ...card } = props;
|
|
69
|
+
const displayName = memberDisplayName(card.name, locale.avatar.unknown);
|
|
70
|
+
const words = { ...locale.memberProfile, ...card.labels };
|
|
71
|
+
return (
|
|
72
|
+
<Peek
|
|
73
|
+
accessibilityLabel={accessibilityLabel ?? words.profile(displayName)}
|
|
74
|
+
content={<MemberProfileCard {...card} />}
|
|
75
|
+
side={side}
|
|
76
|
+
align={align}
|
|
77
|
+
>
|
|
78
|
+
<MemberChip
|
|
79
|
+
name={card.name}
|
|
80
|
+
image={card.image}
|
|
81
|
+
secondary={secondary}
|
|
82
|
+
size={size}
|
|
83
|
+
inactive={card.inactive}
|
|
84
|
+
// This chip IS the door — without the marker it paints nothing at rest
|
|
85
|
+
// and only a pointer already on it would ever learn that.
|
|
86
|
+
marker
|
|
87
|
+
style={style}
|
|
88
|
+
/>
|
|
89
|
+
</Peek>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { StyleSheet, View } from "react-native";
|
|
3
|
+
import { Avatar } from "./avatar";
|
|
4
|
+
import { DetailRow, DetailTable } from "./detail_row";
|
|
5
|
+
import { Badge } from "./badge";
|
|
6
|
+
import { Divider } from "./divider";
|
|
7
|
+
import { Text } from "./text";
|
|
8
|
+
import { memberDisplayName } from "./member_chip";
|
|
9
|
+
import { useLoticsLocale, useLocaleTag } from "./locale";
|
|
10
|
+
import { formatDate } from "./format_date";
|
|
11
|
+
|
|
12
|
+
/** The card's own words — see `LoticsLocale.memberProfile`. */
|
|
13
|
+
export interface MemberProfileLabels {
|
|
14
|
+
/** Row label for the organization role. */
|
|
15
|
+
role?: string;
|
|
16
|
+
/** Row label for the member's groups — the platform's "department". */
|
|
17
|
+
groups?: string;
|
|
18
|
+
/** Row label for when this person joined the organization. */
|
|
19
|
+
joined?: string;
|
|
20
|
+
/** Stands in for the groups VALUE when the member belongs to none. */
|
|
21
|
+
noGroups?: string;
|
|
22
|
+
/** Said under the name when this person has left the organization. */
|
|
23
|
+
inactive?: string;
|
|
24
|
+
/** Screen-reader name of the trigger that opens this card — used by
|
|
25
|
+
* {@link MemberProfileCard}'s companion `MemberPeek`, which is why it lives
|
|
26
|
+
* in this slice rather than Peek's: the words describe the CARD, not the
|
|
27
|
+
* popover mechanism. */
|
|
28
|
+
profile?: (name: string) => string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface MemberProfileCardProps {
|
|
32
|
+
/** Display name. Blank falls back to the locale's unknown label, exactly as
|
|
33
|
+
* `MemberChip` does — the two render the same person. */
|
|
34
|
+
name?: string | null;
|
|
35
|
+
/** Avatar image URL. Absent → initials. */
|
|
36
|
+
image?: string | null;
|
|
37
|
+
/**
|
|
38
|
+
* What this person signs in as — an email address for a self-managed member,
|
|
39
|
+
* an administrator-issued name for an admin-managed one. Not labelled
|
|
40
|
+
* "Email" anywhere in the card, because for half the accounts in a workspace
|
|
41
|
+
* it is not one and cannot receive mail.
|
|
42
|
+
*/
|
|
43
|
+
identity?: string | null;
|
|
44
|
+
/**
|
|
45
|
+
* The organization role, **already translated for display** ("Quản trị
|
|
46
|
+
* viên") — never the raw `owner`/`admin`/`member` enum.
|
|
47
|
+
*
|
|
48
|
+
* The kit carries no domain vocabulary and no i18n logic, so a role arrives
|
|
49
|
+
* as the word the product decided to show. It is also the one field here
|
|
50
|
+
* that a reader routinely mis-reads: a role is a PERMISSION level, not
|
|
51
|
+
* seniority, and "Admin" beside a name on a sales register says nothing
|
|
52
|
+
* about who that person is in the company. Pass it where it means
|
|
53
|
+
* something; omit it (the row disappears) where it does not.
|
|
54
|
+
*/
|
|
55
|
+
role?: string | null;
|
|
56
|
+
/**
|
|
57
|
+
* The member's group names — what an org actually uses to say Sale, Kế toán,
|
|
58
|
+
* CSKH.
|
|
59
|
+
*
|
|
60
|
+
* ABSENT ≠ EMPTY, and the card renders the difference rather than flattening
|
|
61
|
+
* it: `undefined` drops the row entirely (this caller was never told — a
|
|
62
|
+
* public app response, an older server), while `[]` keeps the row and says
|
|
63
|
+
* so. Collapsing the two turns "we do not know" into the confident claim
|
|
64
|
+
* "they are on no team".
|
|
65
|
+
*/
|
|
66
|
+
groups?: readonly string[] | null;
|
|
67
|
+
/**
|
|
68
|
+
* When this person joined the organization, as an ISO date or timestamp.
|
|
69
|
+
*
|
|
70
|
+
* A DATE, not a formatted string — unlike `role`, which is vocabulary only the
|
|
71
|
+
* product can translate, a date has a canonical form the kit already owns, so
|
|
72
|
+
* it renders through `formatDate` under the active locale. Two hosts left to
|
|
73
|
+
* format it themselves would drift ("March 2024" against "03/2024") for a
|
|
74
|
+
* value that means the same thing.
|
|
75
|
+
*
|
|
76
|
+
* Rendered as MONTH AND YEAR. The question it answers is "is this the new
|
|
77
|
+
* person?", and a day is more precision than that asks for — it also stops
|
|
78
|
+
* the row reading as an event the reader is meant to act on.
|
|
79
|
+
*/
|
|
80
|
+
joined?: string | null;
|
|
81
|
+
/** This person has left the organization — shows the state as a badge and
|
|
82
|
+
* mutes the name, so a record that still names them reads as history. */
|
|
83
|
+
inactive?: boolean;
|
|
84
|
+
/** ONE door to the fuller record ("View profile"). A peek that needs a second
|
|
85
|
+
* verb wanted to be a screen. */
|
|
86
|
+
action?: ReactNode;
|
|
87
|
+
/** Per-instance overrides; anything omitted comes from the active locale. */
|
|
88
|
+
labels?: MemberProfileLabels;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* THE CARD'S THREE TEXT CLASSES, named once so every line below picks from a
|
|
93
|
+
* system instead of being decided on its own.
|
|
94
|
+
*
|
|
95
|
+
* This is here because deciding per element is how the card ended up with four
|
|
96
|
+
* type sizes inside 200px, each defensible alone: a heading rung, a value rung,
|
|
97
|
+
* an identity line one step under the name, and a label rung "for a compact
|
|
98
|
+
* peek". Every one had a reason and the set had no shape, so each fix to one
|
|
99
|
+
* element left a neighbour unaligned and the surface never converged.
|
|
100
|
+
*
|
|
101
|
+
* SUBJECT the person — the only heading here
|
|
102
|
+
* VALUE any fact's content: the identity line, a row's value
|
|
103
|
+
* SUPPORTING anything that DESCRIBES a value rather than being one
|
|
104
|
+
*
|
|
105
|
+
* SUPPORTING shares VALUE's rung and separates by INK. In a label/value ROW the
|
|
106
|
+
* label is already identified by its column, so a size step would be a third
|
|
107
|
+
* signal for a distinction position and ink already carry — unlike a STACKED
|
|
108
|
+
* pair (see `MemberChip`), where top-to-bottom reading needs the step to say
|
|
109
|
+
* which line leads.
|
|
110
|
+
*/
|
|
111
|
+
const SUBJECT = { size: "lg", weight: "semibold" } as const;
|
|
112
|
+
const VALUE = { size: "sm" } as const;
|
|
113
|
+
const SUPPORTING = { size: "sm", color: "muted" } as const;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* WHO IS THIS PERSON — a bigger avatar, their name, what they sign in as, their
|
|
117
|
+
* role and their teams, in one card that reads identically everywhere.
|
|
118
|
+
*
|
|
119
|
+
* It is the body `MemberPeek` reveals, and the reason that body is a component
|
|
120
|
+
* rather than four lines at each call site: the interesting failure of a
|
|
121
|
+
* hand-composed profile is not that it looks different, it is that each copy
|
|
122
|
+
* picks a different SUBSET — one shows the email, the next the role, a third
|
|
123
|
+
* neither — so the same colleague answers a different question depending on
|
|
124
|
+
* which screen you met them on. That is the defect `AvatarSize` was written to
|
|
125
|
+
* fix for pixels, one level up.
|
|
126
|
+
*
|
|
127
|
+
* PURE: pass the fields in (an app feeds a `ResolvedMember`, the product feeds
|
|
128
|
+
* its member directory); this fetches nothing and knows no domain types.
|
|
129
|
+
*
|
|
130
|
+
* Rows appear only when their field does — a card with no role and no groups is
|
|
131
|
+
* a header and nothing else, which is the correct rendering of "that is all we
|
|
132
|
+
* were told", not a degraded one.
|
|
133
|
+
*
|
|
134
|
+
* ```tsx
|
|
135
|
+
* <MemberProfileCard
|
|
136
|
+
* name={m.name}
|
|
137
|
+
* image={m.image}
|
|
138
|
+
* identity={m.email}
|
|
139
|
+
* groups={m.groups}
|
|
140
|
+
* action={<Button title="View profile" color="secondary" onPress={open} />}
|
|
141
|
+
* />
|
|
142
|
+
* ```
|
|
143
|
+
*/
|
|
144
|
+
|
|
145
|
+
export function MemberProfileCard(props: MemberProfileCardProps) {
|
|
146
|
+
const locale = useLoticsLocale();
|
|
147
|
+
const words = { ...locale.memberProfile, ...props.labels };
|
|
148
|
+
const { name, image, identity, role, groups, joined, inactive, action } = props;
|
|
149
|
+
const localeTag = useLocaleTag();
|
|
150
|
+
|
|
151
|
+
const displayName = memberDisplayName(name, locale.avatar.unknown);
|
|
152
|
+
// An empty string is a value nobody chose — a role that failed to resolve, an
|
|
153
|
+
// identity that came back blank. Treated as absent so the row disappears
|
|
154
|
+
// rather than rendering a labelled void.
|
|
155
|
+
const roleText = role?.trim() || null;
|
|
156
|
+
const identityText = identity?.trim() || null;
|
|
157
|
+
// `null` joins `undefined` in meaning "not told"; only a real array is data.
|
|
158
|
+
const groupList = groups ?? null;
|
|
159
|
+
// An unparseable date yields "" from `formatDate`, which drops the row rather
|
|
160
|
+
// than printing an empty labelled slot — same rule as an empty role.
|
|
161
|
+
const joinedText = joined ? formatDate(joined, { format: "monthYear", locale: localeTag }) : "";
|
|
162
|
+
const hasRows = roleText !== null || groupList !== null || joinedText !== "";
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<View style={styles.card}>
|
|
166
|
+
<View style={styles.header}>
|
|
167
|
+
{/* `xl` is the rung documented for "a profile's own header", and this is
|
|
168
|
+
one. The chip that opens this card is `md` (28) — a preview whose
|
|
169
|
+
avatar is the same size as its trigger's has not previewed
|
|
170
|
+
anything. */}
|
|
171
|
+
<Avatar size="xl" name={displayName} source={image ? { uri: image } : undefined} />
|
|
172
|
+
<View style={styles.identity}>
|
|
173
|
+
{/* SUBJECT — the card's one heading, and its whole hierarchy. At body
|
|
174
|
+
size it was separated from its own identity line by WEIGHT alone,
|
|
175
|
+
which is a separate font file rather than a step, and the surface
|
|
176
|
+
measured 1.17x — flatter than a register with no page band. */}
|
|
177
|
+
<Text {...SUBJECT} color={inactive ? "zinc-500" : undefined} numberOfLines={2}>
|
|
178
|
+
{displayName}
|
|
179
|
+
</Text>
|
|
180
|
+
{identityText ? (
|
|
181
|
+
<Text {...SUPPORTING} numberOfLines={2}>
|
|
182
|
+
{identityText}
|
|
183
|
+
</Text>
|
|
184
|
+
) : null}
|
|
185
|
+
{inactive ? (
|
|
186
|
+
// A BADGE, not another muted line. Departed is a lifecycle STATE and
|
|
187
|
+
// this is the one status the card can carry, which is precisely what
|
|
188
|
+
// a badge is for — as a `SUPPORTING` line it wore the identical
|
|
189
|
+
// treatment to the email above it, so a state and a piece of
|
|
190
|
+
// metadata read as the same kind of thing and the state lost.
|
|
191
|
+
//
|
|
192
|
+
// No `color`: departed is neutral, not a danger. `alignSelf` because
|
|
193
|
+
// a tonal pill stretches to its container otherwise — see `Badge`.
|
|
194
|
+
<Badge variant="tonal" label={words.inactive} style={styles.status} />
|
|
195
|
+
) : null}
|
|
196
|
+
</View>
|
|
197
|
+
</View>
|
|
198
|
+
|
|
199
|
+
{hasRows ? (
|
|
200
|
+
<>
|
|
201
|
+
<Divider />
|
|
202
|
+
{/* A SHORT label column, and it is load-bearing rather than taste:
|
|
203
|
+
`DetailTable` stacks itself when the columns would crush the value
|
|
204
|
+
below `minValueWidth` (160), and the kit's default label column
|
|
205
|
+
(130) plus that floor plus the gutter exceeds the width a peek has
|
|
206
|
+
to give. At the default every row in this card would render
|
|
207
|
+
stacked — correct-looking in isolation, and quietly nothing like
|
|
208
|
+
the same card in a drawer. */}
|
|
209
|
+
<DetailTable labelWidth={64} minHeight={24}>
|
|
210
|
+
{roleText !== null ? (
|
|
211
|
+
<DetailRow label={words.role} flat>
|
|
212
|
+
<Text {...VALUE}>{roleText}</Text>
|
|
213
|
+
</DetailRow>
|
|
214
|
+
) : null}
|
|
215
|
+
{joinedText !== "" ? (
|
|
216
|
+
<DetailRow label={words.joined} flat>
|
|
217
|
+
<Text {...VALUE}>{joinedText}</Text>
|
|
218
|
+
</DetailRow>
|
|
219
|
+
) : null}
|
|
220
|
+
{groupList !== null ? (
|
|
221
|
+
<DetailRow label={words.groups} flat>
|
|
222
|
+
{groupList.length > 0 ? (
|
|
223
|
+
// Joined, not one chip per group: a group is a CATEGORY, and
|
|
224
|
+
// `Badge`'s own contract reserves it for status ("if you're
|
|
225
|
+
// adding a Badge to show more info, that's text"). Text also
|
|
226
|
+
// wraps predictably where a pill row reflows.
|
|
227
|
+
<Text {...VALUE}>{groupList.join(", ")}</Text>
|
|
228
|
+
) : (
|
|
229
|
+
// The one place a VALUE wears the supporting ink: "None" is
|
|
230
|
+
// the absence of a value, not one.
|
|
231
|
+
<Text {...SUPPORTING}>{words.noGroups}</Text>
|
|
232
|
+
)}
|
|
233
|
+
</DetailRow>
|
|
234
|
+
) : null}
|
|
235
|
+
</DetailTable>
|
|
236
|
+
</>
|
|
237
|
+
) : null}
|
|
238
|
+
|
|
239
|
+
{action ? (
|
|
240
|
+
<>
|
|
241
|
+
<Divider />
|
|
242
|
+
{action}
|
|
243
|
+
</>
|
|
244
|
+
) : null}
|
|
245
|
+
</View>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const styles = StyleSheet.create({
|
|
250
|
+
card: { gap: 10 },
|
|
251
|
+
// Centered: the avatar is the tallest thing here by a wide margin, and a
|
|
252
|
+
// top-aligned two-line name beside a 72px circle reads as a caption that
|
|
253
|
+
// slipped.
|
|
254
|
+
header: { flexDirection: "row", alignItems: "center", gap: 12 },
|
|
255
|
+
identity: { flex: 1, minWidth: 0, gap: 1, alignItems: "flex-start" },
|
|
256
|
+
// The pill sits on its own line under the identity rather than beside the
|
|
257
|
+
// name: at this width a 20px name plus a tonal pill overruns the 236px text
|
|
258
|
+
// column and wraps, and a status that wraps under its own subject reads as a
|
|
259
|
+
// third line rather than as the subject's state.
|
|
260
|
+
status: { marginTop: 4 },
|
|
261
|
+
});
|
package/src/record_summary.tsx
CHANGED
|
@@ -26,7 +26,8 @@ export interface RecordSummaryMetric {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
export interface RecordSummaryProps {
|
|
29
|
-
/** The record's id/name —
|
|
29
|
+
/** The record's id/name — `xxl` semibold (the ramp's `#`), tabular so ids
|
|
30
|
+
* align across records. */
|
|
30
31
|
title: string;
|
|
31
32
|
/** Heading level of the title (`role="header"` + aria-level). Default 1 —
|
|
32
33
|
* the record page's one h1; pass 2 when the band sits inside a surface
|