@estiva-app/ui 0.23.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 (98) hide show
  1. package/dist/AppShell.d.ts.map +1 -1
  2. package/dist/AttachmentCard.d.ts.map +1 -1
  3. package/dist/Form.d.ts.map +1 -1
  4. package/dist/IconButton.d.ts +9 -1
  5. package/dist/IconButton.d.ts.map +1 -1
  6. package/dist/PreviewCard.d.ts +9 -2
  7. package/dist/PreviewCard.d.ts.map +1 -1
  8. package/dist/SectionHeader.d.ts +7 -1
  9. package/dist/SectionHeader.d.ts.map +1 -1
  10. package/dist/Select.d.ts +11 -3
  11. package/dist/Select.d.ts.map +1 -1
  12. package/dist/Tooltip.d.ts +13 -1
  13. package/dist/Tooltip.d.ts.map +1 -1
  14. package/dist/eslint/index.js +2 -1
  15. package/dist/eslint/index.js.map +2 -2
  16. package/dist/eslint/no-restyled-part.d.ts.map +1 -1
  17. package/dist/index.js +127 -108
  18. package/dist/index.js.map +2 -2
  19. package/package.json +1 -1
  20. package/registry.json +51 -8
  21. package/src/AppShell.mdx +10 -0
  22. package/src/AppShell.test.tsx +16 -0
  23. package/src/AppShell.tsx +4 -1
  24. package/src/AttachmentCard.mdx +9 -0
  25. package/src/AttachmentCard.test.tsx +10 -0
  26. package/src/AttachmentCard.tsx +8 -5
  27. package/src/Avatar.mdx +13 -3
  28. package/src/AvatarGroup.mdx +4 -0
  29. package/src/Banner.mdx +13 -3
  30. package/src/Breadcrumb.mdx +10 -0
  31. package/src/Button.mdx +14 -3
  32. package/src/Card.mdx +9 -0
  33. package/src/Checkbox.mdx +10 -0
  34. package/src/Chip.mdx +4 -0
  35. package/src/ChipInput.mdx +12 -0
  36. package/src/CollapsibleSection.mdx +9 -0
  37. package/src/CommandPalette.mdx +14 -2
  38. package/src/ConfirmDialog.mdx +11 -0
  39. package/src/ContainerHeader.mdx +4 -0
  40. package/src/DialogShell.mdx +14 -0
  41. package/src/Divider.mdx +9 -1
  42. package/src/EditableText.mdx +11 -0
  43. package/src/EmptyState.mdx +15 -4
  44. package/src/Field.mdx +10 -1
  45. package/src/FieldLine.mdx +9 -1
  46. package/src/FilePicker.mdx +8 -0
  47. package/src/Form.mdx +10 -0
  48. package/src/Form.test.tsx +26 -0
  49. package/src/Form.tsx +7 -0
  50. package/src/IconButton.mdx +16 -2
  51. package/src/IconButton.stories.tsx +3 -0
  52. package/src/IconButton.test.tsx +25 -0
  53. package/src/IconButton.tsx +61 -46
  54. package/src/IdentityMenu.mdx +9 -0
  55. package/src/InlineChip.mdx +8 -0
  56. package/src/Kbd.mdx +4 -0
  57. package/src/Link.mdx +10 -0
  58. package/src/Menu.mdx +16 -1
  59. package/src/MenuItem.mdx +12 -2
  60. package/src/NavItem.mdx +8 -0
  61. package/src/Person.mdx +4 -0
  62. package/src/PersonTrigger.mdx +9 -0
  63. package/src/Popover.mdx +21 -0
  64. package/src/PreviewCard.mdx +19 -4
  65. package/src/PreviewCard.tsx +11 -4
  66. package/src/ProgressBar.mdx +8 -0
  67. package/src/Property.mdx +4 -0
  68. package/src/Rail.mdx +9 -1
  69. package/src/RailItem.mdx +8 -0
  70. package/src/Reaction.mdx +9 -0
  71. package/src/ReactionPicker.mdx +8 -0
  72. package/src/ScrollArea.mdx +13 -2
  73. package/src/SearchInput.mdx +9 -0
  74. package/src/SectionHeader.mdx +13 -0
  75. package/src/SectionHeader.stories.tsx +9 -0
  76. package/src/SectionHeader.test.tsx +9 -0
  77. package/src/SectionHeader.tsx +8 -2
  78. package/src/SectionLabel.mdx +4 -0
  79. package/src/Select.mdx +21 -2
  80. package/src/Select.stories.tsx +4 -1
  81. package/src/Select.test.tsx +17 -0
  82. package/src/Select.tsx +34 -13
  83. package/src/Sidebar.mdx +8 -0
  84. package/src/Skeleton.mdx +4 -0
  85. package/src/Tabs.mdx +12 -1
  86. package/src/TextInput.mdx +8 -0
  87. package/src/Textarea.mdx +8 -0
  88. package/src/Toast.mdx +12 -1
  89. package/src/Toolbar.mdx +11 -0
  90. package/src/Tooltip.mdx +26 -2
  91. package/src/Tooltip.stories.tsx +26 -0
  92. package/src/Tooltip.test.tsx +29 -0
  93. package/src/Tooltip.tsx +17 -4
  94. package/src/TopBar.mdx +4 -0
  95. package/src/eslint/no-rebuilt-behaviour.ts +1 -1
  96. package/src/eslint/no-restyled-part.ts +1 -0
  97. package/src/pages.test.ts +142 -0
  98. package/tailwind-preset.js +22 -1
@@ -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
 
@@ -83,6 +85,15 @@ The message is the caller's — a shared component has no words of its own
83
85
  for what is missing. A hand-written "Nothing here" paragraph or span in a
84
86
  section is the `section` manner, and becomes it.
85
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
+
86
97
  ## Props
87
98
 
88
99
  <Controls of={EmptyStateStories.Page} />
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(
@@ -37,6 +37,14 @@ export interface IconButtonProps extends ComponentPropsWithRef<'button'> {
37
37
  /** Why the action cannot succeed right now. Disables the button, keeps it
38
38
  * reachable by keyboard, and shows the reason as the tooltip. */
39
39
  disabledReason?: string
40
+ /**
41
+ * A link that looks like an icon button: an anchor, so the address stays
42
+ * real for a modified click or a new tab, as `Link` keeps it. A router app
43
+ * passes `onClick`, prevents the default and navigates. A link cannot be
44
+ * disabled, so while it would be — `disabled`, `disabledReason`, a busy
45
+ * Form — it is the button (UIG-14, Katerina, 18 September).
46
+ */
47
+ href?: string
40
48
  /** The icon: 16px, stroke 1.5. */
41
49
  children: ReactNode
42
50
  }
@@ -53,56 +61,63 @@ export function IconButton({
53
61
  tooltipShortcut,
54
62
  tooltipPlacement,
55
63
  type = 'button',
64
+ href,
56
65
  ...props
57
66
  }: IconButtonProps) {
58
67
  // Inside a busy Form: switched off, and looking it (formBusy.ts).
59
68
  const formBusy = useFormBusy()
60
- const button = (
61
- <BaseButton
62
- type={type}
63
- disabled={disabled || !!disabledReason || formBusy}
64
- focusableWhenDisabled={!!disabledReason}
65
- className={(state) =>
66
- cn(
67
- // `shrink-0` stops a flex parent squashing the button; `self-center`
68
- // stops one stretching it. Two different failures, and both have
69
- // happened here: a row with no `items-*` drew this 24 wide and 228
70
- // tall in Peek's TopicMoreMenu story (Katerina, 2026-09-11). A button
71
- // is the size of its icon and its padding, whatever box it lands in.
72
- //
73
- // This is the one of the two that needs it: an IconButton states no
74
- // height, so its cross size is `auto` and a stretching parent takes
75
- // it. Measured in the same 260px row on 2026-09-12: 24px with this
76
- // class, 226px without. `Button` states `h-8`/`h-6`, so it cannot be
77
- // stretched and carries no `self-center` see the note there.
78
- 'flex items-center justify-center p-1 rounded-lg transition-colors shrink-0 self-center cursor-pointer',
79
- !state.disabled && variant === 'primary' && 'bg-accent-primary hover:bg-accent-hover text-text-inverse',
80
- !state.disabled && (variant === 'muted' || variant === 'resolve') && 'text-text-secondary hover:bg-bg-hover hover:text-text-primary',
81
- !state.disabled && variant === 'outlined' && 'border border-border-default hover:bg-bg-hover text-text-secondary',
82
- !state.disabled && variant === 'current' && 'hover:bg-bg-hover',
83
- state.disabled && variant === 'primary' && 'bg-bg-disabled text-text-disabled',
84
- state.disabled && (variant === 'muted' || variant === 'resolve') && 'text-text-disabled',
85
- state.disabled && variant === 'outlined' && 'border border-border-default text-text-disabled',
86
- // Always, disabled too: the colour of where it sits, and Resolve's green.
87
- variant === 'current' && 'text-current',
88
- variant === 'resolve' && 'signal:hover:bg-success-muted signal:hover:text-success-default',
89
- pressed && 'bg-bg-active text-text-primary',
90
- glow && 'signal:shadow-glow-accent',
91
- // `pointer-events-none` only where the button is truly out of reach:
92
- // with a `disabledReason` the button IS the tooltip's trigger, and a
93
- // trigger the pointer cannot land on never opens one. Base UI already
94
- // swallows the click.
95
- state.disabled && !disabledReason && 'pointer-events-none',
96
- state.disabled && 'cursor-not-allowed',
97
- className,
98
- )
99
- }
100
- aria-pressed={pressed}
101
- {...props}
102
- >
103
- {children}
104
- </BaseButton>
105
- )
69
+ const unusable = disabled || !!disabledReason || formBusy
70
+ const look = (off: boolean) =>
71
+ cn(
72
+ // `shrink-0` stops a flex parent squashing the button; `self-center`
73
+ // stops one stretching it. Two different failures, and both have
74
+ // happened here: a row with no `items-*` drew this 24 wide and 228
75
+ // tall in Peek's TopicMoreMenu story (Katerina, 2026-09-11). A button
76
+ // is the size of its icon and its padding, whatever box it lands in.
77
+ //
78
+ // This is the one of the two that needs it: an IconButton states no
79
+ // height, so its cross size is `auto` and a stretching parent takes
80
+ // it. Measured in the same 260px row on 2026-09-12: 24px with this
81
+ // class, 226px without. `Button` states `h-8`/`h-6`, so it cannot be
82
+ // stretched and carries no `self-center` see the note there.
83
+ 'flex items-center justify-center p-1 rounded-lg transition-colors shrink-0 self-center cursor-pointer',
84
+ !off && variant === 'primary' && 'bg-accent-primary hover:bg-accent-hover text-text-inverse',
85
+ !off && (variant === 'muted' || variant === 'resolve') && 'text-text-secondary hover:bg-bg-hover hover:text-text-primary',
86
+ !off && variant === 'outlined' && 'border border-border-default hover:bg-bg-hover text-text-secondary',
87
+ !off && variant === 'current' && 'hover:bg-bg-hover',
88
+ off && variant === 'primary' && 'bg-bg-disabled text-text-disabled',
89
+ off && (variant === 'muted' || variant === 'resolve') && 'text-text-disabled',
90
+ off && variant === 'outlined' && 'border border-border-default text-text-disabled',
91
+ // Always, disabled too: the colour of where it sits, and Resolve's green.
92
+ variant === 'current' && 'text-current',
93
+ variant === 'resolve' && 'signal:hover:bg-success-muted signal:hover:text-success-default',
94
+ pressed && 'bg-bg-active text-text-primary',
95
+ glow && 'signal:shadow-glow-accent',
96
+ // `pointer-events-none` only where the button is truly out of reach:
97
+ // with a `disabledReason` the button IS the tooltip's trigger, and a
98
+ // trigger the pointer cannot land on never opens one. Base UI already
99
+ // swallows the click.
100
+ off && !disabledReason && 'pointer-events-none',
101
+ off && 'cursor-not-allowed',
102
+ className,
103
+ )
104
+ const button =
105
+ href && !unusable ? (
106
+ <a href={href} className={look(false)} {...(props as unknown as ComponentPropsWithRef<'a'>)}>
107
+ {children}
108
+ </a>
109
+ ) : (
110
+ <BaseButton
111
+ type={type}
112
+ disabled={unusable}
113
+ focusableWhenDisabled={!!disabledReason}
114
+ className={(state) => look(state.disabled)}
115
+ aria-pressed={pressed}
116
+ {...props}
117
+ >
118
+ {children}
119
+ </BaseButton>
120
+ )
106
121
 
107
122
  const label = disabledReason ?? tooltip
108
123
  if (label) {
@@ -62,6 +62,15 @@ import { IdentityMenu } from '@estiva-app/ui'
62
62
  row's full height, and the panel hung from the bottom of *that* — measured
63
63
  360px below the face, with a scrollbar it should not have had.
64
64
 
65
+ ## What it owns
66
+
67
+ It does these by itself. Write none of them around it.
68
+
69
+ | It does | So you never write | Refused when written by hand |
70
+ |---|---|---|
71
+ | Everything a Menu owns: it floats, stays attached, closes on a press outside or on Escape, and walks its rows with the arrow keys | a menu of your own around the face | |
72
+ | Gives focus back to the face when it closes | focus code | |
73
+
65
74
  ## Keys
66
75
 
67
76
  Since stage 4 the panel is a Base UI `Menu`, and **the arrow keys walk the
@@ -51,6 +51,14 @@ import { InlineChip } from '@estiva-app/ui'
51
51
  - A rich-text editor that renders chips from strings cannot use a component.
52
52
  `inlineChipClassName(tone)` gives it the same classes.
53
53
 
54
+ ## What it owns
55
+
56
+ It does these by itself. Write none of them around it.
57
+
58
+ | It does | So you never write | Refused when written by hand |
59
+ |---|---|---|
60
+ | With `href`, keeps a real address, so a modified click or a new tab still works | a `<span>` with a click handler | |
61
+
54
62
  ## Props
55
63
 
56
64
  <Controls of={InlineChipStories.Neutral} />
package/src/Kbd.mdx CHANGED
@@ -72,6 +72,10 @@ It renders what it is given and formats nothing. A modifier is called Cmd on
72
72
  Apple platforms and Ctrl elsewhere, and only the caller knows which it is
73
73
  looking at — so decide there and pass the finished label.
74
74
 
75
+ ## What it owns
76
+
77
+ Nothing. It only draws. Clicks and keys are yours.
78
+
75
79
  ## Props
76
80
 
77
81
  <Controls of={KbdStories.Default} />
package/src/Link.mdx CHANGED
@@ -28,6 +28,7 @@ the app decides how it navigates.
28
28
  - An action — something happens, nothing is navigated to → **Button**.
29
29
  - A row of a sidebar → **NavItem**. A tile of a rail → **RailItem**.
30
30
  - The trail back up → **Breadcrumb**.
31
+ - A link that looks like an icon button → **IconButton** with `href`.
31
32
  - A word standing for a person or a thing, drawn in a small box → **InlineChip**
32
33
  with `href`.
33
34
 
@@ -60,6 +61,15 @@ import { Link } from '@estiva-app/ui'
60
61
  sit, so put those on your own element around the link, not on the link —
61
62
  the lint refuses a look passed in `className` (UIG-9).
62
63
 
64
+ ## What it owns
65
+
66
+ It does these by itself. Write none of them around it.
67
+
68
+ | It does | So you never write | Refused when written by hand |
69
+ |---|---|---|
70
+ | Says what it is to assistive technology: a link | `role="link"` | ✓ |
71
+ | Is reachable with Tab | a `tabIndex` | ✓ |
72
+
63
73
  ## Props
64
74
 
65
75
  <Controls of={LinkStories.Text} />
package/src/Menu.mdx CHANGED
@@ -41,7 +41,7 @@ definition either way.
41
41
  - Picking one value for a form field → **Select**.
42
42
  - Rows that toggle and stay open → that is a listbox, not a menu; a Checkbox
43
43
  inside a `menuitem` is invalid. Build it as a list in a **DialogShell**.
44
- - A strip of icon actions → a toolbar of **IconButton**s.
44
+ - A strip of icon actions → **Toolbar**.
45
45
  - Who-you-are and where-you-are → **IdentityMenu**, already built on this.
46
46
 
47
47
  ## How
@@ -76,6 +76,21 @@ the trigger as "close" rather than as a press outside.
76
76
 
77
77
  <Canvas of={MenuStories.OnACard} />
78
78
 
79
+ ## What it owns
80
+
81
+ It does these by itself. Write none of them around it.
82
+
83
+ | It does | So you never write | Refused when written by hand |
84
+ |---|---|---|
85
+ | Floats on top of the page | a portal, or a `z-index` to win | ✓ |
86
+ | Stays attached to its anchor on scroll and resize | a scroll or resize listener, or placement arithmetic | ✓ |
87
+ | Closes on a press outside | a click listener on the page | ✓ |
88
+ | Takes its keys by itself: Enter and Space choose a row, Escape closes it | a key listener on the page | ✓ |
89
+ | Moves through its items with the arrow keys, and jumps to a row by its first letters | an arrow-key handler | ✓ |
90
+ | Says what it is to assistive technology: a menu, and each row a menu item | `role="menu"` or `role="menuitem"` | ✓ |
91
+ | Keeps its own open state, and closes when a row is chosen | `useState` for open | |
92
+ | Gives focus back to the trigger when it closes | focus code | |
93
+
79
94
  ## Keys
80
95
 
81
96
  | Input | What happens |
package/src/MenuItem.mdx CHANGED
@@ -25,8 +25,9 @@ other two), `destructive` in the error colour, `selected` as the fill.
25
25
 
26
26
  ## When not
27
27
 
28
- - Outside a menu it renders `role="menuitem"` and belongs inside a
29
- `role="menu"` container.
28
+ - A single action that is not a row in a list **Button**. Outside a
29
+ **Menu**, MenuItem is a plain button with a row's look, so a row-shaped
30
+ action at the top of a list — "Add members" — is fine.
30
31
  - Picking a form value → **Select**'s own options.
31
32
  - A row that toggles a checkbox → not a menu at all; see the Menu page.
32
33
 
@@ -50,6 +51,15 @@ import { IconPencil } from '@tabler/icons-react'
50
51
  - A destructive row's `leading` icon takes the error colour itself
51
52
  (`text-error-default`) — the row colours only its label.
52
53
 
54
+ ## What it owns
55
+
56
+ It does these by itself. Write none of them around it.
57
+
58
+ | It does | So you never write | Refused when written by hand |
59
+ |---|---|---|
60
+ | Is a row of its Menu: the Menu highlights it, walks to it with the arrow keys, and closes when it is chosen | a highlight or a key handler on the row | |
61
+ | Opens its `submenu` with → and closes it with ← | a submenu of your own | |
62
+
53
63
  ## Props
54
64
 
55
65
  <Controls of={MenuItemStories.Default} />
package/src/NavItem.mdx CHANGED
@@ -48,6 +48,14 @@ import { NavItem } from '@estiva-app/ui'
48
48
  anchor prop passes through.
49
49
  - The label truncates; the count never gives up its width.
50
50
 
51
+ ## What it owns
52
+
53
+ It does these by itself. Write none of them around it.
54
+
55
+ | It does | So you never write | Refused when written by hand |
56
+ |---|---|---|
57
+ | Marks the current place for assistive technology when `active` | `aria-current` | |
58
+
51
59
  ## Props
52
60
 
53
61
  <Controls of={NavItemStories.Default} />
package/src/Person.mdx CHANGED
@@ -39,6 +39,10 @@ import { Person } from '@estiva-app/ui'
39
39
  and words are always set together (the Sizes story shows the pairings).
40
40
  - The name truncates; give the container `min-w-0` room to let it.
41
41
 
42
+ ## What it owns
43
+
44
+ Nothing. It only draws. Clicks and keys are yours.
45
+
42
46
  ## Props
43
47
 
44
48
  <Controls of={PersonStories.Named} />
@@ -53,6 +53,15 @@ import { Menu, PersonTrigger } from '@estiva-app/ui'
53
53
  - It is a native `<button>` on Base UI's Button: it takes a `ref` and every
54
54
  native button prop.
55
55
 
56
+ ## What it owns
57
+
58
+ It does these by itself. Write none of them around it.
59
+
60
+ | It does | So you never write | Refused when written by hand |
61
+ |---|---|---|
62
+ | Is a button: Tab reaches it, Enter and Space call your `onClick` | a clickable `<div>` | |
63
+ | Names itself from the person's name, and says whether what it opens is open (`open`) | `aria-label` or `aria-expanded` | |
64
+
56
65
  ## Keys
57
66
 
58
67
  | Key | Does |
package/src/Popover.mdx CHANGED
@@ -83,7 +83,15 @@ so it is the one case the caller drives:
83
83
  open={selectionRect !== null}
84
84
  onOpenChange={(open) => !open && setSelectionRect(null)}
85
85
  ariaLabel="Formatting"
86
+ side="top"
87
+ contentClassName="p-1"
86
88
  >
89
+ <Toolbar aria-label="Formatting">
90
+ <ToolbarButton aria-label="Bold" tooltip="Bold" onClick={bold}>
91
+ <IconBold size={16} stroke={1.5} />
92
+ </ToolbarButton>
93
+ </Toolbar>
94
+ </Popover>
87
95
  ```
88
96
 
89
97
  - **`anchor`** takes an element or a rect. A new rect moves the panel, so
@@ -116,6 +124,19 @@ something belongs on a `trigger`.
116
124
 
117
125
  <Canvas of={PopoverStories.FlippedForRoom} />
118
126
 
127
+ ## What it owns
128
+
129
+ It does these by itself. Write none of them around it.
130
+
131
+ | It does | So you never write | Refused when written by hand |
132
+ |---|---|---|
133
+ | Floats on top of the page | a portal, or a `z-index` to win | ✓ |
134
+ | Stays attached to its anchor on scroll and resize, and flips side when there is no room | a scroll or resize listener, or placement arithmetic | ✓ |
135
+ | Closes on a press outside | a click listener on the page | ✓ |
136
+ | Takes its keys by itself: Escape closes it | a key listener on the page | ✓ |
137
+ | Moves focus in when it opens, and back to the trigger when it closes | focus code | |
138
+ | Keeps its own open state | `useState` for open — unless you must know it (`open` / `onOpenChange`) | |
139
+
119
140
  ## Keys
120
141
 
121
142
  | Input | What happens |
@@ -45,7 +45,7 @@ pointer never sees it.
45
45
  import { PreviewCard } from '@estiva-app/ui'
46
46
 
47
47
  <PreviewCard content={<ItemDetail id={row.id} />} wrapperClassName="block w-full">
48
- <ListRow …/>
48
+ <ListRow item={row} />
49
49
  </PreviewCard>
50
50
  ```
51
51
 
@@ -57,9 +57,12 @@ import { PreviewCard } from '@estiva-app/ui'
57
57
  - **It prefers the right of the trigger and flips** when that side has no room,
58
58
  staying 12px from the trigger and 8px clear of every screen edge. `side`
59
59
  changes the preference, not the promise.
60
- - **Width, padding and height are yours** through `className`. The default is
61
- 360px wide, capped at 300px or the room available, whichever is smaller — a
62
- preview of one kind of thing is not the size of a preview of another.
60
+ - **Width is yours** through `className`; **padding and rhythm** through
61
+ `contentClassName`. The default is 360px wide, 12px of padding, capped at
62
+ 300px or the room available, whichever is smaller a preview of one kind of
63
+ thing is not the size of a preview of another.
64
+ - The padding sits inside the scrolling box, as in `Popover`, so the
65
+ scrollbar hugs the card's edge: its thumb 3px from it, like every panel's.
63
66
  - The trigger wrapper is `inline-flex`; a full-width row wants
64
67
  `wrapperClassName="block w-full"`.
65
68
 
@@ -82,6 +85,18 @@ lands.
82
85
 
83
86
  <Canvas of={PreviewCardStories.Loading} />
84
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
+ | Floats on top of the page | a portal, or a `z-index` to win | ✓ |
95
+ | Stays attached to its anchor on scroll and resize | a scroll or resize listener, or placement arithmetic | ✓ |
96
+ | Closes on a press outside | a click listener on the page | ✓ |
97
+ | Opens after the pointer rests, and stays up while the pointer moves into it | a hover timer | |
98
+ | Closes on Escape | a key handler | |
99
+
85
100
  ## Keys
86
101
 
87
102
  There is no keyboard trigger, by design — see above.