@open-mercato/ui 0.6.4-develop.4178.1.aad9ddaa95 → 0.6.4-develop.4210.1.d412061cfe

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.
Files changed (92) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +14 -0
  3. package/dist/backend/DataTable.js +28 -62
  4. package/dist/backend/DataTable.js.map +2 -2
  5. package/dist/backend/detail/AttachmentMetadataDialog.js +1 -1
  6. package/dist/backend/detail/AttachmentMetadataDialog.js.map +2 -2
  7. package/dist/primitives/activity-feed.js +196 -0
  8. package/dist/primitives/activity-feed.js.map +7 -0
  9. package/dist/primitives/avatar.js +140 -14
  10. package/dist/primitives/avatar.js.map +3 -3
  11. package/dist/primitives/badge.js +94 -5
  12. package/dist/primitives/badge.js.map +2 -2
  13. package/dist/primitives/button-group.js +52 -0
  14. package/dist/primitives/button-group.js.map +7 -0
  15. package/dist/primitives/color-picker.js +520 -0
  16. package/dist/primitives/color-picker.js.map +7 -0
  17. package/dist/primitives/command-menu.js +355 -0
  18. package/dist/primitives/command-menu.js.map +7 -0
  19. package/dist/primitives/dialog.js +210 -35
  20. package/dist/primitives/dialog.js.map +2 -2
  21. package/dist/primitives/drawer.js +219 -0
  22. package/dist/primitives/drawer.js.map +7 -0
  23. package/dist/primitives/notification-feed.js +213 -0
  24. package/dist/primitives/notification-feed.js.map +7 -0
  25. package/dist/primitives/notification.js +23 -2
  26. package/dist/primitives/notification.js.map +2 -2
  27. package/dist/primitives/pagination.js +245 -0
  28. package/dist/primitives/pagination.js.map +7 -0
  29. package/dist/primitives/progress.js +178 -5
  30. package/dist/primitives/progress.js.map +2 -2
  31. package/dist/primitives/rating.js +174 -0
  32. package/dist/primitives/rating.js.map +7 -0
  33. package/dist/primitives/scroll-area.js +82 -0
  34. package/dist/primitives/scroll-area.js.map +7 -0
  35. package/dist/primitives/segmented-control.js +101 -0
  36. package/dist/primitives/segmented-control.js.map +7 -0
  37. package/dist/primitives/separator.js +103 -4
  38. package/dist/primitives/separator.js.map +2 -2
  39. package/dist/primitives/slider.js +115 -0
  40. package/dist/primitives/slider.js.map +7 -0
  41. package/dist/primitives/step-indicator.js +187 -0
  42. package/dist/primitives/step-indicator.js.map +7 -0
  43. package/dist/primitives/table.js +102 -10
  44. package/dist/primitives/table.js.map +2 -2
  45. package/dist/primitives/tabs.js +122 -14
  46. package/dist/primitives/tabs.js.map +2 -2
  47. package/dist/primitives/time-picker.js +9 -11
  48. package/dist/primitives/time-picker.js.map +2 -2
  49. package/package.json +8 -4
  50. package/src/backend/DataTable.tsx +43 -60
  51. package/src/backend/detail/AttachmentMetadataDialog.tsx +1 -1
  52. package/src/primitives/__tests__/activity-feed.test.tsx +216 -0
  53. package/src/primitives/__tests__/avatar.test.tsx +131 -0
  54. package/src/primitives/__tests__/badge.test.tsx +189 -0
  55. package/src/primitives/__tests__/button-group.test.tsx +117 -0
  56. package/src/primitives/__tests__/color-picker.test.tsx +589 -0
  57. package/src/primitives/__tests__/command-menu.test.tsx +231 -0
  58. package/src/primitives/__tests__/dialog.test.tsx +290 -0
  59. package/src/primitives/__tests__/drawer.test.tsx +264 -0
  60. package/src/primitives/__tests__/notification-feed.test.tsx +303 -0
  61. package/src/primitives/__tests__/notification.test.tsx +102 -2
  62. package/src/primitives/__tests__/pagination.test.tsx +268 -0
  63. package/src/primitives/__tests__/progress.test.tsx +222 -0
  64. package/src/primitives/__tests__/rating.test.tsx +164 -0
  65. package/src/primitives/__tests__/scroll-area.test.tsx +109 -0
  66. package/src/primitives/__tests__/segmented-control.test.tsx +129 -0
  67. package/src/primitives/__tests__/separator.test.tsx +143 -0
  68. package/src/primitives/__tests__/slider.test.tsx +86 -0
  69. package/src/primitives/__tests__/step-indicator.test.tsx +205 -0
  70. package/src/primitives/__tests__/table.test.tsx +199 -0
  71. package/src/primitives/__tests__/tabs.test.tsx +139 -0
  72. package/src/primitives/activity-feed.tsx +326 -0
  73. package/src/primitives/avatar.tsx +225 -22
  74. package/src/primitives/badge.tsx +141 -6
  75. package/src/primitives/button-group.tsx +100 -0
  76. package/src/primitives/color-picker.tsx +693 -0
  77. package/src/primitives/command-menu.tsx +503 -0
  78. package/src/primitives/dialog.tsx +315 -51
  79. package/src/primitives/drawer.tsx +338 -0
  80. package/src/primitives/notification-feed.tsx +358 -0
  81. package/src/primitives/notification.tsx +42 -2
  82. package/src/primitives/pagination.tsx +376 -0
  83. package/src/primitives/progress.tsx +267 -6
  84. package/src/primitives/rating.tsx +245 -0
  85. package/src/primitives/scroll-area.tsx +169 -0
  86. package/src/primitives/segmented-control.tsx +162 -0
  87. package/src/primitives/separator.tsx +163 -3
  88. package/src/primitives/slider.tsx +160 -0
  89. package/src/primitives/step-indicator.tsx +287 -0
  90. package/src/primitives/table.tsx +142 -11
  91. package/src/primitives/tabs.tsx +174 -12
  92. package/src/primitives/time-picker.tsx +9 -11
@@ -1,7 +1,32 @@
1
+ "use client"
2
+
1
3
  import * as React from 'react'
2
4
  import { cva, type VariantProps } from 'class-variance-authority'
5
+
3
6
  import { cn } from '@open-mercato/shared/lib/utils'
4
7
 
8
+ /**
9
+ * User / entity avatar with auto-generated initials, optional photo,
10
+ * optional icon override, optional **status indicator** (online/away/
11
+ * busy/away dot per Figma `Bottom Status [1.1]`), optional **badge**
12
+ * slot for top-right affordances (verified check, premium icon, X to
13
+ * remove — per Figma `Top Status [1.1]`), and optional outer **ring**
14
+ * (accent / status-colored).
15
+ *
16
+ * Phase B.4 rewrite per Figma `Avatars` page (`210:4129`):
17
+ * - `Avatar [1.1]` (`245:18786`)
18
+ * - `Bottom Status [1.1]` (`245:18721`) — 5 dot tones
19
+ * - `Top Status [1.1]` (`245:18697`) — 6 icon-badge variants
20
+ * - `Avatar Group [1.1]` (`581:6198`) + `Compact Avatar Group [1.1]`
21
+ * (`2906:14962`) — see `AvatarStack`.
22
+ *
23
+ * Backward compatibility (5 import sites — customers linking
24
+ * adapters + LinkEntityDialog + ds-v5 demo): existing
25
+ * `<Avatar src label size variant icon />` API stays callable
26
+ * verbatim. New optional props (`status`, `statusPosition`, `ring`,
27
+ * `badge`, `badgeClassName`) are additive.
28
+ */
29
+
5
30
  const avatarVariants = cva(
6
31
  'inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full font-semibold select-none',
7
32
  {
@@ -37,38 +62,213 @@ function computeInitials(label: string): string {
37
62
  return (first + last).toUpperCase()
38
63
  }
39
64
 
65
+ // Status tone → background color class. Surface stays opaque + ringed
66
+ // by the page background (`ring-background`) so the badge reads as
67
+ // an overlay on top of the avatar circle.
68
+ export type AvatarStatus =
69
+ | 'online'
70
+ | 'offline'
71
+ | 'busy'
72
+ | 'away'
73
+ | 'success'
74
+ | 'warning'
75
+ | 'error'
76
+ | 'info'
77
+
78
+ const AVATAR_STATUS_BG: Record<AvatarStatus, string> = {
79
+ online: 'bg-status-success-icon',
80
+ offline: 'bg-muted-foreground',
81
+ busy: 'bg-status-error-icon',
82
+ away: 'bg-status-warning-icon',
83
+ success: 'bg-status-success-icon',
84
+ warning: 'bg-status-warning-icon',
85
+ error: 'bg-status-error-icon',
86
+ info: 'bg-status-info-icon',
87
+ }
88
+
89
+ // Status dot size per avatar size — slightly less than 1/3 of the
90
+ // avatar diameter so the dot reads but doesn't overpower.
91
+ const AVATAR_STATUS_SIZE: Record<NonNullable<VariantProps<typeof avatarVariants>['size']>, string> = {
92
+ xs: 'size-1.5',
93
+ sm: 'size-2',
94
+ md: 'size-2.5',
95
+ lg: 'size-3',
96
+ xl: 'size-4',
97
+ }
98
+
99
+ // Outer ring tones (story-style highlight, selected, status outline).
100
+ export type AvatarRingTone = 'accent' | 'success' | 'warning' | 'error' | 'muted'
101
+
102
+ const AVATAR_RING_CLASS: Record<AvatarRingTone, string> = {
103
+ accent: 'ring-2 ring-accent-indigo ring-offset-2 ring-offset-background',
104
+ success: 'ring-2 ring-status-success-icon ring-offset-2 ring-offset-background',
105
+ warning: 'ring-2 ring-status-warning-icon ring-offset-2 ring-offset-background',
106
+ error: 'ring-2 ring-status-error-icon ring-offset-2 ring-offset-background',
107
+ muted: 'ring-2 ring-input ring-offset-2 ring-offset-background',
108
+ }
109
+
40
110
  export type AvatarProps = {
41
111
  label: string
42
112
  src?: string | null
43
113
  icon?: React.ReactNode
44
114
  ariaLabel?: string
115
+ /** Bottom-right (default) or top-right status dot. */
116
+ status?: AvatarStatus
117
+ /** Where the status dot sits. @default 'bottom-right' */
118
+ statusPosition?: 'bottom-right' | 'top-right'
119
+ /** Outer ring affordance — pass `true` for accent (alias for
120
+ * `'accent'`) or a specific tone. */
121
+ ring?: boolean | AvatarRingTone
122
+ /** Custom top-right badge slot — overrides `status` when both are
123
+ * set with `statusPosition='top-right'`. Use for verified check
124
+ * icons, premium markers, "X to remove" affordances per Figma
125
+ * `Top Status [1.1]`. The badge content is rendered inside a
126
+ * `rounded-full ring-2 ring-background` shell sized relative to
127
+ * the avatar — pass a simple icon (`<Check className="size-3" />`)
128
+ * for the cleanest look. */
129
+ badge?: React.ReactNode
130
+ /** Override the badge wrapper className (size, bg, etc.). */
131
+ badgeClassName?: string
45
132
  } & VariantProps<typeof avatarVariants> &
46
133
  Omit<React.HTMLAttributes<HTMLDivElement>, 'role' | 'aria-label'>
47
134
 
48
- export const Avatar = React.forwardRef<HTMLDivElement, AvatarProps>(
49
- ({ className, label, src, icon, size, variant, ariaLabel, ...rest }, ref) => {
50
- const initials = React.useMemo(() => computeInitials(label), [label])
135
+ type AvatarCircleProps = Omit<
136
+ AvatarProps,
137
+ 'status' | 'statusPosition' | 'ring' | 'badge' | 'badgeClassName'
138
+ > & {
139
+ className?: string
140
+ }
141
+
142
+ // Inner div (the actual avatar circle). Implemented as forwardRef so
143
+ // the outer Avatar wrapper can forward refs through to the rendered
144
+ // `<div role="img">` regardless of whether it lives directly inside
145
+ // Avatar or inside the decorated `<span data-slot="avatar-root">`
146
+ // wrapper.
147
+ const AvatarCircle = React.forwardRef<HTMLDivElement, AvatarCircleProps>(function AvatarCircle(
148
+ { className, label, src, icon, size, variant, ariaLabel, ...rest },
149
+ ref,
150
+ ) {
151
+ const initials = React.useMemo(() => computeInitials(label), [label])
152
+ return (
153
+ <div
154
+ ref={ref}
155
+ role="img"
156
+ aria-label={ariaLabel ?? label}
157
+ data-slot="avatar"
158
+ className={cn(avatarVariants({ size, variant }), className)}
159
+ {...rest}
160
+ >
161
+ {src ? (
162
+ <img src={src} alt="" className="size-full object-cover" aria-hidden="true" />
163
+ ) : icon ? (
164
+ <span aria-hidden="true" className="flex items-center justify-center [&>svg]:size-[55%]">
165
+ {icon}
166
+ </span>
167
+ ) : (
168
+ <span aria-hidden="true">{initials}</span>
169
+ )}
170
+ </div>
171
+ )
172
+ })
173
+
174
+ export const Avatar = React.forwardRef<HTMLDivElement, AvatarProps>((props, ref) => {
175
+ const {
176
+ className,
177
+ label,
178
+ src,
179
+ icon,
180
+ size = 'md',
181
+ variant,
182
+ ariaLabel,
183
+ status,
184
+ statusPosition = 'bottom-right',
185
+ ring,
186
+ badge,
187
+ badgeClassName,
188
+ ...rest
189
+ } = props
190
+
191
+ const resolvedRingTone: AvatarRingTone | null = ring === true ? 'accent' : ring || null
192
+ const showStatus = status !== undefined
193
+ const showBadge =
194
+ badge !== undefined && (statusPosition === 'top-right' || !showStatus)
195
+ const showTopStatus = showStatus && statusPosition === 'top-right' && !showBadge
196
+ const showBottomStatus = showStatus && statusPosition === 'bottom-right' && badge === undefined
197
+
198
+ // Plain avatar (no overlay decorations) — preserve the original
199
+ // single-element output for backward compat.
200
+ if (!showStatus && !showBadge && !resolvedRingTone) {
51
201
  return (
52
- <div
53
- ref={ref}
54
- role="img"
55
- aria-label={ariaLabel ?? label}
56
- className={cn(avatarVariants({ size, variant }), className)}
202
+ <AvatarCircle
203
+ ref={ref as React.Ref<HTMLDivElement>}
204
+ className={className}
205
+ label={label}
206
+ src={src}
207
+ icon={icon}
208
+ size={size}
209
+ variant={variant}
210
+ ariaLabel={ariaLabel}
57
211
  {...rest}
58
- >
59
- {src ? (
60
- <img src={src} alt="" className="size-full object-cover" aria-hidden="true" />
61
- ) : icon ? (
62
- <span aria-hidden="true" className="flex items-center justify-center [&>svg]:size-[55%]">
63
- {icon}
64
- </span>
65
- ) : (
66
- <span aria-hidden="true">{initials}</span>
67
- )}
68
- </div>
212
+ />
69
213
  )
70
- },
71
- )
214
+ }
215
+
216
+ const ringClass = resolvedRingTone ? AVATAR_RING_CLASS[resolvedRingTone] : ''
217
+ const statusSize = AVATAR_STATUS_SIZE[size ?? 'md']
218
+ const statusPositionClass =
219
+ statusPosition === 'top-right'
220
+ ? '-right-0 -top-0 translate-x-1/4 -translate-y-1/4'
221
+ : '-right-0 -bottom-0 translate-x-1/4 translate-y-1/4'
222
+ const badgePositionClass = '-right-0 -top-0 translate-x-1/4 -translate-y-1/4'
223
+
224
+ return (
225
+ <span
226
+ data-slot="avatar-root"
227
+ className={cn('relative inline-flex shrink-0 rounded-full', ringClass)}
228
+ >
229
+ <AvatarCircle
230
+ ref={ref as React.Ref<HTMLDivElement>}
231
+ className={className}
232
+ label={label}
233
+ src={src}
234
+ icon={icon}
235
+ size={size}
236
+ variant={variant}
237
+ ariaLabel={ariaLabel}
238
+ {...rest}
239
+ />
240
+ {showBottomStatus || showTopStatus ? (
241
+ <span
242
+ data-slot="avatar-status"
243
+ data-status={status}
244
+ data-position={statusPosition}
245
+ aria-hidden="true"
246
+ className={cn(
247
+ 'absolute inline-block rounded-full ring-2 ring-background',
248
+ statusSize,
249
+ AVATAR_STATUS_BG[status as AvatarStatus],
250
+ statusPositionClass,
251
+ )}
252
+ />
253
+ ) : null}
254
+ {showBadge ? (
255
+ <span
256
+ data-slot="avatar-badge"
257
+ aria-hidden="true"
258
+ className={cn(
259
+ 'absolute inline-flex items-center justify-center rounded-full ring-2 ring-background bg-background text-foreground',
260
+ // Default size ~40% of avatar; consumer overrides via badgeClassName.
261
+ size === 'xs' || size === 'sm' ? 'size-3.5' : size === 'md' ? 'size-4' : size === 'lg' ? 'size-5' : 'size-6',
262
+ badgePositionClass,
263
+ badgeClassName,
264
+ )}
265
+ >
266
+ {badge}
267
+ </span>
268
+ ) : null}
269
+ </span>
270
+ )
271
+ })
72
272
 
73
273
  Avatar.displayName = 'Avatar'
74
274
 
@@ -87,7 +287,10 @@ export function AvatarStack({ children, max = 4, size = 'md', className }: Avata
87
287
  const overflow = items.length - max
88
288
 
89
289
  return (
90
- <div className={cn('flex items-center [&>*:not(:first-child)]:-ml-2 [&>*]:ring-2 [&>*]:ring-background', className)}>
290
+ <div
291
+ data-slot="avatar-stack"
292
+ className={cn('flex items-center [&>*:not(:first-child)]:-ml-2 [&>*]:ring-2 [&>*]:ring-background', className)}
293
+ >
91
294
  {visible}
92
295
  {overflow > 0 && (
93
296
  <Avatar label={`+${overflow}`} size={size} variant="monochrome" className="-ml-2" />
@@ -1,14 +1,48 @@
1
+ "use client"
2
+
1
3
  import * as React from 'react'
4
+ import { X } from 'lucide-react'
2
5
  import { cva, type VariantProps } from 'class-variance-authority'
6
+
3
7
  import { cn } from '@open-mercato/shared/lib/utils'
4
8
 
9
+ /**
10
+ * Status / category pill primitive. Phase B.8 rewrite per Figma
11
+ * `Badge` page (`119:2863`) — `Badge [1.1]` (`118:2324`, color + size
12
+ * matrix) and `Status Badge [1.1]` (`171:5100`, semantic-colored
13
+ * status pills).
14
+ *
15
+ * Backward compatibility (83 import sites — biggest cascade in v5):
16
+ * - The complete `variant` union (`default | secondary | destructive
17
+ * | outline | muted | success | warning | info | neutral | error`)
18
+ * stays callable verbatim.
19
+ * - Every existing import-site renders identically — no padding
20
+ * bump, no font-weight delta, no color change.
21
+ * - `className` passthrough works for every variant.
22
+ *
23
+ * New (additive):
24
+ * - `size: 'sm' | 'default' | 'lg'` — text-xs / text-xs / text-sm
25
+ * typography ladder with matching px/py padding.
26
+ * - `dot: boolean` — leading 6px dot in the variant's accent color
27
+ * (status-style "● Active" pattern per Figma).
28
+ * - `removable: boolean` + `onRemove` — trailing X icon-button for
29
+ * tag-style dismissible badges.
30
+ * - `brand` variant — brand-violet/10 tint per `Tag` brand variant
31
+ * (kept distinct from generic info / neutral — used for renewal
32
+ * tags, custom views).
33
+ */
34
+
5
35
  const badgeVariants = cva(
6
- 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
36
+ 'inline-flex items-center gap-1 rounded-full border font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
7
37
  {
8
38
  variants: {
9
39
  variant: {
10
40
  default: 'border-transparent bg-primary text-primary-foreground shadow',
11
41
  secondary: 'border-transparent bg-secondary text-secondary-foreground',
42
+ // Kept verbatim from the base contract — BC guarantee in spec
43
+ // 2026-05-13-ds-foundation-v5.md mandates existing variants must
44
+ // render byte-identically. For the soft error-toned look, use
45
+ // the new `error` variant below.
12
46
  destructive: 'border-transparent bg-destructive text-destructive-foreground shadow',
13
47
  outline: 'text-foreground',
14
48
  muted: 'border-transparent bg-muted text-muted-foreground',
@@ -17,19 +51,120 @@ const badgeVariants = cva(
17
51
  info: 'border-status-info-border bg-status-info-bg text-status-info-text',
18
52
  neutral: 'border-status-neutral-border bg-status-neutral-bg text-status-neutral-text',
19
53
  error: 'border-status-error-border bg-status-error-bg text-status-error-text',
54
+ // Phase B.8 addition — brand-violet tinted pill for custom
55
+ // views / renewal tags. Mirrors `Tag` brand variant.
56
+ brand: 'border-brand-violet/30 bg-brand-violet/10 text-brand-violet',
57
+ },
58
+ size: {
59
+ sm: 'px-2 py-0.5 text-[10px]',
60
+ default: 'px-2.5 py-0.5 text-xs',
61
+ lg: 'px-3 py-1 text-sm',
20
62
  },
21
63
  },
22
64
  defaultVariants: {
23
65
  variant: 'default',
66
+ size: 'default',
24
67
  },
25
- }
68
+ },
26
69
  )
27
70
 
28
- export type BadgeProps = React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof badgeVariants>
71
+ // Leading-dot tone per variant. Uses the icon-color end of the status
72
+ // token palette so the dot reads against the soft tinted background.
73
+ const BADGE_DOT_TONE: Record<NonNullable<VariantProps<typeof badgeVariants>['variant']>, string> = {
74
+ default: 'bg-primary-foreground',
75
+ secondary: 'bg-secondary-foreground',
76
+ destructive: 'bg-status-error-icon',
77
+ outline: 'bg-foreground',
78
+ muted: 'bg-muted-foreground',
79
+ success: 'bg-status-success-icon',
80
+ warning: 'bg-status-warning-icon',
81
+ info: 'bg-status-info-icon',
82
+ neutral: 'bg-muted-foreground',
83
+ error: 'bg-status-error-icon',
84
+ brand: 'bg-brand-violet',
85
+ }
86
+
87
+ // Leading-dot size per badge size.
88
+ const BADGE_DOT_SIZE: Record<NonNullable<VariantProps<typeof badgeVariants>['size']>, string> = {
89
+ sm: 'size-1.5',
90
+ default: 'size-1.5',
91
+ lg: 'size-2',
92
+ }
29
93
 
30
- export const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(({ className, variant, ...props }, ref) => (
31
- <div ref={ref} className={cn(badgeVariants({ variant }), className)} {...props} />
32
- ))
94
+ export type BadgeVariant = NonNullable<VariantProps<typeof badgeVariants>['variant']>
95
+ export type BadgeSize = NonNullable<VariantProps<typeof badgeVariants>['size']>
96
+
97
+ export type BadgeProps = React.HTMLAttributes<HTMLDivElement> &
98
+ VariantProps<typeof badgeVariants> & {
99
+ /** Leading status dot in the variant's accent tone. */
100
+ dot?: boolean
101
+ /** Trailing X icon-button for tag-style dismissible badges.
102
+ * Renders only when `removable` is true. */
103
+ removable?: boolean
104
+ /** Click handler for the remove button. Required when `removable`
105
+ * is true. */
106
+ onRemove?: (event: React.MouseEvent<HTMLButtonElement>) => void
107
+ /** Accessible label for the remove button. Default `'Remove'`. */
108
+ removeAriaLabel?: string
109
+ }
110
+
111
+ export const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
112
+ (
113
+ {
114
+ className,
115
+ variant,
116
+ size,
117
+ dot = false,
118
+ removable = false,
119
+ onRemove,
120
+ removeAriaLabel = 'Remove',
121
+ children,
122
+ ...props
123
+ },
124
+ ref,
125
+ ) => {
126
+ const resolvedVariant = (variant ?? 'default') as BadgeVariant
127
+ const resolvedSize = (size ?? 'default') as BadgeSize
128
+ return (
129
+ <div
130
+ ref={ref}
131
+ data-slot="badge"
132
+ data-variant={resolvedVariant}
133
+ data-size={resolvedSize}
134
+ className={cn(badgeVariants({ variant, size }), className)}
135
+ {...props}
136
+ >
137
+ {dot ? (
138
+ <span
139
+ data-slot="badge-dot"
140
+ aria-hidden="true"
141
+ className={cn(
142
+ 'inline-block shrink-0 rounded-full',
143
+ BADGE_DOT_SIZE[resolvedSize],
144
+ BADGE_DOT_TONE[resolvedVariant],
145
+ )}
146
+ />
147
+ ) : null}
148
+ {children}
149
+ {removable ? (
150
+ <button
151
+ type="button"
152
+ data-slot="badge-remove"
153
+ aria-label={removeAriaLabel}
154
+ onClick={onRemove}
155
+ className={cn(
156
+ '-mr-0.5 ml-0.5 inline-flex shrink-0 items-center justify-center rounded-full outline-none transition-opacity',
157
+ 'opacity-70 hover:opacity-100 focus-visible:opacity-100',
158
+ resolvedSize === 'lg' ? 'size-4' : 'size-3.5',
159
+ )}
160
+ >
161
+ <X aria-hidden="true" className={resolvedSize === 'lg' ? 'size-3' : 'size-2.5'} />
162
+ </button>
163
+ ) : null}
164
+ </div>
165
+ )
166
+ },
167
+ )
33
168
 
34
169
  Badge.displayName = 'Badge'
35
170
 
@@ -0,0 +1,100 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { cva, type VariantProps } from 'class-variance-authority'
5
+
6
+ import { cn } from '@open-mercato/shared/lib/utils'
7
+
8
+ /**
9
+ * Joined / connected button group per Figma `Button Group [1.1]`
10
+ * (component set id `199961:1616` in DS Open Mercato). Renders an outer
11
+ * rounded border with N children that touch each other to share an
12
+ * internal separator line. Use for related actions on the same row
13
+ * (Save / Save & New / overflow ellipsis) or compact view-mode toggles.
14
+ *
15
+ * `ButtonGroup` is for *related actions* (each does something different).
16
+ * For mutually-exclusive selection states (only one selected at a time)
17
+ * use `SegmentedControl` instead.
18
+ *
19
+ * The wrapper takes a `size` (2xs / sm / default) that controls the outer
20
+ * radius per Figma — `2xs` = `rounded-sm` (Figma 2X-Small 24, radius 6),
21
+ * `sm` and `default` = `rounded-md` (Figma X-Small 32 + Small 36, radius 8).
22
+ * Same-row sizing convention from `packages/ui/AGENTS.md` still applies:
23
+ * every child Button / IconButton inside the group MUST share the same
24
+ * `size` prop as the group itself.
25
+ *
26
+ * Visual joining is handled at the wrapper level via descendant CSS
27
+ * selectors — children render normally, the wrapper strips their own
28
+ * corners, shadow, and outer borders, and adds an internal separator
29
+ * (`border-r` / `border-b`) between siblings. This way no `cloneElement`
30
+ * is required and any element that accepts a `className` (Button,
31
+ * IconButton, LinkButton, asChild wrappers, etc.) can be a group member.
32
+ *
33
+ * ```tsx
34
+ * <ButtonGroup>
35
+ * <Button>Save</Button>
36
+ * <Button>Save & New</Button>
37
+ * <IconButton aria-label="More"><MoreHorizontal /></IconButton>
38
+ * </ButtonGroup>
39
+ *
40
+ * <ButtonGroup orientation="vertical" size="sm">
41
+ * <Button variant="outline">Up</Button>
42
+ * <Button variant="outline">Down</Button>
43
+ * </ButtonGroup>
44
+ * ```
45
+ */
46
+ const buttonGroupVariants = cva(
47
+ // Outer chrome: rounded shell + border + clipped corners + shared shadow.
48
+ // Descendant rules collapse children into segments:
49
+ // * strip own corners / shadow / outer border on every child
50
+ // * raise focus-visible above neighbours so the focus ring is not clipped
51
+ // * separator: border-r (horizontal) or border-b (vertical) on every
52
+ // child except the last one
53
+ 'inline-flex w-fit border border-input bg-background shadow-xs overflow-hidden ' +
54
+ "[&>*]:rounded-none [&>*]:shadow-none [&>*]:border-0 " +
55
+ "[&>*]:focus-visible:relative [&>*]:focus-visible:z-10 " +
56
+ 'disabled:opacity-50',
57
+ {
58
+ variants: {
59
+ orientation: {
60
+ horizontal:
61
+ 'flex-row [&>*:not(:last-child)]:border-r [&>*:not(:last-child)]:border-input',
62
+ vertical:
63
+ 'flex-col [&>*:not(:last-child)]:border-b [&>*:not(:last-child)]:border-input',
64
+ },
65
+ size: {
66
+ // Figma `2X-Small (24)` → cornerRadius 6 → DS `rounded-sm` (6px).
67
+ '2xs': 'rounded-sm',
68
+ // Figma `X-Small (32)` → cornerRadius 8 → DS `rounded-md` (8px).
69
+ sm: 'rounded-md',
70
+ // Figma `Small (36)` → cornerRadius 8 → DS `rounded-md` (8px).
71
+ default: 'rounded-md',
72
+ },
73
+ },
74
+ defaultVariants: {
75
+ orientation: 'horizontal',
76
+ size: 'default',
77
+ },
78
+ },
79
+ )
80
+
81
+ export type ButtonGroupProps = React.HTMLAttributes<HTMLDivElement> &
82
+ VariantProps<typeof buttonGroupVariants> & {
83
+ /** Forwarded to the wrapper's `aria-label` for screen-reader context. */
84
+ 'aria-label'?: string
85
+ }
86
+
87
+ export const ButtonGroup = React.forwardRef<HTMLDivElement, ButtonGroupProps>(
88
+ ({ className, orientation, size, ...props }, ref) => (
89
+ <div
90
+ ref={ref}
91
+ data-slot="button-group"
92
+ role="group"
93
+ className={cn(buttonGroupVariants({ orientation, size }), className)}
94
+ {...props}
95
+ />
96
+ ),
97
+ )
98
+ ButtonGroup.displayName = 'ButtonGroup'
99
+
100
+ export { buttonGroupVariants }