@helloimjolopez-pathway/pathway-components 0.9.1 → 0.10.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.
@@ -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;