@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/Select.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import { IconCheck, IconChevronDown } from '@tabler/icons-react'
2
- import { useCallback, useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
2
+ import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
3
3
  import { createPortal } from 'react-dom'
4
4
  import { cn } from './cn'
5
5
 
@@ -8,11 +8,17 @@ import { cn } from './cn'
8
8
  * carry a `leading` node — an avatar beside a person's name — shown in the
9
9
  * trigger and in the list.
10
10
  *
11
- * A button that opens a portalled listbox under itself: arrows move, Home
12
- * and End jump, Enter and Space pick, Escape closes and returns focus to the
13
- * trigger, Tab closes, a click outside closes, a scroll or resize closes
14
- * (the list is fixed to where the trigger was). Two sizes; `disabled`
15
- * explains nothing by itself wrap it in a tooltip that does.
11
+ * A button that opens a portalled listbox under itself: arrows move (and keep
12
+ * the active option scrolled into view), Home and End jump, Enter and Space
13
+ * pick, Escape closes and returns focus to the trigger, Tab closes, a click
14
+ * outside closes, a PAGE scroll or resize closes (the list is fixed to where
15
+ * the trigger was)a scroll *inside* the list is the list's own business
16
+ * and must not dismiss it. The menu keeps itself on screen: clamped to the
17
+ * viewport's sides, height capped to the space it actually has, opening
18
+ * upward when the room below is worse than the room above (Katerina,
19
+ * 2026-09-01 — the files-panel picker was cut right and bottom, and its own
20
+ * scroll closed it). Two sizes; `disabled` explains nothing by itself — wrap
21
+ * it in a tooltip that does.
16
22
  */
17
23
  export interface SelectOption {
18
24
  value: string
@@ -32,11 +38,52 @@ export interface SelectProps {
32
38
  className?: string
33
39
  }
34
40
 
41
+ /**
42
+ * Where a menu of this size goes, given its anchor and the viewport — pure,
43
+ * so the geometry is testable without a browser.
44
+ *
45
+ * Left is clamped inside the viewport with an 8px margin. Height is capped
46
+ * at 288px (the old `max-h-72`) but never taller than the space it opens
47
+ * into; when the room below the anchor is smaller than both the content and
48
+ * the room above, the menu opens UPWARD (anchored to the trigger's top via
49
+ * `bottom`). The 120px floor keeps a menu usable even in a cramped corner —
50
+ * scrollable beats invisible.
51
+ */
52
+ export function fitMenu({
53
+ anchor,
54
+ menu,
55
+ viewport,
56
+ }: {
57
+ anchor: { left: number; top: number; bottom: number }
58
+ menu: { width: number; contentHeight: number }
59
+ viewport: { width: number; height: number }
60
+ }): { left: number; top?: number; bottom?: number; maxHeight: number } {
61
+ const MARGIN = 8
62
+ const GAP = 4
63
+ const CAP = 288
64
+ const left = Math.max(MARGIN, Math.min(anchor.left, viewport.width - menu.width - MARGIN))
65
+ const below = viewport.height - anchor.bottom - GAP - MARGIN
66
+ const above = anchor.top - GAP - MARGIN
67
+ const openUp = below < Math.min(menu.contentHeight, CAP) && above > below
68
+ const maxHeight = Math.max(Math.min(CAP, openUp ? above : below), 120)
69
+ return openUp
70
+ ? { left, bottom: viewport.height - anchor.top + GAP, maxHeight }
71
+ : { left, top: anchor.bottom + GAP, maxHeight }
72
+ }
73
+
35
74
  export function Select({ value, onChange, options, size = 'default', ariaLabel, placeholder = 'Select…', disabled, className }: SelectProps) {
36
75
  const id = useId()
37
76
  const triggerRef = useRef<HTMLButtonElement>(null)
38
77
  const menuRef = useRef<HTMLDivElement>(null)
39
78
  const [rect, setRect] = useState<DOMRect | null>(null)
79
+ /** Where the menu actually goes — measured against the viewport after the
80
+ * provisional render, so it is never cut off by an edge. */
81
+ const [placement, setPlacement] = useState<{
82
+ left: number
83
+ top?: number
84
+ bottom?: number
85
+ maxHeight: number
86
+ } | null>(null)
40
87
  const open = rect !== null
41
88
 
42
89
  const selectedIndex = useMemo(() => Math.max(0, options.findIndex((o) => o.value === value)), [options, value])
@@ -52,6 +99,39 @@ export function Select({ value, onChange, options, size = 'default', ariaLabel,
52
99
  setRect(triggerRef.current?.getBoundingClientRect() ?? null)
53
100
  }, [selectedIndex])
54
101
 
102
+ /*
103
+ * Fit the menu to the viewport, before paint.
104
+ *
105
+ * The provisional render sits at the trigger's corner and is invisible;
106
+ * this measures it and decides the real box: left clamped inside the
107
+ * viewport, height capped to the space available, and the whole thing
108
+ * opening UPWARD when the room below is smaller than both the content and
109
+ * the room above. A menu that is always fully on screen is also the only
110
+ * kind whose scrollbar can actually be used.
111
+ */
112
+ useLayoutEffect(() => {
113
+ if (!rect || !menuRef.current) {
114
+ setPlacement(null)
115
+ return
116
+ }
117
+ setPlacement(
118
+ fitMenu({
119
+ anchor: { left: rect.left, top: rect.top, bottom: rect.bottom },
120
+ menu: { width: menuRef.current.offsetWidth, contentHeight: menuRef.current.scrollHeight },
121
+ viewport: { width: window.innerWidth, height: window.innerHeight },
122
+ }),
123
+ )
124
+ }, [rect, options.length])
125
+
126
+ // Keyboard follows the highlight: without this, arrowing past the fold
127
+ // moved `activeIndex` into rows the capped menu never showed.
128
+ useEffect(() => {
129
+ if (!open) return
130
+ // Optional call: jsdom implements neither scrolling nor this method, and
131
+ // a consumer's component tests should not crash for a scroll nicety.
132
+ document.getElementById(`${id}-${activeIndex}`)?.scrollIntoView?.({ block: 'nearest' })
133
+ }, [open, activeIndex, id])
134
+
55
135
  // Outside click and Escape — the two exits every menu has. `mousedown`
56
136
  // rather than `click`, so a press that starts outside dismisses at once.
57
137
  useEffect(() => {
@@ -61,14 +141,24 @@ export function Select({ value, onChange, options, size = 'default', ariaLabel,
61
141
  if (triggerRef.current?.contains(e.target as Node)) return
62
142
  setRect(null)
63
143
  }
64
- const onMove = () => setRect(null)
144
+ const onResize = () => setRect(null)
145
+ /*
146
+ * A PAGE scroll moves the anchor out from under the fixed menu, so it
147
+ * closes. A scroll INSIDE the menu is the menu working as designed —
148
+ * capture phase sees those too, and closing on them made the list
149
+ * impossible to scroll at all (the bug this comment survives to prevent).
150
+ */
151
+ const onScroll = (e: Event) => {
152
+ if (e.target instanceof Node && menuRef.current?.contains(e.target)) return
153
+ setRect(null)
154
+ }
65
155
  document.addEventListener('mousedown', onDown)
66
- window.addEventListener('resize', onMove)
67
- window.addEventListener('scroll', onMove, true)
156
+ window.addEventListener('resize', onResize)
157
+ window.addEventListener('scroll', onScroll, true)
68
158
  return () => {
69
159
  document.removeEventListener('mousedown', onDown)
70
- window.removeEventListener('resize', onMove)
71
- window.removeEventListener('scroll', onMove, true)
160
+ window.removeEventListener('resize', onResize)
161
+ window.removeEventListener('scroll', onScroll, true)
72
162
  }
73
163
  }, [open])
74
164
 
@@ -133,9 +223,20 @@ export function Select({ value, onChange, options, size = 'default', ariaLabel,
133
223
  onClick={() => (open ? close(false) : openMenu())}
134
224
  onKeyDown={onKeyDown}
135
225
  className={cn(
136
- 'flex w-full items-center justify-between gap-2 rounded-lg border bg-bg-inset text-left',
226
+ /*
227
+ * `min-w-0 max-w-full`: a trigger must never outgrow its container
228
+ * (Katerina, 2026-09-01 — a long label stretched the files panel's
229
+ * Lead row past its card). In a flex row the default min-width:auto
230
+ * forbids shrinking below the label's width, which is what kept
231
+ * `truncate` from ever engaging; in a block container max-w-full is
232
+ * the cap. Full-width callers are unaffected.
233
+ */
234
+ 'flex w-full min-w-0 max-w-full items-center justify-between gap-2 rounded-lg border bg-bg-inset text-left',
137
235
  'border-border-default text-text-primary outline-none transition-colors',
138
- 'hover:border-border-strong disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled',
236
+ // The focused border survives a hover: hover alone strengthens the
237
+ // hairline, but hover while focused must not grey the focus colour —
238
+ // the stacked variant outranks plain hover by specificity.
239
+ 'hover:border-border-strong focus-visible:hover:border-border-focus aria-expanded:hover:border-border-focus disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled',
139
240
  'focus-visible:border-border-focus aria-expanded:border-border-focus',
140
241
  'signal:transition-shadow signal:focus-visible:shadow-focus-ring',
141
242
  size === 'default' && 'px-3 py-2 text-[14px] leading-[1.4] font-normal',
@@ -158,8 +259,15 @@ export function Select({ value, onChange, options, size = 'default', ariaLabel,
158
259
  role="listbox"
159
260
  aria-activedescendant={`${id}-${activeIndex}`}
160
261
  tabIndex={-1}
161
- style={{ top: rect.bottom + 4, left: rect.left, minWidth: rect.width }}
162
- className="fixed z-50 max-h-72 overflow-y-auto rounded-lg border border-border-default bg-bg-elevated p-1 shadow-lg"
262
+ style={
263
+ placement
264
+ ? { ...placement, minWidth: rect.width }
265
+ : // Provisional frame: measured by the layout effect above,
266
+ // replaced before paint. Hidden so a cut-off position is
267
+ // never visible, not even for a frame.
268
+ { top: rect.bottom + 4, left: rect.left, minWidth: rect.width, visibility: 'hidden' }
269
+ }
270
+ className="fixed z-50 overflow-y-auto rounded-lg border border-border-default bg-bg-elevated p-1 shadow-lg"
163
271
  >
164
272
  {options.map((option, index) => (
165
273
  <div
@@ -0,0 +1,56 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as SidebarStories from './Sidebar.stories'
3
+
4
+ <Meta of={SidebarStories} />
5
+
6
+ # Sidebar
7
+
8
+ The navigation column: 240px, a hairline on its right, the surface behind
9
+ it, scrolling independently of the content beside it. What goes inside is
10
+ the caller's — this is the shell.
11
+
12
+ <Canvas of={SidebarStories.Composed} />
13
+
14
+ ## When
15
+
16
+ - The structured frame's navigation (AppShell's `solid` variant), filled
17
+ with **NavItem** rows — the sidebar pairs with the solid TopBar, as the
18
+ Rail pairs with the floating one.
19
+ - A labelled group inside it: a **SectionLabel** in a 32px `px-2` row
20
+ above its rows (the Composed story shows the idiom).
21
+ - It scrolls on its own — a long list never scrolls the frame away.
22
+
23
+ <Canvas of={SidebarStories.Scrolls} />
24
+
25
+ ## When not
26
+
27
+ - An icon strip → **Rail**.
28
+ - Sections within a page's content → **SectionHeader** over content, not
29
+ a second sidebar.
30
+ - Desktop only — there is no narrow-screen drawer; don't improvise one
31
+ per app.
32
+
33
+ ## How
34
+
35
+ ```tsx
36
+ import { Sidebar, NavItem, SectionLabel } from '@estiva-app/ui'
37
+
38
+ <Sidebar>
39
+ <NavItem href="#/documents" label="Documents" active />
40
+ <div className="mt-2 flex h-8 shrink-0 items-center px-2">
41
+ <SectionLabel>Collections</SectionLabel>
42
+ </div>
43
+ <NavItem href="#/collections/12" label="Quarterly plan" count={7} countLabel="7 open" />
44
+ </Sidebar>
45
+ ```
46
+
47
+ Anything you place directly in the column needs `shrink-0` (the heading
48
+ row above carries it): the column scrolls on overflow, and a flex child
49
+ without it gets compressed instead — rows must keep their height.
50
+
51
+ It is a `nav` region — pass `aria-label` when "Workspace" is not the
52
+ right name for what it navigates.
53
+
54
+ ## Props
55
+
56
+ <Controls of={SidebarStories.Composed} />
@@ -0,0 +1,50 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { IconSquareRounded } from '@tabler/icons-react'
3
+ import { NavItem } from './NavItem'
4
+ import { SectionLabel } from './SectionLabel'
5
+ import { Sidebar } from './Sidebar'
6
+
7
+ const placeholder = <IconSquareRounded size={16} stroke={1.5} />
8
+
9
+ const meta = {
10
+ title: 'Frame/Sidebar',
11
+ component: Sidebar,
12
+ parameters: { layout: 'fullscreen' },
13
+ args: { children: null },
14
+ argTypes: { children: { control: false } },
15
+ decorators: [(Story) => <div className="flex h-screen bg-bg-base">{Story()}</div>],
16
+ } satisfies Meta<typeof Sidebar>
17
+
18
+ export default meta
19
+ type Story = StoryObj<typeof meta>
20
+
21
+ /** Entries, then a labelled group — the heading is a SectionLabel in a 32px row. */
22
+ export const Composed: Story = {
23
+ render: (args) => (
24
+ <Sidebar {...args}>
25
+ <NavItem href="#" label="Item one" icon={placeholder} count={18} countLabel="18 open" active />
26
+ <NavItem href="#" label="Item two" icon={placeholder} count={5} countLabel="5 active" />
27
+ <div className="mt-2 flex h-8 shrink-0 items-center px-2">
28
+ <SectionLabel>Group</SectionLabel>
29
+ </div>
30
+ <NavItem href="#" label="Item three" count={7} countLabel="7 open" />
31
+ <NavItem href="#" label="Item four" count={2} countLabel="2 open" />
32
+ <NavItem href="#" label="Item five" />
33
+ </Sidebar>
34
+ ),
35
+ }
36
+
37
+ /** The column scrolls on its own — a long list never scrolls the frame away. */
38
+ export const Scrolls: Story = {
39
+ render: (args) => (
40
+ <Sidebar {...args}>
41
+ <NavItem href="#" label="Item one" icon={placeholder} count={18} countLabel="18 open" />
42
+ <div className="mt-2 flex h-8 shrink-0 items-center px-2">
43
+ <SectionLabel>Group</SectionLabel>
44
+ </div>
45
+ {Array.from({ length: 40 }, (_, i) => (
46
+ <NavItem key={i} href="#" label={`Item ${i + 2}`} active={i === 2} count={((i * 7) % 9) + 1} countLabel={`${((i * 7) % 9) + 1} open`} />
47
+ ))}
48
+ </Sidebar>
49
+ ),
50
+ }
@@ -0,0 +1,32 @@
1
+ import type { ReactNode } from 'react'
2
+ import { cn } from './cn'
3
+
4
+ /**
5
+ * The navigation column: 240px, a hairline on its right, the surface
6
+ * behind it, scrolling independently of the content beside it (the shell
7
+ * of Ship's Sidebar, 2026-09-02 — its contents stayed in the app).
8
+ *
9
+ * What goes inside is the caller's: NavItem rows, a SectionLabel heading
10
+ * in a 32px row over a group. Desktop only — there is no narrow-screen
11
+ * drawer.
12
+ */
13
+ export interface SidebarProps {
14
+ /** Names the navigation region for assistive tech. */
15
+ 'aria-label'?: string
16
+ children: ReactNode
17
+ className?: string
18
+ }
19
+
20
+ export function Sidebar({ 'aria-label': ariaLabel = 'Workspace', children, className }: SidebarProps) {
21
+ return (
22
+ <nav
23
+ aria-label={ariaLabel}
24
+ className={cn(
25
+ 'flex w-60 shrink-0 flex-col gap-px overflow-y-auto border-r border-border-default bg-bg-surface px-2.5 py-3',
26
+ className,
27
+ )}
28
+ >
29
+ {children}
30
+ </nav>
31
+ )
32
+ }
@@ -0,0 +1,39 @@
1
+ import { Meta, Canvas } from '@storybook/addon-docs/blocks'
2
+ import * as SkeletonStories from './Skeleton.stories'
3
+
4
+ <Meta of={SkeletonStories} />
5
+
6
+ # Skeleton
7
+
8
+ The loading placeholder, in generic parts: **SkeletonBar** (the pulsing
9
+ block), **SkeletonRow** (a 32px row — a 16px square and a bar, like a row
10
+ with a face and a name), and **SkeletonList** (rows of varied widths, so
11
+ the placeholder does not read as a pattern).
12
+
13
+ <Canvas of={SkeletonStories.List} />
14
+
15
+ ## When
16
+
17
+ - Data is on its way and the shape of what arrives is known — show that
18
+ shape.
19
+ - A placeholder shaped like your own card is yours to build **from the
20
+ bar**; the parts here stay ignorant of what they stand for.
21
+
22
+ ## When not
23
+
24
+ - Nothing is coming → **EmptyState**.
25
+ - A click is being processed → the control's own busy state, not a
26
+ skeleton.
27
+
28
+ ## How
29
+
30
+ ```tsx
31
+ import { SkeletonList, SkeletonBar } from '@estiva-app/ui'
32
+
33
+ {loading ? <SkeletonList rows={6} /> : <YourList items={items} />}
34
+ ```
35
+
36
+ - **SkeletonList reveals after 150ms** (`animate-skeleton-in`) — a fast
37
+ load never flashes a skeleton. Building your own placeholder from the
38
+ bar? Put that class on its container for the same manner.
39
+ - The list is `aria-hidden` — a placeholder has nothing to read.
package/src/Tabs.mdx ADDED
@@ -0,0 +1,53 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as TabsStories from './Tabs.stories'
3
+
4
+ <Meta of={TabsStories} />
5
+
6
+ # Tabs
7
+
8
+ A row of tabs for switching between views of one thing. The selected tab
9
+ is a neutral fill, not the accent — selection is a place, not a highlight.
10
+
11
+ <Canvas of={TabsStories.WithCounts} />
12
+
13
+ ## When
14
+
15
+ - Views of the same subject: all / unread / flagged, list / grid.
16
+ - A tab's `count` is a muted mono number after the label, sharing its
17
+ baseline — the one way a number sits beside a label. A count of `0` is
18
+ still drawn ("Assigned to me · 0" is an answer); an absent count draws
19
+ nothing.
20
+ - Two sizes: `default` (14px) and `small` (12px, the denser geometry).
21
+
22
+ <Canvas of={TabsStories.WithAZero} />
23
+
24
+ ## When not
25
+
26
+ - Going to a different place → links and **Breadcrumb**.
27
+ - Choosing a value that gets saved → **Select**.
28
+ - Actions → **Button**s; a tab never *does*, it *shows*.
29
+
30
+ ## How
31
+
32
+ ```tsx
33
+ import { Tabs } from '@estiva-app/ui'
34
+
35
+ <Tabs
36
+ tabs={[
37
+ { id: 'all', label: 'All', count: total },
38
+ { id: 'unread', label: 'Unread', count: unread },
39
+ ]}
40
+ active={tab}
41
+ onChange={setTab}
42
+ />
43
+ ```
44
+
45
+ - Generic over the id type — `onChange` hands back your union, not a
46
+ string.
47
+ - It renders `role="tablist"` / `role="tab"` with `aria-selected`;
48
+ switching is by click (there is no arrow-key roving), and wiring the
49
+ panels — what shows for the active tab — is the caller's.
50
+
51
+ ## Props
52
+
53
+ <Controls of={TabsStories.NoCounts} />
@@ -0,0 +1,91 @@
1
+ import { useState } from 'react'
2
+ import type { Meta, StoryObj } from '@storybook/react-vite'
3
+ import { IconLock } from '@tabler/icons-react'
4
+ import { Tabs, type TabDef } from './Tabs'
5
+
6
+ /** A row of tabs; the selected one is a neutral fill, never the accent tint. */
7
+ const meta = {
8
+ title: 'Navigation/Tabs',
9
+ component: Tabs,
10
+ } satisfies Meta<typeof Tabs>
11
+
12
+ export default meta
13
+ type Story = StoryObj<typeof meta>
14
+
15
+ function Demo<T extends string>({ tabs, size, initial }: { tabs: TabDef<T>[]; size?: 'default' | 'small'; initial: T }) {
16
+ const [active, setActive] = useState<T>(initial)
17
+ return <Tabs tabs={tabs} active={active} onChange={setActive} size={size} />
18
+ }
19
+
20
+ /** A count is the sidebar's number — mono, muted, tabular — not a chip (Katerina, 2026-09-01). */
21
+ export const WithCounts: Story = {
22
+ args: { tabs: [], active: 'active', onChange: () => {} },
23
+ render: () => (
24
+ <Demo
25
+ initial="all"
26
+ tabs={[
27
+ { id: 'all', label: 'All', count: 12 },
28
+ { id: 'archived', label: 'Archived', count: 4 },
29
+ ]}
30
+ />
31
+ ),
32
+ }
33
+
34
+ /** A count of 0 is still drawn — "Flagged 0" is an answer, not an absence. */
35
+ export const WithAZero: Story = {
36
+ args: { tabs: [], active: 'all', onChange: () => {} },
37
+ render: () => (
38
+ <Demo
39
+ initial="all"
40
+ tabs={[
41
+ { id: 'all', label: 'All', count: 31 },
42
+ { id: 'unread', label: 'Unread', count: 21 },
43
+ { id: 'flagged', label: 'Flagged', count: 0 },
44
+ ]}
45
+ />
46
+ ),
47
+ }
48
+
49
+ /** A plain pair — a view switch. */
50
+ export const NoCounts: Story = {
51
+ args: { tabs: [], active: 'table', onChange: () => {} },
52
+ render: () => (
53
+ <Demo
54
+ initial="list"
55
+ tabs={[
56
+ { id: 'list', label: 'List' },
57
+ { id: 'grid', label: 'Grid' },
58
+ ]}
59
+ />
60
+ ),
61
+ }
62
+
63
+ /** A leading icon, 16px stroke 1.5 — as Peek draws them. */
64
+ export const WithAnIcon: Story = {
65
+ args: { tabs: [], active: 'open', onChange: () => {} },
66
+ render: () => (
67
+ <Demo
68
+ initial="open"
69
+ tabs={[
70
+ { id: 'open', label: 'Open' },
71
+ { id: 'locked', label: 'Locked', icon: <IconLock size={16} stroke={1.5} /> },
72
+ ]}
73
+ />
74
+ ),
75
+ }
76
+
77
+ /** `small` is the 12px geometry. */
78
+ export const Small: Story = {
79
+ args: { tabs: [], active: 'conversations', onChange: () => {} },
80
+ render: () => (
81
+ <Demo
82
+ size="small"
83
+ initial="overview"
84
+ tabs={[
85
+ { id: 'overview', label: 'Overview' },
86
+ { id: 'private', label: 'Private', icon: <IconLock size={16} stroke={1.5} /> },
87
+ { id: 'activity', label: 'Activity' },
88
+ ]}
89
+ />
90
+ ),
91
+ }
package/src/Tabs.tsx ADDED
@@ -0,0 +1,73 @@
1
+ import type { ReactNode } from 'react'
2
+ import { cn } from './cn'
3
+
4
+ /**
5
+ * A row of tabs. Ship's Tabs (2026-09-01), which was Peek's TopicTabs with
6
+ * the topic-specific ids taken out.
7
+ *
8
+ * A selected tab is a neutral fill (bg-active), not the accent tint —
9
+ * Katerina's ruling (2026-08-27), extended to every app (2026-09-01). Two
10
+ * sizes: `default` is 14px, a little more room; `small` is Peek's own
11
+ * geometry (its inline `fontSize: 12` was the caption token spelled by hand).
12
+ *
13
+ * A tab's count is the sidebar's number, not a chip (Katerina, 2026-09-01,
14
+ * superseding the chip ruling of 2026-08-26): mono, caption size, muted,
15
+ * tabular — the one way a number sits beside a label everywhere. A count of
16
+ * `0` is still drawn ("Assigned to me 0" is an answer); an absent count draws
17
+ * nothing.
18
+ */
19
+ export interface TabDef<T extends string> {
20
+ id: T
21
+ label: string
22
+ /** Shown as a muted mono number after the label. Absent draws nothing; 0 is drawn. */
23
+ count?: number
24
+ /** 16px, stroke 1.5. */
25
+ icon?: ReactNode
26
+ }
27
+
28
+ export interface TabsProps<T extends string> {
29
+ tabs: TabDef<T>[]
30
+ active: T
31
+ onChange: (id: T) => void
32
+ /** `default` 14px; `small` 12px, the denser geometry. */
33
+ size?: 'default' | 'small'
34
+ className?: string
35
+ }
36
+
37
+ export function Tabs<T extends string>({ tabs, active, onChange, size = 'default', className }: TabsProps<T>) {
38
+ return (
39
+ <div role="tablist" className={cn('flex items-center gap-2', className)}>
40
+ {tabs.map((tab) => (
41
+ <button
42
+ key={tab.id}
43
+ type="button"
44
+ role="tab"
45
+ aria-selected={active === tab.id}
46
+ onClick={() => onChange(tab.id)}
47
+ className={cn(
48
+ 'flex cursor-pointer items-center transition-colors',
49
+ // Arbitrary sizes (the body-2 and caption tokens): the colour branch below
50
+ // follows them through cn(), and tw-merge drops a custom text-{size}
51
+ // once a text-{colour} lands after it. Measured: tabs rendered 16px.
52
+ // gap: default is Ship's 6px; small keeps Peek's original 4px, or
53
+ // "small is Peek's geometry" stops being true.
54
+ size === 'default' ? 'gap-1.5 rounded-md px-2 py-1 text-[14px] leading-[140%]' : 'gap-1 rounded px-1.5 py-0.5 text-[12px] leading-[120%]',
55
+ active === tab.id ? 'bg-bg-active text-text-primary' : 'text-text-secondary hover:bg-bg-hover',
56
+ )}
57
+ >
58
+ {tab.icon}
59
+ {/* Label and count share a baseline: a smaller text centred as a box
60
+ (items-center) floats above the label's baseline — the digits
61
+ read as riding high. Baseline alignment is what makes two sizes
62
+ sit on one line. */}
63
+ <span className="flex items-baseline">
64
+ {tab.label}
65
+ {tab.count !== undefined ? (
66
+ <span className="ml-2.5 font-mono text-caption tabular-nums text-text-secondary">{tab.count}</span>
67
+ ) : null}
68
+ </span>
69
+ </button>
70
+ ))}
71
+ </div>
72
+ )
73
+ }
@@ -0,0 +1,39 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as TextInputStories from './TextInput.stories'
3
+
4
+ <Meta of={TextInputStories} />
5
+
6
+ # TextInput
7
+
8
+ The single-line field: inset surface, 8px radius, 14px text, the focus
9
+ border in every theme.
10
+
11
+ <Canvas of={TextInputStories.Filled} />
12
+
13
+ ## When
14
+
15
+ - One line of text in a form or dialog — a name, a title, a URL. Label it
16
+ with a **Field**.
17
+
18
+ ## When not
19
+
20
+ - Several lines → **Textarea**.
21
+ - Filtering a list → **SearchInput**.
22
+ - Editing text where it is displayed → **EditableText**.
23
+
24
+ ## How
25
+
26
+ ```tsx
27
+ import { TextInput } from '@estiva-app/ui'
28
+
29
+ <TextInput value={name} onChange={(e) => setName(e.target.value)} placeholder="Untitled" />
30
+ ```
31
+
32
+ - It is a native `<input>` with a ref — every native prop passes through.
33
+ - Name it: a Field's label is visual, so give the input an `aria-label`
34
+ too.
35
+ - `disabled` gets the disabled surface and text; say why with a tooltip.
36
+
37
+ ## Props
38
+
39
+ <Controls of={TextInputStories.Empty} />
@@ -0,0 +1,36 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as TextareaStories from './Textarea.stories'
3
+
4
+ <Meta of={TextareaStories} />
5
+
6
+ # Textarea
7
+
8
+ TextInput's look on a multi-line field that does not resize.
9
+
10
+ <Canvas of={TextareaStories.Filled} />
11
+
12
+ ## When
13
+
14
+ - Several lines in a form or dialog — a description, a message body. Label
15
+ it with a **Field**.
16
+
17
+ ## When not
18
+
19
+ - One line → **TextInput**.
20
+ - Editing text where it is displayed → **EditableText** with `multiline`.
21
+
22
+ ## How
23
+
24
+ ```tsx
25
+ import { Textarea } from '@estiva-app/ui'
26
+
27
+ <Textarea rows={4} value={text} onChange={(e) => setText(e.target.value)} />
28
+ ```
29
+
30
+ - Resizing is off by design — size it with `rows` or your layout.
31
+ - Native `<textarea>` with a ref; every native prop passes through; give
32
+ it an accessible name.
33
+
34
+ ## Props
35
+
36
+ <Controls of={TextareaStories.Empty} />