@estiva-app/ui 0.12.0 → 0.12.1
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/index.js +88 -86
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/src/AppShell.mdx +15 -0
- package/src/AppShell.stories.tsx +15 -0
- package/src/AppShell.test.tsx +39 -0
- package/src/AppShell.tsx +22 -7
- package/src/EmptyState.mdx +5 -4
package/package.json
CHANGED
package/src/AppShell.mdx
CHANGED
|
@@ -39,6 +39,21 @@ Layout only: no data, no routes.
|
|
|
39
39
|
|
|
40
40
|
## How
|
|
41
41
|
|
|
42
|
+
**The page contract, in the solid manner.** The frame owns the page's
|
|
43
|
+
scrollbar: the content column is a ScrollArea, and `main` inside it is a
|
|
44
|
+
flex column. A page is a flex child of `main`, and says one of two things:
|
|
45
|
+
|
|
46
|
+
- `flex-1` — it fills the frame and, when taller, scrolls in the frame's
|
|
47
|
+
bar. A list page. (Its empty state centres on its own.)
|
|
48
|
+
- `flex-1 min-h-0 [contain:size]` — it takes exactly the frame's height
|
|
49
|
+
and scrolls inside itself, in regions of its own. A detail page with a
|
|
50
|
+
content column and a rail.
|
|
51
|
+
|
|
52
|
+
Never `h-full`: the frame's content box is *at least* the viewport's
|
|
53
|
+
height and grows with a tall page — which is what lets the bar know the
|
|
54
|
+
page changed — so a percentage height has nothing to resolve against.
|
|
55
|
+
Measured on all four of Ship's pages (2026-09-09).
|
|
56
|
+
|
|
42
57
|
```tsx
|
|
43
58
|
import { AppShell, Banner, IdentityMenu, Rail, RailItem } from '@estiva-app/ui'
|
|
44
59
|
|
package/src/AppShell.stories.tsx
CHANGED
|
@@ -74,6 +74,21 @@ export const Solid: Story = {
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
/** The banner belongs to the content area — at its top, never across the navigation. */
|
|
77
|
+
/** A page taller than the frame: it scrolls in the frame's own bar — the region every page passes through — never a native one. */
|
|
78
|
+
export const SolidScrolls: Story = {
|
|
79
|
+
render: (args) => (
|
|
80
|
+
<AppShell {...args} logo="Estiva" search={<SearchInput shortcut="Ctrl+K" className="w-[290px]" />} identity={identity} nav={sidebar}>
|
|
81
|
+
<div className="flex flex-col gap-px px-6 py-5">
|
|
82
|
+
{Array.from({ length: 60 }, (_, i) => (
|
|
83
|
+
<p key={i} className="rounded-md px-2 py-1.5 text-body-2 text-text-primary">
|
|
84
|
+
Row {i + 1}
|
|
85
|
+
</p>
|
|
86
|
+
))}
|
|
87
|
+
</div>
|
|
88
|
+
</AppShell>
|
|
89
|
+
),
|
|
90
|
+
}
|
|
91
|
+
|
|
77
92
|
export const SolidWithBanner: Story = {
|
|
78
93
|
render: (args) => (
|
|
79
94
|
<AppShell {...args} logo="Estiva" identity={identity} nav={sidebar} banner={<Banner tone="ok">Public key copied.</Banner>}>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* The frame owns the page's scrollbar (2026-09-09): in the solid manner the
|
|
4
|
+
* page lands inside a ScrollArea's viewport — Base UI's `overflow: scroll`
|
|
5
|
+
* box — so no page can scroll in a native bar. Whether the bar takes width,
|
|
6
|
+
* and whether an inner region still scrolls on its own, is measured in
|
|
7
|
+
* Chrome on Ship's four pages.
|
|
8
|
+
*/
|
|
9
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
10
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
11
|
+
import { AppShell } from './AppShell'
|
|
12
|
+
|
|
13
|
+
afterEach(cleanup)
|
|
14
|
+
|
|
15
|
+
describe('AppShell', () => {
|
|
16
|
+
it('puts the page inside the frame’s scrolling region in the solid manner', () => {
|
|
17
|
+
render(
|
|
18
|
+
<AppShell nav={<nav>Nav</nav>}>
|
|
19
|
+
<p>Page</p>
|
|
20
|
+
</AppShell>,
|
|
21
|
+
)
|
|
22
|
+
const main = screen.getByRole('main')
|
|
23
|
+
const viewport = main.closest('[style*="overflow: scroll"]') as HTMLElement | null
|
|
24
|
+
expect(viewport).not.toBeNull()
|
|
25
|
+
expect(viewport!.contains(screen.getByText('Page'))).toBe(true)
|
|
26
|
+
expect(main.className).not.toContain('overflow-y-auto')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('leaves the floating manner’s card as it was', () => {
|
|
30
|
+
render(
|
|
31
|
+
<AppShell variant="floating" nav={<nav>Nav</nav>}>
|
|
32
|
+
<p>Page</p>
|
|
33
|
+
</AppShell>,
|
|
34
|
+
)
|
|
35
|
+
const main = screen.getByRole('main')
|
|
36
|
+
expect(main.className).toContain('overflow-hidden')
|
|
37
|
+
expect(main.closest('[style*="overflow: scroll"]')).toBeNull()
|
|
38
|
+
})
|
|
39
|
+
})
|
package/src/AppShell.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ReactNode } from 'react'
|
|
2
2
|
import { cn } from './cn'
|
|
3
|
+
import { ScrollArea } from './ScrollArea'
|
|
3
4
|
import { TopBar } from './TopBar'
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -63,7 +64,9 @@ export function AppShell({ variant = 'solid', menu, logo, search, identity, bann
|
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
return (
|
|
66
|
-
/* `relative overflow-hidden` is the seal the floating manner already has
|
|
67
|
+
/* `relative overflow-hidden` is the seal the floating manner already has
|
|
68
|
+
(and the ScrollArea's own root is `relative`, so an absolutely placed
|
|
69
|
+
stray inside a page now belongs to the region and scrolls with it),
|
|
67
70
|
and it takes both halves: an absolutely positioned descendant with no
|
|
68
71
|
positioned ancestor belongs to the *viewport*, so a scroll container
|
|
69
72
|
never clips it and the document itself gains its position as scroll
|
|
@@ -76,12 +79,24 @@ export function AppShell({ variant = 'solid', menu, logo, search, identity, bann
|
|
|
76
79
|
{nav}
|
|
77
80
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
|
78
81
|
{banner}
|
|
79
|
-
{/*
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
{/* The frame owns the page's scrollbar (Katerina, 2026-09-09: the
|
|
83
|
+
Issues page still had a native bar — D40 said everywhere, and the
|
|
84
|
+
one place every page passes through had been left out). A
|
|
85
|
+
`ScrollArea` here means no page can forget it. The content box is
|
|
86
|
+
at least the viewport's height and grows with a tall page, so Base
|
|
87
|
+
UI sees its size change (the morning's phantom bar was a content
|
|
88
|
+
box it could not watch); `main` fills it as a flex column. The
|
|
89
|
+
page contract (the AppShell page says it): a page is a flex
|
|
90
|
+
child of `main` — `flex-1` to fill and scroll here; `flex-1
|
|
91
|
+
min-h-0 [contain:size]` to take exactly the frame's height and
|
|
92
|
+
scroll inside itself, as Ship's detail grids do — and the outer
|
|
93
|
+
region, with nothing to scroll, passes the wheel through. Never
|
|
94
|
+
`h-full`: a box that is *at least* the viewport's height gives a
|
|
95
|
+
percentage nothing to resolve against — measured, all four Ship
|
|
96
|
+
pages, 2026-09-09. */}
|
|
97
|
+
<ScrollArea className="min-h-0 flex-1" contentClassName="flex min-h-full flex-col">
|
|
98
|
+
<main className="flex min-w-0 flex-1 flex-col">{children}</main>
|
|
99
|
+
</ScrollArea>
|
|
85
100
|
</div>
|
|
86
101
|
</div>
|
|
87
102
|
</div>
|
package/src/EmptyState.mdx
CHANGED
|
@@ -29,8 +29,9 @@ message is still a `page`; a tall section with nothing in it is still a
|
|
|
29
29
|
Start the thread." — not just that it is empty.
|
|
30
30
|
|
|
31
31
|
A `page` sits in the middle of its box both ways. Inside a flex column
|
|
32
|
-
it takes the room left (`flex-1`) and
|
|
33
|
-
|
|
32
|
+
— the frame's `main` is one — it takes the room left (`flex-1`) and
|
|
33
|
+
centres in it, with nothing to add; only outside a flex column does it
|
|
34
|
+
need a height from the caller (`className="h-full"`).
|
|
34
35
|
|
|
35
36
|
## When not
|
|
36
37
|
|
|
@@ -44,8 +45,8 @@ a page, give it the height — `className="h-full"`.
|
|
|
44
45
|
```tsx
|
|
45
46
|
import { EmptyState } from '@estiva-app/ui'
|
|
46
47
|
|
|
47
|
-
// the whole page,
|
|
48
|
-
<EmptyState
|
|
48
|
+
// the whole page, in the frame: it fills and centres on its own
|
|
49
|
+
<EmptyState message="No documents yet. Create the first one." />
|
|
49
50
|
|
|
50
51
|
// one section of a page
|
|
51
52
|
<EmptyState scope="section" message="No comments yet." />
|