@estiva-app/ui 0.4.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/AppShell.d.ts.map +1 -1
- package/dist/Avatar.d.ts.map +1 -1
- package/dist/AvatarGroup.d.ts +14 -5
- package/dist/AvatarGroup.d.ts.map +1 -1
- package/dist/ChipInput.d.ts.map +1 -1
- 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/IdentityMenu.d.ts +6 -1
- package/dist/IdentityMenu.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 +115 -11
- 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/Select.d.ts +2 -31
- package/dist/Select.d.ts.map +1 -1
- package/dist/Tooltip.d.ts +8 -2
- package/dist/Tooltip.d.ts.map +1 -1
- package/dist/fit.d.ts +98 -0
- package/dist/fit.d.ts.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +441 -232
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/src/AppShell.stories.tsx +2 -2
- package/src/AppShell.tsx +8 -1
- package/src/Avatar.tsx +14 -1
- package/src/AvatarGroup.stories.tsx +5 -0
- package/src/AvatarGroup.tsx +36 -13
- package/src/ChipInput.tsx +15 -3
- package/src/Divider.tsx +5 -1
- package/src/IconButton.tsx +5 -1
- package/src/IdentityMenu.tsx +14 -4
- package/src/Kbd.mdx +77 -0
- package/src/Kbd.stories.tsx +58 -0
- package/src/Kbd.tsx +42 -0
- package/src/Menu.fit.test.ts +90 -0
- package/src/Menu.mdx +8 -1
- package/src/Menu.stories.tsx +26 -3
- package/src/Menu.tsx +310 -34
- 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/Select.fit.test.ts +12 -8
- package/src/Select.tsx +8 -32
- package/src/Tooltip.mdx +9 -0
- package/src/Tooltip.stories.tsx +15 -0
- package/src/Tooltip.tsx +19 -5
- package/src/TopBar.mdx +1 -1
- package/src/TopBar.stories.tsx +2 -2
- package/src/fit.ts +94 -0
- package/src/index.ts +3 -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/AppShell.tsx
CHANGED
|
@@ -63,7 +63,14 @@ export function AppShell({ variant = 'solid', menu, logo, search, identity, bann
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
return (
|
|
66
|
-
|
|
66
|
+
/* `relative overflow-hidden` is the seal the floating manner already has,
|
|
67
|
+
and it takes both halves: an absolutely positioned descendant with no
|
|
68
|
+
positioned ancestor belongs to the *viewport*, so a scroll container
|
|
69
|
+
never clips it and the document itself gains its position as scroll
|
|
70
|
+
range — the whole page scrolls, navigation and top bar included.
|
|
71
|
+
`relative` claims such strays for the shell; `overflow-hidden` clips
|
|
72
|
+
them at its edge. Either alone seals nothing. */
|
|
73
|
+
<div className="relative flex h-full min-h-0 flex-col overflow-hidden bg-bg-base text-text-primary">
|
|
67
74
|
{bar}
|
|
68
75
|
<div className="flex min-h-0 flex-1">
|
|
69
76
|
{nav}
|
package/src/Avatar.tsx
CHANGED
|
@@ -65,7 +65,20 @@ export function Avatar({ src, name, alt = '', size = 36, className }: AvatarProp
|
|
|
65
65
|
<img src={picture} alt={alt || name || ''} className="w-full h-full object-cover" onError={() => setBroken(true)} />
|
|
66
66
|
) : label ? (
|
|
67
67
|
<div
|
|
68
|
-
|
|
68
|
+
/*
|
|
69
|
+
`leading-none` is load-bearing (2026-09-03). Centring a flex child
|
|
70
|
+
centres its LINE BOX, and a line box reserves room under the
|
|
71
|
+
baseline for descenders — which capitals never use — so initials
|
|
72
|
+
floated above the middle of every tile. It also inherited whatever
|
|
73
|
+
line-height surrounded it, so the same face sat differently in a
|
|
74
|
+
members pill and in a replies row. Measured over six letter pairs
|
|
75
|
+
at 18/24/36px: mean 0.64px high before, 0.06px after.
|
|
76
|
+
|
|
77
|
+
Per-letter variation stays (a "Y" carries its mass up top, a "ZB"
|
|
78
|
+
more than an "AJ") — that is the letterform, not the box, and it
|
|
79
|
+
is not something a rule here can flatten.
|
|
80
|
+
*/
|
|
81
|
+
className="w-full h-full flex items-center justify-center font-semibold leading-none"
|
|
69
82
|
style={{
|
|
70
83
|
color: '#08121c',
|
|
71
84
|
fontSize: Math.round(size * 0.36),
|
|
@@ -33,6 +33,11 @@ export const OverflowShowsThree: Story = {
|
|
|
33
33
|
},
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/** The smaller face, for a dense row — a reply line's authors, say. */
|
|
37
|
+
export const Small: Story = {
|
|
38
|
+
args: { size: 18 },
|
|
39
|
+
}
|
|
40
|
+
|
|
36
41
|
/**
|
|
37
42
|
* Inside a members pill with the total count — the shape a conversation
|
|
38
43
|
* header draws over the stack. The pill is the caller's; this is the group
|
package/src/AvatarGroup.tsx
CHANGED
|
@@ -8,10 +8,13 @@ import { Avatar } from './Avatar'
|
|
|
8
8
|
* wrapper resolves pictures itself; a consumer without such a wrapper could
|
|
9
9
|
* never have shown one.
|
|
10
10
|
*
|
|
11
|
-
* The ring
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* The ring is drawn OUTSIDE the face, as a shadow (2026-09-03). It was a
|
|
12
|
+
* `border` before, which box-sizing takes out of the inside: a 24px avatar
|
|
13
|
+
* showed 20px of face, and at 18px only 14px — noticeably smaller than the
|
|
14
|
+
* number asked for. A shadow costs the face nothing, so `size` means the
|
|
15
|
+
* size. It replaced an even older wrapper whose `overflow-hidden` clipped
|
|
16
|
+
* 4px of face and the right edge of the initials with it ("CO" as "C(") —
|
|
17
|
+
* hence the rule this file keeps: nothing that clips ever wraps the face.
|
|
15
18
|
*/
|
|
16
19
|
export interface AvatarGroupMember {
|
|
17
20
|
name: string
|
|
@@ -21,21 +24,41 @@ export interface AvatarGroupMember {
|
|
|
21
24
|
|
|
22
25
|
export interface AvatarGroupProps {
|
|
23
26
|
members: AvatarGroupMember[]
|
|
27
|
+
/**
|
|
28
|
+
* Face size in pixels; the overlap follows it. 24 is the members pill,
|
|
29
|
+
* 18 the replies row (Katerina, 2026-09-03) — that row drew its own stack
|
|
30
|
+
* for two months for want of this one number, and drifted while it did.
|
|
31
|
+
*/
|
|
32
|
+
size?: number
|
|
24
33
|
}
|
|
25
34
|
|
|
26
|
-
export function AvatarGroup({ members }: AvatarGroupProps) {
|
|
35
|
+
export function AvatarGroup({ members, size = 24 }: AvatarGroupProps) {
|
|
27
36
|
const visible = members.slice(0, 3)
|
|
37
|
+
/*
|
|
38
|
+
A third of the face, so a stack reads the same at any size — which is
|
|
39
|
+
exactly the ratio both hand-drawn stacks had arrived at independently
|
|
40
|
+
(8px on 24, 6px on 18). The container gives the last face its overlap
|
|
41
|
+
back as padding, so the group measures its true width.
|
|
42
|
+
*/
|
|
43
|
+
const overlap = Math.round(size / 3)
|
|
44
|
+
// The ring scales with the face — 2px at 24, 1.5px at 18, which is what both
|
|
45
|
+
// hand-drawn stacks used before this component carried either of them.
|
|
46
|
+
const ring = size / 12
|
|
28
47
|
return (
|
|
29
|
-
<div className="flex items-center
|
|
48
|
+
<div className="flex items-center" style={{ paddingRight: overlap }}>
|
|
30
49
|
{visible.map((member, i) => (
|
|
31
|
-
|
|
50
|
+
/*
|
|
51
|
+
The wrapper carries the overlap and the ring; the face carries
|
|
52
|
+
neither. It must never clip — see the note at the top of the file —
|
|
53
|
+
so it has the avatar's own radius and no overflow of its own.
|
|
54
|
+
*/
|
|
55
|
+
<span
|
|
32
56
|
key={i}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
alt={member.name}
|
|
37
|
-
|
|
38
|
-
/>
|
|
57
|
+
className="relative flex rounded-sm"
|
|
58
|
+
style={{ marginRight: -overlap, boxShadow: `0 0 0 ${ring}px var(--bg-surface)` }}
|
|
59
|
+
>
|
|
60
|
+
<Avatar size={size} name={member.name} src={member.picture} alt={member.name} />
|
|
61
|
+
</span>
|
|
39
62
|
))}
|
|
40
63
|
</div>
|
|
41
64
|
)
|
package/src/ChipInput.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { useState, useRef, useMemo, useEffect, useLayoutEffect, type KeyboardEve
|
|
|
2
2
|
import { createPortal } from 'react-dom'
|
|
3
3
|
import { IconX } from '@tabler/icons-react'
|
|
4
4
|
import { cn } from './cn'
|
|
5
|
+
import { fitMenu } from './fit'
|
|
5
6
|
import { MenuItem } from './Menu'
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -216,10 +217,21 @@ export function ChipInput<T extends ChipInputOption = ChipInputOption>({
|
|
|
216
217
|
|
|
217
218
|
{showDropdown && anchorRect && createPortal(
|
|
218
219
|
<div
|
|
219
|
-
className="fixed z-[60]
|
|
220
|
+
className="fixed z-[60] overflow-y-auto bg-bg-elevated border border-border-default rounded-lg shadow-lg"
|
|
221
|
+
/*
|
|
222
|
+
Placed by fitMenu (2026-09-03), not hung blindly below: an input
|
|
223
|
+
low on the screen flips its list upward instead of running the
|
|
224
|
+
tail past the bottom edge. The rows are a fixed 48px, so the
|
|
225
|
+
content height is arithmetic and needs no second render pass;
|
|
226
|
+
the 240px cap is the old max-h-[240px].
|
|
227
|
+
*/
|
|
220
228
|
style={{
|
|
221
|
-
|
|
222
|
-
|
|
229
|
+
...fitMenu({
|
|
230
|
+
anchor: { left: anchorRect.left, top: anchorRect.top, bottom: anchorRect.bottom },
|
|
231
|
+
menu: { width: anchorRect.width, contentHeight: matches.length * 48 },
|
|
232
|
+
viewport: { width: window.innerWidth, height: window.innerHeight },
|
|
233
|
+
cap: 240,
|
|
234
|
+
}),
|
|
223
235
|
width: anchorRect.width,
|
|
224
236
|
}}
|
|
225
237
|
>
|
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/IdentityMenu.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, type ReactNode } from 'react'
|
|
1
|
+
import { useRef, useState, type ReactNode } from 'react'
|
|
2
2
|
import { cn } from './cn'
|
|
3
3
|
import { Divider } from './Divider'
|
|
4
4
|
import { Menu, MenuItem, MenuRow, MenuSection } from './Menu'
|
|
@@ -55,6 +55,11 @@ export interface IdentityMenuProps {
|
|
|
55
55
|
|
|
56
56
|
export interface IdentityPanelProps extends Omit<IdentityMenuProps, 'compact' | 'className'> {
|
|
57
57
|
onClose: () => void
|
|
58
|
+
/** The trigger to hang from — the panel portals to the body and fits the
|
|
59
|
+
* viewport, so no header, sidebar or scroll container can cover it (the
|
|
60
|
+
* z-10 floating top bar trapped the old in-flow panel under a z-20 panel
|
|
61
|
+
* header, 2026-09-03). Absent, the panel stands in flow — the stories. */
|
|
62
|
+
anchor?: HTMLElement | null
|
|
58
63
|
/** On the Menu surface — the stories pass `static` to stand it in flow. */
|
|
59
64
|
className?: string
|
|
60
65
|
}
|
|
@@ -62,14 +67,14 @@ export interface IdentityPanelProps extends Omit<IdentityMenuProps, 'compact' |
|
|
|
62
67
|
/** The menu alone — what `IdentityMenu` opens. Exported so the stories show
|
|
63
68
|
* the designed artifact rather than a closed trigger, and for any surface
|
|
64
69
|
* that wants the panel without the trigger. */
|
|
65
|
-
export function IdentityPanel({ me, signedIn, relayUrl, idBase, onCopyKey, onSignOut, onClose, className, children }: IdentityPanelProps) {
|
|
70
|
+
export function IdentityPanel({ me, signedIn, relayUrl, idBase, onCopyKey, onSignOut, onClose, anchor, className, children }: IdentityPanelProps) {
|
|
66
71
|
const act = (action: () => void) => () => {
|
|
67
72
|
onClose()
|
|
68
73
|
action()
|
|
69
74
|
}
|
|
70
75
|
const appRows = typeof children === 'function' ? children(onClose) : children
|
|
71
76
|
return (
|
|
72
|
-
<Menu onClose={onClose} className={cn('w-72', className)}>
|
|
77
|
+
<Menu onClose={onClose} anchor={anchor} align="right" className={cn('w-72', className)}>
|
|
73
78
|
<MenuSection label={signedIn ? 'Signed in as' : 'Acting as'}>
|
|
74
79
|
<MenuRow>
|
|
75
80
|
<Person name={me.name} picture={me.picture} fallback="Anonymous" size={28} className="text-body-2-strong" />
|
|
@@ -117,9 +122,13 @@ export function IdentityPanel({ me, signedIn, relayUrl, idBase, onCopyKey, onSig
|
|
|
117
122
|
|
|
118
123
|
export function IdentityMenu({ me, signedIn, relayUrl, idBase, onCopyKey, onSignOut, compact = false, className, children }: IdentityMenuProps) {
|
|
119
124
|
const [open, setOpen] = useState(false)
|
|
125
|
+
/* The wrapper is the anchor: the panel hangs its right edge from this
|
|
126
|
+
div's, exactly where the old in-flow `absolute right-0` put it — but
|
|
127
|
+
portalled, so nothing z-indexed in the app can cover it. */
|
|
128
|
+
const anchorRef = useRef<HTMLDivElement>(null)
|
|
120
129
|
|
|
121
130
|
return (
|
|
122
|
-
<div className={cn('relative', className)}>
|
|
131
|
+
<div ref={anchorRef} className={cn('relative', className)}>
|
|
123
132
|
<PersonTrigger
|
|
124
133
|
name={me.name}
|
|
125
134
|
picture={me.picture}
|
|
@@ -146,6 +155,7 @@ export function IdentityMenu({ me, signedIn, relayUrl, idBase, onCopyKey, onSign
|
|
|
146
155
|
onCopyKey={onCopyKey}
|
|
147
156
|
onSignOut={onSignOut}
|
|
148
157
|
onClose={() => setOpen(false)}
|
|
158
|
+
anchor={anchorRef.current}
|
|
149
159
|
>
|
|
150
160
|
{children}
|
|
151
161
|
</IdentityPanel>
|
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
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { clampBox, fitMenu, fitSubmenu } from './fit'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The Menu shell's viewport geometry (Katerina, 2026-09-03): the identity
|
|
6
|
+
* menu vanished under a z-indexed panel header, and a reply menu's highlight
|
|
7
|
+
* submenu was cut off by the right screen edge — its hand-rolled flip read a
|
|
8
|
+
* ref that was never attached, so it measured nothing. The portal fixes the
|
|
9
|
+
* covering; these pin the pure half of the fitting.
|
|
10
|
+
*/
|
|
11
|
+
const viewport = { width: 1280, height: 800 }
|
|
12
|
+
|
|
13
|
+
describe('fitMenu without a cap (the Menu shell)', () => {
|
|
14
|
+
it('lets a tall panel stand at its full height when the room is there — no scrollbar', () => {
|
|
15
|
+
// The identity panel: ~450px tall on a 900px screen. With Select's 288
|
|
16
|
+
// cap it grew a scrollbar at full height; without one it just stands.
|
|
17
|
+
const fit = fitMenu({
|
|
18
|
+
anchor: { left: 1100, top: 20, bottom: 56 },
|
|
19
|
+
menu: { width: 288, contentHeight: 450 },
|
|
20
|
+
viewport: { width: 1440, height: 900 },
|
|
21
|
+
})
|
|
22
|
+
expect(fit.maxHeight).toBeGreaterThanOrEqual(450)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('still caps to the room when the screen truly runs out', () => {
|
|
26
|
+
const fit = fitMenu({
|
|
27
|
+
anchor: { left: 100, top: 20, bottom: 56 },
|
|
28
|
+
menu: { width: 288, contentHeight: 1200 },
|
|
29
|
+
viewport: { width: 1440, height: 900 },
|
|
30
|
+
})
|
|
31
|
+
expect(fit.maxHeight).toBe(900 - 56 - 4 - 8)
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('clampBox', () => {
|
|
36
|
+
it('leaves a well-placed box exactly where the caller put it', () => {
|
|
37
|
+
const fit = clampBox({ box: { left: 100, top: 200, width: 300, height: 400 }, viewport })
|
|
38
|
+
expect(fit).toEqual({ left: 100, top: 200, maxHeight: 400 })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('slides a box back inside the right edge, margin kept', () => {
|
|
42
|
+
const fit = clampBox({ box: { left: 1100, top: 200, width: 300, height: 400 }, viewport })
|
|
43
|
+
expect(fit.left).toBe(1280 - 300 - 8)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('never pushes a box past the left edge either', () => {
|
|
47
|
+
const fit = clampBox({ box: { left: -40, top: 200, width: 300, height: 400 }, viewport })
|
|
48
|
+
expect(fit.left).toBe(8)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('slides a box up so its tail stays on screen', () => {
|
|
52
|
+
const fit = clampBox({ box: { left: 100, top: 700, width: 300, height: 400 }, viewport })
|
|
53
|
+
expect(fit.top).toBe(800 - 400 - 8)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('caps a box taller than the viewport and pins it to the top margin', () => {
|
|
57
|
+
const fit = clampBox({ box: { left: 100, top: 100, width: 300, height: 900 }, viewport })
|
|
58
|
+
expect(fit.maxHeight).toBe(800 - 16)
|
|
59
|
+
expect(fit.top).toBe(8)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('keeps the 120px floor — scrollable beats invisible', () => {
|
|
63
|
+
const fit = clampBox({ box: { left: 100, top: 200, width: 300, height: 40 }, viewport })
|
|
64
|
+
expect(fit.maxHeight).toBeGreaterThanOrEqual(120)
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('fitSubmenu', () => {
|
|
69
|
+
const panel = { width: 160, height: 200 }
|
|
70
|
+
|
|
71
|
+
it('opens to the right of a row with room', () => {
|
|
72
|
+
const fit = fitSubmenu({ row: { left: 500, right: 740, top: 300 }, panel, viewport })
|
|
73
|
+
expect(fit).toEqual({ left: 744, top: 300 })
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('flips to the left at the right screen edge (the highlight submenu cut)', () => {
|
|
77
|
+
const fit = fitSubmenu({ row: { left: 1000, right: 1240, top: 300 }, panel, viewport })
|
|
78
|
+
expect(fit.left).toBe(1000 - 4 - 160)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('never lands past the left edge even when flipped from a narrow spot', () => {
|
|
82
|
+
const fit = fitSubmenu({ row: { left: 60, right: 1240, top: 300 }, panel, viewport })
|
|
83
|
+
expect(fit.left).toBe(8)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('slides up when the tail would run past the bottom', () => {
|
|
87
|
+
const fit = fitSubmenu({ row: { left: 500, right: 740, top: 700 }, panel, viewport })
|
|
88
|
+
expect(fit.top).toBe(800 - 200 - 8)
|
|
89
|
+
})
|
|
90
|
+
})
|
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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
-
import { IconCopy, IconPencil, IconTrash } from '@tabler/icons-react'
|
|
2
|
+
import { IconCopy, IconHighlight, IconPencil, IconTrash } from '@tabler/icons-react'
|
|
3
3
|
import { Divider } from './Divider'
|
|
4
|
-
import { Menu, MenuItem, MenuRow, MenuSection } from './Menu'
|
|
4
|
+
import { Menu, MenuItem, MenuRow, MenuSection, MenuSub } from './Menu'
|
|
5
5
|
import { SectionLabel } from './SectionLabel'
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -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>
|
|
@@ -51,6 +51,29 @@ export const Sections: Story = {
|
|
|
51
51
|
),
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* A row that opens another menu beside it — hover it. The panel portals to
|
|
56
|
+
* the body and fits the viewport: right of the row with room, flipped left
|
|
57
|
+
* at the screen edge, never cut off. Live rather than pinned, because the
|
|
58
|
+
* placement IS the designed behaviour.
|
|
59
|
+
*/
|
|
60
|
+
export const WithASubmenu: Story = {
|
|
61
|
+
render: (args) => (
|
|
62
|
+
<Menu {...args}>
|
|
63
|
+
<MenuItem label="Rename" leading={<IconPencil size={16} stroke={1.5} className="text-text-secondary" />} onClick={() => {}} />
|
|
64
|
+
<MenuSub label="Mark as Highlight" leading={<IconHighlight size={16} stroke={1.5} className="text-text-secondary" />}>
|
|
65
|
+
<MenuItem label="Insight" onClick={() => {}} />
|
|
66
|
+
<MenuItem label="Concern" onClick={() => {}} />
|
|
67
|
+
<MenuItem label="Conclusion" onClick={() => {}} />
|
|
68
|
+
<MenuItem label="Question" onClick={() => {}} />
|
|
69
|
+
<MenuItem label="Summary" onClick={() => {}} />
|
|
70
|
+
</MenuSub>
|
|
71
|
+
<Divider className="my-1" />
|
|
72
|
+
<MenuItem label="Delete" destructive onClick={() => {}} />
|
|
73
|
+
</Menu>
|
|
74
|
+
),
|
|
75
|
+
}
|
|
76
|
+
|
|
54
77
|
/** A non-interactive row at the item's geometry — identity lines, hints. */
|
|
55
78
|
export const WithARow: Story = {
|
|
56
79
|
render: (args) => (
|