@estiva-app/ui 0.36.0 → 0.37.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.36.0",
3
+ "version": "0.37.1",
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",
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.36.0",
8
- "exports": 87,
9
- "typeExports": 96,
7
+ "packageVersion": "0.37.1",
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,111 @@
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
+ /** In a plain box with a height, not a flex column: it still fills it, and the body still scrolls. */
88
+ export const InAPlainBox: Story = {
89
+ decorators: [
90
+ (Story) => (
91
+ <div className="h-[560px] w-[380px] border-l border-border-subtle bg-bg-surface">
92
+ {Story()}
93
+ </div>
94
+ ),
95
+ ],
96
+ parameters: { layout: 'fullscreen' },
97
+ render: (args) => <Panel {...args}>{paragraphs(14)}</Panel>,
98
+ }
99
+
100
+ function Broken(): never {
101
+ throw new Error('The body could not draw itself')
102
+ }
103
+
104
+ /** A crash inside it stays inside it: the title stays, and the message takes the body's room. */
105
+ export const WhenTheBodyBreaks: Story = {
106
+ render: (args) => (
107
+ <Panel {...args}>
108
+ <Broken />
109
+ </Panel>
110
+ ),
111
+ }
package/src/Panel.tsx ADDED
@@ -0,0 +1,83 @@
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 it is given, never its content's height, so the body is
21
+ * what scrolls, not the page: the rest of a flex column (`flex-1 min-h-0`), or
22
+ * the whole of a plain box with a height (`h-full`). The second was missing in
23
+ * 0.37.0, and a panel in a plain box grew past it: Peek's thread story pushed
24
+ * its reply box out of its frame. Two panels in one column share it; a
25
+ * `max-h-*` in `className` caps one of them.
26
+ *
27
+ * It keeps a crash inside itself, as ListColumn does: if its body, its actions
28
+ * or its footer break, the panel keeps its title and shows `ErrorBoundary`'s
29
+ * message in the room the body had.
30
+ */
31
+ export interface PanelProps {
32
+ /** The panel's name, in its header. A node for a title that is more than words. */
33
+ title: ReactNode
34
+ /** A chevron after the title, as `ContainerHeader` draws it. */
35
+ chevron?: boolean
36
+ /** The panel's own actions, at the header's right — a `Toolbar` of `ToolbarButton`s, or one `IconButton`. */
37
+ actions?: ReactNode
38
+ /** The body. It scrolls when it is taller than the panel. */
39
+ children: ReactNode
40
+ /** A row fixed under the body that never scrolls — a reply box. */
41
+ footer?: ReactNode
42
+ /** The body's own box: its padding, gap and layout — `px-4 pt-3 pb-4`, `flex flex-col gap-2`. */
43
+ bodyClassName?: string
44
+ /** The body's scrolling box, for a panel that reads or drives its scroll: a thread that opens at its newest reply. */
45
+ bodyRef?: Ref<HTMLDivElement>
46
+ /** Fires as the body scrolls. */
47
+ onBodyScroll?: UIEventHandler<HTMLDivElement>
48
+ /** Placement only: a cap such as `max-h-[60%]` when it shares a column. */
49
+ className?: string
50
+ }
51
+
52
+ function Frame({ title, chevron = false, actions, children, footer, bodyClassName, bodyRef, onBodyScroll, className, broken = false }: PanelProps & { broken?: boolean }) {
53
+ return (
54
+ <div className={cn('flex h-full min-h-0 flex-1 flex-col', className)}>
55
+ <ContainerHeader title={title} chevron={chevron} actions={actions} />
56
+ {broken ? (
57
+ // The message takes the room the body had and centres itself in it.
58
+ <div className="flex min-h-0 flex-1 flex-col px-4">{children}</div>
59
+ ) : (
60
+ <ScrollArea className="min-h-0 flex-1" contentClassName={bodyClassName} viewportRef={bodyRef} onScroll={onBodyScroll}>
61
+ {children}
62
+ </ScrollArea>
63
+ )}
64
+ {!broken && footer && <div className="shrink-0">{footer}</div>}
65
+ </div>
66
+ )
67
+ }
68
+
69
+ export function Panel(props: PanelProps) {
70
+ return (
71
+ <ErrorBoundary
72
+ label="panel"
73
+ frame={(message) => (
74
+ // Only the name comes back with the message: the actions and the footer may be what threw.
75
+ <Frame title={props.title} chevron={props.chevron} className={props.className} broken>
76
+ {message}
77
+ </Frame>
78
+ )}
79
+ >
80
+ <Frame {...props} />
81
+ </ErrorBoundary>
82
+ )
83
+ }
@@ -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 while the pointer is over the region or
10
- the content is moving; nothing moves when it appears.
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 while the pointer is over it or the list is moving. */
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]">
@@ -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; the bar shows while the pointer is over the region or the
14
- * content is moving, and fades otherwise.
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 ContainerHeader is the part" },
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 `ContainerHeader` from @estiva-app/ui: 48px, the title, the column's buttons at the right edge (`actions`), a hairline under it.",
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 =>
@@ -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 = ['ContainerHeader'] }: AppCheckOptions): GateTicket[] {
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: 'a hand-made column header is an error naming ContainerHeader', 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 ContainerHeader') },
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'
@@ -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
- expect(found[0]).toBe('estiva-ui:ContainerHeader')
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 87 names over 57 files: 80 components, 6 helpers and 1 hook (Lightbox joined in UIG-35)', () => {
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: 80, helper: 6, hook: 1 })
52
- expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(57)
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)
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
 
@@ -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** — one per column; the actions as IconButtons in `actions` |
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 column's bar
125
- (**ContainerHeader**) — split views, side rails — not here yet.
125
+ - Page layouts beyond the list column (**ListColumn**) and a panel
126
+ (**Panel**) — split views, side rails — not here yet.