@podoba/react 0.0.24 → 0.0.26

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.26",
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.26",
29
+ "@podoba/tailwind": "^0.0.26",
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
+ }
@@ -94,8 +94,8 @@ export function BrandPageHeader({
94
94
  .filter(Boolean)
95
95
  .join(' ')}
96
96
  >
97
- <div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
98
- <div className="flex min-w-0 flex-col gap-1">
97
+ <div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:gap-4">
98
+ <div className="flex min-w-0 flex-1 flex-col gap-1">
99
99
  {breadcrumbs && breadcrumbs.length > 0 ? (
100
100
  <nav aria-label="Breadcrumb" className="flex flex-wrap items-center gap-1 text-compact text-fg-muted">
101
101
  {breadcrumbs.map((crumb, i) => (
@@ -133,7 +133,8 @@ export function BrandPageHeader({
133
133
  </div>
134
134
 
135
135
  {cta ? (
136
- <div className="w-full shrink-0 sm:w-auto sm:min-w-[360px]">{cta}</div>
136
+ // Hero CTA spans 4 of 12 columns (one third) — the greeting takes the rest.
137
+ <div className="w-full shrink-0 sm:w-1/3">{cta}</div>
137
138
  ) : ctaLabel ? (
138
139
  <div className="shrink-0">
139
140
  {hasExpandable ? (
@@ -26,8 +26,8 @@ export interface CtaPillProps {
26
26
 
27
27
  export function CtaPill({ lead, emphasis, tail, children }: CtaPillProps) {
28
28
  return (
29
- <div className="flex min-w-[560px] items-center justify-between gap-4 rounded-lg bg-brand-secondary px-6 py-4">
30
- <p className="text-heading4 font-medium leading-[22px] tracking-normal text-fg">
29
+ <div className="flex w-full items-center justify-between gap-4 rounded-lg bg-brand-secondary px-6 py-4">
30
+ <p className="min-w-0 text-heading4 font-medium leading-[22px] tracking-normal text-fg">
31
31
  {lead} <span className="font-semibold text-white">{emphasis}</span> {tail}
32
32
  </p>
33
33
  <div className="shrink-0">{children}</div>
@@ -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";