@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.
Files changed (157) hide show
  1. package/README.md +12 -9
  2. package/base.css +43 -0
  3. package/dist/AppShell.d.ts +39 -0
  4. package/dist/AppShell.d.ts.map +1 -0
  5. package/dist/Avatar.d.ts +7 -2
  6. package/dist/Avatar.d.ts.map +1 -1
  7. package/dist/AvatarGroup.d.ts +23 -0
  8. package/dist/AvatarGroup.d.ts.map +1 -0
  9. package/dist/Banner.d.ts +19 -0
  10. package/dist/Banner.d.ts.map +1 -0
  11. package/dist/Breadcrumb.d.ts +28 -0
  12. package/dist/Breadcrumb.d.ts.map +1 -0
  13. package/dist/Checkbox.d.ts +18 -0
  14. package/dist/Checkbox.d.ts.map +1 -0
  15. package/dist/ChipInput.d.ts +57 -0
  16. package/dist/ChipInput.d.ts.map +1 -0
  17. package/dist/ConfirmDialog.d.ts +22 -0
  18. package/dist/ConfirmDialog.d.ts.map +1 -0
  19. package/dist/DialogShell.d.ts +9 -2
  20. package/dist/DialogShell.d.ts.map +1 -1
  21. package/dist/EditableText.d.ts +56 -0
  22. package/dist/EditableText.d.ts.map +1 -0
  23. package/dist/IdentityMenu.d.ts +58 -0
  24. package/dist/IdentityMenu.d.ts.map +1 -0
  25. package/dist/Menu.d.ts +82 -0
  26. package/dist/Menu.d.ts.map +1 -0
  27. package/dist/NavItem.d.ts +26 -0
  28. package/dist/NavItem.d.ts.map +1 -0
  29. package/dist/Person.d.ts +26 -0
  30. package/dist/Person.d.ts.map +1 -0
  31. package/dist/PersonTrigger.d.ts +23 -0
  32. package/dist/PersonTrigger.d.ts.map +1 -0
  33. package/dist/Property.d.ts +24 -0
  34. package/dist/Property.d.ts.map +1 -0
  35. package/dist/Rail.d.ts +18 -0
  36. package/dist/Rail.d.ts.map +1 -0
  37. package/dist/RailItem.d.ts +23 -0
  38. package/dist/RailItem.d.ts.map +1 -0
  39. package/dist/SearchInput.d.ts +21 -0
  40. package/dist/SearchInput.d.ts.map +1 -0
  41. package/dist/SectionHeader.d.ts +34 -0
  42. package/dist/SectionHeader.d.ts.map +1 -0
  43. package/dist/SectionLabel.d.ts +19 -0
  44. package/dist/SectionLabel.d.ts.map +1 -0
  45. package/dist/Select.d.ts +42 -5
  46. package/dist/Select.d.ts.map +1 -1
  47. package/dist/Sidebar.d.ts +18 -0
  48. package/dist/Sidebar.d.ts.map +1 -0
  49. package/dist/Tabs.d.ts +34 -0
  50. package/dist/Tabs.d.ts.map +1 -0
  51. package/dist/Toast.d.ts +48 -0
  52. package/dist/Toast.d.ts.map +1 -0
  53. package/dist/Tooltip.d.ts +3 -1
  54. package/dist/Tooltip.d.ts.map +1 -1
  55. package/dist/TopBar.d.ts +33 -0
  56. package/dist/TopBar.d.ts.map +1 -0
  57. package/dist/cn.d.ts +14 -4
  58. package/dist/cn.d.ts.map +1 -1
  59. package/dist/index.d.ts +23 -0
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +1254 -106
  62. package/dist/index.js.map +4 -4
  63. package/package.json +5 -2
  64. package/src/AppShell.mdx +66 -0
  65. package/src/AppShell.stories.tsx +117 -0
  66. package/src/AppShell.tsx +77 -0
  67. package/src/Avatar.initials.test.ts +21 -0
  68. package/src/Avatar.mdx +49 -0
  69. package/src/Avatar.tsx +12 -9
  70. package/src/AvatarGroup.mdx +39 -0
  71. package/src/AvatarGroup.stories.tsx +56 -0
  72. package/src/AvatarGroup.tsx +42 -0
  73. package/src/Banner.mdx +45 -0
  74. package/src/Banner.stories.tsx +35 -0
  75. package/src/Banner.tsx +35 -0
  76. package/src/Breadcrumb.mdx +52 -0
  77. package/src/Breadcrumb.stories.tsx +42 -0
  78. package/src/Breadcrumb.tsx +106 -0
  79. package/src/Button.mdx +57 -0
  80. package/src/Checkbox.mdx +44 -0
  81. package/src/Checkbox.stories.tsx +49 -0
  82. package/src/Checkbox.tsx +46 -0
  83. package/src/Chip.mdx +43 -0
  84. package/src/ChipInput.mdx +68 -0
  85. package/src/ChipInput.stories.tsx +99 -0
  86. package/src/ChipInput.tsx +247 -0
  87. package/src/ConfirmDialog.mdx +51 -0
  88. package/src/ConfirmDialog.stories.tsx +63 -0
  89. package/src/ConfirmDialog.tsx +55 -0
  90. package/src/DialogShell.mdx +68 -0
  91. package/src/DialogShell.tsx +12 -5
  92. package/src/Divider.mdx +41 -0
  93. package/src/EditableText.mdx +59 -0
  94. package/src/EditableText.stories.tsx +63 -0
  95. package/src/EditableText.tsx +169 -0
  96. package/src/EmptyState.mdx +41 -0
  97. package/src/Field.mdx +39 -0
  98. package/src/IconButton.mdx +47 -0
  99. package/src/IdentityMenu.mdx +62 -0
  100. package/src/IdentityMenu.stories.tsx +52 -0
  101. package/src/IdentityMenu.tsx +159 -0
  102. package/src/Menu.mdx +111 -0
  103. package/src/Menu.stories.tsx +66 -0
  104. package/src/Menu.tsx +172 -0
  105. package/src/MenuItem.mdx +55 -0
  106. package/src/MenuItem.stories.tsx +98 -0
  107. package/src/NavItem.mdx +53 -0
  108. package/src/NavItem.stories.tsx +36 -0
  109. package/src/NavItem.tsx +53 -0
  110. package/src/Person.mdx +44 -0
  111. package/src/Person.stories.tsx +43 -0
  112. package/src/Person.tsx +39 -0
  113. package/src/PersonTrigger.mdx +51 -0
  114. package/src/PersonTrigger.stories.tsx +30 -0
  115. package/src/PersonTrigger.tsx +62 -0
  116. package/src/Property.mdx +51 -0
  117. package/src/Property.stories.tsx +91 -0
  118. package/src/Property.tsx +44 -0
  119. package/src/Rail.tsx +26 -0
  120. package/src/RailItem.mdx +50 -0
  121. package/src/RailItem.stories.tsx +37 -0
  122. package/src/RailItem.tsx +53 -0
  123. package/src/SearchInput.mdx +39 -0
  124. package/src/SearchInput.stories.tsx +24 -0
  125. package/src/SearchInput.tsx +45 -0
  126. package/src/SectionHeader.mdx +52 -0
  127. package/src/SectionHeader.stories.tsx +66 -0
  128. package/src/SectionHeader.tsx +85 -0
  129. package/src/SectionLabel.mdx +42 -0
  130. package/src/SectionLabel.stories.tsx +31 -0
  131. package/src/SectionLabel.tsx +28 -0
  132. package/src/Select.fit.test.ts +97 -0
  133. package/src/Select.mdx +71 -0
  134. package/src/Select.stories.tsx +95 -0
  135. package/src/Select.tsx +123 -15
  136. package/src/Sidebar.mdx +56 -0
  137. package/src/Sidebar.stories.tsx +50 -0
  138. package/src/Sidebar.tsx +32 -0
  139. package/src/Skeleton.mdx +39 -0
  140. package/src/Tabs.mdx +53 -0
  141. package/src/Tabs.stories.tsx +91 -0
  142. package/src/Tabs.tsx +73 -0
  143. package/src/TextInput.mdx +39 -0
  144. package/src/Textarea.mdx +36 -0
  145. package/src/Toast.mdx +54 -0
  146. package/src/Toast.stories.tsx +87 -0
  147. package/src/Toast.tsx +169 -0
  148. package/src/Tooltip.mdx +51 -0
  149. package/src/Tooltip.tsx +4 -2
  150. package/src/TopBar.mdx +62 -0
  151. package/src/TopBar.stories.tsx +96 -0
  152. package/src/TopBar.tsx +57 -0
  153. package/src/cn.test.ts +27 -0
  154. package/src/cn.ts +25 -5
  155. package/src/index.ts +23 -0
  156. package/tailwind-preset.js +4 -3
  157. 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
+ }
@@ -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="inline-flex shrink-0" onMouseEnter={() => setShow(true)} onMouseLeave={() => setShow(false)}>
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 { twMerge } from 'tailwind-merge'
2
+ import { extendTailwindMerge } from 'tailwind-merge'
3
3
 
4
4
  /**
5
- * Peek's `cn`: clsx for the conditionals, tailwind-merge for the conflicts.
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
- * Remember the pitfall the README records: a custom text-size class in a
8
- * merged list is dropped when a `text-{colour}` follows it. Inside this
9
- * package, sizes are arbitrary values (`text-[12px]`), never the token class.
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 }