@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.
@@ -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 and Large 22). An inset box-shadow
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 pill keeps its size (20 / 22 / 22) inside it.
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
- /* Small has a fixed height (20) with its 12 text centred, so it needs no top and bottom padding. */
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