@estiva-app/ui 0.1.0 → 0.3.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 +12 -9
- package/base.css +43 -0
- package/dist/AppShell.d.ts +39 -0
- package/dist/AppShell.d.ts.map +1 -0
- package/dist/Avatar.d.ts +7 -2
- package/dist/Avatar.d.ts.map +1 -1
- package/dist/AvatarGroup.d.ts +23 -0
- package/dist/AvatarGroup.d.ts.map +1 -0
- package/dist/Banner.d.ts +19 -0
- package/dist/Banner.d.ts.map +1 -0
- package/dist/Breadcrumb.d.ts +28 -0
- package/dist/Breadcrumb.d.ts.map +1 -0
- package/dist/Checkbox.d.ts +18 -0
- package/dist/Checkbox.d.ts.map +1 -0
- package/dist/ChipInput.d.ts +57 -0
- package/dist/ChipInput.d.ts.map +1 -0
- package/dist/ConfirmDialog.d.ts +22 -0
- package/dist/ConfirmDialog.d.ts.map +1 -0
- package/dist/DialogShell.d.ts +9 -2
- package/dist/DialogShell.d.ts.map +1 -1
- package/dist/EditableText.d.ts +56 -0
- package/dist/EditableText.d.ts.map +1 -0
- package/dist/IdentityMenu.d.ts +58 -0
- package/dist/IdentityMenu.d.ts.map +1 -0
- package/dist/Menu.d.ts +82 -0
- package/dist/Menu.d.ts.map +1 -0
- package/dist/NavItem.d.ts +26 -0
- package/dist/NavItem.d.ts.map +1 -0
- package/dist/Person.d.ts +26 -0
- package/dist/Person.d.ts.map +1 -0
- package/dist/PersonTrigger.d.ts +23 -0
- package/dist/PersonTrigger.d.ts.map +1 -0
- package/dist/Property.d.ts +24 -0
- package/dist/Property.d.ts.map +1 -0
- package/dist/Rail.d.ts +18 -0
- package/dist/Rail.d.ts.map +1 -0
- package/dist/RailItem.d.ts +23 -0
- package/dist/RailItem.d.ts.map +1 -0
- package/dist/SearchInput.d.ts +21 -0
- package/dist/SearchInput.d.ts.map +1 -0
- package/dist/SectionHeader.d.ts +34 -0
- package/dist/SectionHeader.d.ts.map +1 -0
- package/dist/SectionLabel.d.ts +19 -0
- package/dist/SectionLabel.d.ts.map +1 -0
- package/dist/Select.d.ts +42 -5
- package/dist/Select.d.ts.map +1 -1
- package/dist/Sidebar.d.ts +18 -0
- package/dist/Sidebar.d.ts.map +1 -0
- package/dist/Tabs.d.ts +34 -0
- package/dist/Tabs.d.ts.map +1 -0
- package/dist/Toast.d.ts +48 -0
- package/dist/Toast.d.ts.map +1 -0
- package/dist/Tooltip.d.ts +3 -1
- package/dist/Tooltip.d.ts.map +1 -1
- package/dist/TopBar.d.ts +33 -0
- package/dist/TopBar.d.ts.map +1 -0
- package/dist/cn.d.ts +14 -4
- package/dist/cn.d.ts.map +1 -1
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1254 -106
- package/dist/index.js.map +4 -4
- package/package.json +5 -2
- package/src/AppShell.mdx +66 -0
- package/src/AppShell.stories.tsx +117 -0
- package/src/AppShell.tsx +77 -0
- package/src/Avatar.initials.test.ts +21 -0
- package/src/Avatar.mdx +49 -0
- package/src/Avatar.tsx +12 -9
- package/src/AvatarGroup.mdx +39 -0
- package/src/AvatarGroup.stories.tsx +56 -0
- package/src/AvatarGroup.tsx +42 -0
- package/src/Banner.mdx +45 -0
- package/src/Banner.stories.tsx +35 -0
- package/src/Banner.tsx +35 -0
- package/src/Breadcrumb.mdx +52 -0
- package/src/Breadcrumb.stories.tsx +42 -0
- package/src/Breadcrumb.tsx +106 -0
- package/src/Button.mdx +57 -0
- package/src/Checkbox.mdx +44 -0
- package/src/Checkbox.stories.tsx +49 -0
- package/src/Checkbox.tsx +46 -0
- package/src/Chip.mdx +43 -0
- package/src/ChipInput.mdx +68 -0
- package/src/ChipInput.stories.tsx +99 -0
- package/src/ChipInput.tsx +247 -0
- package/src/ConfirmDialog.mdx +51 -0
- package/src/ConfirmDialog.stories.tsx +63 -0
- package/src/ConfirmDialog.tsx +55 -0
- package/src/DialogShell.mdx +68 -0
- package/src/DialogShell.tsx +12 -5
- package/src/Divider.mdx +41 -0
- package/src/EditableText.mdx +59 -0
- package/src/EditableText.stories.tsx +63 -0
- package/src/EditableText.tsx +169 -0
- package/src/EmptyState.mdx +41 -0
- package/src/Field.mdx +39 -0
- package/src/IconButton.mdx +47 -0
- package/src/IdentityMenu.mdx +62 -0
- package/src/IdentityMenu.stories.tsx +52 -0
- package/src/IdentityMenu.tsx +159 -0
- package/src/Menu.mdx +111 -0
- package/src/Menu.stories.tsx +66 -0
- package/src/Menu.tsx +172 -0
- package/src/MenuItem.mdx +55 -0
- package/src/MenuItem.stories.tsx +98 -0
- package/src/NavItem.mdx +53 -0
- package/src/NavItem.stories.tsx +36 -0
- package/src/NavItem.tsx +53 -0
- package/src/Person.mdx +44 -0
- package/src/Person.stories.tsx +43 -0
- package/src/Person.tsx +39 -0
- package/src/PersonTrigger.mdx +51 -0
- package/src/PersonTrigger.stories.tsx +30 -0
- package/src/PersonTrigger.tsx +62 -0
- package/src/Property.mdx +51 -0
- package/src/Property.stories.tsx +91 -0
- package/src/Property.tsx +44 -0
- package/src/Rail.tsx +26 -0
- package/src/RailItem.mdx +50 -0
- package/src/RailItem.stories.tsx +37 -0
- package/src/RailItem.tsx +53 -0
- package/src/SearchInput.mdx +39 -0
- package/src/SearchInput.stories.tsx +24 -0
- package/src/SearchInput.tsx +45 -0
- package/src/SectionHeader.mdx +52 -0
- package/src/SectionHeader.stories.tsx +66 -0
- package/src/SectionHeader.tsx +85 -0
- package/src/SectionLabel.mdx +42 -0
- package/src/SectionLabel.stories.tsx +31 -0
- package/src/SectionLabel.tsx +28 -0
- package/src/Select.fit.test.ts +97 -0
- package/src/Select.mdx +71 -0
- package/src/Select.stories.tsx +95 -0
- package/src/Select.tsx +123 -15
- package/src/Sidebar.mdx +56 -0
- package/src/Sidebar.stories.tsx +50 -0
- package/src/Sidebar.tsx +32 -0
- package/src/Skeleton.mdx +39 -0
- package/src/Tabs.mdx +53 -0
- package/src/Tabs.stories.tsx +91 -0
- package/src/Tabs.tsx +73 -0
- package/src/TextInput.mdx +39 -0
- package/src/Textarea.mdx +36 -0
- package/src/Toast.mdx +54 -0
- package/src/Toast.stories.tsx +87 -0
- package/src/Toast.tsx +169 -0
- package/src/Tooltip.mdx +51 -0
- package/src/Tooltip.tsx +4 -2
- package/src/TopBar.mdx +62 -0
- package/src/TopBar.stories.tsx +96 -0
- package/src/TopBar.tsx +57 -0
- package/src/cn.test.ts +27 -0
- package/src/cn.ts +25 -5
- package/src/index.ts +23 -0
- package/tailwind-preset.js +4 -3
- package/tokens.css +4 -0
package/src/Toast.mdx
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as ToastStories from './Toast.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={ToastStories} />
|
|
5
|
+
|
|
6
|
+
# Toast
|
|
7
|
+
|
|
8
|
+
A pill that says what just happened — three types, an optional leading
|
|
9
|
+
check, an optional action — and **ToastProvider**/**useToast**, which own
|
|
10
|
+
the positioning (bottom-left), the portal, and the auto-dismiss.
|
|
11
|
+
|
|
12
|
+
<Canvas of={ToastStories.AllTypes} />
|
|
13
|
+
|
|
14
|
+
## When
|
|
15
|
+
|
|
16
|
+
- Confirming an action's outcome, briefly: "Created", "Link copied",
|
|
17
|
+
"Changes saved".
|
|
18
|
+
- `actionLabel` + `onAction` add the one follow-up worth offering — an
|
|
19
|
+
"Undo", a "View".
|
|
20
|
+
- `neutral` states, `success` celebrates, `brand` speaks with the
|
|
21
|
+
accent's voice.
|
|
22
|
+
|
|
23
|
+
<Canvas of={ToastStories.WithAction} />
|
|
24
|
+
|
|
25
|
+
## When not
|
|
26
|
+
|
|
27
|
+
- Something the person must read or act on → put it in the surface; a
|
|
28
|
+
toast dismisses itself.
|
|
29
|
+
- A question before the action → **ConfirmDialog**.
|
|
30
|
+
- Standing status → a Chip or your surface's own chrome.
|
|
31
|
+
|
|
32
|
+
## How
|
|
33
|
+
|
|
34
|
+
```tsx
|
|
35
|
+
import { ToastProvider, useToast } from '@estiva-app/ui'
|
|
36
|
+
|
|
37
|
+
// once, at the root
|
|
38
|
+
<ToastProvider>{app}</ToastProvider>
|
|
39
|
+
|
|
40
|
+
// anywhere below it
|
|
41
|
+
const { showToast } = useToast()
|
|
42
|
+
showToast({ label: 'Link copied', type: 'success' })
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
- **One toast at a time** — a new one replaces the standing one; nothing
|
|
46
|
+
queues.
|
|
47
|
+
- Auto-dismiss is 5s; `durationMs: 0` keeps one up until replaced or
|
|
48
|
+
dismissed (`dismissToast`).
|
|
49
|
+
- `useToast` outside a provider throws — mount the provider at the root,
|
|
50
|
+
once.
|
|
51
|
+
|
|
52
|
+
## Props
|
|
53
|
+
|
|
54
|
+
<Controls of={ToastStories.Neutral} />
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { Button } from './Button'
|
|
3
|
+
import { Toast, ToastProvider, useToast } from './Toast'
|
|
4
|
+
|
|
5
|
+
/** The pill that confirms something happened. Positioning and auto-dismiss live in ToastProvider. */
|
|
6
|
+
const meta = {
|
|
7
|
+
title: 'Feedback/Toast',
|
|
8
|
+
component: Toast,
|
|
9
|
+
args: {
|
|
10
|
+
label: 'Changes saved',
|
|
11
|
+
type: 'neutral',
|
|
12
|
+
leadingIcon: true,
|
|
13
|
+
},
|
|
14
|
+
argTypes: {
|
|
15
|
+
type: { control: 'inline-radio', options: ['success', 'brand', 'neutral'] },
|
|
16
|
+
onAction: { control: false },
|
|
17
|
+
},
|
|
18
|
+
} satisfies Meta<typeof Toast>
|
|
19
|
+
|
|
20
|
+
export default meta
|
|
21
|
+
type Story = StoryObj<typeof meta>
|
|
22
|
+
|
|
23
|
+
export const Neutral: Story = {}
|
|
24
|
+
|
|
25
|
+
export const Success: Story = {
|
|
26
|
+
args: { type: 'success', label: 'Upload complete' },
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const Brand: Story = {
|
|
30
|
+
args: { type: 'brand', label: 'Session started' },
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** With a right-side action. */
|
|
34
|
+
export const WithAction: Story = {
|
|
35
|
+
args: {
|
|
36
|
+
type: 'neutral',
|
|
37
|
+
label: 'Item removed',
|
|
38
|
+
actionLabel: 'Undo',
|
|
39
|
+
onAction: () => {},
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const NoIcon: Story = {
|
|
44
|
+
args: { leadingIcon: false, label: 'Copied to clipboard' },
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** All three surfaces, with and without an action. */
|
|
48
|
+
export const AllTypes: Story = {
|
|
49
|
+
parameters: { controls: { disable: true } },
|
|
50
|
+
render: () => (
|
|
51
|
+
<div className="flex flex-col items-start gap-2">
|
|
52
|
+
{(['success', 'brand', 'neutral'] as const).map((type) => (
|
|
53
|
+
<div key={type} className="flex items-center gap-2">
|
|
54
|
+
<Toast type={type} label="Changes saved" />
|
|
55
|
+
<Toast type={type} label="Changes saved" actionLabel="Undo" onAction={() => {}} />
|
|
56
|
+
</div>
|
|
57
|
+
))}
|
|
58
|
+
</div>
|
|
59
|
+
),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function ProviderDemo() {
|
|
63
|
+
const { showToast } = useToast()
|
|
64
|
+
return (
|
|
65
|
+
<div className="flex items-center gap-2">
|
|
66
|
+
<Button variant="muted" onClick={() => showToast({ label: 'Changes saved' })}>
|
|
67
|
+
Show a toast
|
|
68
|
+
</Button>
|
|
69
|
+
<Button
|
|
70
|
+
variant="muted"
|
|
71
|
+
onClick={() => showToast({ label: 'Item removed', actionLabel: 'Undo', onAction: () => {} })}
|
|
72
|
+
>
|
|
73
|
+
With an action
|
|
74
|
+
</Button>
|
|
75
|
+
</div>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The provider in motion: bottom-left, gone after 5 s, a new toast replaces the standing one. */
|
|
80
|
+
export const FromTheProvider: Story = {
|
|
81
|
+
parameters: { controls: { disable: true } },
|
|
82
|
+
render: () => (
|
|
83
|
+
<ToastProvider>
|
|
84
|
+
<ProviderDemo />
|
|
85
|
+
</ToastProvider>
|
|
86
|
+
),
|
|
87
|
+
}
|
package/src/Toast.tsx
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import { IconCircleCheck } from '@tabler/icons-react'
|
|
4
|
+
import { cn } from './cn'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Peek's Toast (Figma: Alert) and its provider (2026-09-01), verbatim.
|
|
8
|
+
*
|
|
9
|
+
* The pill: three types — `success`, `brand`, `neutral` — an optional leading
|
|
10
|
+
* circle-check, an optional action on the right. Under Signal every toast is
|
|
11
|
+
* the same dark overlay pill; the type lives in the icon's colour and glow,
|
|
12
|
+
* not the surface.
|
|
13
|
+
*
|
|
14
|
+
* The provider owns positioning (bottom-left), the portal, and auto-dismiss
|
|
15
|
+
* (5 s; pass `durationMs: 0` to keep one up). One toast at a time — a new
|
|
16
|
+
* one replaces the standing one, it does not queue.
|
|
17
|
+
*/
|
|
18
|
+
export type ToastType = 'success' | 'brand' | 'neutral'
|
|
19
|
+
|
|
20
|
+
export interface ToastProps {
|
|
21
|
+
label: string
|
|
22
|
+
/** Visual variant per Figma (Alert component): success, brand, or neutral. */
|
|
23
|
+
type?: ToastType
|
|
24
|
+
/** Show the leading circle-check icon. Defaults to true. */
|
|
25
|
+
leadingIcon?: boolean
|
|
26
|
+
/** When set together with onAction, renders a clickable action on the right side. */
|
|
27
|
+
actionLabel?: string
|
|
28
|
+
onAction?: () => void
|
|
29
|
+
className?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Signal: every toast is the same dark overlay pill (v3) — the type lives in
|
|
33
|
+
// the icon color + glow, not the surface.
|
|
34
|
+
const SURFACE_BY_TYPE: Record<ToastType, string> = {
|
|
35
|
+
success: 'bg-success-muted signal:bg-bg-inset signal:border signal:border-border-default signal:shadow-[shadow:var(--shadow-md)]',
|
|
36
|
+
brand: 'bg-accent-muted signal:bg-bg-inset signal:border signal:border-border-default signal:shadow-[shadow:var(--shadow-md)]',
|
|
37
|
+
neutral: 'bg-bg-inset border border-border-subtle signal:border-border-default signal:shadow-[shadow:var(--shadow-md)]',
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const ICON_BY_TYPE: Record<ToastType, string> = {
|
|
41
|
+
success: 'signal:text-success-default signal:drop-shadow-[0_0_5px_rgba(63,222,140,0.7)]',
|
|
42
|
+
brand: 'signal:text-text-interactive signal:drop-shadow-[0_0_5px_rgba(86,200,255,0.6)]',
|
|
43
|
+
neutral: 'signal:text-text-secondary',
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const ACTION_BORDER_BY_TYPE: Record<ToastType, string> = {
|
|
47
|
+
success: 'signal:border signal:border-border-default signal:hover:border-border-strong',
|
|
48
|
+
brand: 'signal:border signal:border-border-default signal:hover:border-border-strong',
|
|
49
|
+
neutral: 'border border-border-default',
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function Toast({ label, type = 'neutral', leadingIcon = true, actionLabel, onAction, className }: ToastProps) {
|
|
53
|
+
const hasAction = !!(actionLabel && onAction)
|
|
54
|
+
return (
|
|
55
|
+
<div
|
|
56
|
+
className={cn(
|
|
57
|
+
'inline-flex items-center min-h-[32px] pl-2 py-1 rounded-lg shadow-lg',
|
|
58
|
+
// Without an action the label needs real right padding; the action
|
|
59
|
+
// button brings its own edge, so the tight pr-1 only applies there.
|
|
60
|
+
hasAction ? 'pr-1 gap-[46px]' : 'pr-3',
|
|
61
|
+
SURFACE_BY_TYPE[type],
|
|
62
|
+
className,
|
|
63
|
+
)}
|
|
64
|
+
>
|
|
65
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
66
|
+
{leadingIcon && (
|
|
67
|
+
<IconCircleCheck size={16} stroke={1.5} className={cn('text-text-primary shrink-0', ICON_BY_TYPE[type])} />
|
|
68
|
+
)}
|
|
69
|
+
<span className="font-normal text-[14px] leading-[1.4] text-text-primary whitespace-nowrap">{label}</span>
|
|
70
|
+
</div>
|
|
71
|
+
{hasAction && (
|
|
72
|
+
<button
|
|
73
|
+
type="button"
|
|
74
|
+
onClick={onAction}
|
|
75
|
+
className={cn(
|
|
76
|
+
'h-6 flex items-center justify-center gap-1 px-1 py-1 rounded-md shrink-0 transition-colors',
|
|
77
|
+
ACTION_BORDER_BY_TYPE[type],
|
|
78
|
+
type === 'neutral' ? 'hover:border-border-strong' : 'hover:opacity-80',
|
|
79
|
+
)}
|
|
80
|
+
>
|
|
81
|
+
<span className="font-medium text-[12px] leading-[12px] text-text-primary whitespace-nowrap">{actionLabel}</span>
|
|
82
|
+
</button>
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface ToastOptions {
|
|
89
|
+
label: string
|
|
90
|
+
/** Visual variant per Figma (Alert component). Defaults to 'neutral'. */
|
|
91
|
+
type?: ToastType
|
|
92
|
+
/** When set, renders a clickable action on the right side. */
|
|
93
|
+
actionLabel?: string
|
|
94
|
+
onAction?: () => void
|
|
95
|
+
/** Show the leading circle-check icon. Defaults to true. */
|
|
96
|
+
leadingIcon?: boolean
|
|
97
|
+
/** Auto-dismiss after this many ms. Defaults to 5000. Pass 0 to disable. */
|
|
98
|
+
durationMs?: number
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface ActiveToast extends ToastOptions {
|
|
102
|
+
id: number
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface ToastValue {
|
|
106
|
+
showToast: (opts: ToastOptions) => void
|
|
107
|
+
dismissToast: () => void
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const ToastContext = createContext<ToastValue | null>(null)
|
|
111
|
+
|
|
112
|
+
let toastSeq = 0
|
|
113
|
+
|
|
114
|
+
export function ToastProvider({ children }: { children: ReactNode }) {
|
|
115
|
+
const [toast, setToast] = useState<ActiveToast | null>(null)
|
|
116
|
+
|
|
117
|
+
const showToast = useCallback((opts: ToastOptions) => {
|
|
118
|
+
setToast({ ...opts, id: ++toastSeq })
|
|
119
|
+
}, [])
|
|
120
|
+
|
|
121
|
+
const dismissToast = useCallback(() => {
|
|
122
|
+
setToast(null)
|
|
123
|
+
}, [])
|
|
124
|
+
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (!toast) return
|
|
127
|
+
const duration = toast.durationMs ?? 5000
|
|
128
|
+
if (duration <= 0) return
|
|
129
|
+
const timer = setTimeout(() => {
|
|
130
|
+
setToast((current) => (current?.id === toast.id ? null : current))
|
|
131
|
+
}, duration)
|
|
132
|
+
return () => clearTimeout(timer)
|
|
133
|
+
}, [toast])
|
|
134
|
+
|
|
135
|
+
const value = useMemo<ToastValue>(() => ({ showToast, dismissToast }), [showToast, dismissToast])
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<ToastContext.Provider value={value}>
|
|
139
|
+
{children}
|
|
140
|
+
{toast &&
|
|
141
|
+
createPortal(
|
|
142
|
+
<div className="fixed bottom-4 left-4 z-[100] pointer-events-none">
|
|
143
|
+
<Toast
|
|
144
|
+
className="pointer-events-auto"
|
|
145
|
+
label={toast.label}
|
|
146
|
+
type={toast.type ?? 'neutral'}
|
|
147
|
+
leadingIcon={toast.leadingIcon ?? true}
|
|
148
|
+
actionLabel={toast.actionLabel}
|
|
149
|
+
onAction={
|
|
150
|
+
toast.onAction
|
|
151
|
+
? () => {
|
|
152
|
+
toast.onAction?.()
|
|
153
|
+
dismissToast()
|
|
154
|
+
}
|
|
155
|
+
: undefined
|
|
156
|
+
}
|
|
157
|
+
/>
|
|
158
|
+
</div>,
|
|
159
|
+
document.body,
|
|
160
|
+
)}
|
|
161
|
+
</ToastContext.Provider>
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function useToast(): ToastValue {
|
|
166
|
+
const ctx = useContext(ToastContext)
|
|
167
|
+
if (!ctx) throw new Error('useToast must be used within ToastProvider')
|
|
168
|
+
return ctx
|
|
169
|
+
}
|
package/src/Tooltip.mdx
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as TooltipStories from './Tooltip.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={TooltipStories} />
|
|
5
|
+
|
|
6
|
+
# Tooltip
|
|
7
|
+
|
|
8
|
+
A 30px elevated pill with a caption, and **WithTooltip**, the wrapper that
|
|
9
|
+
shows it above or below a trigger on hover — portalled, fixed to the
|
|
10
|
+
viewport, kept 8px inside its edges.
|
|
11
|
+
|
|
12
|
+
<Canvas of={TooltipStories.OnHoverTop} />
|
|
13
|
+
|
|
14
|
+
## When
|
|
15
|
+
|
|
16
|
+
- The reason a control is disabled — a disabled control explains itself
|
|
17
|
+
("Available after sign-in"); this is the rule that an action appears
|
|
18
|
+
only where it can succeed.
|
|
19
|
+
- The word for an icon-only control — alongside its `aria-label`, never
|
|
20
|
+
instead of it (IconButton wires this for you via its `tooltip` prop).
|
|
21
|
+
- The full text behind a truncation.
|
|
22
|
+
|
|
23
|
+
<Canvas of={TooltipStories.OnADisabledControl} />
|
|
24
|
+
|
|
25
|
+
## When not
|
|
26
|
+
|
|
27
|
+
- Anything the person must read to proceed → put it in the surface, not
|
|
28
|
+
behind a hover.
|
|
29
|
+
- Anything interactive inside → **Menu** or **DialogShell**; a tooltip is
|
|
30
|
+
`pointer-events: none` by design.
|
|
31
|
+
|
|
32
|
+
## How
|
|
33
|
+
|
|
34
|
+
```tsx
|
|
35
|
+
import { WithTooltip } from '@estiva-app/ui'
|
|
36
|
+
|
|
37
|
+
<WithTooltip label="Available after sign-in">
|
|
38
|
+
<Button disabled>Publish</Button>
|
|
39
|
+
</WithTooltip>
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
- Wrap a **control, never a block** — the wrapper is `inline-flex` and
|
|
43
|
+
shrinks nothing, so a wrapped block collapses to its content width.
|
|
44
|
+
- A truncating label keeps truncating if you pass
|
|
45
|
+
`wrapperClassName="min-w-0 shrink"`.
|
|
46
|
+
- It shows on **hover only** — there is no focus or touch trigger. Don't
|
|
47
|
+
put anything behind it that a keyboard user must reach.
|
|
48
|
+
|
|
49
|
+
## Props
|
|
50
|
+
|
|
51
|
+
<Controls of={TooltipStories.Default} />
|
package/src/Tooltip.tsx
CHANGED
|
@@ -27,13 +27,15 @@ export function Tooltip({ label, className }: TooltipProps) {
|
|
|
27
27
|
export interface WithTooltipProps {
|
|
28
28
|
label: string
|
|
29
29
|
placement?: 'top' | 'bottom'
|
|
30
|
+
/** Extra classes on the wrapper — e.g. `min-w-0 shrink` so a truncating label keeps truncating inside it. */
|
|
31
|
+
wrapperClassName?: string
|
|
30
32
|
children: ReactNode
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
const GAP = 6
|
|
34
36
|
const VIEWPORT_PAD = 8
|
|
35
37
|
|
|
36
|
-
export function WithTooltip({ label, placement = 'top', children }: WithTooltipProps) {
|
|
38
|
+
export function WithTooltip({ label, placement = 'top', wrapperClassName, children }: WithTooltipProps) {
|
|
37
39
|
const [show, setShow] = useState(false)
|
|
38
40
|
const ref = useRef<HTMLDivElement>(null)
|
|
39
41
|
const tooltipRef = useRef<HTMLDivElement>(null)
|
|
@@ -56,7 +58,7 @@ export function WithTooltip({ label, placement = 'top', children }: WithTooltipP
|
|
|
56
58
|
}, [show, reposition])
|
|
57
59
|
|
|
58
60
|
return (
|
|
59
|
-
<div ref={ref} className=
|
|
61
|
+
<div ref={ref} className={cn('inline-flex shrink-0', wrapperClassName)} onMouseEnter={() => setShow(true)} onMouseLeave={() => setShow(false)}>
|
|
60
62
|
{children}
|
|
61
63
|
{show &&
|
|
62
64
|
createPortal(
|
package/src/TopBar.mdx
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as TopBarStories from './TopBar.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={TopBarStories} />
|
|
5
|
+
|
|
6
|
+
# TopBar
|
|
7
|
+
|
|
8
|
+
The 52px top bar: `menu` and `logo` on the left, a `search` slot in the
|
|
9
|
+
centre, the identity cluster on the right. Two manners — **`solid`**, in
|
|
10
|
+
flow with a hairline and a surface, and **`floating`**, an overlay with
|
|
11
|
+
neither, whose bar ignores the pointer so the content underneath stays
|
|
12
|
+
reachable.
|
|
13
|
+
|
|
14
|
+
<Canvas of={TopBarStories.Solid} />
|
|
15
|
+
|
|
16
|
+
<Canvas of={TopBarStories.Floating} />
|
|
17
|
+
|
|
18
|
+
## When
|
|
19
|
+
|
|
20
|
+
- **`solid`** — the structured frame's first row, above the sidebar
|
|
21
|
+
(AppShell's `solid` variant wires exactly this).
|
|
22
|
+
- **`floating`** — an app whose content runs to the top edge; the bar
|
|
23
|
+
floats over it and only its clusters catch clicks (AppShell's
|
|
24
|
+
`floating` variant).
|
|
25
|
+
- `menu` is the menu button, in an app whose navigation collapses — the
|
|
26
|
+
collapsing itself stays the caller's. `logo` sits beside it: the app's
|
|
27
|
+
name as text, or a mark.
|
|
28
|
+
- The `search` slot holds its place even while empty, so a search field
|
|
29
|
+
drops in later without moving anything.
|
|
30
|
+
|
|
31
|
+
<Canvas of={TopBarStories.FloatingNoSearch} />
|
|
32
|
+
|
|
33
|
+
## When not
|
|
34
|
+
|
|
35
|
+
- Page-level headers inside the content → not this; this is the frame's
|
|
36
|
+
bar, once per app.
|
|
37
|
+
- Anything the bar would need to know — who you are, what is searched —
|
|
38
|
+
stays outside: pass composed slots.
|
|
39
|
+
|
|
40
|
+
## How
|
|
41
|
+
|
|
42
|
+
```tsx
|
|
43
|
+
import { TopBar, IdentityMenu, SearchInput } from '@estiva-app/ui'
|
|
44
|
+
|
|
45
|
+
<TopBar
|
|
46
|
+
logo="Estiva"
|
|
47
|
+
search={<SearchInput shortcut="⌘ K" className="w-[290px]" />}
|
|
48
|
+
right={<IdentityMenu me={me} signedIn={signedIn} />}
|
|
49
|
+
/>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
- Geometry is fixed: 52px, `pl-5 pr-[26px]`, right cluster `gap-[6px]` —
|
|
53
|
+
don't restyle it per app.
|
|
54
|
+
- In `floating`, a launcher-affordance search is the caller's trick: keep
|
|
55
|
+
the input `pointer-events-none` and open the launcher from a wrapper's
|
|
56
|
+
`mousedown` (see SearchInput's page).
|
|
57
|
+
- The frame is desktop-only for now — there is no narrow-screen
|
|
58
|
+
behaviour; the menu button collapses panels, it does not open a drawer.
|
|
59
|
+
|
|
60
|
+
## Props
|
|
61
|
+
|
|
62
|
+
<Controls of={TopBarStories.Solid} />
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { IconMenu2 } from '@tabler/icons-react'
|
|
3
|
+
import type { ReactNode } from 'react'
|
|
4
|
+
import { IconButton } from './IconButton'
|
|
5
|
+
import { PersonTrigger } from './PersonTrigger'
|
|
6
|
+
import { SearchInput } from './SearchInput'
|
|
7
|
+
import { TopBar } from './TopBar'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The bar needs its full width, and it needs content to stand against:
|
|
11
|
+
* the solid manner sits above its content, the floating manner floats
|
|
12
|
+
* over it — no border, no background, clicks passing through.
|
|
13
|
+
*/
|
|
14
|
+
const meta = {
|
|
15
|
+
title: 'Frame/TopBar',
|
|
16
|
+
component: TopBar,
|
|
17
|
+
parameters: { layout: 'fullscreen' },
|
|
18
|
+
argTypes: {
|
|
19
|
+
variant: { control: 'inline-radio', options: ['solid', 'floating'] },
|
|
20
|
+
menu: { control: false },
|
|
21
|
+
logo: { control: false },
|
|
22
|
+
search: { control: false },
|
|
23
|
+
right: { control: false },
|
|
24
|
+
},
|
|
25
|
+
} satisfies Meta<typeof TopBar>
|
|
26
|
+
|
|
27
|
+
export default meta
|
|
28
|
+
type Story = StoryObj<typeof meta>
|
|
29
|
+
|
|
30
|
+
const face = <PersonTrigger name="Ana Duarte" />
|
|
31
|
+
const menuButton = (
|
|
32
|
+
<IconButton tooltip="Toggle menu" tooltipPlacement="bottom" aria-label="Toggle menu">
|
|
33
|
+
<IconMenu2 size={16} stroke={1.5} />
|
|
34
|
+
</IconButton>
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
/** Solid sits above its content: bar first, page below. */
|
|
38
|
+
const Below = ({ children }: { children: ReactNode }) => (
|
|
39
|
+
<div className="flex h-40 flex-col overflow-hidden bg-bg-base">
|
|
40
|
+
{children}
|
|
41
|
+
<div className="flex flex-1 items-center justify-center text-body-2 text-text-muted">content below the bar</div>
|
|
42
|
+
</div>
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
/** Floating stands over its content: the page runs to the top edge underneath. */
|
|
46
|
+
const Behind = ({ children }: { children: ReactNode }) => (
|
|
47
|
+
<div className="relative h-40 overflow-hidden bg-bg-base">
|
|
48
|
+
{children}
|
|
49
|
+
<div className="flex h-full items-center justify-center pt-6 text-body-2 text-text-muted">content under the bar</div>
|
|
50
|
+
</div>
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
/** The in-flow manner: hairline, surface, the name on the left. */
|
|
54
|
+
export const Solid: Story = {
|
|
55
|
+
render: (args) => (
|
|
56
|
+
<Below>
|
|
57
|
+
<TopBar {...args} logo="Estiva" search={<SearchInput shortcut="⌘ K" className="w-[290px]" />} right={face} />
|
|
58
|
+
</Below>
|
|
59
|
+
),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** No search yet: the centre slot holds its place, so it drops in later without moving anything. */
|
|
63
|
+
export const SolidNoSearch: Story = {
|
|
64
|
+
render: (args) => (
|
|
65
|
+
<Below>
|
|
66
|
+
<TopBar {...args} logo="Estiva" right={face} />
|
|
67
|
+
</Below>
|
|
68
|
+
),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The overlay manner: no border, no background, and only the clusters catch the pointer. */
|
|
72
|
+
export const Floating: Story = {
|
|
73
|
+
render: (args) => (
|
|
74
|
+
<Behind>
|
|
75
|
+
<TopBar {...args} variant="floating" menu={menuButton} search={<SearchInput shortcut="⌘ K" className="w-[290px]" />} right={face} />
|
|
76
|
+
</Behind>
|
|
77
|
+
),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Floating without a search — menu button and identity alone. */
|
|
81
|
+
export const FloatingNoSearch: Story = {
|
|
82
|
+
render: (args) => (
|
|
83
|
+
<Behind>
|
|
84
|
+
<TopBar {...args} variant="floating" menu={menuButton} right={face} />
|
|
85
|
+
</Behind>
|
|
86
|
+
),
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The menu button and the logo side by side — an app whose rail collapses keeps its name next to the way to collapse it. */
|
|
90
|
+
export const MenuAndLogo: Story = {
|
|
91
|
+
render: (args) => (
|
|
92
|
+
<Behind>
|
|
93
|
+
<TopBar {...args} variant="floating" menu={menuButton} logo="Estiva" right={face} />
|
|
94
|
+
</Behind>
|
|
95
|
+
),
|
|
96
|
+
}
|
package/src/TopBar.tsx
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { cn } from './cn'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The 52px top bar, in two manners:
|
|
6
|
+
*
|
|
7
|
+
* - `solid` — in flow: a hairline under it, the surface behind it, the
|
|
8
|
+
* frame's first row (2026-09-02, verbatim from the structured app; its
|
|
9
|
+
* product title moved to the caller).
|
|
10
|
+
* - `floating` — an overlay: absolute over the content, no border and no
|
|
11
|
+
* background, and the bar itself ignores the pointer — only its clusters
|
|
12
|
+
* catch clicks, so the content underneath stays reachable (2026-09-02,
|
|
13
|
+
* verbatim from the floating app).
|
|
14
|
+
*
|
|
15
|
+
* Geometry both apps agreed on before extraction: 52px tall, `pl-5
|
|
16
|
+
* pr-[26px]`, right cluster `gap-[6px]`. The left region is two slots
|
|
17
|
+
* side by side — `menu` (the menu button, in an app whose navigation
|
|
18
|
+
* collapses) and `logo` (the mark or name beside it; Katerina,
|
|
19
|
+
* 2026-09-02). This component does not know who you are or what is being
|
|
20
|
+
* searched; it only knows where those go.
|
|
21
|
+
*/
|
|
22
|
+
export interface TopBarProps {
|
|
23
|
+
variant?: 'solid' | 'floating'
|
|
24
|
+
/** Far left: the menu button, in an app whose navigation collapses. Stays the caller's — the bar only places it. */
|
|
25
|
+
menu?: ReactNode
|
|
26
|
+
/** Beside the menu button: the app's logo mark, or its name as text. */
|
|
27
|
+
logo?: ReactNode
|
|
28
|
+
/** The centre: a search field, or a launcher affordance — or nothing; the slot holds its place. */
|
|
29
|
+
search?: ReactNode
|
|
30
|
+
/** The right cluster — an `IdentityMenu` and its neighbours. */
|
|
31
|
+
right?: ReactNode
|
|
32
|
+
className?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function TopBar({ variant = 'solid', menu, logo, search, right, className }: TopBarProps) {
|
|
36
|
+
const floating = variant === 'floating'
|
|
37
|
+
return (
|
|
38
|
+
<header
|
|
39
|
+
className={cn(
|
|
40
|
+
'flex h-[52px] items-center pl-5 pr-[26px]',
|
|
41
|
+
floating
|
|
42
|
+
? 'pointer-events-none absolute left-0 right-0 top-0 z-10'
|
|
43
|
+
: 'shrink-0 gap-4 border-b border-border-default bg-bg-surface',
|
|
44
|
+
className,
|
|
45
|
+
)}
|
|
46
|
+
>
|
|
47
|
+
{(menu || logo) && (
|
|
48
|
+
<div className={cn('flex shrink-0 items-center gap-2', floating && 'pointer-events-auto')}>
|
|
49
|
+
{menu}
|
|
50
|
+
{logo && <div className="flex items-center text-body-2-strong text-text-primary">{logo}</div>}
|
|
51
|
+
</div>
|
|
52
|
+
)}
|
|
53
|
+
<div className={cn('flex min-w-0 flex-1 items-center justify-center', floating && 'pointer-events-auto')}>{search}</div>
|
|
54
|
+
<div className={cn('flex shrink-0 items-center gap-[6px]', floating && 'pointer-events-auto')}>{right}</div>
|
|
55
|
+
</header>
|
|
56
|
+
)
|
|
57
|
+
}
|
package/src/cn.test.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import preset from '../tailwind-preset.js'
|
|
3
|
+
import { cn, _fontSizeTokens } from './cn'
|
|
4
|
+
|
|
5
|
+
describe('cn knows the type ramp', () => {
|
|
6
|
+
it('lists exactly the preset fontSize keys', () => {
|
|
7
|
+
const presetKeys = Object.keys((preset as { theme: { extend: { fontSize: Record<string, unknown> } } }).theme.extend.fontSize)
|
|
8
|
+
expect([..._fontSizeTokens].sort()).toEqual([...presetKeys].sort())
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('keeps a token size beside a colour — the trap this fix retires', () => {
|
|
12
|
+
for (const token of _fontSizeTokens) {
|
|
13
|
+
expect(cn(`text-${token} text-text-primary`)).toBe(`text-${token} text-text-primary`)
|
|
14
|
+
}
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('still resolves conflicts, last one wins', () => {
|
|
18
|
+
expect(cn('text-body-2', 'text-[12px]')).toBe('text-[12px]')
|
|
19
|
+
expect(cn('text-[14px]', 'text-caption')).toBe('text-caption')
|
|
20
|
+
expect(cn('text-body-2', 'text-caption')).toBe('text-caption')
|
|
21
|
+
expect(cn('text-text-secondary', 'text-text-primary')).toBe('text-text-primary')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('leaves non-ramp text utilities alone', () => {
|
|
25
|
+
expect(cn('text-left text-body-2 text-text-primary')).toBe('text-left text-body-2 text-text-primary')
|
|
26
|
+
})
|
|
27
|
+
})
|
package/src/cn.ts
CHANGED
|
@@ -1,13 +1,33 @@
|
|
|
1
1
|
import { clsx, type ClassValue } from 'clsx'
|
|
2
|
-
import {
|
|
2
|
+
import { extendTailwindMerge } from 'tailwind-merge'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
5
|
+
* The preset's type ramp, by name, so tailwind-merge can classify the token
|
|
6
|
+
* classes as sizes. Stock tailwind-merge has never heard of `text-body-2`,
|
|
7
|
+
* files it under text-COLOUR, and lets `text-text-primary` in the same
|
|
8
|
+
* merged list knock it out — the trap Katerina's Tabs review confirmed by
|
|
9
|
+
* measurement (2026-09-01), and the reason shared components used to spell
|
|
10
|
+
* sizes as arbitrary values. Naming the ramp here retires that rule for
|
|
11
|
+
* everything using this cn — the package and, since the 2026-09-01 dedupe,
|
|
12
|
+
* both apps.
|
|
6
13
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
14
|
+
* Must match `tailwind-preset.js` `fontSize` key for key; `cn.test.ts`
|
|
15
|
+
* asserts it (the preset imports node-only modules, so it cannot be
|
|
16
|
+
* imported here without dragging them into the browser bundle).
|
|
10
17
|
*/
|
|
18
|
+
const FONT_SIZE_TOKENS = [
|
|
19
|
+
'h1', 'h2', 'h3', 'h4', 'h5',
|
|
20
|
+
'body-1', 'body-2', 'body-2-strong', 'caption', 'menu',
|
|
21
|
+
'btn-default', 'btn-small', 'input-label', 'input-value', 'input-helper', 'chip',
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
const twMerge = extendTailwindMerge({
|
|
25
|
+
extend: { classGroups: { 'font-size': [{ text: FONT_SIZE_TOKENS }] } },
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
/** Merge class names, last-one-wins on conflicting Tailwind utilities. */
|
|
11
29
|
export function cn(...inputs: ClassValue[]) {
|
|
12
30
|
return twMerge(clsx(inputs))
|
|
13
31
|
}
|
|
32
|
+
|
|
33
|
+
export { FONT_SIZE_TOKENS as _fontSizeTokens }
|