@proteos/ui 0.45.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.
- package/dist/index.cjs +605 -60
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +174 -7
- package/dist/index.d.ts +174 -7
- package/dist/index.js +601 -64
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/attribute-type-icon/attribute-type-icon.tsx +5 -0
- package/src/components/avatar/avatar.stories.tsx +37 -1
- package/src/components/avatar/avatar.test.tsx +42 -0
- package/src/components/avatar/avatar.tsx +39 -4
- package/src/components/avatar-group/avatar-group.test.tsx +19 -0
- package/src/components/avatar-group/avatar-group.tsx +18 -3
- package/src/components/badge/badge.stories.tsx +27 -1
- package/src/components/badge/badge.test.tsx +26 -0
- package/src/components/badge/badge.tsx +48 -4
- package/src/components/badge-multi-select/badge-multi-select.stories.tsx +24 -0
- package/src/components/badge-multi-select/badge-multi-select.tsx +25 -4
- package/src/components/color-input/color-input.stories.tsx +72 -0
- package/src/components/color-input/color-input.test.tsx +95 -0
- package/src/components/color-input/color-input.tsx +208 -0
- package/src/components/color-input/index.ts +2 -0
- package/src/components/draft-message-composer/draft-message-composer.stories.tsx +1 -1
- package/src/components/icon-picker/icon-picker.stories.tsx +77 -0
- package/src/components/icon-picker/icon-picker.test.tsx +88 -0
- package/src/components/icon-picker/icon-picker.tsx +336 -0
- package/src/components/icon-picker/index.ts +2 -0
- package/src/components/record-filter/chip-value.tsx +10 -3
- package/src/components/record-filter/filter-value-input.tsx +12 -2
- package/src/components/record-filter/operators.ts +6 -6
- package/src/components/record-filter/record-filter.stories.tsx +1 -1
- package/src/components/record-filter/types.ts +4 -0
- package/src/components/table/table.stories.tsx +12 -13
- package/src/components/toast/index.ts +9 -0
- package/src/components/toast/toast.stories.tsx +58 -0
- package/src/components/toast/toast.test.tsx +34 -0
- package/src/components/toast/toast.tsx +121 -0
- package/src/index.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@proteos/ui",
|
|
3
|
-
"version": "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.
|
|
58
|
+
"@proteos/sdk": "0.46.0"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@biomejs/biome": "^2.4.13",
|
|
@@ -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
|
|
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({
|
|
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
|
-
{
|
|
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({
|
|
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
|
|
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> &
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|