@estiva-app/ui 0.3.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/Field.d.ts +32 -5
- package/dist/Field.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/TextInput.d.ts.map +1 -1
- package/dist/Textarea.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 +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +278 -103
- package/dist/index.js.map +4 -4
- package/package.json +5 -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/Field.test.tsx +117 -0
- package/src/Field.tsx +61 -7
- 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/TextInput.tsx +5 -1
- package/src/Textarea.tsx +5 -1
- package/src/Tooltip.tsx +7 -1
- package/src/fit.ts +94 -0
- package/src/index.ts +3 -2
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'> {
|
package/src/Select.fit.test.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import { fitMenu } from './Select'
|
|
3
3
|
|
|
4
|
+
/** Select's calls carry its option-list cap (the old max-h-72); the cap is
|
|
5
|
+
* the caller's now, so these pin it alongside the geometry. */
|
|
6
|
+
const fitSelect = (args: Omit<Parameters<typeof fitMenu>[0], 'cap'>) => fitMenu({ ...args, cap: 288 })
|
|
7
|
+
|
|
4
8
|
/**
|
|
5
9
|
* The Select menu's viewport geometry (Katerina, 2026-09-01): the files-panel
|
|
6
10
|
* picker was cut off at the right edge, its tail ran past the bottom of the
|
|
@@ -11,7 +15,7 @@ const viewport = { width: 1280, height: 800 }
|
|
|
11
15
|
|
|
12
16
|
describe('fitMenu', () => {
|
|
13
17
|
it('leaves a comfortable menu exactly where the trigger put it', () => {
|
|
14
|
-
const fit =
|
|
18
|
+
const fit = fitSelect({
|
|
15
19
|
anchor: { left: 100, top: 200, bottom: 232 },
|
|
16
20
|
menu: { width: 240, contentHeight: 180 },
|
|
17
21
|
viewport,
|
|
@@ -23,7 +27,7 @@ describe('fitMenu', () => {
|
|
|
23
27
|
})
|
|
24
28
|
|
|
25
29
|
it('clamps a menu that would run off the right edge (the files-panel cut)', () => {
|
|
26
|
-
const fit =
|
|
30
|
+
const fit = fitSelect({
|
|
27
31
|
anchor: { left: 1200, top: 200, bottom: 232 },
|
|
28
32
|
menu: { width: 340, contentHeight: 180 },
|
|
29
33
|
viewport,
|
|
@@ -33,7 +37,7 @@ describe('fitMenu', () => {
|
|
|
33
37
|
})
|
|
34
38
|
|
|
35
39
|
it('never pushes a menu past the LEFT edge either', () => {
|
|
36
|
-
const fit =
|
|
40
|
+
const fit = fitSelect({
|
|
37
41
|
anchor: { left: -20, top: 200, bottom: 232 },
|
|
38
42
|
menu: { width: 240, contentHeight: 180 },
|
|
39
43
|
viewport,
|
|
@@ -42,7 +46,7 @@ describe('fitMenu', () => {
|
|
|
42
46
|
})
|
|
43
47
|
|
|
44
48
|
it('caps height to the room below, so the last option is never off screen', () => {
|
|
45
|
-
const fit =
|
|
49
|
+
const fit = fitSelect({
|
|
46
50
|
anchor: { left: 100, top: 560, bottom: 592 },
|
|
47
51
|
menu: { width: 240, contentHeight: 200 },
|
|
48
52
|
viewport,
|
|
@@ -56,7 +60,7 @@ describe('fitMenu', () => {
|
|
|
56
60
|
})
|
|
57
61
|
|
|
58
62
|
it('opens upward when the room above is better (the low trigger)', () => {
|
|
59
|
-
const fit =
|
|
63
|
+
const fit = fitSelect({
|
|
60
64
|
anchor: { left: 100, top: 700, bottom: 732 },
|
|
61
65
|
menu: { width: 240, contentHeight: 400 },
|
|
62
66
|
viewport,
|
|
@@ -67,7 +71,7 @@ describe('fitMenu', () => {
|
|
|
67
71
|
})
|
|
68
72
|
|
|
69
73
|
it('stays below when the room below is bad but the room above is worse', () => {
|
|
70
|
-
const fit =
|
|
74
|
+
const fit = fitSelect({
|
|
71
75
|
anchor: { left: 100, top: 60, bottom: 92 },
|
|
72
76
|
menu: { width: 240, contentHeight: 400 },
|
|
73
77
|
viewport,
|
|
@@ -78,7 +82,7 @@ describe('fitMenu', () => {
|
|
|
78
82
|
})
|
|
79
83
|
|
|
80
84
|
it('keeps a 120px floor in a cramped corner — scrollable beats invisible', () => {
|
|
81
|
-
const fit =
|
|
85
|
+
const fit = fitSelect({
|
|
82
86
|
anchor: { left: 100, top: 740, bottom: 772 },
|
|
83
87
|
menu: { width: 240, contentHeight: 400 },
|
|
84
88
|
viewport: { width: 1280, height: 800 },
|
|
@@ -87,7 +91,7 @@ describe('fitMenu', () => {
|
|
|
87
91
|
})
|
|
88
92
|
|
|
89
93
|
it('caps a long list at 288 with plenty of room — the old max-h-72', () => {
|
|
90
|
-
const fit =
|
|
94
|
+
const fit = fitSelect({
|
|
91
95
|
anchor: { left: 100, top: 100, bottom: 132 },
|
|
92
96
|
menu: { width: 240, contentHeight: 900 },
|
|
93
97
|
viewport,
|
package/src/Select.tsx
CHANGED
|
@@ -38,38 +38,11 @@ export interface SelectProps {
|
|
|
38
38
|
className?: string
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
* at 288px (the old `max-h-72`) but never taller than the space it opens
|
|
47
|
-
* into; when the room below the anchor is smaller than both the content and
|
|
48
|
-
* the room above, the menu opens UPWARD (anchored to the trigger's top via
|
|
49
|
-
* `bottom`). The 120px floor keeps a menu usable even in a cramped corner —
|
|
50
|
-
* scrollable beats invisible.
|
|
51
|
-
*/
|
|
52
|
-
export function fitMenu({
|
|
53
|
-
anchor,
|
|
54
|
-
menu,
|
|
55
|
-
viewport,
|
|
56
|
-
}: {
|
|
57
|
-
anchor: { left: number; top: number; bottom: number }
|
|
58
|
-
menu: { width: number; contentHeight: number }
|
|
59
|
-
viewport: { width: number; height: number }
|
|
60
|
-
}): { left: number; top?: number; bottom?: number; maxHeight: number } {
|
|
61
|
-
const MARGIN = 8
|
|
62
|
-
const GAP = 4
|
|
63
|
-
const CAP = 288
|
|
64
|
-
const left = Math.max(MARGIN, Math.min(anchor.left, viewport.width - menu.width - MARGIN))
|
|
65
|
-
const below = viewport.height - anchor.bottom - GAP - MARGIN
|
|
66
|
-
const above = anchor.top - GAP - MARGIN
|
|
67
|
-
const openUp = below < Math.min(menu.contentHeight, CAP) && above > below
|
|
68
|
-
const maxHeight = Math.max(Math.min(CAP, openUp ? above : below), 120)
|
|
69
|
-
return openUp
|
|
70
|
-
? { left, bottom: viewport.height - anchor.top + GAP, maxHeight }
|
|
71
|
-
: { left, top: anchor.bottom + GAP, maxHeight }
|
|
72
|
-
}
|
|
41
|
+
// The geometry lives in fit.ts now (2026-09-03) — shared with the Menu
|
|
42
|
+
// shell, so a cut-off surface is fixed once. Re-exported because this is
|
|
43
|
+
// where it grew up and its test still names it by this address.
|
|
44
|
+
import { fitMenu } from './fit'
|
|
45
|
+
export { fitMenu }
|
|
73
46
|
|
|
74
47
|
export function Select({ value, onChange, options, size = 'default', ariaLabel, placeholder = 'Select…', disabled, className }: SelectProps) {
|
|
75
48
|
const id = useId()
|
|
@@ -119,6 +92,9 @@ export function Select({ value, onChange, options, size = 'default', ariaLabel,
|
|
|
119
92
|
anchor: { left: rect.left, top: rect.top, bottom: rect.bottom },
|
|
120
93
|
menu: { width: menuRef.current.offsetWidth, contentHeight: menuRef.current.scrollHeight },
|
|
121
94
|
viewport: { width: window.innerWidth, height: window.innerHeight },
|
|
95
|
+
// The option list keeps its classic height (the old max-h-72); a menu
|
|
96
|
+
// panel passes no cap and stands as tall as the room it opens into.
|
|
97
|
+
cap: 288,
|
|
122
98
|
}),
|
|
123
99
|
)
|
|
124
100
|
}, [rect, options.length])
|
package/src/TextInput.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { forwardRef, type InputHTMLAttributes } from 'react'
|
|
2
2
|
import { cn } from './cn'
|
|
3
|
+
import { useFieldControlId } from './Field'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Peek's TextInput (2026-08-28): the inset field with a 8px radius, 14px
|
|
@@ -10,10 +11,13 @@ import { cn } from './cn'
|
|
|
10
11
|
*/
|
|
11
12
|
export type TextInputProps = InputHTMLAttributes<HTMLInputElement>
|
|
12
13
|
|
|
13
|
-
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function TextInput({ className, type = 'text', ...props }, ref) {
|
|
14
|
+
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function TextInput({ className, type = 'text', id, ...props }, ref) {
|
|
15
|
+
// A surrounding Field names this control (SHA-17); an explicit id still wins.
|
|
16
|
+
const controlId = useFieldControlId(id)
|
|
14
17
|
return (
|
|
15
18
|
<input
|
|
16
19
|
ref={ref}
|
|
20
|
+
id={controlId}
|
|
17
21
|
type={type}
|
|
18
22
|
className={cn(
|
|
19
23
|
'bg-bg-inset border border-border-default focus:border-border-focus rounded-lg px-3 py-2',
|
package/src/Textarea.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { forwardRef, type TextareaHTMLAttributes } from 'react'
|
|
2
2
|
import { cn } from './cn'
|
|
3
|
+
import { useFieldControlId } from './Field'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Peek's Textarea (2026-08-28), verbatim: TextInput's look on a textarea
|
|
@@ -7,10 +8,13 @@ import { cn } from './cn'
|
|
|
7
8
|
*/
|
|
8
9
|
export type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement>
|
|
9
10
|
|
|
10
|
-
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea({ className, ...props }, ref) {
|
|
11
|
+
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea({ className, id, ...props }, ref) {
|
|
12
|
+
// A surrounding Field names this control (SHA-17); an explicit id still wins.
|
|
13
|
+
const controlId = useFieldControlId(id)
|
|
11
14
|
return (
|
|
12
15
|
<textarea
|
|
13
16
|
ref={ref}
|
|
17
|
+
id={controlId}
|
|
14
18
|
className={cn(
|
|
15
19
|
'bg-bg-inset border border-border-default focus:border-border-focus rounded-lg px-3 py-2',
|
|
16
20
|
'text-[14px] leading-[1.4] font-normal text-text-primary placeholder:text-text-muted',
|
package/src/Tooltip.tsx
CHANGED
|
@@ -47,7 +47,13 @@ export function WithTooltip({ label, placement = 'top', wrapperClassName, childr
|
|
|
47
47
|
if (!trigger || !tip) return
|
|
48
48
|
const triggerRect = trigger.getBoundingClientRect()
|
|
49
49
|
const tipRect = tip.getBoundingClientRect()
|
|
50
|
-
|
|
50
|
+
let top = placement === 'bottom' ? triggerRect.bottom + GAP : triggerRect.top - tipRect.height - GAP
|
|
51
|
+
// The vertical axis flips and clamps like the horizontal one always has
|
|
52
|
+
// (2026-09-03): a `top` tooltip on a control near the viewport's top edge
|
|
53
|
+
// was the one floating surface left that could leave the screen.
|
|
54
|
+
if (placement === 'top' && top < VIEWPORT_PAD) top = triggerRect.bottom + GAP
|
|
55
|
+
else if (placement === 'bottom' && top + tipRect.height > window.innerHeight - VIEWPORT_PAD) top = triggerRect.top - tipRect.height - GAP
|
|
56
|
+
top = Math.max(VIEWPORT_PAD, Math.min(top, window.innerHeight - tipRect.height - VIEWPORT_PAD))
|
|
51
57
|
let left = triggerRect.left + triggerRect.width / 2 - tipRect.width / 2
|
|
52
58
|
left = Math.max(VIEWPORT_PAD, Math.min(left, window.innerWidth - tipRect.width - VIEWPORT_PAD))
|
|
53
59
|
setStyle({ position: 'fixed', top, left, zIndex: 9999, pointerEvents: 'none', visibility: 'visible' })
|
package/src/fit.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Viewport geometry for everything that floats — pure, so the decisions are
|
|
3
|
+
* testable without a browser.
|
|
4
|
+
*
|
|
5
|
+
* One module, because the failure it prevents keeps recurring one surface at
|
|
6
|
+
* a time: the files-panel picker was cut off at the right edge (fixed in
|
|
7
|
+
* Select, 2026-09-01), then a reply menu's highlight submenu was cut off the
|
|
8
|
+
* same way and the identity menu vanished under a z-indexed header
|
|
9
|
+
* (2026-09-03). A menu that hand-rolls its own geometry is a menu waiting to
|
|
10
|
+
* be the next screenshot; the shell calls these instead.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const MARGIN = 8
|
|
14
|
+
const GAP = 4
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Where a menu of this size goes, given its anchor and the viewport.
|
|
18
|
+
*
|
|
19
|
+
* Left is clamped inside the viewport with an 8px margin. Height is capped
|
|
20
|
+
* at `cap` — Select's option list keeps the old `max-h-72` (288), a menu
|
|
21
|
+
* panel passes none and uses all the room it opens into, so it scrolls only
|
|
22
|
+
* when the screen truly has no space (the identity panel got Select's cap
|
|
23
|
+
* by accident and grew a scrollbar at full height, 2026-09-03) — but never
|
|
24
|
+
* taller than that room; when the room below the anchor is smaller than
|
|
25
|
+
* both the content and the room above, the menu opens UPWARD (anchored to
|
|
26
|
+
* the trigger's top via `bottom`). The 120px floor keeps a menu usable even
|
|
27
|
+
* in a cramped corner — scrollable beats invisible.
|
|
28
|
+
*/
|
|
29
|
+
export function fitMenu({
|
|
30
|
+
anchor,
|
|
31
|
+
menu,
|
|
32
|
+
viewport,
|
|
33
|
+
cap = Number.POSITIVE_INFINITY,
|
|
34
|
+
}: {
|
|
35
|
+
anchor: { left: number; top: number; bottom: number }
|
|
36
|
+
menu: { width: number; contentHeight: number }
|
|
37
|
+
viewport: { width: number; height: number }
|
|
38
|
+
/** Tallest the menu may stand even with room to spare. Absent: the room is the only limit. */
|
|
39
|
+
cap?: number
|
|
40
|
+
}): { left: number; top?: number; bottom?: number; maxHeight: number } {
|
|
41
|
+
const left = Math.max(MARGIN, Math.min(anchor.left, viewport.width - menu.width - MARGIN))
|
|
42
|
+
const below = viewport.height - anchor.bottom - GAP - MARGIN
|
|
43
|
+
const above = anchor.top - GAP - MARGIN
|
|
44
|
+
const openUp = below < Math.min(menu.contentHeight, cap) && above > below
|
|
45
|
+
const maxHeight = Math.max(Math.min(cap, openUp ? above : below), 120)
|
|
46
|
+
return openUp
|
|
47
|
+
? { left, bottom: viewport.height - anchor.top + GAP, maxHeight }
|
|
48
|
+
: { left, top: anchor.bottom + GAP, maxHeight }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Keep a box a caller has already placed fully on screen.
|
|
53
|
+
*
|
|
54
|
+
* For the menus whose caller measured a rect and chose the corner itself
|
|
55
|
+
* (`position`): the caller's math stays authoritative, but its result can no
|
|
56
|
+
* longer land off screen — it is slid inside the viewport, never flipped,
|
|
57
|
+
* and capped to the viewport's height with the same 120px floor as fitMenu.
|
|
58
|
+
*/
|
|
59
|
+
export function clampBox({
|
|
60
|
+
box,
|
|
61
|
+
viewport,
|
|
62
|
+
}: {
|
|
63
|
+
box: { left: number; top: number; width: number; height: number }
|
|
64
|
+
viewport: { width: number; height: number }
|
|
65
|
+
}): { left: number; top: number; maxHeight: number } {
|
|
66
|
+
const left = Math.max(MARGIN, Math.min(box.left, viewport.width - box.width - MARGIN))
|
|
67
|
+
const maxHeight = Math.max(Math.min(box.height, viewport.height - 2 * MARGIN), 120)
|
|
68
|
+
const top = Math.max(MARGIN, Math.min(box.top, viewport.height - maxHeight - MARGIN))
|
|
69
|
+
return { left, top, maxHeight }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Where a submenu goes, given its trigger row and the viewport.
|
|
74
|
+
*
|
|
75
|
+
* To the RIGHT of the row when it fits, flipped to the LEFT when it does not
|
|
76
|
+
* — the measured flip two menus hand-rolled and one of them broke (the copy
|
|
77
|
+
* dropped the ref its measurement read, so it measured nothing and always
|
|
78
|
+
* opened rightward, off the screen). Top-aligned with the row, slid up when
|
|
79
|
+
* the tail would run past the bottom edge.
|
|
80
|
+
*/
|
|
81
|
+
export function fitSubmenu({
|
|
82
|
+
row,
|
|
83
|
+
panel,
|
|
84
|
+
viewport,
|
|
85
|
+
}: {
|
|
86
|
+
row: { left: number; right: number; top: number }
|
|
87
|
+
panel: { width: number; height: number }
|
|
88
|
+
viewport: { width: number; height: number }
|
|
89
|
+
}): { left: number; top: number } {
|
|
90
|
+
const fitsRight = row.right + GAP + panel.width <= viewport.width - MARGIN
|
|
91
|
+
const left = Math.max(MARGIN, fitsRight ? row.right + GAP : row.left - GAP - panel.width)
|
|
92
|
+
const top = Math.max(MARGIN, Math.min(row.top, viewport.height - panel.height - MARGIN))
|
|
93
|
+
return { left, top }
|
|
94
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -20,7 +20,7 @@ export { ChipInput, InputChip, type ChipInputOption, type ChipInputProps, type I
|
|
|
20
20
|
export { IconButton, type IconButtonProps, type IconButtonVariant } from './IconButton'
|
|
21
21
|
export { IdentityMenu, IdentityPanel, type Identity, type IdentityMenuProps, type IdentityPanelProps } from './IdentityMenu'
|
|
22
22
|
export { Tooltip, WithTooltip, type TooltipProps, type WithTooltipProps } from './Tooltip'
|
|
23
|
-
export { Field, type FieldProps } from './Field'
|
|
23
|
+
export { Field, useFieldControlId, type FieldProps } from './Field'
|
|
24
24
|
export { Select, type SelectOption, type SelectProps } from './Select'
|
|
25
25
|
export { TextInput, type TextInputProps } from './TextInput'
|
|
26
26
|
export { Textarea, type TextareaProps } from './Textarea'
|
|
@@ -31,7 +31,8 @@ export { EditableText, type EditableTextProps } from './EditableText'
|
|
|
31
31
|
export { EmptyState, type EmptyStateProps } from './EmptyState'
|
|
32
32
|
export { SkeletonBar, SkeletonList, SkeletonRow } from './Skeleton'
|
|
33
33
|
export { Breadcrumb, type BreadcrumbProps, type Crumb } from './Breadcrumb'
|
|
34
|
-
export { EnterHint, Menu, MenuItem, MenuRow, MenuSection, type MenuItemProps, type MenuProps } from './Menu'
|
|
34
|
+
export { EnterHint, Menu, MenuItem, MenuRow, MenuSection, MenuSub, type MenuItemProps, type MenuProps, type MenuSubProps } from './Menu'
|
|
35
|
+
export { clampBox, fitMenu, fitSubmenu } from './fit'
|
|
35
36
|
export { NavItem, type NavItemProps } from './NavItem'
|
|
36
37
|
export { Person, type PersonProps } from './Person'
|
|
37
38
|
export { Rail, type RailProps } from './Rail'
|