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
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { Menu } from 'lucide-react';
|
|
3
|
+
|
|
4
|
+
import { cn } from '../lib/utils';
|
|
5
|
+
import { Button } from '../ui/button';
|
|
6
|
+
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from '../ui/sheet';
|
|
7
|
+
import { Box } from './box';
|
|
8
|
+
import { HStack, VStack } from './flex';
|
|
9
|
+
import { Container } from './container';
|
|
10
|
+
import { Text } from './text';
|
|
11
|
+
|
|
12
|
+
/** Brand wordmark used across the public navbar and auth pages. */
|
|
13
|
+
function BrandMark({ className }: { className?: string }) {
|
|
14
|
+
return (
|
|
15
|
+
<HStack className={cn('gap-2.5', className)}>
|
|
16
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
17
|
+
<img
|
|
18
|
+
src='/vinta-wordmark.svg'
|
|
19
|
+
alt='Vinta'
|
|
20
|
+
className='h-5 w-auto dark:brightness-0 dark:invert'
|
|
21
|
+
/>
|
|
22
|
+
<Text
|
|
23
|
+
size='sm'
|
|
24
|
+
weight='medium'
|
|
25
|
+
color='muted-foreground'
|
|
26
|
+
className='border-border border-l pl-2.5'
|
|
27
|
+
>
|
|
28
|
+
Schedule
|
|
29
|
+
</Text>
|
|
30
|
+
</HStack>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Navbar — public / marketing top navigation bar. Sticky, blurred surface with
|
|
36
|
+
* a brand on the left, nav links in the middle, and actions on the right.
|
|
37
|
+
*
|
|
38
|
+
* Responsive: the inner row is an `@container/nav`. Links dock inline above
|
|
39
|
+
* @3xl (768px) and actions above @md (448px); below those breakpoints they
|
|
40
|
+
* collapse into a slide-over menu opened by a hamburger.
|
|
41
|
+
*/
|
|
42
|
+
export interface NavbarProps extends React.HTMLAttributes<HTMLElement> {
|
|
43
|
+
brand?: React.ReactNode;
|
|
44
|
+
/** Center nav links. */
|
|
45
|
+
links?: React.ReactNode;
|
|
46
|
+
/** Right-aligned actions (theme toggle, auth buttons…). */
|
|
47
|
+
actions?: React.ReactNode;
|
|
48
|
+
/** Cap the inner row to the contained width (default) or let it run full. */
|
|
49
|
+
width?: 'contained' | 'full';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const Navbar = React.forwardRef<HTMLElement, NavbarProps>(function Navbar(
|
|
53
|
+
{ className, brand, links, actions, width = 'contained', ...props },
|
|
54
|
+
ref
|
|
55
|
+
) {
|
|
56
|
+
const hasMenu = Boolean(links || actions);
|
|
57
|
+
return (
|
|
58
|
+
<Box
|
|
59
|
+
as='header'
|
|
60
|
+
ref={ref as React.Ref<HTMLElement>}
|
|
61
|
+
className={cn(
|
|
62
|
+
'border-border bg-background/80 sticky top-0 z-40 w-full border-b backdrop-blur',
|
|
63
|
+
className
|
|
64
|
+
)}
|
|
65
|
+
{...props}
|
|
66
|
+
>
|
|
67
|
+
<Container width={width}>
|
|
68
|
+
<HStack
|
|
69
|
+
justify='between'
|
|
70
|
+
gap={6}
|
|
71
|
+
height={72}
|
|
72
|
+
py={3}
|
|
73
|
+
className='@container/nav'
|
|
74
|
+
>
|
|
75
|
+
<HStack gap={10}>
|
|
76
|
+
{brand ?? <BrandMark />}
|
|
77
|
+
{links ? (
|
|
78
|
+
<Box as='nav' className='hidden items-center gap-6 @3xl/nav:flex'>
|
|
79
|
+
{links}
|
|
80
|
+
</Box>
|
|
81
|
+
) : null}
|
|
82
|
+
</HStack>
|
|
83
|
+
|
|
84
|
+
<div className='flex items-center gap-3'>
|
|
85
|
+
{actions ? (
|
|
86
|
+
<div className='hidden items-center gap-3 @md/nav:flex'>
|
|
87
|
+
{actions}
|
|
88
|
+
</div>
|
|
89
|
+
) : null}
|
|
90
|
+
|
|
91
|
+
{hasMenu ? (
|
|
92
|
+
<Sheet>
|
|
93
|
+
<SheetTrigger asChild>
|
|
94
|
+
<Button
|
|
95
|
+
variant='ghost'
|
|
96
|
+
size='icon'
|
|
97
|
+
aria-label='Open menu'
|
|
98
|
+
className='@3xl/nav:hidden'
|
|
99
|
+
>
|
|
100
|
+
<Menu />
|
|
101
|
+
</Button>
|
|
102
|
+
</SheetTrigger>
|
|
103
|
+
<SheetContent side='right' className='w-72'>
|
|
104
|
+
<SheetTitle className='sr-only'>Menu</SheetTitle>
|
|
105
|
+
{links ? (
|
|
106
|
+
<VStack as='nav' gap={1} className='mt-8'>
|
|
107
|
+
{links}
|
|
108
|
+
</VStack>
|
|
109
|
+
) : null}
|
|
110
|
+
{actions ? (
|
|
111
|
+
<VStack
|
|
112
|
+
gap={2}
|
|
113
|
+
className='border-border mt-6 border-t pt-6'
|
|
114
|
+
>
|
|
115
|
+
{actions}
|
|
116
|
+
</VStack>
|
|
117
|
+
) : null}
|
|
118
|
+
</SheetContent>
|
|
119
|
+
</Sheet>
|
|
120
|
+
) : null}
|
|
121
|
+
</div>
|
|
122
|
+
</HStack>
|
|
123
|
+
</Container>
|
|
124
|
+
</Box>
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
export { Navbar, BrandMark };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '../story-types';
|
|
2
|
+
import { CalendarPlus, Download } from 'lucide-react';
|
|
3
|
+
|
|
4
|
+
import { PageHeader } from './page-header';
|
|
5
|
+
import { Button } from '../ui/button';
|
|
6
|
+
import {
|
|
7
|
+
Breadcrumb,
|
|
8
|
+
BreadcrumbItem,
|
|
9
|
+
BreadcrumbLink,
|
|
10
|
+
BreadcrumbList,
|
|
11
|
+
BreadcrumbPage,
|
|
12
|
+
BreadcrumbSeparator,
|
|
13
|
+
} from '../ui/breadcrumb';
|
|
14
|
+
|
|
15
|
+
const meta = {
|
|
16
|
+
title: 'Composition/PageHeader',
|
|
17
|
+
component: PageHeader,
|
|
18
|
+
tags: ['autodocs'],
|
|
19
|
+
// `title` / `description` are ReactNode but render as plain copy → editable
|
|
20
|
+
// text controls. `actions` and `breadcrumb` are composed content → slots (§3).
|
|
21
|
+
argTypes: {
|
|
22
|
+
title: { control: 'text', description: 'Page title' },
|
|
23
|
+
description: { control: 'text', description: 'Supporting copy' },
|
|
24
|
+
},
|
|
25
|
+
args: {
|
|
26
|
+
title: 'Bookings',
|
|
27
|
+
description: 'Manage upcoming and past appointments.',
|
|
28
|
+
},
|
|
29
|
+
parameters: { puck: { slots: ['actions', 'breadcrumb'] } },
|
|
30
|
+
} satisfies Meta<typeof PageHeader>;
|
|
31
|
+
|
|
32
|
+
export default meta;
|
|
33
|
+
type Story = StoryObj<typeof meta>;
|
|
34
|
+
|
|
35
|
+
export const Default: Story = {
|
|
36
|
+
render: (args) => <PageHeader className='w-[640px]' {...args} />,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const WithActions: Story = {
|
|
40
|
+
render: (args) => (
|
|
41
|
+
<PageHeader
|
|
42
|
+
className='w-[640px]'
|
|
43
|
+
{...args}
|
|
44
|
+
actions={
|
|
45
|
+
<>
|
|
46
|
+
<Button variant='outline' size='sm'>
|
|
47
|
+
<Download />
|
|
48
|
+
Export
|
|
49
|
+
</Button>
|
|
50
|
+
<Button size='sm'>
|
|
51
|
+
<CalendarPlus />
|
|
52
|
+
New booking
|
|
53
|
+
</Button>
|
|
54
|
+
</>
|
|
55
|
+
}
|
|
56
|
+
/>
|
|
57
|
+
),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const WithBreadcrumb: Story = {
|
|
61
|
+
render: (args) => (
|
|
62
|
+
<PageHeader
|
|
63
|
+
className='w-[640px]'
|
|
64
|
+
{...args}
|
|
65
|
+
breadcrumb={
|
|
66
|
+
<Breadcrumb>
|
|
67
|
+
<BreadcrumbList>
|
|
68
|
+
<BreadcrumbItem>
|
|
69
|
+
<BreadcrumbLink href='#'>Home</BreadcrumbLink>
|
|
70
|
+
</BreadcrumbItem>
|
|
71
|
+
<BreadcrumbSeparator />
|
|
72
|
+
<BreadcrumbItem>
|
|
73
|
+
<BreadcrumbPage>Bookings</BreadcrumbPage>
|
|
74
|
+
</BreadcrumbItem>
|
|
75
|
+
</BreadcrumbList>
|
|
76
|
+
</Breadcrumb>
|
|
77
|
+
}
|
|
78
|
+
/>
|
|
79
|
+
),
|
|
80
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
import { cn } from '../lib/utils';
|
|
4
|
+
import { HStack } from './flex';
|
|
5
|
+
import { Stack } from './stack';
|
|
6
|
+
import { Heading } from './heading';
|
|
7
|
+
import { Text } from './text';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* PageHeader — title block at the top of a page body. Optional breadcrumb slot
|
|
11
|
+
* above, description below, and right-aligned actions. Built from layout
|
|
12
|
+
* primitives (Stack / Heading / Text).
|
|
13
|
+
*/
|
|
14
|
+
export interface PageHeaderProps extends Omit<
|
|
15
|
+
React.HTMLAttributes<HTMLDivElement>,
|
|
16
|
+
'title'
|
|
17
|
+
> {
|
|
18
|
+
title: React.ReactNode;
|
|
19
|
+
description?: React.ReactNode;
|
|
20
|
+
actions?: React.ReactNode;
|
|
21
|
+
breadcrumb?: React.ReactNode;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const PageHeader = React.forwardRef<HTMLDivElement, PageHeaderProps>(
|
|
25
|
+
function PageHeader(
|
|
26
|
+
{ className, title, description, actions, breadcrumb, ...props },
|
|
27
|
+
ref
|
|
28
|
+
) {
|
|
29
|
+
return (
|
|
30
|
+
<Stack
|
|
31
|
+
ref={ref as React.Ref<HTMLElement>}
|
|
32
|
+
gap={4}
|
|
33
|
+
className={cn('@container/pageheader', className)}
|
|
34
|
+
{...props}
|
|
35
|
+
>
|
|
36
|
+
{breadcrumb}
|
|
37
|
+
<div className='flex flex-col gap-3 @lg/pageheader:flex-row @lg/pageheader:items-start @lg/pageheader:justify-between'>
|
|
38
|
+
<Stack gap={1}>
|
|
39
|
+
<Heading level={1} size='2xl'>
|
|
40
|
+
{title}
|
|
41
|
+
</Heading>
|
|
42
|
+
{description ? (
|
|
43
|
+
<Text size='sm' color='muted-foreground'>
|
|
44
|
+
{description}
|
|
45
|
+
</Text>
|
|
46
|
+
) : null}
|
|
47
|
+
</Stack>
|
|
48
|
+
{actions ? (
|
|
49
|
+
<HStack gap={2} shrink={0}>
|
|
50
|
+
{actions}
|
|
51
|
+
</HStack>
|
|
52
|
+
) : null}
|
|
53
|
+
</div>
|
|
54
|
+
</Stack>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
export { PageHeader };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
import { Box, type BoxProps } from './box';
|
|
4
|
+
import type { Space } from './layout-style';
|
|
5
|
+
|
|
6
|
+
export interface SectionProps extends Omit<BoxProps, 'py'> {
|
|
7
|
+
/** Vertical rhythm between page sections. Defaults to 16 (64px). */
|
|
8
|
+
py?: Space;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Section — a vertical page band with consistent top/bottom padding. Renders a
|
|
13
|
+
* <section> by default; override with `as`.
|
|
14
|
+
*/
|
|
15
|
+
const Section = React.forwardRef<HTMLElement, SectionProps>(function Section(
|
|
16
|
+
{ as = 'section', py = 16, ...props },
|
|
17
|
+
ref
|
|
18
|
+
) {
|
|
19
|
+
return <Box ref={ref} as={as} py={py} {...props} />;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export { Section };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Spacer — flexible gap that pushes siblings apart inside a Flex/HStack/VStack.
|
|
5
|
+
* <HStack><Logo /><Spacer /><Actions /></HStack>
|
|
6
|
+
*/
|
|
7
|
+
const Spacer = React.forwardRef<
|
|
8
|
+
HTMLDivElement,
|
|
9
|
+
React.HTMLAttributes<HTMLDivElement>
|
|
10
|
+
>(function Spacer({ style, ...props }, ref) {
|
|
11
|
+
return (
|
|
12
|
+
<div
|
|
13
|
+
ref={ref}
|
|
14
|
+
aria-hidden
|
|
15
|
+
style={{ flex: '1 1 0%', alignSelf: 'stretch', ...style }}
|
|
16
|
+
{...props}
|
|
17
|
+
/>
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export { Spacer };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '../story-types';
|
|
2
|
+
|
|
3
|
+
import { Stack } from './stack';
|
|
4
|
+
import { Button } from '../ui/button';
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: 'Layout/Stack',
|
|
8
|
+
component: Stack,
|
|
9
|
+
tags: ['autodocs'],
|
|
10
|
+
// Container with BOTH curated scalar controls AND a slot. The typed
|
|
11
|
+
// style-prop vocabulary (layout-style.ts) maps cleanly to select/radio
|
|
12
|
+
// controls; `children` is the composed content slot (§3), typed ReactNode via
|
|
13
|
+
// FlexProps → React.HTMLAttributes. `className`/`style` stay unexposed (§6).
|
|
14
|
+
argTypes: {
|
|
15
|
+
direction: { control: 'inline-radio', options: ['column', 'row'] },
|
|
16
|
+
gap: { control: 'select', options: [0, 1, 2, 3, 4, 5, 6, 8] },
|
|
17
|
+
align: {
|
|
18
|
+
control: 'select',
|
|
19
|
+
options: ['start', 'center', 'end', 'stretch', 'baseline'],
|
|
20
|
+
},
|
|
21
|
+
justify: {
|
|
22
|
+
control: 'select',
|
|
23
|
+
options: ['start', 'center', 'end', 'between', 'around', 'evenly'],
|
|
24
|
+
},
|
|
25
|
+
wrap: { control: 'boolean' },
|
|
26
|
+
},
|
|
27
|
+
args: { direction: 'column', gap: 4 },
|
|
28
|
+
parameters: { puck: { slots: ['children'] } },
|
|
29
|
+
} satisfies Meta<typeof Stack>;
|
|
30
|
+
|
|
31
|
+
export default meta;
|
|
32
|
+
type Story = StoryObj<typeof meta>;
|
|
33
|
+
|
|
34
|
+
const Box = ({ children }: { children: React.ReactNode }) => (
|
|
35
|
+
<div className='bg-vinta-50 text-vinta-700 rounded-md px-4 py-2 text-sm'>
|
|
36
|
+
{children}
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
export const Vertical: Story = {
|
|
41
|
+
render: (args) => (
|
|
42
|
+
<Stack {...args}>
|
|
43
|
+
<Box>One</Box>
|
|
44
|
+
<Box>Two</Box>
|
|
45
|
+
<Box>Three</Box>
|
|
46
|
+
</Stack>
|
|
47
|
+
),
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const Horizontal: Story = {
|
|
51
|
+
args: { direction: 'row', gap: 2, align: 'center' },
|
|
52
|
+
render: (args) => (
|
|
53
|
+
<Stack {...args}>
|
|
54
|
+
<Button>Save</Button>
|
|
55
|
+
<Button variant='outline'>Cancel</Button>
|
|
56
|
+
</Stack>
|
|
57
|
+
),
|
|
58
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
import { Flex, type FlexProps } from './flex';
|
|
4
|
+
|
|
5
|
+
export type { FlexProps as StackOwnProps };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Stack — vertical-by-default flex layout. Thin alias over <Flex>; gaps use the
|
|
9
|
+
* 4px spacing scale (`gap={4}` → 16px). Switch to a row with `direction="row"`,
|
|
10
|
+
* or use the dedicated <HStack> / <VStack>.
|
|
11
|
+
*/
|
|
12
|
+
const Stack = React.forwardRef<HTMLElement, FlexProps>(function Stack(
|
|
13
|
+
{ direction = 'column', gap = 4, ...props },
|
|
14
|
+
ref
|
|
15
|
+
) {
|
|
16
|
+
return <Flex ref={ref} direction={direction} gap={gap} {...props} />;
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export { Stack };
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
import { cn } from '../lib/utils';
|
|
4
|
+
import { color, type ColorToken } from './layout-style';
|
|
5
|
+
|
|
6
|
+
export type TextSize =
|
|
7
|
+
| 'xs'
|
|
8
|
+
| 'sm'
|
|
9
|
+
| 'base'
|
|
10
|
+
| 'lg'
|
|
11
|
+
| 'xl'
|
|
12
|
+
| '2xl'
|
|
13
|
+
| '3xl'
|
|
14
|
+
| '4xl'
|
|
15
|
+
| '5xl'
|
|
16
|
+
| '6xl';
|
|
17
|
+
export type TextWeight =
|
|
18
|
+
| 'light'
|
|
19
|
+
| 'normal'
|
|
20
|
+
| 'medium'
|
|
21
|
+
| 'semibold'
|
|
22
|
+
| 'bold'
|
|
23
|
+
| 'extrabold';
|
|
24
|
+
export type TextFamily = 'sans' | 'mono' | 'display';
|
|
25
|
+
export type TextLeading = 'none' | 'tight' | 'snug' | 'normal' | 'relaxed';
|
|
26
|
+
export type TextTracking = 'tighter' | 'tight' | 'normal' | 'wide' | 'wider';
|
|
27
|
+
export type TextAlign = 'left' | 'center' | 'right' | 'justify';
|
|
28
|
+
|
|
29
|
+
// Literal class maps — Tailwind only emits classes it can see verbatim.
|
|
30
|
+
const SIZE: Record<TextSize, string> = {
|
|
31
|
+
xs: 'text-xs',
|
|
32
|
+
sm: 'text-sm',
|
|
33
|
+
base: 'text-base',
|
|
34
|
+
lg: 'text-lg',
|
|
35
|
+
xl: 'text-xl',
|
|
36
|
+
'2xl': 'text-2xl',
|
|
37
|
+
'3xl': 'text-3xl',
|
|
38
|
+
'4xl': 'text-4xl',
|
|
39
|
+
'5xl': 'text-5xl',
|
|
40
|
+
'6xl': 'text-6xl',
|
|
41
|
+
};
|
|
42
|
+
const WEIGHT: Record<TextWeight, string> = {
|
|
43
|
+
light: 'font-light',
|
|
44
|
+
normal: 'font-normal',
|
|
45
|
+
medium: 'font-medium',
|
|
46
|
+
semibold: 'font-semibold',
|
|
47
|
+
bold: 'font-bold',
|
|
48
|
+
extrabold: 'font-extrabold',
|
|
49
|
+
};
|
|
50
|
+
const FAMILY: Record<TextFamily, string> = {
|
|
51
|
+
sans: 'font-sans',
|
|
52
|
+
mono: 'font-mono',
|
|
53
|
+
display: 'font-display',
|
|
54
|
+
};
|
|
55
|
+
const LEADING: Record<TextLeading, string> = {
|
|
56
|
+
none: 'leading-none',
|
|
57
|
+
tight: 'leading-tight',
|
|
58
|
+
snug: 'leading-snug',
|
|
59
|
+
normal: 'leading-normal',
|
|
60
|
+
relaxed: 'leading-relaxed',
|
|
61
|
+
};
|
|
62
|
+
const TRACKING: Record<TextTracking, string> = {
|
|
63
|
+
tighter: 'tracking-tighter',
|
|
64
|
+
tight: 'tracking-tight',
|
|
65
|
+
normal: 'tracking-normal',
|
|
66
|
+
wide: 'tracking-wide',
|
|
67
|
+
wider: 'tracking-wider',
|
|
68
|
+
};
|
|
69
|
+
const ALIGN: Record<TextAlign, string> = {
|
|
70
|
+
left: 'text-left',
|
|
71
|
+
center: 'text-center',
|
|
72
|
+
right: 'text-right',
|
|
73
|
+
justify: 'text-justify',
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export interface TextProps extends Omit<
|
|
77
|
+
React.HTMLAttributes<HTMLElement>,
|
|
78
|
+
'color'
|
|
79
|
+
> {
|
|
80
|
+
as?: React.ElementType;
|
|
81
|
+
size?: TextSize;
|
|
82
|
+
weight?: TextWeight;
|
|
83
|
+
family?: TextFamily;
|
|
84
|
+
leading?: TextLeading;
|
|
85
|
+
tracking?: TextTracking;
|
|
86
|
+
align?: TextAlign;
|
|
87
|
+
color?: ColorToken;
|
|
88
|
+
truncate?: boolean;
|
|
89
|
+
italic?: boolean;
|
|
90
|
+
uppercase?: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Text — typography primitive. Drive size/weight/family/leading/tracking/color
|
|
95
|
+
* through props instead of utility classes.
|
|
96
|
+
*
|
|
97
|
+
* <Text size="sm" color="muted-foreground">Helper copy</Text>
|
|
98
|
+
*/
|
|
99
|
+
const Text = React.forwardRef<HTMLElement, TextProps>(function Text(
|
|
100
|
+
{
|
|
101
|
+
as: Comp = 'span',
|
|
102
|
+
className,
|
|
103
|
+
style,
|
|
104
|
+
size,
|
|
105
|
+
weight,
|
|
106
|
+
family,
|
|
107
|
+
leading,
|
|
108
|
+
tracking,
|
|
109
|
+
align,
|
|
110
|
+
color: colorToken,
|
|
111
|
+
truncate,
|
|
112
|
+
italic,
|
|
113
|
+
uppercase,
|
|
114
|
+
...props
|
|
115
|
+
},
|
|
116
|
+
ref
|
|
117
|
+
) {
|
|
118
|
+
return (
|
|
119
|
+
<Comp
|
|
120
|
+
ref={ref}
|
|
121
|
+
className={cn(
|
|
122
|
+
size && SIZE[size],
|
|
123
|
+
weight && WEIGHT[weight],
|
|
124
|
+
family && FAMILY[family],
|
|
125
|
+
leading && LEADING[leading],
|
|
126
|
+
tracking && TRACKING[tracking],
|
|
127
|
+
align && ALIGN[align],
|
|
128
|
+
truncate && 'truncate',
|
|
129
|
+
italic && 'italic',
|
|
130
|
+
uppercase && 'uppercase',
|
|
131
|
+
className
|
|
132
|
+
)}
|
|
133
|
+
style={{ color: color(colorToken), ...style }}
|
|
134
|
+
{...props}
|
|
135
|
+
/>
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
export { Text };
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '../story-types';
|
|
2
|
+
|
|
3
|
+
import { Text } from './text';
|
|
4
|
+
import { Heading } from './heading';
|
|
5
|
+
import { VStack } from './flex';
|
|
6
|
+
|
|
7
|
+
const meta = {
|
|
8
|
+
// Title leaf must resolve to the named export in scope (§7): `Text`.
|
|
9
|
+
title: 'Layout/Text',
|
|
10
|
+
component: Text,
|
|
11
|
+
tags: ['autodocs'],
|
|
12
|
+
// Text is a typography leaf: `children` is the editable label (a plain string
|
|
13
|
+
// control, not a slot — same as Button). `className`/`style` stay unexposed.
|
|
14
|
+
argTypes: {
|
|
15
|
+
children: { control: 'text', description: 'Text content' },
|
|
16
|
+
size: {
|
|
17
|
+
control: 'select',
|
|
18
|
+
options: [
|
|
19
|
+
'xs',
|
|
20
|
+
'sm',
|
|
21
|
+
'base',
|
|
22
|
+
'lg',
|
|
23
|
+
'xl',
|
|
24
|
+
'2xl',
|
|
25
|
+
'3xl',
|
|
26
|
+
'4xl',
|
|
27
|
+
'5xl',
|
|
28
|
+
'6xl',
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
weight: {
|
|
32
|
+
control: 'select',
|
|
33
|
+
options: ['light', 'normal', 'medium', 'semibold', 'bold', 'extrabold'],
|
|
34
|
+
},
|
|
35
|
+
family: { control: 'inline-radio', options: ['sans', 'mono', 'display'] },
|
|
36
|
+
leading: {
|
|
37
|
+
control: 'select',
|
|
38
|
+
options: ['none', 'tight', 'snug', 'normal', 'relaxed'],
|
|
39
|
+
},
|
|
40
|
+
tracking: {
|
|
41
|
+
control: 'select',
|
|
42
|
+
options: ['tighter', 'tight', 'normal', 'wide', 'wider'],
|
|
43
|
+
},
|
|
44
|
+
align: {
|
|
45
|
+
control: 'select',
|
|
46
|
+
options: ['left', 'center', 'right', 'justify'],
|
|
47
|
+
},
|
|
48
|
+
color: {
|
|
49
|
+
control: 'select',
|
|
50
|
+
options: [
|
|
51
|
+
'foreground',
|
|
52
|
+
'muted-foreground',
|
|
53
|
+
'primary',
|
|
54
|
+
'destructive',
|
|
55
|
+
'success',
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
truncate: { control: 'boolean' },
|
|
59
|
+
italic: { control: 'boolean' },
|
|
60
|
+
uppercase: { control: 'boolean' },
|
|
61
|
+
},
|
|
62
|
+
args: {
|
|
63
|
+
children: 'The quick brown fox books an appointment at 9:00 AM.',
|
|
64
|
+
size: 'base',
|
|
65
|
+
},
|
|
66
|
+
} satisfies Meta<typeof Text>;
|
|
67
|
+
|
|
68
|
+
export default meta;
|
|
69
|
+
type Story = StoryObj<typeof meta>;
|
|
70
|
+
|
|
71
|
+
export const Playground: Story = {};
|
|
72
|
+
|
|
73
|
+
export const Headings: Story = {
|
|
74
|
+
render: () => (
|
|
75
|
+
<VStack gap={4}>
|
|
76
|
+
{[1, 2, 3, 4, 5, 6].map((l) => (
|
|
77
|
+
<Heading key={l} level={l as 1}>
|
|
78
|
+
Heading level {l}
|
|
79
|
+
</Heading>
|
|
80
|
+
))}
|
|
81
|
+
</VStack>
|
|
82
|
+
),
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export const TextStyles: Story = {
|
|
86
|
+
render: () => (
|
|
87
|
+
<VStack gap={3}>
|
|
88
|
+
<Text size='lg' color='muted-foreground'>
|
|
89
|
+
Lead — reassuring, healthcare-aware copy.
|
|
90
|
+
</Text>
|
|
91
|
+
<Text>Body — the quick brown fox books an appointment at 9:00 AM.</Text>
|
|
92
|
+
<Text size='sm' color='muted-foreground'>
|
|
93
|
+
Small / caption text.
|
|
94
|
+
</Text>
|
|
95
|
+
<Text
|
|
96
|
+
size='xs'
|
|
97
|
+
weight='semibold'
|
|
98
|
+
tracking='wide'
|
|
99
|
+
uppercase
|
|
100
|
+
color='muted-foreground'
|
|
101
|
+
>
|
|
102
|
+
Overline
|
|
103
|
+
</Text>
|
|
104
|
+
<Text family='mono' size='sm'>
|
|
105
|
+
Geist Mono — 09:00–09:30 · ID a1b2c3
|
|
106
|
+
</Text>
|
|
107
|
+
<Text weight='bold' color='primary'>
|
|
108
|
+
Emphasis in brand color.
|
|
109
|
+
</Text>
|
|
110
|
+
</VStack>
|
|
111
|
+
),
|
|
112
|
+
};
|