@estiva-app/ui 0.4.0 → 0.5.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/IdentityMenu.d.ts +6 -1
- package/dist/IdentityMenu.d.ts.map +1 -1
- package/dist/Menu.d.ts +59 -6
- package/dist/Menu.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.map +1 -1
- package/dist/fit.d.ts +98 -0
- package/dist/fit.d.ts.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +252 -97
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- 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/IdentityMenu.tsx +14 -4
- package/src/Menu.fit.test.ts +90 -0
- package/src/Menu.stories.tsx +25 -2
- package/src/Menu.tsx +204 -22
- package/src/Select.fit.test.ts +12 -8
- package/src/Select.tsx +8 -32
- package/src/Tooltip.tsx +7 -1
- package/src/fit.ts +94 -0
- package/src/index.ts +2 -1
package/package.json
CHANGED
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/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>
|
|
@@ -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.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
|
/**
|
|
@@ -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) => (
|
package/src/Menu.tsx
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { useEffect, useRef, type ComponentPropsWithRef, type CSSProperties, type ReactNode } from 'react'
|
|
1
|
+
import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState, type ComponentPropsWithRef, type CSSProperties, type ReactNode } from 'react'
|
|
2
2
|
import { IconChevronRight } from '@tabler/icons-react'
|
|
3
3
|
import { createPortal } from 'react-dom'
|
|
4
4
|
import { cn } from './cn'
|
|
5
|
+
import { clampBox, fitMenu, fitSubmenu } from './fit'
|
|
5
6
|
import { SectionLabel } from './SectionLabel'
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -21,21 +22,68 @@ import { SectionLabel } from './SectionLabel'
|
|
|
21
22
|
* row; and the two exits every menu has — Escape and a click outside —
|
|
22
23
|
* owned by the menu, never copied into a caller.
|
|
23
24
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
25
|
+
* Where it goes is owned here too (2026-09-03), because the two ways a menu
|
|
26
|
+
* goes wrong are both placement: it opens inside a stacking context or a
|
|
27
|
+
* scroll container and something covers or clips it, or it opens near an
|
|
28
|
+
* edge and runs off the screen. Both shipped — the identity menu vanished
|
|
29
|
+
* under a z-indexed panel header, a submenu was cut by the right edge — so
|
|
30
|
+
* the shell now portals to the body (nothing in an app can cover the body's
|
|
31
|
+
* last child at z-50) and fits itself to the viewport (fit.ts, the same
|
|
32
|
+
* measured geometry Select uses). A caller hands over a trigger, not
|
|
33
|
+
* coordinates.
|
|
34
|
+
*
|
|
35
|
+
* Three anchorings, in order of preference:
|
|
36
|
+
* - `anchor` (an element, usually the trigger): portalled, measured, and
|
|
37
|
+
* placed by `fitMenu` — under the anchor, flipped above when the room
|
|
38
|
+
* below is worse, clamped inside the viewport, height-capped with its own
|
|
39
|
+
* scrollbar. `align="right"` hangs the menu's right edge from the
|
|
40
|
+
* anchor's. Closes on resize and on any page scroll, because both move
|
|
41
|
+
* the anchor out from under it.
|
|
42
|
+
* - `position` (viewport coordinates the caller computed): portalled and
|
|
43
|
+
* clamped (`clampBox`) — the caller's corner survives, but can no longer
|
|
44
|
+
* land off screen.
|
|
45
|
+
* - neither: in-flow under a `relative` wrapper, right-aligned. For stories
|
|
46
|
+
* and static surfaces only — inside an app this mode inherits every
|
|
47
|
+
* ancestor's stacking context and clip, which is how the identity menu
|
|
48
|
+
* got covered.
|
|
28
49
|
*/
|
|
29
50
|
export interface MenuProps {
|
|
30
51
|
onClose: () => void
|
|
31
|
-
/**
|
|
52
|
+
/** The trigger — an element, or the rect a click handler already measured.
|
|
53
|
+
* The menu portals to the body and places itself against it. */
|
|
54
|
+
anchor?: HTMLElement | DOMRect | null
|
|
55
|
+
/** With `anchor`: which of the menu's edges hangs from the anchor's. Default left. */
|
|
56
|
+
align?: 'left' | 'right'
|
|
57
|
+
/** Viewport coordinates; the menu is portalled, hung from `top`, aligned to whichever edge is given, and clamped on screen. */
|
|
32
58
|
position?: { top: number; right: number } | { top: number; left: number }
|
|
59
|
+
/** Close 150ms after the pointer leaves the menu — the hover-flow menus
|
|
60
|
+
* (quick-menu cards) dismiss this way. The grace period is shared with any
|
|
61
|
+
* open MenuSub panel, so crossing into a portalled submenu never counts as
|
|
62
|
+
* leaving. */
|
|
63
|
+
closeOnLeave?: boolean
|
|
33
64
|
children: ReactNode
|
|
34
65
|
className?: string
|
|
35
66
|
}
|
|
36
67
|
|
|
37
|
-
|
|
68
|
+
/** MenuSub reports its hover into the enclosing Menu's leave-grace timer, so
|
|
69
|
+
* a `closeOnLeave` menu survives the pointer crossing into a portalled
|
|
70
|
+
* submenu panel — the one hover region the old inline submenus had for free. */
|
|
71
|
+
const MenuHoverContext = createContext<{ hold: () => void; release: () => void } | null>(null)
|
|
72
|
+
|
|
73
|
+
export function Menu({ onClose, anchor, align = 'left', position, closeOnLeave = false, children, className }: MenuProps) {
|
|
38
74
|
const ref = useRef<HTMLDivElement>(null)
|
|
75
|
+
const leaveTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
76
|
+
const hold = useCallback(() => clearTimeout(leaveTimer.current), [])
|
|
77
|
+
const release = useCallback(() => {
|
|
78
|
+
if (!closeOnLeave) return
|
|
79
|
+
clearTimeout(leaveTimer.current)
|
|
80
|
+
leaveTimer.current = setTimeout(onClose, 150)
|
|
81
|
+
}, [closeOnLeave, onClose])
|
|
82
|
+
useEffect(() => () => clearTimeout(leaveTimer.current), [])
|
|
83
|
+
const portalled = Boolean(anchor || position)
|
|
84
|
+
/** Where the menu actually goes — measured against the viewport after a
|
|
85
|
+
* hidden provisional render, so it is never covered and never cut off. */
|
|
86
|
+
const [placed, setPlaced] = useState<{ left: number; top?: number; bottom?: number; maxHeight: number } | null>(null)
|
|
39
87
|
|
|
40
88
|
useEffect(() => {
|
|
41
89
|
const onDown = (event: MouseEvent) => {
|
|
@@ -52,24 +100,158 @@ export function Menu({ onClose, position, children, className }: MenuProps) {
|
|
|
52
100
|
}
|
|
53
101
|
}, [onClose])
|
|
54
102
|
|
|
55
|
-
|
|
103
|
+
// Callers build `position` inline every render; depending on its
|
|
104
|
+
// coordinates rather than the object keeps the effect from re-running
|
|
105
|
+
// (and re-placing) on every parent render.
|
|
106
|
+
const posLeft = position && 'left' in position ? position.left : undefined
|
|
107
|
+
const posRight = position && 'right' in position ? position.right : undefined
|
|
108
|
+
const posTop = position?.top
|
|
109
|
+
|
|
110
|
+
useLayoutEffect(() => {
|
|
111
|
+
if (!portalled || !ref.current) return
|
|
112
|
+
const viewport = { width: window.innerWidth, height: window.innerHeight }
|
|
113
|
+
const menu = ref.current
|
|
114
|
+
if (anchor) {
|
|
115
|
+
const rect = anchor instanceof Element ? anchor.getBoundingClientRect() : anchor
|
|
116
|
+
const left = align === 'right' ? rect.right - menu.offsetWidth : rect.left
|
|
117
|
+
setPlaced(
|
|
118
|
+
fitMenu({
|
|
119
|
+
anchor: { left, top: rect.top, bottom: rect.bottom },
|
|
120
|
+
menu: { width: menu.offsetWidth, contentHeight: menu.scrollHeight },
|
|
121
|
+
viewport,
|
|
122
|
+
}),
|
|
123
|
+
)
|
|
124
|
+
} else if (posTop !== undefined) {
|
|
125
|
+
const left = posLeft ?? viewport.width - (posRight ?? 0) - menu.offsetWidth
|
|
126
|
+
setPlaced(clampBox({ box: { left, top: posTop, width: menu.offsetWidth, height: menu.offsetHeight }, viewport }))
|
|
127
|
+
}
|
|
128
|
+
}, [portalled, anchor, align, posLeft, posRight, posTop])
|
|
129
|
+
|
|
130
|
+
/* An anchored menu is placed against its trigger's rect, and a resize or a
|
|
131
|
+
page scroll moves the trigger out from under it — close, as Select does.
|
|
132
|
+
A scroll INSIDE the menu is its own capped list working; leave those. */
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
if (!anchor) return
|
|
135
|
+
const onScroll = (event: Event) => {
|
|
136
|
+
if (event.target instanceof Node && ref.current?.contains(event.target)) return
|
|
137
|
+
onClose()
|
|
138
|
+
}
|
|
139
|
+
window.addEventListener('resize', onClose)
|
|
140
|
+
window.addEventListener('scroll', onScroll, true)
|
|
141
|
+
return () => {
|
|
142
|
+
window.removeEventListener('resize', onClose)
|
|
143
|
+
window.removeEventListener('scroll', onScroll, true)
|
|
144
|
+
}
|
|
145
|
+
}, [anchor, onClose])
|
|
146
|
+
|
|
147
|
+
const style: CSSProperties | undefined = portalled
|
|
148
|
+
? placed
|
|
149
|
+
? { left: placed.left, top: placed.top, bottom: placed.bottom, maxHeight: placed.maxHeight }
|
|
150
|
+
: // The provisional render: measured by the layout effect, never seen.
|
|
151
|
+
{ left: 0, top: 0, visibility: 'hidden' }
|
|
152
|
+
: undefined
|
|
56
153
|
const node = (
|
|
57
|
-
<
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
154
|
+
<MenuHoverContext.Provider value={{ hold, release }}>
|
|
155
|
+
<div
|
|
156
|
+
ref={ref}
|
|
157
|
+
role="menu"
|
|
158
|
+
data-interactive
|
|
159
|
+
className={cn(
|
|
160
|
+
'z-50 flex min-w-[180px] flex-col rounded-lg border border-border-default bg-bg-elevated p-2 shadow-lg',
|
|
161
|
+
portalled ? 'fixed overflow-y-auto' : 'absolute right-0 top-full mt-1',
|
|
162
|
+
className,
|
|
163
|
+
)}
|
|
164
|
+
style={style}
|
|
165
|
+
onClick={(event) => event.stopPropagation()}
|
|
166
|
+
onMouseEnter={closeOnLeave ? hold : undefined}
|
|
167
|
+
onMouseLeave={closeOnLeave ? release : undefined}
|
|
168
|
+
>
|
|
169
|
+
{children}
|
|
170
|
+
</div>
|
|
171
|
+
</MenuHoverContext.Provider>
|
|
172
|
+
)
|
|
173
|
+
return portalled ? createPortal(node, document.body) : node
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* A row that opens another menu beside it — the submenu two Peek menus
|
|
178
|
+
* hand-rolled before this, one of which dropped the ref its edge-flip
|
|
179
|
+
* measured and shipped a submenu cut off by the screen (2026-09-03).
|
|
180
|
+
*
|
|
181
|
+
* Hover-timed like those were: opens at once, closes 150ms after the
|
|
182
|
+
* pointer leaves row and panel both, so the diagonal from row to panel
|
|
183
|
+
* survives. The panel portals to the body and is placed by `fitSubmenu` —
|
|
184
|
+
* right of the row when it fits, left when it does not, never past an edge
|
|
185
|
+
* — so it also escapes whatever container its menu happens to be in.
|
|
186
|
+
*/
|
|
187
|
+
export interface MenuSubProps {
|
|
188
|
+
/** The trigger row's label. */
|
|
189
|
+
label: string
|
|
190
|
+
leading?: ReactNode
|
|
191
|
+
/** Mark the trigger row as holding a current value. */
|
|
192
|
+
selected?: boolean
|
|
193
|
+
/** The submenu's rows. */
|
|
194
|
+
children: ReactNode
|
|
195
|
+
/** On the submenu panel. */
|
|
196
|
+
className?: string
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function MenuSub({ label, leading, selected, children, className }: MenuSubProps) {
|
|
200
|
+
const [open, setOpen] = useState(false)
|
|
201
|
+
const rowRef = useRef<HTMLDivElement>(null)
|
|
202
|
+
const panelRef = useRef<HTMLDivElement>(null)
|
|
203
|
+
const closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
204
|
+
const [placed, setPlaced] = useState<{ left: number; top: number } | null>(null)
|
|
205
|
+
// The panel portals out of the menu's DOM, so hovering it would read as
|
|
206
|
+
// "left the menu" to a closeOnLeave shell — report hover upward instead.
|
|
207
|
+
const menuHover = useContext(MenuHoverContext)
|
|
208
|
+
|
|
209
|
+
const enter = () => {
|
|
210
|
+
clearTimeout(closeTimer.current)
|
|
211
|
+
menuHover?.hold()
|
|
212
|
+
setOpen(true)
|
|
213
|
+
}
|
|
214
|
+
const leave = () => {
|
|
215
|
+
closeTimer.current = setTimeout(() => setOpen(false), 150)
|
|
216
|
+
menuHover?.release()
|
|
217
|
+
}
|
|
218
|
+
useEffect(() => () => clearTimeout(closeTimer.current), [])
|
|
219
|
+
|
|
220
|
+
useLayoutEffect(() => {
|
|
221
|
+
if (!open || !rowRef.current || !panelRef.current) {
|
|
222
|
+
setPlaced(null)
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
const row = rowRef.current.getBoundingClientRect()
|
|
226
|
+
setPlaced(
|
|
227
|
+
fitSubmenu({
|
|
228
|
+
row: { left: row.left, right: row.right, top: row.top },
|
|
229
|
+
panel: { width: panelRef.current.offsetWidth, height: panelRef.current.offsetHeight },
|
|
230
|
+
viewport: { width: window.innerWidth, height: window.innerHeight },
|
|
231
|
+
}),
|
|
232
|
+
)
|
|
233
|
+
}, [open])
|
|
234
|
+
|
|
235
|
+
return (
|
|
236
|
+
<div ref={rowRef} onMouseEnter={enter} onMouseLeave={leave}>
|
|
237
|
+
<MenuItem label={label} leading={leading} selected={selected} submenu />
|
|
238
|
+
{open &&
|
|
239
|
+
createPortal(
|
|
240
|
+
<div
|
|
241
|
+
ref={panelRef}
|
|
242
|
+
role="menu"
|
|
243
|
+
data-interactive
|
|
244
|
+
className={cn('fixed z-50 flex w-[160px] flex-col rounded-lg border border-border-default bg-bg-elevated p-2 shadow-lg', className)}
|
|
245
|
+
style={placed ?? { left: 0, top: 0, visibility: 'hidden' }}
|
|
246
|
+
onMouseEnter={enter}
|
|
247
|
+
onMouseLeave={leave}
|
|
248
|
+
>
|
|
249
|
+
{children}
|
|
250
|
+
</div>,
|
|
251
|
+
document.body,
|
|
252
|
+
)}
|
|
70
253
|
</div>
|
|
71
254
|
)
|
|
72
|
-
return position ? createPortal(node, document.body) : node
|
|
73
255
|
}
|
|
74
256
|
|
|
75
257
|
export interface MenuItemProps extends Omit<ComponentPropsWithRef<'button'>, 'children'> {
|