@estiva-app/ui 0.1.0 → 0.2.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 +36 -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 +150 -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
@@ -0,0 +1,24 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { SearchInput } from './SearchInput'
3
+
4
+ /** An inset field whose border strengthens on focus; under Signal the shortcut becomes the mono kbd chip. */
5
+ const meta = {
6
+ title: 'Inputs/SearchInput',
7
+ component: SearchInput,
8
+ decorators: [(Story) => <div className="w-[290px]"><Story /></div>],
9
+ } satisfies Meta<typeof SearchInput>
10
+
11
+ export default meta
12
+ type Story = StoryObj<typeof meta>
13
+
14
+ export const Default: Story = {}
15
+
16
+ /** The keyboard hint at the right edge. */
17
+ export const WithShortcut: Story = {
18
+ args: { shortcut: '⌘ K' },
19
+ }
20
+
21
+ /** The default placeholder says only "Search…" — the app names what is searched. */
22
+ export const OwnPlaceholder: Story = {
23
+ args: { placeholder: 'Search documents…', shortcut: '⌘ K' },
24
+ }
@@ -0,0 +1,45 @@
1
+ import { type InputHTMLAttributes } from 'react'
2
+ import { cn } from './cn'
3
+
4
+ /**
5
+ * Peek's SearchInput (2026-09-01), verbatim: an inset field with a hairline
6
+ * border that strengthens on focus, and an optional keyboard hint at the
7
+ * right edge — under Signal, the hint becomes the mono `kbd` chip with the
8
+ * thicker bottom edge.
9
+ *
10
+ * The one product string — Peek's default placeholder "Search Peek..." —
11
+ * stays in Peek; the default here says only "Search…".
12
+ *
13
+ * In Peek's top bar this is a launcher affordance rather than a live field:
14
+ * the input is `pointer-events-none` and clicking the surround opens the
15
+ * command launcher. The component is the same either way.
16
+ */
17
+ export interface SearchInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'className'> {
18
+ /** A keyboard hint drawn at the right edge, e.g. "⌘ K". */
19
+ shortcut?: string
20
+ className?: string
21
+ }
22
+
23
+ export function SearchInput({ shortcut, className, placeholder = 'Search…', ...props }: SearchInputProps) {
24
+ return (
25
+ <div
26
+ className={cn(
27
+ 'flex gap-2 items-center px-3 py-2 rounded-lg',
28
+ 'bg-bg-inset border border-border-default',
29
+ 'focus-within:border-border-strong transition-colors',
30
+ className,
31
+ )}
32
+ >
33
+ <input
34
+ className="flex-1 min-w-0 bg-transparent text-input-value text-text-primary placeholder:text-text-muted outline-none"
35
+ placeholder={placeholder}
36
+ {...props}
37
+ />
38
+ {shortcut && (
39
+ <div className="flex items-center justify-center px-1 py-px rounded-sm bg-bg-inset border border-border-strong shrink-0 signal:bg-[rgba(255,255,255,.05)] signal:border-b-2">
40
+ <span className="text-caption text-text-secondary whitespace-nowrap signal:font-mono signal:text-[10px]">{shortcut}</span>
41
+ </div>
42
+ )}
43
+ </div>
44
+ )
45
+ }
@@ -0,0 +1,52 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as SectionHeaderStories from './SectionHeader.stories'
3
+
4
+ <Meta of={SectionHeaderStories} />
5
+
6
+ # SectionHeader
7
+
8
+ The 32px row a section starts with: a SectionLabel, an optional collapse
9
+ chevron that makes the whole row the toggle, and actions that appear on
10
+ hover as IconButtons with tooltips.
11
+
12
+ <Canvas of={SectionHeaderStories.WithActions} />
13
+
14
+ ## When
15
+
16
+ - A sidebar or panel section that collapses, or that carries its own
17
+ actions — add, sort, filter.
18
+ - `showActions="always"` keeps the actions visible — for touch, or for a
19
+ section whose actions are the point.
20
+
21
+ <Canvas of={SectionHeaderStories.Collapsible} />
22
+
23
+ ## When not
24
+
25
+ - A title with no chevron and no actions → **SectionLabel** alone.
26
+ - A heading inside a menu → **MenuSection**.
27
+ - Page-level headings → the `h*` type tokens.
28
+
29
+ ## How
30
+
31
+ ```tsx
32
+ import { SectionHeader } from '@estiva-app/ui'
33
+ import { IconPlus } from '@tabler/icons-react'
34
+
35
+ <SectionHeader
36
+ title="Sections"
37
+ chevron
38
+ isExpanded={expanded}
39
+ onToggle={() => setExpanded((v) => !v)}
40
+ actions={[{ icon: <IconPlus size={16} stroke={1.5} />, tooltip: 'Add', onClick: add }]}
41
+ />
42
+ ```
43
+
44
+ - An action is `{ icon, tooltip, onClick }` — the tooltip doubles as the
45
+ action's accessible name, and an action's click never also toggles the
46
+ section.
47
+ - With `chevron`, the whole row is the toggle — no separate hit target to
48
+ find.
49
+
50
+ ## Props
51
+
52
+ <Controls of={SectionHeaderStories.Plain} />
@@ -0,0 +1,66 @@
1
+ import { useState } from 'react'
2
+ import type { Meta, StoryObj } from '@storybook/react-vite'
3
+ import { IconPlus, IconSortDescending } from '@tabler/icons-react'
4
+ import { SectionHeader } from './SectionHeader'
5
+
6
+ /** The 32px row a section starts with. Hover it: the row fills, and its actions appear. */
7
+ const meta = {
8
+ title: 'Navigation/SectionHeader',
9
+ component: SectionHeader,
10
+ decorators: [(Story) => <div className="w-[280px]"><Story /></div>],
11
+ args: { title: 'Starred', showActions: 'hover' },
12
+ argTypes: { showActions: { control: 'inline-radio', options: ['hover', 'always'] } },
13
+ } satisfies Meta<typeof SectionHeader>
14
+
15
+ export default meta
16
+ type Story = StoryObj<typeof meta>
17
+
18
+ export const Plain: Story = {}
19
+
20
+ /** The chevron makes the whole row the toggle; click it. */
21
+ export const Collapsible: Story = {
22
+ args: { title: 'Your documents', chevron: true },
23
+ render: (args) => {
24
+ const [expanded, setExpanded] = useState(true)
25
+ return (
26
+ <div className="flex flex-col gap-1">
27
+ <SectionHeader {...args} isExpanded={expanded} onToggle={() => setExpanded((v) => !v)} />
28
+ {expanded &&
29
+ ['Quarterly plan', 'Reading list'].map((row) => (
30
+ <div key={row} className="rounded-lg px-2 py-1.5 text-[14px] leading-[140%] text-text-primary hover:bg-bg-hover">
31
+ {row}
32
+ </div>
33
+ ))}
34
+ </div>
35
+ )
36
+ },
37
+ }
38
+
39
+ /** Peek's add/sort pair, in its original order — revealed on hover, and a click on one never toggles the section. */
40
+ export const WithActions: Story = {
41
+ args: {
42
+ title: 'Sections',
43
+ chevron: true,
44
+ actions: [
45
+ { icon: <IconSortDescending size={16} stroke={1.5} />, tooltip: 'Sort by', onClick: () => {} },
46
+ { icon: <IconPlus size={16} stroke={1.5} />, tooltip: 'Add', onClick: () => {} },
47
+ ],
48
+ },
49
+ }
50
+
51
+ /** Actions without the collapse — a fixed section that still offers Add on hover. */
52
+ export const ActionsOnly: Story = {
53
+ args: {
54
+ title: 'Pinned',
55
+ actions: [{ icon: <IconPlus size={16} stroke={1.5} />, tooltip: 'Add', onClick: () => {} }],
56
+ },
57
+ }
58
+
59
+ /** The actions held on screen — `showActions="always"` — for a section whose affordance should not hide. */
60
+ export const PersistentActions: Story = {
61
+ args: {
62
+ title: 'Pinned',
63
+ showActions: 'always',
64
+ actions: [{ icon: <IconPlus size={16} stroke={1.5} />, tooltip: 'Add', onClick: () => {} }],
65
+ },
66
+ }
@@ -0,0 +1,85 @@
1
+ import { useState, type ReactNode } from 'react'
2
+ import { IconChevronRight } from '@tabler/icons-react'
3
+ import { cn } from './cn'
4
+ import { IconButton } from './IconButton'
5
+ import { SectionLabel } from './SectionLabel'
6
+
7
+ /**
8
+ * The 32px row a section starts with (Peek's SectionHeader, 2026-09-01):
9
+ * a SectionLabel, an optional collapse chevron that makes the whole row the
10
+ * toggle, and actions that appear only while the row is hovered, as
11
+ * IconButtons with tooltips.
12
+ *
13
+ * One API change from Peek's original, safe because no call site used the
14
+ * old shape yet: the two hard-wired action slots (add, sort) became
15
+ * `actions` — the caller brings the icon, the tooltip and the handler; the
16
+ * header owns the hover reveal and stops the click from also toggling the
17
+ * section. Peek's add/sort pair is the WithActions story, in its original
18
+ * order.
19
+ */
20
+ export interface SectionAction {
21
+ /** 16px, stroke 1.5. */
22
+ icon: ReactNode
23
+ tooltip: string
24
+ onClick: () => void
25
+ }
26
+
27
+ export interface SectionHeaderProps {
28
+ title: string
29
+ /** Collapsible: draws the chevron and makes the whole row the toggle. */
30
+ chevron?: boolean
31
+ isExpanded?: boolean
32
+ onToggle?: () => void
33
+ /** Right-aligned, in the order given. */
34
+ actions?: SectionAction[]
35
+ /** `hover` reveals the actions only while the row is hovered; `always` keeps them. */
36
+ showActions?: 'hover' | 'always'
37
+ className?: string
38
+ }
39
+
40
+ export function SectionHeader({ title, chevron = false, isExpanded = true, onToggle, actions, showActions = 'hover', className }: SectionHeaderProps) {
41
+ const [isHovered, setIsHovered] = useState(false)
42
+
43
+ return (
44
+ <div
45
+ className={cn(
46
+ 'flex h-[32px] items-center justify-between rounded-lg px-2 transition-colors',
47
+ isHovered && 'bg-bg-hover',
48
+ chevron && 'cursor-pointer',
49
+ className,
50
+ )}
51
+ onClick={chevron ? onToggle : undefined}
52
+ onMouseEnter={() => setIsHovered(true)}
53
+ onMouseLeave={() => setIsHovered(false)}
54
+ >
55
+ <div className="flex shrink-0 items-center gap-1">
56
+ {chevron && (
57
+ <IconChevronRight
58
+ size={12}
59
+ stroke={1.5}
60
+ className={cn('text-text-secondary transition-transform duration-150', isExpanded && 'rotate-90')}
61
+ />
62
+ )}
63
+ <SectionLabel>{title}</SectionLabel>
64
+ </div>
65
+
66
+ {(showActions === 'always' || isHovered) && actions && actions.length > 0 && (
67
+ <div className="flex items-center gap-1">
68
+ {actions.map((action) => (
69
+ <IconButton
70
+ key={action.tooltip}
71
+ tooltip={action.tooltip}
72
+ aria-label={action.tooltip}
73
+ onClick={(event) => {
74
+ event.stopPropagation()
75
+ action.onClick()
76
+ }}
77
+ >
78
+ {action.icon}
79
+ </IconButton>
80
+ ))}
81
+ </div>
82
+ )}
83
+ </div>
84
+ )
85
+ }
@@ -0,0 +1,42 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as SectionLabelStories from './SectionLabel.stories'
3
+
4
+ <Meta of={SectionLabelStories} />
5
+
6
+ # SectionLabel
7
+
8
+ **The** section-title span. Every section heading — sidebar sections, menu
9
+ headings, panel titles — renders through it, so a change of voice is one
10
+ edit. 12px / 500, primary text; some themes restyle it (the toolbar shows
11
+ how).
12
+
13
+ <Canvas of={SectionLabelStories.HeadingAList} />
14
+
15
+ ## When
16
+
17
+ - Titling a group of rows in a sidebar, a panel, a rail.
18
+ - Inside a menu it arrives via **MenuSection** — don't place it there by
19
+ hand.
20
+
21
+ ## When not
22
+
23
+ - A heading that also collapses or carries actions → **SectionHeader**.
24
+ - The label above a stacked control → **Property** `stacked` — a field
25
+ label is a different voice, at a different size, on purpose.
26
+ - Page-level headings → the `h*` type tokens.
27
+
28
+ ## How
29
+
30
+ ```tsx
31
+ import { SectionLabel } from '@estiva-app/ui'
32
+
33
+ <SectionLabel>Pinned</SectionLabel>
34
+ ```
35
+
36
+ It is a bare span: the caller owns the row it sits in and the spacing
37
+ around it. Read it `secondary` (via `className`) where it labels rather
38
+ than leads — menu headings do.
39
+
40
+ ## Props
41
+
42
+ <Controls of={SectionLabelStories.Default} />
@@ -0,0 +1,31 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { SectionLabel } from './SectionLabel'
3
+
4
+ /** Every section title renders through this span — a style change is one edit. Under Signal it becomes the mono uppercase micro-label. */
5
+ const meta = {
6
+ title: 'Primitives/SectionLabel',
7
+ component: SectionLabel,
8
+ args: { children: 'Starred' },
9
+ } satisfies Meta<typeof SectionLabel>
10
+
11
+ export default meta
12
+ type Story = StoryObj<typeof meta>
13
+
14
+ export const Default: Story = {}
15
+
16
+ /** Where it lives: heading a group of rows. */
17
+ export const HeadingAList: Story = {
18
+ parameters: { controls: { disable: true } },
19
+ render: () => (
20
+ <div className="flex w-[240px] flex-col gap-1">
21
+ <div className="flex h-8 items-center px-2">
22
+ <SectionLabel>Recent</SectionLabel>
23
+ </div>
24
+ {['Quarterly plan', 'Reading list', 'Archive'].map((row) => (
25
+ <div key={row} className="rounded-lg px-2 py-1.5 text-[14px] leading-[140%] text-text-primary hover:bg-bg-hover">
26
+ {row}
27
+ </div>
28
+ ))}
29
+ </div>
30
+ ),
31
+ }
@@ -0,0 +1,28 @@
1
+ import type { ReactNode } from 'react'
2
+ import { cn } from './cn'
3
+
4
+ /**
5
+ * THE section-title label — every section heading (sidebar sections, menu
6
+ * headings, panel titles) renders through this span, so a style change is
7
+ * one edit. Peek's original, verbatim: 12px / 12px / 500, primary text
8
+ * (Peek's ruling 2026-07-23: labels stay primary), the mono uppercase
9
+ * micro-label under Signal. Metrics as arbitrary values for the tw-merge
10
+ * reason the README records.
11
+ *
12
+ * Deliberately NOT the same thing as Property's stacked field label (the
13
+ * 9px `menu`-token one): they were merged for a day and unmerged by
14
+ * Katerina's ruling (2026-09-01) — a section title and a field label are
15
+ * different voices, at different sizes, on purpose.
16
+ */
17
+ export function SectionLabel({ children, className }: { children: ReactNode; className?: string }) {
18
+ return (
19
+ <span
20
+ className={cn(
21
+ 'text-[12px] leading-[12px] font-medium text-text-primary signal:font-mono signal:text-[10px] signal:uppercase signal:tracking-[0.14em]',
22
+ className,
23
+ )}
24
+ >
25
+ {children}
26
+ </span>
27
+ )
28
+ }
@@ -0,0 +1,97 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { fitMenu } from './Select'
3
+
4
+ /**
5
+ * The Select menu's viewport geometry (Katerina, 2026-09-01): the files-panel
6
+ * picker was cut off at the right edge, its tail ran past the bottom of the
7
+ * screen, and it could not be scrolled. `fitMenu` is the pure half of the
8
+ * fix; these pin its decisions.
9
+ */
10
+ const viewport = { width: 1280, height: 800 }
11
+
12
+ describe('fitMenu', () => {
13
+ it('leaves a comfortable menu exactly where the trigger put it', () => {
14
+ const fit = fitMenu({
15
+ anchor: { left: 100, top: 200, bottom: 232 },
16
+ menu: { width: 240, contentHeight: 180 },
17
+ viewport,
18
+ })
19
+ expect(fit.left).toBe(100)
20
+ expect(fit.top).toBe(236)
21
+ expect(fit.bottom).toBeUndefined()
22
+ expect(fit.maxHeight).toBe(288) // room to spare — the cap is the only limit
23
+ })
24
+
25
+ it('clamps a menu that would run off the right edge (the files-panel cut)', () => {
26
+ const fit = fitMenu({
27
+ anchor: { left: 1200, top: 200, bottom: 232 },
28
+ menu: { width: 340, contentHeight: 180 },
29
+ viewport,
30
+ })
31
+ // 1280 - 340 - 8 margin: fully on screen, margin kept.
32
+ expect(fit.left).toBe(932)
33
+ })
34
+
35
+ it('never pushes a menu past the LEFT edge either', () => {
36
+ const fit = fitMenu({
37
+ anchor: { left: -20, top: 200, bottom: 232 },
38
+ menu: { width: 240, contentHeight: 180 },
39
+ viewport,
40
+ })
41
+ expect(fit.left).toBe(8)
42
+ })
43
+
44
+ it('caps height to the room below, so the last option is never off screen', () => {
45
+ const fit = fitMenu({
46
+ anchor: { left: 100, top: 560, bottom: 592 },
47
+ menu: { width: 240, contentHeight: 200 },
48
+ viewport,
49
+ })
50
+ // Room below = 800 - 592 - 4 gap - 8 margin = 196 < content 200 …but the
51
+ // room above (548) is not better than needed-below comparison? It is —
52
+ // openUp triggers only when below < min(content, cap) AND above > below.
53
+ // Here above (548) > below (196), content 200 > 196 → opens upward.
54
+ expect(fit.bottom).toBe(800 - 560 + 4)
55
+ expect(fit.maxHeight).toBe(288)
56
+ })
57
+
58
+ it('opens upward when the room above is better (the low trigger)', () => {
59
+ const fit = fitMenu({
60
+ anchor: { left: 100, top: 700, bottom: 732 },
61
+ menu: { width: 240, contentHeight: 400 },
62
+ viewport,
63
+ })
64
+ expect(fit.top).toBeUndefined()
65
+ expect(fit.bottom).toBe(800 - 700 + 4) // anchored to the trigger's top
66
+ expect(fit.maxHeight).toBe(288) // still capped
67
+ })
68
+
69
+ it('stays below when the room below is bad but the room above is worse', () => {
70
+ const fit = fitMenu({
71
+ anchor: { left: 100, top: 60, bottom: 92 },
72
+ menu: { width: 240, contentHeight: 400 },
73
+ viewport,
74
+ })
75
+ expect(fit.top).toBe(96)
76
+ // Room below = 800 - 92 - 12 = 696 ≥ cap → full cap available.
77
+ expect(fit.maxHeight).toBe(288)
78
+ })
79
+
80
+ it('keeps a 120px floor in a cramped corner — scrollable beats invisible', () => {
81
+ const fit = fitMenu({
82
+ anchor: { left: 100, top: 740, bottom: 772 },
83
+ menu: { width: 240, contentHeight: 400 },
84
+ viewport: { width: 1280, height: 800 },
85
+ })
86
+ expect(fit.maxHeight).toBeGreaterThanOrEqual(120)
87
+ })
88
+
89
+ it('caps a long list at 288 with plenty of room — the old max-h-72', () => {
90
+ const fit = fitMenu({
91
+ anchor: { left: 100, top: 100, bottom: 132 },
92
+ menu: { width: 240, contentHeight: 900 },
93
+ viewport,
94
+ })
95
+ expect(fit.maxHeight).toBe(288)
96
+ })
97
+ })
package/src/Select.mdx ADDED
@@ -0,0 +1,71 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as SelectStories from './Select.stories'
3
+
4
+ <Meta of={SelectStories} />
5
+
6
+ # Select
7
+
8
+ One value for a form field: a button that shows the choice and opens a
9
+ portalled listbox under itself. The list keeps itself on screen — clamped
10
+ to the viewport's sides, height capped to the room it actually has,
11
+ opening upward when the room below is worse — so it works in cramped
12
+ corners and scrolling panels.
13
+
14
+ <Canvas of={SelectStories.Default} />
15
+
16
+ ## When
17
+
18
+ - A field with one answer out of a known set: a status, a stage, a person.
19
+ - Options may carry a `leading` node — a face beside a name — shown in the
20
+ trigger and the list alike.
21
+ - Two sizes; `small` for dense rows and rails (it sits well inside
22
+ **Property**).
23
+
24
+ <Canvas of={SelectStories.WithLeading} />
25
+
26
+ ## When not
27
+
28
+ - A list of actions → **Menu** — a Select changes a value, it does not do
29
+ things.
30
+ - Several values → **ChipInput**.
31
+ - Switching views of one thing → **Tabs**.
32
+ - `disabled` explains nothing by itself — wrap it in a **WithTooltip**
33
+ that gives the reason.
34
+
35
+ ## How
36
+
37
+ ```tsx
38
+ import { Select } from '@estiva-app/ui'
39
+
40
+ <Select
41
+ value={status}
42
+ onChange={setStatus}
43
+ options={[
44
+ { value: 'open', label: 'Open' },
45
+ { value: 'done', label: 'Done' },
46
+ ]}
47
+ ariaLabel="Status"
48
+ />
49
+ ```
50
+
51
+ - The trigger truncates long labels instead of stretching its row — give
52
+ it room with your layout, not with `white-space` tricks.
53
+ - Its geometry is pure and tested (`fitMenu`): 288px cap, 120px floor —
54
+ scrollable beats invisible.
55
+
56
+ ## Keys
57
+
58
+ | Input | What happens |
59
+ |---|---|
60
+ | Enter / Space / click, closed | opens the list |
61
+ | ↑ / ↓ | move the active option, kept scrolled into view |
62
+ | Home / End | jump to the first / last option |
63
+ | Enter / Space, open | picks the active option |
64
+ | Escape | closes, returns focus to the trigger |
65
+ | Tab | closes |
66
+ | click outside | closes |
67
+ | page scroll or resize | closes (the list is fixed where the trigger was) — the list's **own** scroll never dismisses it |
68
+
69
+ ## Props
70
+
71
+ <Controls of={SelectStories.Default} />
@@ -105,3 +105,98 @@ export const LongLabels: Story = {
105
105
  export const ManyOptions: Story = {
106
106
  render: (args) => <Demo {...args} initial="p3" options={Array.from({ length: 20 }, (_, i) => ({ value: `p${i}`, label: `Project ${i + 1}` }))} />,
107
107
  }
108
+
109
+ /**
110
+ * The files-panel bug, reproduced (Katerina, 2026-09-01): a trigger in the
111
+ * bottom-right corner with a long people list. The menu used to run off the
112
+ * right edge, past the bottom, and close itself on its own scroll. Now it
113
+ * clamps to the viewport, opens upward when the room below is worse, and
114
+ * scrolls — open it and try the wheel and the arrow keys.
115
+ */
116
+ export const CorneredBottomRight: Story = {
117
+ parameters: { layout: 'fullscreen' },
118
+ render: (args) => (
119
+ <div className="h-[560px]">
120
+ {/* `fixed`, escaping the meta decorator's 360px column: the point is the
121
+ REAL viewport corner, where both clamps have to earn their keep. */}
122
+ <div className="fixed bottom-6 right-2 w-[240px]">
123
+ <Demo
124
+ {...args}
125
+ size="small"
126
+ initial=""
127
+ placeholder="Unassigned"
128
+ options={[
129
+ { value: '', label: 'Unassigned' },
130
+ ...[
131
+ 'Claude (steered by Katerina Kelepouri)',
132
+ 'Claude (steered by Miky)',
133
+ 'Handle probe',
134
+ 'Katerina Kelepouri',
135
+ 'Miky',
136
+ 'PC (steered by Katerina Kelepouri)',
137
+ 'PEEK-72 renamed',
138
+ 'Relay Operator',
139
+ 'Amie Miles',
140
+ 'Alice Johnson',
141
+ 'Daniel Stanton',
142
+ 'Greg Bothman',
143
+ ].map((name, i) => ({
144
+ value: `p${i}`,
145
+ label: name,
146
+ leading: <Avatar name={name} size={16} />,
147
+ })),
148
+ ]}
149
+ />
150
+ </div>
151
+ </div>
152
+ ),
153
+ }
154
+
155
+ /** The same list from a trigger high on screen — stays below, capped, scrollable. */
156
+ export const LongPeopleList: Story = {
157
+ render: (args) => (
158
+ <Demo
159
+ {...args}
160
+ initial=""
161
+ placeholder="Unassigned"
162
+ options={[
163
+ { value: '', label: 'Unassigned' },
164
+ ...Array.from({ length: 18 }, (_, i) => ({
165
+ value: `p${i}`,
166
+ label: `Person ${i + 1}`,
167
+ leading: <Avatar name={`Person ${i + 1}`} size={16} />,
168
+ })),
169
+ ]}
170
+ />
171
+ ),
172
+ }
173
+
174
+
175
+ /**
176
+ * A long label in a labelled property row (Katerina, 2026-09-01): the trigger
177
+ * used to size to its content and shove the row past its card. `min-w-0
178
+ * max-w-full` lets the flex row shrink it, so the label truncates instead.
179
+ */
180
+ export const LongLabelInPropertyRow: Story = {
181
+ render: (args) => (
182
+ <div className="w-[300px] rounded-lg border border-border-subtle p-3">
183
+ <div className="flex items-center gap-2">
184
+ <span className="w-[68px] shrink-0 text-xs text-text-secondary">Lead</span>
185
+ <Demo
186
+ {...args}
187
+ size="small"
188
+ className="w-auto"
189
+ initial="claude"
190
+ options={[
191
+ { value: '', label: 'Unassigned' },
192
+ {
193
+ value: 'claude',
194
+ label: 'Claude (steered by Katerina Kelepouri)',
195
+ leading: <Avatar name="Claude K" size={16} />,
196
+ },
197
+ ]}
198
+ />
199
+ </div>
200
+ </div>
201
+ ),
202
+ }