vinta-schedule-design-system 0.1.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/components.json +21 -0
- package/dist/styles.css +3452 -0
- package/package.json +103 -0
- package/src/layout/app-shell.tsx +115 -0
- package/src/layout/app-topbar.stories.tsx +74 -0
- package/src/layout/app-topbar.tsx +145 -0
- package/src/layout/auth-layout.tsx +45 -0
- package/src/layout/box.stories.tsx +86 -0
- package/src/layout/box.tsx +34 -0
- package/src/layout/center.tsx +24 -0
- package/src/layout/container.stories.tsx +44 -0
- package/src/layout/container.tsx +39 -0
- package/src/layout/divider.tsx +64 -0
- package/src/layout/flex.stories.tsx +109 -0
- package/src/layout/flex.test.tsx +71 -0
- package/src/layout/flex.tsx +112 -0
- package/src/layout/grid.stories.tsx +91 -0
- package/src/layout/grid.tsx +132 -0
- package/src/layout/heading.tsx +53 -0
- package/src/layout/index.ts +47 -0
- package/src/layout/layout-style.ts +291 -0
- package/src/layout/link.stories.tsx +54 -0
- package/src/layout/link.test.tsx +75 -0
- package/src/layout/link.tsx +100 -0
- package/src/layout/navbar.stories.tsx +70 -0
- package/src/layout/navbar.tsx +128 -0
- package/src/layout/page-header.stories.tsx +80 -0
- package/src/layout/page-header.tsx +59 -0
- package/src/layout/section.tsx +22 -0
- package/src/layout/spacer.tsx +21 -0
- package/src/layout/stack.stories.tsx +58 -0
- package/src/layout/stack.tsx +19 -0
- package/src/layout/text.tsx +139 -0
- package/src/layout/typography.stories.tsx +112 -0
- package/src/lib/utils.ts +6 -0
- package/src/story-types.ts +80 -0
- package/src/styles/build.css +33 -0
- package/src/styles/globals.css +9 -0
- package/src/styles/tokens.css +281 -0
- package/src/ui/accordion.stories.tsx +84 -0
- package/src/ui/accordion.tsx +57 -0
- package/src/ui/alert-dialog.stories.tsx +57 -0
- package/src/ui/alert-dialog.tsx +141 -0
- package/src/ui/alert.stories.tsx +46 -0
- package/src/ui/alert.tsx +59 -0
- package/src/ui/avatar.stories.tsx +56 -0
- package/src/ui/avatar.tsx +50 -0
- package/src/ui/badge.stories.tsx +105 -0
- package/src/ui/badge.tsx +57 -0
- package/src/ui/breadcrumb.stories.tsx +69 -0
- package/src/ui/breadcrumb.tsx +140 -0
- package/src/ui/button.stories.tsx +85 -0
- package/src/ui/button.tsx +60 -0
- package/src/ui/card.stories.tsx +62 -0
- package/src/ui/card.tsx +96 -0
- package/src/ui/carousel.stories.tsx +55 -0
- package/src/ui/carousel.tsx +262 -0
- package/src/ui/checkbox.stories.tsx +52 -0
- package/src/ui/checkbox.tsx +30 -0
- package/src/ui/combobox.stories.tsx +138 -0
- package/src/ui/combobox.tsx +217 -0
- package/src/ui/command.tsx +184 -0
- package/src/ui/dialog.stories.tsx +66 -0
- package/src/ui/dialog.tsx +163 -0
- package/src/ui/drawer.stories.tsx +80 -0
- package/src/ui/drawer.tsx +118 -0
- package/src/ui/dropdown-menu.stories.tsx +57 -0
- package/src/ui/dropdown-menu.tsx +201 -0
- package/src/ui/form.stories.tsx +70 -0
- package/src/ui/form.tsx +181 -0
- package/src/ui/input-otp.stories.tsx +52 -0
- package/src/ui/input-otp.tsx +71 -0
- package/src/ui/input.stories.tsx +68 -0
- package/src/ui/input.tsx +22 -0
- package/src/ui/label.stories.tsx +35 -0
- package/src/ui/label.tsx +26 -0
- package/src/ui/navigation-menu.stories.tsx +79 -0
- package/src/ui/navigation-menu.tsx +128 -0
- package/src/ui/pagination.stories.tsx +62 -0
- package/src/ui/pagination.tsx +137 -0
- package/src/ui/popover.tsx +89 -0
- package/src/ui/progress.stories.tsx +33 -0
- package/src/ui/progress.tsx +28 -0
- package/src/ui/radio-group.stories.tsx +52 -0
- package/src/ui/radio-group.tsx +44 -0
- package/src/ui/scheduling-chip.stories.tsx +117 -0
- package/src/ui/scheduling-chip.tsx +62 -0
- package/src/ui/scroll-area.stories.tsx +52 -0
- package/src/ui/scroll-area.tsx +48 -0
- package/src/ui/select.stories.tsx +56 -0
- package/src/ui/select.tsx +159 -0
- package/src/ui/sheet.stories.tsx +63 -0
- package/src/ui/sheet.tsx +141 -0
- package/src/ui/skeleton.stories.tsx +40 -0
- package/src/ui/skeleton.tsx +15 -0
- package/src/ui/sonner.stories.tsx +62 -0
- package/src/ui/sonner.tsx +34 -0
- package/src/ui/switch.stories.tsx +41 -0
- package/src/ui/switch.tsx +29 -0
- package/src/ui/table.stories.tsx +88 -0
- package/src/ui/table.tsx +132 -0
- package/src/ui/tabs.stories.tsx +63 -0
- package/src/ui/tabs.tsx +55 -0
- package/src/ui/textarea.stories.tsx +45 -0
- package/src/ui/textarea.tsx +22 -0
- package/src/ui/tooltip.stories.tsx +48 -0
- package/src/ui/tooltip.tsx +32 -0
package/package.json
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "vinta-schedule-design-system",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": [
|
|
7
|
+
"**/*.css"
|
|
8
|
+
],
|
|
9
|
+
"exports": {
|
|
10
|
+
"./ui/*": "./src/ui/*.tsx",
|
|
11
|
+
"./layout": "./src/layout/index.ts",
|
|
12
|
+
"./layout/*": "./src/layout/*.tsx",
|
|
13
|
+
"./lib/utils": "./src/lib/utils.ts",
|
|
14
|
+
"./story-types": "./src/story-types.ts",
|
|
15
|
+
"./styles.css": "./dist/styles.css",
|
|
16
|
+
"./styles/tokens.css": "./src/styles/tokens.css",
|
|
17
|
+
"./styles/globals.css": "./src/styles/globals.css"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"src/ui",
|
|
21
|
+
"src/layout",
|
|
22
|
+
"src/lib",
|
|
23
|
+
"src/styles",
|
|
24
|
+
"src/story-types.ts",
|
|
25
|
+
"dist",
|
|
26
|
+
"components.json"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"typecheck": "tsc --noEmit",
|
|
30
|
+
"test": "vitest run",
|
|
31
|
+
"test:watch": "vitest",
|
|
32
|
+
"storybook": "storybook dev -p 6007",
|
|
33
|
+
"build-storybook": "storybook build",
|
|
34
|
+
"build:css": "tailwindcss -i ./src/styles/build.css -o ./dist/styles.css",
|
|
35
|
+
"build": "pnpm run build:css",
|
|
36
|
+
"check:contract": "node ./scripts/check-contract.mjs",
|
|
37
|
+
"prepack": "pnpm run build:css"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"react": "^19.0.0",
|
|
41
|
+
"react-dom": "^19.0.0",
|
|
42
|
+
"vinta-ui-composer-prototype-mode": "^0.1.0"
|
|
43
|
+
},
|
|
44
|
+
"peerDependenciesMeta": {
|
|
45
|
+
"vinta-ui-composer-prototype-mode": {
|
|
46
|
+
"optional": true
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@radix-ui/react-accordion": "^1.2.12",
|
|
51
|
+
"@radix-ui/react-alert-dialog": "^1.1.15",
|
|
52
|
+
"@radix-ui/react-avatar": "^1.1.11",
|
|
53
|
+
"@radix-ui/react-checkbox": "^1.3.3",
|
|
54
|
+
"@radix-ui/react-dialog": "^1.1.15",
|
|
55
|
+
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
56
|
+
"@radix-ui/react-label": "^2.1.8",
|
|
57
|
+
"@radix-ui/react-navigation-menu": "^1.2.14",
|
|
58
|
+
"@radix-ui/react-progress": "^1.1.8",
|
|
59
|
+
"@radix-ui/react-radio-group": "^1.3.8",
|
|
60
|
+
"@radix-ui/react-scroll-area": "^1.2.10",
|
|
61
|
+
"@radix-ui/react-select": "^2.2.6",
|
|
62
|
+
"@radix-ui/react-slot": "^1.2.4",
|
|
63
|
+
"@radix-ui/react-switch": "^1.2.6",
|
|
64
|
+
"@radix-ui/react-tabs": "^1.1.13",
|
|
65
|
+
"@radix-ui/react-tooltip": "^1.2.8",
|
|
66
|
+
"class-variance-authority": "^0.7.1",
|
|
67
|
+
"clsx": "^2.1.1",
|
|
68
|
+
"cmdk": "^1.1.1",
|
|
69
|
+
"embla-carousel-react": "^8.6.0",
|
|
70
|
+
"input-otp": "^1.4.2",
|
|
71
|
+
"lucide-react": "^1.17.0",
|
|
72
|
+
"next-themes": "^0.4.6",
|
|
73
|
+
"radix-ui": "^1.5.0",
|
|
74
|
+
"react-hook-form": "^7.77.0",
|
|
75
|
+
"sonner": "^2.0.7",
|
|
76
|
+
"tailwind-merge": "^3.6.0",
|
|
77
|
+
"vaul": "^1.1.2"
|
|
78
|
+
},
|
|
79
|
+
"devDependencies": {
|
|
80
|
+
"@storybook/addon-a11y": "^10.4.2",
|
|
81
|
+
"@storybook/addon-docs": "^10.4.2",
|
|
82
|
+
"@storybook/addon-themes": "^10.4.2",
|
|
83
|
+
"@storybook/react-vite": "^10.4.2",
|
|
84
|
+
"@tailwindcss/cli": "^4.3.0",
|
|
85
|
+
"@tailwindcss/postcss": "^4.3.0",
|
|
86
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
87
|
+
"@testing-library/react": "^16.3.2",
|
|
88
|
+
"@testing-library/user-event": "^14.6.1",
|
|
89
|
+
"@types/react": "^19.2.16",
|
|
90
|
+
"@types/react-dom": "^19.2.3",
|
|
91
|
+
"@vitejs/plugin-react": "^6.0.2",
|
|
92
|
+
"esbuild": "^0.28.1",
|
|
93
|
+
"jsdom": "^29.1.1",
|
|
94
|
+
"react": "^19.2.7",
|
|
95
|
+
"react-dom": "^19.2.7",
|
|
96
|
+
"storybook": "^10.4.2",
|
|
97
|
+
"tailwindcss": "^4.3.0",
|
|
98
|
+
"tw-animate-css": "^1.4.0",
|
|
99
|
+
"typescript": "^6.0.3",
|
|
100
|
+
"vinta-ui-composer-prototype-mode": "^0.1.0",
|
|
101
|
+
"vitest": "^4.1.8"
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { Menu } from 'lucide-react';
|
|
5
|
+
|
|
6
|
+
import { cn } from '../lib/utils';
|
|
7
|
+
import { Button } from '../ui/button';
|
|
8
|
+
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from '../ui/sheet';
|
|
9
|
+
import { Box } from './box';
|
|
10
|
+
import { Flex } from './flex';
|
|
11
|
+
import { Container } from './container';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* AppShell — the authenticated application frame. Composes an optional sidebar
|
|
15
|
+
* and topbar around a scrollable main region. `width` controls whether the page
|
|
16
|
+
* body is capped to the contained 1200px column or runs full-bleed.
|
|
17
|
+
*
|
|
18
|
+
* Responsive: the shell is an `@container/app`. Above @4xl (896px) the sidebar
|
|
19
|
+
* is docked on the left; below that it collapses into a slide-over drawer
|
|
20
|
+
* toggled by a hamburger (injected into the topbar's `leading` slot, or shown
|
|
21
|
+
* in a thin mobile bar when there's no topbar). The page body is an
|
|
22
|
+
* `@container/content` so content reacts to its own width, not the viewport.
|
|
23
|
+
*
|
|
24
|
+
* Covers every generic page layout by toggling props:
|
|
25
|
+
* sidebar + topbar · topbar only · no bars × contained | full
|
|
26
|
+
*/
|
|
27
|
+
export interface AppShellProps {
|
|
28
|
+
sidebar?: React.ReactNode;
|
|
29
|
+
topbar?: React.ReactNode;
|
|
30
|
+
width?: 'contained' | 'full';
|
|
31
|
+
className?: string;
|
|
32
|
+
/** Extra classes on the <Container> wrapping page content. */
|
|
33
|
+
contentClassName?: string;
|
|
34
|
+
children?: React.ReactNode;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function AppShell({
|
|
38
|
+
sidebar,
|
|
39
|
+
topbar,
|
|
40
|
+
width = 'contained',
|
|
41
|
+
className,
|
|
42
|
+
contentClassName,
|
|
43
|
+
children,
|
|
44
|
+
}: AppShellProps) {
|
|
45
|
+
const trigger = sidebar ? (
|
|
46
|
+
<SheetTrigger asChild>
|
|
47
|
+
<Button
|
|
48
|
+
variant='ghost'
|
|
49
|
+
size='icon'
|
|
50
|
+
aria-label='Open navigation'
|
|
51
|
+
className='-ml-1 shrink-0 @4xl/app:hidden'
|
|
52
|
+
>
|
|
53
|
+
<Menu />
|
|
54
|
+
</Button>
|
|
55
|
+
</SheetTrigger>
|
|
56
|
+
) : null;
|
|
57
|
+
|
|
58
|
+
// Inject the mobile trigger into the topbar's leading slot when possible so
|
|
59
|
+
// the hamburger sits inline with the page title.
|
|
60
|
+
const topbarNode =
|
|
61
|
+
topbar && trigger && React.isValidElement(topbar)
|
|
62
|
+
? React.cloneElement(
|
|
63
|
+
topbar as React.ReactElement<{ leading?: React.ReactNode }>,
|
|
64
|
+
{ leading: trigger }
|
|
65
|
+
)
|
|
66
|
+
: topbar;
|
|
67
|
+
|
|
68
|
+
const shell = (
|
|
69
|
+
<Flex
|
|
70
|
+
height='screen'
|
|
71
|
+
width='full'
|
|
72
|
+
overflow='hidden'
|
|
73
|
+
bg='background'
|
|
74
|
+
color='foreground'
|
|
75
|
+
className={cn('@container/app', className)}
|
|
76
|
+
>
|
|
77
|
+
{/* Docked sidebar — display:contents above @4xl so the aside is a direct
|
|
78
|
+
flex child; fully hidden below (the drawer takes over). */}
|
|
79
|
+
{sidebar ? (
|
|
80
|
+
<div className='hidden @4xl/app:contents'>{sidebar}</div>
|
|
81
|
+
) : null}
|
|
82
|
+
|
|
83
|
+
<Flex direction='column' grow={1} minWidth={0} overflow='hidden'>
|
|
84
|
+
{topbarNode}
|
|
85
|
+
|
|
86
|
+
{/* Mobile bar with just the trigger, for the no-topbar layouts. */}
|
|
87
|
+
{sidebar && !topbar ? (
|
|
88
|
+
<div className='border-border flex h-14 shrink-0 items-center border-b px-4 @4xl/app:hidden'>
|
|
89
|
+
{trigger}
|
|
90
|
+
</div>
|
|
91
|
+
) : null}
|
|
92
|
+
|
|
93
|
+
<Box as='main' grow={1} overflow='auto' className='@container/content'>
|
|
94
|
+
<Container width={width} py={8} className={contentClassName}>
|
|
95
|
+
{children}
|
|
96
|
+
</Container>
|
|
97
|
+
</Box>
|
|
98
|
+
</Flex>
|
|
99
|
+
</Flex>
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
if (!sidebar) return shell;
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<Sheet>
|
|
106
|
+
{shell}
|
|
107
|
+
<SheetContent side='left' className='w-[244px] p-0'>
|
|
108
|
+
<SheetTitle className='sr-only'>Navigation</SheetTitle>
|
|
109
|
+
{sidebar}
|
|
110
|
+
</SheetContent>
|
|
111
|
+
</Sheet>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export { AppShell };
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '../story-types';
|
|
2
|
+
import { CalendarPlus } from 'lucide-react';
|
|
3
|
+
|
|
4
|
+
import { AppTopbar } from './app-topbar';
|
|
5
|
+
import { Button } from '../ui/button';
|
|
6
|
+
|
|
7
|
+
const meta = {
|
|
8
|
+
title: 'Composition/AppTopbar',
|
|
9
|
+
component: AppTopbar,
|
|
10
|
+
tags: ['autodocs'],
|
|
11
|
+
// `title` / `subtitle` are ReactNode but render as plain copy → text controls.
|
|
12
|
+
// `actions` and `leading` are composed content → slots (§3). `className` /
|
|
13
|
+
// `style` stay unexposed (§6).
|
|
14
|
+
argTypes: {
|
|
15
|
+
title: { control: 'text', description: 'Page title' },
|
|
16
|
+
subtitle: {
|
|
17
|
+
control: 'text',
|
|
18
|
+
description: 'Secondary line under the title',
|
|
19
|
+
},
|
|
20
|
+
showSearch: { control: 'boolean' },
|
|
21
|
+
searchPlaceholder: { control: 'text' },
|
|
22
|
+
sync: {
|
|
23
|
+
control: 'select',
|
|
24
|
+
options: ['synced', 'syncing'],
|
|
25
|
+
description: 'Sync-status pill (omit to hide it)',
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
args: { title: 'Calendar', subtitle: 'This week · 3 bookings' },
|
|
29
|
+
parameters: {
|
|
30
|
+
layout: 'fullscreen',
|
|
31
|
+
puck: { slots: ['actions', 'leading'] },
|
|
32
|
+
},
|
|
33
|
+
} satisfies Meta<typeof AppTopbar>;
|
|
34
|
+
|
|
35
|
+
export default meta;
|
|
36
|
+
type Story = StoryObj<typeof meta>;
|
|
37
|
+
|
|
38
|
+
// Collapses to an icon button at narrow topbar widths.
|
|
39
|
+
const NewBookingAction = () => (
|
|
40
|
+
<>
|
|
41
|
+
<Button
|
|
42
|
+
size='icon'
|
|
43
|
+
aria-label='New booking'
|
|
44
|
+
className='shrink-0 @3xl/topbar:hidden'
|
|
45
|
+
>
|
|
46
|
+
<CalendarPlus />
|
|
47
|
+
</Button>
|
|
48
|
+
<Button size='sm' className='hidden shrink-0 @3xl/topbar:inline-flex'>
|
|
49
|
+
<CalendarPlus />
|
|
50
|
+
New booking
|
|
51
|
+
</Button>
|
|
52
|
+
</>
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
export const Default: Story = {
|
|
56
|
+
render: (args) => <AppTopbar {...args} actions={<NewBookingAction />} />,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const Syncing: Story = {
|
|
60
|
+
args: { sync: 'syncing', subtitle: 'Refreshing connected calendars' },
|
|
61
|
+
render: (args) => <AppTopbar {...args} />,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const NoSearch: Story = {
|
|
65
|
+
args: { showSearch: false, sync: null },
|
|
66
|
+
render: (args) => <AppTopbar {...args} />,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// At narrow widths: search → icon button, action → icon button, sync pill
|
|
70
|
+
// stays. Title shrinks.
|
|
71
|
+
export const Mobile: Story = {
|
|
72
|
+
globals: { viewport: { value: 'mobile' } },
|
|
73
|
+
render: (args) => <AppTopbar {...args} actions={<NewBookingAction />} />,
|
|
74
|
+
};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { Check, RefreshCw, Search, X } from 'lucide-react';
|
|
5
|
+
|
|
6
|
+
import { cn } from '../lib/utils';
|
|
7
|
+
import { Badge } from '../ui/badge';
|
|
8
|
+
import { Button } from '../ui/button';
|
|
9
|
+
import { Input } from '../ui/input';
|
|
10
|
+
|
|
11
|
+
type SyncState = 'synced' | 'syncing';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* AppTopbar — in-app header bar. Page title + subtitle on the left, optional
|
|
15
|
+
* search, a sync-status pill, and action slot on the right. Sticky + blurred.
|
|
16
|
+
*/
|
|
17
|
+
export interface AppTopbarProps extends Omit<
|
|
18
|
+
React.HTMLAttributes<HTMLElement>,
|
|
19
|
+
'title'
|
|
20
|
+
> {
|
|
21
|
+
/** Optional — omit for a minimal bar (e.g. just a leading trigger + actions). */
|
|
22
|
+
title?: React.ReactNode;
|
|
23
|
+
subtitle?: React.ReactNode;
|
|
24
|
+
actions?: React.ReactNode;
|
|
25
|
+
/** Leading slot — e.g. a mobile nav trigger injected by AppShell. */
|
|
26
|
+
leading?: React.ReactNode;
|
|
27
|
+
showSearch?: boolean;
|
|
28
|
+
searchPlaceholder?: string;
|
|
29
|
+
sync?: SyncState | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const AppTopbar = React.forwardRef<HTMLElement, AppTopbarProps>(
|
|
33
|
+
function AppTopbar(
|
|
34
|
+
{
|
|
35
|
+
className,
|
|
36
|
+
title,
|
|
37
|
+
subtitle,
|
|
38
|
+
actions,
|
|
39
|
+
leading,
|
|
40
|
+
showSearch = true,
|
|
41
|
+
searchPlaceholder = 'Search people & resources',
|
|
42
|
+
sync = 'synced',
|
|
43
|
+
...props
|
|
44
|
+
},
|
|
45
|
+
ref
|
|
46
|
+
) {
|
|
47
|
+
const [searchOpen, setSearchOpen] = React.useState(false);
|
|
48
|
+
return (
|
|
49
|
+
<header
|
|
50
|
+
ref={ref}
|
|
51
|
+
className={cn(
|
|
52
|
+
// @container/topbar so the inner controls react to the bar's own
|
|
53
|
+
// width, not the viewport — they collapse first when a sidebar eats
|
|
54
|
+
// the horizontal space. `relative` anchors the search overlay.
|
|
55
|
+
'border-border bg-background/80 @container/topbar relative sticky top-0 z-20 flex h-16 shrink-0 items-center gap-3 border-b px-4 backdrop-blur @2xl/topbar:gap-4 @2xl/topbar:px-6',
|
|
56
|
+
className
|
|
57
|
+
)}
|
|
58
|
+
{...props}
|
|
59
|
+
>
|
|
60
|
+
{leading}
|
|
61
|
+
|
|
62
|
+
{title || subtitle ? (
|
|
63
|
+
<div className='min-w-0'>
|
|
64
|
+
{title ? (
|
|
65
|
+
<h1 className='truncate text-[15px] leading-tight font-semibold @2xl/topbar:text-[17px]'>
|
|
66
|
+
{title}
|
|
67
|
+
</h1>
|
|
68
|
+
) : null}
|
|
69
|
+
{subtitle ? (
|
|
70
|
+
<p className='text-muted-foreground truncate text-[12.5px] leading-tight'>
|
|
71
|
+
{subtitle}
|
|
72
|
+
</p>
|
|
73
|
+
) : null}
|
|
74
|
+
</div>
|
|
75
|
+
) : null}
|
|
76
|
+
|
|
77
|
+
<div className='flex-1' />
|
|
78
|
+
|
|
79
|
+
{showSearch ? (
|
|
80
|
+
<>
|
|
81
|
+
{/* Narrow: collapse the field to an icon button that expands the
|
|
82
|
+
full-width search overlay. */}
|
|
83
|
+
<Button
|
|
84
|
+
variant='ghost'
|
|
85
|
+
size='icon'
|
|
86
|
+
aria-label={searchPlaceholder}
|
|
87
|
+
onClick={() => setSearchOpen(true)}
|
|
88
|
+
className='shrink-0 @3xl/topbar:hidden'
|
|
89
|
+
>
|
|
90
|
+
<Search />
|
|
91
|
+
</Button>
|
|
92
|
+
<div className='relative hidden w-64 @3xl/topbar:block'>
|
|
93
|
+
<Search className='text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2' />
|
|
94
|
+
<Input className='pl-9' placeholder={searchPlaceholder} />
|
|
95
|
+
</div>
|
|
96
|
+
</>
|
|
97
|
+
) : null}
|
|
98
|
+
|
|
99
|
+
{sync ? (
|
|
100
|
+
<Badge
|
|
101
|
+
variant={sync === 'synced' ? 'success' : 'info'}
|
|
102
|
+
className='shrink-0'
|
|
103
|
+
>
|
|
104
|
+
{sync === 'synced' ? (
|
|
105
|
+
<Check />
|
|
106
|
+
) : (
|
|
107
|
+
<RefreshCw className='animate-spin' />
|
|
108
|
+
)}
|
|
109
|
+
<span className='hidden @xl/topbar:inline'>
|
|
110
|
+
{sync === 'synced' ? 'All synced' : 'Syncing…'}
|
|
111
|
+
</span>
|
|
112
|
+
</Badge>
|
|
113
|
+
) : null}
|
|
114
|
+
|
|
115
|
+
{actions}
|
|
116
|
+
|
|
117
|
+
{/* Expanded search — full-width, covers the bar. Autofocused; X closes. */}
|
|
118
|
+
{showSearch && searchOpen ? (
|
|
119
|
+
<div className='bg-background absolute inset-0 z-10 flex items-center gap-2 px-4 @2xl/topbar:px-6'>
|
|
120
|
+
<Search className='text-muted-foreground size-4 shrink-0' />
|
|
121
|
+
<Input
|
|
122
|
+
autoFocus
|
|
123
|
+
placeholder={searchPlaceholder}
|
|
124
|
+
onKeyDown={(e) => {
|
|
125
|
+
if (e.key === 'Escape') setSearchOpen(false);
|
|
126
|
+
}}
|
|
127
|
+
className='h-9 flex-1 border-0 bg-transparent shadow-none focus-visible:ring-0'
|
|
128
|
+
/>
|
|
129
|
+
<Button
|
|
130
|
+
variant='ghost'
|
|
131
|
+
size='icon'
|
|
132
|
+
aria-label='Close search'
|
|
133
|
+
onClick={() => setSearchOpen(false)}
|
|
134
|
+
className='shrink-0'
|
|
135
|
+
>
|
|
136
|
+
<X />
|
|
137
|
+
</Button>
|
|
138
|
+
</div>
|
|
139
|
+
) : null}
|
|
140
|
+
</header>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
export { AppTopbar };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
import { Box } from './box';
|
|
4
|
+
import { Flex } from './flex';
|
|
5
|
+
import { Center } from './center';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* AuthLayout — public authentication page shell: a top navbar above a single
|
|
9
|
+
* centered column. `variant` only changes how wide that column is:
|
|
10
|
+
* single → 448px (one-column forms)
|
|
11
|
+
* two-column → 768px (a double-width container that splits into two columns
|
|
12
|
+
* internally — e.g. social sign-in beside the form).
|
|
13
|
+
*/
|
|
14
|
+
export interface AuthLayoutProps {
|
|
15
|
+
navbar?: React.ReactNode;
|
|
16
|
+
variant?: 'single' | 'two-column';
|
|
17
|
+
className?: string;
|
|
18
|
+
children?: React.ReactNode;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function AuthLayout({
|
|
22
|
+
navbar,
|
|
23
|
+
variant = 'single',
|
|
24
|
+
className,
|
|
25
|
+
children,
|
|
26
|
+
}: AuthLayoutProps) {
|
|
27
|
+
return (
|
|
28
|
+
<Flex
|
|
29
|
+
direction='column'
|
|
30
|
+
minHeight='screen'
|
|
31
|
+
bg='background'
|
|
32
|
+
color='foreground'
|
|
33
|
+
className={className}
|
|
34
|
+
>
|
|
35
|
+
{navbar}
|
|
36
|
+
<Center as='main' grow={1} px={4} py={12}>
|
|
37
|
+
<Box width='full' maxWidth={variant === 'two-column' ? 768 : 448}>
|
|
38
|
+
{children}
|
|
39
|
+
</Box>
|
|
40
|
+
</Center>
|
|
41
|
+
</Flex>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export { AuthLayout };
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '../story-types';
|
|
2
|
+
|
|
3
|
+
import { Box } from './box';
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Layout/Box',
|
|
7
|
+
component: Box,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
// Box's props are the shared BoxStyleProps vocabulary (layout-style.ts). Only
|
|
10
|
+
// a meaningful subset is exposed — spacing, surface, sizing. `className` /
|
|
11
|
+
// `style` are deliberately NOT editable (§6), and `children` is the composed
|
|
12
|
+
// content slot (§3) so it must not appear in argTypes (SLOT_ARGTYPE_COLLISION).
|
|
13
|
+
argTypes: {
|
|
14
|
+
p: { control: 'select', options: [0, 2, 4, 6, 8, 12] },
|
|
15
|
+
px: { control: 'select', options: [0, 2, 4, 6, 8, 12] },
|
|
16
|
+
py: { control: 'select', options: [0, 2, 4, 6, 8, 12] },
|
|
17
|
+
radius: {
|
|
18
|
+
control: 'select',
|
|
19
|
+
options: ['none', 'sm', 'md', 'lg', 'xl', '2xl', 'full'],
|
|
20
|
+
},
|
|
21
|
+
shadow: {
|
|
22
|
+
control: 'select',
|
|
23
|
+
options: ['none', 'xs', 'sm', 'md', 'lg', 'xl'],
|
|
24
|
+
},
|
|
25
|
+
bg: {
|
|
26
|
+
control: 'select',
|
|
27
|
+
options: ['background', 'card', 'muted', 'accent', 'primary'],
|
|
28
|
+
},
|
|
29
|
+
color: {
|
|
30
|
+
control: 'select',
|
|
31
|
+
options: [
|
|
32
|
+
'foreground',
|
|
33
|
+
'muted-foreground',
|
|
34
|
+
'primary',
|
|
35
|
+
'primary-foreground',
|
|
36
|
+
'vinta-700',
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
border: { control: 'boolean' },
|
|
40
|
+
width: { control: 'select', options: [180, 320, 480, 'full'] },
|
|
41
|
+
textAlign: {
|
|
42
|
+
control: 'inline-radio',
|
|
43
|
+
options: ['left', 'center', 'right'],
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
args: {
|
|
47
|
+
p: 6,
|
|
48
|
+
bg: 'card',
|
|
49
|
+
radius: 'lg',
|
|
50
|
+
shadow: 'sm',
|
|
51
|
+
border: true,
|
|
52
|
+
},
|
|
53
|
+
parameters: { puck: { slots: ['children'] } },
|
|
54
|
+
} satisfies Meta<typeof Box>;
|
|
55
|
+
|
|
56
|
+
export default meta;
|
|
57
|
+
type Story = StoryObj<typeof meta>;
|
|
58
|
+
|
|
59
|
+
export const Playground: Story = {
|
|
60
|
+
render: (args) => <Box {...args}>Box — styled by props, no classes.</Box>,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const Surfaces: Story = {
|
|
64
|
+
render: () => (
|
|
65
|
+
<Box display='flex' style={{ gap: '1rem', flexWrap: 'wrap' }}>
|
|
66
|
+
<Box p={6} bg='card' radius='lg' shadow='sm' border width={180}>
|
|
67
|
+
card · shadow sm
|
|
68
|
+
</Box>
|
|
69
|
+
<Box p={6} bg='muted' radius='lg' width={180}>
|
|
70
|
+
muted
|
|
71
|
+
</Box>
|
|
72
|
+
<Box
|
|
73
|
+
p={6}
|
|
74
|
+
bg='primary'
|
|
75
|
+
color='primary-foreground'
|
|
76
|
+
radius='lg'
|
|
77
|
+
width={180}
|
|
78
|
+
>
|
|
79
|
+
primary
|
|
80
|
+
</Box>
|
|
81
|
+
<Box p={6} bg='vinta-50' color='vinta-700' radius='xl' width={180}>
|
|
82
|
+
vinta-50 / 700
|
|
83
|
+
</Box>
|
|
84
|
+
</Box>
|
|
85
|
+
),
|
|
86
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
import { cn } from '../lib/utils';
|
|
4
|
+
import { boxStyle, splitBoxProps, type BoxStyleProps } from './layout-style';
|
|
5
|
+
|
|
6
|
+
export interface BoxProps
|
|
7
|
+
extends BoxStyleProps, Omit<React.HTMLAttributes<HTMLElement>, 'color'> {
|
|
8
|
+
/** Element/component to render. Defaults to `div`. */
|
|
9
|
+
as?: React.ElementType;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Box — the primitive every other layout component builds on. A styled element
|
|
14
|
+
* driven entirely by token props (padding, margin, bg, radius, shadow, sizing…)
|
|
15
|
+
* instead of utility classes.
|
|
16
|
+
*
|
|
17
|
+
* <Box p={4} bg="card" radius="lg" shadow="sm" border>…</Box>
|
|
18
|
+
*/
|
|
19
|
+
const Box = React.forwardRef<HTMLElement, BoxProps>(function Box(
|
|
20
|
+
{ as: Comp = 'div', className, style, ...props },
|
|
21
|
+
ref
|
|
22
|
+
) {
|
|
23
|
+
const { style: resolved, rest } = splitBoxProps(props);
|
|
24
|
+
return (
|
|
25
|
+
<Comp
|
|
26
|
+
ref={ref}
|
|
27
|
+
className={cn(className)}
|
|
28
|
+
style={{ ...resolved, ...style }}
|
|
29
|
+
{...rest}
|
|
30
|
+
/>
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export { Box, boxStyle };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
import { Flex, type FlexProps } from './flex';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Center — centers its children on both axes. Pass `width`/`height`/`minHeight`
|
|
7
|
+
* to size the centering area (e.g. `minHeight="screen"`).
|
|
8
|
+
*/
|
|
9
|
+
const Center = React.forwardRef<
|
|
10
|
+
HTMLElement,
|
|
11
|
+
Omit<FlexProps, 'align' | 'justify'>
|
|
12
|
+
>(function Center({ inline, ...props }, ref) {
|
|
13
|
+
return (
|
|
14
|
+
<Flex
|
|
15
|
+
ref={ref}
|
|
16
|
+
align='center'
|
|
17
|
+
justify='center'
|
|
18
|
+
inline={inline}
|
|
19
|
+
{...props}
|
|
20
|
+
/>
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export { Center };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '../story-types';
|
|
2
|
+
|
|
3
|
+
import { Container } from './container';
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Layout/Container',
|
|
7
|
+
component: Container,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
// `width` is Container's own prop; the rest come from BoxStyleProps, which
|
|
10
|
+
// Container extends. `children` is the composed content slot (§3).
|
|
11
|
+
argTypes: {
|
|
12
|
+
width: { control: 'inline-radio', options: ['contained', 'prose', 'full'] },
|
|
13
|
+
py: { control: 'select', options: [0, 2, 4, 6, 8, 12, 16] },
|
|
14
|
+
bg: {
|
|
15
|
+
control: 'select',
|
|
16
|
+
options: ['background', 'card', 'muted', 'accent'],
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
args: { width: 'contained' },
|
|
20
|
+
parameters: { layout: 'fullscreen', puck: { slots: ['children'] } },
|
|
21
|
+
} satisfies Meta<typeof Container>;
|
|
22
|
+
|
|
23
|
+
export default meta;
|
|
24
|
+
type Story = StoryObj<typeof meta>;
|
|
25
|
+
|
|
26
|
+
const Demo = () => (
|
|
27
|
+
<div className='border-primary/40 bg-accent/40 text-muted-foreground rounded-lg border border-dashed p-6 text-center text-sm'>
|
|
28
|
+
Container content
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
export const Contained: Story = {
|
|
33
|
+
args: { width: 'contained' },
|
|
34
|
+
render: (args) => (
|
|
35
|
+
<div className='bg-muted/40 py-8'>
|
|
36
|
+
<Container {...args}>
|
|
37
|
+
<Demo />
|
|
38
|
+
</Container>
|
|
39
|
+
</div>
|
|
40
|
+
),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const Prose: Story = { ...Contained, args: { width: 'prose' } };
|
|
44
|
+
export const Full: Story = { ...Contained, args: { width: 'full' } };
|