@podoba/react 0.0.24 → 0.0.25

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@podoba/react",
3
- "version": "0.0.24",
3
+ "version": "0.0.25",
4
4
  "type": "module",
5
5
  "description": "podoba React components — React Aria Components + Tailwind primitives + layout, built with uic.",
6
6
  "repository": {
@@ -25,8 +25,8 @@
25
25
  "typecheck": "tsc --build"
26
26
  },
27
27
  "dependencies": {
28
- "@podoba/tokens": "^0.0.24",
29
- "@podoba/tailwind": "^0.0.24",
28
+ "@podoba/tokens": "^0.0.25",
29
+ "@podoba/tailwind": "^0.0.25",
30
30
  "react-aria-components": "1.18.0",
31
31
  "class-variance-authority": "0.7.1",
32
32
  "clsx": "2.1.1",
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Avatar + AvatarGroup — user avatars (port of gs `GSTeamAvatarGroup`, extended with
3
+ * image support). A single `Avatar` shows the user's photo, falling back to initials
4
+ * on the brand surface. It is inline-friendly (`inline-flex align-middle`) so it drops
5
+ * straight into a line of text — "Agáta Zapotilová <Avatar/> created project". Overlap a
6
+ * set with `AvatarGroup` (trailing "+N" overflow, optional dashed add affordance).
7
+ *
8
+ * Presentational only (hard rule #1): names/urls arrive via props.
9
+ */
10
+ export type AvatarSize = 'xs' | 'sm' | 'md' | 'lg'
11
+
12
+ const SIZE: Record<AvatarSize, string> = {
13
+ xs: 'h-5 w-5 text-micro',
14
+ sm: 'h-6 w-6 text-caption',
15
+ md: 'h-7 w-7 text-caption',
16
+ lg: 'h-9 w-9 text-compact',
17
+ }
18
+
19
+ /** First + last initial (single word → first two letters). */
20
+ function initials(name: string): string {
21
+ const parts = name.trim().split(/\s+/).filter(Boolean)
22
+ const first = parts[0] ?? ''
23
+ if (parts.length === 0) return '?'
24
+ if (parts.length === 1) return first.slice(0, 2).toUpperCase()
25
+ const last = parts[parts.length - 1] ?? ''
26
+ return ((first[0] ?? '') + (last[0] ?? '')).toUpperCase()
27
+ }
28
+
29
+ export interface AvatarProps {
30
+ /** Person's name — drives the initials fallback + the tooltip/alt text. */
31
+ name: string
32
+ /** Avatar image URL. Falls back to initials when omitted. */
33
+ src?: string
34
+ size?: AvatarSize
35
+ /** On a dark surface → light ring so avatars separate cleanly. */
36
+ onDark?: boolean
37
+ /** Ring/border around the avatar (default true — needed when they overlap). */
38
+ ring?: boolean
39
+ className?: string
40
+ }
41
+
42
+ export function Avatar({ name, src, size = 'md', onDark = false, ring = true, className }: AvatarProps) {
43
+ const ringClass = ring ? `border-2 ${onDark ? 'border-surface-inverted' : 'border-surface'}` : ''
44
+ const base = `${SIZE[size]} ${ringClass} inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full align-middle`
45
+ if (src) {
46
+ return <img src={src} alt={name} title={name} className={[base, 'object-cover', className].filter(Boolean).join(' ')} />
47
+ }
48
+ return (
49
+ <span title={name} className={[base, 'bg-brand-primary font-medium text-fg-inverted', className].filter(Boolean).join(' ')}>
50
+ {initials(name)}
51
+ </span>
52
+ )
53
+ }
54
+
55
+ export interface AvatarPerson {
56
+ name: string
57
+ src?: string
58
+ }
59
+
60
+ export interface AvatarGroupProps {
61
+ /** People to show (with optional image). Prefer this over `names`. */
62
+ people?: AvatarPerson[]
63
+ /** Names-only convenience (initials avatars). */
64
+ names?: string[]
65
+ max?: number
66
+ size?: AvatarSize
67
+ onDark?: boolean
68
+ /** Trailing dashed "+" add affordance. */
69
+ showAdd?: boolean
70
+ }
71
+
72
+ export function AvatarGroup({ people, names, max = 3, size = 'md', onDark = false, showAdd = false }: AvatarGroupProps) {
73
+ const all: AvatarPerson[] = people ?? (names ?? []).map((name) => ({ name }))
74
+ const visible = all.slice(0, max)
75
+ const overflow = all.length - visible.length
76
+ const chip = `${SIZE[size]} border-2 ${onDark ? 'border-surface-inverted' : 'border-surface'} -ml-1.5 inline-flex items-center justify-center rounded-full`
77
+ return (
78
+ <div className="flex items-center">
79
+ {visible.map((p, i) => (
80
+ <Avatar key={`${p.name}-${i}`} name={p.name} src={p.src} size={size} onDark={onDark} className="-ml-1.5 first:ml-0" />
81
+ ))}
82
+ {overflow > 0 ? <span className={`${chip} bg-surface-muted font-medium text-fg-muted`}>+{overflow}</span> : null}
83
+ {showAdd ? (
84
+ <span className={`${chip} border-dashed bg-transparent ${onDark ? 'text-white/70' : 'text-fg-muted'}`}>+</span>
85
+ ) : null}
86
+ </div>
87
+ )
88
+ }
@@ -152,6 +152,10 @@ function TileBands({
152
152
  )
153
153
  }
154
154
 
155
+ // Footer defaults to 14px muted (gs supporting text); self-styled footer content
156
+ // (links, buttons) overrides both size and colour.
157
+ const footerTone = theme === 'dark' ? 'text-white/50' : theme === 'light' ? 'text-fg-muted' : 'text-fg/70'
158
+
155
159
  return (
156
160
  <>
157
161
  {eyebrow != null || badge != null ? (
@@ -161,7 +165,7 @@ function TileBands({
161
165
  </div>
162
166
  ) : null}
163
167
  {content}
164
- {footer != null ? <div className="min-w-0">{footer}</div> : null}
168
+ {footer != null ? <div className={`min-w-0 text-small leading-5 ${footerTone}`}>{footer}</div> : null}
165
169
  </>
166
170
  )
167
171
  }
package/src/index.ts CHANGED
@@ -51,6 +51,7 @@ export * from "./layout/topbar";
51
51
  export * from "./layout/persistent-page-shell";
52
52
 
53
53
  // --- product patterns (label-driven; no domain coupling) ---
54
+ export * from "./components/avatar";
54
55
  export * from "./components/brand-page-header";
55
56
  export * from "./components/count-up";
56
57
  export * from "./components/cta-pill";