@proteos/ui 0.44.0 → 0.46.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.
Files changed (38) hide show
  1. package/dist/index.cjs +605 -60
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +174 -7
  4. package/dist/index.d.ts +174 -7
  5. package/dist/index.js +601 -64
  6. package/dist/index.js.map +1 -1
  7. package/package.json +4 -4
  8. package/src/components/attribute-type-icon/attribute-type-icon.tsx +5 -0
  9. package/src/components/avatar/avatar.stories.tsx +37 -1
  10. package/src/components/avatar/avatar.test.tsx +42 -0
  11. package/src/components/avatar/avatar.tsx +39 -4
  12. package/src/components/avatar-group/avatar-group.test.tsx +19 -0
  13. package/src/components/avatar-group/avatar-group.tsx +18 -3
  14. package/src/components/badge/badge.stories.tsx +27 -1
  15. package/src/components/badge/badge.test.tsx +26 -0
  16. package/src/components/badge/badge.tsx +48 -4
  17. package/src/components/badge-multi-select/badge-multi-select.stories.tsx +24 -0
  18. package/src/components/badge-multi-select/badge-multi-select.tsx +25 -4
  19. package/src/components/color-input/color-input.stories.tsx +72 -0
  20. package/src/components/color-input/color-input.test.tsx +95 -0
  21. package/src/components/color-input/color-input.tsx +208 -0
  22. package/src/components/color-input/index.ts +2 -0
  23. package/src/components/draft-message-composer/draft-message-composer.stories.tsx +1 -1
  24. package/src/components/icon-picker/icon-picker.stories.tsx +77 -0
  25. package/src/components/icon-picker/icon-picker.test.tsx +88 -0
  26. package/src/components/icon-picker/icon-picker.tsx +336 -0
  27. package/src/components/icon-picker/index.ts +2 -0
  28. package/src/components/record-filter/chip-value.tsx +10 -3
  29. package/src/components/record-filter/filter-value-input.tsx +12 -2
  30. package/src/components/record-filter/operators.ts +6 -6
  31. package/src/components/record-filter/record-filter.stories.tsx +1 -1
  32. package/src/components/record-filter/types.ts +4 -0
  33. package/src/components/table/table.stories.tsx +12 -13
  34. package/src/components/toast/index.ts +9 -0
  35. package/src/components/toast/toast.stories.tsx +58 -0
  36. package/src/components/toast/toast.test.tsx +34 -0
  37. package/src/components/toast/toast.tsx +121 -0
  38. package/src/index.ts +3 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteos/ui",
3
- "version": "0.44.0",
3
+ "version": "0.46.0",
4
4
  "license": "FSL-1.1-Apache-2.0",
5
5
  "description": "Proteos React component library (React Aria Components + Tailwind v4).",
6
6
  "repository": {
@@ -55,7 +55,7 @@
55
55
  "tailwind-merge": "^3.5.0",
56
56
  "tailwind-variants": "^3.2.2",
57
57
  "tailwindcss": "^4.2.4",
58
- "@proteos/sdk": "0.44.0"
58
+ "@proteos/sdk": "0.46.0"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@biomejs/biome": "^2.4.13",
@@ -77,8 +77,8 @@
77
77
  "typescript": "^6.0.3",
78
78
  "vite": "^8.0.10",
79
79
  "vitest": "^3.2.4",
80
- "@proteos/tsconfig": "0.0.0",
81
- "@proteos/biome-config": "0.0.0"
80
+ "@proteos/biome-config": "0.0.0",
81
+ "@proteos/tsconfig": "0.0.0"
82
82
  },
83
83
  "scripts": {
84
84
  "lint": "biome check .",
@@ -13,6 +13,7 @@ import {
13
13
  ToggleLeft,
14
14
  Type,
15
15
  User,
16
+ UsersRound,
16
17
  } from 'lucide-react'
17
18
  import { cn } from '../../lib/cn'
18
19
 
@@ -49,6 +50,8 @@ import { cn } from '../../lib/cn'
49
50
  object → Braces ("{ }")
50
51
  relation → Link2 (chain link — foreign-key reference)
51
52
  user → User (person glyph — platform-user reference)
53
+ principal → UsersRound (a party that can HOLD ACCESS — user, team or org;
54
+ a group glyph, distinct from `user`'s single person)
52
55
  currency → Banknote (money — amount + currency_code)
53
56
  knowledge-text → BookText (long text stored as a knowledge node)
54
57
  file → Paperclip (attachment — stored-file reference)
@@ -74,6 +77,7 @@ export const AttributeType = {
74
77
  Object: 'object',
75
78
  Relation: 'relation',
76
79
  User: 'user',
80
+ Principal: 'principal',
77
81
  Currency: 'currency',
78
82
  KnowledgeText: 'knowledge-text',
79
83
  File: 'file',
@@ -94,6 +98,7 @@ export const ATTRIBUTE_TYPE_ICONS: Record<AttributeType, LucideIcon> = {
94
98
  [AttributeType.Object]: Braces,
95
99
  [AttributeType.Relation]: Link2,
96
100
  [AttributeType.User]: User,
101
+ [AttributeType.Principal]: UsersRound,
97
102
  [AttributeType.Currency]: Banknote,
98
103
  [AttributeType.KnowledgeText]: BookText,
99
104
  [AttributeType.File]: Paperclip,
@@ -7,7 +7,7 @@ const meta: Meta<typeof Avatar> = {
7
7
  component: Avatar,
8
8
  tags: ['autodocs'],
9
9
  argTypes: {
10
- size: { control: 'inline-radio', options: ['sm', 'md', 'lg'] },
10
+ size: { control: 'inline-radio', options: ['sm', 'md', 'lg', 'xl'] },
11
11
  tone: { control: 'inline-radio', options: ['paper', 'indigo', 'moss', 'terra', 'slate'] },
12
12
  },
13
13
  }
@@ -15,6 +15,18 @@ const meta: Meta<typeof Avatar> = {
15
15
  export default meta
16
16
  type Story = StoryObj<typeof Avatar>
17
17
 
18
+ // A self-contained "photo" so the story needs no network: a soft two-tone
19
+ // gradient with a pale disc, roughly the silhouette of a portrait.
20
+ const PORTRAIT_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
21
+ <defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
22
+ <stop offset="0" stop-color="#5b7052"/><stop offset="1" stop-color="#8a5a3f"/>
23
+ </linearGradient></defs>
24
+ <rect width="64" height="64" fill="url(#g)"/>
25
+ <circle cx="32" cy="26" r="11" fill="#f0efe8" opacity="0.9"/>
26
+ <ellipse cx="32" cy="54" rx="19" ry="13" fill="#f0efe8" opacity="0.9"/>
27
+ </svg>`
28
+ const PORTRAIT_SRC = `data:image/svg+xml;utf8,${encodeURIComponent(PORTRAIT_SVG)}`
29
+
18
30
  export const Playground: Story = { args: { initials: 'JM' } }
19
31
 
20
32
  export const Tones: Story = {
@@ -37,6 +49,30 @@ export const Sizes: Story = {
37
49
  <Avatar size="sm" initials="SM" />
38
50
  <Avatar size="md" initials="MD" />
39
51
  <Avatar size="lg" initials="LG" />
52
+ <Avatar size="xl" initials="XL" />
53
+ </div>
54
+ ),
55
+ }
56
+
57
+ export const WithImage: Story = {
58
+ parameters: { controls: { hideNoControlsWarning: true } },
59
+ render: () => (
60
+ <div className="flex items-end gap-3">
61
+ <Avatar size="sm" initials="JM" src={PORTRAIT_SRC} alt="Jo Miller" />
62
+ <Avatar size="md" initials="JM" src={PORTRAIT_SRC} alt="Jo Miller" />
63
+ <Avatar size="lg" initials="JM" src={PORTRAIT_SRC} alt="Jo Miller" />
64
+ <Avatar size="xl" initials="JM" src={PORTRAIT_SRC} alt="Jo Miller" />
65
+ </div>
66
+ ),
67
+ }
68
+
69
+ /** A src that 404s — the chip reverts to initials instead of a blank circle. */
70
+ export const ImageFallback: Story = {
71
+ parameters: { controls: { hideNoControlsWarning: true } },
72
+ render: () => (
73
+ <div className="flex items-center gap-3">
74
+ <Avatar initials="JM" src="https://example.invalid/missing.png" />
75
+ <Avatar tone="indigo" initials="N" src="https://example.invalid/missing.png" />
40
76
  </div>
41
77
  ),
42
78
  }
@@ -0,0 +1,42 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import { Avatar } from './avatar'
5
+
6
+ describe('Avatar', () => {
7
+ it('renders initials when there is no image', () => {
8
+ render(<Avatar initials="AL" />)
9
+ expect(screen.getByText('AL')).toBeInTheDocument()
10
+ expect(screen.queryByRole('img')).not.toBeInTheDocument()
11
+ })
12
+
13
+ it('renders the image instead of initials when src is set', () => {
14
+ render(<Avatar initials="AL" src="data:image/gif;base64,R0lGODlhAQABAAAAACw=" alt="Ada" />)
15
+ expect(screen.getByRole('img', { name: 'Ada' })).toHaveAttribute(
16
+ 'src',
17
+ expect.stringContaining('data:'),
18
+ )
19
+ expect(screen.queryByText('AL')).not.toBeInTheDocument()
20
+ })
21
+
22
+ it('falls back to initials when the image fails to load', () => {
23
+ render(<Avatar initials="AL" src="https://example.invalid/missing.png" alt="Ada" />)
24
+ fireEvent.error(screen.getByRole('img', { name: 'Ada' }))
25
+ expect(screen.getByText('AL')).toBeInTheDocument()
26
+ expect(screen.queryByRole('img')).not.toBeInTheDocument()
27
+ })
28
+
29
+ it('retries a new src after a previous one failed', () => {
30
+ const { rerender } = render(
31
+ <Avatar initials="AL" src="https://example.invalid/a.png" alt="Ada" />,
32
+ )
33
+ fireEvent.error(screen.getByRole('img', { name: 'Ada' }))
34
+ expect(screen.getByText('AL')).toBeInTheDocument()
35
+
36
+ rerender(<Avatar initials="AL" src="https://example.invalid/b.png" alt="Ada" />)
37
+ expect(screen.getByRole('img', { name: 'Ada' })).toHaveAttribute(
38
+ 'src',
39
+ 'https://example.invalid/b.png',
40
+ )
41
+ })
42
+ })
@@ -1,17 +1,20 @@
1
- import type { HTMLAttributes } from 'react'
1
+ import { type HTMLAttributes, useState } from 'react'
2
2
  import { tv, type VariantProps } from 'tailwind-variants'
3
3
 
4
4
  import { cn } from '../../lib/cn'
5
5
 
6
6
  /* =========================================================================
7
7
  Avatar — round, paper-tinted initials chip. Mono glyph, hairline border.
8
+ Given a `src` it paints the image over the same chrome and falls back to
9
+ the initials the moment the image fails to load (missing, forbidden,
10
+ broken) — a person never renders as a blank circle.
8
11
  For square colored "app tiles" use the AppSwitcher's internal tile —
9
12
  Avatar is reserved for people.
10
13
  ========================================================================= */
11
14
 
12
15
  export const avatarStyles = tv({
13
16
  base: [
14
- 'inline-grid place-items-center shrink-0 select-none',
17
+ 'inline-grid place-items-center shrink-0 select-none overflow-hidden',
15
18
  'rounded-full border',
16
19
  'font-mono font-medium tracking-[-0.005em]',
17
20
  ],
@@ -20,6 +23,7 @@ export const avatarStyles = tv({
20
23
  sm: 'size-[22px] text-[10px]',
21
24
  md: 'size-7 text-[12px]',
22
25
  lg: 'size-9 text-[13px]',
26
+ xl: 'size-[120px] text-[34px]',
23
27
  },
24
28
  tone: {
25
29
  paper: 'bg-surface-3 text-ink border-rule',
@@ -37,12 +41,43 @@ export type AvatarProps = HTMLAttributes<HTMLSpanElement> &
37
41
  AvatarVariants & {
38
42
  /** 1–2 character initials. Ignored if `children` is provided. */
39
43
  initials?: string
44
+ /**
45
+ * Image URL. Rendered over the chip; on load error the chip reverts to
46
+ * `children ?? initials`. `null`/`undefined` = no image.
47
+ */
48
+ src?: string | null
49
+ /** Alt text for the image. Defaults to empty — the avatar is decorative. */
50
+ alt?: string
40
51
  }
41
52
 
42
- export function Avatar({ size, tone, initials, className, children, ...props }: AvatarProps) {
53
+ export function Avatar({
54
+ size,
55
+ tone,
56
+ initials,
57
+ src,
58
+ alt = '',
59
+ className,
60
+ children,
61
+ ...props
62
+ }: AvatarProps) {
63
+ // The URL that failed, not a bare flag: a new src automatically gets a
64
+ // fresh chance without an effect — the error belongs to the URL, not the
65
+ // person.
66
+ const [failedSrc, setFailedSrc] = useState<string | null>(null)
67
+ const showsImage = Boolean(src) && failedSrc !== src
68
+
43
69
  return (
44
70
  <span {...props} className={cn(avatarStyles({ size, tone }), className)}>
45
- {children ?? initials}
71
+ {showsImage ? (
72
+ <img
73
+ src={src ?? undefined}
74
+ alt={alt}
75
+ className="size-full object-cover"
76
+ onError={() => setFailedSrc(src ?? null)}
77
+ />
78
+ ) : (
79
+ (children ?? initials)
80
+ )}
46
81
  </span>
47
82
  )
48
83
  }
@@ -18,6 +18,25 @@ describe('AvatarGroup', () => {
18
18
  expect(screen.queryByText(/^\+/)).not.toBeInTheDocument()
19
19
  })
20
20
 
21
+ it('renders a photo for an item with src and initials for the rest', () => {
22
+ render(
23
+ <AvatarGroup
24
+ avatars={[
25
+ {
26
+ initials: 'AB',
27
+ key: 'a',
28
+ src: 'data:image/gif;base64,R0lGODlhAQABAAAAACw=',
29
+ alt: 'Ab',
30
+ },
31
+ { initials: 'CD', key: 'b' },
32
+ ]}
33
+ />,
34
+ )
35
+ expect(screen.getByRole('img', { name: 'Ab' })).toBeInTheDocument()
36
+ expect(screen.queryByText('AB')).not.toBeInTheDocument()
37
+ expect(screen.getByText('CD')).toBeInTheDocument()
38
+ })
39
+
21
40
  it('collapses the overflow into a +N chip', () => {
22
41
  render(
23
42
  <AvatarGroup
@@ -25,10 +25,13 @@ export const avatarGroupStyles = tv({
25
25
 
26
26
  export type AvatarGroupVariants = VariantProps<typeof avatarGroupStyles>
27
27
 
28
- /** One person in the stack — initials plus an optional tone. */
28
+ /** One person in the stack — initials plus an optional tone and photo. */
29
29
  export interface AvatarGroupItem {
30
30
  initials: string
31
31
  tone?: AvatarVariants['tone']
32
+ /** Photo URL; falls back to `initials` when absent or failing to load. */
33
+ src?: string | null
34
+ alt?: string
32
35
  /** Stable key (e.g. the participant's external id); falls back to the index. */
33
36
  key?: string
34
37
  }
@@ -40,14 +43,26 @@ export type AvatarGroupProps = Omit<HTMLAttributes<HTMLSpanElement>, 'children'>
40
43
  max?: number
41
44
  }
42
45
 
43
- export function AvatarGroup({ avatars, max = 3, size = 'md', className, ...props }: AvatarGroupProps) {
46
+ export function AvatarGroup({
47
+ avatars,
48
+ max = 3,
49
+ size = 'md',
50
+ className,
51
+ ...props
52
+ }: AvatarGroupProps) {
44
53
  const shown = avatars.slice(0, max)
45
54
  const overflow = avatars.length - shown.length
46
55
  return (
47
56
  <span {...props} className={cn(avatarGroupStyles({ size }), className)}>
48
57
  {shown.map((avatar, index) => (
49
58
  <span key={avatar.key ?? index} className="rounded-full ring-[1.5px] ring-surface">
50
- <Avatar size={size} tone={avatar.tone ?? 'paper'} initials={avatar.initials} />
59
+ <Avatar
60
+ size={size}
61
+ tone={avatar.tone ?? 'paper'}
62
+ initials={avatar.initials}
63
+ src={avatar.src ?? null}
64
+ alt={avatar.alt ?? ''}
65
+ />
51
66
  </span>
52
67
  ))}
53
68
  {overflow > 0 && (
@@ -13,7 +13,7 @@ const meta: Meta<typeof Badge> = {
13
13
  argTypes: {
14
14
  variant: {
15
15
  control: { type: 'inline-radio' },
16
- options: ['default', 'neutral', 'active', 'success', 'warning', 'danger'],
16
+ options: ['default', 'neutral', 'active', 'success', 'warning', 'danger', 'custom'],
17
17
  },
18
18
  },
19
19
  }
@@ -37,6 +37,32 @@ export const AllVariants: Story = {
37
37
  ),
38
38
  }
39
39
 
40
+ /** Schema-driven chrome: `color` tints dot/border/fill (text stays ink),
41
+ * `icon` swaps the dot for a Lucide glyph. This is how enum values render. */
42
+ export const WithColorAndIcon: Story = {
43
+ parameters: { controls: { hideNoControlsWarning: true } },
44
+ render: () => (
45
+ <div className="flex flex-wrap items-center gap-2">
46
+ <Badge icon="Circle" color="#8a8f9c">
47
+ to do
48
+ </Badge>
49
+ <Badge icon="CircleDashed" color="#5b8def">
50
+ in progress
51
+ </Badge>
52
+ <Badge icon="CircleCheck" color="#3f9d6b">
53
+ done
54
+ </Badge>
55
+ <Badge icon="Flame" color="#d94f4f">
56
+ urgent
57
+ </Badge>
58
+ <Badge color="#b07cd8">color only</Badge>
59
+ <Badge variant="active" icon="Sparkles">
60
+ icon on a register
61
+ </Badge>
62
+ </div>
63
+ ),
64
+ }
65
+
40
66
  export const InContext: Story = {
41
67
  parameters: { controls: { hideNoControlsWarning: true } },
42
68
  render: () => (
@@ -33,4 +33,30 @@ describe('Badge', () => {
33
33
  expect(screen.getByText('reconciled').className).toContain('ml-3')
34
34
  expect(screen.getByText('reconciled').className).toContain('text-success')
35
35
  })
36
+
37
+ it('tints the chrome from a custom color and keeps ink text', () => {
38
+ render(
39
+ <Badge variant="danger" color="#5b8def">
40
+ in review
41
+ </Badge>,
42
+ )
43
+ const badge = screen.getByText('in review')
44
+ expect(badge.style.getPropertyValue('--badge-color')).toBe('#5b8def')
45
+ expect(badge.className).toContain('text-ink-2')
46
+ expect(badge.className).not.toContain('text-danger')
47
+ })
48
+
49
+ it('replaces the dot with a resolved icon', () => {
50
+ render(<Badge icon="circle-check">done</Badge>)
51
+ const badge = screen.getByText('done')
52
+ expect(badge.querySelector('svg')).not.toBeNull()
53
+ expect(badge.className).toContain('before:hidden')
54
+ })
55
+
56
+ it('keeps the dot when the icon name is unknown', () => {
57
+ render(<Badge icon="not-an-icon">done</Badge>)
58
+ const badge = screen.getByText('done')
59
+ expect(badge.querySelector('svg')).toBeNull()
60
+ expect(badge.className).not.toContain('before:hidden')
61
+ })
36
62
  })
@@ -1,12 +1,19 @@
1
- import type { HTMLAttributes } from 'react'
1
+ import type { CSSProperties, HTMLAttributes } from 'react'
2
2
  import { tv, type VariantProps } from 'tailwind-variants'
3
3
 
4
4
  import { cn } from '../../lib/cn'
5
+ import { resolveLucideIcon } from '../icon-picker/icon-picker'
5
6
 
6
7
  /* =========================================================================
7
8
  Badge — mono, pill-shaped status chip with a leading dot.
8
9
  Matches the Proteos spec's six registers: default, neutral, active (indigo),
9
10
  success, warning, danger. Paper-compatible fills — never pure-color.
11
+
12
+ Two data-driven escape hatches for schema-defined values (enum options,
13
+ labels): `color` — a #rrggbb tint that drives the dot, border and fill
14
+ via color-mix while the text stays ink so any user-picked color keeps
15
+ its contrast in light/dark/paper; and `icon` — a Lucide name that
16
+ replaces the dot.
10
17
  ========================================================================= */
11
18
 
12
19
  export const badgeStyles = tv({
@@ -41,14 +48,51 @@ export const badgeStyles = tv({
41
48
  'border-[color-mix(in_oklab,var(--color-danger)_40%,var(--color-rule))]',
42
49
  'bg-[color-mix(in_oklab,var(--color-danger)_12%,var(--color-surface))]',
43
50
  ],
51
+ // Driven by the `--badge-color` custom property (set from the `color`
52
+ // prop). Chrome only — text stays ink so contrast never depends on the
53
+ // picked color.
54
+ custom: [
55
+ 'text-ink-2 before:bg-[var(--badge-color)]',
56
+ 'border-[color-mix(in_oklab,var(--badge-color)_35%,var(--color-rule))]',
57
+ 'bg-[color-mix(in_oklab,var(--badge-color)_12%,var(--color-surface))]',
58
+ ],
44
59
  },
45
60
  },
46
61
  defaultVariants: { variant: 'default' },
47
62
  })
48
63
 
49
64
  export type BadgeVariants = VariantProps<typeof badgeStyles>
50
- export type BadgeProps = HTMLAttributes<HTMLSpanElement> & BadgeVariants
65
+ export type BadgeProps = HTMLAttributes<HTMLSpanElement> &
66
+ BadgeVariants & {
67
+ /** `#rrggbb` tint. When set it overrides `variant` with the `custom` register. */
68
+ color?: string
69
+ /** Lucide icon name (any casing). Replaces the leading dot; unknown names keep the dot. */
70
+ icon?: string
71
+ }
72
+
73
+ /** Icon tone per register — mirrors each variant's `before:bg-*` dot color. */
74
+ const ICON_TONE: Record<NonNullable<BadgeVariants['variant']>, string> = {
75
+ default: 'text-ink-4',
76
+ neutral: 'text-ink-3',
77
+ active: 'text-accent',
78
+ success: 'text-success',
79
+ warning: 'text-warning',
80
+ danger: 'text-danger',
81
+ custom: 'text-[var(--badge-color)]',
82
+ }
51
83
 
52
- export function Badge({ variant, className, ...props }: BadgeProps) {
53
- return <span {...props} className={cn(badgeStyles({ variant }), className)} />
84
+ export function Badge({ variant, color, icon, className, style, children, ...props }: BadgeProps) {
85
+ const Icon = icon ? resolveLucideIcon(icon) : null
86
+ const register: NonNullable<BadgeVariants['variant']> = color ? 'custom' : (variant ?? 'default')
87
+ const mergedStyle = color ? ({ ...style, '--badge-color': color } as CSSProperties) : style
88
+ return (
89
+ <span
90
+ {...props}
91
+ style={mergedStyle}
92
+ className={cn(badgeStyles({ variant: register }), Icon ? 'before:hidden' : null, className)}
93
+ >
94
+ {Icon ? <Icon aria-hidden className={cn('size-3 shrink-0', ICON_TONE[register])} /> : null}
95
+ {children}
96
+ </span>
97
+ )
54
98
  }
@@ -28,6 +28,30 @@ const statuses: BadgeMultiSelectOption<string>[] = [
28
28
  { id: 'exception', label: 'exception', variant: 'danger' },
29
29
  ]
30
30
 
31
+ /** Schema-driven chrome — enum values carry `color` (#rrggbb) and a Lucide
32
+ * `icon`; both flow through to the chip and the listbox row. */
33
+ const taskStatuses: BadgeMultiSelectOption<string>[] = [
34
+ { id: 'to_do', label: 'To Do', icon: 'Circle', color: '#8a8f9c' },
35
+ { id: 'in_progress', label: 'In Progress', icon: 'CircleDashed', color: '#5b8def' },
36
+ { id: 'done', label: 'Done', icon: 'CircleCheck', color: '#3f9d6b' },
37
+ { id: 'blocked', label: 'Blocked', icon: 'OctagonX', color: '#d94f4f' },
38
+ ]
39
+
40
+ export const WithColorAndIcon: Story = {
41
+ render: () => {
42
+ const [selected, setSelected] = useState<Set<string>>(new Set(['in_progress', 'blocked']))
43
+ return (
44
+ <BadgeMultiSelect
45
+ label="Status"
46
+ placeholder="Any status"
47
+ options={taskStatuses}
48
+ selectedKeys={selected}
49
+ onSelectionChange={setSelected}
50
+ />
51
+ )
52
+ },
53
+ }
54
+
31
55
  export const Playground: Story = {
32
56
  render: () => {
33
57
  const [selected, setSelected] = useState<Set<string>>(new Set(['in-transit', 'past-due']))
@@ -5,13 +5,13 @@ import { Badge, type BadgeVariants } from '../badge/badge'
5
5
  import {
6
6
  type FieldAppearance,
7
7
  FieldCaret,
8
+ type FieldSize,
8
9
  fieldChipPadding,
9
10
  fieldControlStyles,
10
11
  fieldErrorStyles,
11
12
  fieldHintStyles,
12
13
  fieldLabelStyles,
13
14
  fieldMinHeight,
14
- type FieldSize,
15
15
  fieldTextStyles,
16
16
  } from '../field/field'
17
17
 
@@ -29,9 +29,26 @@ export type BadgeMultiSelectOption<T extends Key = Key> = {
29
29
  label: string
30
30
  /** Badge color register. Defaults to `default`. */
31
31
  variant?: BadgeVariants['variant']
32
+ /** `#rrggbb` tint — overrides `variant` (see Badge). */
33
+ color?: string
34
+ /** Lucide icon name; replaces the badge dot. */
35
+ icon?: string
32
36
  isDisabled?: boolean
33
37
  }
34
38
 
39
+ /** The Badge chrome props an option carries — spread onto every Badge it renders as. */
40
+ function badgeChrome(option: {
41
+ variant?: BadgeVariants['variant'] | undefined
42
+ color?: string | undefined
43
+ icon?: string | undefined
44
+ }) {
45
+ return {
46
+ ...(option.variant ? { variant: option.variant } : {}),
47
+ ...(option.color ? { color: option.color } : {}),
48
+ ...(option.icon ? { icon: option.icon } : {}),
49
+ }
50
+ }
51
+
35
52
  export type BadgeMultiSelectProps<T extends Key = Key> = {
36
53
  label?: ReactNode
37
54
  description?: ReactNode
@@ -145,7 +162,7 @@ export function BadgeMultiSelect<T extends Key>({
145
162
  selectedOptions.map((o) => (
146
163
  <RemovableBadge
147
164
  key={o.id}
148
- variant={o.variant}
165
+ {...badgeChrome(o)}
149
166
  onRemove={() => remove(o.id)}
150
167
  isDisabled={isDisabled}
151
168
  >
@@ -209,7 +226,7 @@ export function BadgeMultiSelect<T extends Key>({
209
226
  'data-[disabled]:opacity-45 data-[disabled]:cursor-not-allowed',
210
227
  )}
211
228
  >
212
- <Badge variant={option.variant}>{option.label}</Badge>
229
+ <Badge {...badgeChrome(option)}>{option.label}</Badge>
213
230
  </ListBoxItem>
214
231
  ))}
215
232
  </ListBox>
@@ -227,16 +244,20 @@ export function BadgeMultiSelect<T extends Key>({
227
244
  function RemovableBadge({
228
245
  children,
229
246
  variant,
247
+ color,
248
+ icon,
230
249
  onRemove,
231
250
  isDisabled,
232
251
  }: {
233
252
  children: ReactNode
234
253
  variant?: BadgeVariants['variant']
254
+ color?: string
255
+ icon?: string
235
256
  onRemove: () => void
236
257
  isDisabled: boolean
237
258
  }) {
238
259
  return (
239
- <Badge variant={variant}>
260
+ <Badge {...badgeChrome({ variant, color, icon })}>
240
261
  <span>{children}</span>
241
262
  <button
242
263
  type="button"
@@ -0,0 +1,72 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useState } from 'react'
3
+
4
+ import { Badge } from '../badge/badge'
5
+ import { ColorInput } from './color-input'
6
+
7
+ const meta: Meta<typeof ColorInput> = {
8
+ title: 'Components/ColorInput',
9
+ component: ColorInput,
10
+ tags: ['autodocs'],
11
+ decorators: [
12
+ (Story) => (
13
+ <div className="w-[320px]">
14
+ <Story />
15
+ </div>
16
+ ),
17
+ ],
18
+ }
19
+
20
+ export default meta
21
+ type Story = StoryObj<typeof ColorInput>
22
+
23
+ function Controlled({ initial }: { initial: string | null }) {
24
+ const [value, setValue] = useState<string | null>(initial)
25
+ return (
26
+ <div className="grid gap-4">
27
+ <ColorInput
28
+ label="Color"
29
+ description="Stored as lowercase #rrggbb. Shorthand and a missing # are accepted."
30
+ value={value}
31
+ onChange={setValue}
32
+ />
33
+ <div className="flex items-center gap-2">
34
+ <span className="font-mono text-[11px] text-ink-4">preview</span>
35
+ <Badge icon="CircleDashed" {...(value ? { color: value } : {})}>
36
+ in progress
37
+ </Badge>
38
+ </div>
39
+ </div>
40
+ )
41
+ }
42
+
43
+ export const Empty: Story = {
44
+ render: () => <Controlled initial={null} />,
45
+ }
46
+
47
+ export const Preset: Story = {
48
+ render: () => <Controlled initial="#5b8def" />,
49
+ }
50
+
51
+ export const Small: Story = {
52
+ render: () => {
53
+ const [value, setValue] = useState<string | null>('#d94f4f')
54
+ return <ColorInput size="sm" aria-label="Color" value={value} onChange={setValue} />
55
+ },
56
+ }
57
+
58
+ export const Invalid: Story = {
59
+ render: () => (
60
+ <ColorInput
61
+ label="Color"
62
+ value={null}
63
+ onChange={() => {}}
64
+ isInvalid
65
+ errorMessage="Pick a color."
66
+ />
67
+ ),
68
+ }
69
+
70
+ export const Disabled: Story = {
71
+ render: () => <ColorInput label="Color" value="#3f9d6b" onChange={() => {}} isDisabled />,
72
+ }