@lotics/ui 30.1.0 → 32.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 +62 -0
- package/docs/catalog.md +23 -6
- 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/table.tsx +7 -2
package/MIGRATION.md
CHANGED
|
@@ -4,6 +4,68 @@ 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
|
+
## 32.0.0 — `Table`'s `align` prop is gone; rows are centred
|
|
8
|
+
|
|
9
|
+
`align?: "center" | "top"` and the `TableAlign` type are removed. `center` was the
|
|
10
|
+
default and nothing shipped on `top`, so a caller passing nothing is unaffected.
|
|
11
|
+
|
|
12
|
+
**Why it went, so nobody rebuilds it.** It was added to close a measured 12px spread
|
|
13
|
+
between the first lines of cells carrying different numbers of lines. That number was
|
|
14
|
+
never compared against a baseline: the kit's own canonical register (`tpl_item_list`)
|
|
15
|
+
ships a **15px** spread, centred, and reads correctly. The case the prop existed for
|
|
16
|
+
is the case the reference example already handles.
|
|
17
|
+
|
|
18
|
+
It was also wrong as built. `top` moved the CELLS to a shared top edge but left the
|
|
19
|
+
row's ordinal, leading checkbox and trailing menu centred — so on a tall row a row's
|
|
20
|
+
number sat ~24px below the name it belonged to, and the register read as scatter.
|
|
21
|
+
Aligning the chrome too is possible, but that yields a correct prop with no consumers.
|
|
22
|
+
|
|
23
|
+
**If a register looks misaligned, the cell is too tall for a columnar row.** Centring
|
|
24
|
+
works because register cells are bounded; when they cannot be, the row is meant to
|
|
25
|
+
stack — `computeTableFit` already drops to stacked below the register floor. Reach for
|
|
26
|
+
that, or move the content to a record page. Do not reintroduce a cross-axis knob.
|
|
27
|
+
|
|
28
|
+
## 31.0.0 — avatar `size` is a rung, not a pixel count
|
|
29
|
+
|
|
30
|
+
`Avatar`, `MemberChip` and `GroupAvatar` no longer take `size?: number`. They take
|
|
31
|
+
`size?: AvatarSize` — `"sm" | "md" | "lg" | "xl"` = **24 / 36 / 48 / 72** — defaulting to `md`.
|
|
32
|
+
|
|
33
|
+
Map an EXPLICIT size by the density it was reaching for, not by nearest pixel:
|
|
34
|
+
|
|
35
|
+
| was | now |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| ≤ 24 (18, 20, 22, 24) | `"sm"` |
|
|
38
|
+
| 28, 32, 36 | `"md"` |
|
|
39
|
+
| 40, 48 | `"lg"` |
|
|
40
|
+
| 64, 80 | `"xl"` |
|
|
41
|
+
|
|
42
|
+
**The DEFAULT is now `md` (36) on all three components**, which is a separate move from that
|
|
43
|
+
table and goes in different directions: `Avatar` grows (32 → 36), `MemberChip` grows (28 → 36),
|
|
44
|
+
and **`GroupAvatar` shrinks (40 → 36)**. If a `GroupAvatar` was relying on its default to sit at
|
|
45
|
+
40 beside something else, pass `"lg"`. One default is the point — a member and a group in one
|
|
46
|
+
list had no business being different sizes.
|
|
47
|
+
|
|
48
|
+
Need an avatar's pixels for a sibling box — a stack's overlap, a dashed placeholder? Import
|
|
49
|
+
`AVATAR_PX` from `@lotics/ui/avatar_size` and index it by the rung, so the two cannot drift.
|
|
50
|
+
|
|
51
|
+
**Three rendering changes come with it, and none is opt-in.**
|
|
52
|
+
|
|
53
|
+
Initials are now the first and **last** word of the name rather than the first two: "Vũ Thị
|
|
54
|
+
Lan" renders VL where it rendered VT. A Vietnamese name is họ + đệm + tên, so the first two
|
|
55
|
+
words are the parts a whole office shares — on a real 29-person roster the old rule left 11
|
|
56
|
+
people indistinguishable and this one leaves 5. Two-word names are unaffected ("John Smith" is
|
|
57
|
+
JS either way), and it matches the conventional English rendering ("Mary Jane Watson" → MW).
|
|
58
|
+
`GroupAvatar` follows the same rule now: it showed one letter at every size and its neighbours
|
|
59
|
+
in the same list showed two.
|
|
60
|
+
|
|
61
|
+
The one-initial cutoff moved from "≤ 32px" to "`sm` only". Anything you map to `md` or above
|
|
62
|
+
now shows two letters where it showed one — which is the point: every call site in the
|
|
63
|
+
register apps sat under the old threshold, so the product only ever rendered a single letter.
|
|
64
|
+
If a surface genuinely wants one letter, that surface is `sm`.
|
|
65
|
+
|
|
66
|
+
The initials' TYPE SIZE now follows the rung instead of being a fixed `xs`. Most visible on the
|
|
67
|
+
large rungs: an avatar that was 80px wore 12px letters and looked empty; at `xl` it wears 28px.
|
|
68
|
+
|
|
7
69
|
## 30.0.0 — a washed row bleeds; content sits on the container's edge
|
|
8
70
|
|
|
9
71
|
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.
|
|
@@ -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": "32.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/table.tsx
CHANGED
|
@@ -35,6 +35,7 @@ export interface TableColumn extends TableFitColumn {
|
|
|
35
35
|
sortable?: boolean;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
|
|
38
39
|
interface TableCtx {
|
|
39
40
|
columns: TableColumn[];
|
|
40
41
|
leading: number;
|
|
@@ -312,6 +313,10 @@ export function TableRow(props: TableRowProps) {
|
|
|
312
313
|
// no cell, and column 0 never drops in register mode either.)
|
|
313
314
|
.map(({ cell, column }, i) => cloneElement(cell, { _column: column, _stacked: ctx.stacked, _primary: i === 0 }));
|
|
314
315
|
|
|
316
|
+
// ONE style for the column row, shared by the pressable and read-only paths
|
|
317
|
+
// below, which render near-identical Views.
|
|
318
|
+
const cellsStyle = [styles.cells, { minHeight }];
|
|
319
|
+
|
|
315
320
|
const body = ctx.stacked ? (
|
|
316
321
|
<View style={styles.stackedBody}>
|
|
317
322
|
<View style={styles.stackedTopLine}>
|
|
@@ -346,7 +351,7 @@ export function TableRow(props: TableRowProps) {
|
|
|
346
351
|
return (
|
|
347
352
|
<View style={styles.staticRow}>
|
|
348
353
|
<LeadGutter ordinal={ordinal}>{leading}</LeadGutter>
|
|
349
|
-
<View style={
|
|
354
|
+
<View style={cellsStyle}>{cells}</View>
|
|
350
355
|
{ctx.trailing > 0 ? (
|
|
351
356
|
<View style={[styles.trailingSlot, { width: ctx.trailing }]}>
|
|
352
357
|
{action}
|
|
@@ -368,7 +373,7 @@ export function TableRow(props: TableRowProps) {
|
|
|
368
373
|
body
|
|
369
374
|
) : (
|
|
370
375
|
<>
|
|
371
|
-
<View style={
|
|
376
|
+
<View style={cellsStyle}>{cells}</View>
|
|
372
377
|
{ctx.trailing > 0 ? (
|
|
373
378
|
<View style={[styles.slot, styles.trailingSlot, { width: ctx.trailing }]}>
|
|
374
379
|
{action}
|