@estiva-app/ui 0.36.0 → 0.37.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/Panel.d.ts +44 -0
- package/dist/Panel.d.ts.map +1 -0
- package/dist/ScrollArea.d.ts +7 -2
- package/dist/ScrollArea.d.ts.map +1 -1
- package/dist/eslint/index.js +16 -3
- package/dist/eslint/index.js.map +3 -3
- package/dist/eslint/no-handmade-header.d.ts +5 -0
- package/dist/eslint/no-handmade-header.d.ts.map +1 -1
- package/dist/eslint/no-native-title.d.ts +1 -1
- package/dist/eslint/no-native-title.d.ts.map +1 -1
- package/dist/gates/app-checks.d.ts +1 -1
- package/dist/gates/app-checks.d.ts.map +1 -1
- package/dist/gates/index.js +5 -2
- package/dist/gates/index.js.map +2 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +63 -36
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/registry.json +77 -3
- package/src/Panel.mdx +78 -0
- package/src/Panel.stories.tsx +98 -0
- package/src/Panel.tsx +80 -0
- package/src/ScrollArea.mdx +2 -2
- package/src/ScrollArea.stories.tsx +1 -1
- package/src/ScrollArea.tsx +9 -4
- package/src/eslint/no-handmade-header.test.ts +9 -0
- package/src/eslint/no-handmade-header.ts +24 -2
- package/src/eslint/no-native-title.ts +1 -1
- package/src/gates/app-checks.ts +5 -3
- package/src/index.ts +1 -0
- package/src/registry/app.test.ts +3 -1
- package/src/registry/registry.test.ts +4 -4
- package/stories/Choosing.mdx +4 -3
package/package.json
CHANGED
package/registry.json
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
"kind": "package",
|
|
5
5
|
"repo": "estiva-ui",
|
|
6
6
|
"package": "@estiva-app/ui",
|
|
7
|
-
"packageVersion": "0.
|
|
8
|
-
"exports":
|
|
9
|
-
"typeExports":
|
|
7
|
+
"packageVersion": "0.37.0",
|
|
8
|
+
"exports": 88,
|
|
9
|
+
"typeExports": 97,
|
|
10
10
|
"files": null
|
|
11
11
|
},
|
|
12
12
|
"storybook": {
|
|
@@ -3303,6 +3303,80 @@
|
|
|
3303
3303
|
"text-center font-mono text-caption tabular-nums text-text-muted"
|
|
3304
3304
|
]
|
|
3305
3305
|
},
|
|
3306
|
+
{
|
|
3307
|
+
"name": "Panel",
|
|
3308
|
+
"repo": "estiva-ui",
|
|
3309
|
+
"kind": "component",
|
|
3310
|
+
"importPath": "@estiva-app/ui",
|
|
3311
|
+
"sourceFile": "src/Panel.tsx",
|
|
3312
|
+
"purpose": "A panel of a page: its name in a header, a body that scrolls whenever it is taller than the room it has, and a row fixed at the bottom when there is one.",
|
|
3313
|
+
"purposeFrom": "page",
|
|
3314
|
+
"props": [
|
|
3315
|
+
{
|
|
3316
|
+
"name": "title",
|
|
3317
|
+
"takes": "anything",
|
|
3318
|
+
"required": true,
|
|
3319
|
+
"note": "The panel's name, in its header. A node for a title that is more than words."
|
|
3320
|
+
},
|
|
3321
|
+
{
|
|
3322
|
+
"name": "chevron",
|
|
3323
|
+
"takes": "true/false",
|
|
3324
|
+
"required": false,
|
|
3325
|
+
"note": "A chevron after the title, as `ContainerHeader` draws it."
|
|
3326
|
+
},
|
|
3327
|
+
{
|
|
3328
|
+
"name": "actions",
|
|
3329
|
+
"takes": "anything",
|
|
3330
|
+
"required": false,
|
|
3331
|
+
"note": "The panel's own actions, at the header's right — a `Toolbar` of `ToolbarButton`s, or one `IconButton`."
|
|
3332
|
+
},
|
|
3333
|
+
{
|
|
3334
|
+
"name": "children",
|
|
3335
|
+
"takes": "anything",
|
|
3336
|
+
"required": true,
|
|
3337
|
+
"note": "The body. It scrolls when it is taller than the panel."
|
|
3338
|
+
},
|
|
3339
|
+
{
|
|
3340
|
+
"name": "footer",
|
|
3341
|
+
"takes": "anything",
|
|
3342
|
+
"required": false,
|
|
3343
|
+
"note": "A row fixed under the body that never scrolls — a reply box."
|
|
3344
|
+
},
|
|
3345
|
+
{
|
|
3346
|
+
"name": "bodyClassName",
|
|
3347
|
+
"takes": "text",
|
|
3348
|
+
"required": false,
|
|
3349
|
+
"note": "The body's own box: its padding, gap and layout — `px-4 pt-3 pb-4`, `flex flex-col gap-2`."
|
|
3350
|
+
},
|
|
3351
|
+
{
|
|
3352
|
+
"name": "bodyRef",
|
|
3353
|
+
"takes": "Ref<HTMLDivElement>",
|
|
3354
|
+
"required": false,
|
|
3355
|
+
"note": "The body's scrolling box, for a panel that reads or drives its scroll: a thread that opens at its newest reply."
|
|
3356
|
+
},
|
|
3357
|
+
{
|
|
3358
|
+
"name": "onBodyScroll",
|
|
3359
|
+
"takes": "UIEventHandler<HTMLDivElement>",
|
|
3360
|
+
"required": false,
|
|
3361
|
+
"note": "Fires as the body scrolls."
|
|
3362
|
+
},
|
|
3363
|
+
{
|
|
3364
|
+
"name": "className",
|
|
3365
|
+
"takes": "text",
|
|
3366
|
+
"required": false,
|
|
3367
|
+
"note": "Placement only: a cap such as `max-h-[60%]` when it shares a column."
|
|
3368
|
+
}
|
|
3369
|
+
],
|
|
3370
|
+
"variants": [],
|
|
3371
|
+
"ownsBehaviours": [],
|
|
3372
|
+
"status": "stable",
|
|
3373
|
+
"migrationStage": null,
|
|
3374
|
+
"docsId": "layout-panel--docs",
|
|
3375
|
+
"storyId": "layout-panel--default",
|
|
3376
|
+
"docPage": "src/Panel.mdx",
|
|
3377
|
+
"app": null,
|
|
3378
|
+
"looks": []
|
|
3379
|
+
},
|
|
3306
3380
|
{
|
|
3307
3381
|
"name": "Person",
|
|
3308
3382
|
"repo": "estiva-ui",
|
package/src/Panel.mdx
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as PanelStories from './Panel.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={PanelStories} />
|
|
5
|
+
|
|
6
|
+
# Panel
|
|
7
|
+
|
|
8
|
+
A panel of a page: its name in a header, a body that scrolls whenever it is
|
|
9
|
+
taller than the room it has, and a row fixed at the bottom when there is one.
|
|
10
|
+
|
|
11
|
+
<Canvas of={PanelStories.ALongBody} />
|
|
12
|
+
|
|
13
|
+
## When
|
|
14
|
+
|
|
15
|
+
- **Any panel of a page that is not a list**: a side column's details, a
|
|
16
|
+
thread, another app's card beside a file, the contents of what is open.
|
|
17
|
+
Its body scrolls by itself, so it is never cut off at the fold, even when
|
|
18
|
+
today's content fits.
|
|
19
|
+
- `footer` for a row that stays put under the body while it scrolls: a reply
|
|
20
|
+
box.
|
|
21
|
+
- Two panels in one column share it. Cap the first with a `max-h-*` in
|
|
22
|
+
`className`, and each scrolls in its own room.
|
|
23
|
+
|
|
24
|
+
<Canvas of={PanelStories.WithAFooter} />
|
|
25
|
+
|
|
26
|
+
<Canvas of={PanelStories.TwoInAColumn} />
|
|
27
|
+
|
|
28
|
+
## When not
|
|
29
|
+
|
|
30
|
+
- A list of things to open beside the page → **ListColumn**, which is a panel
|
|
31
|
+
with the list's own width and insets.
|
|
32
|
+
- A window over the page → **DialogShell**.
|
|
33
|
+
- A box inside a panel or a card that should scroll on its own → **ScrollArea**.
|
|
34
|
+
|
|
35
|
+
## How
|
|
36
|
+
|
|
37
|
+
```tsx
|
|
38
|
+
import { Panel, IconButton } from '@estiva-app/ui'
|
|
39
|
+
|
|
40
|
+
<Panel
|
|
41
|
+
title="Details"
|
|
42
|
+
actions={
|
|
43
|
+
<IconButton tooltip="More" aria-label="More" onClick={openMenu}>
|
|
44
|
+
<IconDotsVertical size={16} stroke={1.5} />
|
|
45
|
+
</IconButton>
|
|
46
|
+
}
|
|
47
|
+
bodyClassName="flex flex-col gap-3 px-4 pt-3 pb-4"
|
|
48
|
+
>
|
|
49
|
+
{sections}
|
|
50
|
+
</Panel>
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
- It takes the room its column gives it, never its content's height, so the
|
|
54
|
+
body is what scrolls. Put it straight into the column.
|
|
55
|
+
- `bodyClassName` is the body's padding, gap and layout. The header is
|
|
56
|
+
ContainerHeader's: 48px, a hairline under it.
|
|
57
|
+
- A panel that follows its scroll (a thread that opens at its newest reply)
|
|
58
|
+
reads it with `bodyRef` and `onBodyScroll`.
|
|
59
|
+
- **A crash stays inside it.** If its body, its actions or its footer break,
|
|
60
|
+
it keeps its title and shows "Something went wrong in the panel… try again"
|
|
61
|
+
in the room the body had. It is **ErrorBoundary**'s.
|
|
62
|
+
|
|
63
|
+
<Canvas of={PanelStories.WhenTheBodyBreaks} />
|
|
64
|
+
|
|
65
|
+
## What it owns
|
|
66
|
+
|
|
67
|
+
It does these by itself. Write none of them around it.
|
|
68
|
+
|
|
69
|
+
| It does | So you never write | Refused when written by hand |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| Draws the header: a ContainerHeader with the title and the actions | a header row of your own over a box | ContainerHeader used on its own in an app |
|
|
72
|
+
| Scrolls its body in a ScrollArea whenever it is taller than the panel, with the bar showing | `overflow-auto`, or a box that cuts off what does not fit | |
|
|
73
|
+
| Keeps the footer fixed under the body | a flex column of your own around the body and the footer | |
|
|
74
|
+
| Keeps a crash inside itself: its title stays, the message and Try again sit in the room the body had | an error boundary of your own around it | |
|
|
75
|
+
|
|
76
|
+
## Props
|
|
77
|
+
|
|
78
|
+
<Controls of={PanelStories.Default} />
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { IconDotsVertical } from '@tabler/icons-react'
|
|
3
|
+
import { IconButton } from './IconButton'
|
|
4
|
+
import { Panel } from './Panel'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A panel of a page: a header, a body that scrolls whenever it is taller than
|
|
8
|
+
* the panel, and a row fixed under it when there is one. The body's words are
|
|
9
|
+
* stand-ins.
|
|
10
|
+
*/
|
|
11
|
+
const meta = {
|
|
12
|
+
title: 'Layout/Panel',
|
|
13
|
+
component: Panel,
|
|
14
|
+
parameters: { layout: 'fullscreen' },
|
|
15
|
+
args: { title: 'Details', children: null, bodyClassName: 'flex flex-col gap-3 px-4 pt-3 pb-4' },
|
|
16
|
+
argTypes: { children: { control: false }, actions: { control: false }, footer: { control: false } },
|
|
17
|
+
// A side column of the frame: 380px wide and a fixed height, where a panel sits beside a page.
|
|
18
|
+
decorators: [
|
|
19
|
+
(Story) => (
|
|
20
|
+
<div className="flex h-[560px] w-[380px] flex-col border-l border-border-subtle bg-bg-surface">
|
|
21
|
+
{Story()}
|
|
22
|
+
</div>
|
|
23
|
+
),
|
|
24
|
+
],
|
|
25
|
+
} satisfies Meta<typeof Panel>
|
|
26
|
+
|
|
27
|
+
export default meta
|
|
28
|
+
type Story = StoryObj<typeof meta>
|
|
29
|
+
|
|
30
|
+
const paragraph = (n: number) => (
|
|
31
|
+
<p key={n} className="text-body-2 text-text-secondary">
|
|
32
|
+
Paragraph {n}. What this item is for, who it is for, and what it leaves for later, written out in full the way a real description is.
|
|
33
|
+
</p>
|
|
34
|
+
)
|
|
35
|
+
const paragraphs = (count: number) => Array.from({ length: count }, (_, i) => paragraph(i + 1))
|
|
36
|
+
const menu = <IconButton tooltip="More" aria-label="More"><IconDotsVertical size={16} stroke={1.5} /></IconButton>
|
|
37
|
+
|
|
38
|
+
/** A short body: it fits, so there is no bar. */
|
|
39
|
+
export const Default: Story = {
|
|
40
|
+
args: { actions: menu },
|
|
41
|
+
render: (args) => <Panel {...args}>{paragraphs(2)}</Panel>,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A title that opens something: the chevron after it, as ContainerHeader draws it. */
|
|
45
|
+
export const WithChevron: Story = {
|
|
46
|
+
args: { chevron: true },
|
|
47
|
+
render: (args) => <Panel {...args}>{paragraphs(2)}</Panel>,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A body taller than the panel: it scrolls, and the bar shows at rest because there is more below. */
|
|
51
|
+
export const ALongBody: Story = {
|
|
52
|
+
args: { actions: menu },
|
|
53
|
+
render: (args) => <Panel {...args}>{paragraphs(14)}</Panel>,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A row fixed under the body, such as a reply box: the body scrolls above it, and it stays put. */
|
|
57
|
+
export const WithAFooter: Story = {
|
|
58
|
+
render: (args) => (
|
|
59
|
+
<Panel
|
|
60
|
+
{...args}
|
|
61
|
+
footer={
|
|
62
|
+
// A stand-in for whatever the footer holds: the part only keeps it in place.
|
|
63
|
+
<div className="border-t border-border-subtle p-3">
|
|
64
|
+
<div className="flex h-10 items-center justify-center rounded-md bg-bg-elevated text-caption text-text-muted">Footer</div>
|
|
65
|
+
</div>
|
|
66
|
+
}
|
|
67
|
+
>
|
|
68
|
+
{paragraphs(12)}
|
|
69
|
+
</Panel>
|
|
70
|
+
),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Two panels in one column: the first capped at three fifths, each scrolls in its own room. */
|
|
74
|
+
export const TwoInAColumn: Story = {
|
|
75
|
+
render: (args) => (
|
|
76
|
+
<>
|
|
77
|
+
<Panel {...args} title="Details" className="max-h-[60%]">
|
|
78
|
+
{paragraphs(10)}
|
|
79
|
+
</Panel>
|
|
80
|
+
<Panel {...args} title="Activity">
|
|
81
|
+
{paragraphs(6)}
|
|
82
|
+
</Panel>
|
|
83
|
+
</>
|
|
84
|
+
),
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function Broken(): never {
|
|
88
|
+
throw new Error('The body could not draw itself')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A crash inside it stays inside it: the title stays, and the message takes the body's room. */
|
|
92
|
+
export const WhenTheBodyBreaks: Story = {
|
|
93
|
+
render: (args) => (
|
|
94
|
+
<Panel {...args}>
|
|
95
|
+
<Broken />
|
|
96
|
+
</Panel>
|
|
97
|
+
),
|
|
98
|
+
}
|
package/src/Panel.tsx
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { ReactNode, Ref, UIEventHandler } from 'react'
|
|
2
|
+
import { cn } from './cn'
|
|
3
|
+
import { ContainerHeader } from './ContainerHeader'
|
|
4
|
+
import { ErrorBoundary } from './ErrorBoundary'
|
|
5
|
+
import { ScrollArea } from './ScrollArea'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A panel of a page: its name in a `ContainerHeader`, a body under it that
|
|
9
|
+
* scrolls whenever it is taller than the room it has, and, when there is one,
|
|
10
|
+
* a row fixed at the bottom that never scrolls — a reply box.
|
|
11
|
+
*
|
|
12
|
+
* Made so that scrolling comes from the package, not from each app remembering
|
|
13
|
+
* it (Katerina, 26 September 2026). The apps built their panels from the
|
|
14
|
+
* package's header and a box of their own, and a box without a `ScrollArea`
|
|
15
|
+
* simply cut off what did not fit: Peek's widget panel on a Ship file lost the
|
|
16
|
+
* end of a long description and the status control under it, with no bar and
|
|
17
|
+
* nothing to scroll. Here the body is always a `ScrollArea`, so the bar shows
|
|
18
|
+
* as soon as there is more, and an empty panel or a short one has none.
|
|
19
|
+
*
|
|
20
|
+
* It takes the room its column gives it (`flex-1 min-h-0`), never its content's
|
|
21
|
+
* height, so the body is what scrolls, not the page. Two panels in one column
|
|
22
|
+
* share it; a `max-h-*` in `className` caps one of them.
|
|
23
|
+
*
|
|
24
|
+
* It keeps a crash inside itself, as ListColumn does: if its body, its actions
|
|
25
|
+
* or its footer break, the panel keeps its title and shows `ErrorBoundary`'s
|
|
26
|
+
* message in the room the body had.
|
|
27
|
+
*/
|
|
28
|
+
export interface PanelProps {
|
|
29
|
+
/** The panel's name, in its header. A node for a title that is more than words. */
|
|
30
|
+
title: ReactNode
|
|
31
|
+
/** A chevron after the title, as `ContainerHeader` draws it. */
|
|
32
|
+
chevron?: boolean
|
|
33
|
+
/** The panel's own actions, at the header's right — a `Toolbar` of `ToolbarButton`s, or one `IconButton`. */
|
|
34
|
+
actions?: ReactNode
|
|
35
|
+
/** The body. It scrolls when it is taller than the panel. */
|
|
36
|
+
children: ReactNode
|
|
37
|
+
/** A row fixed under the body that never scrolls — a reply box. */
|
|
38
|
+
footer?: ReactNode
|
|
39
|
+
/** The body's own box: its padding, gap and layout — `px-4 pt-3 pb-4`, `flex flex-col gap-2`. */
|
|
40
|
+
bodyClassName?: string
|
|
41
|
+
/** The body's scrolling box, for a panel that reads or drives its scroll: a thread that opens at its newest reply. */
|
|
42
|
+
bodyRef?: Ref<HTMLDivElement>
|
|
43
|
+
/** Fires as the body scrolls. */
|
|
44
|
+
onBodyScroll?: UIEventHandler<HTMLDivElement>
|
|
45
|
+
/** Placement only: a cap such as `max-h-[60%]` when it shares a column. */
|
|
46
|
+
className?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function Frame({ title, chevron = false, actions, children, footer, bodyClassName, bodyRef, onBodyScroll, className, broken = false }: PanelProps & { broken?: boolean }) {
|
|
50
|
+
return (
|
|
51
|
+
<div className={cn('flex min-h-0 flex-1 flex-col', className)}>
|
|
52
|
+
<ContainerHeader title={title} chevron={chevron} actions={actions} />
|
|
53
|
+
{broken ? (
|
|
54
|
+
// The message takes the room the body had and centres itself in it.
|
|
55
|
+
<div className="flex min-h-0 flex-1 flex-col px-4">{children}</div>
|
|
56
|
+
) : (
|
|
57
|
+
<ScrollArea className="min-h-0 flex-1" contentClassName={bodyClassName} viewportRef={bodyRef} onScroll={onBodyScroll}>
|
|
58
|
+
{children}
|
|
59
|
+
</ScrollArea>
|
|
60
|
+
)}
|
|
61
|
+
{!broken && footer && <div className="shrink-0">{footer}</div>}
|
|
62
|
+
</div>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function Panel(props: PanelProps) {
|
|
67
|
+
return (
|
|
68
|
+
<ErrorBoundary
|
|
69
|
+
label="panel"
|
|
70
|
+
frame={(message) => (
|
|
71
|
+
// Only the name comes back with the message: the actions and the footer may be what threw.
|
|
72
|
+
<Frame title={props.title} chevron={props.chevron} className={props.className} broken>
|
|
73
|
+
{message}
|
|
74
|
+
</Frame>
|
|
75
|
+
)}
|
|
76
|
+
>
|
|
77
|
+
<Frame {...props} />
|
|
78
|
+
</ErrorBoundary>
|
|
79
|
+
)
|
|
80
|
+
}
|
package/src/ScrollArea.mdx
CHANGED
|
@@ -6,8 +6,8 @@ import * as ScrollAreaStories from './ScrollArea.stories'
|
|
|
6
6
|
# ScrollArea
|
|
7
7
|
|
|
8
8
|
A region that scrolls without taking width for its scrollbar. The bar is
|
|
9
|
-
drawn over the content and shows
|
|
10
|
-
|
|
9
|
+
drawn over the content and shows whenever there is more to scroll to, so a
|
|
10
|
+
long list says so before anyone hovers it; nothing moves when it appears.
|
|
11
11
|
|
|
12
12
|
<Canvas of={ScrollAreaStories.Default} />
|
|
13
13
|
|
|
@@ -15,7 +15,7 @@ type Story = StoryObj<typeof meta>
|
|
|
15
15
|
|
|
16
16
|
const rows = Array.from({ length: 40 }, (_, i) => `Row ${i + 1}`)
|
|
17
17
|
|
|
18
|
-
/** A list taller than its box. The bar shows
|
|
18
|
+
/** A list taller than its box. The bar shows at rest, because there is more to scroll to. */
|
|
19
19
|
export const Default: Story = {
|
|
20
20
|
render: () => (
|
|
21
21
|
<Card className="h-[240px] w-[280px]">
|
package/src/ScrollArea.tsx
CHANGED
|
@@ -10,8 +10,13 @@ import { cn } from './cn'
|
|
|
10
10
|
* right-hand padding looks wider than the left. Katerina saw it in every
|
|
11
11
|
* scrolling surface of both apps (2026-09-08). Base UI's `ScrollArea` hides
|
|
12
12
|
* the native bar and draws its own over the content, so nothing moves when
|
|
13
|
-
* it appears
|
|
14
|
-
*
|
|
13
|
+
* it appears.
|
|
14
|
+
*
|
|
15
|
+
* The bar shows whenever the region has more to scroll to, at rest too
|
|
16
|
+
* (Katerina, 26 September: a long folder list in Peek's side column looked
|
|
17
|
+
* complete, because the bar waited for the pointer). Base UI writes
|
|
18
|
+
* `data-has-overflow-y` / `-x` on the bar while that axis overflows; a region
|
|
19
|
+
* with nothing more to show keeps no bar at all.
|
|
15
20
|
*
|
|
16
21
|
* Three boxes, and each class prop lands on one:
|
|
17
22
|
*
|
|
@@ -99,12 +104,12 @@ export function ScrollArea({ orientation = 'vertical', className, viewportClassN
|
|
|
99
104
|
</BaseScrollArea.Content>
|
|
100
105
|
</BaseScrollArea.Viewport>
|
|
101
106
|
{vertical && (
|
|
102
|
-
<BaseScrollArea.Scrollbar orientation="vertical" className={cn(BAR, 'w-1.5 justify-center py-0.5 pr-0.5')}>
|
|
107
|
+
<BaseScrollArea.Scrollbar orientation="vertical" className={cn(BAR, 'w-1.5 justify-center py-0.5 pr-0.5 data-[has-overflow-y]:opacity-100')}>
|
|
103
108
|
<BaseScrollArea.Thumb className={cn(THUMB, 'w-full')} />
|
|
104
109
|
</BaseScrollArea.Scrollbar>
|
|
105
110
|
)}
|
|
106
111
|
{horizontal && (
|
|
107
|
-
<BaseScrollArea.Scrollbar orientation="horizontal" className={cn(BAR, 'h-1.5 flex-col justify-center px-0.5 pb-0.5')}>
|
|
112
|
+
<BaseScrollArea.Scrollbar orientation="horizontal" className={cn(BAR, 'h-1.5 flex-col justify-center px-0.5 pb-0.5 data-[has-overflow-x]:opacity-100')}>
|
|
108
113
|
<BaseScrollArea.Thumb className={cn(THUMB, 'h-full')} />
|
|
109
114
|
</BaseScrollArea.Scrollbar>
|
|
110
115
|
)}
|
|
@@ -17,6 +17,10 @@ const handmade = [{ messageId: 'handmade' }]
|
|
|
17
17
|
|
|
18
18
|
tester.run('no-handmade-header', noHandmadeHeader, {
|
|
19
19
|
valid: [
|
|
20
|
+
// 26 September: the package's own parts import the header from their sibling file.
|
|
21
|
+
{ name: 'the header inside the package, from its own file', code: `import { ContainerHeader } from './ContainerHeader'\n${component('<ContainerHeader title="Files" />')}` },
|
|
22
|
+
{ name: 'Panel in an app', code: `import { Panel } from '@estiva-app/ui'\n${component('<Panel title="Files">x</Panel>')}` },
|
|
23
|
+
{ name: 'the bare header in an app, escaped with its reason', code: `import { ContainerHeader } from '@estiva-app/ui'\n${component('// @estiva-escape(no-handmade-header): a header over a loading skeleton that cannot scroll\n<ContainerHeader title="Files" />')}` },
|
|
20
24
|
{ name: 'the part itself', code: component('<div className="flex h-full flex-col">\n<ContainerHeader title="Files" />\n<div />\n</div>') },
|
|
21
25
|
{
|
|
22
26
|
name: "a group heading inside a list: SectionHeader's row, not a column's bar (Ship's issue groups)",
|
|
@@ -37,6 +41,11 @@ tester.run('no-handmade-header', noHandmadeHeader, {
|
|
|
37
41
|
},
|
|
38
42
|
],
|
|
39
43
|
invalid: [
|
|
44
|
+
{
|
|
45
|
+
name: "the package's header on its own in an app: a panel made by hand, naming Panel (26 September)",
|
|
46
|
+
code: `import { ContainerHeader as Header } from '@estiva-app/ui'\n${component('<div className="flex flex-col">\n<Header title="Estiva Ship" />\n<div className="px-4">{title}</div>\n</div>')}`,
|
|
47
|
+
errors: [{ messageId: 'bare' }],
|
|
48
|
+
},
|
|
40
49
|
{
|
|
41
50
|
// The Folders page's right pane, at the commit before 3dc663b.
|
|
42
51
|
name: 'a pane that heads itself around an editable name',
|
|
@@ -121,20 +121,42 @@ const INTERACTIVE = ['onClick', 'href', 'role', 'onKeyDown', 'onPointerDown']
|
|
|
121
121
|
* What it leaves alone, on purpose: a group heading inside a list (the row one
|
|
122
122
|
* section starts with), a label strip on a card, and a row a person presses.
|
|
123
123
|
* Only a lowercase element is judged: a part is already a part.
|
|
124
|
+
*
|
|
125
|
+
* And the package's `ContainerHeader` on its own in an app (26 September): a
|
|
126
|
+
* header over a box of the app's own is a panel made by hand, and the box is
|
|
127
|
+
* what forgot to scroll — Peek's widget panel cut off a long description. The
|
|
128
|
+
* message names `Panel`, whose body always scrolls.
|
|
124
129
|
*/
|
|
125
130
|
export const noHandmadeHeader: Rule.RuleModule = {
|
|
126
131
|
meta: {
|
|
127
132
|
type: 'problem',
|
|
128
|
-
docs: { description: "A column's header bar drawn by hand where
|
|
133
|
+
docs: { description: "A column's header bar drawn by hand, or the package's header on its own in an app, where Panel is the part" },
|
|
129
134
|
schema: [],
|
|
130
135
|
messages: {
|
|
131
136
|
handmade:
|
|
132
|
-
"A column's header bar drawn by hand. Use `
|
|
137
|
+
"A column's header bar drawn by hand. Use `Panel` from @estiva-app/ui: its header (48px, the title, the column's buttons at the right edge in `actions`, a hairline under it) and a body under it that scrolls. For a list beside the page, `ListColumn`.",
|
|
138
|
+
bare:
|
|
139
|
+
"A panel put together by hand: `ContainerHeader` over a box of your own, which cuts off whatever does not fit. Use `Panel` from @estiva-app/ui: the same header (`title`, `actions`), a body that scrolls whenever it is taller, and a `footer` that stays put. For a list beside the page, `ListColumn`.",
|
|
133
140
|
...ESCAPE_MESSAGES,
|
|
134
141
|
},
|
|
135
142
|
},
|
|
136
143
|
create(context) {
|
|
144
|
+
// The package's header, imported by name in an app (Katerina, 26 September): in an
|
|
145
|
+
// app it comes with Panel or ListColumn, whose body scrolls. The package's own parts
|
|
146
|
+
// import it from their sibling file, so this never reads them.
|
|
147
|
+
const bare = new Set<string>()
|
|
137
148
|
return {
|
|
149
|
+
ImportDeclaration(node: Rule.Node) {
|
|
150
|
+
const declaration = node as unknown as { source: { value: unknown }; specifiers: { type: string; imported?: { name?: string }; local: { name: string } }[] }
|
|
151
|
+
if (declaration.source.value !== '@estiva-app/ui') return
|
|
152
|
+
for (const specifier of declaration.specifiers) if (specifier.type === 'ImportSpecifier' && specifier.imported?.name === 'ContainerHeader') bare.add(specifier.local.name)
|
|
153
|
+
},
|
|
154
|
+
JSXOpeningElement(node: Rule.Node) {
|
|
155
|
+
const element = node as unknown as JSXElement['openingElement']
|
|
156
|
+
if (element.name.type !== 'JSXIdentifier' || !bare.has(element.name.name ?? '')) return
|
|
157
|
+
if (isEscaped(context, element)) return
|
|
158
|
+
context.report({ loc: element.loc, messageId: 'bare' })
|
|
159
|
+
},
|
|
138
160
|
JSXElement(node: Rule.Node) {
|
|
139
161
|
const column = node as unknown as JSXElement
|
|
140
162
|
const kids = meaningful(column)
|
|
@@ -8,7 +8,7 @@ import { bindingOfTag, partsOfFile, type Node, type Parser, type PartBinding } f
|
|
|
8
8
|
* the browser shows it as its tooltip. `no-native-title.test.ts` holds this
|
|
9
9
|
* list to the catalogue: a part that gains a `title` of its own joins it.
|
|
10
10
|
*/
|
|
11
|
-
export const PARTS_WITH_A_TITLE = ['CollapsibleSection', 'ConfirmDialog', 'ContainerHeader', 'DialogShell', 'ListColumn', 'SectionHeader'] as const
|
|
11
|
+
export const PARTS_WITH_A_TITLE = ['CollapsibleSection', 'ConfirmDialog', 'ContainerHeader', 'DialogShell', 'ListColumn', 'Panel', 'SectionHeader'] as const
|
|
12
12
|
|
|
13
13
|
const child = (node: Node, key: string) => node[key] as Node | null | undefined
|
|
14
14
|
const nameOf = (node: Node | null | undefined): string | undefined =>
|
package/src/gates/app-checks.ts
CHANGED
|
@@ -31,7 +31,7 @@ export interface AppCheckOptions {
|
|
|
31
31
|
chain?: { ref: string; owner: boolean }
|
|
32
32
|
/** The token lint configs the app has, relative to the app. */
|
|
33
33
|
tokenConfigs?: string[]
|
|
34
|
-
/** The part a hand-made column header should name (UIG-22): `ContainerHeader
|
|
34
|
+
/** The part a hand-made column header should name (UIG-22): `Panel` since 0.37.0, `ContainerHeader` before. */
|
|
35
35
|
headers?: string[]
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -70,7 +70,7 @@ function all(h: GateHelpers, steps: (() => CheckResult | Promise<CheckResult>)[]
|
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG-3', owner: false }, tokenConfigs = ['eslint.tokens.config.js', 'eslint.config.js'], headers = ['
|
|
73
|
+
export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG-3', owner: false }, tokenConfigs = ['eslint.tokens.config.js', 'eslint.config.js'], headers = ['Panel'] }: AppCheckOptions): GateTicket[] {
|
|
74
74
|
const WEB = app === '.' ? '' : `${app.replace(/\/$/, '')}/`
|
|
75
75
|
const cwd = app === '.' ? undefined : app
|
|
76
76
|
const PROBE = 'src/components/__gates_probe__.tsx'
|
|
@@ -221,7 +221,9 @@ export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG
|
|
|
221
221
|
]),
|
|
222
222
|
ticket('UIG-22', [
|
|
223
223
|
// The Folders pane's own row, at the commit before peek 3dc663b.
|
|
224
|
-
{ what:
|
|
224
|
+
{ what: `a hand-made column header is an error naming ${headers.join(' or ')}`, run: all(h, headers.map((name) => probe(component('<div className="flex h-full flex-col"><div className="flex items-center gap-2 px-3 py-2"><span>Folders</span></div><div /></div>'), 'error', name)), `a hand-made column header is an error naming ${headers.join(' or ')}`) },
|
|
225
|
+
// 26 September: the package's header on its own is a panel made by hand, whose box forgot to scroll.
|
|
226
|
+
{ what: "the package's ContainerHeader on its own is an error naming Panel", run: probe(`import { ContainerHeader } from '@estiva-app/ui'\n${component('<div className="flex flex-col"><ContainerHeader title="Details" /><div className="px-4">x</div></div>')}`, 'error', '`Panel`') },
|
|
225
227
|
{ what: 'a group heading inside a list is left alone', run: probe(component('<section className="flex flex-col gap-px"><h3 className="flex h-8 items-center gap-2 px-3"><span>Todo</span></h3><div /></section>'), 'none') },
|
|
226
228
|
]),
|
|
227
229
|
ticket('UIG-23', [
|
package/src/index.ts
CHANGED
|
@@ -83,6 +83,7 @@ export { Reaction, type ReactionProps } from './Reaction'
|
|
|
83
83
|
export { SearchInput, type SearchInputProps } from './SearchInput'
|
|
84
84
|
export { SectionHeader, type SectionAction, type SectionHeaderProps } from './SectionHeader'
|
|
85
85
|
export { ContainerHeader, type ContainerHeaderProps } from './ContainerHeader'
|
|
86
|
+
export { Panel, type PanelProps } from './Panel'
|
|
86
87
|
export { ListColumn, type ListColumnProps } from './ListColumn'
|
|
87
88
|
export { CollapsibleSection, type CollapsibleSectionProps } from './CollapsibleSection'
|
|
88
89
|
export { SectionLabel } from './SectionLabel'
|
package/src/registry/app.test.ts
CHANGED
|
@@ -567,7 +567,9 @@ describe('one search over the package and the apps', () => {
|
|
|
567
567
|
repo: 'fixture',
|
|
568
568
|
})
|
|
569
569
|
const found = findInRegistries([packageRegistry, header], 'panel header', { limit: 5 }).map((f) => `${f.entry.repo}:${f.entry.name}`)
|
|
570
|
-
|
|
570
|
+
// Since 0.37.0 an app's panel header is Panel's (26 September): the package answers first either way.
|
|
571
|
+
expect(found[0]).toBe('estiva-ui:Panel')
|
|
572
|
+
expect(found).toContain('estiva-ui:ContainerHeader')
|
|
571
573
|
expect(found).toContain('fixture:Header')
|
|
572
574
|
})
|
|
573
575
|
|
|
@@ -45,16 +45,16 @@ describe('the registry builds', () => {
|
|
|
45
45
|
expect(registry.entries.map((one) => one.name).sort()).toEqual(values.map((one) => one.name).sort())
|
|
46
46
|
})
|
|
47
47
|
|
|
48
|
-
it('counts
|
|
48
|
+
it('counts 88 names over 58 files: 81 components, 6 helpers and 1 hook (Panel joined on 26 September)', () => {
|
|
49
49
|
// The reconciliation GATES.md §24 explains. If this changes, that changes.
|
|
50
50
|
const kinds = registry.entries.reduce<Record<string, number>>((all, one) => ({ ...all, [one.kind]: (all[one.kind] ?? 0) + 1 }), {})
|
|
51
|
-
expect(kinds).toEqual({ component:
|
|
52
|
-
expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(
|
|
51
|
+
expect(kinds).toEqual({ component: 81, helper: 6, hook: 1 })
|
|
52
|
+
expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(58)
|
|
53
53
|
})
|
|
54
54
|
|
|
55
55
|
it('gives every entry a purpose, from its own page or from the comment above it', () => {
|
|
56
56
|
expect(registry.entries.filter((one) => one.purpose.trim() === '')).toEqual([])
|
|
57
|
-
expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(
|
|
57
|
+
expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(58)
|
|
58
58
|
expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(30)
|
|
59
59
|
})
|
|
60
60
|
|
package/stories/Choosing.mdx
CHANGED
|
@@ -75,7 +75,7 @@ fork freely, owe nothing back, mention it on the package's ticket.
|
|
|
75
75
|
| You need | Reach for |
|
|
76
76
|
|---|---|
|
|
77
77
|
| Label–value rows (a details rail) | **Property** — `row` and `stacked` |
|
|
78
|
-
| The bar across the top of a column: its title, and the buttons that act on the whole column | **ContainerHeader** —
|
|
78
|
+
| The bar across the top of a column: its title, and the buttons that act on the whole column | **ContainerHeader** — inside the package's columns; in an app, the header comes with **Panel** or **ListColumn** |
|
|
79
79
|
| A box that can hold more than fits — a list that grows with its data counts, even when today's data fits | **ScrollArea** — its bar is drawn over the content and takes no width |
|
|
80
80
|
| A status, a count or a category, in a small pill | **Chip** — six colour types; the colour is the message, the label makes it legible |
|
|
81
81
|
| One thing — a conversation, a project, an object — as a box you can pick | **Card** — the fill by what it sits on; `href` for a link, `hover="fill"` in a feed |
|
|
@@ -115,11 +115,12 @@ the **floating** TopBar goes with the **Rail** and the content card.
|
|
|
115
115
|
| A 64px icon strip | **Rail**, filled with **RailItem** tiles |
|
|
116
116
|
| The menu button and the logo | the TopBar's `menu` and `logo` slots — collapsing stays yours |
|
|
117
117
|
| The list beside the thing that is open | **ListColumn** — 290px, its name in a **ContainerHeader**, the list under it scrolling |
|
|
118
|
+
| A panel of a page that is not a list — details, a thread, what is open — with a body that must never be cut off | **Panel** — a header, a body that scrolls whenever it is taller, and a `footer` that stays put |
|
|
118
119
|
|
|
119
120
|
## Deliberately not in the package
|
|
120
121
|
|
|
121
122
|
- Anything that knows your **product's objects** — its documents, threads,
|
|
122
123
|
files, tickets. Build it in your app, out of these parts, and keep its
|
|
123
124
|
stories in your app's own Storybook.
|
|
124
|
-
- Page layouts beyond the list column (**ListColumn**) and a
|
|
125
|
-
(**
|
|
125
|
+
- Page layouts beyond the list column (**ListColumn**) and a panel
|
|
126
|
+
(**Panel**) — split views, side rails — not here yet.
|