@estiva-app/ui 0.7.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/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.js +353 -326
- 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/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/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
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 }
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { Meta } from '@storybook/addon-docs/blocks'
|
|
2
|
+
|
|
3
|
+
<Meta title="Docs/Choosing a component" />
|
|
4
|
+
|
|
5
|
+
# Choosing a component
|
|
6
|
+
|
|
7
|
+
Find what you need; take the row. When nothing fits, build it in your app —
|
|
8
|
+
and if what you built knows nothing about your product's objects, propose
|
|
9
|
+
it for the package. A component that nearly fits is **forked, not fought**:
|
|
10
|
+
fork freely, owe nothing back, mention it on the package's ticket.
|
|
11
|
+
|
|
12
|
+
## An action
|
|
13
|
+
|
|
14
|
+
| You need | Reach for |
|
|
15
|
+
|---|---|
|
|
16
|
+
| A labeled action — a verb, clicked once | **Button** — `primary` at most once per surface |
|
|
17
|
+
| An icon-only action | **IconButton** — it requires an `aria-label` |
|
|
18
|
+
| An action in a popover list | **MenuItem**, inside a **Menu** |
|
|
19
|
+
| A destructive action | **Button** `destructive`, guarded by a **ConfirmDialog** |
|
|
20
|
+
|
|
21
|
+
## A choice
|
|
22
|
+
|
|
23
|
+
| You need | Reach for | Not |
|
|
24
|
+
|---|---|---|
|
|
25
|
+
| One value for a form field | **Select** | a Menu — Select's trigger shows the value |
|
|
26
|
+
| Several values, typed then removable | **ChipInput** | checkboxes in a Menu |
|
|
27
|
+
| Yes or no | **Checkbox** | a toggle we don't have |
|
|
28
|
+
| Switching between views of one thing | **Tabs** | a row of Buttons |
|
|
29
|
+
| View options behind a `…` | **Menu**, `selected` on the current row | Select |
|
|
30
|
+
| Rows of checkboxes that stay open | a list in a **DialogShell** | a Menu — a Checkbox inside a `menuitem` is invalid HTML |
|
|
31
|
+
|
|
32
|
+
## Typing
|
|
33
|
+
|
|
34
|
+
| You need | Reach for |
|
|
35
|
+
|---|---|
|
|
36
|
+
| One line | **TextInput** |
|
|
37
|
+
| Several lines | **Textarea** |
|
|
38
|
+
| Typing that filters a list | **SearchInput** |
|
|
39
|
+
| A title edited in place | **EditableText** |
|
|
40
|
+
| Label and required-mark around any control | **Field** |
|
|
41
|
+
|
|
42
|
+
## People
|
|
43
|
+
|
|
44
|
+
| You need | Reach for |
|
|
45
|
+
|---|---|
|
|
46
|
+
| One face | **Avatar** |
|
|
47
|
+
| Several faces, overlapping | **AvatarGroup** |
|
|
48
|
+
| Face and name in a row | **Person** |
|
|
49
|
+
| The face that opens a menu | **PersonTrigger** |
|
|
50
|
+
| Who you are, where you are, sign out | **IdentityMenu** — the account menu, ready-made |
|
|
51
|
+
|
|
52
|
+
## Floating surfaces
|
|
53
|
+
|
|
54
|
+
| You need | Reach for |
|
|
55
|
+
|---|---|
|
|
56
|
+
| A list of verbs from a trigger | **Menu** — Escape and outside-click come with it |
|
|
57
|
+
| A hint on hover | **Tooltip** / **WithTooltip** |
|
|
58
|
+
| Anything with a title, body and footer | **DialogShell** |
|
|
59
|
+
| "Are you sure?" | **ConfirmDialog** |
|
|
60
|
+
|
|
61
|
+
## Structure and wayfinding
|
|
62
|
+
|
|
63
|
+
| You need | Reach for |
|
|
64
|
+
|---|---|
|
|
65
|
+
| Label–value rows (a details rail) | **Property** — `row` and `stacked` |
|
|
66
|
+
| A section heading, chevron, hover actions | **SectionHeader** |
|
|
67
|
+
| The uppercase micro-label | **SectionLabel** |
|
|
68
|
+
| A hairline | **Divider** |
|
|
69
|
+
| Where am I, and the way back up | **Breadcrumb** |
|
|
70
|
+
| The key that does this too | **Kbd** — or the `shortcut` prop on **MenuItem**, **Tooltip** and **SearchInput** |
|
|
71
|
+
|
|
72
|
+
## Feedback
|
|
73
|
+
|
|
74
|
+
| You need | Reach for |
|
|
75
|
+
|---|---|
|
|
76
|
+
| Data is on its way | **Skeleton** — shaped like what will arrive |
|
|
77
|
+
| Nothing to show | **EmptyState** — says what would fill it |
|
|
78
|
+
| It happened, briefly | **Toast**, via `useToast` inside a `ToastProvider` |
|
|
79
|
+
| The whole surface has something to say | **Banner** — the strip under the top bar |
|
|
80
|
+
|
|
81
|
+
## The frame
|
|
82
|
+
|
|
83
|
+
Two frames, two pairs: the **solid** TopBar goes with the **Sidebar**;
|
|
84
|
+
the **floating** TopBar goes with the **Rail** and the content card.
|
|
85
|
+
|
|
86
|
+
| You need | Reach for |
|
|
87
|
+
|---|---|
|
|
88
|
+
| The structured frame: solid bar, sidebar, content | **AppShell** `solid` |
|
|
89
|
+
| The floating frame: bar over the content, rail, rounded card | **AppShell** `floating` |
|
|
90
|
+
| The 52px bar alone | **TopBar** — `solid` in flow, or `floating` over the content |
|
|
91
|
+
| A 240px navigation column | **Sidebar**, filled with **NavItem** rows |
|
|
92
|
+
| A 64px icon strip | **Rail**, filled with **RailItem** tiles |
|
|
93
|
+
| The menu button and the logo | the TopBar's `menu` and `logo` slots — collapsing stays yours |
|
|
94
|
+
|
|
95
|
+
## Deliberately not in the package
|
|
96
|
+
|
|
97
|
+
- Anything that knows your **product's objects** — its documents, threads,
|
|
98
|
+
files, tickets. Build it in your app, out of these parts, and keep its
|
|
99
|
+
stories in your app's own Storybook.
|
|
100
|
+
- Page layouts — panels inside the content, split views, side rails —
|
|
101
|
+
not here yet; they are the next batch.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Meta, Unstyled } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import { TokensPage } from './TokensPage'
|
|
3
|
+
|
|
4
|
+
<Meta title="Docs/Design Tokens" />
|
|
5
|
+
|
|
6
|
+
{/* Unstyled: the docs container sets 16px Nunito Sans on every div outside it,
|
|
7
|
+
at the same specificity as a token class, so the page draws its own type. */}
|
|
8
|
+
<Unstyled>
|
|
9
|
+
<TokensPage />
|
|
10
|
+
</Unstyled>
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Meta } from '@storybook/addon-docs/blocks'
|
|
2
|
+
|
|
3
|
+
<Meta title="Docs/Getting started" />
|
|
4
|
+
|
|
5
|
+
# Getting started
|
|
6
|
+
|
|
7
|
+
Wiring a new Estiva app to the package, in six steps. After these, every
|
|
8
|
+
component on these pages works in your app — right theme, right sizes,
|
|
9
|
+
right scrollbars.
|
|
10
|
+
|
|
11
|
+
## 1 · Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install @estiva-app/ui
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Peers you provide: `react` / `react-dom` 19, `tailwindcss` 3.4, and
|
|
18
|
+
`@tabler/icons-react` — the icon set every component and example draws from.
|
|
19
|
+
|
|
20
|
+
## 2 · The Tailwind preset — and the content spread
|
|
21
|
+
|
|
22
|
+
```js
|
|
23
|
+
// tailwind.config.js
|
|
24
|
+
import estiva, { estivaContent } from '@estiva-app/ui/tailwind-preset'
|
|
25
|
+
|
|
26
|
+
export default {
|
|
27
|
+
presets: [estiva],
|
|
28
|
+
content: [...estivaContent, './index.html', './src/**/*.{ts,tsx}'],
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The spread is not optional. Tailwind does not merge `content` from a
|
|
33
|
+
preset, so without it every class only these components use is purged —
|
|
34
|
+
the build succeeds, and components render at the wrong size.
|
|
35
|
+
|
|
36
|
+
## 3 · The stylesheets
|
|
37
|
+
|
|
38
|
+
```css
|
|
39
|
+
/* your entry CSS, before anything that uses a token */
|
|
40
|
+
@import '@estiva-app/ui/tokens.css';
|
|
41
|
+
@import '@estiva-app/ui/base.css';
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`tokens.css` is the token values, one block per theme. `base.css` is the
|
|
45
|
+
shared chrome that is not a token — the thin, token-coloured scrollbar
|
|
46
|
+
every Estiva app shows.
|
|
47
|
+
|
|
48
|
+
## 4 · Pick a theme
|
|
49
|
+
|
|
50
|
+
```html
|
|
51
|
+
<html data-theme="ship">
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
| Theme | Selected by |
|
|
55
|
+
|---|---|
|
|
56
|
+
| `signal` | `data-theme="signal"` |
|
|
57
|
+
| `ship` | `data-theme="ship"` |
|
|
58
|
+
|
|
59
|
+
A new app starts on one of these. Its own look, when it earns one, is **one
|
|
60
|
+
new block in `tokens.css`** defining every token — the package's test holds
|
|
61
|
+
every theme to the full set, so nothing can half-exist.
|
|
62
|
+
|
|
63
|
+
## 5 · Merge classes with the package `cn()`
|
|
64
|
+
|
|
65
|
+
```tsx
|
|
66
|
+
import { cn } from '@estiva-app/ui'
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
It is tailwind-merge taught this preset's type ramp: token size classes
|
|
70
|
+
(`text-body-2`, `text-caption`) survive merging next to a text colour.
|
|
71
|
+
Stock `twMerge` silently drops them — don't bring your own.
|
|
72
|
+
|
|
73
|
+
## 6 · First screen
|
|
74
|
+
|
|
75
|
+
```tsx
|
|
76
|
+
import { Button, Field, TextInput, ToastProvider, useToast } from '@estiva-app/ui'
|
|
77
|
+
|
|
78
|
+
function NewThing() {
|
|
79
|
+
const { showToast } = useToast()
|
|
80
|
+
return (
|
|
81
|
+
<div className="flex max-w-sm flex-col gap-4 p-6">
|
|
82
|
+
<Field label="Name" required>
|
|
83
|
+
<TextInput placeholder="What is it called?" />
|
|
84
|
+
</Field>
|
|
85
|
+
<Button variant="primary" onClick={() => showToast({ label: 'Created' })}>
|
|
86
|
+
Create
|
|
87
|
+
</Button>
|
|
88
|
+
</div>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export default function App() {
|
|
93
|
+
return (
|
|
94
|
+
<ToastProvider>
|
|
95
|
+
<NewThing />
|
|
96
|
+
</ToastProvider>
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Where next
|
|
102
|
+
|
|
103
|
+
- **[Choosing a component](?path=/docs/docs-choosing-a-component--docs)** —
|
|
104
|
+
what to reach for, by what you need.
|
|
105
|
+
- Each component's page: when, when not — with the alternative named — and
|
|
106
|
+
how.
|
|
107
|
+
- The README's rules travel with the package: fork freely; tokens, never
|
|
108
|
+
raw hex; the five popover rules; only offer actions that can succeed.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Meta } from '@storybook/addon-docs/blocks'
|
|
2
|
+
|
|
3
|
+
<Meta title="Docs/Introduction" />
|
|
4
|
+
|
|
5
|
+
# @estiva-app/ui
|
|
6
|
+
|
|
7
|
+
The shared foundation of every Estiva app's interface: the **design tokens**
|
|
8
|
+
(colour, type, radius, shadow) and a **small set of primitives** built on
|
|
9
|
+
them.
|
|
10
|
+
|
|
11
|
+
**Tokens are the contract. Components are a convenience.** An app must not
|
|
12
|
+
diverge from the token names. It may fork any component, freely, and owes
|
|
13
|
+
nothing back — a fork is information about the seam, not a failure; say so
|
|
14
|
+
on the package's ticket, and never bend your view around a component.
|
|
15
|
+
|
|
16
|
+
## Start here
|
|
17
|
+
|
|
18
|
+
1. **[Getting started](?path=/docs/docs-getting-started--docs)** — wire a
|
|
19
|
+
new app to the package, in six steps.
|
|
20
|
+
2. **[Choosing a component](?path=/docs/docs-choosing-a-component--docs)**
|
|
21
|
+
— what to reach for, by what you need.
|
|
22
|
+
3. **[Design Tokens](?path=/docs/docs-design-tokens--docs)** — the
|
|
23
|
+
vocabulary everything here is written in.
|
|
24
|
+
4. Every component's own page says **when** to use it, when **not** — with
|
|
25
|
+
the alternative named — and **how**, with its stories live on the page.
|
|
26
|
+
|
|
27
|
+
Use the **Theme** toolbar anywhere in this Storybook to see every page
|
|
28
|
+
under each of the package's themes: the same token names, a different look.
|
|
29
|
+
|
|
30
|
+
## What belongs here
|
|
31
|
+
|
|
32
|
+
- In the package: components that know nothing about any product — built
|
|
33
|
+
from tokens, generic over their content, with stories that need no app
|
|
34
|
+
data.
|
|
35
|
+
- In your app: anything that knows your product's objects. Build it there,
|
|
36
|
+
out of these parts, and keep its stories in your app's own Storybook.
|