@estiva-app/ui 0.22.0 → 0.24.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 (173) hide show
  1. package/README.md +21 -0
  2. package/dist/AppShell.d.ts.map +1 -1
  3. package/dist/AttachmentCard.d.ts.map +1 -1
  4. package/dist/Banner.d.ts +20 -2
  5. package/dist/Banner.d.ts.map +1 -1
  6. package/dist/ContainerHeader.d.ts +25 -0
  7. package/dist/ContainerHeader.d.ts.map +1 -0
  8. package/dist/EmptyState.d.ts +10 -1
  9. package/dist/EmptyState.d.ts.map +1 -1
  10. package/dist/Form.d.ts.map +1 -1
  11. package/dist/IconButton.d.ts +9 -1
  12. package/dist/IconButton.d.ts.map +1 -1
  13. package/dist/PreviewCard.d.ts +9 -2
  14. package/dist/PreviewCard.d.ts.map +1 -1
  15. package/dist/SectionHeader.d.ts +7 -1
  16. package/dist/SectionHeader.d.ts.map +1 -1
  17. package/dist/Select.d.ts +11 -3
  18. package/dist/Select.d.ts.map +1 -1
  19. package/dist/Tooltip.d.ts +13 -1
  20. package/dist/Tooltip.d.ts.map +1 -1
  21. package/dist/eslint/index.js +2 -1
  22. package/dist/eslint/index.js.map +2 -2
  23. package/dist/eslint/no-restyled-part.d.ts.map +1 -1
  24. package/dist/gates/app-checks.d.ts.map +1 -1
  25. package/dist/gates/{chunk-ZGJ2J5NU.js → chunk-EA33NP5B.js} +275 -11
  26. package/dist/gates/chunk-EA33NP5B.js.map +7 -0
  27. package/dist/gates/{chunk-AUXD4GCY.js → chunk-GTQZEHPC.js} +15 -1
  28. package/dist/gates/chunk-GTQZEHPC.js.map +7 -0
  29. package/dist/gates/cli.js +1 -1
  30. package/dist/gates/create-app.d.ts +2 -0
  31. package/dist/gates/create-app.d.ts.map +1 -1
  32. package/dist/gates/create-app.js +1 -1
  33. package/dist/gates/index.js +25 -3
  34. package/dist/gates/index.js.map +2 -2
  35. package/dist/gates/status.d.ts +2 -0
  36. package/dist/gates/status.d.ts.map +1 -1
  37. package/dist/index.d.ts +1 -0
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +196 -156
  40. package/dist/index.js.map +4 -4
  41. package/dist/registry/app-DQI6WLHR.js +10 -0
  42. package/dist/registry/app.d.ts +21 -0
  43. package/dist/registry/app.d.ts.map +1 -0
  44. package/dist/registry/build-LGFCCOLR.js +27 -0
  45. package/dist/registry/build-LGFCCOLR.js.map +7 -0
  46. package/dist/registry/build.d.ts +68 -2
  47. package/dist/registry/build.d.ts.map +1 -1
  48. package/dist/registry/{chunk-MRSBS5OP.js → chunk-E4JNV7PC.js} +68 -12
  49. package/dist/registry/chunk-E4JNV7PC.js.map +7 -0
  50. package/dist/registry/{chunk-QDYGB3QN.js → chunk-NJN4MQAM.js} +60 -15
  51. package/dist/registry/chunk-NJN4MQAM.js.map +7 -0
  52. package/dist/registry/{chunk-IJNCYVH4.js → chunk-W2B2G7OE.js} +34 -9
  53. package/dist/registry/chunk-W2B2G7OE.js.map +7 -0
  54. package/dist/registry/chunk-WMFF3MPP.js +648 -0
  55. package/dist/registry/chunk-WMFF3MPP.js.map +7 -0
  56. package/dist/registry/cli.js +81 -26
  57. package/dist/registry/cli.js.map +3 -3
  58. package/dist/registry/find.d.ts +12 -1
  59. package/dist/registry/find.d.ts.map +1 -1
  60. package/dist/registry/index.d.ts +5 -2
  61. package/dist/registry/index.d.ts.map +1 -1
  62. package/dist/registry/index.js +11 -3
  63. package/dist/registry/schema.d.ts +83 -6
  64. package/dist/registry/schema.d.ts.map +1 -1
  65. package/package.json +2 -2
  66. package/registry.json +283 -95
  67. package/src/AppShell.mdx +10 -0
  68. package/src/AppShell.test.tsx +16 -0
  69. package/src/AppShell.tsx +4 -1
  70. package/src/AttachmentCard.mdx +9 -0
  71. package/src/AttachmentCard.test.tsx +10 -0
  72. package/src/AttachmentCard.tsx +8 -5
  73. package/src/Avatar.mdx +13 -3
  74. package/src/AvatarGroup.mdx +4 -0
  75. package/src/Banner.mdx +30 -7
  76. package/src/Banner.stories.tsx +22 -0
  77. package/src/Banner.test.tsx +35 -0
  78. package/src/Banner.tsx +34 -9
  79. package/src/Breadcrumb.mdx +10 -0
  80. package/src/Button.mdx +14 -3
  81. package/src/Card.mdx +9 -0
  82. package/src/Checkbox.mdx +10 -0
  83. package/src/Chip.mdx +4 -0
  84. package/src/ChipInput.mdx +12 -0
  85. package/src/CollapsibleSection.mdx +9 -0
  86. package/src/CommandPalette.mdx +14 -2
  87. package/src/ConfirmDialog.mdx +11 -0
  88. package/src/ContainerHeader.mdx +60 -0
  89. package/src/ContainerHeader.stories.tsx +62 -0
  90. package/src/ContainerHeader.test.tsx +47 -0
  91. package/src/ContainerHeader.tsx +45 -0
  92. package/src/DialogShell.mdx +14 -0
  93. package/src/Divider.mdx +9 -1
  94. package/src/EditableText.mdx +11 -0
  95. package/src/EmptyState.mdx +27 -4
  96. package/src/EmptyState.stories.tsx +6 -1
  97. package/src/EmptyState.test.tsx +18 -1
  98. package/src/EmptyState.tsx +14 -1
  99. package/src/Field.mdx +10 -1
  100. package/src/FieldLine.mdx +9 -1
  101. package/src/FilePicker.mdx +8 -0
  102. package/src/Form.mdx +10 -0
  103. package/src/Form.test.tsx +26 -0
  104. package/src/Form.tsx +7 -0
  105. package/src/IconButton.mdx +16 -2
  106. package/src/IconButton.stories.tsx +3 -0
  107. package/src/IconButton.test.tsx +25 -0
  108. package/src/IconButton.tsx +61 -46
  109. package/src/IdentityMenu.mdx +9 -0
  110. package/src/InlineChip.mdx +8 -0
  111. package/src/Kbd.mdx +4 -0
  112. package/src/Link.mdx +10 -0
  113. package/src/Menu.mdx +16 -1
  114. package/src/MenuItem.mdx +12 -2
  115. package/src/NavItem.mdx +8 -0
  116. package/src/Person.mdx +4 -0
  117. package/src/PersonTrigger.mdx +9 -0
  118. package/src/Popover.mdx +21 -0
  119. package/src/PreviewCard.mdx +19 -4
  120. package/src/PreviewCard.tsx +11 -4
  121. package/src/ProgressBar.mdx +8 -0
  122. package/src/Property.mdx +4 -0
  123. package/src/Rail.mdx +9 -1
  124. package/src/RailItem.mdx +8 -0
  125. package/src/Reaction.mdx +9 -0
  126. package/src/ReactionPicker.mdx +8 -0
  127. package/src/ScrollArea.mdx +13 -2
  128. package/src/SearchInput.mdx +9 -0
  129. package/src/SectionHeader.mdx +13 -0
  130. package/src/SectionHeader.stories.tsx +9 -0
  131. package/src/SectionHeader.test.tsx +9 -0
  132. package/src/SectionHeader.tsx +8 -2
  133. package/src/SectionLabel.mdx +4 -0
  134. package/src/Select.mdx +21 -2
  135. package/src/Select.stories.tsx +4 -1
  136. package/src/Select.test.tsx +17 -0
  137. package/src/Select.tsx +34 -13
  138. package/src/Sidebar.mdx +8 -0
  139. package/src/Skeleton.mdx +4 -0
  140. package/src/Tabs.mdx +12 -1
  141. package/src/TextInput.mdx +8 -0
  142. package/src/Textarea.mdx +8 -0
  143. package/src/Toast.mdx +12 -1
  144. package/src/Toolbar.mdx +11 -0
  145. package/src/Tooltip.mdx +26 -2
  146. package/src/Tooltip.stories.tsx +26 -0
  147. package/src/Tooltip.test.tsx +29 -0
  148. package/src/Tooltip.tsx +17 -4
  149. package/src/TopBar.mdx +4 -0
  150. package/src/eslint/no-rebuilt-behaviour.ts +1 -1
  151. package/src/eslint/no-restyled-part.ts +1 -0
  152. package/src/gates/app-checks.ts +23 -1
  153. package/src/gates/create-app.test.ts +77 -6
  154. package/src/gates/create-app.ts +288 -15
  155. package/src/gates/status.ts +16 -0
  156. package/src/index.ts +1 -0
  157. package/src/pages.test.ts +142 -0
  158. package/src/registry/app.test.ts +562 -0
  159. package/src/registry/app.ts +854 -0
  160. package/src/registry/build.ts +114 -25
  161. package/src/registry/cli.ts +105 -42
  162. package/src/registry/find.ts +45 -8
  163. package/src/registry/index.ts +8 -1
  164. package/src/registry/registry.test.ts +5 -5
  165. package/src/registry/schema.ts +163 -12
  166. package/tailwind-preset.js +22 -1
  167. package/dist/gates/chunk-AUXD4GCY.js.map +0 -7
  168. package/dist/gates/chunk-ZGJ2J5NU.js.map +0 -7
  169. package/dist/registry/build-GOVLABI6.js +0 -13
  170. package/dist/registry/chunk-IJNCYVH4.js.map +0 -7
  171. package/dist/registry/chunk-MRSBS5OP.js.map +0 -7
  172. package/dist/registry/chunk-QDYGB3QN.js.map +0 -7
  173. /package/dist/registry/{build-GOVLABI6.js.map → app-DQI6WLHR.js.map} +0 -0
@@ -0,0 +1,62 @@
1
+ import type { Decorator, Meta, StoryObj } from '@storybook/react-vite'
2
+ import { IconEdit, IconSortDescending, IconX } from '@tabler/icons-react'
3
+ import { ContainerHeader } from './ContainerHeader'
4
+ import { IconButton } from './IconButton'
5
+
6
+ // The top of a column: a surface with a hairline, so the bar's own hairline and height can be seen.
7
+ const inColumn: Decorator = (Story) => <div className="w-96 overflow-hidden rounded-lg border border-border-default bg-bg-surface">{Story()}</div>
8
+
9
+ const meta = {
10
+ title: 'Navigation/ContainerHeader',
11
+ component: ContainerHeader,
12
+ args: { title: 'Items' },
13
+ argTypes: { actions: { control: false } },
14
+ decorators: [inColumn],
15
+ } satisfies Meta<typeof ContainerHeader>
16
+
17
+ export default meta
18
+ type Story = StoryObj<typeof meta>
19
+
20
+ /** A string title, one line, alone. */
21
+ export const Default: Story = {}
22
+
23
+ /** A chevron after the title, for a title that opens something. */
24
+ export const WithChevron: Story = { args: { title: 'All items', chevron: true } }
25
+
26
+ /** The column's own buttons at the right edge: IconButtons with tooltips. */
27
+ export const WithActions: Story = {
28
+ args: {
29
+ title: 'All items',
30
+ chevron: true,
31
+ actions: (
32
+ <>
33
+ <IconButton tooltip="Sort by" aria-label="Sort by">
34
+ <IconSortDescending size={16} stroke={1.5} />
35
+ </IconButton>
36
+ <IconButton tooltip="New item" aria-label="New item">
37
+ <IconEdit size={16} stroke={1.5} />
38
+ </IconButton>
39
+ </>
40
+ ),
41
+ },
42
+ }
43
+
44
+ /** A title that is not a string — a name over its caption — takes the room left and is drawn as given. */
45
+ export const WithRichTitle: Story = {
46
+ // axe color-contrast is off here until PLAN.md stage 0.10 is ruled: the caption
47
+ // is muted text, 3.94:1 in signal (AA 4.5:1) — as Peek's file page draws it.
48
+ parameters: { a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } } },
49
+ args: {
50
+ title: (
51
+ <div className="min-w-0">
52
+ <p className="truncate text-body-2-strong text-text-primary">Quarterly plan</p>
53
+ <p className="truncate text-caption text-text-muted">In Project Alpha</p>
54
+ </div>
55
+ ),
56
+ actions: (
57
+ <IconButton tooltip="Close" aria-label="Close">
58
+ <IconX size={16} stroke={1.5} />
59
+ </IconButton>
60
+ ),
61
+ },
62
+ }
@@ -0,0 +1,47 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * What `ContainerHeader.mdx` claims: a string title is one line that keeps its
4
+ * width, a node is drawn as given and takes the room left, and the column's
5
+ * buttons sit at the right edge. The pixels are proved against Peek's own
6
+ * (docs/GATES.md, UIG-13: A).
7
+ */
8
+ import { afterEach, describe, expect, it } from 'vitest'
9
+ import { cleanup, render, screen } from '@testing-library/react'
10
+ import { ContainerHeader } from './ContainerHeader'
11
+
12
+ afterEach(cleanup)
13
+
14
+ describe('ContainerHeader', () => {
15
+ it('is the 48px bar with a hairline under it', () => {
16
+ const { container } = render(<ContainerHeader title="Items" />)
17
+ const bar = container.firstElementChild!.className.split(' ')
18
+ expect(bar).toContain('h-12')
19
+ expect(bar).toContain('border-b')
20
+ expect(bar).toContain('border-border-subtle')
21
+ })
22
+
23
+ it('a string title is one line in body-2-strong, and keeps its width', () => {
24
+ render(<ContainerHeader title="Items" />)
25
+ const title = screen.getByText('Items')
26
+ expect(title.className).toContain('text-body-2-strong')
27
+ expect(title.className).toContain('whitespace-nowrap')
28
+ expect(title.parentElement!.className).toContain('shrink-0')
29
+ })
30
+
31
+ it('a node title is drawn as given and takes the room left', () => {
32
+ render(<ContainerHeader title={<em>Plan</em>} />)
33
+ const holder = screen.getByText('Plan').parentElement!
34
+ expect(holder.className).toContain('flex-1')
35
+ expect(holder.className).toContain('min-w-0')
36
+ })
37
+
38
+ it('draws a chevron only when asked, and the buttons at the right edge', () => {
39
+ const { container, rerender } = render(<ContainerHeader title="Items" />)
40
+ expect(container.querySelector('svg')).toBeNull()
41
+ expect(container.firstElementChild!.children).toHaveLength(1)
42
+ rerender(<ContainerHeader title="Items" chevron actions={<button type="button">New</button>} />)
43
+ expect(container.querySelector('svg')).not.toBeNull()
44
+ expect(container.firstElementChild!.children).toHaveLength(2)
45
+ expect(screen.getByRole('button', { name: 'New' }).closest('div')!.className).toContain('gap-1')
46
+ })
47
+ })
@@ -0,0 +1,45 @@
1
+ import type { ReactNode } from 'react'
2
+ import { IconChevronDown } from '@tabler/icons-react'
3
+ import { cn } from './cn'
4
+
5
+ /**
6
+ * The bar across the top of a column — a list, a thread, a side panel: its
7
+ * title, and the buttons that act on the whole column at the right edge.
8
+ * Peek's ContainerHeader, moved into the package exactly as it looks
9
+ * (Katerina, 2026-09-18, UIG-13).
10
+ *
11
+ * 48px tall, a hairline under it. A string title is one line in
12
+ * `body-2-strong` and keeps its width; anything else — an EditableText, a
13
+ * title over a caption — takes the room that is left and is drawn as given.
14
+ *
15
+ * Not a SectionHeader: that is the 32px row one section of a column starts
16
+ * with. This is the column's own bar, one per column.
17
+ */
18
+ export interface ContainerHeaderProps {
19
+ /** A string keeps the one-line treatment; a node is drawn as given. */
20
+ title: ReactNode
21
+ /** A chevron after the title, for a title that opens something. */
22
+ chevron?: boolean
23
+ /** The column's own buttons, at the right edge: IconButtons with tooltips, 4px apart. */
24
+ actions?: ReactNode
25
+ className?: string
26
+ }
27
+
28
+ export function ContainerHeader({ title, chevron = false, actions, className }: ContainerHeaderProps) {
29
+ const text = typeof title === 'string'
30
+ return (
31
+ <div className={cn('flex h-12 shrink-0 items-center justify-between overflow-hidden border-b border-border-subtle py-2 pr-4 pl-5', className)}>
32
+ <div className={cn('flex items-center gap-2 overflow-hidden', text ? 'shrink-0' : 'min-w-0 flex-1')}>
33
+ <div className={cn('flex items-center gap-1', text ? 'shrink-0' : 'min-w-0 flex-1')}>
34
+ {text ? <span className="whitespace-nowrap text-body-2-strong text-text-primary">{title}</span> : <div className="min-w-0 flex-1">{title}</div>}
35
+ {chevron && <IconChevronDown size={12} stroke={1.5} className="shrink-0 text-text-secondary" />}
36
+ </div>
37
+ </div>
38
+ {actions && (
39
+ <div className="flex shrink-0 items-center justify-end gap-3">
40
+ <div className="flex shrink-0 items-center gap-1">{actions}</div>
41
+ </div>
42
+ )}
43
+ </div>
44
+ )
45
+ }
@@ -28,6 +28,7 @@ footer for the buttons. A dialog is just what goes in the three slots.
28
28
  dialog rebuilt by hand, which is the one thing this bullet exists to stop.
29
29
  Its canvases and its nine tests are on that page.
30
30
  - Telling someone what happened → **Toast**.
31
+ - Searching for something and acting on it → **CommandPalette**.
31
32
 
32
33
  ## How
33
34
 
@@ -73,6 +74,19 @@ import { DialogShell, Button } from '@estiva-app/ui'
73
74
  stops closing it (Katerina, D20). Escape and the ✕ still do.
74
75
  `ConfirmDialog` sets it; a form or a roster should not.
75
76
 
77
+ ## What it owns
78
+
79
+ It does these by itself. Write none of them around it.
80
+
81
+ | It does | So you never write | Refused when written by hand |
82
+ |---|---|---|
83
+ | Floats on top of the page | a portal, or a `z-index` to win | ✓ |
84
+ | Closes on a press outside, except with `alert` | a click listener on the page | ✓ |
85
+ | Takes its keys by itself: Escape closes it | a key listener on the page | ✓ |
86
+ | Holds focus inside while open, and gives it back to whatever opened it | focus code, or a Tab handler | ✓ |
87
+ | Stops the page behind it scrolling | `overflow: hidden` on the page | ✓ |
88
+ | Says what it is to assistive technology: a dialog | `role="dialog"` | ✓ |
89
+
76
90
  ## Keys
77
91
 
78
92
  | Input | What happens |
package/src/Divider.mdx CHANGED
@@ -16,7 +16,7 @@ orientation for assistive tech.
16
16
 
17
17
  - Between groups in a **Menu** (`className="my-1"` gives it its breathing
18
18
  room there).
19
- - Between sections of a panel or rail.
19
+ - Between sections of a panel, a rail or a card.
20
20
  - Vertical: between clusters in a toolbar or a dense row.
21
21
 
22
22
  <Canvas of={DividerStories.Vertical} />
@@ -49,6 +49,14 @@ import { Divider } from '@estiva-app/ui'
49
49
  - **Inside a `Menu` or a `Popover` it has no inset**: the hairline runs the
50
50
  width of the rows. The panel tells it so; nothing is passed in.
51
51
 
52
+ ## What it owns
53
+
54
+ It does these by itself. Write none of them around it.
55
+
56
+ | It does | So you never write | Refused when written by hand |
57
+ |---|---|---|
58
+ | Says what it is to assistive technology: a separator | `role="separator"` | ✓ |
59
+
52
60
  ## Props
53
61
 
54
62
  <Controls of={DividerStories.Default} />
@@ -23,6 +23,8 @@ edit is never silently lost.
23
23
  - A field in a form or dialog → **TextInput** / **Textarea** in a
24
24
  **Field**.
25
25
  - Composing something new → the field, not an edit-in-place of nothing.
26
+ - An edit that publishes something, like a message → **Textarea** with Save
27
+ and Cancel. EditableText saves when you leave it.
26
28
 
27
29
  ## How
28
30
 
@@ -55,6 +57,15 @@ import { EditableText } from '@estiva-app/ui'
55
57
  the page's font). `className` places it — `-mx-2` to line its text up with
56
58
  a header's inset — and a size passed there is refused by the lint (UIG-9).
57
59
 
60
+ ## What it owns
61
+
62
+ It does these by itself. Write none of them around it.
63
+
64
+ | It does | So you never write | Refused when written by hand |
65
+ |---|---|---|
66
+ | Opens its field on a click, with the value selected | an edit mode of your own | |
67
+ | Enter and leaving the field save; Escape cancels and puts the value back (`multiline`: Shift+Enter is a new line) | key or blur handlers | |
68
+
58
69
  ## Keys
59
70
 
60
71
  | Input | What happens |
@@ -36,10 +36,12 @@ need a height from the caller (`className="h-full"`).
36
36
 
37
37
  ## When not
38
38
 
39
- - Data is still on its way → **Skeleton**; empty and loading are
40
- different truths.
41
- - Something went wrong → say that, in your surface's own error treatment
42
- an empty state that hides a failure misleads.
39
+ - Data is still on its way → **Skeleton**; empty and loading are different
40
+ truths. When the shape of what arrives is unknown, an EmptyState that says so
41
+ "Opening…" is fine.
42
+ - Something went wrong say what failed, in words. An EmptyState can carry
43
+ it, in its usual look — never red. An empty state that hides a failure
44
+ misleads.
43
45
 
44
46
  ## How
45
47
 
@@ -53,6 +55,18 @@ import { EmptyState } from '@estiva-app/ui'
53
55
  <EmptyState scope="section" message="No comments yet." />
54
56
  ```
55
57
 
58
+ **When there is one thing to do about it, give it as `action`.** It is drawn
59
+ 16px under the line as the package's Button, outlined — the same look wherever
60
+ it appears (Katerina, 2026-09-18). Never put a Button beside an EmptyState in a
61
+ box of your own: the box places it, and no two boxes place it alike. A `page`
62
+ only; a section's line takes no action.
63
+
64
+ <Canvas of={EmptyStateStories.WithAction} />
65
+
66
+ ```tsx
67
+ <EmptyState icon={<IconMessagePlus size={16} stroke={1.5} />} message="No topics yet." action={{ label: 'New topic', onClick: openNewTopic }} />
68
+ ```
69
+
56
70
  **Put a section's empty state inside the box its rows live in, and give it no
57
71
  padding.** Write the box once; inside it goes either the rows or the empty
58
72
  state. The box's padding then places both, and the line starts where the first
@@ -71,6 +85,15 @@ The message is the caller's — a shared component has no words of its own
71
85
  for what is missing. A hand-written "Nothing here" paragraph or span in a
72
86
  section is the `section` manner, and becomes it.
73
87
 
88
+ ## What it owns
89
+
90
+ It does these by itself. Write none of them around it.
91
+
92
+ | It does | So you never write | Refused when written by hand |
93
+ |---|---|---|
94
+ | As a `page`, fills the room it is given and centres itself in it | centring, or padding around it | |
95
+ | Draws its one action, an outlined Button, under the line when you pass `action` | a button of your own under the line | |
96
+
74
97
  ## Props
75
98
 
76
99
  <Controls of={EmptyStateStories.Page} />
@@ -1,5 +1,5 @@
1
1
  import type { Decorator, Meta, StoryObj } from '@storybook/react-vite'
2
- import { IconLock } from '@tabler/icons-react'
2
+ import { IconLock, IconMessagePlus } from '@tabler/icons-react'
3
3
  import { EmptyState } from './EmptyState'
4
4
 
5
5
  // A box with a hairline, so where the state sits inside its room can be seen: a page's in the middle both ways, a section's at the top left.
@@ -25,6 +25,11 @@ const sectionContrastDeferred = { a11y: { config: { rules: [{ id: 'color-contras
25
25
  export const Section: Story = { args: { scope: 'section' }, decorators: [inBox], parameters: sectionContrastDeferred }
26
26
  export const LongerMessage: Story = { args: { message: 'No items yet. Add one from any list.' }, decorators: [inBox] }
27
27
  export const CustomIcon: Story = { args: { icon: <IconLock size={16} stroke={1.5} />, message: 'Nothing you can read here yet.' }, decorators: [inBox] }
28
+ /** The one thing to do about it, under the line: the package's Button, outlined. */
29
+ export const WithAction: Story = {
30
+ args: { icon: <IconMessagePlus size={16} stroke={1.5} />, message: 'No topics yet.', action: { label: 'New topic', onClick: () => {} } },
31
+ decorators: [inBox],
32
+ }
28
33
 
29
34
  /** One box, written once, holding the rows or the empty state: its padding places both, so the line starts where the first row does. */
30
35
  export const InsideTheRowsBox: Story = {
@@ -6,7 +6,7 @@
6
6
  * structure.
7
7
  */
8
8
  import { afterEach, describe, expect, it } from 'vitest'
9
- import { cleanup, render, screen } from '@testing-library/react'
9
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react'
10
10
  import { EmptyState } from './EmptyState'
11
11
 
12
12
  afterEach(cleanup)
@@ -59,4 +59,21 @@ describe('EmptyState', () => {
59
59
  expect(container.querySelector('[data-testid="own"]')).not.toBeNull()
60
60
  expect(container.querySelector('svg')).toBeNull()
61
61
  })
62
+
63
+ it('draws its action under the line as the package Button, outlined (Katerina, 2026-09-18)', () => {
64
+ let clicked = 0
65
+ render(<EmptyState message="No topics yet." action={{ label: 'New topic', onClick: () => (clicked += 1) }} />)
66
+ const button = screen.getByRole('button', { name: 'New topic' })
67
+ expect(button.className).toContain('border-border-default')
68
+ expect(button.className).toContain('mt-2')
69
+ // Under the line, in the same column.
70
+ expect(screen.getByText('No topics yet.').nextElementSibling).toBe(button)
71
+ fireEvent.click(button)
72
+ expect(clicked).toBe(1)
73
+ })
74
+
75
+ it('a section takes no action', () => {
76
+ render(<EmptyState scope="section" message="Nothing here yet." action={{ label: 'Add', onClick: () => {} }} />)
77
+ expect(screen.queryByRole('button')).toBeNull()
78
+ })
62
79
  })
@@ -1,5 +1,6 @@
1
1
  import type { ReactNode } from 'react'
2
2
  import { IconMessage2 } from '@tabler/icons-react'
3
+ import { Button } from './Button'
3
4
  import { cn } from './cn'
4
5
 
5
6
  /**
@@ -28,10 +29,16 @@ export interface EmptyStateProps {
28
29
  message: string
29
30
  /** `page` when the whole page is empty; `section` when one part of it is. */
30
31
  scope?: 'page' | 'section'
32
+ /**
33
+ * The one thing to do about it — "New topic", "Try again" — drawn 16px under
34
+ * the line as the package's Button, outlined (Katerina, 2026-09-18). A `page`
35
+ * only: a section's empty line is one line among others, not a stage.
36
+ */
37
+ action?: { label: string; onClick: () => void }
31
38
  className?: string
32
39
  }
33
40
 
34
- export function EmptyState({ icon, message, scope = 'page', className }: EmptyStateProps) {
41
+ export function EmptyState({ icon, message, scope = 'page', action, className }: EmptyStateProps) {
35
42
  if (scope === 'section') {
36
43
  return <p className={cn('text-caption text-text-muted', className)}>{message}</p>
37
44
  }
@@ -39,6 +46,12 @@ export function EmptyState({ icon, message, scope = 'page', className }: EmptySt
39
46
  <div className={cn('flex flex-1 flex-col items-center justify-center gap-2', className)}>
40
47
  <span className="text-text-secondary">{icon ?? <IconMessage2 size={16} stroke={1.5} />}</span>
41
48
  <p className="text-body-2 text-text-secondary text-center">{message}</p>
49
+ {/* 8px of the column's gap and 8px of its own: 16px under the line, as Peek had it. */}
50
+ {action && (
51
+ <Button variant="outlined" className="mt-2" onClick={action.onClick}>
52
+ {action.label}
53
+ </Button>
54
+ )}
42
55
  </div>
43
56
  )
44
57
  }
package/src/Field.mdx CHANGED
@@ -19,7 +19,7 @@ token; `required` marks it with the error-coloured asterisk.
19
19
 
20
20
  - Displaying a label–value pair → **Property**; Field is for editing.
21
21
  - The line belongs to a **group** of controls rather than to one — a value
22
- with Save beside it, a row of action controls → **Field line**, the same
22
+ with Save beside it, a row of action controls → **FieldLine**, the same
23
23
  small line on its own.
24
24
 
25
25
  ## How
@@ -55,6 +55,15 @@ import { Field, TextInput } from '@estiva-app/ui'
55
55
  that says so already is left alone, and a `children` of several elements
56
56
  keeps the asterisk and owes its own.
57
57
 
58
+ ## What it owns
59
+
60
+ It does these by itself. Write none of them around it.
61
+
62
+ | It does | So you never write | Refused when written by hand |
63
+ |---|---|---|
64
+ | Ties the label, the description and the error to the control inside it | `aria-label`, an `id` / `htmlFor` pair, or `aria-describedby` | |
65
+ | Marks the control invalid when it has an `error`, and announces it | `aria-invalid` | |
66
+
58
67
  ## Props
59
68
 
60
69
  <Controls of={FieldStories.Default} />
package/src/FieldLine.mdx CHANGED
@@ -32,7 +32,7 @@ import { FieldLine } from '@estiva-app/ui'
32
32
 
33
33
  <div className="flex flex-col gap-1.5">
34
34
  <div className="flex items-center gap-2">
35
- <TextInput value={draft} onChange={} />
35
+ <TextInput value={draft} onChange={(e) => setDraft(e.target.value)} />
36
36
  <Button variant="primary" size="small">Save</Button>
37
37
  </div>
38
38
  {outcome && <FieldLine tone={outcome.kind}>{outcome.message}</FieldLine>}
@@ -52,6 +52,14 @@ import { FieldLine } from '@estiva-app/ui'
52
52
  - The three tones are the same two classes Field draws, from one map in
53
53
  `Field.tsx`, so they cannot drift.
54
54
 
55
+ ## What it owns
56
+
57
+ It does these by itself. Write none of them around it.
58
+
59
+ | It does | So you never write | Refused when written by hand |
60
+ |---|---|---|
61
+ | Says what it is to assistive technology: an alert for an error, a status for the rest | `role="alert"` or `role="status"` | ✓ |
62
+
55
63
  ## Props
56
64
 
57
65
  <Controls of={FieldLineStories.Helper} />
@@ -44,6 +44,14 @@ const picker = useRef<HTMLInputElement>(null)
44
44
  nothing to reset afterwards.
45
45
  - `data-*` attributes pass on to the input, for a test to find it.
46
46
 
47
+ ## What it owns
48
+
49
+ It does these by itself. Write none of them around it.
50
+
51
+ | It does | So you never write | Refused when written by hand |
52
+ |---|---|---|
53
+ | Opens the system's file picker from its button, and keeps the real file input out of Tab and screen readers | a hidden `<input type="file">` | |
54
+
47
55
  ## Props
48
56
 
49
57
  <Controls of={FilePickerStories.Default} />
package/src/Form.mdx CHANGED
@@ -58,6 +58,16 @@ import { Button, Field, Form, TextInput } from '@estiva-app/ui'
58
58
  - `className` places the fields: the form draws no box, and neither does the
59
59
  `<fieldset>` inside it.
60
60
 
61
+ ## What it owns
62
+
63
+ It does these by itself. Write none of them around it.
64
+
65
+ | It does | So you never write | Refused when written by hand |
66
+ |---|---|---|
67
+ | Sends on Enter in a one-line field, and on Ctrl+Enter anywhere inside | a key handler that submits | |
68
+ | Leaves Enter alone where it means something else: a Textarea, a list, a people picker | exceptions of your own | |
69
+ | Does nothing while it is sending: no second send, and nothing inside takes focus | a busy flag on every control | |
70
+
61
71
  ## Keys
62
72
 
63
73
  The same in every form (Katerina, 16 September).
package/src/Form.test.tsx CHANGED
@@ -18,6 +18,7 @@ import { Checkbox } from './Checkbox'
18
18
  import { IconButton } from './IconButton'
19
19
  import { Field } from './Field'
20
20
  import { Form } from './Form'
21
+ import { Popover } from './Popover'
21
22
  import { TextInput } from './TextInput'
22
23
  import { Textarea } from './Textarea'
23
24
 
@@ -46,6 +47,31 @@ describe('Form', () => {
46
47
  expect(onSubmit).toHaveBeenCalledTimes(1)
47
48
  })
48
49
 
50
+ it('a Form in a Popover inside a Form sends only itself (C1)', async () => {
51
+ // A link field in a pop-up over a composer: Enter there adds the link, and
52
+ // must not also send the message around it. React carries the submit
53
+ // through the Popover's portal to the outer form.
54
+ const user = userEvent.setup()
55
+ const outer = vi.fn()
56
+ const inner = vi.fn()
57
+ render(
58
+ <Form onSubmit={outer}>
59
+ <TextInput aria-label="Message" />
60
+ <Popover trigger={<Button>Link</Button>} open ariaLabel="Link">
61
+ <Form onSubmit={inner}>
62
+ <TextInput aria-label="Address" />
63
+ </Form>
64
+ </Popover>
65
+ </Form>,
66
+ )
67
+ await user.type(screen.getByRole('textbox', { name: 'Address' }), 'example.com{Enter}')
68
+ expect(inner).toHaveBeenCalledTimes(1)
69
+ expect(outer).not.toHaveBeenCalled()
70
+ await user.type(screen.getByRole('textbox', { name: 'Message' }), 'hi{Enter}')
71
+ expect(outer).toHaveBeenCalledTimes(1)
72
+ expect(inner).toHaveBeenCalledTimes(1)
73
+ })
74
+
49
75
  it('sends from its submit button', async () => {
50
76
  const user = userEvent.setup()
51
77
  const onSubmit = vi.fn()
package/src/Form.tsx CHANGED
@@ -160,6 +160,13 @@ export function Form({ onSubmit, busy: ownBusy = false, enterSends = true, class
160
160
  }}
161
161
  onKeyDown={onKeyDown}
162
162
  onSubmit={(event) => {
163
+ /*
164
+ A form's own send only. React carries a submit up the component tree,
165
+ through a portal too, so a Form in a Popover inside this Form sent both:
166
+ Enter in a link field sent the whole message around it (UIG-14, C1,
167
+ Katerina 19 September). The inner Form has already handled its own.
168
+ */
169
+ if (event.target !== event.currentTarget) return
163
170
  event.preventDefault()
164
171
  if (busyNow.current) return
165
172
  void onSubmit()
@@ -22,8 +22,8 @@ variants. For the actions a surface repeats too often to label every time.
22
22
  - The action deserves a visible label → **Button** (words are cheaper than
23
23
  a guessed icon).
24
24
  - Inside a menu → **MenuItem** with `leading`.
25
- - A strip of these is a toolbar right; but the moment rows toggle and
26
- persist state, see **Checkbox**.
25
+ - A strip of these acting on one thing **Toolbar**. The moment rows toggle
26
+ and persist state, see **Checkbox**.
27
27
 
28
28
  ## How
29
29
 
@@ -54,11 +54,25 @@ import { IconPencil } from '@tabler/icons-react'
54
54
  - `disabledReason="Read only"` disables it, keeps it reachable by Tab,
55
55
  and shows the reason in place of the tooltip — **on keyboard focus as
56
56
  well as on hover**.
57
+ - **`href`** makes it a link that looks like the button: an anchor, so the
58
+ address stays real for a modified click or a new tab. A router app passes
59
+ `onClick`, prevents the default and navigates. While it cannot be used it
60
+ is the button — a link cannot be disabled.
57
61
  - `tooltip` costs no wrapper: the button is the tooltip's trigger, so
58
62
  this component's root is the `<button>` whether it carries one or not,
59
63
  and both sit at the same height. A `Dialog.Close` or a `Menu.Trigger`
60
64
  can be an IconButton that has a tooltip.
61
65
 
66
+ ## What it owns
67
+
68
+ It does these by itself. Write none of them around it.
69
+
70
+ | It does | So you never write | Refused when written by hand |
71
+ |---|---|---|
72
+ | Says what it is to assistive technology: a button | `role="button"` | ✓ |
73
+ | Is reachable with Tab, also when it is disabled with a reason | a `tabIndex` | ✓ |
74
+ | Presses on Enter and Space, and does nothing while disabled | a key handler | |
75
+
62
76
  ## Keys
63
77
 
64
78
  | Key | Does |
@@ -26,6 +26,9 @@ export const WithTooltip: Story = { args: { tooltip: 'Settings', tooltipPlacemen
26
26
  /** Disabled with its reason in place of the tooltip; Tab still reaches it. */
27
27
  export const WithAReason: Story = { args: { tooltip: 'Settings', disabledReason: 'Sign in to change settings' } }
28
28
 
29
+ /** `href`: a link that looks like the button — the same box, an anchor underneath. */
30
+ export const AsALink: Story = { args: { href: '#settings', tooltip: 'Settings' } }
31
+
29
32
  /** Every variant × enabled/disabled. */
30
33
  export const AllVariants: Story = {
31
34
  // axe color-contrast is off here until PLAN.md stage 0.10 is ruled:
@@ -28,6 +28,31 @@ describe('IconButton', () => {
28
28
  expect(onClick).toHaveBeenCalledTimes(1)
29
29
  })
30
30
 
31
+ it('href: a link that looks like the button, with its tooltip', async () => {
32
+ const user = userEvent.setup()
33
+ render(
34
+ <IconButton href="/documents/12" aria-label="Open" tooltip="Open">
35
+ {icon}
36
+ </IconButton>,
37
+ )
38
+ expect(screen.queryByRole('button')).toBeNull()
39
+ const link = screen.getByRole('link', { name: 'Open' })
40
+ expect(link.getAttribute('href')).toBe('/documents/12')
41
+ expect(link.className).toContain('rounded-lg')
42
+ await user.hover(link)
43
+ expect((await screen.findByRole('tooltip')).textContent).toBe('Open')
44
+ })
45
+
46
+ it('href while it cannot be used: the button, since a link cannot be disabled', () => {
47
+ render(
48
+ <IconButton href="/documents/12" aria-label="Open" disabledReason="Read only">
49
+ {icon}
50
+ </IconButton>,
51
+ )
52
+ expect(screen.queryByRole('link')).toBeNull()
53
+ expect(screen.getByRole('button', { name: 'Open' }).getAttribute('aria-disabled')).toBe('true')
54
+ })
55
+
31
56
  it('shows the tooltip on hover, with its shortcut', async () => {
32
57
  const user = userEvent.setup()
33
58
  render(