@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.
@@ -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 = fitMenu({
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 = fitMenu({
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 = fitMenu({
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 = fitMenu({
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 = fitMenu({
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 = fitMenu({
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 = fitMenu({
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 = fitMenu({
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
- * Where a menu of this size goes, given its anchor and the viewport — pure,
43
- * so the geometry is testable without a browser.
44
- *
45
- * Left is clamped inside the viewport with an 8px margin. Height is capped
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/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
- const top = placement === 'bottom' ? triggerRect.bottom + GAP : triggerRect.top - tipRect.height - GAP
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
@@ -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'