@estiva-app/ui 0.5.0 → 0.6.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.
- package/dist/Divider.d.ts.map +1 -1
- package/dist/IconButton.d.ts +4 -1
- package/dist/IconButton.d.ts.map +1 -1
- package/dist/Kbd.d.ts +27 -0
- package/dist/Kbd.d.ts.map +1 -0
- package/dist/Menu.d.ts +56 -5
- package/dist/Menu.d.ts.map +1 -1
- package/dist/SearchInput.d.ts +1 -1
- package/dist/SearchInput.d.ts.map +1 -1
- package/dist/Tooltip.d.ts +8 -2
- package/dist/Tooltip.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +194 -140
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/src/AppShell.stories.tsx +2 -2
- package/src/Divider.tsx +5 -1
- package/src/IconButton.tsx +5 -1
- package/src/Kbd.mdx +77 -0
- package/src/Kbd.stories.tsx +58 -0
- package/src/Kbd.tsx +42 -0
- package/src/Menu.mdx +8 -1
- package/src/Menu.stories.tsx +1 -1
- package/src/Menu.tsx +112 -18
- package/src/MenuItem.mdx +1 -1
- package/src/MenuItem.stories.tsx +5 -5
- package/src/SearchInput.mdx +2 -2
- package/src/SearchInput.stories.tsx +2 -2
- package/src/SearchInput.tsx +3 -6
- package/src/Tooltip.mdx +9 -0
- package/src/Tooltip.stories.tsx +15 -0
- package/src/Tooltip.tsx +12 -4
- package/src/TopBar.mdx +1 -1
- package/src/TopBar.stories.tsx +2 -2
- package/src/index.ts +2 -1
- package/tailwind-preset.js +9 -1
package/package.json
CHANGED
package/src/AppShell.stories.tsx
CHANGED
|
@@ -59,7 +59,7 @@ const rail = (
|
|
|
59
59
|
/** The structured frame: solid bar, sidebar, content beside it. */
|
|
60
60
|
export const Solid: Story = {
|
|
61
61
|
render: (args) => (
|
|
62
|
-
<AppShell {...args} logo="Estiva" search={<SearchInput shortcut="
|
|
62
|
+
<AppShell {...args} logo="Estiva" search={<SearchInput shortcut="Ctrl+K" className="w-[290px]" />} identity={identity} nav={sidebar}>
|
|
63
63
|
<div className="flex h-full items-center justify-center">
|
|
64
64
|
<EmptyState message="Nothing here yet." />
|
|
65
65
|
</div>
|
|
@@ -86,7 +86,7 @@ export const Floating: Story = {
|
|
|
86
86
|
variant="floating"
|
|
87
87
|
menu={menuButton}
|
|
88
88
|
logo="Estiva"
|
|
89
|
-
search={<SearchInput shortcut="
|
|
89
|
+
search={<SearchInput shortcut="Ctrl+K" className="w-[290px]" />}
|
|
90
90
|
identity={identity}
|
|
91
91
|
nav={rail}
|
|
92
92
|
>
|
package/src/Divider.tsx
CHANGED
|
@@ -16,7 +16,11 @@ export function Divider({ orientation = 'horizontal', className }: DividerProps)
|
|
|
16
16
|
<div
|
|
17
17
|
role="separator"
|
|
18
18
|
aria-orientation={orientation}
|
|
19
|
-
|
|
19
|
+
// shrink-0 on both orientations: a 1px flex child in an overflowing
|
|
20
|
+
// column shrinks to nothing, and a hairline that renders 0px tall is a
|
|
21
|
+
// hairline nobody can see — Peek's `/` menu had been drawing two of
|
|
22
|
+
// them, measured 0px, since it was built (2026-09-05).
|
|
23
|
+
className={cn('shrink-0 bg-border-subtle', orientation === 'horizontal' ? 'h-px mx-3' : 'w-px self-stretch', className)}
|
|
20
24
|
/>
|
|
21
25
|
)
|
|
22
26
|
}
|
package/src/IconButton.tsx
CHANGED
|
@@ -13,6 +13,9 @@ export type IconButtonVariant = 'muted' | 'outlined' | 'primary'
|
|
|
13
13
|
export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
14
14
|
variant?: IconButtonVariant
|
|
15
15
|
tooltip?: string
|
|
16
|
+
/** A key hint drawn as the `Kbd` chip inside the tooltip — for a button
|
|
17
|
+
* whose only other affordance is a keyboard shortcut. */
|
|
18
|
+
tooltipShortcut?: string
|
|
16
19
|
tooltipPlacement?: 'top' | 'bottom'
|
|
17
20
|
/** The icon: 16px, stroke 1.5. */
|
|
18
21
|
children: ReactNode
|
|
@@ -24,6 +27,7 @@ export function IconButton({
|
|
|
24
27
|
children,
|
|
25
28
|
disabled,
|
|
26
29
|
tooltip,
|
|
30
|
+
tooltipShortcut,
|
|
27
31
|
tooltipPlacement,
|
|
28
32
|
type = 'button',
|
|
29
33
|
...props
|
|
@@ -51,7 +55,7 @@ export function IconButton({
|
|
|
51
55
|
|
|
52
56
|
if (tooltip) {
|
|
53
57
|
return (
|
|
54
|
-
<WithTooltip label={tooltip} placement={tooltipPlacement}>
|
|
58
|
+
<WithTooltip label={tooltip} shortcut={tooltipShortcut} placement={tooltipPlacement}>
|
|
55
59
|
{button}
|
|
56
60
|
</WithTooltip>
|
|
57
61
|
)
|
package/src/Kbd.mdx
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as KbdStories from './Kbd.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={KbdStories} />
|
|
5
|
+
|
|
6
|
+
# Kbd
|
|
7
|
+
|
|
8
|
+
The small chip that names the key which does the same thing. A bordered
|
|
9
|
+
`kbd` on the inset fill, `caption` type in secondary ink, sized to its
|
|
10
|
+
content and never shrinking.
|
|
11
|
+
|
|
12
|
+
<Canvas of={KbdStories.Row} />
|
|
13
|
+
|
|
14
|
+
## When
|
|
15
|
+
|
|
16
|
+
- **At the right edge of a menu row** — `MenuItem`'s `shortcut` draws one for
|
|
17
|
+
you; pass the string, not the element.
|
|
18
|
+
- **Inside a tooltip** — `Tooltip` and `WithTooltip` take a `shortcut` for the
|
|
19
|
+
same reason: an icon button whose only other affordance is a key.
|
|
20
|
+
`IconButton` forwards its own `tooltipShortcut` there.
|
|
21
|
+
- **At the right edge of a search field** — `SearchInput`'s `shortcut`, for
|
|
22
|
+
the key that puts the cursor in it.
|
|
23
|
+
- **In a shortcuts sheet**, where the key is the content rather than a hint.
|
|
24
|
+
|
|
25
|
+
## Naming the key
|
|
26
|
+
|
|
27
|
+
The label is a word, never a symbol — `Cmd+K`, `Enter`, `Esc`, `Backspace`.
|
|
28
|
+
Two reasons, and they cost nothing to obey:
|
|
29
|
+
|
|
30
|
+
1. A symbol is often wrong. `⌘` on a Windows machine names a key that is not
|
|
31
|
+
there.
|
|
32
|
+
2. **The symbols are not in the font.** Geist Mono has no `⌘`, `⌫` or `↩`, so
|
|
33
|
+
the browser silently borrows each one from another face — measured at 10px,
|
|
34
|
+
every character the font owns advances exactly 6px, while `↩` comes back
|
|
35
|
+
8.63px and `⌫` 12.81px. One borrowed glyph in a row of chips makes that
|
|
36
|
+
chip wider and its ink a different shape.
|
|
37
|
+
|
|
38
|
+
**`↑` and `↓` are the exception** — they are in Geist Mono at the same 6px, and
|
|
39
|
+
an arrow is what is printed on the key. Their horizontal partners are not:
|
|
40
|
+
Geist Mono owns `←` and `→` but draws them 10px wide, so a chip holding one
|
|
41
|
+
comes out wider than the chips beside it. Measure before adding a symbol to
|
|
42
|
+
this list; the font's coverage is not uniform.
|
|
43
|
+
|
|
44
|
+
And a trigger is the character you would actually type. Peek's resolution
|
|
45
|
+
block fires on `->`, so its row says `->` — there is no `→` key to press.
|
|
46
|
+
|
|
47
|
+
## When not
|
|
48
|
+
|
|
49
|
+
- A status, count or category → **Chip**. This one means *press this*.
|
|
50
|
+
- A run of code inside prose → the consumer's own `code` styling; a keyboard
|
|
51
|
+
hint and a code span are different ideas that happen to both be monospace-ish.
|
|
52
|
+
- Anything clickable → **Button**. A key hint is a label, not a control.
|
|
53
|
+
|
|
54
|
+
## How
|
|
55
|
+
|
|
56
|
+
```tsx
|
|
57
|
+
import { Kbd } from '@estiva-app/ui'
|
|
58
|
+
|
|
59
|
+
<Kbd>Cmd+K</Kbd>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Most callers never reach for it directly — they pass a string:
|
|
63
|
+
|
|
64
|
+
```tsx
|
|
65
|
+
<MenuItem label="Heading" shortcut="#" />
|
|
66
|
+
<Tooltip label="Bold" shortcut="Cmd+B" />
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
<Canvas of={KbdStories.InContext} />
|
|
70
|
+
|
|
71
|
+
It renders what it is given and formats nothing. A modifier is called Cmd on
|
|
72
|
+
Apple platforms and Ctrl elsewhere, and only the caller knows which it is
|
|
73
|
+
looking at — so decide there and pass the finished label.
|
|
74
|
+
|
|
75
|
+
## Props
|
|
76
|
+
|
|
77
|
+
<Controls of={KbdStories.Default} />
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { Kbd } from './Kbd'
|
|
3
|
+
import { Menu, MenuItem, MenuSection } from './Menu'
|
|
4
|
+
import { Tooltip } from './Tooltip'
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: 'Primitives/Kbd',
|
|
8
|
+
component: Kbd,
|
|
9
|
+
args: { children: 'Cmd+K' },
|
|
10
|
+
argTypes: { children: { control: 'text' } },
|
|
11
|
+
} satisfies Meta<typeof Kbd>
|
|
12
|
+
|
|
13
|
+
export default meta
|
|
14
|
+
type Story = StoryObj<typeof meta>
|
|
15
|
+
|
|
16
|
+
export const Default: Story = {}
|
|
17
|
+
|
|
18
|
+
/** A single trigger character, the way a command menu names its own key. */
|
|
19
|
+
export const TriggerCharacter: Story = { args: { children: '/' } }
|
|
20
|
+
|
|
21
|
+
/** A chord. Whoever passes it decides the platform's spelling. */
|
|
22
|
+
export const Chord: Story = { args: { children: 'Ctrl+Alt+1' } }
|
|
23
|
+
|
|
24
|
+
/** A named key. */
|
|
25
|
+
export const NamedKey: Story = { args: { children: 'Esc' } }
|
|
26
|
+
|
|
27
|
+
/** The four side by side. */
|
|
28
|
+
export const Row: Story = {
|
|
29
|
+
parameters: { controls: { disable: true } },
|
|
30
|
+
render: () => (
|
|
31
|
+
<div className="flex items-center gap-2">
|
|
32
|
+
{['/', 'Cmd+K', 'Ctrl+Alt+1', 'Esc'].map((k) => (
|
|
33
|
+
<Kbd key={k}>{k}</Kbd>
|
|
34
|
+
))}
|
|
35
|
+
</div>
|
|
36
|
+
),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Where it appears on its own: a menu row's `shortcut`, and a tooltip's. */
|
|
40
|
+
export const InContext: Story = {
|
|
41
|
+
parameters: { controls: { disable: true } },
|
|
42
|
+
render: () => (
|
|
43
|
+
<div className="flex items-start gap-6">
|
|
44
|
+
<Menu onClose={() => {}} className="static w-[220px]">
|
|
45
|
+
<MenuSection label="Format">
|
|
46
|
+
<MenuItem label="Heading" shortcut="#" />
|
|
47
|
+
<MenuItem label="Quote" shortcut=">" />
|
|
48
|
+
<MenuItem label="Numbered list" shortcut="1." />
|
|
49
|
+
</MenuSection>
|
|
50
|
+
</Menu>
|
|
51
|
+
<div className="flex flex-col gap-2">
|
|
52
|
+
<Tooltip label="Bold" shortcut="Cmd+B" />
|
|
53
|
+
<Tooltip label="Italic" shortcut="Ctrl+I" />
|
|
54
|
+
<Tooltip label="Comment" />
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
),
|
|
58
|
+
}
|
package/src/Kbd.tsx
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { cn } from './cn'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A keyboard hint — the small chip that names the key which does the same thing.
|
|
6
|
+
*
|
|
7
|
+
* **The look is SearchInput's shortcut chip** (Katerina, 2026-09-05), which is
|
|
8
|
+
* the one the apps already show: an inset pill with a hairline border, and under
|
|
9
|
+
* Signal and Ship the keycap treatment — mono, 10px, a lighter fill and a thicker
|
|
10
|
+
* bottom edge, so it reads as a key rather than a label.
|
|
11
|
+
*
|
|
12
|
+
* It existed before this file did, drawn inline inside `MenuItem` and again
|
|
13
|
+
* inside `SearchInput`. Extracted when `Tooltip` needed a third copy.
|
|
14
|
+
*
|
|
15
|
+
* **Sizes are arbitrary values on purpose.** `text-caption` beside a
|
|
16
|
+
* `text-{color}` is dropped by tailwind-merge, so the chip silently inherited
|
|
17
|
+
* whatever size surrounded it — 10px in a composer, something else elsewhere.
|
|
18
|
+
* `text-[12px]` survives the merge.
|
|
19
|
+
*
|
|
20
|
+
* Content is whatever names the key — a trigger character (`/`, `@`), a chord
|
|
21
|
+
* (`Cmd+B`, `Ctrl+Alt+1`), or a word (`Esc`). It does not format anything: a caller
|
|
22
|
+
* that knows the platform passes the label it wants.
|
|
23
|
+
*/
|
|
24
|
+
export interface KbdProps {
|
|
25
|
+
children: ReactNode
|
|
26
|
+
className?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function Kbd({ children, className }: KbdProps) {
|
|
30
|
+
return (
|
|
31
|
+
<kbd
|
|
32
|
+
className={cn(
|
|
33
|
+
'inline-flex shrink-0 items-center justify-center whitespace-nowrap rounded-sm border border-border-strong bg-bg-inset px-1 py-px font-sans text-[12px] leading-[120%] font-normal text-text-secondary',
|
|
34
|
+
'signal:border-b-2 signal:pt-[2px] signal:pb-px signal:bg-[rgba(255,255,255,.05)] signal:font-mono signal:text-[10px]',
|
|
35
|
+
'ship:border-b-2 ship:pt-[2px] ship:pb-px ship:bg-[rgba(255,255,255,.05)] ship:font-mono ship:text-[10px]',
|
|
36
|
+
className,
|
|
37
|
+
)}
|
|
38
|
+
>
|
|
39
|
+
{children}
|
|
40
|
+
</kbd>
|
|
41
|
+
)
|
|
42
|
+
}
|
package/src/Menu.mdx
CHANGED
|
@@ -9,9 +9,16 @@ The shell every menu shares: an elevated container — hairline border, 8px
|
|
|
9
9
|
radius, 8px padding, the large shadow — and the two exits every menu owes,
|
|
10
10
|
**Escape and a click outside, owned here and never by the caller**. Rows are
|
|
11
11
|
**MenuItem**, headings are **MenuSection**, a non-interactive line at row
|
|
12
|
-
geometry is **MenuRow**, and **EnterHint** is the
|
|
12
|
+
geometry is **MenuRow**, and **EnterHint** is the `↩ Enter` chip a picker row
|
|
13
13
|
shows while highlighted.
|
|
14
14
|
|
|
15
|
+
**MenuPanel** is the container on its own, without the behaviour — for the
|
|
16
|
+
one case that cannot have it: a type-ahead popup inside a text editor, where
|
|
17
|
+
the editor's own plugin already owns the keyboard and the placement, and a
|
|
18
|
+
second Escape handler fights it. `Menu` renders `MenuPanel`, so the surface
|
|
19
|
+
has one definition either way. If your surface opens from a trigger, you want
|
|
20
|
+
`Menu`; reach for `MenuPanel` only when something else is already driving.
|
|
21
|
+
|
|
15
22
|
<Canvas of={MenuStories.Items} />
|
|
16
23
|
|
|
17
24
|
## When
|
package/src/Menu.stories.tsx
CHANGED
|
@@ -27,7 +27,7 @@ export const Items: Story = {
|
|
|
27
27
|
render: (args) => (
|
|
28
28
|
<Menu {...args}>
|
|
29
29
|
<MenuItem label="Rename" leading={<IconPencil size={16} stroke={1.5} className="text-text-secondary" />} onClick={() => {}} />
|
|
30
|
-
<MenuItem label="Copy link" leading={<IconCopy size={16} stroke={1.5} className="text-text-secondary" />} shortcut="
|
|
30
|
+
<MenuItem label="Copy link" leading={<IconCopy size={16} stroke={1.5} className="text-text-secondary" />} shortcut="Ctrl+C" onClick={() => {}} />
|
|
31
31
|
<Divider className="my-1" />
|
|
32
32
|
<MenuItem label="Delete" destructive leading={<IconTrash size={16} stroke={1.5} className="text-error-default" />} onClick={() => {}} />
|
|
33
33
|
</Menu>
|
package/src/Menu.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { createContext, useCallback, useContext, useEffect, useLayoutEffect, use
|
|
|
2
2
|
import { IconChevronRight } from '@tabler/icons-react'
|
|
3
3
|
import { createPortal } from 'react-dom'
|
|
4
4
|
import { cn } from './cn'
|
|
5
|
+
import { Kbd } from './Kbd'
|
|
5
6
|
import { clampBox, fitMenu, fitSubmenu } from './fit'
|
|
6
7
|
import { SectionLabel } from './SectionLabel'
|
|
7
8
|
|
|
@@ -70,6 +71,38 @@ export interface MenuProps {
|
|
|
70
71
|
* submenu panel — the one hover region the old inline submenus had for free. */
|
|
71
72
|
const MenuHoverContext = createContext<{ hold: () => void; release: () => void } | null>(null)
|
|
72
73
|
|
|
74
|
+
/**
|
|
75
|
+
* The menu's surface, with none of its behaviour — an elevated box with a
|
|
76
|
+
* hairline border, 8px radius, 8px padding and the large shadow.
|
|
77
|
+
*
|
|
78
|
+
* Split out of `Menu` on 2026-09-05. `Menu` owns Escape, outside-click and
|
|
79
|
+
* placement, and that is right for a menu opened from a trigger — but a
|
|
80
|
+
* type-ahead popup inside a text editor cannot have them: the editor's
|
|
81
|
+
* suggestion plugin already owns the keyboard and positions the popup, and a
|
|
82
|
+
* second Escape handler fights it. So Peek's `@`, `/` and `[` menus each drew
|
|
83
|
+
* this box by hand, and the three had already drifted apart.
|
|
84
|
+
*
|
|
85
|
+
* `Menu` renders this, so there is still exactly one definition of the
|
|
86
|
+
* surface — change it here and every menu in every app follows.
|
|
87
|
+
*
|
|
88
|
+
* Width, height and internal rhythm belong to the caller: a picker that lists
|
|
89
|
+
* people is not the width of one that lists verbs.
|
|
90
|
+
*/
|
|
91
|
+
export interface MenuPanelProps extends Omit<ComponentPropsWithRef<'div'>, 'children'> {
|
|
92
|
+
children: ReactNode
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function MenuPanel({ children, className, ...props }: MenuPanelProps) {
|
|
96
|
+
return (
|
|
97
|
+
<div
|
|
98
|
+
className={cn('flex flex-col rounded-lg border border-border-default bg-bg-elevated p-2 shadow-lg', className)}
|
|
99
|
+
{...props}
|
|
100
|
+
>
|
|
101
|
+
{children}
|
|
102
|
+
</div>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
73
106
|
export function Menu({ onClose, anchor, align = 'left', position, closeOnLeave = false, children, className }: MenuProps) {
|
|
74
107
|
const ref = useRef<HTMLDivElement>(null)
|
|
75
108
|
const leaveTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
@@ -152,12 +185,12 @@ export function Menu({ onClose, anchor, align = 'left', position, closeOnLeave =
|
|
|
152
185
|
: undefined
|
|
153
186
|
const node = (
|
|
154
187
|
<MenuHoverContext.Provider value={{ hold, release }}>
|
|
155
|
-
<
|
|
188
|
+
<MenuPanel
|
|
156
189
|
ref={ref}
|
|
157
190
|
role="menu"
|
|
158
191
|
data-interactive
|
|
159
192
|
className={cn(
|
|
160
|
-
'z-50
|
|
193
|
+
'z-50 min-w-[180px]',
|
|
161
194
|
portalled ? 'fixed overflow-y-auto' : 'absolute right-0 top-full mt-1',
|
|
162
195
|
className,
|
|
163
196
|
)}
|
|
@@ -167,7 +200,7 @@ export function Menu({ onClose, anchor, align = 'left', position, closeOnLeave =
|
|
|
167
200
|
onMouseLeave={closeOnLeave ? release : undefined}
|
|
168
201
|
>
|
|
169
202
|
{children}
|
|
170
|
-
</
|
|
203
|
+
</MenuPanel>
|
|
171
204
|
</MenuHoverContext.Provider>
|
|
172
205
|
)
|
|
173
206
|
return portalled ? createPortal(node, document.body) : node
|
|
@@ -237,17 +270,17 @@ export function MenuSub({ label, leading, selected, children, className }: MenuS
|
|
|
237
270
|
<MenuItem label={label} leading={leading} selected={selected} submenu />
|
|
238
271
|
{open &&
|
|
239
272
|
createPortal(
|
|
240
|
-
<
|
|
273
|
+
<MenuPanel
|
|
241
274
|
ref={panelRef}
|
|
242
275
|
role="menu"
|
|
243
276
|
data-interactive
|
|
244
|
-
className={cn('fixed z-50
|
|
277
|
+
className={cn('fixed z-50 w-[160px]', className)}
|
|
245
278
|
style={placed ?? { left: 0, top: 0, visibility: 'hidden' }}
|
|
246
279
|
onMouseEnter={enter}
|
|
247
280
|
onMouseLeave={leave}
|
|
248
281
|
>
|
|
249
282
|
{children}
|
|
250
|
-
</
|
|
283
|
+
</MenuPanel>,
|
|
251
284
|
document.body,
|
|
252
285
|
)}
|
|
253
286
|
</div>
|
|
@@ -269,6 +302,23 @@ export interface MenuItemProps extends Omit<ComponentPropsWithRef<'button'>, 'ch
|
|
|
269
302
|
leading?: ReactNode
|
|
270
303
|
/** At the right edge: a hint, a value — anything. Wins over `shortcut` and `submenu`. */
|
|
271
304
|
trailing?: ReactNode
|
|
305
|
+
/**
|
|
306
|
+
* Shown at the right edge **only while this row is the one you are pointing
|
|
307
|
+
* at or have arrowed onto** — an `EnterHint`, typically.
|
|
308
|
+
*
|
|
309
|
+
* Pass it unconditionally. Do not do `trailing={active ? <EnterHint/> : undefined}`:
|
|
310
|
+
* that mounts the hint, and a mount is instant while the row's own fill is a
|
|
311
|
+
* 150ms fade, so the hint lands ahead of the highlight on the way in and
|
|
312
|
+
* vanishes ahead of it on the way out (measured 2026-09-05 — ~7 frames of a
|
|
313
|
+
* chip sitting on an unhighlighted row, and two rows lit at once when
|
|
314
|
+
* sweeping). This slot is always in the DOM and revealed by the *same*
|
|
315
|
+
* `:hover` / `selected` the fill uses, on the same duration and curve, so
|
|
316
|
+
* the two cannot come apart — and the row does not reflow when it appears.
|
|
317
|
+
*
|
|
318
|
+
* It cross-fades with `trailing`/`shortcut`/`submenu` rather than displacing
|
|
319
|
+
* them, and reserves the wider of the two, so nothing moves either way.
|
|
320
|
+
*/
|
|
321
|
+
hint?: ReactNode
|
|
272
322
|
/** A keyboard hint, drawn as the kbd chip. */
|
|
273
323
|
shortcut?: string
|
|
274
324
|
/** The row opens another menu: draws the chevron at the right edge. */
|
|
@@ -278,13 +328,11 @@ export interface MenuItemProps extends Omit<ComponentPropsWithRef<'button'>, 'ch
|
|
|
278
328
|
selected?: boolean
|
|
279
329
|
}
|
|
280
330
|
|
|
281
|
-
export function MenuItem({ label, children, size = 'default', description, leading, trailing, shortcut, submenu, destructive, selected, className, ...props }: MenuItemProps) {
|
|
331
|
+
export function MenuItem({ label, children, size = 'default', description, leading, trailing, hint, shortcut, submenu, destructive, selected, className, ...props }: MenuItemProps) {
|
|
282
332
|
const edge =
|
|
283
333
|
trailing ??
|
|
284
334
|
(shortcut ? (
|
|
285
|
-
<
|
|
286
|
-
{shortcut}
|
|
287
|
-
</kbd>
|
|
335
|
+
<Kbd>{shortcut}</Kbd>
|
|
288
336
|
) : submenu ? (
|
|
289
337
|
<IconChevronRight size={16} stroke={1.5} className="shrink-0 text-text-muted" />
|
|
290
338
|
) : null)
|
|
@@ -293,7 +341,21 @@ export function MenuItem({ label, children, size = 'default', description, leadi
|
|
|
293
341
|
type="button"
|
|
294
342
|
role="menuitem"
|
|
295
343
|
className={cn(
|
|
296
|
-
|
|
344
|
+
// shrink-0: a menu is a flex column that scrolls at its max height,
|
|
345
|
+
// and a flex child shrinks before its container does — so every row
|
|
346
|
+
// in an overflowing menu was squashed to its `min-h`, and a row given
|
|
347
|
+
// an explicit height silently lost it (Peek's `[` menu: h-12 rows
|
|
348
|
+
// measured 40px). The same fix NavItem took on 2026-09-02.
|
|
349
|
+
// `group`: the `hint` slot reveals itself from this row's own :hover,
|
|
350
|
+
// so the hint and the fill are one CSS state change, not two engines.
|
|
351
|
+
//
|
|
352
|
+
// No transition on the fill (Katerina, 2026-09-05). It faded over
|
|
353
|
+
// 150ms, and anything appearing with it had to fade too or arrive
|
|
354
|
+
// ahead of it — which, sweeping a pointer down a list, read as the
|
|
355
|
+
// hint flickering in and out. Both are instant now: they still change
|
|
356
|
+
// on exactly the same :hover, so they cannot come apart, and a row
|
|
357
|
+
// lights and unlights crisply as the pointer crosses it.
|
|
358
|
+
'group flex w-full shrink-0 cursor-pointer items-center rounded-lg text-left hover:bg-bg-hover',
|
|
297
359
|
// tall: as tall as its content, never shorter than 40px (Katerina,
|
|
298
360
|
// 2026-09-01) — a single-line picker row sits at 40, a row with a
|
|
299
361
|
// 32px face and a role line comes out at its natural 48. One rule,
|
|
@@ -316,20 +378,52 @@ export function MenuItem({ label, children, size = 'default', description, leadi
|
|
|
316
378
|
{description && <span className="truncate text-[12px] leading-[120%] text-text-secondary">{description}</span>}
|
|
317
379
|
</span>
|
|
318
380
|
)}
|
|
319
|
-
{edge
|
|
381
|
+
{(edge || hint) && (
|
|
382
|
+
// One grid cell holding both, right-aligned: the slot is as wide as
|
|
383
|
+
// the wider of the two and never changes, so revealing the hint moves
|
|
384
|
+
// nothing. No transition here either — the hint switches on the same
|
|
385
|
+
// :hover / `selected` as the fill, in the same frame.
|
|
386
|
+
<span className="grid shrink-0 items-center justify-items-end [&>*]:col-start-1 [&>*]:row-start-1">
|
|
387
|
+
{edge && (
|
|
388
|
+
<span className={cn('flex items-center', hint && 'group-hover:opacity-0', hint && selected && 'opacity-0')}>
|
|
389
|
+
{edge}
|
|
390
|
+
</span>
|
|
391
|
+
)}
|
|
392
|
+
{hint && (
|
|
393
|
+
<span className={cn('flex items-center opacity-0 group-hover:opacity-100', selected && 'opacity-100')}>
|
|
394
|
+
{hint}
|
|
395
|
+
</span>
|
|
396
|
+
)}
|
|
397
|
+
</span>
|
|
398
|
+
)}
|
|
320
399
|
</button>
|
|
321
400
|
)
|
|
322
401
|
}
|
|
323
402
|
|
|
324
403
|
/**
|
|
325
|
-
* The keyboard hint a picker row shows while highlighted —
|
|
326
|
-
*
|
|
404
|
+
* The keyboard hint a picker row shows while highlighted — hand-rolled in
|
|
405
|
+
* five files before this (2026-09-01), and drawn as its own thing until
|
|
406
|
+
* 2026-09-05, when it became the `Kbd` chip every other key hint uses. A
|
|
407
|
+
* picker row and a menu row sit in the same menu; they named the same key
|
|
408
|
+
* two ways.
|
|
409
|
+
*
|
|
410
|
+
* `target` is what pressing it gives you, when that is worth saying — the
|
|
411
|
+
* topic picker's "↩ Enter #topic". It sits after the chip, in the picker's
|
|
412
|
+
* own 9px voice, because it is not a key.
|
|
413
|
+
*
|
|
414
|
+
* Katerina, 2026-09-05, told the measurement and asked again: the chip keeps
|
|
415
|
+
* the `↩` character. It is not in Geist Mono — the browser borrows it, so it
|
|
416
|
+
* advances 8.63px where the font's own characters advance 6 — and that is a
|
|
417
|
+
* knowing trade for the glyph, not an oversight. If the width ever has to go,
|
|
418
|
+
* Tabler's IconCornerDownLeft draws the same shape.
|
|
327
419
|
*/
|
|
328
|
-
export function EnterHint({
|
|
420
|
+
export function EnterHint({ target }: { target?: string }) {
|
|
329
421
|
return (
|
|
330
|
-
<span className="flex shrink-0 items-center gap-
|
|
331
|
-
<
|
|
332
|
-
|
|
422
|
+
<span className="flex shrink-0 items-center gap-1.5 text-text-muted">
|
|
423
|
+
<Kbd>↩ Enter</Kbd>
|
|
424
|
+
{target && (
|
|
425
|
+
<span className="text-[9px] font-medium leading-[115%] signal:font-mono signal:text-[9.5px] signal:tracking-[0.04em]">{target}</span>
|
|
426
|
+
)}
|
|
333
427
|
</span>
|
|
334
428
|
)
|
|
335
429
|
}
|
package/src/MenuItem.mdx
CHANGED
package/src/MenuItem.stories.tsx
CHANGED
|
@@ -40,7 +40,7 @@ export const WithAnIcon: Story = {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
export const WithAShortcut: Story = {
|
|
43
|
-
args: { label: 'Copy link', leading: <IconCopy size={16} stroke={1.5} className="text-text-secondary" />, shortcut: '
|
|
43
|
+
args: { label: 'Copy link', leading: <IconCopy size={16} stroke={1.5} className="text-text-secondary" />, shortcut: 'Ctrl+C' },
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
/** The row opens another menu. */
|
|
@@ -54,7 +54,7 @@ export const APerson: Story = {
|
|
|
54
54
|
label: 'Ana Duarte',
|
|
55
55
|
description: 'Product designer',
|
|
56
56
|
leading: <Avatar name="Ana Duarte" size={32} />,
|
|
57
|
-
trailing: <
|
|
57
|
+
trailing: <EnterHint />,
|
|
58
58
|
},
|
|
59
59
|
}
|
|
60
60
|
|
|
@@ -74,10 +74,10 @@ export const AllVariants: Story = {
|
|
|
74
74
|
<>
|
|
75
75
|
<MenuItem label="Bare" onClick={() => {}} />
|
|
76
76
|
<MenuItem label="With an icon" leading={<IconPin size={16} stroke={1.5} className="text-text-secondary" />} onClick={() => {}} />
|
|
77
|
-
<MenuItem label="With a shortcut" shortcut="
|
|
78
|
-
<MenuItem label="Icon and shortcut" leading={<IconCopy size={16} stroke={1.5} className="text-text-secondary" />} shortcut="
|
|
77
|
+
<MenuItem label="With a shortcut" shortcut="Ctrl+K" onClick={() => {}} />
|
|
78
|
+
<MenuItem label="Icon and shortcut" leading={<IconCopy size={16} stroke={1.5} className="text-text-secondary" />} shortcut="Ctrl+C" onClick={() => {}} />
|
|
79
79
|
<MenuItem label="Opens another menu" submenu onClick={() => {}} />
|
|
80
|
-
<MenuItem label="Ana Duarte" description="Product designer" leading={<Avatar name="Ana Duarte" size={32} />} trailing={<
|
|
80
|
+
<MenuItem label="Ana Duarte" description="Product designer" leading={<Avatar name="Ana Duarte" size={32} />} trailing={<EnterHint />} onClick={() => {}} />
|
|
81
81
|
<MenuItem label="The chosen value" selected onClick={() => {}} />
|
|
82
82
|
<MenuItem label="Destructive" destructive leading={<IconTrash size={16} stroke={1.5} className="text-error-default" />} onClick={() => {}} />
|
|
83
83
|
<MenuItem label="A very long label that runs out of room and truncates" onClick={() => {}} />
|
package/src/SearchInput.mdx
CHANGED
|
@@ -15,7 +15,7 @@ optional keyboard hint at the right edge.
|
|
|
15
15
|
- Filtering a list, live, above the list it filters.
|
|
16
16
|
- As a **launcher affordance**: keep the input `pointer-events-none` and
|
|
17
17
|
open your command surface from a click on the surround — the component
|
|
18
|
-
is the same either way, and `shortcut` shows the way in ("
|
|
18
|
+
is the same either way, and `shortcut` shows the way in ("Ctrl+K").
|
|
19
19
|
|
|
20
20
|
## When not
|
|
21
21
|
|
|
@@ -27,7 +27,7 @@ optional keyboard hint at the right edge.
|
|
|
27
27
|
```tsx
|
|
28
28
|
import { SearchInput } from '@estiva-app/ui'
|
|
29
29
|
|
|
30
|
-
<SearchInput value={query} onChange={(e) => setQuery(e.target.value)} shortcut="
|
|
30
|
+
<SearchInput value={query} onChange={(e) => setQuery(e.target.value)} shortcut="Ctrl+K" />
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
- The default placeholder is "Search…" — override it with your own.
|
|
@@ -15,10 +15,10 @@ export const Default: Story = {}
|
|
|
15
15
|
|
|
16
16
|
/** The keyboard hint at the right edge. */
|
|
17
17
|
export const WithShortcut: Story = {
|
|
18
|
-
args: { shortcut: '
|
|
18
|
+
args: { shortcut: 'Ctrl+K' },
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
/** The default placeholder says only "Search…" — the app names what is searched. */
|
|
22
22
|
export const OwnPlaceholder: Story = {
|
|
23
|
-
args: { placeholder: 'Search documents…', shortcut: '
|
|
23
|
+
args: { placeholder: 'Search documents…', shortcut: 'Ctrl+K' },
|
|
24
24
|
}
|
package/src/SearchInput.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type InputHTMLAttributes } from 'react'
|
|
2
2
|
import { cn } from './cn'
|
|
3
|
+
import { Kbd } from './Kbd'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Peek's SearchInput (2026-09-01), verbatim: an inset field with a hairline
|
|
@@ -15,7 +16,7 @@ import { cn } from './cn'
|
|
|
15
16
|
* command launcher. The component is the same either way.
|
|
16
17
|
*/
|
|
17
18
|
export interface SearchInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'className'> {
|
|
18
|
-
/** A keyboard hint drawn at the right edge, e.g. "
|
|
19
|
+
/** A keyboard hint drawn at the right edge, e.g. "Ctrl+K". */
|
|
19
20
|
shortcut?: string
|
|
20
21
|
className?: string
|
|
21
22
|
}
|
|
@@ -35,11 +36,7 @@ export function SearchInput({ shortcut, className, placeholder = 'Search…', ..
|
|
|
35
36
|
placeholder={placeholder}
|
|
36
37
|
{...props}
|
|
37
38
|
/>
|
|
38
|
-
{shortcut &&
|
|
39
|
-
<div className="flex items-center justify-center px-1 py-px rounded-sm bg-bg-inset border border-border-strong shrink-0 signal:bg-[rgba(255,255,255,.05)] signal:border-b-2">
|
|
40
|
-
<span className="text-caption text-text-secondary whitespace-nowrap signal:font-mono signal:text-[10px]">{shortcut}</span>
|
|
41
|
-
</div>
|
|
42
|
-
)}
|
|
39
|
+
{shortcut && <Kbd>{shortcut}</Kbd>}
|
|
43
40
|
</div>
|
|
44
41
|
)
|
|
45
42
|
}
|
package/src/Tooltip.mdx
CHANGED
|
@@ -19,6 +19,11 @@ viewport, kept 8px inside its edges.
|
|
|
19
19
|
- The word for an icon-only control — alongside its `aria-label`, never
|
|
20
20
|
instead of it (IconButton wires this for you via its `tooltip` prop).
|
|
21
21
|
- The full text behind a truncation.
|
|
22
|
+
- **The key that does the same thing** — pass `shortcut` and it is drawn as
|
|
23
|
+
the `Kbd` chip after the label. For an icon-only control whose only other
|
|
24
|
+
affordance is a keyboard shortcut, the tooltip is the only place to say so.
|
|
25
|
+
|
|
26
|
+
<Canvas of={TooltipStories.ShortcutComparison} />
|
|
22
27
|
|
|
23
28
|
<Canvas of={TooltipStories.OnADisabledControl} />
|
|
24
29
|
|
|
@@ -45,6 +50,10 @@ import { WithTooltip } from '@estiva-app/ui'
|
|
|
45
50
|
`wrapperClassName="min-w-0 shrink"`.
|
|
46
51
|
- It shows on **hover only** — there is no focus or touch trigger. Don't
|
|
47
52
|
put anything behind it that a keyboard user must reach.
|
|
53
|
+
- `shortcut` renders, it does not format. A modifier is called Cmd on Apple
|
|
54
|
+
platforms and Ctrl elsewhere, and only the caller knows which it is
|
|
55
|
+
looking at — so pass the finished label. `IconButton` forwards its own
|
|
56
|
+
`tooltipShortcut` here.
|
|
48
57
|
|
|
49
58
|
## Props
|
|
50
59
|
|
package/src/Tooltip.stories.tsx
CHANGED
|
@@ -14,6 +14,21 @@ type Story = StoryObj<typeof meta>
|
|
|
14
14
|
/** The static tooltip surface. */
|
|
15
15
|
export const Default: Story = {}
|
|
16
16
|
|
|
17
|
+
/** With a key hint — drawn as the `Kbd` chip after the label. */
|
|
18
|
+
export const WithShortcut: Story = { args: { label: 'Bold', shortcut: 'Cmd+B' } }
|
|
19
|
+
|
|
20
|
+
/** With and without, so the difference is one glance. */
|
|
21
|
+
export const ShortcutComparison: Story = {
|
|
22
|
+
parameters: { controls: { disable: true } },
|
|
23
|
+
render: () => (
|
|
24
|
+
<div className="flex flex-col items-start gap-2">
|
|
25
|
+
<Tooltip label="Comment" />
|
|
26
|
+
<Tooltip label="Bold" shortcut="Cmd+B" />
|
|
27
|
+
<Tooltip label="Heading" shortcut="Ctrl+Alt+1" />
|
|
28
|
+
</div>
|
|
29
|
+
),
|
|
30
|
+
}
|
|
31
|
+
|
|
17
32
|
/** Hover the button — WithTooltip portals the tooltip above the trigger. */
|
|
18
33
|
export const OnHoverTop: Story = {
|
|
19
34
|
parameters: { controls: { disable: true } },
|