@estiva-app/ui 0.23.0 → 0.24.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/AppShell.d.ts.map +1 -1
- package/dist/AttachmentCard.d.ts.map +1 -1
- package/dist/Form.d.ts.map +1 -1
- package/dist/IconButton.d.ts +9 -1
- package/dist/IconButton.d.ts.map +1 -1
- package/dist/ListColumn.d.ts +41 -0
- package/dist/ListColumn.d.ts.map +1 -0
- package/dist/PreviewCard.d.ts +9 -2
- package/dist/PreviewCard.d.ts.map +1 -1
- package/dist/SectionHeader.d.ts +7 -1
- package/dist/SectionHeader.d.ts.map +1 -1
- package/dist/Select.d.ts +12 -3
- package/dist/Select.d.ts.map +1 -1
- package/dist/Toolbar.d.ts +18 -0
- package/dist/Toolbar.d.ts.map +1 -1
- package/dist/Tooltip.d.ts +13 -1
- package/dist/Tooltip.d.ts.map +1 -1
- package/dist/eslint/index.js +2 -1
- package/dist/eslint/index.js.map +2 -2
- package/dist/eslint/no-restyled-part.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +189 -143
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/registry.json +214 -11
- package/src/AppShell.mdx +18 -1
- package/src/AppShell.test.tsx +16 -0
- package/src/AppShell.tsx +4 -1
- package/src/AttachmentCard.mdx +12 -1
- package/src/AttachmentCard.test.tsx +10 -0
- package/src/AttachmentCard.tsx +8 -5
- package/src/Avatar.mdx +20 -5
- package/src/AvatarGroup.mdx +4 -0
- package/src/Banner.mdx +17 -8
- package/src/Breadcrumb.mdx +12 -0
- package/src/Button.mdx +17 -5
- package/src/Button.tsx +2 -2
- package/src/Card.mdx +14 -1
- package/src/Checkbox.mdx +11 -0
- package/src/Chip.mdx +4 -0
- package/src/ChipInput.mdx +13 -1
- package/src/CollapsibleSection.mdx +12 -3
- package/src/CommandPalette.mdx +14 -2
- package/src/ConfirmDialog.mdx +13 -2
- package/src/ContainerHeader.mdx +15 -2
- package/src/DialogShell.mdx +15 -1
- package/src/Divider.mdx +12 -1
- package/src/EditableText.mdx +17 -1
- package/src/EmptyState.mdx +17 -6
- package/src/Field.mdx +13 -5
- package/src/FieldLine.mdx +9 -1
- package/src/FilePicker.mdx +8 -0
- package/src/Form.mdx +11 -1
- package/src/Form.test.tsx +26 -0
- package/src/Form.tsx +7 -0
- package/src/IconButton.mdx +17 -3
- package/src/IconButton.stories.tsx +3 -0
- package/src/IconButton.test.tsx +25 -0
- package/src/IconButton.tsx +61 -46
- package/src/IdentityMenu.mdx +10 -1
- package/src/InlineChip.mdx +11 -0
- package/src/Kbd.mdx +6 -2
- package/src/Link.mdx +13 -1
- package/src/ListColumn.mdx +87 -0
- package/src/ListColumn.stories.tsx +136 -0
- package/src/ListColumn.test.tsx +50 -0
- package/src/ListColumn.tsx +63 -0
- package/src/Menu.mdx +18 -1
- package/src/MenuItem.mdx +15 -2
- package/src/NavItem.mdx +12 -0
- package/src/Person.mdx +7 -0
- package/src/PersonTrigger.mdx +11 -1
- package/src/PersonTrigger.tsx +1 -1
- package/src/Popover.mdx +23 -0
- package/src/PreviewCard.mdx +19 -4
- package/src/PreviewCard.tsx +11 -4
- package/src/ProgressBar.mdx +8 -0
- package/src/Property.mdx +4 -0
- package/src/Rail.mdx +12 -1
- package/src/RailItem.mdx +12 -0
- package/src/RailItem.tsx +1 -1
- package/src/Reaction.mdx +10 -0
- package/src/Reaction.tsx +1 -1
- package/src/ReactionPicker.mdx +8 -0
- package/src/ScrollArea.mdx +16 -2
- package/src/SearchInput.mdx +9 -0
- package/src/SectionHeader.mdx +18 -1
- package/src/SectionHeader.stories.tsx +9 -0
- package/src/SectionHeader.test.tsx +9 -0
- package/src/SectionHeader.tsx +9 -3
- package/src/SectionLabel.mdx +10 -2
- package/src/Select.mdx +24 -4
- package/src/Select.stories.tsx +4 -1
- package/src/Select.test.tsx +29 -0
- package/src/Select.tsx +36 -14
- package/src/Sidebar.mdx +8 -0
- package/src/Skeleton.mdx +8 -0
- package/src/Skeleton.tsx +1 -1
- package/src/Tabs.mdx +12 -1
- package/src/TextInput.mdx +9 -0
- package/src/TextInput.tsx +1 -1
- package/src/Textarea.mdx +8 -0
- package/src/Toast.mdx +12 -1
- package/src/Toolbar.mdx +20 -2
- package/src/Toolbar.stories.tsx +19 -2
- package/src/Toolbar.test.tsx +24 -1
- package/src/Toolbar.tsx +22 -0
- package/src/Tooltip.mdx +27 -2
- package/src/Tooltip.stories.tsx +26 -0
- package/src/Tooltip.test.tsx +51 -0
- package/src/Tooltip.tsx +17 -4
- package/src/TopBar.mdx +11 -0
- package/src/eslint/no-rebuilt-behaviour.ts +1 -1
- package/src/eslint/no-restyled-part.ts +1 -0
- package/src/heights.test.tsx +78 -0
- package/src/index.ts +2 -1
- package/src/pages.test.ts +142 -0
- package/src/registry/registry.test.ts +16 -8
- package/tailwind-preset.js +22 -1
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
|
|
33
|
-
*
|
|
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,14 @@ 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
|
+
* Given with `disabled`, the reason wins, as on `Button`.
|
|
60
|
+
*/
|
|
61
|
+
disabledReason?: string
|
|
52
62
|
className?: string
|
|
53
63
|
}
|
|
54
64
|
|
|
@@ -57,20 +67,16 @@ export interface SelectProps {
|
|
|
57
67
|
const GAP = 4
|
|
58
68
|
const VIEWPORT_PAD = 8
|
|
59
69
|
|
|
60
|
-
export function Select({ value, onChange, options, size = 'default', ariaLabel, placeholder = 'Select…', disabled, className, ...aria }: SelectProps) {
|
|
70
|
+
export function Select({ value, onChange, options, size = 'default', ariaLabel, placeholder = 'Select…', disabled, disabledReason, className, ...aria }: SelectProps) {
|
|
61
71
|
const selected = options.find((o) => o.value === value)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
>
|
|
72
|
+
// With a reason it is held shut rather than disabled: a disabled trigger is
|
|
73
|
+
// a native `disabled` button, which Tab skips and a tooltip cannot open on.
|
|
74
|
+
const held = Boolean(disabledReason)
|
|
75
|
+
const trigger = (
|
|
71
76
|
<BaseSelect.Trigger
|
|
72
77
|
aria-label={ariaLabel}
|
|
73
78
|
aria-required={aria['aria-required']}
|
|
79
|
+
aria-disabled={held || undefined}
|
|
74
80
|
className={cn(
|
|
75
81
|
/*
|
|
76
82
|
* `min-w-0 max-w-full`: a trigger must never outgrow its container
|
|
@@ -85,11 +91,13 @@ export function Select({ value, onChange, options, size = 'default', ariaLabel,
|
|
|
85
91
|
// The focused border survives a hover: hover alone strengthens the
|
|
86
92
|
// hairline, but hover while focused must not grey the focus colour —
|
|
87
93
|
// 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
|
|
94
|
+
!held && 'hover:border-border-strong focus-visible:hover:border-border-focus aria-expanded:hover:border-border-focus',
|
|
95
|
+
'disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled',
|
|
96
|
+
held && 'cursor-not-allowed bg-bg-disabled text-text-disabled',
|
|
89
97
|
'focus-visible:border-border-focus aria-expanded:border-border-focus',
|
|
90
98
|
'signal:transition-shadow signal:focus-visible:shadow-focus-ring',
|
|
91
99
|
size === 'default' && 'px-3 py-2 text-input-value',
|
|
92
|
-
size === 'small' && 'h-6 px-2 text-caption',
|
|
100
|
+
size === 'small' && 'h-6 min-h-6 px-2 text-caption',
|
|
93
101
|
className,
|
|
94
102
|
)}
|
|
95
103
|
>
|
|
@@ -101,6 +109,20 @@ export function Select({ value, onChange, options, size = 'default', ariaLabel,
|
|
|
101
109
|
render={<IconChevronDown size={size === 'small' ? 14 : 16} stroke={1.5} className="shrink-0 text-text-secondary" />}
|
|
102
110
|
/>
|
|
103
111
|
</BaseSelect.Trigger>
|
|
112
|
+
)
|
|
113
|
+
return (
|
|
114
|
+
<BaseSelect.Root
|
|
115
|
+
value={value}
|
|
116
|
+
onValueChange={(next) => {
|
|
117
|
+
if (!held) onChange(next as string)
|
|
118
|
+
}}
|
|
119
|
+
{...(held ? { open: false, onOpenChange: () => {} } : {})}
|
|
120
|
+
disabled={disabled && !held}
|
|
121
|
+
// Non-modal, as it has always been: the page behind stays scrollable and
|
|
122
|
+
// keeps its scrollbar, so opening a select never shifts the layout.
|
|
123
|
+
modal={false}
|
|
124
|
+
>
|
|
125
|
+
{held ? <TooltipTrigger label={disabledReason ?? ''}>{trigger}</TooltipTrigger> : trigger}
|
|
104
126
|
|
|
105
127
|
<BaseSelect.Portal>
|
|
106
128
|
<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,11 @@ 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
|
+
It does these by itself. Write none of them around it.
|
|
44
|
+
|
|
45
|
+
| It does | So you never write | Refused when written by hand |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| Keeps each row's 32px in a scrolling column | `shrink-0` | |
|
package/src/Skeleton.tsx
CHANGED
|
@@ -21,7 +21,7 @@ const ROW_BAR_WIDTHS = [150, 100, 170, 120, 90, 140, 110, 160]
|
|
|
21
21
|
/** A 32px row: a 16px square and a bar, like a row with a face and a name. */
|
|
22
22
|
export function SkeletonRow({ barWidth = 130 }: { barWidth?: number }) {
|
|
23
23
|
return (
|
|
24
|
-
<div className="flex items-center gap-2 px-2 h-[32px] rounded-lg">
|
|
24
|
+
<div className="flex shrink-0 items-center gap-2 px-2 h-[32px] rounded-lg">
|
|
25
25
|
<SkeletonBar className="w-4 h-4 shrink-0" />
|
|
26
26
|
<SkeletonBar className="h-3.5" style={{ width: barWidth }} />
|
|
27
27
|
</div>
|
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 →
|
|
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,15 @@ 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
|
+
| Keeps its height in a scrolling column | `shrink-0` | |
|
|
57
|
+
|
|
49
58
|
## Props
|
|
50
59
|
|
|
51
60
|
<Controls of={TextInputStories.Empty} />
|
package/src/TextInput.tsx
CHANGED
|
@@ -38,7 +38,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function T
|
|
|
38
38
|
'bg-bg-inset border border-border-default hover:border-border-strong focus:border-border-focus rounded-lg',
|
|
39
39
|
// The small size is the small Select's trigger, class for class.
|
|
40
40
|
size === 'default' && 'px-3 py-2 text-input-value',
|
|
41
|
-
size === 'small' && 'h-6 px-2 text-caption',
|
|
41
|
+
size === 'small' && 'h-6 min-h-6 px-2 text-caption',
|
|
42
42
|
'text-text-primary placeholder:text-text-muted',
|
|
43
43
|
'outline-none transition-colors',
|
|
44
44
|
'disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled',
|
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 →
|
|
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
|
@@ -69,8 +69,8 @@ import { Toolbar, ToolbarButton, ToolbarSeparator } from '@estiva-app/ui'
|
|
|
69
69
|
A toolbar floats over what it acts on — a card, a paragraph, an image — so it
|
|
70
70
|
draws the elevated surface that separates it from that: **the same `MenuPanel`
|
|
71
71
|
a `Menu` draws**, because a floating strip and a floating list are the same
|
|
72
|
-
box.
|
|
73
|
-
|
|
72
|
+
box. Built twice by hand, the two drift: one `rounded-sm` with `shadow-sm`,
|
|
73
|
+
the other `rounded-lg` with `shadow-lg`.
|
|
74
74
|
|
|
75
75
|
`surface={false}` for a strip inside something that draws that box already — a
|
|
76
76
|
`Popover`, a dialog, a card's own panel.
|
|
@@ -82,6 +82,24 @@ why the placement is its job and not arithmetic of ours.
|
|
|
82
82
|
|
|
83
83
|
<Canvas of={ToolbarStories.OnAnExistingSurface} />
|
|
84
84
|
|
|
85
|
+
**A control that goes somewhere is a `ToolbarLink`** with an `href`: the
|
|
86
|
+
strip's look, in the same walk, with a real address, so a Ctrl+click opens a
|
|
87
|
+
new tab. A `ToolbarButton` cannot be a link. A place you cannot go is left out
|
|
88
|
+
of the strip rather than disabled.
|
|
89
|
+
|
|
90
|
+
<Canvas of={ToolbarStories.WithALink} />
|
|
91
|
+
|
|
92
|
+
## What it owns
|
|
93
|
+
|
|
94
|
+
It does these by itself. Write none of them around it.
|
|
95
|
+
|
|
96
|
+
| It does | So you never write | Refused when written by hand |
|
|
97
|
+
|---|---|---|
|
|
98
|
+
| Takes its keys by itself: Enter and Space press the focused control | a key listener on the page | ✓ |
|
|
99
|
+
| Moves through its items with the arrow keys, and wraps at the ends | an arrow-key handler | ✓ |
|
|
100
|
+
| Says what it is to assistive technology: a toolbar | `role="toolbar"` | ✓ |
|
|
101
|
+
| Is one Tab stop, and remembers where the walk left off | `tabIndex` on each control | |
|
|
102
|
+
|
|
85
103
|
## Keys
|
|
86
104
|
|
|
87
105
|
| Input | What happens |
|
package/src/Toolbar.stories.tsx
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
-
import { IconArrowBackUp, IconArrowForwardUp, IconBold, IconItalic, IconLink, IconSquareRounded, IconUnderline } from '@tabler/icons-react'
|
|
2
|
+
import { IconArrowBackUp, IconArrowForwardUp, IconArrowUpRight, IconBold, IconItalic, IconLink, IconSquareRounded, IconUnderline } from '@tabler/icons-react'
|
|
3
3
|
import { useState } from 'react'
|
|
4
4
|
import { IconButton } from './IconButton'
|
|
5
5
|
import { Popover } from './Popover'
|
|
6
|
-
import { Toolbar, ToolbarButton, ToolbarInput, ToolbarSeparator } from './Toolbar'
|
|
6
|
+
import { Toolbar, ToolbarButton, ToolbarInput, ToolbarLink, ToolbarSeparator } from './Toolbar'
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* A strip of controls that behaves as **one** control: Tab in, arrow keys
|
|
@@ -100,6 +100,23 @@ export const WithADisabledControl: Story = {
|
|
|
100
100
|
),
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
/**
|
|
104
|
+
* A control that goes somewhere, beside controls that do something. The link
|
|
105
|
+
* has the buttons' look and joins the same walk; it has a real address, so a
|
|
106
|
+
* Ctrl+click opens a new tab.
|
|
107
|
+
*/
|
|
108
|
+
export const WithALink: Story = {
|
|
109
|
+
parameters: { controls: { disable: true } },
|
|
110
|
+
render: () => (
|
|
111
|
+
<Toolbar aria-label="Item actions" surface={false}>
|
|
112
|
+
<ToolbarButton aria-label="Item one" tooltip="Item one">{icon}</ToolbarButton>
|
|
113
|
+
<ToolbarLink href="#item" aria-label="Open the item" tooltip="Open">
|
|
114
|
+
<IconArrowUpRight size={16} stroke={1.5} />
|
|
115
|
+
</ToolbarLink>
|
|
116
|
+
</Toolbar>
|
|
117
|
+
),
|
|
118
|
+
}
|
|
119
|
+
|
|
103
120
|
/** Down rather than across: ↑ and ↓ walk it. */
|
|
104
121
|
export const Vertical: Story = {
|
|
105
122
|
parameters: { controls: { disable: true } },
|
package/src/Toolbar.test.tsx
CHANGED
|
@@ -7,7 +7,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
|
7
7
|
import { cleanup, render, screen } from '@testing-library/react'
|
|
8
8
|
import userEvent from '@testing-library/user-event'
|
|
9
9
|
import { IconButton } from './IconButton'
|
|
10
|
-
import { Toolbar, ToolbarButton, ToolbarInput, ToolbarSeparator } from './Toolbar'
|
|
10
|
+
import { Toolbar, ToolbarButton, ToolbarInput, ToolbarLink, ToolbarSeparator } from './Toolbar'
|
|
11
11
|
import { Menu, MenuItem } from './Menu'
|
|
12
12
|
import { Popover } from './Popover'
|
|
13
13
|
|
|
@@ -176,6 +176,29 @@ describe('Toolbar', () => {
|
|
|
176
176
|
})
|
|
177
177
|
})
|
|
178
178
|
|
|
179
|
+
describe('a link in the strip (F3)', () => {
|
|
180
|
+
it('is a real link, in the walk with the buttons beside it', async () => {
|
|
181
|
+
const user = userEvent.setup()
|
|
182
|
+
render(
|
|
183
|
+
<>
|
|
184
|
+
<button type="button">Before</button>
|
|
185
|
+
<Toolbar aria-label="Item actions">
|
|
186
|
+
<ToolbarButton aria-label="One">{dot}</ToolbarButton>
|
|
187
|
+
<ToolbarLink href="/somewhere" aria-label="Open" tooltip="Open">{dot}</ToolbarLink>
|
|
188
|
+
</Toolbar>
|
|
189
|
+
</>,
|
|
190
|
+
)
|
|
191
|
+
const link = screen.getByRole('link', { name: 'Open' })
|
|
192
|
+
expect(link.getAttribute('href')).toBe('/somewhere')
|
|
193
|
+
await user.click(screen.getByRole('button', { name: 'Before' }))
|
|
194
|
+
await user.tab()
|
|
195
|
+
expect(focused()).toBe('One')
|
|
196
|
+
await user.keyboard('{ArrowRight}')
|
|
197
|
+
expect(document.activeElement).toBe(link)
|
|
198
|
+
expect((await screen.findByRole('tooltip')).textContent).toBe('Open')
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
|
|
179
202
|
describe('a loose row, for comparison', () => {
|
|
180
203
|
/** The measurement the component exists to change: the same three buttons
|
|
181
204
|
* outside a toolbar are three Tab stops. */
|
package/src/Toolbar.tsx
CHANGED
|
@@ -129,6 +129,28 @@ export function ToolbarButton({ ref, disabled, disabledReason, ...props }: Toolb
|
|
|
129
129
|
)
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
/**
|
|
133
|
+
* A link in the strip: the strip's own look, and it goes somewhere. It has a
|
|
134
|
+
* real address, so a Ctrl+click or a middle click opens a new tab, which a
|
|
135
|
+
* button that navigates cannot do.
|
|
136
|
+
*
|
|
137
|
+
* It is `IconButton`'s `href` joined to the toolbar's walk: Base UI's
|
|
138
|
+
* `Toolbar.Link`. A `ToolbarButton` cannot be a link — Base UI expects a real
|
|
139
|
+
* button there and warns when it gets an anchor (UIG-14, finding F3).
|
|
140
|
+
*
|
|
141
|
+
* A link is never disabled: a place you cannot go is left out of the strip.
|
|
142
|
+
*/
|
|
143
|
+
export interface ToolbarLinkProps extends Omit<IconButtonProps, 'href' | 'disabled' | 'disabledReason' | 'pressed' | 'type' | 'ref'> {
|
|
144
|
+
href: string
|
|
145
|
+
ref?: Ref<HTMLAnchorElement>
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** An `IconButton` that is a link, and an item of a `Toolbar`: the arrow keys
|
|
149
|
+
* reach it like the buttons beside it. */
|
|
150
|
+
export function ToolbarLink({ ref, href, ...props }: ToolbarLinkProps) {
|
|
151
|
+
return <BaseToolbar.Link ref={ref} render={<IconButton href={href} {...props} />} />
|
|
152
|
+
}
|
|
153
|
+
|
|
132
154
|
/**
|
|
133
155
|
* A text field inside the strip — the link editor in a selection toolbar.
|
|
134
156
|
*
|
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
|
|
34
|
-
|
|
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,12 @@ 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. It sits at the top of the line by itself, so the line keeps its
|
|
56
|
+
height; add no wrapper of your own around it.
|
|
57
|
+
- A label longer than 320px wraps onto more lines rather than running off the
|
|
58
|
+
screen; a short one stays one line at 30px.
|
|
50
59
|
- **`Button` and `IconButton` need no wrapper**: pass `tooltip` (or
|
|
51
60
|
`disabledReason`) and the button itself becomes the trigger. That is what
|
|
52
61
|
lets a `Dialog.Close` or a `Menu.Trigger` be one of them — the component's
|
|
@@ -56,6 +65,10 @@ import { WithTooltip } from '@estiva-app/ui'
|
|
|
56
65
|
looking at — so pass the finished label. `IconButton` forwards its own
|
|
57
66
|
`tooltipShortcut` here.
|
|
58
67
|
|
|
68
|
+
<Canvas of={TooltipStories.InsideText} />
|
|
69
|
+
|
|
70
|
+
<Canvas of={TooltipStories.LongLabel} />
|
|
71
|
+
|
|
59
72
|
### The delay, and why a toolbar only pauses once
|
|
60
73
|
|
|
61
74
|
A tooltip waits **300ms** before it opens. Mount **one `TooltipProvider` at
|
|
@@ -82,6 +95,18 @@ beneath one — and fades out over **80ms**. Moving between triggers inside a
|
|
|
82
95
|
warm group skips the animation entirely, so a toolbar sweep does not flicker.
|
|
83
96
|
`prefers-reduced-motion` removes it.
|
|
84
97
|
|
|
98
|
+
## What it owns
|
|
99
|
+
|
|
100
|
+
It does these by itself. Write none of them around it.
|
|
101
|
+
|
|
102
|
+
| It does | So you never write | Refused when written by hand |
|
|
103
|
+
|---|---|---|
|
|
104
|
+
| Floats on top of the page | a portal, or a `z-index` to win | ✓ |
|
|
105
|
+
| Stays attached to its anchor on scroll and resize | a scroll or resize listener, or placement arithmetic | ✓ |
|
|
106
|
+
| Says what it is to assistive technology: a tooltip | `role="tooltip"` | ✓ |
|
|
107
|
+
| Opens after the pointer rests, and at once on keyboard focus | a timer, or hover and focus handlers | |
|
|
108
|
+
| Closes on Escape, on a click of its control, and when the pointer leaves | close handlers | |
|
|
109
|
+
|
|
85
110
|
## Keys
|
|
86
111
|
|
|
87
112
|
| Input | What happens |
|
package/src/Tooltip.stories.tsx
CHANGED
|
@@ -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 } },
|
package/src/Tooltip.test.tsx
CHANGED
|
@@ -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,49 @@ 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
|
+
|
|
81
|
+
// F1: without `align-top` a chip in the wrapper drops 0.2px and its line grows
|
|
82
|
+
// from 19.6 to 20px; the apps may not add the class to the part, so it carries it.
|
|
83
|
+
it('inline: the wrapper sits at the top of the line by itself', () => {
|
|
84
|
+
render(
|
|
85
|
+
<p>
|
|
86
|
+
<WithTooltip inline label="Item one">
|
|
87
|
+
<span>Item one</span>
|
|
88
|
+
</WithTooltip>
|
|
89
|
+
</p>,
|
|
90
|
+
)
|
|
91
|
+
expect(screen.getByText('Item one').parentElement?.className.split(' ')).toContain('align-top')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('a block wrapper is not moved', () => {
|
|
95
|
+
render(
|
|
96
|
+
<WithTooltip label="Item one">
|
|
97
|
+
<span>Item one</span>
|
|
98
|
+
</WithTooltip>,
|
|
99
|
+
)
|
|
100
|
+
expect(screen.getByText('Item one').parentElement?.className.split(' ')).not.toContain('align-top')
|
|
101
|
+
})
|
|
102
|
+
|
|
52
103
|
it('takes wrapperClassName, which is what keeps a truncating label truncating', () => {
|
|
53
104
|
const { container } = render(
|
|
54
105
|
<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
|
|
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={<
|
|
174
|
+
<BaseTooltip.Trigger delay={OPEN_DELAY} render={<Wrapper className={cn('inline-flex shrink-0', inline && 'align-top', 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,17 @@ 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
|
+
- The bar is 52px tall, 20px in on the left and 26px on the right, 16px between
|
|
61
|
+
its slots.
|
|
62
|
+
|
|
63
|
+
## What it owns
|
|
64
|
+
|
|
65
|
+
It does these by itself. Write none of them around it.
|
|
66
|
+
|
|
67
|
+
| It does | So you never write | Refused when written by hand |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
| Keeps its 52px in a scrolling column | `shrink-0` | |
|
|
70
|
+
|
|
60
71
|
## Props
|
|
61
72
|
|
|
62
73
|
<Controls of={TopBarStories.Solid} />
|
|
@@ -202,7 +202,7 @@ export const OWNED_BEHAVIOURS: OwnedBehaviour[] = [
|
|
|
202
202
|
},
|
|
203
203
|
{
|
|
204
204
|
id: 'page-keys',
|
|
205
|
-
behaviour: '
|
|
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',
|