@lotics/ui 30.0.0 → 31.0.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/MIGRATION.md +41 -0
- package/docs/catalog.md +29 -8
- package/docs/composition.md +11 -0
- package/docs/data_entry.md +5 -1
- package/examples/tpl_attendance.tsx +2 -2
- package/examples/tpl_dashboard.tsx +2 -2
- package/examples/tpl_shifts.tsx +4 -4
- package/examples/tpl_task_board.tsx +3 -3
- package/package.json +2 -1
- package/src/avatar.tsx +10 -23
- package/src/avatar.web.tsx +9 -23
- package/src/avatar_size.ts +77 -0
- package/src/group_avatar.tsx +18 -9
- package/src/inline_member_select.tsx +7 -2
- package/src/member_chip.tsx +4 -3
- package/src/mime.ts +19 -1
- package/src/table.tsx +43 -4
package/MIGRATION.md
CHANGED
|
@@ -4,6 +4,47 @@ Breaking changes, newest first — normally per major, plus the rare minor that
|
|
|
4
4
|
anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
|
|
5
5
|
this file exists only to move an app from one release to the next.
|
|
6
6
|
|
|
7
|
+
## 31.0.0 — avatar `size` is a rung, not a pixel count
|
|
8
|
+
|
|
9
|
+
`Avatar`, `MemberChip` and `GroupAvatar` no longer take `size?: number`. They take
|
|
10
|
+
`size?: AvatarSize` — `"sm" | "md" | "lg" | "xl"` = **24 / 36 / 48 / 72** — defaulting to `md`.
|
|
11
|
+
|
|
12
|
+
Map an EXPLICIT size by the density it was reaching for, not by nearest pixel:
|
|
13
|
+
|
|
14
|
+
| was | now |
|
|
15
|
+
| --- | --- |
|
|
16
|
+
| ≤ 24 (18, 20, 22, 24) | `"sm"` |
|
|
17
|
+
| 28, 32, 36 | `"md"` |
|
|
18
|
+
| 40, 48 | `"lg"` |
|
|
19
|
+
| 64, 80 | `"xl"` |
|
|
20
|
+
|
|
21
|
+
**The DEFAULT is now `md` (36) on all three components**, which is a separate move from that
|
|
22
|
+
table and goes in different directions: `Avatar` grows (32 → 36), `MemberChip` grows (28 → 36),
|
|
23
|
+
and **`GroupAvatar` shrinks (40 → 36)**. If a `GroupAvatar` was relying on its default to sit at
|
|
24
|
+
40 beside something else, pass `"lg"`. One default is the point — a member and a group in one
|
|
25
|
+
list had no business being different sizes.
|
|
26
|
+
|
|
27
|
+
Need an avatar's pixels for a sibling box — a stack's overlap, a dashed placeholder? Import
|
|
28
|
+
`AVATAR_PX` from `@lotics/ui/avatar_size` and index it by the rung, so the two cannot drift.
|
|
29
|
+
|
|
30
|
+
**Three rendering changes come with it, and none is opt-in.**
|
|
31
|
+
|
|
32
|
+
Initials are now the first and **last** word of the name rather than the first two: "Vũ Thị
|
|
33
|
+
Lan" renders VL where it rendered VT. A Vietnamese name is họ + đệm + tên, so the first two
|
|
34
|
+
words are the parts a whole office shares — on a real 29-person roster the old rule left 11
|
|
35
|
+
people indistinguishable and this one leaves 5. Two-word names are unaffected ("John Smith" is
|
|
36
|
+
JS either way), and it matches the conventional English rendering ("Mary Jane Watson" → MW).
|
|
37
|
+
`GroupAvatar` follows the same rule now: it showed one letter at every size and its neighbours
|
|
38
|
+
in the same list showed two.
|
|
39
|
+
|
|
40
|
+
The one-initial cutoff moved from "≤ 32px" to "`sm` only". Anything you map to `md` or above
|
|
41
|
+
now shows two letters where it showed one — which is the point: every call site in the
|
|
42
|
+
register apps sat under the old threshold, so the product only ever rendered a single letter.
|
|
43
|
+
If a surface genuinely wants one letter, that surface is `sm`.
|
|
44
|
+
|
|
45
|
+
The initials' TYPE SIZE now follows the rung instead of being a fixed `xs`. Most visible on the
|
|
46
|
+
large rungs: an avatar that was 80px wore 12px letters and looked empty; at `xl` it wears 28px.
|
|
47
|
+
|
|
7
48
|
## 30.0.0 — a washed row bleeds; content sits on the container's edge
|
|
8
49
|
|
|
9
50
|
No API changed. Every register, table and choice list **moves its content 20px
|
package/docs/catalog.md
CHANGED
|
@@ -76,6 +76,20 @@ inline: a picker option, an assignee, a `select_member` value. Pure: resolve the
|
|
|
76
76
|
your directory and pass `name` / `image`; never hand-roll `Avatar` + `Text`. (`MemberSelect`
|
|
77
77
|
renders these per option.)
|
|
78
78
|
|
|
79
|
+
**Size is a RUNG, never a pixel count** — `sm | md | lg | xl` = 24 / 36 / 48 / 72
|
|
80
|
+
(`@lotics/ui/avatar_size`, shared by `Avatar`, `MemberChip` and `GroupAvatar`). Default `md`.
|
|
81
|
+
The number was removed because the same concept had drifted to eleven different values across
|
|
82
|
+
the product and its apps, so one person looked different on every screen. Pick by density:
|
|
83
|
+
`sm` for a dense row or inline cell, `md` for a register or list (the default), `lg` for a
|
|
84
|
+
prominent row or the account menu, `xl` for a profile's own header.
|
|
85
|
+
|
|
86
|
+
**`sm` shows ONE initial; every larger rung shows two** — two letters are not legible at 24px.
|
|
87
|
+
That is the practical reason not to reach for `sm` by reflex: on a real roster a single letter
|
|
88
|
+
is rarely unique (nine people can share "N"), so a register that must identify someone wants
|
|
89
|
+
`md`. Initials are the first and **last** word of the name — "Vũ Thị Lan" → VL, "Mary Jane
|
|
90
|
+
Watson" → MW — because a Vietnamese name's first two words are the family and middle names
|
|
91
|
+
that a whole office shares.
|
|
92
|
+
|
|
79
93
|
### A select-field value
|
|
80
94
|
|
|
81
95
|
`OptionBadge` (a stored `select` value as its CONFIGURED colored badge) — never hand-map
|
|
@@ -425,12 +439,15 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
425
439
|
`themeColor` palette token (unknown → neutral zinc) holding any Lucide icon by runtime
|
|
426
440
|
name via `DynamicIcon`; `size` sm|md. One render wherever an app shows — launcher, list,
|
|
427
441
|
picker, settings.
|
|
428
|
-
- **`avatar`** — `Avatar`: image-or-initial person avatar (`source`/`name`/`size
|
|
429
|
-
`announce` when standalone — decorative by default because the name text usually
|
|
430
|
-
beside it).
|
|
431
|
-
- **`
|
|
432
|
-
|
|
433
|
-
|
|
442
|
+
- **`avatar`** — `Avatar`: image-or-initial person avatar (`source`/`name`/`size` — a rung,
|
|
443
|
+
see above; `announce` when standalone — decorative by default because the name text usually
|
|
444
|
+
sits beside it).
|
|
445
|
+
- **`avatar_size`** — the shared avatar scale: `AvatarSize` (`sm|md|lg|xl`), `AVATAR_PX`,
|
|
446
|
+
`AVATAR_TEXT`, `avatarInitials`. Import `AVATAR_PX` when a sibling box must match an
|
|
447
|
+
avatar's pixels (a stack's overlap, a dashed placeholder) so the two cannot drift.
|
|
448
|
+
- **`group_avatar`** — `GroupAvatar`: a name's initials in a zinc rounded square (`size` — the
|
|
449
|
+
same rung scale AND the same initials rule as `Avatar`, default `md`); the avatar for
|
|
450
|
+
image-less entities — groups, organizations. A person → `Avatar`/`MemberChip`.
|
|
434
451
|
- **`wave_avatar`** — `WaveAvatar`: decorative animated waveform avatar (voice/audit
|
|
435
452
|
history); animates on web, renders a static fallback on native.
|
|
436
453
|
- **`member_chip`** — `MemberChip`: avatar + name; the universal person render.
|
|
@@ -1333,8 +1350,12 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
1333
1350
|
honestly-declared type is returned untouched; an *uninformative* one (`""`,
|
|
1334
1351
|
`application/octet-stream` and friends — what an OS clipboard or drag hands
|
|
1335
1352
|
over) is resolved from the filename extension instead, falling back to the
|
|
1336
|
-
declaration when the extension is unknown too.
|
|
1337
|
-
|
|
1353
|
+
declaration when the extension is unknown too. Documents, images and A/V; on
|
|
1354
|
+
every extension it knows it returns exactly what the SERVER stores, pinned by
|
|
1355
|
+
`src/mime_parity.test.ts`. The server reads a far larger table, so an `accept`
|
|
1356
|
+
naming a MIME pattern (`audio/*`) for an extension outside this one drops a
|
|
1357
|
+
file the server would have kept — write that `accept` as an EXTENSION
|
|
1358
|
+
(`.dwg`), which never consults the table, or add the entry.
|
|
1338
1359
|
- **`download`** — `downloadFileFromUrl(url, filename, { credentials? })`: fetch+blob+anchor
|
|
1339
1360
|
download that works inside sandboxed iframes (where `window.open` is silently dropped);
|
|
1340
1361
|
`credentials` defaults to `same-origin` — pass `"include"` only for auth-gated same-site
|
package/docs/composition.md
CHANGED
|
@@ -402,6 +402,17 @@ running total. `tpl_item_list` is the reference. Paginate OUTSIDE (slice + `Pagi
|
|
|
402
402
|
Right-hand columns align only if every trailing element is FIXED-width — give each trailing action
|
|
403
403
|
a fixed `width`, so amount/status columns don't jitter.
|
|
404
404
|
|
|
405
|
+
**Cells whose LINE COUNTS differ need `align="top"`.** Cells are centred by default, which is
|
|
406
|
+
right while every cell is one line. The moment they are not — an identity cell carrying a
|
|
407
|
+
supporting line (2), a status cell with a bar over a label (3), a member chip (1) — centring four
|
|
408
|
+
different heights puts their four FIRST lines at four different heights, and the eye steps down
|
|
409
|
+
and back up reading across one row. Measured on a live register: a 12px spread, closed to 2px by
|
|
410
|
+
`align="top"`, which makes cells share a top edge so first lines land on one baseline. It governs
|
|
411
|
+
the CELLS only; the ordinal, the leading checkbox and the trailing ⋯ stay centred, being one line
|
|
412
|
+
by construction. Note a cell whose first element is a BAR or an image still starts its text below
|
|
413
|
+
that element — top-aligning the cell does not top-align the text inside it, so put the line you
|
|
414
|
+
want on the baseline first.
|
|
415
|
+
|
|
405
416
|
## Row actions are always-visible siblings — never hover-revealed, never nested
|
|
406
417
|
|
|
407
418
|
A pressable row = a role-less surface (the hover/press wash covers the whole row) + an accessible
|
package/docs/data_entry.md
CHANGED
|
@@ -589,7 +589,11 @@ Finder/Explorer and pasted arrives with real bytes and a real filename but an un
|
|
|
589
589
|
type — `""`, or just as often `application/octet-stream`, the same non-answer wearing the shape
|
|
590
590
|
of one. Judging it on that reads silence as "not a PDF", the file is discarded, and since an
|
|
591
591
|
unacceptable paste is deliberately left alone, the user sees nothing happen at all. Matching on
|
|
592
|
-
the resolved type also means the surface agrees with what the server will store.
|
|
592
|
+
the resolved type also means the surface agrees with what the server will store. The kit's
|
|
593
|
+
extension table covers documents, images and A/V; the server reads a far larger one, so an
|
|
594
|
+
`accept` naming a MIME pattern (`audio/*`) for some exotic extension the kit does not list
|
|
595
|
+
drops a file the server would have stored — write that `accept` as an EXTENSION (`.dwg`), which
|
|
596
|
+
never consults the table, or add the entry. Separately,
|
|
593
597
|
some clipboards carry only a file *reference* (a path/URL, no bytes): there is nothing to
|
|
594
598
|
upload and no API that can go read it, so those pastes cannot work by any means — dragging the
|
|
595
599
|
file is the path that always does.
|
|
@@ -141,7 +141,7 @@ function HomNayRow({ nv, selected, onPress }: { nv: NhanVien; selected: boolean;
|
|
|
141
141
|
>
|
|
142
142
|
<TableCell>
|
|
143
143
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
|
|
144
|
-
<Avatar name={nv.ten} size=
|
|
144
|
+
<Avatar name={nv.ten} size="md" />
|
|
145
145
|
<View style={{ flex: 1, gap: 2, minWidth: 0 }}>
|
|
146
146
|
<Text weight="medium" numberOfLines={1}>{nv.ten}</Text>
|
|
147
147
|
<Text size="sm" color="zinc-500" numberOfLines={1}>{nv.chucVu}</Text>
|
|
@@ -207,7 +207,7 @@ function NhanVienWorkspace({ nv }: { nv: NhanVien }) {
|
|
|
207
207
|
<>
|
|
208
208
|
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 24, gap: 20 }}>
|
|
209
209
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
210
|
-
<Avatar name={nv.ten} size=
|
|
210
|
+
<Avatar name={nv.ten} size="lg" />
|
|
211
211
|
<View style={{ flex: 1, gap: 2 }}>
|
|
212
212
|
<Text size="sm" color="muted">{nv.chucVu}</Text>
|
|
213
213
|
</View>
|
|
@@ -257,7 +257,7 @@ function KhachPeek({ kh }: { kh: (typeof KHACH_DAN_DAU)[number] }) {
|
|
|
257
257
|
return (
|
|
258
258
|
<View style={{ gap: 10 }}>
|
|
259
259
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
|
|
260
|
-
<Avatar name={kh.ten} size=
|
|
260
|
+
<Avatar name={kh.ten} size="md" />
|
|
261
261
|
<View style={{ gap: 1, flex: 1 }}>
|
|
262
262
|
<Text size="sm" weight="semibold">{kh.ten}</Text>
|
|
263
263
|
<Text size="xs" color="muted">{kh.nganh}</Text>
|
|
@@ -500,7 +500,7 @@ export function TplDashboard() {
|
|
|
500
500
|
<View key={kh.ten}>
|
|
501
501
|
{i > 0 ? <Divider /> : null}
|
|
502
502
|
<View style={{ paddingHorizontal: 20, paddingVertical: 12, flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
503
|
-
<Avatar name={kh.ten} size=
|
|
503
|
+
<Avatar name={kh.ten} size="md" />
|
|
504
504
|
<View style={{ flex: 1, gap: 0, alignItems: "flex-start" }}>
|
|
505
505
|
<Peek accessibilityLabel={`Customer profile for ${kh.ten}`} content={<KhachPeek kh={kh} />}>
|
|
506
506
|
<Text size="sm" weight="medium" numberOfLines={1}>{kh.ten}</Text>
|
package/examples/tpl_shifts.tsx
CHANGED
|
@@ -283,7 +283,7 @@ export function TplShifts() {
|
|
|
283
283
|
onPress={() => setOpenSlot(slotId(su.day, su.shift))}
|
|
284
284
|
style={{ flex: 1, flexDirection: "row", alignItems: "center", gap: 12, minHeight: 52 }}
|
|
285
285
|
>
|
|
286
|
-
<Avatar name={su.member} size=
|
|
286
|
+
<Avatar name={su.member} size="md" />
|
|
287
287
|
<View style={{ flex: 1, gap: 0 }}>
|
|
288
288
|
<Text size="sm" numberOfLines={1}>{su.member}</Text>
|
|
289
289
|
<Text size="xs" color="muted">{slotLabel(su.day, su.shift)}</Text>
|
|
@@ -344,7 +344,7 @@ export function TplShifts() {
|
|
|
344
344
|
) : (
|
|
345
345
|
openAssigned.map((m) => (
|
|
346
346
|
<View key={m} style={{ flexDirection: "row", alignItems: "center", gap: 10, minHeight: 36 }}>
|
|
347
|
-
<Avatar name={m} size=
|
|
347
|
+
<Avatar name={m} size="md" />
|
|
348
348
|
<Text size="sm" style={{ flex: 1 }}>{m}</Text>
|
|
349
349
|
<Text size="xs" color="muted" tabular>{`${hoursOf(m)}h this week`}</Text>
|
|
350
350
|
<ActionMenu
|
|
@@ -362,7 +362,7 @@ export function TplShifts() {
|
|
|
362
362
|
const hoursAfter = hoursOf(su.member) + SHIFT_HOURS;
|
|
363
363
|
return (
|
|
364
364
|
<View key={su.id} style={{ flexDirection: "row", alignItems: "center", gap: 10, minHeight: 36 }}>
|
|
365
|
-
<Avatar name={su.member} size=
|
|
365
|
+
<Avatar name={su.member} size="md" />
|
|
366
366
|
<Text size="sm" style={{ flex: 1 }}>{su.member}</Text>
|
|
367
367
|
<Text size="xs" color={hoursAfter > WEEK_LIMIT ? "danger" : "muted"} tabular>{`→ ${hoursAfter}h`}</Text>
|
|
368
368
|
{hoursAfter > WEEK_LIMIT ? (
|
|
@@ -398,7 +398,7 @@ export function TplShifts() {
|
|
|
398
398
|
return (
|
|
399
399
|
<MenuListItem
|
|
400
400
|
key={m}
|
|
401
|
-
icon={<Avatar name={m} size=
|
|
401
|
+
icon={<Avatar name={m} size="md" />}
|
|
402
402
|
title={m}
|
|
403
403
|
description={
|
|
404
404
|
after > WEEK_LIMIT
|
|
@@ -322,7 +322,7 @@ 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
|
-
...MEMBERS.map((m) => ({ key: m.id, header: <MemberChip name={m.name} size=
|
|
325
|
+
...MEMBERS.map((m) => ({ key: m.id, header: <MemberChip name={m.name} size="sm" />, items: pool.filter((t) => t.ownerId === m.id) })),
|
|
326
326
|
{ key: "none", header: <Text size="sm" color="muted">Unassigned</Text>, items: pool.filter((t) => !t.ownerId) },
|
|
327
327
|
];
|
|
328
328
|
} else {
|
|
@@ -348,11 +348,11 @@ export function TplTaskBoard() {
|
|
|
348
348
|
</FilterChip>
|
|
349
349
|
<FilterChip
|
|
350
350
|
label="Assignee"
|
|
351
|
-
summary={assigneeFilter.length > 0 ? <View style={styles.summary}>{assigneeFilter.slice(0, 4).map((id) => <Avatar key={id} name={memberName(id)} size=
|
|
351
|
+
summary={assigneeFilter.length > 0 ? <View style={styles.summary}>{assigneeFilter.slice(0, 4).map((id) => <Avatar key={id} name={memberName(id)} size="sm" />)}</View> : undefined}
|
|
352
352
|
onClear={() => setAssigneeFilter([])}
|
|
353
353
|
clearLabel="Clear assignee filter"
|
|
354
354
|
>
|
|
355
|
-
<OptionList search={{ mode: "none" }} multi options={MEMBER_OPTS} value={assigneeFilter} onValueChange={setAssigneeFilter} renderOptionContent={(o) => <MemberChip name={o.label ?? memberName(o.value)} size=
|
|
355
|
+
<OptionList search={{ mode: "none" }} multi options={MEMBER_OPTS} value={assigneeFilter} onValueChange={setAssigneeFilter} renderOptionContent={(o) => <MemberChip name={o.label ?? memberName(o.value)} size="sm" />} />
|
|
356
356
|
</FilterChip>
|
|
357
357
|
<FilterChip
|
|
358
358
|
label="Tag"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "31.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./vite": {
|
|
@@ -257,6 +257,7 @@
|
|
|
257
257
|
"./text_utils": "./src/text_utils.ts",
|
|
258
258
|
"./column_filter": "./src/column_filter.tsx",
|
|
259
259
|
"./chip_group": "./src/chip_group.tsx",
|
|
260
|
+
"./avatar_size": "./src/avatar_size.ts",
|
|
260
261
|
"./diff_value": "./src/diff_value.tsx",
|
|
261
262
|
"./diff_mark": "./src/diff_mark.tsx",
|
|
262
263
|
"./use_change_set": "./src/use_change_set.ts"
|
package/src/avatar.tsx
CHANGED
|
@@ -4,9 +4,12 @@ import { View, StyleSheet, StyleProp, ViewStyle, ImageStyle } from "react-native
|
|
|
4
4
|
import { Text } from "./text";
|
|
5
5
|
import { colors } from "./colors";
|
|
6
6
|
import { useLoticsLocale } from "./locale";
|
|
7
|
+
import { AVATAR_PX, AVATAR_TEXT, avatarInitials, type AvatarSize } from "./avatar_size";
|
|
7
8
|
|
|
8
9
|
interface AvatarProps {
|
|
9
|
-
|
|
10
|
+
/** A rung on the shared avatar scale — never a pixel count. See
|
|
11
|
+
* [`avatar_size`](./avatar_size.ts) for why the number was removed. */
|
|
12
|
+
size?: AvatarSize;
|
|
10
13
|
source?: ImageSource;
|
|
11
14
|
name?: string;
|
|
12
15
|
style?: StyleProp<ViewStyle | ImageStyle>;
|
|
@@ -22,8 +25,9 @@ interface AvatarProps {
|
|
|
22
25
|
|
|
23
26
|
export function Avatar(props: AvatarProps) {
|
|
24
27
|
const locale = useLoticsLocale();
|
|
25
|
-
const { source, size =
|
|
28
|
+
const { source, size = "md", name = locale.avatar.unknown, style, contentFit, announce } = props;
|
|
26
29
|
const decorative = !announce;
|
|
30
|
+
const px = AVATAR_PX[size];
|
|
27
31
|
|
|
28
32
|
if (!source || !source.uri) {
|
|
29
33
|
return (
|
|
@@ -35,7 +39,7 @@ export function Avatar(props: AvatarProps) {
|
|
|
35
39
|
aria-hidden={decorative || undefined}
|
|
36
40
|
style={[
|
|
37
41
|
styles.base,
|
|
38
|
-
{ backgroundColor: colors.blue["600"], width:
|
|
42
|
+
{ backgroundColor: colors.blue["600"], width: px, height: px },
|
|
39
43
|
style,
|
|
40
44
|
]}
|
|
41
45
|
>
|
|
@@ -43,14 +47,14 @@ export function Avatar(props: AvatarProps) {
|
|
|
43
47
|
on the container so the SR does not read "HM" in addition. */}
|
|
44
48
|
<Text
|
|
45
49
|
userSelect="none"
|
|
46
|
-
size=
|
|
50
|
+
size={AVATAR_TEXT[size]}
|
|
47
51
|
weight="medium"
|
|
48
52
|
color="inverted"
|
|
49
53
|
accessibilityElementsHidden
|
|
50
54
|
importantForAccessibility="no-hide-descendants"
|
|
51
55
|
aria-hidden
|
|
52
56
|
>
|
|
53
|
-
{
|
|
57
|
+
{avatarInitials(name, size)}
|
|
54
58
|
</Text>
|
|
55
59
|
</View>
|
|
56
60
|
);
|
|
@@ -61,7 +65,7 @@ export function Avatar(props: AvatarProps) {
|
|
|
61
65
|
alt={decorative ? "" : name}
|
|
62
66
|
accessibilityElementsHidden={decorative}
|
|
63
67
|
importantForAccessibility={decorative ? "no-hide-descendants" : undefined}
|
|
64
|
-
style={[styles.base, { width:
|
|
68
|
+
style={[styles.base, { width: px, height: px }, style as ImageStyle]}
|
|
65
69
|
source={source}
|
|
66
70
|
contentFit={contentFit}
|
|
67
71
|
cachePolicy="memory-disk"
|
|
@@ -69,23 +73,6 @@ export function Avatar(props: AvatarProps) {
|
|
|
69
73
|
);
|
|
70
74
|
}
|
|
71
75
|
|
|
72
|
-
function getInitials(name: string, size?: number): string {
|
|
73
|
-
let initials = 2;
|
|
74
|
-
|
|
75
|
-
if (size && size <= 32) {
|
|
76
|
-
initials = 1;
|
|
77
|
-
|
|
78
|
-
if (name.length <= 2) {
|
|
79
|
-
return name;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
return name
|
|
84
|
-
.split(" ")
|
|
85
|
-
.map((c) => c.charAt(0).toUpperCase())
|
|
86
|
-
.slice(0, initials)
|
|
87
|
-
.join("");
|
|
88
|
-
}
|
|
89
76
|
|
|
90
77
|
const styles = StyleSheet.create({
|
|
91
78
|
base: {
|
package/src/avatar.web.tsx
CHANGED
|
@@ -3,9 +3,11 @@ import { Image, View, StyleSheet, StyleProp, ViewStyle, ImageStyle } from "react
|
|
|
3
3
|
import { Text } from "./text";
|
|
4
4
|
import { colors } from "./colors";
|
|
5
5
|
import { useLoticsLocale } from "./locale";
|
|
6
|
+
import { AVATAR_PX, AVATAR_TEXT, avatarInitials, type AvatarSize } from "./avatar_size";
|
|
6
7
|
|
|
7
8
|
interface AvatarProps {
|
|
8
|
-
|
|
9
|
+
/** A rung on the shared avatar scale — never a pixel count. */
|
|
10
|
+
size?: AvatarSize;
|
|
9
11
|
source?: ImageSource;
|
|
10
12
|
name?: string;
|
|
11
13
|
style?: StyleProp<ViewStyle | ImageStyle>;
|
|
@@ -30,8 +32,9 @@ interface AvatarProps {
|
|
|
30
32
|
*/
|
|
31
33
|
export function Avatar(props: AvatarProps) {
|
|
32
34
|
const locale = useLoticsLocale();
|
|
33
|
-
const { source, size =
|
|
35
|
+
const { source, size = "md", name = locale.avatar.unknown, style, contentFit, announce } = props;
|
|
34
36
|
const decorative = !announce;
|
|
37
|
+
const px = AVATAR_PX[size];
|
|
35
38
|
|
|
36
39
|
if (!source || !source.uri) {
|
|
37
40
|
return (
|
|
@@ -43,7 +46,7 @@ export function Avatar(props: AvatarProps) {
|
|
|
43
46
|
aria-hidden={decorative || undefined}
|
|
44
47
|
style={[
|
|
45
48
|
styles.base,
|
|
46
|
-
{ backgroundColor: colors.blue["600"], width:
|
|
49
|
+
{ backgroundColor: colors.blue["600"], width: px, height: px },
|
|
47
50
|
style,
|
|
48
51
|
]}
|
|
49
52
|
>
|
|
@@ -51,14 +54,14 @@ export function Avatar(props: AvatarProps) {
|
|
|
51
54
|
on the container so the SR does not read "HM" in addition. */}
|
|
52
55
|
<Text
|
|
53
56
|
userSelect="none"
|
|
54
|
-
size=
|
|
57
|
+
size={AVATAR_TEXT[size]}
|
|
55
58
|
weight="medium"
|
|
56
59
|
color="inverted"
|
|
57
60
|
accessibilityElementsHidden
|
|
58
61
|
importantForAccessibility="no-hide-descendants"
|
|
59
62
|
aria-hidden
|
|
60
63
|
>
|
|
61
|
-
{
|
|
64
|
+
{avatarInitials(name, size)}
|
|
62
65
|
</Text>
|
|
63
66
|
</View>
|
|
64
67
|
);
|
|
@@ -69,30 +72,13 @@ export function Avatar(props: AvatarProps) {
|
|
|
69
72
|
accessibilityLabel={decorative ? undefined : name}
|
|
70
73
|
accessibilityElementsHidden={decorative}
|
|
71
74
|
importantForAccessibility={decorative ? "no-hide-descendants" : undefined}
|
|
72
|
-
style={[styles.base, { width:
|
|
75
|
+
style={[styles.base, { width: px, height: px }, style as ImageStyle]}
|
|
73
76
|
source={{ uri: source.uri }}
|
|
74
77
|
resizeMode={contentFit === "contain" ? "contain" : "cover"}
|
|
75
78
|
/>
|
|
76
79
|
);
|
|
77
80
|
}
|
|
78
81
|
|
|
79
|
-
function getInitials(name: string, size?: number): string {
|
|
80
|
-
let initials = 2;
|
|
81
|
-
|
|
82
|
-
if (size && size <= 32) {
|
|
83
|
-
initials = 1;
|
|
84
|
-
|
|
85
|
-
if (name.length <= 2) {
|
|
86
|
-
return name;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
return name
|
|
91
|
-
.split(" ")
|
|
92
|
-
.map((c) => c.charAt(0).toUpperCase())
|
|
93
|
-
.slice(0, initials)
|
|
94
|
-
.join("");
|
|
95
|
-
}
|
|
96
82
|
|
|
97
83
|
const styles = StyleSheet.create({
|
|
98
84
|
base: {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { TextSize } from "./text";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ONE scale for every avatar in the product — `Avatar`, `MemberChip`,
|
|
5
|
+
* `GroupAvatar`.
|
|
6
|
+
*
|
|
7
|
+
* It replaces a free `size?: number`, and the reason is not tidiness. Across the
|
|
8
|
+
* product and the apps that number had been given ELEVEN different values for the
|
|
9
|
+
* same concept (18, 20, 22, 24, 28, 32, 36, 40, 48, 64, 80), which is what made a
|
|
10
|
+
* person look different depending on which screen you met them on. A closed scale
|
|
11
|
+
* cannot drift that way, and it lets the initials rule below key off a rung the
|
|
12
|
+
* component actually knows rather than an arbitrary pixel count.
|
|
13
|
+
*/
|
|
14
|
+
export type AvatarSize = "sm" | "md" | "lg" | "xl";
|
|
15
|
+
|
|
16
|
+
export const AVATAR_PX: Record<AvatarSize, number> = {
|
|
17
|
+
/** Dense rows and inline chips — one initial, because two do not fit. */
|
|
18
|
+
sm: 24,
|
|
19
|
+
/** The default: registers, lists, anywhere a person is a row. */
|
|
20
|
+
md: 36,
|
|
21
|
+
/** Prominent rows, the account menu. */
|
|
22
|
+
lg: 48,
|
|
23
|
+
/** A profile's own header. */
|
|
24
|
+
xl: 72,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The initials' type size, so a large avatar does not wear tiny letters — the
|
|
29
|
+
* old fixed `xs` made an 80px circle look empty.
|
|
30
|
+
*
|
|
31
|
+
* Measured in the gallery on web (a `TextSize` resolves to different pixels on
|
|
32
|
+
* native, and `text.css` moves again at 768px, so these are the desktop numbers
|
|
33
|
+
* and only a render can give them):
|
|
34
|
+
*
|
|
35
|
+
* sm 12 on 24 = 50% · md 14 on 36 = 39%
|
|
36
|
+
* lg 20 on 48 = 42% · xl 32 on 72 = 44%
|
|
37
|
+
*
|
|
38
|
+
* The two-letter rungs sit within 6 points of each other, so an avatar reads the
|
|
39
|
+
* same at every size rather than growing emptier as it grows — `lg` was on `md`
|
|
40
|
+
* type (16 on 48 = 33%) and was visibly the thin one. `sm` runs richer by design:
|
|
41
|
+
* one letter in a small circle needs the weight.
|
|
42
|
+
*/
|
|
43
|
+
export const AVATAR_TEXT: Record<AvatarSize, TextSize> = {
|
|
44
|
+
sm: "xs", // 12 on 24 — one letter, so it carries a larger share of the circle
|
|
45
|
+
md: "sm", // 14 on 36
|
|
46
|
+
lg: "lg", // 18 on 48
|
|
47
|
+
xl: "xxl", // 28 on 72
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const words = (name: string): string[] => name.trim().split(/\s+/).filter((w) => w !== "");
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The initials shown when a member has no photo.
|
|
54
|
+
*
|
|
55
|
+
* TWO LETTERS, AND THEY ARE THE FIRST AND **LAST** WORDS — not the first two.
|
|
56
|
+
* A Vietnamese name is họ + đệm + tên, so the first two words are the family name
|
|
57
|
+
* and a middle name shared by half the office: "Vũ Thị Lan" and "Vũ Thị Ngọc" both
|
|
58
|
+
* reduce to "VT". Taking the first and last gives VL and VN. Measured on a real
|
|
59
|
+
* 29-person workspace, first-two-words leaves 11 people indistinguishable and
|
|
60
|
+
* first-and-last leaves 5. It is also what English display initials have always
|
|
61
|
+
* been — "Mary Jane Watson" is MW — so this is a correction, not a localization.
|
|
62
|
+
*
|
|
63
|
+
* `sm` gets ONE letter: two do not fit legibly at 24px, which is the whole reason
|
|
64
|
+
* the rung exists. Every larger rung gets two — where the old rule keyed off a
|
|
65
|
+
* `<= 32px` threshold that every call site in both apps happened to sit under, so
|
|
66
|
+
* in practice the product only ever showed a single letter.
|
|
67
|
+
*/
|
|
68
|
+
export function avatarInitials(name: string, size: AvatarSize): string {
|
|
69
|
+
const parts = words(name);
|
|
70
|
+
if (parts.length === 0) return "";
|
|
71
|
+
const first = parts[0];
|
|
72
|
+
if (size === "sm") return first.charAt(0).toUpperCase();
|
|
73
|
+
// A single-word name gives up its second letter rather than a lone initial
|
|
74
|
+
// floating in a 36px circle — "Nga" reads better as "NG" than as "N".
|
|
75
|
+
if (parts.length === 1) return first.slice(0, 2).toUpperCase();
|
|
76
|
+
return (first.charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
|
|
77
|
+
}
|
package/src/group_avatar.tsx
CHANGED
|
@@ -1,32 +1,41 @@
|
|
|
1
1
|
import { StyleSheet, View } from "react-native";
|
|
2
2
|
import { Text } from "@lotics/ui/text";
|
|
3
3
|
import { colors } from "@lotics/ui/colors";
|
|
4
|
+
import { AVATAR_PX, AVATAR_TEXT, avatarInitials, type AvatarSize } from "@lotics/ui/avatar_size";
|
|
4
5
|
|
|
5
6
|
export interface GroupAvatarProps {
|
|
6
7
|
name: string;
|
|
7
|
-
|
|
8
|
+
/** A rung on the shared avatar scale. Default `md`. */
|
|
9
|
+
size?: AvatarSize;
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
|
-
* Renders
|
|
12
|
-
*
|
|
13
|
+
* Renders a name's initials in a styled rounded square. Use for entities
|
|
14
|
+
* without an image (groups, organizations, tags).
|
|
15
|
+
*
|
|
16
|
+
* Shares `Avatar`'s scale AND its initials rule, so a member and a group
|
|
17
|
+
* standing next to each other in one list — a permission list, an approver
|
|
18
|
+
* picker, a search result — read as the same kind of thing. A group left on a
|
|
19
|
+
* single letter beside a person showing two was the tell that the two had
|
|
20
|
+
* drifted apart.
|
|
13
21
|
*/
|
|
14
22
|
export function GroupAvatar(props: GroupAvatarProps) {
|
|
15
|
-
const { name, size =
|
|
23
|
+
const { name, size = "md" } = props;
|
|
24
|
+
const px = AVATAR_PX[size];
|
|
16
25
|
|
|
17
26
|
return (
|
|
18
27
|
<View
|
|
19
28
|
style={[
|
|
20
29
|
styles.container,
|
|
21
30
|
{
|
|
22
|
-
width:
|
|
23
|
-
height:
|
|
24
|
-
borderRadius:
|
|
31
|
+
width: px,
|
|
32
|
+
height: px,
|
|
33
|
+
borderRadius: px * 0.2,
|
|
25
34
|
},
|
|
26
35
|
]}
|
|
27
36
|
>
|
|
28
|
-
<Text size=
|
|
29
|
-
{name
|
|
37
|
+
<Text size={AVATAR_TEXT[size]} weight="medium">
|
|
38
|
+
{avatarInitials(name, size)}
|
|
30
39
|
</Text>
|
|
31
40
|
</View>
|
|
32
41
|
);
|
|
@@ -4,6 +4,7 @@ import type { InlineEditVariant } from "./inline_edit";
|
|
|
4
4
|
import { InlineSelect } from "./inline_select";
|
|
5
5
|
import { MemberChip } from "./member_chip";
|
|
6
6
|
import { Avatar } from "./avatar";
|
|
7
|
+
import { AVATAR_PX, type AvatarSize } from "./avatar_size";
|
|
7
8
|
import { Icon } from "./icon";
|
|
8
9
|
import { colors } from "./colors";
|
|
9
10
|
import type { MemberSelectMember } from "./member_select";
|
|
@@ -42,7 +43,11 @@ interface InlineMemberSelectProps {
|
|
|
42
43
|
avatarOnly?: boolean;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
|
|
46
|
+
// The avatar-only variant sits in a dense inline cell — `sm`. The placeholder
|
|
47
|
+
// box below takes its pixels from the SAME rung, so the empty slot can never
|
|
48
|
+
// stop matching the avatar that fills it.
|
|
49
|
+
const AVATAR_ONLY: AvatarSize = "sm";
|
|
50
|
+
const AVATAR_ONLY_SIZE = AVATAR_PX[AVATAR_ONLY];
|
|
46
51
|
|
|
47
52
|
/**
|
|
48
53
|
* Edit a `select_member` field in place: the assigned member shows as a
|
|
@@ -79,7 +84,7 @@ export function InlineMemberSelect(props: InlineMemberSelectProps) {
|
|
|
79
84
|
(option: PickerOption<string>) => {
|
|
80
85
|
const member = byId.get(option.value);
|
|
81
86
|
if (!member) return null;
|
|
82
|
-
return <Avatar size={
|
|
87
|
+
return <Avatar size={AVATAR_ONLY} name={member.name || member.email || member.id} source={member.image ? { uri: member.image } : undefined} announce />;
|
|
83
88
|
},
|
|
84
89
|
[byId],
|
|
85
90
|
);
|
package/src/member_chip.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import React from "react";
|
|
|
2
2
|
import { View, StyleSheet, StyleProp, ViewStyle } from "react-native";
|
|
3
3
|
import { Avatar } from "./avatar";
|
|
4
4
|
import { Text } from "./text";
|
|
5
|
+
import type { AvatarSize } from "./avatar_size";
|
|
5
6
|
|
|
6
7
|
interface MemberChipProps {
|
|
7
8
|
/** Display name. Empty/blank falls back to a neutral label — pass
|
|
@@ -11,8 +12,8 @@ interface MemberChipProps {
|
|
|
11
12
|
image?: string | null;
|
|
12
13
|
/** Optional secondary line under the name — e.g. email, role, department. */
|
|
13
14
|
secondary?: string | null;
|
|
14
|
-
/**
|
|
15
|
-
size?:
|
|
15
|
+
/** A rung on the shared avatar scale. Default `md`. */
|
|
16
|
+
size?: AvatarSize;
|
|
16
17
|
style?: StyleProp<ViewStyle>;
|
|
17
18
|
}
|
|
18
19
|
|
|
@@ -26,7 +27,7 @@ interface MemberChipProps {
|
|
|
26
27
|
* `select_member` cell joined against that roster); this component fetches
|
|
27
28
|
* nothing and carries no domain types.
|
|
28
29
|
*/
|
|
29
|
-
export function MemberChip({ name, image, secondary, size =
|
|
30
|
+
export function MemberChip({ name, image, secondary, size = "md", style }: MemberChipProps) {
|
|
30
31
|
const displayName = name?.trim() || "Unknown";
|
|
31
32
|
return (
|
|
32
33
|
<View style={[styles.row, style]}>
|
package/src/mime.ts
CHANGED
|
@@ -73,7 +73,10 @@ const GENERIC_MIME_TYPES: ReadonlySet<string> = new Set([
|
|
|
73
73
|
]);
|
|
74
74
|
|
|
75
75
|
/**
|
|
76
|
-
* Extension → MIME, for the document and
|
|
76
|
+
* Extension → MIME, for the document, image and A/V formats an intake surface
|
|
77
|
+
* names. Values are taken verbatim from the server`s table (`mime-types`, via
|
|
78
|
+
* `@lotics/shared/mime_resolution`) — `mime_parity.test.ts` pins them, and an
|
|
79
|
+
* intake that knows LESS than the server silently drops a storable file.
|
|
77
80
|
* A `Map`, not an object literal, so a file named `x.constructor` looks up a
|
|
78
81
|
* miss rather than `Object.prototype`'s member.
|
|
79
82
|
*/
|
|
@@ -101,6 +104,21 @@ const MIME_BY_EXTENSION: ReadonlyMap<string, string> = new Map([
|
|
|
101
104
|
["xls", "application/vnd.ms-excel"],
|
|
102
105
|
["docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
|
|
103
106
|
["doc", "application/msword"],
|
|
107
|
+
// A/V: an `audio/*` or `video/*` intake resolves a pasted recording only if
|
|
108
|
+
// its extension is here — the extension-pattern form (`.m4a`) never needed it.
|
|
109
|
+
["m4a", "audio/mp4"],
|
|
110
|
+
["mp3", "audio/mpeg"],
|
|
111
|
+
["wav", "audio/wave"],
|
|
112
|
+
["ogg", "audio/ogg"],
|
|
113
|
+
["opus", "audio/ogg"],
|
|
114
|
+
["flac", "audio/x-flac"],
|
|
115
|
+
["aac", "audio/x-aac"],
|
|
116
|
+
["mp4", "video/mp4"],
|
|
117
|
+
["m4v", "video/x-m4v"],
|
|
118
|
+
["mov", "video/quicktime"],
|
|
119
|
+
["webm", "video/webm"],
|
|
120
|
+
["avi", "video/x-msvideo"],
|
|
121
|
+
["mkv", "video/x-matroska"],
|
|
104
122
|
]);
|
|
105
123
|
|
|
106
124
|
/** What a filename's extension says the file is, or `""` when it says nothing. */
|
package/src/table.tsx
CHANGED
|
@@ -35,6 +35,30 @@ export interface TableColumn extends TableFitColumn {
|
|
|
35
35
|
sortable?: boolean;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* How cells sit on the row's CROSS axis.
|
|
40
|
+
*
|
|
41
|
+
* `center` (the default) — every cell centred in the row. Right for a register
|
|
42
|
+
* whose cells are one line each, which is most of them, and the reason this is
|
|
43
|
+
* not being changed underneath anyone.
|
|
44
|
+
*
|
|
45
|
+
* `top` — cells share a top edge, so the FIRST LINE of every cell lands on one
|
|
46
|
+
* baseline. Reach for it the moment cells carry different numbers of lines: an
|
|
47
|
+
* identity cell with a supporting line (2), a status cell with a bar over a
|
|
48
|
+
* label (3), a member chip (1) and a wrapped cell (2) centred together put four
|
|
49
|
+
* first lines at four different heights, and the eye steps down and back up
|
|
50
|
+
* across a single row. Measured on a live register: a 12px spread.
|
|
51
|
+
*
|
|
52
|
+
* It governs the CELLS only. The ordinal, the leading checkbox and the trailing
|
|
53
|
+
* ⋯ stay centred in the row — they are chrome, they are one line by
|
|
54
|
+
* construction, and pinning them to the top of a tall row would strand them.
|
|
55
|
+
*
|
|
56
|
+
* It applies to EVERY column row — pressable and read-only alike — because a
|
|
57
|
+
* table that mixed the two would otherwise align its own rows differently from
|
|
58
|
+
* each other. A STACKED row (tier 3) is unaffected: it has no columns to line up.
|
|
59
|
+
*/
|
|
60
|
+
export type TableAlign = "center" | "top";
|
|
61
|
+
|
|
38
62
|
interface TableCtx {
|
|
39
63
|
columns: TableColumn[];
|
|
40
64
|
leading: number;
|
|
@@ -43,6 +67,7 @@ interface TableCtx {
|
|
|
43
67
|
trailing: number;
|
|
44
68
|
visibleKeys: ReadonlySet<string>;
|
|
45
69
|
stacked: boolean;
|
|
70
|
+
align: TableAlign;
|
|
46
71
|
}
|
|
47
72
|
const TableContext = createContext<TableCtx | null>(null);
|
|
48
73
|
|
|
@@ -123,6 +148,9 @@ export interface TableProps {
|
|
|
123
148
|
count?: number;
|
|
124
149
|
/** Reserve a trailing gutter (px) for rows that render a `trailing` slot (a ⋯ / button). */
|
|
125
150
|
trailing?: number;
|
|
151
|
+
/** How cells sit on the row's cross axis — see {@link TableAlign}. Default
|
|
152
|
+
* `center`; pass `top` when cells carry different numbers of lines. */
|
|
153
|
+
align?: TableAlign;
|
|
126
154
|
/** The `TableRow`s. */
|
|
127
155
|
children: ReactNode;
|
|
128
156
|
}
|
|
@@ -142,7 +170,7 @@ export interface TableProps {
|
|
|
142
170
|
* keeps its current order). See `computeTableFit`.
|
|
143
171
|
*/
|
|
144
172
|
export function Table(props: TableProps) {
|
|
145
|
-
const { columns, sort, onSort, sortLabels, selectAll, leading = 0, count, trailing = 0, children } = props;
|
|
173
|
+
const { columns, sort, onSort, sortLabels, selectAll, leading = 0, count, trailing = 0, align = "center", children } = props;
|
|
146
174
|
const ordinal = count != null ? ORDINAL_W : 0;
|
|
147
175
|
const rows = Children.toArray(children).filter(isValidElement);
|
|
148
176
|
|
|
@@ -160,7 +188,7 @@ export function Table(props: TableProps) {
|
|
|
160
188
|
const visibleColumns = columns.filter((c) => fit.visibleKeys.has(c.key));
|
|
161
189
|
|
|
162
190
|
return (
|
|
163
|
-
<TableContext.Provider value={{ columns, leading, ordinal, trailing, visibleKeys: fit.visibleKeys, stacked: fit.stacked }}>
|
|
191
|
+
<TableContext.Provider value={{ columns, leading, ordinal, trailing, visibleKeys: fit.visibleKeys, stacked: fit.stacked, align }}>
|
|
164
192
|
{/* ONE layout node: without this wrapper the header band + body land as two
|
|
165
193
|
direct flex children of the app's container, and a parent column `gap`
|
|
166
194
|
(the standard section spacing) opens a hole between the header and rows. */}
|
|
@@ -312,6 +340,12 @@ export function TableRow(props: TableRowProps) {
|
|
|
312
340
|
// no cell, and column 0 never drops in register mode either.)
|
|
313
341
|
.map(({ cell, column }, i) => cloneElement(cell, { _column: column, _stacked: ctx.stacked, _primary: i === 0 }));
|
|
314
342
|
|
|
343
|
+
// ONE style for the column row, shared by the pressable and read-only paths
|
|
344
|
+
// below. They rendered near-identical Views and `align` was added to only one,
|
|
345
|
+
// so a table mixing the two aligned its own rows differently — computing it
|
|
346
|
+
// once is what makes that divergence impossible rather than merely fixed.
|
|
347
|
+
const cellsStyle = [styles.cells, { minHeight }, ctx.align === "top" ? styles.cellsTop : null];
|
|
348
|
+
|
|
315
349
|
const body = ctx.stacked ? (
|
|
316
350
|
<View style={styles.stackedBody}>
|
|
317
351
|
<View style={styles.stackedTopLine}>
|
|
@@ -346,7 +380,7 @@ export function TableRow(props: TableRowProps) {
|
|
|
346
380
|
return (
|
|
347
381
|
<View style={styles.staticRow}>
|
|
348
382
|
<LeadGutter ordinal={ordinal}>{leading}</LeadGutter>
|
|
349
|
-
<View style={
|
|
383
|
+
<View style={cellsStyle}>{cells}</View>
|
|
350
384
|
{ctx.trailing > 0 ? (
|
|
351
385
|
<View style={[styles.trailingSlot, { width: ctx.trailing }]}>
|
|
352
386
|
{action}
|
|
@@ -368,7 +402,7 @@ export function TableRow(props: TableRowProps) {
|
|
|
368
402
|
body
|
|
369
403
|
) : (
|
|
370
404
|
<>
|
|
371
|
-
<View style={
|
|
405
|
+
<View style={cellsStyle}>{cells}</View>
|
|
372
406
|
{ctx.trailing > 0 ? (
|
|
373
407
|
<View style={[styles.slot, styles.trailingSlot, { width: ctx.trailing }]}>
|
|
374
408
|
{action}
|
|
@@ -495,6 +529,11 @@ const styles = StyleSheet.create({
|
|
|
495
529
|
gap: COLUMN_GAP,
|
|
496
530
|
zIndex: 1,
|
|
497
531
|
},
|
|
532
|
+
// `align="top"` — see `TableAlign`. Overrides the centring above so unequal
|
|
533
|
+
// cells share a first-line baseline; the row's own chrome stays centred.
|
|
534
|
+
cellsTop: {
|
|
535
|
+
alignItems: "flex-start",
|
|
536
|
+
},
|
|
498
537
|
// Leading/trailing gutters carry their own controls — lifted above the door
|
|
499
538
|
// like the cells.
|
|
500
539
|
slot: {
|