@spunto/design-system 0.21.0 → 0.22.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.
package/README.md CHANGED
@@ -56,6 +56,20 @@ const nextConfig = { transpilePackages: ["@spunto/design-system"] }
56
56
  `Separator`, `Spinner`, `Skeleton`, `Avatar` (+`AvatarImage`/`AvatarFallback`),
57
57
  `Kbd` — one keyboard cap, or a whole shortcut (`keys="mod+k"` → ⌘ K, Ctrl K off Apple),
58
58
  spelled out for screen readers.
59
+ - _AvatarGroup_ — the stack of overlapping avatars. `size` (`xs`…`xl`) sizes the whole
60
+ stack at once — on `Avatar` it sets the circle **and** its initials, because a 64 px
61
+ avatar with 14 px initials looks broken. **Compositional**: the group never reads a
62
+ `{ name, src }` shape, it wraps whatever you hand it (an `Avatar`, one inside a
63
+ `Tooltip`, a round `Skeleton` while the list loads) in its own span, so a loading
64
+ stack has exactly the geometry of the loaded one. It owns only what a caller can't
65
+ get right by hand: the overlap (a ratio of the diameter, `spacing` = ~40/29/20%), the
66
+ `ringClassName` that separates two faces (match the surface *behind* the stack —
67
+ `ring-card` inside a `Card`), the paint order, and the `+N`. Counting has two rules:
68
+ the counter adds the children `max` cut **and** the people never rendered
69
+ (`total` − children), so a paginated list shows `+38` without fabricating 38 ghost
70
+ avatars; and a `+1` never appears — one hidden child takes exactly the room its
71
+ counter would, saying less, so the face is drawn instead. `onOverflowClick` makes
72
+ the counter a real button.
59
73
  - _Forms_ — `Input`, `Textarea`, `Label`, `Switch`, `Checkbox`,
60
74
  `RadioGroup` (+`RadioGroupItem`), `Select` (+`SelectTrigger`/`SelectValue`/
61
75
  `SelectContent`/`SelectItem`/`SelectGroup`/`SelectGroupLabel`/`SelectSeparator`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spunto/design-system",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "Spunto's shared design system — warm/flame tokens, color constants, and UI primitives.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,18 +1,54 @@
1
1
  "use client"
2
2
 
3
+ import { Children, createContext, useContext, type ComponentProps, type ReactNode } from "react"
3
4
  import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
5
+ import { cva, type VariantProps } from "class-variance-authority"
4
6
 
5
7
  import { cn } from "../utils"
6
8
 
9
+ const avatarVariants = cva("relative flex shrink-0 overflow-hidden rounded-full bg-muted select-none", {
10
+ variants: {
11
+ // The font-size of each step lives here rather than on the fallback: the
12
+ // initials then *inherit* it, so one `size` sets the circle and its text
13
+ // together (a 64 px avatar with 14 px initials looks broken).
14
+ size: {
15
+ xs: "size-5 text-[0.5625rem]",
16
+ sm: "size-7 text-[0.6875rem]",
17
+ default: "size-9 text-sm",
18
+ lg: "size-12 text-base",
19
+ xl: "size-16 text-xl",
20
+ },
21
+ },
22
+ defaultVariants: {
23
+ size: "default",
24
+ },
25
+ })
26
+
27
+ export type AvatarSize = NonNullable<VariantProps<typeof avatarVariants>["size"]>
28
+
29
+ /**
30
+ * The size an `AvatarGroup` imposes on the avatars inside it, so a stack is
31
+ * sized once instead of on every child. `null` = no group above.
32
+ */
33
+ const AvatarGroupSizeContext = createContext<AvatarSize | null>(null)
34
+
35
+ export interface AvatarProps extends AvatarPrimitive.Root.Props {
36
+ /**
37
+ * Diameter of the circle (20 / 28 / 36 / 48 / 64 px) — and the size of the
38
+ * fallback initials with it. Inside an `AvatarGroup`, defaults to the group's.
39
+ * @default "default"
40
+ */
41
+ size?: AvatarSize
42
+ }
43
+
7
44
  /** Circular avatar container. Compose `AvatarImage` + `AvatarFallback` inside. */
8
- function Avatar({ className, ...props }: AvatarPrimitive.Root.Props) {
45
+ function Avatar({ className, size, ...props }: AvatarProps) {
46
+ const groupSize = useContext(AvatarGroupSizeContext)
47
+
9
48
  return (
10
49
  <AvatarPrimitive.Root
11
50
  data-slot="avatar"
12
- className={cn(
13
- "relative flex size-9 shrink-0 overflow-hidden rounded-full bg-muted select-none",
14
- className
15
- )}
51
+ className={cn(avatarVariants({ size: size ?? groupSize ?? undefined }), className)}
16
52
  {...props}
17
53
  />
18
54
  )
@@ -29,7 +65,8 @@ function AvatarFallback({ className, ...props }: AvatarPrimitive.Fallback.Props)
29
65
  <AvatarPrimitive.Fallback
30
66
  data-slot="avatar-fallback"
31
67
  className={cn(
32
- "flex size-full items-center justify-center bg-muted text-sm font-medium text-muted-foreground",
68
+ // No text-size here on purpose — it comes from the root's `size`.
69
+ "flex size-full items-center justify-center bg-muted font-medium text-muted-foreground",
33
70
  className
34
71
  )}
35
72
  {...props}
@@ -37,4 +74,169 @@ function AvatarFallback({ className, ...props }: AvatarPrimitive.Fallback.Props)
37
74
  )
38
75
  }
39
76
 
40
- export { Avatar, AvatarImage, AvatarFallback }
77
+ export type AvatarGroupSpacing = "tight" | "default" | "loose"
78
+
79
+ // How far each avatar slides under the previous one. Expressed per size because
80
+ // an overlap is a *ratio* of the circle (~29% at default), and Tailwind needs
81
+ // whole class names — hence a table rather than a computed `-ml-${n}`.
82
+ const OVERLAP: Record<AvatarSize, Record<AvatarGroupSpacing, string>> = {
83
+ xs: { tight: "-ml-2", default: "-ml-1.5", loose: "-ml-1" },
84
+ sm: { tight: "-ml-3", default: "-ml-2", loose: "-ml-1.5" },
85
+ default: { tight: "-ml-3.5", default: "-ml-2.5", loose: "-ml-2" },
86
+ lg: { tight: "-ml-5", default: "-ml-3.5", loose: "-ml-2.5" },
87
+ xl: { tight: "-ml-6", default: "-ml-5", loose: "-ml-3.5" },
88
+ }
89
+
90
+ // The ring is what separates two overlapping circles — it has to grow with them.
91
+ const RING: Record<AvatarSize, string> = {
92
+ xs: "ring-[1.5px]",
93
+ sm: "ring-2",
94
+ default: "ring-2",
95
+ lg: "ring-2",
96
+ xl: "ring-[3px]",
97
+ }
98
+
99
+ export interface AvatarGroupProps extends Omit<ComponentProps<"div">, "children"> {
100
+ /** The avatars, in reading order. Each one paints over its left neighbour. */
101
+ children?: ReactNode
102
+ /**
103
+ * Size applied to every avatar in the stack (a child's own `size` still wins).
104
+ * @default "default"
105
+ */
106
+ size?: AvatarSize
107
+ /**
108
+ * Show at most this many avatars; the rest collapse into a `+N` counter.
109
+ * Left unset, everything is drawn.
110
+ */
111
+ max?: number
112
+ /**
113
+ * Real number of people, when the caller only renders a sample of them (a
114
+ * members list that fetched 5 avatars out of 40 → `total={40}` counts the 35
115
+ * it never received). Defaults to the number of children.
116
+ */
117
+ total?: number
118
+ /**
119
+ * How much of each avatar the next one covers — ~40%, ~29%, ~20%.
120
+ * @default "default"
121
+ */
122
+ spacing?: AvatarGroupSpacing
123
+ /**
124
+ * Ring color punched between overlapping avatars: it must match the surface
125
+ * *behind* the stack, so inside a `Card` it's `ring-card`.
126
+ * @default "ring-background"
127
+ */
128
+ ringClassName?: string
129
+ /**
130
+ * Hovering an avatar brings it to the front (and lifts it a hair), which is
131
+ * how a covered face becomes readable without a click.
132
+ * @default true
133
+ */
134
+ revealOnHover?: boolean
135
+ /** Makes the `+N` counter a real button — "see all members". */
136
+ onOverflowClick?: () => void
137
+ /** Accessible name of that button. @default "Voir tout" */
138
+ overflowLabel?: string
139
+ }
140
+
141
+ /**
142
+ * A stack of overlapping avatars — the compact way to say "these people".
143
+ *
144
+ * ```tsx
145
+ * <AvatarGroup size="sm" max={4} total={members.length}>
146
+ * {members.map((m) => (
147
+ * <Avatar key={m.id}>
148
+ * <AvatarImage src={m.avatarUrl} alt={m.name} />
149
+ * <AvatarFallback>{initials(m.name)}</AvatarFallback>
150
+ * </Avatar>
151
+ * ))}
152
+ * </AvatarGroup>
153
+ * ```
154
+ *
155
+ * Purely presentational, and deliberately **compositional**: the group never
156
+ * reads a `{ name, src }` shape, it wraps whatever you put in it — an `Avatar`,
157
+ * one inside a `Tooltip`, a `Skeleton` while the list loads. It only owns what
158
+ * a caller can't get right by hand: the overlap, the ring that separates two
159
+ * faces, the paint order, and the `+N`.
160
+ */
161
+ function AvatarGroup({
162
+ className,
163
+ children,
164
+ size = "default",
165
+ max,
166
+ total,
167
+ spacing = "default",
168
+ ringClassName = "ring-background",
169
+ revealOnHover = true,
170
+ onOverflowClick,
171
+ overflowLabel = "Voir tout",
172
+ ...props
173
+ }: AvatarGroupProps) {
174
+ const items = Children.toArray(children)
175
+ const limit = max != null && max > 0 ? max : items.length
176
+
177
+ let shown = items.slice(0, limit)
178
+ // People the caller knows about but didn't render at all.
179
+ const unrendered = Math.max(0, (total ?? items.length) - items.length)
180
+ let overflow = items.length - shown.length + unrendered
181
+
182
+ // A single hidden child costs exactly the room a "+1" would take, and says
183
+ // strictly less. Draw the face instead.
184
+ if (overflow === 1 && unrendered === 0) {
185
+ shown = items
186
+ overflow = 0
187
+ }
188
+
189
+ const item = (node: ReactNode, index: number) => (
190
+ <span
191
+ key={index}
192
+ data-slot="avatar-group-item"
193
+ className={cn(
194
+ "relative inline-flex rounded-full",
195
+ RING[size],
196
+ ringClassName,
197
+ index > 0 && OVERLAP[size][spacing],
198
+ revealOnHover && "transition-transform duration-150 hover:z-10 hover:-translate-y-0.5"
199
+ )}
200
+ >
201
+ {node}
202
+ </span>
203
+ )
204
+
205
+ const counter = `+${overflow}`
206
+ const counterClass = cn(
207
+ avatarVariants({ size }),
208
+ "items-center justify-center font-medium text-muted-foreground"
209
+ )
210
+
211
+ return (
212
+ <AvatarGroupSizeContext.Provider value={size}>
213
+ <div data-slot="avatar-group" className={cn("flex items-center", className)} {...props}>
214
+ {shown.map(item)}
215
+ {overflow > 0 &&
216
+ item(
217
+ onOverflowClick ? (
218
+ <button
219
+ type="button"
220
+ data-slot="avatar-group-overflow"
221
+ aria-label={overflowLabel}
222
+ onClick={onOverflowClick}
223
+ className={cn(
224
+ counterClass,
225
+ "cursor-pointer transition-colors hover:bg-primary/15 hover:text-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
226
+ )}
227
+ >
228
+ {counter}
229
+ </button>
230
+ ) : (
231
+ <span data-slot="avatar-group-overflow" className={counterClass}>
232
+ {counter}
233
+ </span>
234
+ ),
235
+ shown.length
236
+ )}
237
+ </div>
238
+ </AvatarGroupSizeContext.Provider>
239
+ )
240
+ }
241
+
242
+ export { Avatar, AvatarImage, AvatarFallback, AvatarGroup }
package/src/index.ts CHANGED
@@ -23,7 +23,11 @@ export { RadioGroup, RadioGroupItem } from "./components/radio-group"
23
23
  export { Separator } from "./components/separator"
24
24
  export { Skeleton } from "./components/skeleton"
25
25
  export { Spinner, spinnerVariants } from "./components/spinner"
26
- export { Avatar, AvatarImage, AvatarFallback } from "./components/avatar"
26
+ // Avatar + the stack of overlapping avatars (`AvatarGroup`) — compositional:
27
+ // the group wraps whatever you put in it and owns the overlap, the ring, the
28
+ // paint order and the `+N`.
29
+ export { Avatar, AvatarImage, AvatarFallback, AvatarGroup } from "./components/avatar"
30
+ export type { AvatarProps, AvatarSize, AvatarGroupProps, AvatarGroupSpacing } from "./components/avatar"
27
31
 
28
32
  // xterm.js surface — theme + fit-on-resize, transport-agnostic (feed it via
29
33
  // the write/writeln ref handle).