@estiva-app/ui 0.38.0 → 0.39.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 +5 -2
- package/dist/AppShell.d.ts.map +1 -1
- package/dist/Sidebar.d.ts +7 -1
- package/dist/Sidebar.d.ts.map +1 -1
- package/dist/TopBar.d.ts +5 -2
- package/dist/TopBar.d.ts.map +1 -1
- package/dist/index.js +467 -462
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/registry.json +8 -6
- package/src/AppShell.mdx +11 -3
- package/src/AppShell.stories.tsx +19 -0
- package/src/AppShell.test.tsx +20 -0
- package/src/AppShell.tsx +43 -24
- package/src/Sidebar.tsx +10 -2
- package/src/TopBar.mdx +6 -4
- package/src/TopBar.tsx +6 -3
package/package.json
CHANGED
package/registry.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"kind": "package",
|
|
5
5
|
"repo": "estiva-ui",
|
|
6
6
|
"package": "@estiva-app/ui",
|
|
7
|
-
"packageVersion": "0.
|
|
7
|
+
"packageVersion": "0.39.0",
|
|
8
8
|
"exports": 88,
|
|
9
9
|
"typeExports": 97,
|
|
10
10
|
"files": null
|
|
@@ -21,12 +21,12 @@
|
|
|
21
21
|
"kind": "component",
|
|
22
22
|
"importPath": "@estiva-app/ui",
|
|
23
23
|
"sourceFile": "src/AppShell.tsx",
|
|
24
|
-
"purpose": "The frame of an app, in
|
|
24
|
+
"purpose": "The frame of an app, in three manners: `solid` — the structured frame, a solid TopBar over a Sidebar and the content beside it — `inset` — the same frame the other way round, the TopBar and the Sidebar standing on the ground and the content a lighter card beside them — and `floating` — the TopBar floating over the top edge, a Rail standing on the background, and the content living in the rounded card.",
|
|
25
25
|
"purposeFrom": "page",
|
|
26
26
|
"props": [
|
|
27
27
|
{
|
|
28
28
|
"name": "variant",
|
|
29
|
-
"takes": "solid | floating",
|
|
29
|
+
"takes": "solid | floating | inset",
|
|
30
30
|
"required": false,
|
|
31
31
|
"note": null
|
|
32
32
|
},
|
|
@@ -78,7 +78,8 @@
|
|
|
78
78
|
"prop": "variant",
|
|
79
79
|
"values": [
|
|
80
80
|
"solid",
|
|
81
|
-
"floating"
|
|
81
|
+
"floating",
|
|
82
|
+
"inset"
|
|
82
83
|
]
|
|
83
84
|
}
|
|
84
85
|
],
|
|
@@ -5412,7 +5413,7 @@
|
|
|
5412
5413
|
"props": [
|
|
5413
5414
|
{
|
|
5414
5415
|
"name": "variant",
|
|
5415
|
-
"takes": "solid | floating",
|
|
5416
|
+
"takes": "solid | floating | inset",
|
|
5416
5417
|
"required": false,
|
|
5417
5418
|
"note": null
|
|
5418
5419
|
},
|
|
@@ -5452,7 +5453,8 @@
|
|
|
5452
5453
|
"prop": "variant",
|
|
5453
5454
|
"values": [
|
|
5454
5455
|
"solid",
|
|
5455
|
-
"floating"
|
|
5456
|
+
"floating",
|
|
5457
|
+
"inset"
|
|
5456
5458
|
]
|
|
5457
5459
|
}
|
|
5458
5460
|
],
|
package/src/AppShell.mdx
CHANGED
|
@@ -5,14 +5,18 @@ import * as AppShellStories from './AppShell.stories'
|
|
|
5
5
|
|
|
6
6
|
# AppShell
|
|
7
7
|
|
|
8
|
-
The frame of an app, in
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
The frame of an app, in three manners: **`solid`** — the structured
|
|
9
|
+
frame, a solid TopBar over a Sidebar and the content beside it —
|
|
10
|
+
**`inset`** — the same frame the other way round, the TopBar and the
|
|
11
|
+
Sidebar standing on the ground and the content a lighter card beside
|
|
12
|
+
them — and **`floating`** — the TopBar floating over the top edge, a Rail
|
|
11
13
|
standing on the background, and the content living in the rounded card.
|
|
12
14
|
Layout only: no data, no routes.
|
|
13
15
|
|
|
14
16
|
<Canvas of={AppShellStories.Solid} />
|
|
15
17
|
|
|
18
|
+
<Canvas of={AppShellStories.Inset} />
|
|
19
|
+
|
|
16
20
|
<Canvas of={AppShellStories.Floating} />
|
|
17
21
|
|
|
18
22
|
## When
|
|
@@ -20,6 +24,10 @@ Layout only: no data, no routes.
|
|
|
20
24
|
- **`solid` + Sidebar** — a place-by-place app: labelled entries, counts,
|
|
21
25
|
groups. Sidebar and main scroll independently, so a long list never
|
|
22
26
|
scrolls the navigation away.
|
|
27
|
+
- **`inset` + Sidebar** — the same place-by-place app, when its pages
|
|
28
|
+
read as one sheet on a desk: the content is a card 16px from the right
|
|
29
|
+
and bottom, with 8px corners, and the Sidebar drops its line and surface
|
|
30
|
+
by itself. Any theme: the ground and the card are its base and surface.
|
|
23
31
|
- **`floating` + Rail** — an app with a handful of top-level places and
|
|
24
32
|
its content in one card; the `menu` button in the bar collapses the
|
|
25
33
|
rail (the collapsing stays the app's).
|
package/src/AppShell.stories.tsx
CHANGED
|
@@ -95,6 +95,25 @@ export const SolidWithBanner: Story = {
|
|
|
95
95
|
),
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/** The structured frame the other way round: the bar and the sidebar on the ground, the content a lighter card beside them. */
|
|
99
|
+
export const Inset: Story = {
|
|
100
|
+
args: { variant: 'inset' },
|
|
101
|
+
render: (args) => (
|
|
102
|
+
<AppShell {...args} logo="Estiva" search={<SearchInput shortcut="Ctrl+K" className="w-[290px]" />} identity={identity} nav={sidebar}>
|
|
103
|
+
<EmptyState message="Nothing here yet." />
|
|
104
|
+
</AppShell>
|
|
105
|
+
),
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const InsetWithBanner: Story = {
|
|
109
|
+
args: { variant: 'inset' },
|
|
110
|
+
render: (args) => (
|
|
111
|
+
<AppShell {...args} logo="Estiva" identity={identity} nav={sidebar} banner={<Banner tone="ok">Public key copied.</Banner>}>
|
|
112
|
+
<EmptyState message="Nothing here yet." />
|
|
113
|
+
</AppShell>
|
|
114
|
+
),
|
|
115
|
+
}
|
|
116
|
+
|
|
98
117
|
/** The floating frame: the bar floats over the top edge, the rail stands on the background, and the content lives in the rounded card. */
|
|
99
118
|
export const Floating: Story = {
|
|
100
119
|
render: (args) => (
|
package/src/AppShell.test.tsx
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
10
10
|
import { cleanup, render, screen } from '@testing-library/react'
|
|
11
11
|
import { AppShell } from './AppShell'
|
|
12
|
+
import { Sidebar } from './Sidebar'
|
|
12
13
|
|
|
13
14
|
afterEach(cleanup)
|
|
14
15
|
|
|
@@ -26,6 +27,25 @@ describe('AppShell', () => {
|
|
|
26
27
|
expect(main.className).not.toContain('overflow-y-auto')
|
|
27
28
|
})
|
|
28
29
|
|
|
30
|
+
it('stands the sidebar on the ground and makes the content a panel in the inset manner, and only there', () => {
|
|
31
|
+
const frame = (variant: 'solid' | 'inset') =>
|
|
32
|
+
render(
|
|
33
|
+
<AppShell variant={variant} nav={<Sidebar>Rows</Sidebar>}>
|
|
34
|
+
<p>Page</p>
|
|
35
|
+
</AppShell>,
|
|
36
|
+
)
|
|
37
|
+
const { unmount } = frame('solid')
|
|
38
|
+
expect(screen.getByRole('navigation').className).toContain('border-r')
|
|
39
|
+
expect(screen.getByRole('banner').className).toContain('border-b')
|
|
40
|
+
unmount()
|
|
41
|
+
frame('inset')
|
|
42
|
+
expect(screen.getByRole('navigation').className).not.toMatch(/border-r|bg-bg-surface/)
|
|
43
|
+
expect(screen.getByRole('banner').className).not.toMatch(/border-b|bg-bg-surface/)
|
|
44
|
+
const panel = screen.getByRole('main').closest('.rounded-lg')
|
|
45
|
+
expect(panel?.className).toContain('bg-bg-surface')
|
|
46
|
+
expect(panel?.className).toContain('mr-4')
|
|
47
|
+
})
|
|
48
|
+
|
|
29
49
|
it('leaves the floating manner’s card as it was', () => {
|
|
30
50
|
render(
|
|
31
51
|
<AppShell variant="floating" nav={<nav>Nav</nav>}>
|
package/src/AppShell.tsx
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type { ReactNode } from 'react'
|
|
2
2
|
import { cn } from './cn'
|
|
3
|
+
import { Card } from './Card'
|
|
3
4
|
import { ScrollArea } from './ScrollArea'
|
|
5
|
+
import { SidebarOnGround } from './Sidebar'
|
|
4
6
|
import { TopBar } from './TopBar'
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
|
-
* The frame of an app, in the
|
|
9
|
+
* The frame of an app, in the manners that pair with the TopBar's
|
|
8
10
|
* (Katerina, 2026-09-02: the two shells go together — the solid bar with
|
|
9
11
|
* the sidebar, the floating bar with the rail):
|
|
10
12
|
*
|
|
@@ -15,6 +17,9 @@ import { TopBar } from './TopBar'
|
|
|
15
17
|
* edge, the rail stands on the app background, and the content lives
|
|
16
18
|
* in the rounded card beside it (2026-09-02, the floating app's frame
|
|
17
19
|
* geometry; its collapse animation and launcher stay in the app).
|
|
20
|
+
* - `inset` — the structured frame, the other way round: the TopBar and
|
|
21
|
+
* the Sidebar stand on the ground, and the content is a lighter card
|
|
22
|
+
* beside them (Katerina, 28 September: Linear's layout, in any theme).
|
|
18
23
|
*
|
|
19
24
|
* The banner belongs to the content area (her ruling, 2026-09-02): it
|
|
20
25
|
* renders at the top of the main column — inside the card, in the
|
|
@@ -24,7 +29,7 @@ import { TopBar } from './TopBar'
|
|
|
24
29
|
* has no data and no routes.
|
|
25
30
|
*/
|
|
26
31
|
export interface AppShellProps {
|
|
27
|
-
variant?: 'solid' | 'floating'
|
|
32
|
+
variant?: 'solid' | 'floating' | 'inset'
|
|
28
33
|
/** The top bar's menu button, in an app whose navigation collapses. */
|
|
29
34
|
menu?: ReactNode
|
|
30
35
|
/** The top bar's left: the app's logo mark, or its name as text. */
|
|
@@ -42,6 +47,7 @@ export interface AppShellProps {
|
|
|
42
47
|
|
|
43
48
|
export function AppShell({ variant = 'solid', menu, logo, search, identity, banner, nav, children }: AppShellProps) {
|
|
44
49
|
const bar = <TopBar variant={variant} menu={menu} logo={logo} search={search} right={identity} />
|
|
50
|
+
const inset = variant === 'inset'
|
|
45
51
|
|
|
46
52
|
if (variant === 'floating') {
|
|
47
53
|
return (
|
|
@@ -66,6 +72,30 @@ export function AppShell({ variant = 'solid', menu, logo, search, identity, bann
|
|
|
66
72
|
)
|
|
67
73
|
}
|
|
68
74
|
|
|
75
|
+
const column = (
|
|
76
|
+
<>
|
|
77
|
+
{banner}
|
|
78
|
+
{/* The frame owns the page's scrollbar (Katerina, 2026-09-09: the
|
|
79
|
+
Issues page still had a native bar — D40 said everywhere, and the
|
|
80
|
+
one place every page passes through had been left out). A
|
|
81
|
+
`ScrollArea` here means no page can forget it. The content box is
|
|
82
|
+
at least the viewport's height and grows with a tall page, so Base
|
|
83
|
+
UI sees its size change (the morning's phantom bar was a content
|
|
84
|
+
box it could not watch); `main` fills it as a flex column. The
|
|
85
|
+
page contract (the AppShell page says it): a page is a flex
|
|
86
|
+
child of `main` — `flex-1` to fill and scroll here; `flex-1
|
|
87
|
+
min-h-0 [contain:size]` to take exactly the frame's height and
|
|
88
|
+
scroll inside itself, as Ship's detail grids do — and the outer
|
|
89
|
+
region, with nothing to scroll, passes the wheel through. Never
|
|
90
|
+
`h-full`: a box that is *at least* the viewport's height gives a
|
|
91
|
+
percentage nothing to resolve against — measured, all four Ship
|
|
92
|
+
pages, 2026-09-09. */}
|
|
93
|
+
<ScrollArea className="min-h-0 flex-1" contentClassName="flex min-h-full flex-col">
|
|
94
|
+
<main className="flex min-w-0 flex-1 flex-col">{children}</main>
|
|
95
|
+
</ScrollArea>
|
|
96
|
+
</>
|
|
97
|
+
)
|
|
98
|
+
|
|
69
99
|
return (
|
|
70
100
|
/* `relative overflow-hidden` is the seal the floating manner already has
|
|
71
101
|
(and the ScrollArea's own root is `relative`, so an absolutely placed
|
|
@@ -79,28 +109,17 @@ export function AppShell({ variant = 'solid', menu, logo, search, identity, bann
|
|
|
79
109
|
<div className="relative flex h-full min-h-0 flex-col overflow-hidden bg-bg-base text-text-primary">
|
|
80
110
|
{bar}
|
|
81
111
|
<div className="flex min-h-0 flex-1">
|
|
82
|
-
{nav}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
child of `main` — `flex-1` to fill and scroll here; `flex-1
|
|
94
|
-
min-h-0 [contain:size]` to take exactly the frame's height and
|
|
95
|
-
scroll inside itself, as Ship's detail grids do — and the outer
|
|
96
|
-
region, with nothing to scroll, passes the wheel through. Never
|
|
97
|
-
`h-full`: a box that is *at least* the viewport's height gives a
|
|
98
|
-
percentage nothing to resolve against — measured, all four Ship
|
|
99
|
-
pages, 2026-09-09. */}
|
|
100
|
-
<ScrollArea className="min-h-0 flex-1" contentClassName="flex min-h-full flex-col">
|
|
101
|
-
<main className="flex min-w-0 flex-1 flex-col">{children}</main>
|
|
102
|
-
</ScrollArea>
|
|
103
|
-
</div>
|
|
112
|
+
<SidebarOnGround.Provider value={inset}>{nav}</SidebarOnGround.Provider>
|
|
113
|
+
{/* `inset`: the content is a card on the ground, the Card's own surface look,
|
|
114
|
+
16px from the right and bottom as the floating card is, 8px corners (Katerina,
|
|
115
|
+
28 September). */}
|
|
116
|
+
{inset ? (
|
|
117
|
+
<Card fill="surface" clip className="mb-4 mr-4 flex min-h-0 min-w-0 flex-1 flex-col">
|
|
118
|
+
{column}
|
|
119
|
+
</Card>
|
|
120
|
+
) : (
|
|
121
|
+
<div className="flex min-h-0 min-w-0 flex-1 flex-col">{column}</div>
|
|
122
|
+
)}
|
|
104
123
|
</div>
|
|
105
124
|
</div>
|
|
106
125
|
)
|
package/src/Sidebar.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { createContext, useContext, type ReactNode } from 'react'
|
|
2
2
|
import { cn } from './cn'
|
|
3
3
|
import { ScrollArea } from './ScrollArea'
|
|
4
4
|
|
|
@@ -10,6 +10,10 @@ import { ScrollArea } from './ScrollArea'
|
|
|
10
10
|
* What goes inside is the caller's: NavItem rows, a SectionLabel heading
|
|
11
11
|
* in a 32px row over a group. Desktop only — there is no narrow-screen
|
|
12
12
|
* drawer.
|
|
13
|
+
*
|
|
14
|
+
* In AppShell's `inset` frame it stands on the ground instead: no hairline,
|
|
15
|
+
* no surface, the content's card beside it (Katerina, 28 September). The
|
|
16
|
+
* frame tells it so; the caller passes nothing.
|
|
13
17
|
*/
|
|
14
18
|
export interface SidebarProps {
|
|
15
19
|
/** Names the navigation region for assistive tech. */
|
|
@@ -18,11 +22,15 @@ export interface SidebarProps {
|
|
|
18
22
|
className?: string
|
|
19
23
|
}
|
|
20
24
|
|
|
25
|
+
/** Set by AppShell's `inset` frame: the sidebar stands on the ground, with no line or surface of its own. */
|
|
26
|
+
export const SidebarOnGround = createContext(false)
|
|
27
|
+
|
|
21
28
|
export function Sidebar({ 'aria-label': ariaLabel = 'Workspace', children, className }: SidebarProps) {
|
|
29
|
+
const onGround = useContext(SidebarOnGround)
|
|
22
30
|
return (
|
|
23
31
|
<nav
|
|
24
32
|
aria-label={ariaLabel}
|
|
25
|
-
className={cn('flex w-60 shrink-0 flex-col border-r border-border-default bg-bg-surface', className)}
|
|
33
|
+
className={cn('flex w-60 shrink-0 flex-col', !onGround && 'border-r border-border-default bg-bg-surface', className)}
|
|
26
34
|
>
|
|
27
35
|
{/* The rows scroll in a ScrollArea: the bar takes no width, so the
|
|
28
36
|
column's padding reads the same with a long list as with a short
|
package/src/TopBar.mdx
CHANGED
|
@@ -6,10 +6,10 @@ import * as TopBarStories from './TopBar.stories'
|
|
|
6
6
|
# TopBar
|
|
7
7
|
|
|
8
8
|
The 52px top bar: `menu` and `logo` on the left, a `search` slot in the
|
|
9
|
-
centre, the identity cluster on the right.
|
|
10
|
-
flow with a hairline and a surface
|
|
11
|
-
|
|
12
|
-
reachable.
|
|
9
|
+
centre, the identity cluster on the right. Three manners — **`solid`**,
|
|
10
|
+
in flow with a hairline and a surface; **`inset`**, in flow with neither,
|
|
11
|
+
standing on the ground; and **`floating`**, an overlay with neither, whose
|
|
12
|
+
bar ignores the pointer so the content underneath stays reachable.
|
|
13
13
|
|
|
14
14
|
<Canvas of={TopBarStories.Solid} />
|
|
15
15
|
|
|
@@ -19,6 +19,8 @@ reachable.
|
|
|
19
19
|
|
|
20
20
|
- **`solid`** — the structured frame's first row, above the sidebar
|
|
21
21
|
(AppShell's `solid` variant wires exactly this).
|
|
22
|
+
- **`inset`** — the first row of AppShell's `inset` frame, on the ground
|
|
23
|
+
above a sidebar that is on the ground too.
|
|
22
24
|
- **`floating`** — an app whose content runs to the top edge; the bar
|
|
23
25
|
floats over it and only its clusters catch clicks (AppShell's
|
|
24
26
|
`floating` variant).
|
package/src/TopBar.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import type { ReactNode } from 'react'
|
|
|
2
2
|
import { cn } from './cn'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* The 52px top bar, in
|
|
5
|
+
* The 52px top bar, in three manners:
|
|
6
6
|
*
|
|
7
7
|
* - `solid` — in flow: a hairline under it, the surface behind it, the
|
|
8
8
|
* frame's first row (2026-09-02, verbatim from the structured app; its
|
|
@@ -11,6 +11,9 @@ import { cn } from './cn'
|
|
|
11
11
|
* background, and the bar itself ignores the pointer — only its clusters
|
|
12
12
|
* catch clicks, so the content underneath stays reachable (2026-09-02,
|
|
13
13
|
* verbatim from the floating app).
|
|
14
|
+
* - `inset` — in flow like `solid`, but standing on the ground: no hairline
|
|
15
|
+
* and no surface, above a sidebar on the ground and the content's card
|
|
16
|
+
* (Katerina, 28 September).
|
|
14
17
|
*
|
|
15
18
|
* Geometry both apps agreed on before extraction: 52px tall, `pl-5
|
|
16
19
|
* pr-[26px]`, right cluster `gap-[6px]`. The left region is two slots
|
|
@@ -20,7 +23,7 @@ import { cn } from './cn'
|
|
|
20
23
|
* searched; it only knows where those go.
|
|
21
24
|
*/
|
|
22
25
|
export interface TopBarProps {
|
|
23
|
-
variant?: 'solid' | 'floating'
|
|
26
|
+
variant?: 'solid' | 'floating' | 'inset'
|
|
24
27
|
/** Far left: the menu button, in an app whose navigation collapses. Stays the caller's — the bar only places it. */
|
|
25
28
|
menu?: ReactNode
|
|
26
29
|
/** Beside the menu button: the app's logo mark, or its name as text. */
|
|
@@ -40,7 +43,7 @@ export function TopBar({ variant = 'solid', menu, logo, search, right, className
|
|
|
40
43
|
'flex h-[52px] items-center pl-5 pr-[26px]',
|
|
41
44
|
floating
|
|
42
45
|
? 'pointer-events-none absolute left-0 right-0 top-0 z-10'
|
|
43
|
-
: 'shrink-0 gap-4 border-b border-border-default bg-bg-surface',
|
|
46
|
+
: cn('shrink-0 gap-4', variant === 'solid' && 'border-b border-border-default bg-bg-surface'),
|
|
44
47
|
className,
|
|
45
48
|
)}
|
|
46
49
|
>
|