@proteos/ui 0.54.1 → 0.56.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 (33) hide show
  1. package/dist/index.cjs +679 -321
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +84 -9
  4. package/dist/index.d.ts +84 -9
  5. package/dist/index.js +677 -324
  6. package/dist/index.js.map +1 -1
  7. package/package.json +4 -4
  8. package/src/components/badge/badge.stories.tsx +37 -1
  9. package/src/components/badge/badge.test.tsx +39 -2
  10. package/src/components/badge/badge.tsx +65 -1
  11. package/src/components/badge/index.ts +2 -2
  12. package/src/components/badge-group/badge-group.stories.tsx +75 -0
  13. package/src/components/badge-group/badge-group.test.tsx +121 -0
  14. package/src/components/badge-group/badge-group.tsx +208 -0
  15. package/src/components/badge-group/index.ts +7 -0
  16. package/src/components/badge-multi-select/badge-multi-select.stories.tsx +25 -0
  17. package/src/components/badge-multi-select/badge-multi-select.test.tsx +110 -1
  18. package/src/components/badge-multi-select/badge-multi-select.tsx +190 -128
  19. package/src/components/data-table/data-table.stories.tsx +53 -2
  20. package/src/components/data-table/data-table.tsx +45 -25
  21. package/src/components/field/listbox-check.tsx +44 -0
  22. package/src/components/field/use-chip-row-wrap.ts +45 -0
  23. package/src/components/multi-select/multi-select.tsx +8 -39
  24. package/src/components/record-filter/chip-value.tsx +6 -5
  25. package/src/components/record-filter/filter-value-input.tsx +11 -9
  26. package/src/components/record-filter/operators.test.ts +67 -12
  27. package/src/components/record-filter/operators.ts +43 -2
  28. package/src/components/record-filter/types.ts +6 -1
  29. package/src/components/table/table.test.tsx +88 -9
  30. package/src/components/table/table.tsx +139 -13
  31. package/src/components/tag-input/tag-input.test.tsx +67 -1
  32. package/src/components/tag-input/tag-input.tsx +98 -64
  33. package/src/index.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteos/ui",
3
- "version": "0.54.1",
3
+ "version": "0.56.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.54.1"
59
+ "@proteos/sdk": "0.56.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/tsconfig": "0.0.0",
82
- "@proteos/biome-config": "0.0.0"
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 { describe, expect, it } from 'vitest'
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
+ }
@@ -0,0 +1,7 @@
1
+ export type { BadgeGroupMetrics, BadgeGroupProps, BadgeGroupVariants } from './badge-group'
2
+ export {
3
+ BadgeGroup,
4
+ badgeGroupOverflowStyles,
5
+ badgeGroupStyles,
6
+ fitBadgeCount,
7
+ } from './badge-group'
@@ -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
+ }