@helloimjolopez-pathway/pathway-components 0.9.1 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/avatar/avatar.jsx +167 -0
- package/components/badge/badge.jsx +5 -5
- package/dist/pathway-components.js +36 -36
- package/package.json +2 -1
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Avatar: a person's or a household's photo, initials or icon. Avatar Group:
|
|
3
|
+
* several people in one spot, up to three overlapping, then +N.
|
|
4
|
+
*
|
|
5
|
+
* Figma `Avatar`, set 68:659, and `Avatar Group`, set 9704:74872, page
|
|
6
|
+
* "↳ Avatar" (5926:18314). Spec: components/avatar/avatar-spec.md.
|
|
7
|
+
*
|
|
8
|
+
* Props mirror the Figma properties (CLAUDE.md §10 rule 9):
|
|
9
|
+
* Size size XXXS 16, XXS 20, XS 24, S 32, M 40, L 56, XL 96.
|
|
10
|
+
* One pixel size per name for every content type.
|
|
11
|
+
* Content content "Image" | "Text" | "Icon". Optional: when omitted
|
|
12
|
+
* it follows the data, src, else text, else icon.
|
|
13
|
+
* An Image whose photo fails to load falls back the
|
|
14
|
+
* same way (Radix Avatar's Fallback model).
|
|
15
|
+
* Shape shape "Circle" for people, "Square" for households and
|
|
16
|
+
* things. Square radius grows with the size.
|
|
17
|
+
* Style avatarStyle "Fill" (brand tint) | "Stroke" (outline, dense
|
|
18
|
+
* lists). Image is Fill only.
|
|
19
|
+
* Text text the initials, at most two letters.
|
|
20
|
+
* Icon icon a Material Symbols Rounded ligature, FILL 1.
|
|
21
|
+
* "person", or "home" for a household.
|
|
22
|
+
* Show Badge showBadge the ringed status dot. badgeStatus picks Positive
|
|
23
|
+
* (online) or Attention (away). Always say the
|
|
24
|
+
* status in words too; the dot is never announced.
|
|
25
|
+
*
|
|
26
|
+
* ACCESSIBILITY. Not interactive and never focusable. Pass `name` when the
|
|
27
|
+
* Avatar stands alone: it becomes role img with that name. When the name is
|
|
28
|
+
* shown beside it, leave `name` out and the Avatar is hidden from screen
|
|
29
|
+
* readers, so the name is not read twice.
|
|
30
|
+
*/
|
|
31
|
+
import React, { useEffect, useState } from "react";
|
|
32
|
+
|
|
33
|
+
const C = (n) => `var(--semantic-color-${n})`;
|
|
34
|
+
const U = (n) => `var(--semantic-layout-units-${n})`;
|
|
35
|
+
const Y = (n) => `var(--semantic-type-${n})`;
|
|
36
|
+
|
|
37
|
+
export const AVATAR_SIZES = ["XXXS", "XXS", "XS", "S", "M", "L", "XL"];
|
|
38
|
+
export const AVATAR_CONTENTS = ["Image", "Text", "Icon"];
|
|
39
|
+
export const AVATAR_SHAPES = ["Circle", "Square"];
|
|
40
|
+
export const AVATAR_STYLES = ["Fill", "Stroke"];
|
|
41
|
+
export const AVATAR_BADGE_STATUS = ["Positive", "Attention"];
|
|
42
|
+
export const AVATAR_GROUP_SIZES = ["XS", "S", "M"];
|
|
43
|
+
|
|
44
|
+
/** Per size: the box, initials type, square radius, icon and badge. Sizes are measurements, not tokens. */
|
|
45
|
+
export const SIZES = {
|
|
46
|
+
XXXS: { px: 16, fontSize: Y("font-size-xxxs"), lineHeight: Y("line-height-xxxs-single"), radius: U("cornerradius-small"), icon: 10, badge: 6 },
|
|
47
|
+
XXS: { px: 20, fontSize: Y("font-size-xxxs"), lineHeight: Y("line-height-xxxs-single"), radius: U("cornerradius-small"), icon: 12, badge: 7 },
|
|
48
|
+
XS: { px: 24, fontSize: Y("font-size-xxs"), lineHeight: Y("line-height-xxs-single"), radius: U("cornerradius-small"), icon: 14, badge: 8 },
|
|
49
|
+
S: { px: 32, fontSize: Y("font-size-s"), lineHeight: Y("line-height-s-single"), radius: U("cornerradius-small"), icon: 20, badge: 10 },
|
|
50
|
+
M: { px: 40, fontSize: Y("font-size-r"), lineHeight: Y("line-height-r-single"), radius: U("cornerradius-base"), icon: 24, badge: 12 },
|
|
51
|
+
L: { px: 56, fontSize: Y("font-size-l"), lineHeight: Y("line-height-l-single"), radius: U("cornerradius-large"), icon: 34, badge: 16 },
|
|
52
|
+
XL: { px: 96, fontSize: Y("font-size-xxl"), lineHeight: Y("line-height-xxl-single"), radius: U("cornerradius-xlarge"), icon: 58, badge: 28 },
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Colours and widths, all semantic tokens. */
|
|
56
|
+
export const T = {
|
|
57
|
+
fill: C("fill-static-brand-subtle"), // Text and Icon, Style Fill
|
|
58
|
+
foreground: C("foreground-static-brand-on-subtle"), // initials and icon
|
|
59
|
+
imageFill: C("fill-static-neutral-faint"), // behind the photo
|
|
60
|
+
stroke: C("stroke-static-neutral-base"), // Style Stroke
|
|
61
|
+
strokeWidth: U("borderwidth-thin"),
|
|
62
|
+
circle: U("cornerradius-full"),
|
|
63
|
+
badgeRing: C("stroke-static-neutral-mono"),
|
|
64
|
+
badgeRingW: U("borderwidth-base"),
|
|
65
|
+
badge: { Positive: C("fill-static-positive-strong"), Attention: C("fill-static-attention-strong") },
|
|
66
|
+
groupRing: C("fill-surface-elevated"), // Avatar Group, separates overlapping avatars
|
|
67
|
+
groupRingW: U("borderwidth-thick"),
|
|
68
|
+
family: Y("family-brand"),
|
|
69
|
+
weight: Y("weight-semibold"),
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** First letters of the first and last name, at most two. */
|
|
73
|
+
export function initialsFrom(name = "") {
|
|
74
|
+
const parts = String(name).trim().split(/\s+/).filter(Boolean);
|
|
75
|
+
if (!parts.length) return "";
|
|
76
|
+
const first = parts[0][0] || "";
|
|
77
|
+
const last = parts.length > 1 ? parts[parts.length - 1][0] : "";
|
|
78
|
+
return (first + last).toUpperCase();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function Avatar({
|
|
82
|
+
size = "M",
|
|
83
|
+
content,
|
|
84
|
+
shape = "Circle",
|
|
85
|
+
avatarStyle = "Fill",
|
|
86
|
+
src,
|
|
87
|
+
text,
|
|
88
|
+
icon = "person",
|
|
89
|
+
name,
|
|
90
|
+
showBadge = false,
|
|
91
|
+
badgeStatus = "Positive",
|
|
92
|
+
style,
|
|
93
|
+
...rest
|
|
94
|
+
}) {
|
|
95
|
+
const z = SIZES[size] || SIZES.M;
|
|
96
|
+
const [failed, setFailed] = useState(false);
|
|
97
|
+
useEffect(() => setFailed(false), [src]);
|
|
98
|
+
const letters = (text || "").slice(0, 2);
|
|
99
|
+
const resolved = content === "Icon" ? "Icon"
|
|
100
|
+
: content === "Text" ? (letters ? "Text" : "Icon")
|
|
101
|
+
: src && !failed ? "Image"
|
|
102
|
+
: letters ? "Text" : "Icon";
|
|
103
|
+
const look = resolved === "Image" ? "Fill" : avatarStyle;
|
|
104
|
+
const radius = shape === "Square" ? z.radius : T.circle;
|
|
105
|
+
const root = {
|
|
106
|
+
position: "relative", flex: "none", boxSizing: "border-box",
|
|
107
|
+
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
|
108
|
+
width: z.px, height: z.px, borderRadius: radius,
|
|
109
|
+
background: resolved === "Image" ? T.imageFill : look === "Stroke" ? "transparent" : T.fill,
|
|
110
|
+
boxShadow: look === "Stroke" ? `inset 0 0 0 ${T.strokeWidth} ${T.stroke}` : "none",
|
|
111
|
+
color: T.foreground, fontFamily: T.family, ...style,
|
|
112
|
+
};
|
|
113
|
+
const a11y = name ? { role: "img", "aria-label": name } : { "aria-hidden": true };
|
|
114
|
+
return (
|
|
115
|
+
<span data-pw-avatar="" data-size={size} data-content={resolved} style={root} {...a11y} {...rest}>
|
|
116
|
+
{resolved === "Image" && (
|
|
117
|
+
<img src={src} alt="" onError={() => setFailed(true)}
|
|
118
|
+
style={{ width: "100%", height: "100%", objectFit: "cover", display: "block", borderRadius: radius }} />
|
|
119
|
+
)}
|
|
120
|
+
{resolved === "Text" && (
|
|
121
|
+
<span aria-hidden="true" style={{ fontSize: z.fontSize, lineHeight: z.lineHeight, fontWeight: T.weight, letterSpacing: 0, whiteSpace: "nowrap" }}>{letters}</span>
|
|
122
|
+
)}
|
|
123
|
+
{resolved === "Icon" && (
|
|
124
|
+
<span className="material-symbols-rounded" aria-hidden="true"
|
|
125
|
+
style={{ fontSize: z.icon, width: z.icon, height: z.icon, fontVariationSettings: "'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24",
|
|
126
|
+
lineHeight: 1 /* pathway-allow: the icon box is the glyph's em, sized by table 3.5 */ }}>{icon}</span>
|
|
127
|
+
)}
|
|
128
|
+
{showBadge && (
|
|
129
|
+
<span aria-hidden="true" data-pw-avatar-badge="" style={{
|
|
130
|
+
position: "absolute", right: 0, bottom: 0, boxSizing: "border-box", width: z.badge, height: z.badge,
|
|
131
|
+
borderRadius: T.circle, background: T.badge[badgeStatus] || T.badge.Positive,
|
|
132
|
+
boxShadow: `inset 0 0 0 ${T.badgeRingW} ${T.badgeRing}`,
|
|
133
|
+
}} />
|
|
134
|
+
)}
|
|
135
|
+
</span>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* AvatarGroup: up to `max` avatars (3), overlapping by a quarter of their size,
|
|
141
|
+
* each ringed in the surface colour, then a +N count of the rest. One role img
|
|
142
|
+
* named with the people shown and how many more.
|
|
143
|
+
*
|
|
144
|
+
* Figma: Size (XS, S, M) maps to size; Show Avatar 3 and Show more follow from
|
|
145
|
+
* how many people there are and `showMore`.
|
|
146
|
+
*/
|
|
147
|
+
export function AvatarGroup({ people = [], size = "XS", max = 3, showMore = true, label, style, ...rest }) {
|
|
148
|
+
const z = SIZES[size] || SIZES.XS;
|
|
149
|
+
const shown = people.slice(0, max);
|
|
150
|
+
const more = people.length - shown.length;
|
|
151
|
+
const named = shown.map((p) => p.name).filter(Boolean);
|
|
152
|
+
const aria = label || (named.join(", ") + (more > 0 ? `, and ${more} more` : ""));
|
|
153
|
+
const ring = { boxShadow: `0 0 0 ${T.groupRingW} ${T.groupRing}` };
|
|
154
|
+
return (
|
|
155
|
+
<span data-pw-avatar-group="" role="img" aria-label={aria} style={{ display: "inline-flex", alignItems: "center", ...style }} {...rest}>
|
|
156
|
+
{shown.map((p, i) => (
|
|
157
|
+
<Avatar key={(p.name || "") + i} size={size} src={p.src} text={p.text || initialsFrom(p.name)} icon={p.icon}
|
|
158
|
+
style={{ ...ring, marginLeft: i ? -z.px / 4 : 0 }} />
|
|
159
|
+
))}
|
|
160
|
+
{showMore && more > 0 && (
|
|
161
|
+
<Avatar size={size} content="Text" text={`+${more}`.slice(0, 3)} style={{ ...ring, marginLeft: shown.length ? -z.px / 4 : 0 }} />
|
|
162
|
+
)}
|
|
163
|
+
</span>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export default Avatar;
|
|
@@ -16,14 +16,14 @@
|
|
|
16
16
|
* Outline & Fill stroke v1 Subtle, Figma Strong (Neutral Bold, accents plain).
|
|
17
17
|
* Neutral Naked text v1 Neutral/Bold, Figma Neutral/Base.
|
|
18
18
|
* Stroke placement Inside the badge, which keeps its size in every style
|
|
19
|
-
* (Small 20, Medium
|
|
19
|
+
* (Small 20, Medium 22, Large 24). An inset box-shadow
|
|
20
20
|
* draws it without changing the box.
|
|
21
21
|
* Figma's default leading icon is the placeholder `error`; in code the icon is
|
|
22
22
|
* passed explicitly, so existing call sites render exactly as before.
|
|
23
23
|
*
|
|
24
24
|
* DISMISSIBLE and INTERACTIVE, every size, with or without icons:
|
|
25
25
|
* outer frame invisible, min width and height Accessibility/Touch Target/AA
|
|
26
|
-
* (44). The
|
|
26
|
+
* (44). The badge keeps its size (20 / 22 / 24) inside it.
|
|
27
27
|
* Focus Ring wraps Container.Main, padding FocusRing/Offset, ring outside
|
|
28
28
|
* when Focused.
|
|
29
29
|
* Container.Main the badge: fill, stroke inside, CornerRadius/Large;
|
|
@@ -101,8 +101,8 @@ const ACTION = (s) => {
|
|
|
101
101
|
|
|
102
102
|
export const BADGE_SIZE_SPEC = {
|
|
103
103
|
Small: { fontSize: "font-size-xs", lineHeight: "line-height-xs-single", icon: 11, ico: 12, contentMax: 200, height: 20 },
|
|
104
|
-
Medium: { fontSize: "font-size-xs", lineHeight: "line-height-xs-single", icon: 12, ico: 14, contentMax: 196 },
|
|
105
|
-
Large: { fontSize: "font-size-s", lineHeight: "line-height-xs-single", icon: 14, ico: 16, contentMax: 192 },
|
|
104
|
+
Medium: { fontSize: "font-size-xs", lineHeight: "line-height-xs-single", icon: 12, ico: 14, contentMax: 196, height: 22 },
|
|
105
|
+
Large: { fontSize: "font-size-s", lineHeight: "line-height-xs-single", icon: 14, ico: 16, contentMax: 192, height: 24 },
|
|
106
106
|
};
|
|
107
107
|
|
|
108
108
|
/** Layout constants. Static keeps v1's asymmetric padding; the other types use T.pill / T.text. */
|
|
@@ -147,7 +147,7 @@ const strokeShadow = (stroke) => (stroke ? `inset 0 0 0 ${L.border} ${stroke}` :
|
|
|
147
147
|
const focusRing = (focused) => (focused
|
|
148
148
|
? { outline: `${T.ringWidth} solid ${T.ring}`, outlineOffset: T.ringOffset }
|
|
149
149
|
: { outline: "none" });
|
|
150
|
-
/*
|
|
150
|
+
/* Every size has a fixed height (Small 20, Medium 22, Large 24) with its text centred, so none needs top and bottom padding. */
|
|
151
151
|
const fixedHeight = (spec) => (spec.height ? { height: spec.height, paddingTop: 0, paddingBottom: 0 } : {});
|
|
152
152
|
const transition = `background ${T.duration} ${T.easing}, color ${T.duration} ${T.easing}, box-shadow ${T.duration} ${T.easing}`;
|
|
153
153
|
|