@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/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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* (the list is fixed to where
|
|
15
|
-
*
|
|
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
|
|
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',
|
|
67
|
-
window.addEventListener('scroll',
|
|
156
|
+
window.addEventListener('resize', onResize)
|
|
157
|
+
window.addEventListener('scroll', onScroll, true)
|
|
68
158
|
return () => {
|
|
69
159
|
document.removeEventListener('mousedown', onDown)
|
|
70
|
-
window.removeEventListener('resize',
|
|
71
|
-
window.removeEventListener('scroll',
|
|
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
|
-
|
|
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
|
-
|
|
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={
|
|
162
|
-
|
|
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
|
package/src/Sidebar.mdx
ADDED
|
@@ -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
|
+
}
|
package/src/Sidebar.tsx
ADDED
|
@@ -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
|
+
}
|
package/src/Skeleton.mdx
ADDED
|
@@ -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} />
|
package/src/Textarea.mdx
ADDED
|
@@ -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} />
|