@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Estiva's design tokens (the contract) and a small set of primitives (a convenience) for every Estiva app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -42,12 +42,16 @@
42
42
  "@storybook/addon-docs": "^10.5.10",
43
43
  "@storybook/react-vite": "^10.5.10",
44
44
  "@tabler/icons-react": "^3.41.1",
45
+ "@testing-library/dom": "^10.4.1",
46
+ "@testing-library/react": "^16.3.3",
47
+ "@testing-library/user-event": "^14.6.7",
45
48
  "@types/node": "^24.12.0",
46
49
  "@types/react": "^19.2.14",
47
50
  "@types/react-dom": "^19.2.3",
48
51
  "@vitejs/plugin-react": "^6.0.1",
49
52
  "autoprefixer": "^10.4.27",
50
53
  "esbuild": "^0.28.0",
54
+ "jsdom": "^30.0.1",
51
55
  "postcss": "^8.5.8",
52
56
  "react": "^19.2.4",
53
57
  "react-dom": "^19.2.4",
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
- <div className="flex h-full min-h-0 flex-col bg-bg-base text-text-primary">
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
- className="w-full h-full flex items-center justify-center font-semibold"
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
@@ -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 sits on the Avatar itself: the old extra wrapper was a 24px
12
- * window minus a 2px border with a fixed 24px Avatar inside, so
13
- * overflow-hidden clipped 4px of face and the right edge of the initials
14
- * with it, which is how "CO" rendered as "C(". One box, whole letters.
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 pr-2">
48
+ <div className="flex items-center" style={{ paddingRight: overlap }}>
30
49
  {visible.map((member, i) => (
31
- <Avatar
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
- size={24}
34
- name={member.name}
35
- src={member.picture}
36
- alt={member.name}
37
- className="-mr-2 relative border-2 border-bg-surface"
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] max-h-[240px] overflow-y-auto bg-bg-elevated border border-border-default rounded-lg shadow-lg"
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
- top: anchorRect.bottom + 4,
222
- left: anchorRect.left,
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
  >
@@ -0,0 +1,117 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * The label names its control — SHA-17.
4
+ *
5
+ * It did not. `Field` rendered a `<label>` with no `htmlFor` and the control as
6
+ * its *sibling*, so there was neither an explicit nor an implicit association:
7
+ * a screen reader announced an unlabelled edit box, and clicking the label
8
+ * focused nothing.
9
+ *
10
+ * These are the first DOM tests in this package, and they exist because the
11
+ * defect cannot be seen without a document. It surfaced in a *consumer's* test
12
+ * — `getByLabelText(/title/i)` in Peek's ActionFormPanel suite, failing with
13
+ * "Found a label with the text of: /title/i, however no form control was found
14
+ * associated to that label". That message is the bug stated precisely, and the
15
+ * consumer worked around it by querying by role. The guard belongs here, where
16
+ * the next primitive added under Field will meet it.
17
+ */
18
+ import { afterEach, describe, expect, it } from 'vitest'
19
+ import { cleanup, render, screen } from '@testing-library/react'
20
+ import { Field } from './Field'
21
+ import { TextInput } from './TextInput'
22
+ import { Textarea } from './Textarea'
23
+
24
+ // Testing Library registers its own cleanup only when vitest runs with
25
+ // `globals: true`, and this package does not — so the first two tests passed,
26
+ // the third found two elements labelled "Title", and the failure read like a
27
+ // bug in the component.
28
+ afterEach(cleanup)
29
+
30
+ describe('Field', () => {
31
+ it('names a TextInput, so it is reachable by its label', () => {
32
+ render(
33
+ <Field label="Title">
34
+ <TextInput defaultValue="" />
35
+ </Field>,
36
+ )
37
+ expect(screen.getByLabelText('Title').tagName).toBe('INPUT')
38
+ })
39
+
40
+ it('names a Textarea too', () => {
41
+ render(
42
+ <Field label="Description">
43
+ <Textarea defaultValue="" />
44
+ </Field>,
45
+ )
46
+ expect(screen.getByLabelText('Description').tagName).toBe('TEXTAREA')
47
+ })
48
+
49
+ it('points the label at the control it wraps, not at some other field', () => {
50
+ // Two Fields on one form is the case a single hardcoded id gets wrong, and
51
+ // `useId` is what makes it right. Asserted because "it works with one
52
+ // field" is the version of this that ships broken.
53
+ const { container } = render(
54
+ <form>
55
+ <Field label="Title">
56
+ <TextInput defaultValue="" />
57
+ </Field>
58
+ <Field label="Description">
59
+ <Textarea defaultValue="" />
60
+ </Field>
61
+ </form>,
62
+ )
63
+ const labels = [...container.querySelectorAll('label')]
64
+ const ids = labels.map((l) => l.getAttribute('for'))
65
+ expect(ids.filter(Boolean)).toHaveLength(2)
66
+ expect(new Set(ids).size).toBe(2)
67
+ expect(screen.getByLabelText('Title')).toBe(container.querySelector(`#${CSS.escape(ids[0]!)}`))
68
+ expect(screen.getByLabelText('Description')).toBe(container.querySelector(`#${CSS.escape(ids[1]!)}`))
69
+ })
70
+
71
+ it('wins over an id the caller put on the control, rather than breaking the pair', () => {
72
+ // The first version of this asserted the opposite — that a control keeps
73
+ // its own id inside a Field — and it failed with this ticket's own error
74
+ // message, because the label went on pointing at the generated id. Two
75
+ // halves of one association cannot be set from two places.
76
+ //
77
+ // So the Field wins, and `htmlFor` below is how a caller chooses. Silently
78
+ // overriding an id is a smaller surprise than silently unlabelling a
79
+ // control, and only one of the two is invisible until somebody uses a
80
+ // screen reader.
81
+ render(
82
+ <Field label="Title">
83
+ <TextInput id="chosen-by-the-caller" defaultValue="" />
84
+ </Field>,
85
+ )
86
+ const input = screen.getByLabelText('Title')
87
+ expect(input.tagName).toBe('INPUT')
88
+ expect(input.id).not.toBe('chosen-by-the-caller')
89
+ })
90
+
91
+ it('lets the Field be told the id instead, for the same reason', () => {
92
+ render(
93
+ <Field label="Title" htmlFor="named-outside">
94
+ <TextInput defaultValue="" />
95
+ </Field>,
96
+ )
97
+ expect(screen.getByLabelText('Title').id).toBe('named-outside')
98
+ })
99
+
100
+ it('leaves a control outside a Field alone', () => {
101
+ // `useFieldControlId` returns undefined outside a provider, so nothing
102
+ // acquires a stray id — a control with an id it did not ask for is its own
103
+ // small bug.
104
+ const { container } = render(<TextInput defaultValue="" />)
105
+ expect(container.querySelector('input')?.getAttribute('id')).toBeNull()
106
+ })
107
+
108
+ it('still marks a required field, which was the only thing it did before', () => {
109
+ render(
110
+ <Field label="Title" required>
111
+ <TextInput defaultValue="" />
112
+ </Field>,
113
+ )
114
+ expect(screen.getByText('*')).toBeTruthy()
115
+ expect(screen.getByLabelText(/Title/).tagName).toBe('INPUT')
116
+ })
117
+ })
package/src/Field.tsx CHANGED
@@ -1,24 +1,78 @@
1
- import type { ReactNode } from 'react'
1
+ import { createContext, useContext, useId, type ReactNode } from 'react'
2
2
 
3
3
  /**
4
- * Peek's Field (2026-08-28), verbatim: a label over a control, 8px apart,
5
- * with a red asterisk when required. The label is the `input-label` type
6
- * token as a plain class, never merged.
4
+ * The id of the control this Field labels.
5
+ *
6
+ * **A control has to opt in by calling `useFieldControlId`.** The automatic
7
+ * alternative is nesting the control inside the `<label>`, which associates
8
+ * anything by construction and needs no cooperation — and it is not used here,
9
+ * because a control that is *both* nested in a label and named by its `htmlFor`
10
+ * can receive two activations from one click. That is a real hazard for a
11
+ * checkbox and a latent one for everything else, and this library has a
12
+ * `Checkbox`.
13
+ *
14
+ * So: one explicit mechanism, and a test that pins it for every primitive that
15
+ * uses it (`Field.test.tsx`). A new primitive that renders a labelable element
16
+ * calls this hook and spreads the result; one that does not is unlabelled, and
17
+ * the test is where that gets noticed.
18
+ */
19
+ const FieldControlIdContext = createContext<string | undefined>(undefined)
20
+
21
+ /**
22
+ * The id a surrounding `Field` wants this control to have, falling back to the
23
+ * caller's own outside one.
24
+ *
25
+ * **Inside a Field, the Field wins**, which is the opposite of what I wrote
26
+ * first and the test caught within the minute. Letting a control's own `id`
27
+ * take precedence leaves the label's `htmlFor` pointing at the id the Field
28
+ * generated and the control answering to a different one — which is this
29
+ * ticket's defect exactly, reproduced by the fix for it, and it fails with the
30
+ * same message: *"Found a label with the text of: Title, however no form
31
+ * control was found associated to that label."*
32
+ *
33
+ * A caller who needs to choose the id names it on the Field (`htmlFor`), which
34
+ * is the one place that can set both halves. Outside a Field there is nothing
35
+ * to disagree with, so the caller's id is used.
36
+ */
37
+ export function useFieldControlId(ownId?: string): string | undefined {
38
+ const fromField = useContext(FieldControlIdContext)
39
+ return fromField ?? ownId
40
+ }
41
+
42
+ /**
43
+ * Peek's Field (2026-08-28): a label over a control, 8px apart, with a red
44
+ * asterisk when required. The label is the `input-label` type token as a plain
45
+ * class, never merged.
46
+ *
47
+ * The label names its control (SHA-17). It did not, and the control was a
48
+ * *sibling* of the label with no `htmlFor`, so there was neither an explicit
49
+ * nor an implicit association: a screen reader announced an unlabelled edit
50
+ * box and clicking the label focused nothing.
7
51
  */
8
52
  export interface FieldProps {
9
53
  label: string
10
54
  required?: boolean
55
+ /**
56
+ * Override the generated id. Only needed when something outside has to name
57
+ * the control — an `aria-describedby` elsewhere, or a form library.
58
+ */
59
+ htmlFor?: string
11
60
  children: ReactNode
12
61
  }
13
62
 
14
- export function Field({ label, required = false, children }: FieldProps) {
63
+ export function Field({ label, required = false, htmlFor, children }: FieldProps) {
64
+ const generated = useId()
65
+ const id = htmlFor ?? generated
15
66
  return (
16
67
  <div className="flex flex-col gap-2">
17
- <label className={`text-input-label text-text-primary${required ? ' flex items-center' : ''}`}>
68
+ <label
69
+ htmlFor={id}
70
+ className={`text-input-label text-text-primary${required ? ' flex items-center' : ''}`}
71
+ >
18
72
  {label}
19
73
  {required && <span className="text-error-default ml-0.5">*</span>}
20
74
  </label>
21
- {children}
75
+ <FieldControlIdContext.Provider value={id}>{children}</FieldControlIdContext.Provider>
22
76
  </div>
23
77
  )
24
78
  }
@@ -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
+ })
@@ -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) => (