@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
@@ -59,6 +59,12 @@ export interface SectionHeaderProps {
59
59
  actions?: SectionAction[]
60
60
  /** `hover` reveals the actions while the row is hovered or focused; `always` keeps them. */
61
61
  showActions?: 'hover' | 'always'
62
+ /**
63
+ * `fill` lights the row under the pointer when it does something (a toggle,
64
+ * actions); `none` keeps it still — a heading whose actions are always shown,
65
+ * where the buttons light up on their own (UIG-14, Katerina, 19 September).
66
+ */
67
+ hover?: 'fill' | 'none'
62
68
  /**
63
69
  * What the title renders as, in Base UI's manner. A plain button with
64
70
  * `onToggle` by default; `CollapsibleSection` hands in `Collapsible.Trigger`.
@@ -67,7 +73,7 @@ export interface SectionHeaderProps {
67
73
  className?: string
68
74
  }
69
75
 
70
- export function SectionHeader({ title, chevron = false, isExpanded = true, onToggle, trailing, actions, showActions = 'hover', render, className }: SectionHeaderProps) {
76
+ export function SectionHeader({ title, chevron = false, isExpanded = true, onToggle, trailing, actions, showActions = 'hover', hover = 'fill', render, className }: SectionHeaderProps) {
71
77
  const titleElement = useRender({
72
78
  render: render ?? (chevron ? <button type="button" onClick={onToggle} aria-expanded={isExpanded} /> : <span />),
73
79
  props: {
@@ -97,7 +103,7 @@ export function SectionHeader({ title, chevron = false, isExpanded = true, onTog
97
103
  // The fill says "this does something": a row with a toggle or actions
98
104
  // lights up, a fixed heading over rows does not (2026-09-09, the
99
105
  // Sidebar's fixed group).
100
- (chevron || (actions && actions.length > 0)) && 'hover:bg-bg-hover',
106
+ hover === 'fill' && (chevron || (actions && actions.length > 0)) && 'hover:bg-bg-hover',
101
107
  className,
102
108
  )}
103
109
  >
@@ -38,6 +38,10 @@ around it. Read it `tone="secondary"` where it labels rather than leads — menu
38
38
  headings and the command palette's groups do (Katerina, 2026-09-01). A colour
39
39
  passed in `className` is refused by the lint (UIG-9).
40
40
 
41
+ ## What it owns
42
+
43
+ Nothing. It only draws. Clicks and keys are yours.
44
+
41
45
  ## Props
42
46
 
43
47
  <Controls of={SectionLabelStories.Default} />
package/src/Select.mdx CHANGED
@@ -29,8 +29,10 @@ corners and scrolling panels.
29
29
  things.
30
30
  - Several values → **ChipInput**.
31
31
  - Switching views of one thing → **Tabs**.
32
- - `disabled` explains nothing by itself — wrap it in a **WithTooltip**
33
- that gives the reason.
32
+ - `disabled` explains nothing by itself — give it `disabledReason`, which says
33
+ why and keeps it reachable by Tab. Busy for a moment while an action runs
34
+ needs no reason.
35
+ - Ticking several on and off in a list → **Checkbox** with `row`.
34
36
 
35
37
  ## How
36
38
 
@@ -59,10 +61,27 @@ import { Select } from '@estiva-app/ui'
59
61
  smaller — and scrolls inside that. Measured in a bottom-right corner:
60
62
  the list flips above the trigger, slides left to stay on screen, and
61
63
  stands at its full 288.
64
+ - **`disabledReason="Read only"`** holds it shut and keeps it reachable by Tab,
65
+ showing the reason on hover and on keyboard focus — as `Button` does. Plain
66
+ `disabled` is a native disabled control: Tab skips it, so it cannot say why.
62
67
  - **The trigger is a `combobox`, not a `button`.** That is the ARIA pattern
63
68
  for this control and Base UI's doing; a test looking for it by role has
64
69
  to ask for `combobox`.
65
70
 
71
+ ## What it owns
72
+
73
+ It does these by itself. Write none of them around it.
74
+
75
+ | It does | So you never write | Refused when written by hand |
76
+ |---|---|---|
77
+ | Floats on top of the page | a portal, or a `z-index` to win | ✓ |
78
+ | Stays attached to its anchor on scroll and resize, and hides when its trigger scrolls out of sight | a scroll or resize listener, or placement arithmetic | ✓ |
79
+ | Closes on a press outside | a click listener on the page | ✓ |
80
+ | Takes its keys by itself: Enter and Space pick, Escape and Tab close | a key listener on the page | ✓ |
81
+ | Moves through its items with the arrow keys, and jumps to an option by its first letters | an arrow-key handler | ✓ |
82
+ | Says what it is to assistive technology: a list of options | `role="listbox"` or `role="option"` | ✓ |
83
+ | Keeps the highlighted option in view, and gives focus back to the trigger | scroll or focus code | |
84
+
66
85
  ## Keys
67
86
 
68
87
  | Input | What happens |
@@ -89,9 +89,12 @@ export const InAField: Story = {
89
89
  ),
90
90
  }
91
91
 
92
- /** Unavailable — the same disabled treatment as every other control. Say why, with a tooltip around it. */
92
+ /** Unavailable — the same disabled treatment as every other control. To say why, `WithAReason`. */
93
93
  export const Disabled: Story = { render: (args) => <Demo {...args} initial="done" disabled /> }
94
94
 
95
+ /** `disabledReason`: it looks disabled and will not open, but Tab reaches it and the reason shows on hover and on focus. */
96
+ export const WithAReason: Story = { render: (args) => <Demo {...args} initial="done" disabledReason="Read only: you are a guest here" /> }
97
+
95
98
  /** Longer labels than the trigger is wide: the trigger truncates rather than growing; the menu takes the trigger's width as a minimum and grows past it. */
96
99
  export const LongLabels: Story = {
97
100
  render: (args) => (
@@ -57,6 +57,23 @@ describe('Select', () => {
57
57
  expect(trigger.getAttribute('aria-expanded')).toBe('false')
58
58
  })
59
59
 
60
+ it('disabledReason: held shut, reachable by Tab, and it says why', async () => {
61
+ const user = userEvent.setup()
62
+ const onChange = vi.fn()
63
+ render(<Select value="todo" onChange={onChange} options={STATUSES} ariaLabel="Status" disabledReason="Read only: you are a guest" />)
64
+ const trigger = screen.getByRole('combobox', { name: 'Status' })
65
+ expect(trigger.getAttribute('aria-disabled')).toBe('true')
66
+ expect(trigger.hasAttribute('disabled')).toBe(false)
67
+ await user.click(trigger)
68
+ await user.keyboard('{ArrowDown}{Enter}')
69
+ expect(screen.queryByRole('listbox')).toBeNull()
70
+ expect(onChange).not.toHaveBeenCalled()
71
+ await user.tab()
72
+ await user.tab({ shift: true })
73
+ expect(document.activeElement).toBe(trigger)
74
+ expect((await screen.findByRole('tooltip')).textContent).toBe('Read only: you are a guest')
75
+ })
76
+
60
77
  it('shows the placeholder when nothing matches', () => {
61
78
  render(<Controlled initial="" placeholder="Choose" />)
62
79
  expect(screen.getByRole('combobox', { name: 'Status' }).textContent).toContain('Choose')
package/src/Select.tsx CHANGED
@@ -4,6 +4,7 @@ import type { ReactNode } from 'react'
4
4
  import { cn } from './cn'
5
5
  import { ScrollArea } from './ScrollArea'
6
6
  import { MenuPanel, menuItemClassName } from './Menu'
7
+ import { TooltipTrigger } from './Tooltip'
7
8
 
8
9
  /**
9
10
  * Peek's Select (2026-08-28), verbatim, plus what Ship added: an option may
@@ -29,8 +30,9 @@ import { MenuPanel, menuItemClassName } from './Menu'
29
30
  * highlight is managed rather than counted, and a value that can be part of a
30
31
  * form.
31
32
  *
32
- * Two sizes; `disabled` explains nothing by itself wrap it in a tooltip
33
- * that does.
33
+ * Two sizes. `disabled` explains nothing by itself; `disabledReason` says
34
+ * why, as Button's does: the select looks disabled, will not open, stays
35
+ * reachable by Tab, and shows the reason on hover and on keyboard focus.
34
36
  */
35
37
  export interface SelectOption {
36
38
  value: string
@@ -49,6 +51,13 @@ export interface SelectProps {
49
51
  'aria-required'?: boolean | 'true' | 'false'
50
52
  placeholder?: string
51
53
  disabled?: boolean
54
+ /**
55
+ * Why it cannot be changed right now (UIG-14, Katerina, 19 September). It
56
+ * looks disabled and will not open, but Tab still reaches it, and the reason
57
+ * shows as its tooltip — a native disabled button cannot be focused, so it
58
+ * could never say why. Takes the place of wrapping it in `WithTooltip`.
59
+ */
60
+ disabledReason?: string
52
61
  className?: string
53
62
  }
54
63
 
@@ -57,20 +66,16 @@ export interface SelectProps {
57
66
  const GAP = 4
58
67
  const VIEWPORT_PAD = 8
59
68
 
60
- export function Select({ value, onChange, options, size = 'default', ariaLabel, placeholder = 'Select…', disabled, className, ...aria }: SelectProps) {
69
+ export function Select({ value, onChange, options, size = 'default', ariaLabel, placeholder = 'Select…', disabled, disabledReason, className, ...aria }: SelectProps) {
61
70
  const selected = options.find((o) => o.value === value)
62
- return (
63
- <BaseSelect.Root
64
- value={value}
65
- onValueChange={(next) => onChange(next as string)}
66
- disabled={disabled}
67
- // Non-modal, as it has always been: the page behind stays scrollable and
68
- // keeps its scrollbar, so opening a select never shifts the layout.
69
- modal={false}
70
- >
71
+ // With a reason it is held shut rather than disabled: a disabled trigger is
72
+ // a native `disabled` button, which Tab skips and a tooltip cannot open on.
73
+ const held = Boolean(disabledReason) && !disabled
74
+ const trigger = (
71
75
  <BaseSelect.Trigger
72
76
  aria-label={ariaLabel}
73
77
  aria-required={aria['aria-required']}
78
+ aria-disabled={held || undefined}
74
79
  className={cn(
75
80
  /*
76
81
  * `min-w-0 max-w-full`: a trigger must never outgrow its container
@@ -85,7 +90,9 @@ export function Select({ value, onChange, options, size = 'default', ariaLabel,
85
90
  // The focused border survives a hover: hover alone strengthens the
86
91
  // hairline, but hover while focused must not grey the focus colour —
87
92
  // the stacked variant outranks plain hover by specificity.
88
- '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',
93
+ !held && 'hover:border-border-strong focus-visible:hover:border-border-focus aria-expanded:hover:border-border-focus',
94
+ 'disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled',
95
+ held && 'cursor-not-allowed bg-bg-disabled text-text-disabled',
89
96
  'focus-visible:border-border-focus aria-expanded:border-border-focus',
90
97
  'signal:transition-shadow signal:focus-visible:shadow-focus-ring',
91
98
  size === 'default' && 'px-3 py-2 text-input-value',
@@ -101,6 +108,20 @@ export function Select({ value, onChange, options, size = 'default', ariaLabel,
101
108
  render={<IconChevronDown size={size === 'small' ? 14 : 16} stroke={1.5} className="shrink-0 text-text-secondary" />}
102
109
  />
103
110
  </BaseSelect.Trigger>
111
+ )
112
+ return (
113
+ <BaseSelect.Root
114
+ value={value}
115
+ onValueChange={(next) => {
116
+ if (!held) onChange(next as string)
117
+ }}
118
+ {...(held ? { open: false, onOpenChange: () => {} } : {})}
119
+ disabled={disabled}
120
+ // Non-modal, as it has always been: the page behind stays scrollable and
121
+ // keeps its scrollbar, so opening a select never shifts the layout.
122
+ modal={false}
123
+ >
124
+ {held ? <TooltipTrigger label={disabledReason ?? ''}>{trigger}</TooltipTrigger> : trigger}
104
125
 
105
126
  <BaseSelect.Portal>
106
127
  <BaseSelect.Positioner
package/src/Sidebar.mdx CHANGED
@@ -59,6 +59,14 @@ compressed instead — rows must keep their height.
59
59
  It is a `nav` region — pass `aria-label` when "Workspace" is not the
60
60
  right name for what it navigates.
61
61
 
62
+ ## What it owns
63
+
64
+ It does these by itself. Write none of them around it.
65
+
66
+ | It does | So you never write | Refused when written by hand |
67
+ |---|---|---|
68
+ | Scrolls its rows in a ScrollArea when they overflow | `overflow-auto` on the column | |
69
+
62
70
  ## Props
63
71
 
64
72
  <Controls of={SidebarStories.Composed} />
package/src/Skeleton.mdx CHANGED
@@ -37,3 +37,7 @@ import { SkeletonList, SkeletonBar } from '@estiva-app/ui'
37
37
  load never flashes a skeleton. Building your own placeholder from the
38
38
  bar? Put that class on its container for the same manner.
39
39
  - The list is `aria-hidden` — a placeholder has nothing to read.
40
+
41
+ ## What it owns
42
+
43
+ Nothing. It only draws. Clicks and keys are yours.
package/src/Tabs.mdx CHANGED
@@ -23,7 +23,7 @@ is a neutral fill, not the accent — selection is a place, not a highlight.
23
23
 
24
24
  ## When not
25
25
 
26
- - Going to a different place → links and **Breadcrumb**.
26
+ - Going to a different place → **Link**, **NavItem** or **Breadcrumb**.
27
27
  - Choosing a value that gets saved → **Select**.
28
28
  - Actions → **Button**s; a tab never *does*, it *shows*.
29
29
 
@@ -54,6 +54,17 @@ import { Tabs } from '@estiva-app/ui'
54
54
  that already names it takes `aria-labelledby` instead. Unnamed is fine for
55
55
  the only tab row on a page.
56
56
 
57
+ ## What it owns
58
+
59
+ It does these by itself. Write none of them around it.
60
+
61
+ | It does | So you never write | Refused when written by hand |
62
+ |---|---|---|
63
+ | Takes its keys by itself: Home and End select the first and last tab | a key listener on the page | ✓ |
64
+ | Moves through its items with the arrow keys: ← → select the next tab, and wrap at the ends | an arrow-key handler | ✓ |
65
+ | Says what it is to assistive technology: a set of tabs and their panels | `role="tablist"`, `tab` or `tabpanel` | ✓ |
66
+ | Is one Tab stop: Tab lands on the selected tab | a `tabIndex` on each tab | |
67
+
57
68
  ## Keys
58
69
 
59
70
  | Key | Does |
package/src/TextInput.mdx CHANGED
@@ -46,6 +46,14 @@ import { TextInput } from '@estiva-app/ui'
46
46
  - `size` is this component's (`default` or `small`), not the native attribute
47
47
  that counts characters. Set a width with the layout around it.
48
48
 
49
+ ## What it owns
50
+
51
+ It does these by itself. Write none of them around it.
52
+
53
+ | It does | So you never write | Refused when written by hand |
54
+ |---|---|---|
55
+ | Inside a `Field`, takes its name from the Field's label, and its error state | an `id` / `htmlFor` pair, or `aria-invalid` | |
56
+
49
57
  ## Props
50
58
 
51
59
  <Controls of={TextInputStories.Empty} />
package/src/Textarea.mdx CHANGED
@@ -35,6 +35,14 @@ import { Textarea } from '@estiva-app/ui'
35
35
  - **Inside a `Field`, the label names it. Add nothing.** Outside one, give
36
36
  it an `aria-label`.
37
37
 
38
+ ## What it owns
39
+
40
+ It does these by itself. Write none of them around it.
41
+
42
+ | It does | So you never write | Refused when written by hand |
43
+ |---|---|---|
44
+ | Inside a `Field`, takes its name from the Field's label, and its error state | an `id` / `htmlFor` pair, or `aria-invalid` | |
45
+
38
46
  ## Props
39
47
 
40
48
  <Controls of={TextareaStories.Empty} />
package/src/Toast.mdx CHANGED
@@ -33,7 +33,7 @@ the positioning (bottom-left), the portal, and the auto-dismiss.
33
33
  - Something the person must read or act on → put it in the surface; a
34
34
  toast dismisses itself.
35
35
  - A question before the action → **ConfirmDialog**.
36
- - Standing status → a Chip or your surface's own chrome.
36
+ - Standing status → **Chip**, or your surface's own chrome.
37
37
 
38
38
  ## How
39
39
 
@@ -70,6 +70,17 @@ showToast({ label: 'Link copied', type: 'success' })
70
70
  - `useToast` outside a provider throws — mount the provider at the root,
71
71
  once.
72
72
 
73
+ ## What it owns
74
+
75
+ It does these by itself. Write none of them around it.
76
+
77
+ | It does | So you never write | Refused when written by hand |
78
+ |---|---|---|
79
+ | Floats on top of the page | a portal, or a `z-index` to win | ✓ |
80
+ | Says what it is to assistive technology: a message, announced as it arrives | `role="status"` or `aria-live` | ✓ |
81
+ | F6 moves focus to the toasts from anywhere; Escape closes one | a key listener | |
82
+ | Leaves by itself after a while | a timer | |
83
+
73
84
  ## Keys
74
85
 
75
86
  | Key | What happens |
package/src/Toolbar.mdx CHANGED
@@ -82,6 +82,17 @@ why the placement is its job and not arithmetic of ours.
82
82
 
83
83
  <Canvas of={ToolbarStories.OnAnExistingSurface} />
84
84
 
85
+ ## What it owns
86
+
87
+ It does these by itself. Write none of them around it.
88
+
89
+ | It does | So you never write | Refused when written by hand |
90
+ |---|---|---|
91
+ | Takes its keys by itself: Enter and Space press the focused control | a key listener on the page | ✓ |
92
+ | Moves through its items with the arrow keys, and wraps at the ends | an arrow-key handler | ✓ |
93
+ | Says what it is to assistive technology: a toolbar | `role="toolbar"` | ✓ |
94
+ | Is one Tab stop, and remembers where the walk left off | `tabIndex` on each control | |
95
+
85
96
  ## Keys
86
97
 
87
98
  | Input | What happens |
package/src/Tooltip.mdx CHANGED
@@ -21,6 +21,7 @@ shows it beside a trigger — on hover, and on keyboard focus.
21
21
  - **The key that does the same thing** — pass `shortcut` and it is drawn as
22
22
  the `Kbd` chip after the label. For an icon-only control whose only other
23
23
  affordance is a keyboard shortcut, the tooltip is the only place to say so.
24
+ - A detail behind a short word in running text — the time behind "edited".
24
25
 
25
26
  <Canvas of={TooltipStories.ShortcutComparison} />
26
27
 
@@ -30,8 +31,10 @@ shows it beside a trigger — on hover, and on keyboard focus.
30
31
 
31
32
  - Anything the person must read to proceed → put it in the surface, not
32
33
  behind a hover.
33
- - Anything interactive inside → **Menu** or **DialogShell**; a tooltip is
34
- `pointer-events: none` by design and cannot be pointed at.
34
+ - Anything with controls inside → **Popover**, opened by a click. A list of
35
+ actions **Menu**. A tooltip disappears as the pointer moves onto it, so
36
+ nothing inside it can be clicked.
37
+ - Something worth reading, scrolling or copying on hover → **PreviewCard**.
35
38
 
36
39
  ## How
37
40
 
@@ -47,6 +50,11 @@ import { WithTooltip } from '@estiva-app/ui'
47
50
  shrinks nothing, so a wrapped block collapses to its content width.
48
51
  - A truncating label keeps truncating if you pass
49
52
  `wrapperClassName="min-w-0 shrink"`.
53
+ - **`inline`** for a trigger inside a line of text — a reference, a name in a
54
+ sentence: the wrapper is a `<span>`, because a `<div>` is not allowed inside
55
+ a paragraph.
56
+ - A label longer than 320px wraps onto more lines rather than running off the
57
+ screen; a short one stays one line at 30px.
50
58
  - **`Button` and `IconButton` need no wrapper**: pass `tooltip` (or
51
59
  `disabledReason`) and the button itself becomes the trigger. That is what
52
60
  lets a `Dialog.Close` or a `Menu.Trigger` be one of them — the component's
@@ -56,6 +64,10 @@ import { WithTooltip } from '@estiva-app/ui'
56
64
  looking at — so pass the finished label. `IconButton` forwards its own
57
65
  `tooltipShortcut` here.
58
66
 
67
+ <Canvas of={TooltipStories.InsideText} />
68
+
69
+ <Canvas of={TooltipStories.LongLabel} />
70
+
59
71
  ### The delay, and why a toolbar only pauses once
60
72
 
61
73
  A tooltip waits **300ms** before it opens. Mount **one `TooltipProvider` at
@@ -82,6 +94,18 @@ beneath one — and fades out over **80ms**. Moving between triggers inside a
82
94
  warm group skips the animation entirely, so a toolbar sweep does not flicker.
83
95
  `prefers-reduced-motion` removes it.
84
96
 
97
+ ## What it owns
98
+
99
+ It does these by itself. Write none of them around it.
100
+
101
+ | It does | So you never write | Refused when written by hand |
102
+ |---|---|---|
103
+ | Floats on top of the page | a portal, or a `z-index` to win | ✓ |
104
+ | Stays attached to its anchor on scroll and resize | a scroll or resize listener, or placement arithmetic | ✓ |
105
+ | Says what it is to assistive technology: a tooltip | `role="tooltip"` | ✓ |
106
+ | Opens after the pointer rests, and at once on keyboard focus | a timer, or hover and focus handlers | |
107
+ | Closes on Escape, on a click of its control, and when the pointer leaves | close handlers | |
108
+
85
109
  ## Keys
86
110
 
87
111
  | Input | What happens |
@@ -31,6 +31,32 @@ export const ShortcutComparison: Story = {
31
31
  ),
32
32
  }
33
33
 
34
+ /** A label longer than 320px wraps onto more lines, rather than running off the screen. A short one stays one line at 30px. */
35
+ export const LongLabel: Story = {
36
+ parameters: { controls: { disable: true } },
37
+ render: () => (
38
+ <div className="flex flex-col items-start gap-2">
39
+ <Tooltip label="Comment" />
40
+ <Tooltip label="naddr1qvzqqqr4gupzq9h35qgq6n8ll0xyyv8gurjzjrx9sjwp4hry6ejnlks8cqcmzp6tqyfhwumn8ghj7mmxve3ksctfdch8qatz9uq3wamnwvaz7tmjv4kxz7fwwpexjmtpdshxuet59uq3qamnwvaz7tm99ehx2aqqz9mhxue69uhkummnw3ez6un9d3shjtnwda4k7tnr" />
41
+ <Tooltip label="Item one, with a title long enough that it no longer fits on one line of a tooltip" />
42
+ </div>
43
+ ),
44
+ }
45
+
46
+ /** `inline`: a tooltip on a word inside a sentence. The wrapper is a span, so the paragraph stays valid HTML. Hover the word. */
47
+ export const InsideText: Story = {
48
+ parameters: { controls: { disable: true } },
49
+ render: () => (
50
+ <p className="max-w-sm text-body-2 text-text-primary">
51
+ The plan is in{' '}
52
+ <WithTooltip inline label="Item one, with a title long enough that it no longer fits on one line of a tooltip">
53
+ <span className="font-semibold">Item one</span>
54
+ </WithTooltip>
55
+ , and the rest follows.
56
+ </p>
57
+ ),
58
+ }
59
+
34
60
  /** Hover the button — WithTooltip portals the tooltip above the trigger. */
35
61
  export const OnHoverTop: Story = {
36
62
  parameters: { controls: { disable: true } },
@@ -27,6 +27,14 @@ describe('Tooltip, the surface', () => {
27
27
  expect(tip.textContent).toBe('BoldCmd+B')
28
28
  })
29
29
 
30
+ it('wraps a long label at 320px instead of running it off the screen, and stays 30px on one line', () => {
31
+ render(<Tooltip label="A label long enough to wrap onto a second line of the tooltip" />)
32
+ const pill = screen.getByRole('tooltip')
33
+ expect(pill.className).toContain('max-w-80')
34
+ expect(pill.className).toContain('min-h-[30px]')
35
+ expect(pill.querySelector('span')?.className).not.toContain('whitespace-nowrap')
36
+ })
37
+
30
38
  it('takes a caller class without losing its own', () => {
31
39
  render(<Tooltip label="Bold" className="w-40" />)
32
40
  const tip = screen.getByRole('tooltip')
@@ -49,6 +57,27 @@ describe('WithTooltip', () => {
49
57
  expect(wrapper.firstElementChild?.tagName).toBe('BUTTON')
50
58
  })
51
59
 
60
+ it('inline: a span wrapper, so it can sit inside a paragraph without React warning', () => {
61
+ const errors: unknown[] = []
62
+ const original = console.error
63
+ console.error = (...args: unknown[]) => { errors.push(args) }
64
+ try {
65
+ render(
66
+ <p>
67
+ The plan is in{' '}
68
+ <WithTooltip inline label="Item one">
69
+ <span>Item one</span>
70
+ </WithTooltip>
71
+ .
72
+ </p>,
73
+ )
74
+ } finally {
75
+ console.error = original
76
+ }
77
+ expect(screen.getByText('Item one').parentElement?.tagName).toBe('SPAN')
78
+ expect(errors).toEqual([])
79
+ })
80
+
52
81
  it('takes wrapperClassName, which is what keeps a truncating label truncating', () => {
53
82
  const { container } = render(
54
83
  <WithTooltip label="A long name" wrapperClassName="min-w-0 shrink">
package/src/Tooltip.tsx CHANGED
@@ -64,10 +64,16 @@ export interface TooltipProps extends Omit<ComponentPropsWithRef<'div'>, 'childr
64
64
  shortcut?: string
65
65
  }
66
66
 
67
+ /**
68
+ * One line at 30px, as it has always been; a label longer than 320px wraps
69
+ * onto more lines rather than running off the screen (Katerina, 19 September:
70
+ * a reference's full address drew a pill 950px wide). `py-1` only shows once
71
+ * it wraps: a single line is centred in the 30px.
72
+ */
67
73
  export function Tooltip({ label, shortcut, className, ...props }: TooltipProps) {
68
74
  return (
69
- <div role="tooltip" className={cn('bg-bg-elevated border border-border-default rounded-lg h-[30px] flex items-center justify-center gap-1.5 px-2 shadow-lg', className)} {...props}>
70
- <span className="text-caption text-text-primary whitespace-nowrap">{label}</span>
75
+ <div role="tooltip" className={cn('bg-bg-elevated border border-border-default rounded-lg min-h-[30px] max-w-80 flex items-center justify-center gap-1.5 px-2 py-1 shadow-lg', className)} {...props}>
76
+ <span className="min-w-0 text-caption text-text-primary break-words">{label}</span>
71
77
  {shortcut && <Kbd>{shortcut}</Kbd>}
72
78
  </div>
73
79
  )
@@ -80,6 +86,12 @@ export interface WithTooltipProps {
80
86
  placement?: 'top' | 'bottom'
81
87
  /** Extra classes on the wrapper — e.g. `min-w-0 shrink` so a truncating label keeps truncating inside it. */
82
88
  wrapperClassName?: string
89
+ /**
90
+ * For a trigger inside a line of text — a reference, a name in a sentence:
91
+ * the wrapper is a `<span>`, because a `<div>` is not allowed inside a
92
+ * paragraph (UIG-14, Katerina, 19 September).
93
+ */
94
+ inline?: boolean
83
95
  children: ReactNode
84
96
  }
85
97
 
@@ -150,7 +162,8 @@ function TooltipSurface({ label, shortcut, placement }: { label: string; shortcu
150
162
  * an icon. It wraps what it is given in an element that carries the handlers.
151
163
  * A control that can be the trigger does it itself: `IconButton` takes a
152
164
  * `tooltip` prop, and `Button` shows a `disabledReason` the same way. */
153
- export function WithTooltip({ label, shortcut, placement = 'top', wrapperClassName, children }: WithTooltipProps) {
165
+ export function WithTooltip({ label, shortcut, placement = 'top', wrapperClassName, inline = false, children }: WithTooltipProps) {
166
+ const Wrapper = inline ? 'span' : 'div'
154
167
  return (
155
168
  <BaseTooltip.Root disableHoverablePopup>
156
169
  {/* The wrapper stays the trigger: `WithTooltip` wraps whatever it is
@@ -158,7 +171,7 @@ export function WithTooltip({ label, shortcut, placement = 'top', wrapperClassNa
158
171
  can carry the handlers for all of them. A control that IS the trigger
159
172
  (Button, IconButton) composes the part onto itself instead, which is
160
173
  what lets a `Dialog.Close` or a `Menu.Trigger` be one of those. */}
161
- <BaseTooltip.Trigger delay={OPEN_DELAY} render={<div className={cn('inline-flex shrink-0', wrapperClassName)} />}>
174
+ <BaseTooltip.Trigger delay={OPEN_DELAY} render={<Wrapper className={cn('inline-flex shrink-0', wrapperClassName)} />}>
162
175
  {children}
163
176
  </BaseTooltip.Trigger>
164
177
  <TooltipSurface label={label} shortcut={shortcut} placement={placement} />
package/src/TopBar.mdx CHANGED
@@ -57,6 +57,10 @@ import { TopBar, IdentityMenu, SearchInput } from '@estiva-app/ui'
57
57
  - The frame is desktop-only for now — there is no narrow-screen
58
58
  behaviour; the menu button collapses panels, it does not open a drawer.
59
59
 
60
+ ## What it owns
61
+
62
+ Nothing. It only draws. Clicks and keys are yours.
63
+
60
64
  ## Props
61
65
 
62
66
  <Controls of={TopBarStories.Solid} />
@@ -202,7 +202,7 @@ export const OWNED_BEHAVIOURS: OwnedBehaviour[] = [
202
202
  },
203
203
  {
204
204
  id: 'page-keys',
205
- behaviour: 'Closes on Escape, and takes its keys, by itself',
205
+ behaviour: 'Takes its keys by itself',
206
206
  baseUi: ['Dialog', 'AlertDialog', 'Popover', 'Menu', 'Select', 'Combobox', 'Tooltip', 'PreviewCard', 'Toast'],
207
207
  owners: ['DialogShell', 'Popover', 'Menu', 'Select', 'Tabs', 'Toolbar'],
208
208
  reads: 'a keydown, keyup or keypress listener on window or document',
@@ -118,6 +118,7 @@ export const PART_LOOK_PROPS: Record<string, string[]> = {
118
118
  RailItem: ['active'],
119
119
  Reaction: ['pressed'],
120
120
  ReactionPicker: ['surface'],
121
+ SectionHeader: ['hover'],
121
122
  SectionLabel: ['tone'],
122
123
  Select: ['size'],
123
124
  Tabs: ['size'],
@@ -43,6 +43,7 @@ export const APP_TICKET_TITLES: Record<string, string> = {
43
43
  'UIG-7': 'Lint rule — every remaining raw element',
44
44
  'UIG-8': 'Lint rule — forbid the reach',
45
45
  'UIG-9': 'Lint rule — the className allow-list',
46
+ 'UIG-10': 'create-app — a command that makes a new Estiva app that runs',
46
47
  'UIG-13': "The registry widens to Peek's 115 and Ship's 74, with classification",
47
48
  'UIG-19': 'Lock the contract in CI, and make the three Storybooks one search',
48
49
  'UIG-20': 'The Claude skill, reading the registry',
@@ -164,8 +165,29 @@ export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG
164
165
  } },
165
166
  { what: 'a real page gets no error', run: realPage },
166
167
  ]),
168
+ // UIG-10, reopened 18 September: a tab holds one relay client, the way Peek and Ship hold
169
+ // theirs and a made app is written to. Measured on both mains that day: one each, and no
170
+ // socket opened any other way. A second holder, or a socket of the app's own, is a second
171
+ // connection the relay signs in and budgets separately.
172
+ ticket('UIG-10', [
173
+ { what: 'the app holds one relay client, at module level, and opens no socket of its own', run: () => {
174
+ const sources = h.listFiles(at('src'), (n) => /\.tsx?$/.test(n) && !/\.(test|stories)\.tsx?$/.test(n))
175
+ const own = sources.filter((f) => /\bcreateLiveClient\(|\bcreateLiveRelay\(|\bnew WebSocket\(/.test(h.read(f)))
176
+ if (own.length) return h.FAIL(`opens a socket of its own (${own.join(', ')}): hold one client from createLiveClientHolder()`)
177
+ const holders = sources.filter((f) => /^\s*(?:export\s+)?const\s+\w+\s*=\s*createLiveClientHolder\(\)/m.test(h.read(f)))
178
+ if (holders.length !== 1) return h.FAIL(holders.length ? `${holders.length} client holders (${holders.join(', ')}): a tab holds one` : 'no module-level createLiveClientHolder(): the app has no relay client')
179
+ return h.PASS(`one client for the tab, held in ${holders[0]}`)
180
+ } },
181
+ ]),
182
+ // Built by UIG-13, 18 September. An app's catalogue is built fresh from its code
183
+ // and never committed: the package is public and the apps are private (Katerina).
167
184
  ticket('UIG-13', [
168
- { what: 'registry.json is committed and has entries', run: () => h.json(h.exists('registry.json') ? 'registry.json' : at('registry.json'), (d) => ((d as { entries?: unknown[]; components?: unknown[] }).entries ?? (d as { components?: unknown[] }).components ?? []).length > 0, 'registry.json has entries') },
185
+ { what: "npm run ui:find is the package's search, over the package and this app", run: () => h.contains(at('package.json'), /"ui:find":\s*"estiva-ui find\b/, "ui:find runs the package's estiva-ui find") },
186
+ { what: "npm run registry:check is the package's check", run: () => h.contains(at('package.json'), /"registry:check":\s*"estiva-ui check\b/, 'registry:check runs estiva-ui check') },
187
+ { what: "CI's job gate refuses a part with no description", run: () => h.ciJob('gate', 'registry:check') },
188
+ // In the .gitignore of the app's own folder, where `estiva-ui build` would write it.
189
+ { what: 'the catalogue is never committed: git ignores registry.json', run: () => h.contains(at('.gitignore'), /^\/?registry\.json\r?$/m, `${at('.gitignore')} ignores registry.json`) },
190
+ { what: 'every part is described and sorted, and every file is accounted for', run: () => h.catalogue(at('.')) },
169
191
  ]),
170
192
  ticket('UIG-19', [
171
193
  { what: 'the usage-page contract runs in CI', run: () => h.ci(/[\w:-]*contract[\w:-]*/) },