@estiva-app/ui 0.6.0 → 0.8.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/README.md +23 -6
- package/dist/Button.d.ts +12 -5
- package/dist/Button.d.ts.map +1 -1
- package/dist/Checkbox.d.ts +19 -5
- package/dist/Checkbox.d.ts.map +1 -1
- package/dist/IconButton.d.ts +9 -2
- package/dist/IconButton.d.ts.map +1 -1
- package/dist/PersonTrigger.d.ts +2 -1
- package/dist/PersonTrigger.d.ts.map +1 -1
- package/dist/Reaction.d.ts +54 -0
- package/dist/Reaction.d.ts.map +1 -0
- package/dist/Tabs.d.ts +4 -1
- package/dist/Tabs.d.ts.map +1 -1
- package/dist/cn.d.ts +10 -1
- package/dist/cn.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +383 -329
- package/dist/index.js.map +4 -4
- package/package.json +17 -3
- package/src/AppShell.stories.tsx +7 -1
- package/src/AppShell.tsx +1 -1
- package/src/Avatar.stories.tsx +3 -1
- package/src/Banner.stories.tsx +6 -1
- package/src/Banner.tsx +2 -2
- package/src/Breadcrumb.stories.tsx +3 -0
- package/src/Button.mdx +17 -3
- package/src/Button.stories.tsx +4 -1
- package/src/Button.test.tsx +119 -0
- package/src/Button.tsx +37 -23
- package/src/Checkbox.mdx +17 -6
- package/src/Checkbox.stories.tsx +10 -5
- package/src/Checkbox.test.tsx +73 -0
- package/src/Checkbox.tsx +52 -25
- package/src/Chip.stories.tsx +4 -0
- package/src/Chip.tsx +5 -5
- package/src/ChipInput.stories.tsx +4 -0
- package/src/DialogShell.tsx +1 -1
- package/src/EditableText.stories.tsx +6 -1
- package/src/IconButton.mdx +12 -1
- package/src/IconButton.stories.tsx +9 -4
- package/src/IconButton.test.tsx +102 -0
- package/src/IconButton.tsx +30 -17
- package/src/IdentityMenu.stories.tsx +5 -1
- package/src/Kbd.tsx +2 -2
- package/src/NavItem.stories.tsx +3 -0
- package/src/Person.stories.tsx +3 -0
- package/src/PersonTrigger.mdx +9 -1
- package/src/PersonTrigger.stories.tsx +3 -0
- package/src/PersonTrigger.test.tsx +52 -0
- package/src/PersonTrigger.tsx +8 -9
- package/src/Reaction.mdx +49 -0
- package/src/Reaction.stories.tsx +49 -0
- package/src/Reaction.tsx +86 -0
- package/src/Select.stories.tsx +7 -2
- package/src/Sidebar.stories.tsx +6 -0
- package/src/Tabs.mdx +13 -4
- package/src/Tabs.test.tsx +117 -0
- package/src/Tabs.tsx +45 -34
- package/src/Toast.tsx +8 -8
- package/src/TopBar.stories.tsx +7 -1
- package/src/cn.test.ts +20 -1
- package/src/cn.ts +18 -2
- package/src/index.ts +1 -0
- package/stories/Choosing.mdx +101 -0
- package/stories/DesignTokens.mdx +10 -0
- package/stories/GettingStarted.mdx +108 -0
- package/stories/Introduction.mdx +36 -0
- package/stories/TokensPage.tsx +293 -0
- package/tailwind-preset.js +17 -0
- package/tokens.css +48 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { fn } from 'storybook/test'
|
|
3
|
+
import { Reaction } from './Reaction'
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Components/Reaction',
|
|
7
|
+
component: Reaction,
|
|
8
|
+
args: { emoji: '👍', count: 2, 'aria-label': 'Makes sense, 2', onClick: fn() },
|
|
9
|
+
} satisfies Meta<typeof Reaction>
|
|
10
|
+
|
|
11
|
+
export default meta
|
|
12
|
+
type Story = StoryObj<typeof meta>
|
|
13
|
+
|
|
14
|
+
/** Somebody else's reaction: the inset fill and a hairline, like a neutral Chip. */
|
|
15
|
+
export const Default: Story = {}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Yours.
|
|
19
|
+
*
|
|
20
|
+
* The state a reaction has and a chip does not — the accent's muted tint and an
|
|
21
|
+
* accent edge, so a glance separates "two people, one of them me" from "two
|
|
22
|
+
* people". `aria-pressed` carries the same fact to assistive tech.
|
|
23
|
+
*/
|
|
24
|
+
export const Pressed: Story = { args: { pressed: true } }
|
|
25
|
+
|
|
26
|
+
/** A row of them, as a message carries them. */
|
|
27
|
+
export const Row: Story = {
|
|
28
|
+
render: (args) => (
|
|
29
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
30
|
+
<Reaction {...args} emoji="👍" count={3} aria-label="Makes sense, 3" pressed />
|
|
31
|
+
<Reaction {...args} emoji="🎉" count={1} aria-label="Congrats, 1" />
|
|
32
|
+
<Reaction {...args} emoji="🚀" count={12} aria-label="Let's go!, 12" />
|
|
33
|
+
</div>
|
|
34
|
+
),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A count with two digits, so the pill grows rather than clipping. */
|
|
38
|
+
export const WideCount: Story = { args: { count: 128, 'aria-label': 'Makes sense, 128' } }
|
|
39
|
+
|
|
40
|
+
/** Momentarily unavailable — signing in, or a write already in flight. */
|
|
41
|
+
export const Disabled: Story = { args: { disabled: true } }
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* An emoji from outside any curated set.
|
|
45
|
+
*
|
|
46
|
+
* Another client may publish anything, and a reaction a consumer cannot name is
|
|
47
|
+
* still one it must draw rather than hide — so nothing here validates the glyph.
|
|
48
|
+
*/
|
|
49
|
+
export const UnknownEmoji: Story = { args: { emoji: '🦆', count: 1, 'aria-label': '🦆, 1' } }
|
package/src/Reaction.tsx
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
|
2
|
+
import { cn } from './cn'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A reaction: an emoji, how many people chose it, and whether you are one of
|
|
6
|
+
* them.
|
|
7
|
+
*
|
|
8
|
+
* **This exists because both apps had already built it, and neither could use
|
|
9
|
+
* the components that were here.** A reaction is a pill with a count that
|
|
10
|
+
* toggles — `Chip` is a pill and rules itself out for anything clickable,
|
|
11
|
+
* `Button` is clickable and is a 6px-radius rectangle, and `IconButton` has
|
|
12
|
+
* nowhere to put the count. So Peek re-typed Chip's class list with a border
|
|
13
|
+
* added, and Ship reached for a small Button with the count as its label. The
|
|
14
|
+
* two apps' reactions do not look alike today, and neither is what was drawn.
|
|
15
|
+
*
|
|
16
|
+
* ## The one thing it does that nothing else here does
|
|
17
|
+
*
|
|
18
|
+
* **It says the reaction is yours.** That is the state a reaction has and a
|
|
19
|
+
* chip does not: `pressed` fills it with the accent's muted tint and gives it
|
|
20
|
+
* an accent edge, so a glance separates "two people, one of them me" from "two
|
|
21
|
+
* people". Ship approximated it as `outlined` versus `muted` — measured at a
|
|
22
|
+
* 1px hairline against no border at all — which is legible and is not the
|
|
23
|
+
* signal Peek's accent fill gives.
|
|
24
|
+
*
|
|
25
|
+
* It is a real `<button>` with `aria-pressed`, so the state reaches assistive
|
|
26
|
+
* tech as a toggle rather than as a colour.
|
|
27
|
+
*
|
|
28
|
+
* ## `aria-label` is required, and the reason is specific
|
|
29
|
+
*
|
|
30
|
+
* The emoji is decorative here — it is `aria-hidden`, because a glyph read
|
|
31
|
+
* aloud is noise and its spoken name differs per screen reader — and the only
|
|
32
|
+
* visible text is the count. Without a label the control is announced as
|
|
33
|
+
* **"2"**, which is what Ship shipped and its own tests caught. Pass the
|
|
34
|
+
* meaning and the count: `"Makes sense, 2"`.
|
|
35
|
+
*
|
|
36
|
+
* ## Geometry
|
|
37
|
+
*
|
|
38
|
+
* Chip's pill, at a control's height: fully rounded, 24px to match `Button`
|
|
39
|
+
* `small`, the same 8px horizontal padding, the `chip` type token for the
|
|
40
|
+
* count so it sits at 11px/500 like every other count in the system.
|
|
41
|
+
*/
|
|
42
|
+
export interface ReactionProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
|
43
|
+
/** The emoji, drawn decoratively — name the control with `aria-label`. */
|
|
44
|
+
emoji: ReactNode
|
|
45
|
+
/** How many people reacted. Drawn as-is; `0` is not a reaction and is not drawn. */
|
|
46
|
+
count: number
|
|
47
|
+
/** You are one of them: the accent tint and edge, and `aria-pressed`. */
|
|
48
|
+
pressed?: boolean
|
|
49
|
+
/**
|
|
50
|
+
* Names the control. **Required** — the emoji is decorative and the count is
|
|
51
|
+
* the only visible text, so without this it is announced as a bare number.
|
|
52
|
+
*/
|
|
53
|
+
'aria-label': string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function Reaction({ emoji, count, pressed = false, className, type, ...props }: ReactionProps) {
|
|
57
|
+
return (
|
|
58
|
+
<button
|
|
59
|
+
type={type ?? 'button'}
|
|
60
|
+
aria-pressed={pressed}
|
|
61
|
+
className={cn(
|
|
62
|
+
// Chip's pill at a control's height, so a reaction and a status chip
|
|
63
|
+
// read as the same family — 24px matches Button `small`.
|
|
64
|
+
'inline-flex h-6 items-center justify-center gap-1.5 rounded-full px-2',
|
|
65
|
+
'border transition-colors',
|
|
66
|
+
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
67
|
+
pressed
|
|
68
|
+
? 'border-accent-primary bg-accent-muted text-accent-primary hover:border-accent-hover'
|
|
69
|
+
: 'border-border-default bg-bg-inset text-text-primary hover:border-border-strong hover:bg-bg-hover',
|
|
70
|
+
className,
|
|
71
|
+
)}
|
|
72
|
+
{...props}
|
|
73
|
+
>
|
|
74
|
+
{/* Decorative: the control is named by `aria-label`, and a glyph read
|
|
75
|
+
aloud is noise. 16px so the emoji is legible at chip scale. */}
|
|
76
|
+
<span aria-hidden="true" className="shrink-0 text-[16px] leading-none">
|
|
77
|
+
{emoji}
|
|
78
|
+
</span>
|
|
79
|
+
{/* `text-chip` is a plain class, never merged — the same guard Chip uses,
|
|
80
|
+
because tailwind-merge drops a token size that follows a text colour. */}
|
|
81
|
+
<span className="text-chip signal:font-mono signal:text-[10px] signal:font-semibold signal:tabular-nums">
|
|
82
|
+
{count}
|
|
83
|
+
</span>
|
|
84
|
+
</button>
|
|
85
|
+
)
|
|
86
|
+
}
|
package/src/Select.stories.tsx
CHANGED
|
@@ -49,7 +49,12 @@ function Demo(props: Omit<ComponentProps<typeof Select>, 'value' | 'onChange'> &
|
|
|
49
49
|
export const Default: Story = { render: (args) => <Demo {...args} initial="in_progress" /> }
|
|
50
50
|
|
|
51
51
|
/** Nothing chosen yet: the placeholder is muted, so an empty select does not read as one already holding a value. */
|
|
52
|
-
export const Empty: Story = {
|
|
52
|
+
export const Empty: Story = {
|
|
53
|
+
render: (args) => <Demo {...args} initial="" placeholder="Choose a status…" />,
|
|
54
|
+
// axe color-contrast is off here until PLAN.md stage 0.10 is ruled:
|
|
55
|
+
// the placeholder is muted text, 3.21:1 on the field in signal (AA 4.5:1).
|
|
56
|
+
parameters: { a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } } },
|
|
57
|
+
}
|
|
53
58
|
|
|
54
59
|
/** The dense size, matching `Button`'s small — for property rows, where a full-height field would dominate the label beside it. */
|
|
55
60
|
export const Small: Story = { render: (args) => <Demo {...args} size="small" initial="todo" /> }
|
|
@@ -181,7 +186,7 @@ export const LongLabelInPropertyRow: Story = {
|
|
|
181
186
|
render: (args) => (
|
|
182
187
|
<div className="w-[300px] rounded-lg border border-border-subtle p-3">
|
|
183
188
|
<div className="flex items-center gap-2">
|
|
184
|
-
<span className="w-[68px] shrink-0 text-
|
|
189
|
+
<span className="w-[68px] shrink-0 text-caption text-text-secondary">Lead</span>
|
|
185
190
|
<Demo
|
|
186
191
|
{...args}
|
|
187
192
|
size="small"
|
package/src/Sidebar.stories.tsx
CHANGED
|
@@ -20,6 +20,9 @@ type Story = StoryObj<typeof meta>
|
|
|
20
20
|
|
|
21
21
|
/** Entries, then a labelled group — the heading is a SectionLabel in a 32px row. */
|
|
22
22
|
export const Composed: Story = {
|
|
23
|
+
// axe color-contrast is off here until PLAN.md stage 0.10 is ruled:
|
|
24
|
+
// the count chip is muted text, 3.06:1 on the active row in signal (AA 4.5:1).
|
|
25
|
+
parameters: { a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } } },
|
|
23
26
|
render: (args) => (
|
|
24
27
|
<Sidebar {...args}>
|
|
25
28
|
<NavItem href="#" label="Item one" icon={placeholder} count={18} countLabel="18 open" active />
|
|
@@ -36,6 +39,9 @@ export const Composed: Story = {
|
|
|
36
39
|
|
|
37
40
|
/** The column scrolls on its own — a long list never scrolls the frame away. */
|
|
38
41
|
export const Scrolls: Story = {
|
|
42
|
+
// axe color-contrast is off here until PLAN.md stage 0.10 is ruled:
|
|
43
|
+
// the count chips are muted text, 3.78:1 on --bg-surface in signal (AA 4.5:1).
|
|
44
|
+
parameters: { a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } } },
|
|
39
45
|
render: (args) => (
|
|
40
46
|
<Sidebar {...args}>
|
|
41
47
|
<NavItem href="#" label="Item one" icon={placeholder} count={18} countLabel="18 open" />
|
package/src/Tabs.mdx
CHANGED
|
@@ -43,10 +43,19 @@ import { Tabs } from '@estiva-app/ui'
|
|
|
43
43
|
```
|
|
44
44
|
|
|
45
45
|
- Generic over the id type — `onChange` hands back your union, not a
|
|
46
|
-
string.
|
|
47
|
-
-
|
|
48
|
-
|
|
49
|
-
panels — what shows for the active tab — is the caller's.
|
|
46
|
+
string, and only for a person's choice.
|
|
47
|
+
- Built on Base UI Tabs: `role="tablist"` / `role="tab"` with
|
|
48
|
+
`aria-selected`, one Tab stop for the row, arrow keys between the tabs.
|
|
49
|
+
Wiring the panels — what shows for the active tab — is the caller's.
|
|
50
|
+
- `className` lands on the outer box, around the row.
|
|
51
|
+
|
|
52
|
+
## Keys
|
|
53
|
+
|
|
54
|
+
| Key | Does |
|
|
55
|
+
|---|---|
|
|
56
|
+
| Tab | Into the row, onto the selected tab; out of it on the next press. |
|
|
57
|
+
| ← → | Select the previous / next tab. The ends wrap. |
|
|
58
|
+
| Home / End | Select the first / last tab. |
|
|
50
59
|
|
|
51
60
|
## Props
|
|
52
61
|
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* What the Tabs page claims about the keyboard, pinned. The keys come from
|
|
4
|
+
* Base UI's Tabs (stage 1 of the migration); before it, every tab was its own
|
|
5
|
+
* Tab stop and the arrow keys did nothing.
|
|
6
|
+
*/
|
|
7
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
8
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
9
|
+
import userEvent from '@testing-library/user-event'
|
|
10
|
+
import { useState } from 'react'
|
|
11
|
+
import { Tabs, type TabDef } from './Tabs'
|
|
12
|
+
|
|
13
|
+
afterEach(cleanup)
|
|
14
|
+
|
|
15
|
+
type Id = 'one' | 'two' | 'three'
|
|
16
|
+
const THREE: TabDef<Id>[] = [
|
|
17
|
+
{ id: 'one', label: 'One' },
|
|
18
|
+
{ id: 'two', label: 'Two', count: 3 },
|
|
19
|
+
{ id: 'three', label: 'Three' },
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
/** A parent that owns the state, as every caller does. */
|
|
23
|
+
function Harness({ onChange, initial = 'one' }: { onChange?: (id: Id) => void; initial?: Id }) {
|
|
24
|
+
const [active, setActive] = useState<Id>(initial)
|
|
25
|
+
return (
|
|
26
|
+
<>
|
|
27
|
+
<button type="button">Before</button>
|
|
28
|
+
<Tabs
|
|
29
|
+
tabs={THREE}
|
|
30
|
+
active={active}
|
|
31
|
+
onChange={(id) => {
|
|
32
|
+
onChange?.(id)
|
|
33
|
+
setActive(id)
|
|
34
|
+
}}
|
|
35
|
+
/>
|
|
36
|
+
<button type="button">After</button>
|
|
37
|
+
</>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const tab = (name: string) => screen.getByRole('tab', { name: new RegExp(`^${name}`) })
|
|
42
|
+
const selected = (name: string) => tab(name).getAttribute('aria-selected')
|
|
43
|
+
const tabindex = (name: string) => tab(name).getAttribute('tabindex')
|
|
44
|
+
const focused = () => document.activeElement
|
|
45
|
+
|
|
46
|
+
describe('Tabs', () => {
|
|
47
|
+
it('marks the active tab selected, and makes it the only Tab stop in the row', async () => {
|
|
48
|
+
const user = userEvent.setup()
|
|
49
|
+
render(<Harness />)
|
|
50
|
+
expect(selected('One')).toBe('true')
|
|
51
|
+
expect(selected('Two')).toBe('false')
|
|
52
|
+
expect(tabindex('One')).toBe('0')
|
|
53
|
+
expect(tabindex('Two')).toBe('-1')
|
|
54
|
+
expect(tabindex('Three')).toBe('-1')
|
|
55
|
+
|
|
56
|
+
await user.tab()
|
|
57
|
+
expect(focused()).toBe(screen.getByRole('button', { name: 'Before' }))
|
|
58
|
+
await user.tab()
|
|
59
|
+
expect(focused()).toBe(tab('One'))
|
|
60
|
+
await user.tab()
|
|
61
|
+
expect(focused()).toBe(screen.getByRole('button', { name: 'After' }))
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('selects on click and hands back the id', async () => {
|
|
65
|
+
const user = userEvent.setup()
|
|
66
|
+
const onChange = vi.fn()
|
|
67
|
+
render(<Harness onChange={onChange} />)
|
|
68
|
+
await user.click(tab('Two'))
|
|
69
|
+
expect(onChange).toHaveBeenCalledTimes(1)
|
|
70
|
+
expect(onChange).toHaveBeenCalledWith('two')
|
|
71
|
+
expect(selected('Two')).toBe('true')
|
|
72
|
+
expect(selected('One')).toBe('false')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('selects the next and previous tab with the arrow keys, wrapping at the ends', async () => {
|
|
76
|
+
const user = userEvent.setup()
|
|
77
|
+
const onChange = vi.fn()
|
|
78
|
+
render(<Harness onChange={onChange} />)
|
|
79
|
+
await user.click(tab('One'))
|
|
80
|
+
onChange.mockClear()
|
|
81
|
+
|
|
82
|
+
await user.keyboard('{ArrowRight}')
|
|
83
|
+
expect(focused()).toBe(tab('Two'))
|
|
84
|
+
expect(selected('Two')).toBe('true')
|
|
85
|
+
await user.keyboard('{ArrowRight}')
|
|
86
|
+
expect(selected('Three')).toBe('true')
|
|
87
|
+
await user.keyboard('{ArrowRight}')
|
|
88
|
+
expect(selected('One')).toBe('true')
|
|
89
|
+
await user.keyboard('{ArrowLeft}')
|
|
90
|
+
expect(selected('Three')).toBe('true')
|
|
91
|
+
expect(onChange.mock.calls.map(([id]) => id)).toEqual(['two', 'three', 'one', 'three'])
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('selects the first and last tab with Home and End', async () => {
|
|
95
|
+
const user = userEvent.setup()
|
|
96
|
+
render(<Harness initial="two" />)
|
|
97
|
+
await user.click(tab('Two'))
|
|
98
|
+
await user.keyboard('{End}')
|
|
99
|
+
expect(selected('Three')).toBe('true')
|
|
100
|
+
await user.keyboard('{Home}')
|
|
101
|
+
expect(selected('One')).toBe('true')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('stays quiet when `active` matches no tab: onChange is for a person, not a fallback', () => {
|
|
105
|
+
const onChange = vi.fn()
|
|
106
|
+
render(<Tabs tabs={THREE} active={'nowhere' as Id} onChange={onChange} />)
|
|
107
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
108
|
+
expect(screen.getAllByRole('tab').map((t) => t.getAttribute('aria-selected'))).toEqual(['false', 'false', 'false'])
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('puts className on the outer box, around the row', () => {
|
|
112
|
+
const { container } = render(<Tabs tabs={THREE} active="one" onChange={() => {}} className="mt-4" />)
|
|
113
|
+
const outer = container.firstElementChild as HTMLElement
|
|
114
|
+
expect(outer.classList.contains('mt-4')).toBe(true)
|
|
115
|
+
expect(outer.querySelector('[role="tablist"]')).not.toBeNull()
|
|
116
|
+
})
|
|
117
|
+
})
|
package/src/Tabs.tsx
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import type { ReactNode } from 'react'
|
|
2
|
+
import { Tabs as BaseTabs } from '@base-ui/react/tabs'
|
|
2
3
|
import { cn } from './cn'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* A row of tabs. Ship's Tabs (2026-09-01), which was Peek's TopicTabs with
|
|
6
|
-
* the topic-specific ids taken out
|
|
7
|
+
* the topic-specific ids taken out; on Base UI Tabs since stage 1 of the
|
|
8
|
+
* migration (2026-09-06), which is where the keyboard comes from: one Tab
|
|
9
|
+
* stop for the row, arrow keys between the tabs.
|
|
7
10
|
*
|
|
8
11
|
* A selected tab is a neutral fill (bg-active), not the accent tint —
|
|
9
12
|
* Katerina's ruling (2026-08-27), extended to every app (2026-09-01). Two
|
|
@@ -31,43 +34,51 @@ export interface TabsProps<T extends string> {
|
|
|
31
34
|
onChange: (id: T) => void
|
|
32
35
|
/** `default` 14px; `small` 12px, the denser geometry. */
|
|
33
36
|
size?: 'default' | 'small'
|
|
37
|
+
/** Lands on the outer box, around the row. */
|
|
34
38
|
className?: string
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
export function Tabs<T extends string>({ tabs, active, onChange, size = 'default', className }: TabsProps<T>) {
|
|
38
42
|
return (
|
|
39
|
-
<
|
|
40
|
-
{
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
{tab.
|
|
65
|
-
{
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
43
|
+
<BaseTabs.Root
|
|
44
|
+
value={active}
|
|
45
|
+
onValueChange={(value, details) => {
|
|
46
|
+
// Only a person's choice reaches the caller. Base UI also reports its
|
|
47
|
+
// own fallbacks (an `active` that matches no tab), and those carry
|
|
48
|
+
// `null`, which is not a T.
|
|
49
|
+
if (details.reason === 'none') onChange(value as T)
|
|
50
|
+
}}
|
|
51
|
+
className={className}
|
|
52
|
+
>
|
|
53
|
+
<BaseTabs.List activateOnFocus className="flex items-center gap-2">
|
|
54
|
+
{tabs.map((tab) => (
|
|
55
|
+
<BaseTabs.Tab
|
|
56
|
+
key={tab.id}
|
|
57
|
+
value={tab.id}
|
|
58
|
+
className={(state) =>
|
|
59
|
+
cn(
|
|
60
|
+
'flex cursor-pointer items-center transition-colors',
|
|
61
|
+
// gap: default is Ship's 6px; small keeps Peek's original 4px, or
|
|
62
|
+
// "small is Peek's geometry" stops being true.
|
|
63
|
+
size === 'default' ? 'gap-1.5 rounded-md px-2 py-1 text-body-2' : 'gap-1 rounded px-1.5 py-0.5 text-caption',
|
|
64
|
+
state.active ? 'bg-bg-active text-text-primary' : 'text-text-secondary hover:bg-bg-hover',
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
>
|
|
68
|
+
{tab.icon}
|
|
69
|
+
{/* Label and count share a baseline: a smaller text centred as a box
|
|
70
|
+
(items-center) floats above the label's baseline — the digits
|
|
71
|
+
read as riding high. Baseline alignment is what makes two sizes
|
|
72
|
+
sit on one line. */}
|
|
73
|
+
<span className="flex items-baseline">
|
|
74
|
+
{tab.label}
|
|
75
|
+
{tab.count !== undefined ? (
|
|
76
|
+
<span className="ml-2.5 font-mono text-caption tabular-nums text-text-secondary">{tab.count}</span>
|
|
77
|
+
) : null}
|
|
78
|
+
</span>
|
|
79
|
+
</BaseTabs.Tab>
|
|
80
|
+
))}
|
|
81
|
+
</BaseTabs.List>
|
|
82
|
+
</BaseTabs.Root>
|
|
72
83
|
)
|
|
73
84
|
}
|
package/src/Toast.tsx
CHANGED
|
@@ -31,19 +31,19 @@ export interface ToastProps {
|
|
|
31
31
|
|
|
32
32
|
// Signal: every toast is the same dark overlay pill (v3) — the type lives in
|
|
33
33
|
// the icon color + glow, not the surface.
|
|
34
|
-
const
|
|
34
|
+
const SURFACE_STYLES: Record<ToastType, string> = {
|
|
35
35
|
success: 'bg-success-muted signal:bg-bg-inset signal:border signal:border-border-default signal:shadow-[shadow:var(--shadow-md)]',
|
|
36
36
|
brand: 'bg-accent-muted signal:bg-bg-inset signal:border signal:border-border-default signal:shadow-[shadow:var(--shadow-md)]',
|
|
37
37
|
neutral: 'bg-bg-inset border border-border-subtle signal:border-border-default signal:shadow-[shadow:var(--shadow-md)]',
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
const
|
|
41
|
-
success: 'signal:text-success-default signal:drop-shadow-
|
|
42
|
-
brand: 'signal:text-text-interactive signal:drop-shadow-
|
|
40
|
+
const ICON_STYLES: Record<ToastType, string> = {
|
|
41
|
+
success: 'signal:text-success-default signal:drop-shadow-glow-success',
|
|
42
|
+
brand: 'signal:text-text-interactive signal:drop-shadow-glow-accent',
|
|
43
43
|
neutral: 'signal:text-text-secondary',
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
const
|
|
46
|
+
const ACTION_BORDER_STYLES: Record<ToastType, string> = {
|
|
47
47
|
success: 'signal:border signal:border-border-default signal:hover:border-border-strong',
|
|
48
48
|
brand: 'signal:border signal:border-border-default signal:hover:border-border-strong',
|
|
49
49
|
neutral: 'border border-border-default',
|
|
@@ -58,13 +58,13 @@ export function Toast({ label, type = 'neutral', leadingIcon = true, actionLabel
|
|
|
58
58
|
// Without an action the label needs real right padding; the action
|
|
59
59
|
// button brings its own edge, so the tight pr-1 only applies there.
|
|
60
60
|
hasAction ? 'pr-1 gap-[46px]' : 'pr-3',
|
|
61
|
-
|
|
61
|
+
SURFACE_STYLES[type],
|
|
62
62
|
className,
|
|
63
63
|
)}
|
|
64
64
|
>
|
|
65
65
|
<div className="flex items-center gap-2 shrink-0">
|
|
66
66
|
{leadingIcon && (
|
|
67
|
-
<IconCircleCheck size={16} stroke={1.5} className={cn('text-text-primary shrink-0',
|
|
67
|
+
<IconCircleCheck size={16} stroke={1.5} className={cn('text-text-primary shrink-0', ICON_STYLES[type])} />
|
|
68
68
|
)}
|
|
69
69
|
<span className="font-normal text-[14px] leading-[1.4] text-text-primary whitespace-nowrap">{label}</span>
|
|
70
70
|
</div>
|
|
@@ -74,7 +74,7 @@ export function Toast({ label, type = 'neutral', leadingIcon = true, actionLabel
|
|
|
74
74
|
onClick={onAction}
|
|
75
75
|
className={cn(
|
|
76
76
|
'h-6 flex items-center justify-center gap-1 px-1 py-1 rounded-md shrink-0 transition-colors',
|
|
77
|
-
|
|
77
|
+
ACTION_BORDER_STYLES[type],
|
|
78
78
|
type === 'neutral' ? 'hover:border-border-strong' : 'hover:opacity-80',
|
|
79
79
|
)}
|
|
80
80
|
>
|
package/src/TopBar.stories.tsx
CHANGED
|
@@ -14,7 +14,13 @@ import { TopBar } from './TopBar'
|
|
|
14
14
|
const meta = {
|
|
15
15
|
title: 'Frame/TopBar',
|
|
16
16
|
component: TopBar,
|
|
17
|
-
parameters: {
|
|
17
|
+
parameters: {
|
|
18
|
+
layout: 'fullscreen',
|
|
19
|
+
// axe color-contrast is off here until PLAN.md stage 0.10 is ruled:
|
|
20
|
+
// the placeholder content under the bar is muted text, 3.94:1 on --bg-base in
|
|
21
|
+
// signal (AA 4.5:1).
|
|
22
|
+
a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } },
|
|
23
|
+
},
|
|
18
24
|
argTypes: {
|
|
19
25
|
variant: { control: 'inline-radio', options: ['solid', 'floating'] },
|
|
20
26
|
menu: { control: false },
|
package/src/cn.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import preset from '../tailwind-preset.js'
|
|
3
|
-
import { cn, _fontSizeTokens } from './cn'
|
|
3
|
+
import { cn, _boxShadowTokens, _dropShadowTokens, _fontSizeTokens } from './cn'
|
|
4
4
|
|
|
5
5
|
describe('cn knows the type ramp', () => {
|
|
6
6
|
it('lists exactly the preset fontSize keys', () => {
|
|
@@ -25,3 +25,22 @@ describe('cn knows the type ramp', () => {
|
|
|
25
25
|
expect(cn('text-left text-body-2 text-text-primary')).toBe('text-left text-body-2 text-text-primary')
|
|
26
26
|
})
|
|
27
27
|
})
|
|
28
|
+
|
|
29
|
+
describe('cn knows the shadow tokens', () => {
|
|
30
|
+
it('lists exactly the preset boxShadow and dropShadow keys', () => {
|
|
31
|
+
const extend = (preset as { theme: { extend: { boxShadow: Record<string, unknown>; dropShadow: Record<string, unknown> } } }).theme.extend
|
|
32
|
+
expect([..._boxShadowTokens].sort()).toEqual(Object.keys(extend.boxShadow).sort())
|
|
33
|
+
expect([..._dropShadowTokens].sort()).toEqual(Object.keys(extend.dropShadow).sort())
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('treats a shadow token as a shadow: two of them conflict, the last wins', () => {
|
|
37
|
+
expect(cn('shadow-glow-warning', 'shadow-md')).toBe('shadow-md')
|
|
38
|
+
expect(cn('shadow-focus-ring', 'shadow-glow-warning')).toBe('shadow-glow-warning')
|
|
39
|
+
expect(cn('drop-shadow-glow-success', 'drop-shadow-glow-accent')).toBe('drop-shadow-glow-accent')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('keeps a shadow token beside a colour, and under a variant', () => {
|
|
43
|
+
expect(cn('signal:shadow-glow-warning text-text-primary')).toBe('signal:shadow-glow-warning text-text-primary')
|
|
44
|
+
expect(cn('signal:border-warning-outline', 'text-warning-default')).toBe('signal:border-warning-outline text-warning-default')
|
|
45
|
+
})
|
|
46
|
+
})
|
package/src/cn.ts
CHANGED
|
@@ -21,8 +21,24 @@ const FONT_SIZE_TOKENS = [
|
|
|
21
21
|
'btn-default', 'btn-small', 'input-label', 'input-value', 'input-helper', 'chip',
|
|
22
22
|
]
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* The preset's box-shadow and drop-shadow keys, for the same reason: stock
|
|
26
|
+
* tailwind-merge files an unknown `shadow-x` under shadow-COLOUR, so
|
|
27
|
+
* `shadow-focus-ring` beside `shadow-glow-warning` lost one of them, and
|
|
28
|
+
* `shadow-glow-warning` beside `shadow-md` kept both (measured 2026-09-06).
|
|
29
|
+
* `cn.test.ts` pins these to the preset too.
|
|
30
|
+
*/
|
|
31
|
+
const BOX_SHADOW_TOKENS = ['sm', 'md', 'lg', 'focus-ring', 'glow-warning', 'highlight-inset']
|
|
32
|
+
const DROP_SHADOW_TOKENS = ['glow-success', 'glow-accent']
|
|
33
|
+
|
|
24
34
|
const twMerge = extendTailwindMerge({
|
|
25
|
-
extend: {
|
|
35
|
+
extend: {
|
|
36
|
+
classGroups: {
|
|
37
|
+
'font-size': [{ text: FONT_SIZE_TOKENS }],
|
|
38
|
+
shadow: [{ shadow: BOX_SHADOW_TOKENS }],
|
|
39
|
+
'drop-shadow': [{ 'drop-shadow': DROP_SHADOW_TOKENS }],
|
|
40
|
+
},
|
|
41
|
+
},
|
|
26
42
|
})
|
|
27
43
|
|
|
28
44
|
/** Merge class names, last-one-wins on conflicting Tailwind utilities. */
|
|
@@ -30,4 +46,4 @@ export function cn(...inputs: ClassValue[]) {
|
|
|
30
46
|
return twMerge(clsx(inputs))
|
|
31
47
|
}
|
|
32
48
|
|
|
33
|
-
export { FONT_SIZE_TOKENS as _fontSizeTokens }
|
|
49
|
+
export { FONT_SIZE_TOKENS as _fontSizeTokens, BOX_SHADOW_TOKENS as _boxShadowTokens, DROP_SHADOW_TOKENS as _dropShadowTokens }
|
package/src/index.ts
CHANGED
|
@@ -42,6 +42,7 @@ export { Sidebar, type SidebarProps } from './Sidebar'
|
|
|
42
42
|
export { TopBar, type TopBarProps } from './TopBar'
|
|
43
43
|
export { PersonTrigger, type PersonTriggerProps } from './PersonTrigger'
|
|
44
44
|
export { Property, type PropertyProps } from './Property'
|
|
45
|
+
export { Reaction, type ReactionProps } from './Reaction'
|
|
45
46
|
export { SearchInput, type SearchInputProps } from './SearchInput'
|
|
46
47
|
export { SectionHeader, type SectionAction, type SectionHeaderProps } from './SectionHeader'
|
|
47
48
|
export { SectionLabel } from './SectionLabel'
|