@proteos/ui 0.54.1 → 0.55.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 +638 -311
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +79 -7
- package/dist/index.d.ts +79 -7
- package/dist/index.js +636 -314
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/components/badge/badge.stories.tsx +37 -1
- package/src/components/badge/badge.test.tsx +39 -2
- package/src/components/badge/badge.tsx +65 -1
- package/src/components/badge/index.ts +2 -2
- package/src/components/badge-group/badge-group.stories.tsx +75 -0
- package/src/components/badge-group/badge-group.test.tsx +121 -0
- package/src/components/badge-group/badge-group.tsx +208 -0
- package/src/components/badge-group/index.ts +7 -0
- package/src/components/badge-multi-select/badge-multi-select.stories.tsx +25 -0
- package/src/components/badge-multi-select/badge-multi-select.test.tsx +110 -1
- package/src/components/badge-multi-select/badge-multi-select.tsx +190 -128
- package/src/components/data-table/data-table.stories.tsx +53 -2
- package/src/components/data-table/data-table.tsx +45 -25
- package/src/components/field/listbox-check.tsx +44 -0
- package/src/components/field/use-chip-row-wrap.ts +45 -0
- package/src/components/multi-select/multi-select.tsx +8 -39
- package/src/components/table/table.test.tsx +88 -9
- package/src/components/table/table.tsx +139 -13
- package/src/components/tag-input/tag-input.test.tsx +67 -1
- package/src/components/tag-input/tag-input.tsx +98 -64
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@proteos/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.55.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": {
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"tailwind-variants": "^3.2.2",
|
|
57
57
|
"tailwindcss": "^4.2.4",
|
|
58
58
|
"thinking-orbs": "^0.3.1",
|
|
59
|
-
"@proteos/sdk": "0.
|
|
59
|
+
"@proteos/sdk": "0.55.0"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"@biomejs/biome": "^2.4.13",
|
|
@@ -78,8 +78,8 @@
|
|
|
78
78
|
"typescript": "^6.0.3",
|
|
79
79
|
"vite": "^8.0.10",
|
|
80
80
|
"vitest": "^3.2.4",
|
|
81
|
-
"@proteos/
|
|
82
|
-
"@proteos/
|
|
81
|
+
"@proteos/biome-config": "0.0.0",
|
|
82
|
+
"@proteos/tsconfig": "0.0.0"
|
|
83
83
|
},
|
|
84
84
|
"scripts": {
|
|
85
85
|
"lint": "biome check .",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
2
|
|
|
3
|
-
import { Badge } from './badge'
|
|
3
|
+
import { Badge, BadgeAction } from './badge'
|
|
4
4
|
|
|
5
5
|
const meta: Meta<typeof Badge> = {
|
|
6
6
|
title: 'Components/Badge',
|
|
@@ -111,3 +111,39 @@ export const InContext: Story = {
|
|
|
111
111
|
</div>
|
|
112
112
|
),
|
|
113
113
|
}
|
|
114
|
+
|
|
115
|
+
/** The `actions` slot: a chip a picker can take back. `BadgeAction` inherits
|
|
116
|
+
* the register's ink, so the × reads correctly on every fill and tint. */
|
|
117
|
+
export const WithActions: Story = {
|
|
118
|
+
parameters: { controls: { hideNoControlsWarning: true } },
|
|
119
|
+
render: () => (
|
|
120
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
121
|
+
<Badge
|
|
122
|
+
shape="rounded"
|
|
123
|
+
hasDot={false}
|
|
124
|
+
variant="active"
|
|
125
|
+
actions={<BadgeAction label="Remove to do" onPress={() => {}} />}
|
|
126
|
+
>
|
|
127
|
+
to do
|
|
128
|
+
</Badge>
|
|
129
|
+
<Badge
|
|
130
|
+
shape="rounded"
|
|
131
|
+
hasDot={false}
|
|
132
|
+
icon="CircleCheck"
|
|
133
|
+
color="#3f9d6b"
|
|
134
|
+
actions={<BadgeAction label="Remove done" onPress={() => {}} />}
|
|
135
|
+
>
|
|
136
|
+
done
|
|
137
|
+
</Badge>
|
|
138
|
+
<Badge variant="warning" actions={<BadgeAction label="Remove past due" onPress={() => {}} />}>
|
|
139
|
+
past due
|
|
140
|
+
</Badge>
|
|
141
|
+
<Badge
|
|
142
|
+
variant="neutral"
|
|
143
|
+
actions={<BadgeAction label="Remove dispatching" onPress={() => {}} isDisabled />}
|
|
144
|
+
>
|
|
145
|
+
disabled
|
|
146
|
+
</Badge>
|
|
147
|
+
</div>
|
|
148
|
+
),
|
|
149
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { render, screen } from '@testing-library/react'
|
|
2
|
-
import
|
|
2
|
+
import userEvent from '@testing-library/user-event'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
4
|
|
|
4
|
-
import { Badge } from './badge'
|
|
5
|
+
import { Badge, BadgeAction } from './badge'
|
|
5
6
|
|
|
6
7
|
describe('Badge', () => {
|
|
7
8
|
it('renders its content', () => {
|
|
@@ -82,3 +83,39 @@ describe('Badge', () => {
|
|
|
82
83
|
expect(badge.className).not.toContain('before:hidden')
|
|
83
84
|
})
|
|
84
85
|
})
|
|
86
|
+
|
|
87
|
+
describe('Badge actions slot', () => {
|
|
88
|
+
it('renders nothing extra without actions', () => {
|
|
89
|
+
render(<Badge>done</Badge>)
|
|
90
|
+
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('renders the action inside the chip', () => {
|
|
94
|
+
render(<Badge actions={<BadgeAction label="Remove done" onPress={() => {}} />}>done</Badge>)
|
|
95
|
+
const button = screen.getByRole('button', { name: 'Remove done' })
|
|
96
|
+
expect(screen.getByText('done')).toContainElement(button)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('fires onPress and keeps the click from reaching the host', async () => {
|
|
100
|
+
const user = userEvent.setup()
|
|
101
|
+
const onPress = vi.fn()
|
|
102
|
+
const onHost = vi.fn()
|
|
103
|
+
render(
|
|
104
|
+
// biome-ignore lint/a11y/useKeyWithClickEvents: test host mimicking a clickable picker trigger
|
|
105
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: same
|
|
106
|
+
<div onClick={onHost}>
|
|
107
|
+
<Badge actions={<BadgeAction label="Remove" onPress={onPress} />}>done</Badge>
|
|
108
|
+
</div>,
|
|
109
|
+
)
|
|
110
|
+
await user.click(screen.getByRole('button', { name: 'Remove' }))
|
|
111
|
+
expect(onPress).toHaveBeenCalledTimes(1)
|
|
112
|
+
expect(onHost).not.toHaveBeenCalled()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('disables the action when asked', () => {
|
|
116
|
+
render(
|
|
117
|
+
<Badge actions={<BadgeAction label="Remove" onPress={() => {}} isDisabled />}>done</Badge>,
|
|
118
|
+
)
|
|
119
|
+
expect(screen.getByRole('button', { name: 'Remove' })).toBeDisabled()
|
|
120
|
+
})
|
|
121
|
+
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CSSProperties, HTMLAttributes } from 'react'
|
|
1
|
+
import type { CSSProperties, HTMLAttributes, ReactNode } from 'react'
|
|
2
2
|
import { tv, type VariantProps } from 'tailwind-variants'
|
|
3
3
|
|
|
4
4
|
import { cn } from '../../lib/cn'
|
|
@@ -20,6 +20,11 @@ import { resolveLucideIcon } from '../icon-picker/icon-picker'
|
|
|
20
20
|
via color-mix while the text stays ink so any user-picked color keeps
|
|
21
21
|
its contrast in light/dark/paper; and `icon` — a Lucide name that
|
|
22
22
|
replaces the dot (whatever `hasDot` says).
|
|
23
|
+
|
|
24
|
+
`actions` — an optional trailing slot for controls that act on the chip
|
|
25
|
+
(the × of a removable selection, say). Absent on read-only surfaces;
|
|
26
|
+
pickers fill it with <BadgeAction>, which inherits the register's ink so
|
|
27
|
+
it reads correctly on every fill.
|
|
23
28
|
========================================================================= */
|
|
24
29
|
|
|
25
30
|
export const badgeStyles = tv({
|
|
@@ -81,6 +86,8 @@ export type BadgeProps = HTMLAttributes<HTMLSpanElement> &
|
|
|
81
86
|
color?: string
|
|
82
87
|
/** Lucide icon name (any casing). Replaces the leading dot; unknown names keep the dot. */
|
|
83
88
|
icon?: string
|
|
89
|
+
/** Trailing slot for controls acting on the chip — usually a <BadgeAction>. */
|
|
90
|
+
actions?: ReactNode
|
|
84
91
|
}
|
|
85
92
|
|
|
86
93
|
/** Icon tone per register — mirrors each variant's `before:bg-*` dot color. */
|
|
@@ -100,6 +107,7 @@ export function Badge({
|
|
|
100
107
|
hasDot,
|
|
101
108
|
color,
|
|
102
109
|
icon,
|
|
110
|
+
actions,
|
|
103
111
|
className,
|
|
104
112
|
style,
|
|
105
113
|
children,
|
|
@@ -124,6 +132,62 @@ export function Badge({
|
|
|
124
132
|
>
|
|
125
133
|
{Icon ? <Icon aria-hidden className={cn('size-3 shrink-0', ICON_TONE[register])} /> : null}
|
|
126
134
|
{children}
|
|
135
|
+
{actions ? (
|
|
136
|
+
// Pulled flush with the trailing edge so the chip still reads
|
|
137
|
+
// symmetric around its leading dot / icon.
|
|
138
|
+
<span className="inline-flex items-center shrink-0 -mr-1">{actions}</span>
|
|
139
|
+
) : null}
|
|
127
140
|
</span>
|
|
128
141
|
)
|
|
129
142
|
}
|
|
143
|
+
|
|
144
|
+
/* =========================================================================
|
|
145
|
+
BadgeAction — the icon button that lives in a Badge's `actions` slot.
|
|
146
|
+
Inherits the badge's text color through `currentColor`, so the same
|
|
147
|
+
button reads correctly on every register and custom tint. Stops the
|
|
148
|
+
click from bubbling: badges sit inside clickable triggers (a picker's
|
|
149
|
+
combobox box, a table row), and acting on the chip must not also act on
|
|
150
|
+
the host. Defaults to the × glyph — the remove case needs no children.
|
|
151
|
+
========================================================================= */
|
|
152
|
+
|
|
153
|
+
export type BadgeActionProps = {
|
|
154
|
+
/** Accessible name — the glyph is decorative. */
|
|
155
|
+
label: string
|
|
156
|
+
onPress: () => void
|
|
157
|
+
isDisabled?: boolean
|
|
158
|
+
/** Custom glyph; omit for ×. Size it `size-2.5`. */
|
|
159
|
+
children?: ReactNode
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function BadgeAction({ label, onPress, isDisabled = false, children }: BadgeActionProps) {
|
|
163
|
+
return (
|
|
164
|
+
<button
|
|
165
|
+
type="button"
|
|
166
|
+
aria-label={label}
|
|
167
|
+
disabled={isDisabled}
|
|
168
|
+
onClick={(event) => {
|
|
169
|
+
event.stopPropagation()
|
|
170
|
+
onPress()
|
|
171
|
+
}}
|
|
172
|
+
className={cn(
|
|
173
|
+
'inline-flex items-center justify-center shrink-0',
|
|
174
|
+
'size-3.5 rounded-full',
|
|
175
|
+
'text-current/70 hover:text-current hover:bg-current/10',
|
|
176
|
+
'outline-none cursor-pointer',
|
|
177
|
+
'focus-visible:shadow-[var(--shadow-focus)]',
|
|
178
|
+
'disabled:opacity-45 disabled:cursor-not-allowed',
|
|
179
|
+
)}
|
|
180
|
+
>
|
|
181
|
+
{children ?? (
|
|
182
|
+
<svg aria-hidden viewBox="0 0 10 10" className="size-2.5" fill="none">
|
|
183
|
+
<path
|
|
184
|
+
d="M2 2l6 6M8 2l-6 6"
|
|
185
|
+
stroke="currentColor"
|
|
186
|
+
strokeWidth="1.4"
|
|
187
|
+
strokeLinecap="round"
|
|
188
|
+
/>
|
|
189
|
+
</svg>
|
|
190
|
+
)}
|
|
191
|
+
</button>
|
|
192
|
+
)
|
|
193
|
+
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export type { BadgeProps, BadgeVariants } from './badge'
|
|
2
|
-
export { Badge, badgeStyles } from './badge'
|
|
1
|
+
export type { BadgeActionProps, BadgeProps, BadgeVariants } from './badge'
|
|
2
|
+
export { Badge, BadgeAction, badgeStyles } from './badge'
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
|
|
3
|
+
import { Badge } from '../badge/badge'
|
|
4
|
+
import { BadgeGroup } from './badge-group'
|
|
5
|
+
|
|
6
|
+
const values = [
|
|
7
|
+
'sap',
|
|
8
|
+
'transporeon',
|
|
9
|
+
'test',
|
|
10
|
+
'another test',
|
|
11
|
+
'super looong test',
|
|
12
|
+
'another super long text',
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
const meta: Meta<typeof BadgeGroup> = {
|
|
16
|
+
title: 'Components/BadgeGroup',
|
|
17
|
+
component: BadgeGroup,
|
|
18
|
+
tags: ['autodocs'],
|
|
19
|
+
parameters: { controls: { hideNoControlsWarning: true } },
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default meta
|
|
23
|
+
type Story = StoryObj<typeof BadgeGroup>
|
|
24
|
+
|
|
25
|
+
/** Drag the box's corner: badges collapse into `+N more` as it narrows and
|
|
26
|
+
* come back as it widens. Hover or focus the chip for the full list. */
|
|
27
|
+
export const Resizable: Story = {
|
|
28
|
+
render: () => (
|
|
29
|
+
<div className="w-[380px] max-w-full resize-x overflow-hidden rounded-md border border-rule p-3">
|
|
30
|
+
<BadgeGroup>
|
|
31
|
+
{values.map((value) => (
|
|
32
|
+
<Badge key={value} variant="active" shape="rounded" hasDot={false}>
|
|
33
|
+
{value}
|
|
34
|
+
</Badge>
|
|
35
|
+
))}
|
|
36
|
+
</BadgeGroup>
|
|
37
|
+
</div>
|
|
38
|
+
),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const AllFit: Story = {
|
|
42
|
+
render: () => (
|
|
43
|
+
<div className="w-[560px] max-w-full rounded-md border border-rule p-3">
|
|
44
|
+
<BadgeGroup>
|
|
45
|
+
{values.slice(0, 3).map((value) => (
|
|
46
|
+
<Badge key={value} variant="active" shape="rounded" hasDot={false}>
|
|
47
|
+
{value}
|
|
48
|
+
</Badge>
|
|
49
|
+
))}
|
|
50
|
+
</BadgeGroup>
|
|
51
|
+
</div>
|
|
52
|
+
),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Enum-style chips keep their icon and color in the row and in the tooltip. */
|
|
56
|
+
export const WithColorAndIcon: Story = {
|
|
57
|
+
render: () => (
|
|
58
|
+
<div className="w-[260px] max-w-full resize-x overflow-hidden rounded-md border border-rule p-3">
|
|
59
|
+
<BadgeGroup>
|
|
60
|
+
<Badge shape="rounded" hasDot={false} icon="CircleCheck" color="#3f9d6b">
|
|
61
|
+
Done
|
|
62
|
+
</Badge>
|
|
63
|
+
<Badge shape="rounded" hasDot={false} icon="Clock" color="#c98a2b">
|
|
64
|
+
Waiting
|
|
65
|
+
</Badge>
|
|
66
|
+
<Badge shape="rounded" hasDot={false} icon="Flag" color="#c0392b">
|
|
67
|
+
Blocked
|
|
68
|
+
</Badge>
|
|
69
|
+
<Badge shape="rounded" hasDot={false} variant="active">
|
|
70
|
+
To Do
|
|
71
|
+
</Badge>
|
|
72
|
+
</BadgeGroup>
|
|
73
|
+
</div>
|
|
74
|
+
),
|
|
75
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import userEvent from '@testing-library/user-event'
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import { Badge } from '../badge/badge'
|
|
6
|
+
import { BadgeGroup, fitBadgeCount } from './badge-group'
|
|
7
|
+
|
|
8
|
+
const labels = ['sap', 'transporeon', 'test']
|
|
9
|
+
|
|
10
|
+
function renderGroup() {
|
|
11
|
+
return render(
|
|
12
|
+
<BadgeGroup>
|
|
13
|
+
{labels.map((label) => (
|
|
14
|
+
<Badge key={label} shape="rounded" hasDot={false}>
|
|
15
|
+
{label}
|
|
16
|
+
</Badge>
|
|
17
|
+
))}
|
|
18
|
+
</BadgeGroup>,
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** jsdom has no layout: pretend the group is 100px wide, every badge 40px
|
|
23
|
+
* and the `+N more` chip 50px — so one badge plus the chip fits. */
|
|
24
|
+
function mockLayout() {
|
|
25
|
+
vi.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(100)
|
|
26
|
+
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
|
|
27
|
+
const width = this.textContent?.startsWith('+') ? 50 : 40
|
|
28
|
+
return {
|
|
29
|
+
width,
|
|
30
|
+
height: 20,
|
|
31
|
+
top: 0,
|
|
32
|
+
left: 0,
|
|
33
|
+
right: width,
|
|
34
|
+
bottom: 20,
|
|
35
|
+
x: 0,
|
|
36
|
+
y: 0,
|
|
37
|
+
toJSON: () => ({}),
|
|
38
|
+
} as DOMRect
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('fitBadgeCount', () => {
|
|
43
|
+
const metrics = { itemWidths: [40, 40, 40], overflowWidth: 50, gap: 6 }
|
|
44
|
+
|
|
45
|
+
it('shows everything when the whole row fits', () => {
|
|
46
|
+
expect(fitBadgeCount(metrics, 132)).toBe(3)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('reserves room for the overflow chip once the row overflows', () => {
|
|
50
|
+
// 131px: the full row (132) no longer fits; one item + gap + chip (96) does,
|
|
51
|
+
// two items + gaps + chip (142) does not.
|
|
52
|
+
expect(fitBadgeCount(metrics, 131)).toBe(1)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('shows only the chip when not even one item fits beside it', () => {
|
|
56
|
+
expect(fitBadgeCount(metrics, 80)).toBe(0)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('shows everything when there is no layout to measure against', () => {
|
|
60
|
+
expect(fitBadgeCount(metrics, 0)).toBe(3)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('BadgeGroup', () => {
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
vi.restoreAllMocks()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('renders every badge and no chip while the row fits', () => {
|
|
70
|
+
renderGroup()
|
|
71
|
+
for (const label of labels) expect(screen.getByText(label)).toBeInTheDocument()
|
|
72
|
+
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('collapses the badges that do not fit into a +N more chip', () => {
|
|
76
|
+
mockLayout()
|
|
77
|
+
renderGroup()
|
|
78
|
+
expect(screen.getByText('sap')).toBeInTheDocument()
|
|
79
|
+
expect(screen.queryByText('transporeon')).not.toBeInTheDocument()
|
|
80
|
+
expect(screen.queryByText('test')).not.toBeInTheDocument()
|
|
81
|
+
expect(screen.getByRole('button', { name: '+2 more' })).toBeInTheDocument()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('lists every value in a tooltip on the chip', async () => {
|
|
85
|
+
mockLayout()
|
|
86
|
+
const user = userEvent.setup()
|
|
87
|
+
renderGroup()
|
|
88
|
+
await user.tab()
|
|
89
|
+
const tooltip = await screen.findByRole('tooltip')
|
|
90
|
+
for (const label of labels) expect(tooltip).toHaveTextContent(label)
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('BadgeGroup overflow chip', () => {
|
|
95
|
+
afterEach(() => {
|
|
96
|
+
vi.restoreAllMocks()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('opens a popover with every value on click, without reaching the host', async () => {
|
|
100
|
+
mockLayout()
|
|
101
|
+
const user = userEvent.setup()
|
|
102
|
+
const onHost = vi.fn()
|
|
103
|
+
render(
|
|
104
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: test host mimicking a clickable table row
|
|
105
|
+
// biome-ignore lint/a11y/useKeyWithClickEvents: same
|
|
106
|
+
<div onClick={onHost}>
|
|
107
|
+
<BadgeGroup>
|
|
108
|
+
{labels.map((label) => (
|
|
109
|
+
<Badge key={label} shape="rounded" hasDot={false}>
|
|
110
|
+
{label}
|
|
111
|
+
</Badge>
|
|
112
|
+
))}
|
|
113
|
+
</BadgeGroup>
|
|
114
|
+
</div>,
|
|
115
|
+
)
|
|
116
|
+
await user.click(screen.getByRole('button', { name: '+2 more' }))
|
|
117
|
+
const dialog = await screen.findByRole('dialog')
|
|
118
|
+
for (const label of labels) expect(dialog).toHaveTextContent(label)
|
|
119
|
+
expect(onHost).not.toHaveBeenCalled()
|
|
120
|
+
})
|
|
121
|
+
})
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
type HTMLAttributes,
|
|
4
|
+
isValidElement,
|
|
5
|
+
type ReactNode,
|
|
6
|
+
useEffect,
|
|
7
|
+
useLayoutEffect,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
} from 'react'
|
|
11
|
+
import { Button as AriaButton } from 'react-aria-components'
|
|
12
|
+
import { tv, type VariantProps } from 'tailwind-variants'
|
|
13
|
+
|
|
14
|
+
import { cn } from '../../lib/cn'
|
|
15
|
+
import { badgeStyles } from '../badge/badge'
|
|
16
|
+
import { Popover, PopoverContent } from '../popover/popover'
|
|
17
|
+
import { Tooltip, TooltipTrigger } from '../tooltip/tooltip'
|
|
18
|
+
|
|
19
|
+
/* =========================================================================
|
|
20
|
+
BadgeGroup — a single-line row of badges that collapses whatever does
|
|
21
|
+
not fit into a `+N more` chip. Hovering or focusing that chip peeks at
|
|
22
|
+
every badge in a Tooltip; clicking it pins them in a Popover. The click
|
|
23
|
+
stops at the chip, so a clickable host row (a table row that opens the
|
|
24
|
+
record) is not triggered by reading the overflow.
|
|
25
|
+
|
|
26
|
+
Sibling of AvatarGroup, but fit-driven instead of count-driven: the
|
|
27
|
+
group measures its children once (a layout pass before first paint),
|
|
28
|
+
then re-fits on every container resize from those cached widths.
|
|
29
|
+
|
|
30
|
+
<BadgeGroup>
|
|
31
|
+
<Badge shape="rounded" hasDot={false}>sap</Badge>
|
|
32
|
+
<Badge shape="rounded" hasDot={false}>transporeon</Badge>
|
|
33
|
+
</BadgeGroup>
|
|
34
|
+
|
|
35
|
+
Children render as-is — any inline element works, not only Badge. The
|
|
36
|
+
group is `w-full`, so it fills the read-only field or table cell it sits
|
|
37
|
+
in: the parent decides the width, the group decides the count.
|
|
38
|
+
========================================================================= */
|
|
39
|
+
|
|
40
|
+
export const badgeGroupStyles = tv({
|
|
41
|
+
base: 'flex flex-nowrap items-center gap-1.5 w-full min-w-0',
|
|
42
|
+
variants: {
|
|
43
|
+
// The measuring pass renders every chip plus the overflow chip so their
|
|
44
|
+
// widths can be read; it is hidden so that pass never paints.
|
|
45
|
+
isMeasuring: { true: 'invisible', false: '' },
|
|
46
|
+
},
|
|
47
|
+
defaultVariants: { isMeasuring: false },
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
export type BadgeGroupVariants = VariantProps<typeof badgeGroupStyles>
|
|
51
|
+
|
|
52
|
+
export type BadgeGroupProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
|
|
53
|
+
children: ReactNode
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Measured once per set of children; re-fitting on resize needs no DOM read. */
|
|
57
|
+
export type BadgeGroupMetrics = {
|
|
58
|
+
itemWidths: number[]
|
|
59
|
+
overflowWidth: number
|
|
60
|
+
gap: number
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** How many leading items fit in `available` px, reserving room for the
|
|
64
|
+
* overflow chip whenever not all of them do. `0` means only the chip shows. */
|
|
65
|
+
export function fitBadgeCount(metrics: BadgeGroupMetrics, available: number): number {
|
|
66
|
+
const { itemWidths, overflowWidth, gap } = metrics
|
|
67
|
+
const total = itemWidths.length
|
|
68
|
+
// No layout to measure against (jsdom, display:none) — show everything.
|
|
69
|
+
if (available <= 0) return total
|
|
70
|
+
let fullWidth = 0
|
|
71
|
+
for (let index = 0; index < total; index += 1) {
|
|
72
|
+
fullWidth += (itemWidths[index] ?? 0) + (index > 0 ? gap : 0)
|
|
73
|
+
}
|
|
74
|
+
if (fullWidth <= available) return total
|
|
75
|
+
let used = 0
|
|
76
|
+
let count = 0
|
|
77
|
+
for (let index = 0; index < total; index += 1) {
|
|
78
|
+
// Each shown item is followed by a gap — before the next item or the chip.
|
|
79
|
+
const next = used + (itemWidths[index] ?? 0) + gap
|
|
80
|
+
if (next + overflowWidth > available) break
|
|
81
|
+
used = next
|
|
82
|
+
count = index + 1
|
|
83
|
+
}
|
|
84
|
+
return count
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
type Layout = {
|
|
88
|
+
/** Identity of the children the layout was computed for. */
|
|
89
|
+
signature: string
|
|
90
|
+
/** `null` = measuring pass: every item + the chip render, then get measured. */
|
|
91
|
+
visibleCount: number | null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The `+N more` chip: a neutral rounded Badge that is also a press target
|
|
95
|
+
* (popover) and a hover / focus target (tooltip). */
|
|
96
|
+
export const badgeGroupOverflowStyles = tv({
|
|
97
|
+
base: [
|
|
98
|
+
badgeStyles({ variant: 'neutral', shape: 'rounded', hasDot: false }),
|
|
99
|
+
// badgeStyles' register still materializes an empty ::before — hide it,
|
|
100
|
+
// as Badge itself does when no dot should show.
|
|
101
|
+
'before:hidden shrink-0 cursor-pointer whitespace-nowrap',
|
|
102
|
+
'hover:bg-surface-2 hover:text-ink',
|
|
103
|
+
'outline-none focus-visible:shadow-[var(--shadow-focus)]',
|
|
104
|
+
],
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
export function BadgeGroup({ children, className, ...props }: BadgeGroupProps) {
|
|
108
|
+
const items = Children.toArray(children)
|
|
109
|
+
const signature = items
|
|
110
|
+
.map((item, index) => (isValidElement(item) ? String(item.key ?? index) : String(item)))
|
|
111
|
+
.join('\u001f')
|
|
112
|
+
|
|
113
|
+
const rootRef = useRef<HTMLDivElement>(null)
|
|
114
|
+
const metricsRef = useRef<BadgeGroupMetrics | null>(null)
|
|
115
|
+
const [layout, setLayout] = useState<Layout>({ signature, visibleCount: null })
|
|
116
|
+
|
|
117
|
+
// New children — forget the cached widths and take a fresh measuring pass.
|
|
118
|
+
if (layout.signature !== signature) {
|
|
119
|
+
metricsRef.current = null
|
|
120
|
+
setLayout({ signature, visibleCount: null })
|
|
121
|
+
}
|
|
122
|
+
const measuring = layout.signature !== signature || layout.visibleCount === null
|
|
123
|
+
const visibleCount = measuring ? items.length : (layout.visibleCount ?? items.length)
|
|
124
|
+
const shown = items.slice(0, visibleCount)
|
|
125
|
+
// While measuring, the chip carries the widest label it can ever need.
|
|
126
|
+
const hidden = measuring ? items.length : items.length - visibleCount
|
|
127
|
+
|
|
128
|
+
// Measuring pass: read every item's natural width plus the chip's, then
|
|
129
|
+
// commit the fitting count before the browser paints.
|
|
130
|
+
useLayoutEffect(() => {
|
|
131
|
+
if (!measuring) return
|
|
132
|
+
const root = rootRef.current
|
|
133
|
+
if (!root) return
|
|
134
|
+
const nodes = Array.from(root.children) as HTMLElement[]
|
|
135
|
+
const metrics: BadgeGroupMetrics = {
|
|
136
|
+
itemWidths: nodes.slice(0, items.length).map((node) => node.getBoundingClientRect().width),
|
|
137
|
+
overflowWidth: nodes[items.length]?.getBoundingClientRect().width ?? 0,
|
|
138
|
+
gap: Number.parseFloat(getComputedStyle(root).columnGap) || 0,
|
|
139
|
+
}
|
|
140
|
+
metricsRef.current = metrics
|
|
141
|
+
setLayout({ signature, visibleCount: fitBadgeCount(metrics, root.clientWidth) })
|
|
142
|
+
}, [measuring, signature, items.length])
|
|
143
|
+
|
|
144
|
+
// Container resized — re-fit from the cached widths, no re-measure needed.
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
const root = rootRef.current
|
|
147
|
+
if (!root || typeof ResizeObserver === 'undefined') return
|
|
148
|
+
const observer = new ResizeObserver(() => {
|
|
149
|
+
const metrics = metricsRef.current
|
|
150
|
+
if (!metrics) return
|
|
151
|
+
const next = fitBadgeCount(metrics, root.clientWidth)
|
|
152
|
+
setLayout((prev) => (prev.visibleCount === next ? prev : { ...prev, visibleCount: next }))
|
|
153
|
+
})
|
|
154
|
+
observer.observe(root)
|
|
155
|
+
return () => observer.disconnect()
|
|
156
|
+
}, [])
|
|
157
|
+
|
|
158
|
+
// Web fonts landing after mount change every width — measure again once.
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
const fonts = typeof document === 'undefined' ? undefined : document.fonts
|
|
161
|
+
if (!fonts || fonts.status === 'loaded') return
|
|
162
|
+
let cancelled = false
|
|
163
|
+
void fonts.ready.then(() => {
|
|
164
|
+
if (cancelled) return
|
|
165
|
+
metricsRef.current = null
|
|
166
|
+
setLayout((prev) => ({ ...prev, visibleCount: null }))
|
|
167
|
+
})
|
|
168
|
+
return () => {
|
|
169
|
+
cancelled = true
|
|
170
|
+
}
|
|
171
|
+
}, [])
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<div
|
|
175
|
+
{...props}
|
|
176
|
+
ref={rootRef}
|
|
177
|
+
className={cn(badgeGroupStyles({ isMeasuring: measuring }), className)}
|
|
178
|
+
>
|
|
179
|
+
{shown.map((item, index) => (
|
|
180
|
+
<span
|
|
181
|
+
key={(isValidElement(item) ? item.key : null) ?? index}
|
|
182
|
+
className="inline-flex shrink-0"
|
|
183
|
+
>
|
|
184
|
+
{item}
|
|
185
|
+
</span>
|
|
186
|
+
))}
|
|
187
|
+
{hidden > 0 ? (
|
|
188
|
+
// The span swallows the click before it reaches a clickable host row
|
|
189
|
+
// (React propagation), so opening the overflow never opens the record.
|
|
190
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: click fence only — the button inside is the interactive element
|
|
191
|
+
// biome-ignore lint/a11y/useKeyWithClickEvents: keyboard handling lives on the button inside
|
|
192
|
+
<span className="inline-flex shrink-0" onClick={(event) => event.stopPropagation()}>
|
|
193
|
+
<Popover>
|
|
194
|
+
<TooltipTrigger>
|
|
195
|
+
<AriaButton className={badgeGroupOverflowStyles()}>+{hidden} more</AriaButton>
|
|
196
|
+
<Tooltip className="max-w-sm">
|
|
197
|
+
<span className="flex flex-wrap items-center gap-1.5">{items}</span>
|
|
198
|
+
</Tooltip>
|
|
199
|
+
</TooltipTrigger>
|
|
200
|
+
<PopoverContent placement="bottom start" panelClassName="max-w-sm">
|
|
201
|
+
<div className="flex flex-wrap items-center gap-1.5 p-2.5">{items}</div>
|
|
202
|
+
</PopoverContent>
|
|
203
|
+
</Popover>
|
|
204
|
+
</span>
|
|
205
|
+
) : null}
|
|
206
|
+
</div>
|
|
207
|
+
)
|
|
208
|
+
}
|
|
@@ -199,3 +199,28 @@ export const AllStates: Story = {
|
|
|
199
199
|
)
|
|
200
200
|
},
|
|
201
201
|
}
|
|
202
|
+
|
|
203
|
+
/** The inline table-cell editor: fixed 29px box in a narrow column, chips
|
|
204
|
+
* on one line, listbox open from the first render. */
|
|
205
|
+
export const CellEditor: Story = {
|
|
206
|
+
render: () => {
|
|
207
|
+
const [selected, setSelected] = useState<Set<string>>(
|
|
208
|
+
new Set(['in-transit', 'quoted', 'reconciled', 'past-due']),
|
|
209
|
+
)
|
|
210
|
+
return (
|
|
211
|
+
<div className="w-[220px] h-[36px] flex items-center border-y border-rule px-[14px]">
|
|
212
|
+
<BadgeMultiSelect<string>
|
|
213
|
+
aria-label="Status"
|
|
214
|
+
size="sm"
|
|
215
|
+
appearance="cell"
|
|
216
|
+
options={statuses}
|
|
217
|
+
selectedKeys={selected}
|
|
218
|
+
onSelectionChange={setSelected}
|
|
219
|
+
autoFocus
|
|
220
|
+
defaultOpen
|
|
221
|
+
className="w-full"
|
|
222
|
+
/>
|
|
223
|
+
</div>
|
|
224
|
+
)
|
|
225
|
+
},
|
|
226
|
+
}
|