Skip to content

Commit 4303412

Browse files
authored
Refined Shade unified page header component (TryGhost#27860)
ref https://linear.app/ghost/issue/DES-1382/tags-list-page-header-migration - Restructures `PageHeader` (`apps/shade`) into a single purpose component - Updates `List Page` component to incorporate PageHeader, ViewBar, FilterBar with a sticky header - Shade-only change — no consumer apps touched, the components changed in this PR are not consumed in the Admin yet
1 parent 92e7b69 commit 4303412

16 files changed

Lines changed: 1004 additions & 595 deletions

apps/shade/.storybook/preview.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,9 @@ const preview: Preview = {
9696
'Components',
9797
['Components Guide', '*'],
9898
'Patterns',
99-
['Patterns Guide', 'Page Types', '*'],
99+
['Patterns Guide', '*'],
100+
'Page Templates',
101+
['Page Types', '*'],
100102
'Recipes',
101103
['Recipes Guide', '*'],
102104
'Posts–Stats',

apps/shade/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@
3333
"import": "./es/patterns.js",
3434
"default": "./es/patterns.js"
3535
},
36+
"./page-templates": {
37+
"types": "./types/page-templates.d.ts",
38+
"import": "./es/page-templates.js",
39+
"default": "./es/page-templates.js"
40+
},
3641
"./posts-stats": {
3742
"types": "./types/posts-stats.d.ts",
3843
"import": "./es/posts-stats.js",

apps/shade/src/components/page-templates/list-page.stories.tsx

Lines changed: 374 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import React from 'react';
2+
import {cn} from '@/lib/utils';
3+
4+
type PropsWithChildrenAndClassName = React.PropsWithChildren & {
5+
className?: string;
6+
};
7+
8+
type ListPageProps = PropsWithChildrenAndClassName;
9+
10+
/**
11+
* Sticky, full-bleed header stack. Place `PageHeader`, `ViewBar`, and/or
12+
* `FilterBar` directly inside — they stack with a consistent gap.
13+
*
14+
* Full-bleed is achieved by negating the parent's horizontal padding
15+
* (`-mx-4 lg:-mx-8`) and re-applying it (`px-4 lg:px-8`), so the blurred
16+
* background spans edge-to-edge while content stays aligned with the body.
17+
*
18+
* Pass `sticky={false} blurredBackground={false}` to `PageHeader` when using
19+
* it here — stickiness and blur are handled by `ListPage.Header` instead.
20+
*/
21+
function ListPageHeader({className, children}: PropsWithChildrenAndClassName) {
22+
return (
23+
<div
24+
className={cn(
25+
'-mx-4 lg:-mx-8 px-4 lg:px-8',
26+
'sticky top-0 z-50',
27+
'bg-gradient-to-b from-background via-background/70 to-background/70 backdrop-blur-md dark:bg-black',
28+
'flex flex-col gap-3 py-4',
29+
className
30+
)}
31+
data-list-page='header'
32+
>
33+
{children}
34+
</div>
35+
);
36+
}
37+
38+
function ListPageBody({className, children}: PropsWithChildrenAndClassName) {
39+
return (
40+
<div
41+
className={cn('flex-1 min-h-0 min-w-0', className)}
42+
data-list-page='body'
43+
>
44+
{children}
45+
</div>
46+
);
47+
}
48+
49+
function ListPagePagination({className, children}: PropsWithChildrenAndClassName) {
50+
return (
51+
<div
52+
className={cn('flex items-center justify-center py-4', className)}
53+
data-list-page='pagination'
54+
>
55+
{children}
56+
</div>
57+
);
58+
}
59+
60+
type ListPageComponent = React.FC<ListPageProps> & {
61+
Header: React.FC<PropsWithChildrenAndClassName>;
62+
Body: React.FC<PropsWithChildrenAndClassName>;
63+
Pagination: React.FC<PropsWithChildrenAndClassName>;
64+
};
65+
66+
/**
67+
* ListPage is the canonical recipe for the **List page** type — the recurring
68+
* structure used by Members, Tags, Comments, Automations, etc.
69+
*
70+
* It is intentionally thin: a vertical flex-col stack with horizontal padding.
71+
* Drop the named slots in as direct children.
72+
*
73+
* Composition:
74+
* - `<ListPage.Header>` — sticky, blurred, full-bleed chrome band. Place
75+
* `PageHeader`, `ViewBar`, and/or `FilterBar` directly inside as siblings.
76+
* `FilterBar` auto-collapses when it has no active filters.
77+
* - `<ListPage.Body>` — main content area (table, list, or empty state)
78+
* - `<ListPage.Pagination>` — optional centered pagination row (load-more, page links)
79+
*/
80+
const ListPage: ListPageComponent = Object.assign(
81+
function ListPage({className, children}: ListPageProps) {
82+
return (
83+
<div
84+
className={cn('flex flex-col h-full min-h-0 px-4 lg:px-8', className)}
85+
data-list-page='list-page'
86+
>
87+
{children}
88+
</div>
89+
);
90+
},
91+
{
92+
Header: ListPageHeader,
93+
Body: ListPageBody,
94+
Pagination: ListPagePagination
95+
}
96+
);
97+
98+
export {
99+
ListPage,
100+
ListPageHeader,
101+
ListPageBody,
102+
ListPagePagination
103+
};
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import {useState} from 'react';
2+
import type {Meta, StoryObj} from '@storybook/react-vite';
3+
import {Button} from '@/components/ui/button';
4+
import {FilterBar} from '@/components/patterns/filter-bar';
5+
import {Filters, createFilter, type Filter, type FilterFieldConfig} from '@/components/patterns/filters';
6+
import {Circle, X} from 'lucide-react';
7+
8+
const meta = {
9+
title: 'Patterns / Filter Bar',
10+
component: FilterBar,
11+
tags: ['autodocs'],
12+
parameters: {
13+
layout: 'fullscreen'
14+
}
15+
} satisfies Meta<typeof FilterBar>;
16+
17+
export default meta;
18+
type Story = StoryObj<typeof FilterBar>;
19+
20+
const memberStatusFields: FilterFieldConfig[] = [
21+
{
22+
key: 'memberStatus',
23+
label: 'Member status',
24+
type: 'select',
25+
icon: <Circle className="size-4" />,
26+
options: [
27+
{value: 'free', label: 'Free'},
28+
{value: 'paid', label: 'Paid'},
29+
{value: 'complimentary', label: 'Complimentary'}
30+
]
31+
}
32+
];
33+
34+
export const WithFilters: Story = {
35+
name: 'With filters',
36+
render: () => {
37+
const [filters, setFilters] = useState<Filter[]>([
38+
createFilter('memberStatus', 'is', ['complimentary'])
39+
]);
40+
41+
return (
42+
<FilterBar>
43+
<Filters
44+
addButtonText="Add filter"
45+
clearButtonIcon={<X className="size-4" />}
46+
clearButtonText="Clear"
47+
fields={memberStatusFields}
48+
filters={filters}
49+
showClearButton={true}
50+
onChange={setFilters}
51+
/>
52+
<Button variant="ghost">Save view</Button>
53+
</FilterBar>
54+
);
55+
}
56+
};
57+
58+
export const Empty: Story = {
59+
render: () => {
60+
const [filters, setFilters] = useState<Filter[]>([]);
61+
62+
return (
63+
<div className="space-y-2">
64+
<p className="px-4 text-sm text-muted-foreground">FilterBar auto-collapses when empty:</p>
65+
<FilterBar>
66+
<Filters
67+
addButtonText="Add filter"
68+
fields={memberStatusFields}
69+
filters={filters}
70+
onChange={setFilters}
71+
/>
72+
</FilterBar>
73+
</div>
74+
);
75+
}
76+
};
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import React from 'react';
2+
import {Inline} from '@/components/primitives';
3+
import {cn} from '@/lib/utils';
4+
5+
type FilterBarProps = React.PropsWithChildren & {
6+
className?: string;
7+
};
8+
9+
/**
10+
* FilterBar is a full-width horizontal row for active filters and related
11+
* controls (clear, save view, etc.). It renders null when it has no children,
12+
* so consumers can always mount it without conditional wrapping.
13+
*
14+
* Typical usage:
15+
* <FilterBar>
16+
* <Filters ... />
17+
* <Button variant="ghost">Save view</Button>
18+
* </FilterBar>
19+
*/
20+
function FilterBar({className, children}: FilterBarProps) {
21+
if (React.Children.count(children) === 0) {
22+
return null;
23+
}
24+
25+
return (
26+
<Inline
27+
align='start'
28+
className={cn('w-full', className)}
29+
data-slot='filter-bar'
30+
gap='sm'
31+
justify='between'
32+
>
33+
{children}
34+
</Inline>
35+
);
36+
}
37+
38+
export {FilterBar};

0 commit comments

Comments
 (0)