@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@estiva-app/ui",
|
|
3
|
-
"version": "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
|
-
|
|
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
|
>
|
|
@@ -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
|
|
1
|
+
import { createContext, useContext, useId, type ReactNode } from 'react'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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
|
}
|
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) => (
|