@podoba/react 0.0.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/README.md +25 -0
- package/package.json +39 -0
- package/src/components/button.tsx +53 -0
- package/src/components/checkbox.tsx +64 -0
- package/src/components/context-menu.tsx +397 -0
- package/src/components/dialog.tsx +203 -0
- package/src/components/disclosure.tsx +36 -0
- package/src/components/dropdown-menu.tsx +143 -0
- package/src/components/input.tsx +70 -0
- package/src/components/radio.tsx +72 -0
- package/src/components/section-tabs.tsx +103 -0
- package/src/components/select.tsx +115 -0
- package/src/components/separator.tsx +36 -0
- package/src/components/switch.tsx +40 -0
- package/src/components/tabs.tsx +40 -0
- package/src/components/text.tsx +68 -0
- package/src/components/textarea.tsx +59 -0
- package/src/components/toast.tsx +177 -0
- package/src/components/tooltip.tsx +66 -0
- package/src/components/view-toggle.tsx +101 -0
- package/src/index.ts +37 -0
- package/src/layout/app-shell.tsx +184 -0
- package/src/layout/card.tsx +41 -0
- package/src/layout/page-container.tsx +36 -0
- package/src/layout/persistent-page-shell.tsx +134 -0
- package/src/layout/section.tsx +35 -0
- package/src/layout/topbar.tsx +220 -0
- package/src/utils/uic.ts +232 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { ToggleButton, ToggleButtonGroup } from 'react-aria-components'
|
|
3
|
+
import { Tooltip, TooltipTrigger } from './tooltip'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* ViewToggle — controlled segmented icon-toggle (port of gs-platform's
|
|
7
|
+
* `ViewToggle`).
|
|
8
|
+
*
|
|
9
|
+
* gs renders N 36px-square icon buttons; exactly one is active. The Tasks /
|
|
10
|
+
* Projects screens use it as the view-switcher (Table / Kanban / Calendar), so
|
|
11
|
+
* the API is kept generic: pass any list of `{ id, icon, label }` options.
|
|
12
|
+
*
|
|
13
|
+
* Built on React Aria Components `ToggleButtonGroup` + `ToggleButton` (single
|
|
14
|
+
* selection, empty-selection disallowed). RAC supplies the roving-tabindex
|
|
15
|
+
* radiogroup-style keyboard model (arrow keys move + select), `aria-pressed`
|
|
16
|
+
* state and focus management for free. Each option's `label` is its accessible
|
|
17
|
+
* name (`aria-label`) and its hover/focus tooltip.
|
|
18
|
+
*
|
|
19
|
+
* gs SCSS → our tokens (Tailwind + `@app/tokens` CSS vars, no SCSS — hard rule
|
|
20
|
+
* #3). The gs active treatment is a blue `#007bff` border — flagged OFF-brand,
|
|
21
|
+
* so the active segment uses our brand treatment instead (`fg` border +
|
|
22
|
+
* `surface-muted` fill). No blue anywhere.
|
|
23
|
+
* - resting border `rgba(0,0,0,.1)` → `border-fg/10` (translucent black, closest)
|
|
24
|
+
* - radius `4px` → `rounded-[4px]`
|
|
25
|
+
* - hover bg `#f9f9f9` → `surface-card` (#f7f6f2)
|
|
26
|
+
* - active border (gs blue) → `fg` (#0d0d0d) — on-brand
|
|
27
|
+
* - active bg → `surface-muted` (#eceae1)
|
|
28
|
+
* - active press `scale(.95)` → `data-[pressed]:scale-95`
|
|
29
|
+
* - icon box `16px` → `h-4 w-4`
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export type ViewToggleOption = {
|
|
33
|
+
/** Stable identifier — matched against `value` and returned by `onChange`. */
|
|
34
|
+
id: string
|
|
35
|
+
/** Icon node rendered inside the 16px box (decorative; `label` names it). */
|
|
36
|
+
icon: ReactNode
|
|
37
|
+
/** Accessible name + hover/focus tooltip for the option. */
|
|
38
|
+
label: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type ViewToggleProps = {
|
|
42
|
+
/** The selectable view options, rendered left-to-right. */
|
|
43
|
+
options: ViewToggleOption[]
|
|
44
|
+
/** Currently-active option id (controlled). */
|
|
45
|
+
value: string
|
|
46
|
+
/** Fired with the newly-selected option id. */
|
|
47
|
+
onChange: (id: string) => void
|
|
48
|
+
/** Accessible name for the whole group (e.g. "Switch view"). */
|
|
49
|
+
'aria-label': string
|
|
50
|
+
/** Disable the entire group. */
|
|
51
|
+
isDisabled?: boolean
|
|
52
|
+
className?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const buttonClass =
|
|
56
|
+
'inline-flex h-9 w-9 shrink-0 cursor-pointer select-none items-center justify-center rounded-[4px] ' +
|
|
57
|
+
'border border-fg/10 bg-surface text-fg outline-none transition-all duration-200 ' +
|
|
58
|
+
'data-[hovered]:bg-surface-card ' +
|
|
59
|
+
// On-brand active treatment (NOT gs blue): fg border + surface-muted fill.
|
|
60
|
+
'data-[selected]:border-fg data-[selected]:bg-surface-muted ' +
|
|
61
|
+
'data-[pressed]:scale-95 ' +
|
|
62
|
+
'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring data-[focus-visible]:ring-offset-2 ' +
|
|
63
|
+
'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50'
|
|
64
|
+
|
|
65
|
+
export function ViewToggle({
|
|
66
|
+
options,
|
|
67
|
+
value,
|
|
68
|
+
onChange,
|
|
69
|
+
isDisabled,
|
|
70
|
+
className,
|
|
71
|
+
'aria-label': ariaLabel,
|
|
72
|
+
}: ViewToggleProps) {
|
|
73
|
+
return (
|
|
74
|
+
<ToggleButtonGroup
|
|
75
|
+
aria-label={ariaLabel}
|
|
76
|
+
selectionMode="single"
|
|
77
|
+
disallowEmptySelection
|
|
78
|
+
isDisabled={isDisabled}
|
|
79
|
+
selectedKeys={[value]}
|
|
80
|
+
onSelectionChange={keys => {
|
|
81
|
+
const next = [...keys][0]
|
|
82
|
+
if (next != null && String(next) !== value) onChange(String(next))
|
|
83
|
+
}}
|
|
84
|
+
className={['inline-flex items-center gap-1', className].filter(Boolean).join(' ')}
|
|
85
|
+
>
|
|
86
|
+
{options.map(option => (
|
|
87
|
+
<TooltipTrigger key={option.id}>
|
|
88
|
+
<ToggleButton id={option.id} aria-label={option.label} className={buttonClass}>
|
|
89
|
+
<span
|
|
90
|
+
className="flex h-4 w-4 items-center justify-center [&_svg]:h-4 [&_svg]:w-4"
|
|
91
|
+
aria-hidden="true"
|
|
92
|
+
>
|
|
93
|
+
{option.icon}
|
|
94
|
+
</span>
|
|
95
|
+
</ToggleButton>
|
|
96
|
+
<Tooltip>{option.label}</Tooltip>
|
|
97
|
+
</TooltipTrigger>
|
|
98
|
+
))}
|
|
99
|
+
</ToggleButtonGroup>
|
|
100
|
+
)
|
|
101
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// @podoba/react — the universal component library.
|
|
2
|
+
//
|
|
3
|
+
// React Aria Components + Tailwind, composed with `uic`. Seeded from graphic-standard's
|
|
4
|
+
// @app/ui — atomic primitives + layout ONLY. Product-specific components (schema
|
|
5
|
+
// renderer, delivery/approval modals, brand headers) stay in GS and consume this.
|
|
6
|
+
// See ../../EXTRACTION.md.
|
|
7
|
+
|
|
8
|
+
// --- factory ---
|
|
9
|
+
export { uic, uiconfig, type ConfigVariants, type NoInfer } from "./utils/uic";
|
|
10
|
+
|
|
11
|
+
// --- primitives ---
|
|
12
|
+
export * from "./components/button";
|
|
13
|
+
export * from "./components/input";
|
|
14
|
+
export * from "./components/textarea";
|
|
15
|
+
export * from "./components/checkbox";
|
|
16
|
+
export * from "./components/radio";
|
|
17
|
+
export * from "./components/switch";
|
|
18
|
+
export * from "./components/select";
|
|
19
|
+
export * from "./components/dialog";
|
|
20
|
+
export * from "./components/dropdown-menu";
|
|
21
|
+
export * from "./components/context-menu";
|
|
22
|
+
export * from "./components/tooltip";
|
|
23
|
+
export * from "./components/toast";
|
|
24
|
+
export * from "./components/disclosure";
|
|
25
|
+
export * from "./components/tabs";
|
|
26
|
+
export * from "./components/section-tabs";
|
|
27
|
+
export * from "./components/separator";
|
|
28
|
+
export * from "./components/text";
|
|
29
|
+
export * from "./components/view-toggle";
|
|
30
|
+
|
|
31
|
+
// --- layout ---
|
|
32
|
+
export * from "./layout/card";
|
|
33
|
+
export * from "./layout/section";
|
|
34
|
+
export * from "./layout/page-container";
|
|
35
|
+
export * from "./layout/app-shell";
|
|
36
|
+
export * from "./layout/topbar";
|
|
37
|
+
export * from "./layout/persistent-page-shell";
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { type ReactNode, useId, useState } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Button as RACButton,
|
|
4
|
+
Dialog as RACDialog,
|
|
5
|
+
Modal as RACModal,
|
|
6
|
+
ModalOverlay as RACModalOverlay,
|
|
7
|
+
} from 'react-aria-components'
|
|
8
|
+
import { uic } from '../utils/uic'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* AppShell — top-level application layout (ported pattern from gs-manager's
|
|
12
|
+
* `AppShell` + `AppShell.module.scss`).
|
|
13
|
+
*
|
|
14
|
+
* gs-manager used a full-height flex column (`100vh`, `overflow: hidden`) with
|
|
15
|
+
* a sticky `<header>` topbar and a single scrolling `<main>`. The Phase-1 GS
|
|
16
|
+
* Platform shell adds a left sidebar, so the SCSS intent is reimplemented in
|
|
17
|
+
* Tailwind as a CSS grid:
|
|
18
|
+
*
|
|
19
|
+
* grid-rows-[auto_1fr] — topbar row sizes to content, content fills the rest
|
|
20
|
+
* grid-cols-[auto_1fr] — sidebar takes its intrinsic width, main fills
|
|
21
|
+
*
|
|
22
|
+
* Slots (all optional except `main`):
|
|
23
|
+
* banner — full-bleed bar at the very top of the scroll column; scrolls away
|
|
24
|
+
* topbar — sticky header that pins to the top once the banner scrolls past
|
|
25
|
+
* sidebar — left rail; `<aside>` landmark. Collapses to a drawer on mobile
|
|
26
|
+
* main — `<main>` landmark content region
|
|
27
|
+
*
|
|
28
|
+
* Scroll model: the shell is `h-screen overflow-hidden`; the content side is a
|
|
29
|
+
* single vertical scroll column holding [full-bleed banner · sticky topbar ·
|
|
30
|
+
* main]. The banner sits at the top of that column so it SCROLLS AWAY with the
|
|
31
|
+
* content, while the topbar is `position: sticky; top: 0` and PINS to the top of
|
|
32
|
+
* the viewport once the banner scrolls past it (the classic announcement-bar +
|
|
33
|
+
* sticky-header pattern). The sidebar is a separate full-height rail outside the
|
|
34
|
+
* scroller, so it never scrolls with the content.
|
|
35
|
+
*
|
|
36
|
+
* Responsive: below `md` the sidebar leaves the grid flow and becomes an
|
|
37
|
+
* off-canvas drawer toggled by a hamburger button rendered in the topbar row.
|
|
38
|
+
* The drawer is a React Aria `ModalOverlay`/`Dialog` (accessibility.md): focus
|
|
39
|
+
* moves into the dialog on open and restores to the trigger on close, focus is
|
|
40
|
+
* trapped while open, and `Esc` / clicking the dimmed backdrop dismisses. The
|
|
41
|
+
* trigger advertises the drawer via `aria-expanded` + `aria-controls`.
|
|
42
|
+
*
|
|
43
|
+
* i18n: `@app/ui` ships no i18n, so when `sidebar` is passed the accessible
|
|
44
|
+
* names (`sidebarLabel`, `drawerToggleLabel`) are REQUIRED from the consumer —
|
|
45
|
+
* translated there, never English defaults baked in here.
|
|
46
|
+
*/
|
|
47
|
+
const Root = uic('div', {
|
|
48
|
+
displayName: 'AppShell',
|
|
49
|
+
baseClass:
|
|
50
|
+
'grid h-screen w-full overflow-hidden bg-surface ' +
|
|
51
|
+
// one full-height row; sidebar (auto) + content (1fr) columns on md+
|
|
52
|
+
'grid-rows-[100%] grid-cols-[1fr] md:grid-cols-[auto_1fr]',
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const SidebarRail = uic('aside', {
|
|
56
|
+
displayName: 'AppShell.Sidebar',
|
|
57
|
+
baseClass: 'h-full w-64 shrink-0 overflow-y-auto border-r border-border bg-surface',
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const Main = uic('main', {
|
|
61
|
+
displayName: 'AppShell.Main',
|
|
62
|
+
// gs-manager `.content`: white, 24px horizontal page padding (page-edge-padding-
|
|
63
|
+
// double) so page content is inset from the viewport edge — not flush left.
|
|
64
|
+
// Vertical padding gives every page breathing room. The SCROLL lives on the
|
|
65
|
+
// parent column (so the banner can scroll away under the sticky topbar), not here.
|
|
66
|
+
baseClass: 'min-w-0 flex-1 bg-surface px-6 pb-6 pt-6',
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
export type AppShellProps = {
|
|
70
|
+
/**
|
|
71
|
+
* Full-bleed banner slot rendered ABOVE the topbar, edge-to-edge (e.g. the
|
|
72
|
+
* demo-workspace bar). Takes no height when its content is null.
|
|
73
|
+
*/
|
|
74
|
+
banner?: ReactNode
|
|
75
|
+
/** Topbar slot — typically `<Topbar>…</Topbar>`. Spans the full width. */
|
|
76
|
+
topbar?: ReactNode
|
|
77
|
+
/** Main scrollable content region (`<main>` landmark). */
|
|
78
|
+
children: ReactNode
|
|
79
|
+
className?: string
|
|
80
|
+
} & (
|
|
81
|
+
| {
|
|
82
|
+
/** Left sidebar; collapses to a drawer below `md`. */
|
|
83
|
+
sidebar: ReactNode
|
|
84
|
+
/** Accessible name of the sidebar landmark + its mobile drawer dialog (translated by the consumer). */
|
|
85
|
+
sidebarLabel: string
|
|
86
|
+
/** Accessible label for the mobile drawer toggle button (translated by the consumer). */
|
|
87
|
+
drawerToggleLabel: string
|
|
88
|
+
}
|
|
89
|
+
| {
|
|
90
|
+
sidebar?: undefined
|
|
91
|
+
sidebarLabel?: undefined
|
|
92
|
+
drawerToggleLabel?: undefined
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
export const AppShell = ({
|
|
97
|
+
banner,
|
|
98
|
+
topbar,
|
|
99
|
+
sidebar,
|
|
100
|
+
sidebarLabel,
|
|
101
|
+
drawerToggleLabel,
|
|
102
|
+
children,
|
|
103
|
+
className,
|
|
104
|
+
}: AppShellProps) => {
|
|
105
|
+
const [drawerOpen, setDrawerOpen] = useState(false)
|
|
106
|
+
const drawerId = useId()
|
|
107
|
+
const hasSidebar = Boolean(sidebar)
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<Root className={className}>
|
|
111
|
+
{/* Desktop sidebar — a full-height rail OUTSIDE the scroll column, so it
|
|
112
|
+
never scrolls with the content. */}
|
|
113
|
+
{hasSidebar ? (
|
|
114
|
+
<SidebarRail aria-label={sidebarLabel} className="hidden md:block">
|
|
115
|
+
{sidebar}
|
|
116
|
+
</SidebarRail>
|
|
117
|
+
) : null}
|
|
118
|
+
|
|
119
|
+
{/* Mobile drawer — off-canvas RAC modal dialog toggled by the hamburger.
|
|
120
|
+
ModalOverlay supplies the focus trap, initial focus move, focus restore,
|
|
121
|
+
Esc-to-close and backdrop dismissal; the visual (dimmed backdrop +
|
|
122
|
+
left-anchored rail with shadow) matches the previous hand-rolled drawer. */}
|
|
123
|
+
{hasSidebar ? (
|
|
124
|
+
<RACModalOverlay
|
|
125
|
+
isOpen={drawerOpen}
|
|
126
|
+
onOpenChange={setDrawerOpen}
|
|
127
|
+
isDismissable
|
|
128
|
+
className="fixed inset-0 z-40 bg-black/40 md:hidden"
|
|
129
|
+
>
|
|
130
|
+
<RACModal className="absolute inset-y-0 left-0 z-50 h-full outline-none">
|
|
131
|
+
<RACDialog id={drawerId} aria-label={sidebarLabel} className="h-full outline-none">
|
|
132
|
+
<SidebarRail aria-label={sidebarLabel} className="shadow-lg">
|
|
133
|
+
{sidebar}
|
|
134
|
+
</SidebarRail>
|
|
135
|
+
</RACDialog>
|
|
136
|
+
</RACModal>
|
|
137
|
+
</RACModalOverlay>
|
|
138
|
+
) : null}
|
|
139
|
+
|
|
140
|
+
{/* Content column — the single vertical scroller. Holds the full-bleed
|
|
141
|
+
banner, the sticky topbar, and main. Spans both columns when there is
|
|
142
|
+
no sidebar so it always fills the viewport. */}
|
|
143
|
+
<div
|
|
144
|
+
className={
|
|
145
|
+
'flex min-h-0 min-w-0 flex-col overflow-y-auto overflow-x-hidden bg-surface' +
|
|
146
|
+
(hasSidebar ? '' : ' col-span-full')
|
|
147
|
+
}
|
|
148
|
+
>
|
|
149
|
+
{/* Full-bleed banner (edge-to-edge, no page padding). Sits at the top of
|
|
150
|
+
the scroll column so it scrolls away with the content. */}
|
|
151
|
+
{banner ? <div className="shrink-0">{banner}</div> : null}
|
|
152
|
+
|
|
153
|
+
{/* Sticky topbar — pins to the top of the viewport once the banner
|
|
154
|
+
scrolls past. 24px horizontal page padding (gs `.topbar` `padding: 0
|
|
155
|
+
spacing-6`, zero vertical — the Topbar header carries its own 72px
|
|
156
|
+
height + bottom border). The border lives on the Topbar header so it
|
|
157
|
+
aligns (inset) with the padded content below. `bg-surface` keeps content
|
|
158
|
+
from showing through while pinned. z-20 sits above scrolling content but
|
|
159
|
+
below the mobile drawer (z-40/z-50); gs's literal z-10001 is to clear a
|
|
160
|
+
fullscreen overlay we don't have. */}
|
|
161
|
+
<div className="sticky top-0 z-20 flex shrink-0 items-center bg-surface px-6">
|
|
162
|
+
{hasSidebar ? (
|
|
163
|
+
<RACButton
|
|
164
|
+
aria-label={drawerToggleLabel}
|
|
165
|
+
aria-expanded={drawerOpen}
|
|
166
|
+
aria-controls={drawerOpen ? drawerId : undefined}
|
|
167
|
+
onPress={() => setDrawerOpen((open) => !open)}
|
|
168
|
+
className={
|
|
169
|
+
'ml-2 flex h-9 w-9 shrink-0 items-center justify-center rounded-md text-fg outline-none ' +
|
|
170
|
+
'transition-colors hover:bg-surface-muted md:hidden ' +
|
|
171
|
+
'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring'
|
|
172
|
+
}
|
|
173
|
+
>
|
|
174
|
+
<span aria-hidden="true">☰</span>
|
|
175
|
+
</RACButton>
|
|
176
|
+
) : null}
|
|
177
|
+
<div className="min-w-0 flex-1">{topbar}</div>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
<Main>{children}</Main>
|
|
181
|
+
</div>
|
|
182
|
+
</Root>
|
|
183
|
+
)
|
|
184
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { uic } from '../utils/uic'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Card — surface panel (port of gs-manager's `Card`).
|
|
5
|
+
*
|
|
6
|
+
* gs SCSS → our tokens (Tailwind + `@app/tokens` CSS vars, no SCSS — hard rule #3):
|
|
7
|
+
* - `--radius-lg` (8px) → `rounded-lg` (our `--radius-lg` is 0.5rem = 8px). NB the
|
|
8
|
+
* `rounded-panel` token is 1.1rem (~17.6px), NOT the 8px card radius — gs's Card
|
|
9
|
+
* used `--radius-lg`, so this maps to `rounded-lg`.
|
|
10
|
+
* - `--color-background-secondary` (the light card fill) → `bg-surface-card`
|
|
11
|
+
* (our `--color-surface-card` = #f7f6f2).
|
|
12
|
+
* - `outlined` → 1px `--color-border` (#eceae1) → `border border-border`.
|
|
13
|
+
* - `elevated` → `card-shadow('md')` → `shadow-md`.
|
|
14
|
+
* - padding sm/md/lg = 16/24/32px → `p-4` / `p-6` / `p-8`.
|
|
15
|
+
*
|
|
16
|
+
* Presentational only (hard rule #1) — no app imports. `relative` is preserved from
|
|
17
|
+
* gs so absolutely-positioned children (e.g. a Kanban overlay) anchor to the card.
|
|
18
|
+
*/
|
|
19
|
+
export const Card = uic('div', {
|
|
20
|
+
displayName: 'Card',
|
|
21
|
+
baseClass: 'relative rounded-lg bg-surface-card',
|
|
22
|
+
variants: {
|
|
23
|
+
variant: {
|
|
24
|
+
plain: '',
|
|
25
|
+
outlined: 'border border-border',
|
|
26
|
+
elevated: 'shadow-md',
|
|
27
|
+
},
|
|
28
|
+
padding: {
|
|
29
|
+
none: 'p-0',
|
|
30
|
+
sm: 'p-4',
|
|
31
|
+
md: 'p-6',
|
|
32
|
+
lg: 'p-8',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
defaultVariants: {
|
|
36
|
+
variant: 'plain',
|
|
37
|
+
padding: 'md',
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
export type CardProps = React.ComponentProps<typeof Card>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { uic } from '../utils/uic'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PageContainer — max-width page shell (port of gs-manager's `PageContainer`).
|
|
5
|
+
*
|
|
6
|
+
* gs SCSS → our tokens (Tailwind + `@app/tokens` CSS vars, no SCSS — hard rule #3).
|
|
7
|
+
* No `size` → full-width with no constraint (horizontal padding is owned by the
|
|
8
|
+
* AppShell content, as in gs). The `size` scale maps to Tailwind's stock screen
|
|
9
|
+
* max-widths, matching gs's pixel values exactly:
|
|
10
|
+
* - sm → 640px, centred → `max-w-screen-sm mx-auto`
|
|
11
|
+
* - md → 768px, centred → `max-w-screen-md mx-auto`
|
|
12
|
+
* - lg → 1024px, centred → `max-w-screen-lg mx-auto`
|
|
13
|
+
* - xl → 1280px, LEFT-aligned to match the header, then released to full width
|
|
14
|
+
* at ≥1200px → `max-w-screen-xl ml-0 mr-0 min-[1200px]:max-w-full`
|
|
15
|
+
* - 2xl → 1536px, centred, then released to full-width left-aligned at ≥1600px →
|
|
16
|
+
* `max-w-screen-2xl mx-auto min-[1600px]:max-w-full min-[1600px]:mx-0`
|
|
17
|
+
* - full → no constraint → `max-w-full`
|
|
18
|
+
*
|
|
19
|
+
* Presentational only (hard rule #1) — no app imports.
|
|
20
|
+
*/
|
|
21
|
+
export const PageContainer = uic('div', {
|
|
22
|
+
displayName: 'PageContainer',
|
|
23
|
+
baseClass: 'w-full',
|
|
24
|
+
variants: {
|
|
25
|
+
size: {
|
|
26
|
+
sm: 'max-w-screen-sm mx-auto',
|
|
27
|
+
md: 'max-w-screen-md mx-auto',
|
|
28
|
+
lg: 'max-w-screen-lg mx-auto',
|
|
29
|
+
xl: 'max-w-screen-xl ml-0 mr-0 min-[1200px]:max-w-full',
|
|
30
|
+
'2xl': 'max-w-screen-2xl mx-auto min-[1600px]:max-w-full min-[1600px]:mx-0',
|
|
31
|
+
full: 'max-w-full',
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
export type PageContainerProps = React.ComponentProps<typeof PageContainer>
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { type ReactNode, useEffect } from 'react'
|
|
2
|
+
import { uic } from '../utils/uic'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* PersistentPageShell — page-level shell with a persistent header (hero +
|
|
6
|
+
* actions + navigation) and a content region that swaps between ready / loading
|
|
7
|
+
* / empty / error states without the header shifting (ported pattern from
|
|
8
|
+
* gs-manager's `PersistentPageShell`).
|
|
9
|
+
*
|
|
10
|
+
* gs-manager SCSS intent → Tailwind:
|
|
11
|
+
* .shell flex column, gap-6, full width → `flex flex-col gap-6 w-full`
|
|
12
|
+
* .header flex column header cluster, optionally sticky
|
|
13
|
+
* .topRow grid 2fr/1fr (hero | actions) → `grid grid-cols-[2fr_1fr]`
|
|
14
|
+
* .actionSlotPlaceholder reserves action space → `invisible pointer-events-none`
|
|
15
|
+
* .navigationSlotEmpty reserves nav row height → `min-h-10`
|
|
16
|
+
* .contentSlot `<main>` content region → `w-full`
|
|
17
|
+
*
|
|
18
|
+
* "Persistent" = the header (hero/actions/navigation) stays mounted while the
|
|
19
|
+
* content region swaps via `contentState`; reserving action/nav space (the
|
|
20
|
+
* placeholder modifiers) prevents layout shift when those slots are empty,
|
|
21
|
+
* which is also what keeps viewport-locked modals from nudging the page.
|
|
22
|
+
*
|
|
23
|
+
* Page title: pass `title` and it is written to `document.title` (React 19,
|
|
24
|
+
* SSR-safe via `useEffect` so `renderToString` is a no-op).
|
|
25
|
+
*/
|
|
26
|
+
export type PersistentPageShellContentState = 'ready' | 'loading' | 'empty' | 'error'
|
|
27
|
+
export type PersistentPageShellNavigationMode = 'auto' | 'preserve' | 'hidden'
|
|
28
|
+
|
|
29
|
+
export type PersistentPageShellProps = {
|
|
30
|
+
className?: string
|
|
31
|
+
/** Page heading / title region (left of the top row). */
|
|
32
|
+
heroSlot?: ReactNode
|
|
33
|
+
/** Primary actions (right of the top row). Space is reserved if absent. */
|
|
34
|
+
actionSlot?: ReactNode
|
|
35
|
+
/** Secondary navigation row beneath the top row (tabs, breadcrumbs…). */
|
|
36
|
+
navigationSlot?: ReactNode
|
|
37
|
+
/** Main content, shown when `contentState === 'ready'`. */
|
|
38
|
+
contentSlot: ReactNode
|
|
39
|
+
/** When set, drives which slot the content region renders. */
|
|
40
|
+
contentState?: PersistentPageShellContentState
|
|
41
|
+
loadingSlot?: ReactNode
|
|
42
|
+
emptySlot?: ReactNode
|
|
43
|
+
errorSlot?: ReactNode
|
|
44
|
+
/** `preserve` always reserves the nav row; `auto` only when content exists. */
|
|
45
|
+
navigationMode?: PersistentPageShellNavigationMode
|
|
46
|
+
/** Reserve horizontal space for actions even when `actionSlot` is empty. */
|
|
47
|
+
preserveActionSlotSpace?: boolean
|
|
48
|
+
/** Keep the header pinned to the top while content scrolls. */
|
|
49
|
+
sticky?: boolean
|
|
50
|
+
/** Document title; written to `document.title` on mount (React 19). */
|
|
51
|
+
title?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const defaultLoadingSlot = <div className="text-sm text-fg-muted">Loading…</div>
|
|
55
|
+
const defaultEmptySlot = <div className="text-sm text-fg-muted">No content available.</div>
|
|
56
|
+
const defaultErrorSlot = <div className="text-sm text-danger">Failed to load content.</div>
|
|
57
|
+
|
|
58
|
+
const Header = uic('header', {
|
|
59
|
+
displayName: 'PersistentPageShell.Header',
|
|
60
|
+
baseClass: 'flex w-full flex-col gap-6 bg-surface',
|
|
61
|
+
variants: {
|
|
62
|
+
sticky: { true: 'sticky top-0 z-[1]', false: '' },
|
|
63
|
+
},
|
|
64
|
+
defaultVariants: { sticky: false },
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
export const PersistentPageShell = ({
|
|
68
|
+
className,
|
|
69
|
+
heroSlot,
|
|
70
|
+
actionSlot,
|
|
71
|
+
navigationSlot,
|
|
72
|
+
contentSlot,
|
|
73
|
+
contentState = 'ready',
|
|
74
|
+
loadingSlot,
|
|
75
|
+
emptySlot,
|
|
76
|
+
errorSlot,
|
|
77
|
+
navigationMode = 'preserve',
|
|
78
|
+
preserveActionSlotSpace = true,
|
|
79
|
+
sticky = false,
|
|
80
|
+
title,
|
|
81
|
+
}: PersistentPageShellProps) => {
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (title !== undefined && typeof document !== 'undefined') {
|
|
84
|
+
document.title = title
|
|
85
|
+
}
|
|
86
|
+
}, [title])
|
|
87
|
+
|
|
88
|
+
const shouldRenderActionSlot = Boolean(actionSlot) || preserveActionSlotSpace
|
|
89
|
+
const shouldRenderNavigation =
|
|
90
|
+
navigationMode === 'preserve' || (navigationMode === 'auto' && Boolean(navigationSlot))
|
|
91
|
+
|
|
92
|
+
const resolvedContent =
|
|
93
|
+
contentState === 'loading'
|
|
94
|
+
? (loadingSlot ?? defaultLoadingSlot)
|
|
95
|
+
: contentState === 'empty'
|
|
96
|
+
? (emptySlot ?? defaultEmptySlot)
|
|
97
|
+
: contentState === 'error'
|
|
98
|
+
? (errorSlot ?? defaultErrorSlot)
|
|
99
|
+
: contentSlot
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<div className={['flex w-full flex-col gap-6', className].filter(Boolean).join(' ')}>
|
|
103
|
+
<Header sticky={sticky}>
|
|
104
|
+
<div className="grid w-full grid-cols-1 items-stretch gap-4 md:grid-cols-[2fr_1fr]">
|
|
105
|
+
<div className="min-w-0">{heroSlot}</div>
|
|
106
|
+
{shouldRenderActionSlot ? (
|
|
107
|
+
<div
|
|
108
|
+
className={[
|
|
109
|
+
'flex min-w-0 items-stretch justify-end',
|
|
110
|
+
actionSlot ? '' : 'invisible pointer-events-none',
|
|
111
|
+
]
|
|
112
|
+
.filter(Boolean)
|
|
113
|
+
.join(' ')}
|
|
114
|
+
aria-hidden={actionSlot ? undefined : true}
|
|
115
|
+
>
|
|
116
|
+
{actionSlot}
|
|
117
|
+
</div>
|
|
118
|
+
) : null}
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
{shouldRenderNavigation ? (
|
|
122
|
+
<div
|
|
123
|
+
className={['min-w-0', navigationSlot ? '' : 'min-h-10'].filter(Boolean).join(' ')}
|
|
124
|
+
aria-hidden={navigationSlot ? undefined : true}
|
|
125
|
+
>
|
|
126
|
+
{navigationSlot}
|
|
127
|
+
</div>
|
|
128
|
+
) : null}
|
|
129
|
+
</Header>
|
|
130
|
+
|
|
131
|
+
<main className="w-full min-w-0">{resolvedContent}</main>
|
|
132
|
+
</div>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { uic } from '../utils/uic'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Section — vertical rhythm wrapper (port of gs-manager's `Section`).
|
|
5
|
+
*
|
|
6
|
+
* gs SCSS → our tokens (Tailwind + `@app/tokens` CSS vars, no SCSS — hard rule #3):
|
|
7
|
+
* - full-width block, `overflow` left visible so focus rings / negative-margin
|
|
8
|
+
* sticky headers on children aren't clipped (gs comment) → `w-full`.
|
|
9
|
+
* - vertical padding scale (gs `--spacing-*`, responsive @640px = Tailwind `sm:`):
|
|
10
|
+
* none → `py-0`
|
|
11
|
+
* sm → `py-4`
|
|
12
|
+
* md → `py-6 sm:py-8` (the gs default)
|
|
13
|
+
* lg → `py-8 sm:py-12`
|
|
14
|
+
* xl → `py-12 sm:py-16`
|
|
15
|
+
*
|
|
16
|
+
* Presentational only (hard rule #1) — no app imports.
|
|
17
|
+
*/
|
|
18
|
+
export const Section = uic('section', {
|
|
19
|
+
displayName: 'Section',
|
|
20
|
+
baseClass: 'w-full',
|
|
21
|
+
variants: {
|
|
22
|
+
padding: {
|
|
23
|
+
none: 'py-0',
|
|
24
|
+
sm: 'py-4',
|
|
25
|
+
md: 'py-6 sm:py-8',
|
|
26
|
+
lg: 'py-8 sm:py-12',
|
|
27
|
+
xl: 'py-12 sm:py-16',
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
defaultVariants: {
|
|
31
|
+
padding: 'md',
|
|
32
|
+
},
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
export type SectionProps = React.ComponentProps<typeof Section>
|