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.
Files changed (107) hide show
  1. package/components.json +21 -0
  2. package/dist/styles.css +3452 -0
  3. package/package.json +103 -0
  4. package/src/layout/app-shell.tsx +115 -0
  5. package/src/layout/app-topbar.stories.tsx +74 -0
  6. package/src/layout/app-topbar.tsx +145 -0
  7. package/src/layout/auth-layout.tsx +45 -0
  8. package/src/layout/box.stories.tsx +86 -0
  9. package/src/layout/box.tsx +34 -0
  10. package/src/layout/center.tsx +24 -0
  11. package/src/layout/container.stories.tsx +44 -0
  12. package/src/layout/container.tsx +39 -0
  13. package/src/layout/divider.tsx +64 -0
  14. package/src/layout/flex.stories.tsx +109 -0
  15. package/src/layout/flex.test.tsx +71 -0
  16. package/src/layout/flex.tsx +112 -0
  17. package/src/layout/grid.stories.tsx +91 -0
  18. package/src/layout/grid.tsx +132 -0
  19. package/src/layout/heading.tsx +53 -0
  20. package/src/layout/index.ts +47 -0
  21. package/src/layout/layout-style.ts +291 -0
  22. package/src/layout/link.stories.tsx +54 -0
  23. package/src/layout/link.test.tsx +75 -0
  24. package/src/layout/link.tsx +100 -0
  25. package/src/layout/navbar.stories.tsx +70 -0
  26. package/src/layout/navbar.tsx +128 -0
  27. package/src/layout/page-header.stories.tsx +80 -0
  28. package/src/layout/page-header.tsx +59 -0
  29. package/src/layout/section.tsx +22 -0
  30. package/src/layout/spacer.tsx +21 -0
  31. package/src/layout/stack.stories.tsx +58 -0
  32. package/src/layout/stack.tsx +19 -0
  33. package/src/layout/text.tsx +139 -0
  34. package/src/layout/typography.stories.tsx +112 -0
  35. package/src/lib/utils.ts +6 -0
  36. package/src/story-types.ts +80 -0
  37. package/src/styles/build.css +33 -0
  38. package/src/styles/globals.css +9 -0
  39. package/src/styles/tokens.css +281 -0
  40. package/src/ui/accordion.stories.tsx +84 -0
  41. package/src/ui/accordion.tsx +57 -0
  42. package/src/ui/alert-dialog.stories.tsx +57 -0
  43. package/src/ui/alert-dialog.tsx +141 -0
  44. package/src/ui/alert.stories.tsx +46 -0
  45. package/src/ui/alert.tsx +59 -0
  46. package/src/ui/avatar.stories.tsx +56 -0
  47. package/src/ui/avatar.tsx +50 -0
  48. package/src/ui/badge.stories.tsx +105 -0
  49. package/src/ui/badge.tsx +57 -0
  50. package/src/ui/breadcrumb.stories.tsx +69 -0
  51. package/src/ui/breadcrumb.tsx +140 -0
  52. package/src/ui/button.stories.tsx +85 -0
  53. package/src/ui/button.tsx +60 -0
  54. package/src/ui/card.stories.tsx +62 -0
  55. package/src/ui/card.tsx +96 -0
  56. package/src/ui/carousel.stories.tsx +55 -0
  57. package/src/ui/carousel.tsx +262 -0
  58. package/src/ui/checkbox.stories.tsx +52 -0
  59. package/src/ui/checkbox.tsx +30 -0
  60. package/src/ui/combobox.stories.tsx +138 -0
  61. package/src/ui/combobox.tsx +217 -0
  62. package/src/ui/command.tsx +184 -0
  63. package/src/ui/dialog.stories.tsx +66 -0
  64. package/src/ui/dialog.tsx +163 -0
  65. package/src/ui/drawer.stories.tsx +80 -0
  66. package/src/ui/drawer.tsx +118 -0
  67. package/src/ui/dropdown-menu.stories.tsx +57 -0
  68. package/src/ui/dropdown-menu.tsx +201 -0
  69. package/src/ui/form.stories.tsx +70 -0
  70. package/src/ui/form.tsx +181 -0
  71. package/src/ui/input-otp.stories.tsx +52 -0
  72. package/src/ui/input-otp.tsx +71 -0
  73. package/src/ui/input.stories.tsx +68 -0
  74. package/src/ui/input.tsx +22 -0
  75. package/src/ui/label.stories.tsx +35 -0
  76. package/src/ui/label.tsx +26 -0
  77. package/src/ui/navigation-menu.stories.tsx +79 -0
  78. package/src/ui/navigation-menu.tsx +128 -0
  79. package/src/ui/pagination.stories.tsx +62 -0
  80. package/src/ui/pagination.tsx +137 -0
  81. package/src/ui/popover.tsx +89 -0
  82. package/src/ui/progress.stories.tsx +33 -0
  83. package/src/ui/progress.tsx +28 -0
  84. package/src/ui/radio-group.stories.tsx +52 -0
  85. package/src/ui/radio-group.tsx +44 -0
  86. package/src/ui/scheduling-chip.stories.tsx +117 -0
  87. package/src/ui/scheduling-chip.tsx +62 -0
  88. package/src/ui/scroll-area.stories.tsx +52 -0
  89. package/src/ui/scroll-area.tsx +48 -0
  90. package/src/ui/select.stories.tsx +56 -0
  91. package/src/ui/select.tsx +159 -0
  92. package/src/ui/sheet.stories.tsx +63 -0
  93. package/src/ui/sheet.tsx +141 -0
  94. package/src/ui/skeleton.stories.tsx +40 -0
  95. package/src/ui/skeleton.tsx +15 -0
  96. package/src/ui/sonner.stories.tsx +62 -0
  97. package/src/ui/sonner.tsx +34 -0
  98. package/src/ui/switch.stories.tsx +41 -0
  99. package/src/ui/switch.tsx +29 -0
  100. package/src/ui/table.stories.tsx +88 -0
  101. package/src/ui/table.tsx +132 -0
  102. package/src/ui/tabs.stories.tsx +63 -0
  103. package/src/ui/tabs.tsx +55 -0
  104. package/src/ui/textarea.stories.tsx +45 -0
  105. package/src/ui/textarea.tsx +22 -0
  106. package/src/ui/tooltip.stories.tsx +48 -0
  107. package/src/ui/tooltip.tsx +32 -0
@@ -0,0 +1,39 @@
1
+ import * as React from 'react';
2
+
3
+ import { cn } from '../lib/utils';
4
+ import { Box, type BoxProps } from './box';
5
+ import type { Size } from './layout-style';
6
+
7
+ /**
8
+ * Container — horizontal layout frame. Centers content and applies the design
9
+ * system's page margins (32px). `width` caps the content:
10
+ * contained → 1200px (DS --container-max) prose → 68ch full → edge-to-edge
11
+ *
12
+ * Accepts all Box style props (e.g. `py`, `bg`) on top of the width cap.
13
+ */
14
+ export interface ContainerProps extends Omit<BoxProps, 'width' | 'maxWidth'> {
15
+ width?: 'contained' | 'prose' | 'full';
16
+ }
17
+
18
+ const MAX_WIDTH: Record<NonNullable<ContainerProps['width']>, Size> = {
19
+ contained: 1200,
20
+ prose: '68ch',
21
+ full: 'none',
22
+ };
23
+
24
+ const Container = React.forwardRef<HTMLElement, ContainerProps>(
25
+ function Container({ className, width = 'contained', ...box }, ref) {
26
+ return (
27
+ <Box
28
+ ref={ref}
29
+ mx='auto'
30
+ width='full'
31
+ maxWidth={MAX_WIDTH[width]}
32
+ className={cn('px-4 md:px-8', className)}
33
+ {...box}
34
+ />
35
+ );
36
+ }
37
+ );
38
+
39
+ export { Container };
@@ -0,0 +1,64 @@
1
+ import * as React from 'react';
2
+ import type { CSSProperties } from 'react';
3
+
4
+ import { cn } from '../lib/utils';
5
+ import {
6
+ color,
7
+ resolveSpace,
8
+ type ColorToken,
9
+ type Space,
10
+ } from './layout-style';
11
+
12
+ export interface DividerProps extends React.HTMLAttributes<HTMLDivElement> {
13
+ orientation?: 'horizontal' | 'vertical';
14
+ /** Line color token. Defaults to `border`. */
15
+ tone?: ColorToken;
16
+ /** Margin on the cross axis (space around the line). */
17
+ spacing?: Space;
18
+ }
19
+
20
+ /**
21
+ * Divider — a hairline rule between content. Horizontal by default; set
22
+ * `orientation="vertical"` inside an HStack.
23
+ */
24
+ const Divider = React.forwardRef<HTMLDivElement, DividerProps>(function Divider(
25
+ {
26
+ className,
27
+ style,
28
+ orientation = 'horizontal',
29
+ tone = 'border',
30
+ spacing,
31
+ ...props
32
+ },
33
+ ref
34
+ ) {
35
+ const vertical = orientation === 'vertical';
36
+ const gap = spacing != null ? resolveSpace(spacing) : undefined;
37
+ const s: CSSProperties = vertical
38
+ ? {
39
+ width: '1px',
40
+ alignSelf: 'stretch',
41
+ backgroundColor: color(tone),
42
+ marginLeft: gap,
43
+ marginRight: gap,
44
+ }
45
+ : {
46
+ height: '1px',
47
+ width: '100%',
48
+ backgroundColor: color(tone),
49
+ marginTop: gap,
50
+ marginBottom: gap,
51
+ };
52
+ return (
53
+ <div
54
+ ref={ref}
55
+ role='separator'
56
+ aria-orientation={orientation}
57
+ className={cn(className)}
58
+ style={{ ...s, ...style }}
59
+ {...props}
60
+ />
61
+ );
62
+ });
63
+
64
+ export { Divider };
@@ -0,0 +1,109 @@
1
+ import type { Meta, StoryObj } from '../story-types';
2
+
3
+ import { Flex, HStack, VStack } from './flex';
4
+ import { Box } from './box';
5
+ import { Spacer } from './spacer';
6
+ import { Center } from './center';
7
+ import { Divider } from './divider';
8
+ import { Button } from '../ui/button';
9
+
10
+ const meta = {
11
+ title: 'Layout/Flex',
12
+ component: Flex,
13
+ tags: ['autodocs'],
14
+ // FlexProps = flex vocabulary (direction/align/justify/wrap/gap) + the shared
15
+ // BoxStyleProps. A useful subset is curated; `children` is the composed
16
+ // content slot (§3) and `className`/`style` stay unexposed (§6).
17
+ argTypes: {
18
+ direction: {
19
+ control: 'inline-radio',
20
+ options: ['row', 'column', 'row-reverse', 'column-reverse'],
21
+ },
22
+ align: {
23
+ control: 'select',
24
+ options: ['start', 'center', 'end', 'stretch', 'baseline'],
25
+ },
26
+ justify: {
27
+ control: 'select',
28
+ options: ['start', 'center', 'end', 'between', 'around', 'evenly'],
29
+ },
30
+ gap: { control: 'select', options: [0, 2, 4, 6, 8] },
31
+ wrap: { control: 'boolean' },
32
+ inline: { control: 'boolean' },
33
+ p: { control: 'select', options: [0, 2, 4, 6, 8] },
34
+ bg: {
35
+ control: 'select',
36
+ options: ['background', 'card', 'muted', 'accent'],
37
+ },
38
+ radius: {
39
+ control: 'select',
40
+ options: ['none', 'sm', 'md', 'lg', 'xl', '2xl', 'full'],
41
+ },
42
+ },
43
+ args: { gap: 4, align: 'center', justify: 'start' },
44
+ parameters: { puck: { slots: ['children'] } },
45
+ } satisfies Meta<typeof Flex>;
46
+
47
+ export default meta;
48
+ type Story = StoryObj<typeof meta>;
49
+
50
+ const Item = ({ children }: { children: React.ReactNode }) => (
51
+ <Box bg='vinta-50' color='vinta-700' px={4} py={2} radius='md'>
52
+ {children}
53
+ </Box>
54
+ );
55
+
56
+ export const Playground: Story = {
57
+ render: (args) => (
58
+ <Flex {...args}>
59
+ <Item>One</Item>
60
+ <Item>Two</Item>
61
+ <Item>Three</Item>
62
+ </Flex>
63
+ ),
64
+ };
65
+
66
+ export const SpaceBetween: Story = {
67
+ name: 'HStack + Spacer (toolbar)',
68
+ render: () => (
69
+ <HStack gap={3} p={3} bg='card' radius='lg' border>
70
+ <Item>Logo</Item>
71
+ <Spacer />
72
+ <Button variant='ghost' size='sm'>
73
+ Settings
74
+ </Button>
75
+ <Button size='sm'>New</Button>
76
+ </HStack>
77
+ ),
78
+ };
79
+
80
+ export const Stacks: Story = {
81
+ render: () => (
82
+ <Flex gap={8} align='start'>
83
+ <VStack gap={2}>
84
+ <Item>VStack 1</Item>
85
+ <Item>VStack 2</Item>
86
+ <Item>VStack 3</Item>
87
+ </VStack>
88
+ <Divider orientation='vertical' />
89
+ <HStack gap={2}>
90
+ <Item>HStack 1</Item>
91
+ <Item>HStack 2</Item>
92
+ </HStack>
93
+ </Flex>
94
+ ),
95
+ };
96
+
97
+ export const CenterAndDivider: Story = {
98
+ render: () => (
99
+ <VStack gap={4}>
100
+ <Center bg='muted' radius='lg' height={120}>
101
+ <Item>Centered</Item>
102
+ </Center>
103
+ <Divider />
104
+ <Center bg='muted' radius='lg' height={80}>
105
+ <span className='text-muted-foreground text-sm'>Below the divider</span>
106
+ </Center>
107
+ </VStack>
108
+ ),
109
+ };
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Flex / Grid gap regression tests.
3
+ *
4
+ * Guards the bug where the `gap` shorthand was emitted alongside `undefined`
5
+ * `rowGap`/`columnGap` longhands: React warns about mixing shorthand +
6
+ * longhand and DROPS the shorthand on rerender, silently zeroing the gap
7
+ * (which glued labels to switches/checkboxes/radios). The fix only assigns the
8
+ * gap keys that are actually set.
9
+ */
10
+
11
+ import { describe, it, expect } from 'vitest';
12
+ import { render } from '@testing-library/react';
13
+ import { Flex, HStack } from './flex';
14
+ import { Grid } from './grid';
15
+
16
+ describe('Flex gap', () => {
17
+ it('emits the gap shorthand and NO rowGap/columnGap longhands when only gap is set', () => {
18
+ const { container } = render(
19
+ <HStack gap={2}>
20
+ <span>a</span>
21
+ <span>b</span>
22
+ </HStack>
23
+ );
24
+ const el = container.firstElementChild as HTMLElement;
25
+ expect(el.style.display).toBe('flex');
26
+ expect(el.style.gap).toBe('0.5rem');
27
+ // The longhands must be absent — their presence (even empty) is what made
28
+ // React drop the shorthand.
29
+ expect(el.style.rowGap).toBe('');
30
+ expect(el.style.columnGap).toBe('');
31
+ });
32
+
33
+ it('omits gap entirely when not provided', () => {
34
+ const { container } = render(
35
+ <Flex>
36
+ <span>a</span>
37
+ </Flex>
38
+ );
39
+ const el = container.firstElementChild as HTMLElement;
40
+ expect(el.style.gap).toBe('');
41
+ expect(el.style.rowGap).toBe('');
42
+ expect(el.style.columnGap).toBe('');
43
+ });
44
+
45
+ it('uses longhands (no shorthand) when only rowGap/columnGap are set', () => {
46
+ const { container } = render(
47
+ <Flex rowGap={2} columnGap={4}>
48
+ <span>a</span>
49
+ </Flex>
50
+ );
51
+ const el = container.firstElementChild as HTMLElement;
52
+ expect(el.style.rowGap).toBe('0.5rem');
53
+ expect(el.style.columnGap).toBe('1rem');
54
+ expect(el.style.gap).toBe('');
55
+ });
56
+ });
57
+
58
+ describe('Grid gap', () => {
59
+ it('emits the gap shorthand and NO longhands when only gap is set', () => {
60
+ const { container } = render(
61
+ <Grid gap={3}>
62
+ <span>a</span>
63
+ </Grid>
64
+ );
65
+ const el = container.firstElementChild as HTMLElement;
66
+ expect(el.style.display).toBe('grid');
67
+ expect(el.style.gap).toBe('0.75rem');
68
+ expect(el.style.rowGap).toBe('');
69
+ expect(el.style.columnGap).toBe('');
70
+ });
71
+ });
@@ -0,0 +1,112 @@
1
+ import * as React from 'react';
2
+ import type { CSSProperties } from 'react';
3
+
4
+ import { cn } from '../lib/utils';
5
+ import {
6
+ boxStyle,
7
+ splitBoxProps,
8
+ resolveSpace,
9
+ type BoxStyleProps,
10
+ type Space,
11
+ } from './layout-style';
12
+
13
+ type Align = 'start' | 'center' | 'end' | 'stretch' | 'baseline';
14
+ type Justify = 'start' | 'center' | 'end' | 'between' | 'around' | 'evenly';
15
+
16
+ const ALIGN: Record<Align, CSSProperties['alignItems']> = {
17
+ start: 'flex-start',
18
+ center: 'center',
19
+ end: 'flex-end',
20
+ stretch: 'stretch',
21
+ baseline: 'baseline',
22
+ };
23
+
24
+ const JUSTIFY: Record<Justify, CSSProperties['justifyContent']> = {
25
+ start: 'flex-start',
26
+ center: 'center',
27
+ end: 'flex-end',
28
+ between: 'space-between',
29
+ around: 'space-around',
30
+ evenly: 'space-evenly',
31
+ };
32
+
33
+ export interface FlexProps
34
+ extends BoxStyleProps, Omit<React.HTMLAttributes<HTMLElement>, 'color'> {
35
+ as?: React.ElementType;
36
+ direction?: 'row' | 'column' | 'row-reverse' | 'column-reverse';
37
+ align?: Align;
38
+ justify?: Justify;
39
+ wrap?: boolean | 'reverse';
40
+ gap?: Space;
41
+ rowGap?: Space;
42
+ columnGap?: Space;
43
+ inline?: boolean;
44
+ }
45
+
46
+ /**
47
+ * Flex — flexbox container driven by props. Composes all Box style props plus
48
+ * direction / align / justify / wrap / gap.
49
+ *
50
+ * <Flex justify="between" align="center" gap={4}>…</Flex>
51
+ */
52
+ const Flex = React.forwardRef<HTMLElement, FlexProps>(function Flex(
53
+ {
54
+ as: Comp = 'div',
55
+ className,
56
+ style,
57
+ direction = 'row',
58
+ align,
59
+ justify,
60
+ wrap,
61
+ gap,
62
+ rowGap,
63
+ columnGap,
64
+ inline,
65
+ ...boxProps
66
+ },
67
+ ref
68
+ ) {
69
+ const { style: resolved, rest } = splitBoxProps(boxProps);
70
+ const flexStyle: CSSProperties = {
71
+ display: inline ? 'inline-flex' : 'flex',
72
+ flexDirection: direction,
73
+ alignItems: align ? ALIGN[align] : undefined,
74
+ justifyContent: justify ? JUSTIFY[justify] : undefined,
75
+ flexWrap:
76
+ wrap === true ? 'wrap' : wrap === 'reverse' ? 'wrap-reverse' : undefined,
77
+ };
78
+ // Only assign the gap keys that are actually set. Emitting the `gap`
79
+ // shorthand alongside `undefined` `rowGap`/`columnGap` longhands makes React
80
+ // warn about mixing shorthand + longhand and DROP the shorthand on rerender —
81
+ // which silently zeroed the gap (glued labels next to switches/checkboxes).
82
+ if (gap != null) flexStyle.gap = resolveSpace(gap);
83
+ if (rowGap != null) flexStyle.rowGap = resolveSpace(rowGap);
84
+ if (columnGap != null) flexStyle.columnGap = resolveSpace(columnGap);
85
+ return (
86
+ <Comp
87
+ ref={ref}
88
+ className={cn(className)}
89
+ style={{ ...resolved, ...flexStyle, ...style }}
90
+ {...rest}
91
+ />
92
+ );
93
+ });
94
+
95
+ export type StackProps = Omit<FlexProps, 'direction'>;
96
+
97
+ /** Vertical flex stack. */
98
+ const VStack = React.forwardRef<HTMLElement, StackProps>(
99
+ function VStack(props, ref) {
100
+ return <Flex ref={ref} direction='column' {...props} />;
101
+ }
102
+ );
103
+
104
+ /** Horizontal flex stack (centers items vertically by default). */
105
+ const HStack = React.forwardRef<HTMLElement, StackProps>(function HStack(
106
+ { align = 'center', ...props },
107
+ ref
108
+ ) {
109
+ return <Flex ref={ref} direction='row' align={align} {...props} />;
110
+ });
111
+
112
+ export { Flex, HStack, VStack, boxStyle };
@@ -0,0 +1,91 @@
1
+ import type { Meta, StoryObj } from '../story-types';
2
+
3
+ import { Grid, GridItem } from './grid';
4
+
5
+ const meta = {
6
+ title: 'Layout/Grid',
7
+ component: Grid,
8
+ tags: ['autodocs'],
9
+ // GridProps = grid vocabulary (columns/rows/gap/align/justify/inline) + the
10
+ // shared BoxStyleProps. `children` is the composed content slot (§3).
11
+ argTypes: {
12
+ columns: { control: { type: 'number', min: 1, max: 12 } },
13
+ gap: { control: 'select', options: [0, 1, 2, 3, 4, 5, 6, 8] },
14
+ rowGap: { control: 'select', options: [0, 1, 2, 3, 4, 5, 6, 8] },
15
+ columnGap: { control: 'select', options: [0, 1, 2, 3, 4, 5, 6, 8] },
16
+ align: {
17
+ control: 'select',
18
+ options: ['start', 'center', 'end', 'stretch'],
19
+ },
20
+ justify: {
21
+ control: 'select',
22
+ options: ['start', 'center', 'end', 'stretch'],
23
+ },
24
+ p: { control: 'select', options: [0, 2, 4, 6, 8] },
25
+ },
26
+ args: { columns: 12, gap: 6 },
27
+ parameters: { puck: { slots: ['children'] } },
28
+ } satisfies Meta<typeof Grid>;
29
+
30
+ export default meta;
31
+ type Story = StoryObj<typeof meta>;
32
+
33
+ const Cell = ({ children }: { children: React.ReactNode }) => (
34
+ <div className='bg-vinta-50 text-vinta-700 rounded-md px-3 py-4 text-center font-mono text-xs'>
35
+ {children}
36
+ </div>
37
+ );
38
+
39
+ export const TwelveColumns: Story = {
40
+ args: { columns: 12, gap: 6 },
41
+ render: (args) => (
42
+ <Grid {...args}>
43
+ {Array.from({ length: 12 }).map((_, i) => (
44
+ <GridItem key={i} span={1}>
45
+ <Cell>{i + 1}</Cell>
46
+ </GridItem>
47
+ ))}
48
+ </Grid>
49
+ ),
50
+ };
51
+
52
+ export const Spans: Story = {
53
+ render: () => (
54
+ <Grid gap={4}>
55
+ <GridItem span={6}>
56
+ <Cell>span 6</Cell>
57
+ </GridItem>
58
+ <GridItem span={6}>
59
+ <Cell>span 6</Cell>
60
+ </GridItem>
61
+ <GridItem span={4}>
62
+ <Cell>span 4</Cell>
63
+ </GridItem>
64
+ <GridItem span={4}>
65
+ <Cell>span 4</Cell>
66
+ </GridItem>
67
+ <GridItem span={4}>
68
+ <Cell>span 4</Cell>
69
+ </GridItem>
70
+ <GridItem span={8}>
71
+ <Cell>span 8</Cell>
72
+ </GridItem>
73
+ <GridItem span={4}>
74
+ <Cell>span 4</Cell>
75
+ </GridItem>
76
+ </Grid>
77
+ ),
78
+ };
79
+
80
+ export const EqualColumns: Story = {
81
+ name: 'Equal columns (columns=3)',
82
+ render: () => (
83
+ <Grid columns={3} gap={4}>
84
+ {Array.from({ length: 6 }).map((_, i) => (
85
+ <GridItem key={i}>
86
+ <Cell>{i + 1}</Cell>
87
+ </GridItem>
88
+ ))}
89
+ </Grid>
90
+ ),
91
+ };
@@ -0,0 +1,132 @@
1
+ import * as React from 'react';
2
+ import type { CSSProperties } from 'react';
3
+
4
+ import { cn } from '../lib/utils';
5
+ import {
6
+ splitBoxProps,
7
+ resolveSpace,
8
+ type BoxStyleProps,
9
+ type Space,
10
+ } from './layout-style';
11
+
12
+ type Align = 'start' | 'center' | 'end' | 'stretch';
13
+ type Justify = 'start' | 'center' | 'end' | 'stretch';
14
+
15
+ const ALIGN: Record<Align, CSSProperties['alignItems']> = {
16
+ start: 'start',
17
+ center: 'center',
18
+ end: 'end',
19
+ stretch: 'stretch',
20
+ };
21
+
22
+ export interface GridProps
23
+ extends BoxStyleProps, Omit<React.HTMLAttributes<HTMLElement>, 'color'> {
24
+ as?: React.ElementType;
25
+ /** Number of equal columns, or a raw grid-template-columns string. */
26
+ columns?: number | string;
27
+ rows?: number | string;
28
+ gap?: Space;
29
+ rowGap?: Space;
30
+ columnGap?: Space;
31
+ align?: Align;
32
+ justify?: Justify;
33
+ inline?: boolean;
34
+ }
35
+
36
+ function template(value: number | string | undefined): string | undefined {
37
+ if (value == null) return undefined;
38
+ if (typeof value === 'number') return `repeat(${value}, minmax(0, 1fr))`;
39
+ return value;
40
+ }
41
+
42
+ /**
43
+ * Grid — CSS grid container. Defaults to the design system's 12-column grid;
44
+ * pass `columns` for any count (or a template string). Compose with <GridItem>.
45
+ *
46
+ * <Grid columns={3} gap={4}>…</Grid>
47
+ */
48
+ const Grid = React.forwardRef<HTMLElement, GridProps>(function Grid(
49
+ {
50
+ as: Comp = 'div',
51
+ className,
52
+ style,
53
+ columns = 12,
54
+ rows,
55
+ gap = 6,
56
+ rowGap,
57
+ columnGap,
58
+ align,
59
+ justify,
60
+ inline,
61
+ ...boxProps
62
+ },
63
+ ref
64
+ ) {
65
+ const { style: resolved, rest } = splitBoxProps(boxProps);
66
+ const gridStyle: CSSProperties = {
67
+ display: inline ? 'inline-grid' : 'grid',
68
+ gridTemplateColumns: template(columns),
69
+ gridTemplateRows: template(rows),
70
+ alignItems: align ? ALIGN[align] : undefined,
71
+ justifyItems: justify ? ALIGN[justify] : undefined,
72
+ };
73
+ // Never emit the `gap` shorthand together with `undefined` `rowGap`/`columnGap`
74
+ // longhands — React drops the shorthand on rerender, zeroing the gap.
75
+ if (gap != null) gridStyle.gap = resolveSpace(gap);
76
+ if (rowGap != null) gridStyle.rowGap = resolveSpace(rowGap);
77
+ if (columnGap != null) gridStyle.columnGap = resolveSpace(columnGap);
78
+ return (
79
+ <Comp
80
+ ref={ref}
81
+ className={cn(className)}
82
+ style={{ ...resolved, ...gridStyle, ...style }}
83
+ {...rest}
84
+ />
85
+ );
86
+ });
87
+
88
+ export interface GridItemProps
89
+ extends BoxStyleProps, Omit<React.HTMLAttributes<HTMLElement>, 'color'> {
90
+ as?: React.ElementType;
91
+ /** Columns to span. */
92
+ span?: number;
93
+ /** Rows to span. */
94
+ rowSpan?: number;
95
+ colStart?: number;
96
+ colEnd?: number;
97
+ }
98
+
99
+ const GridItem = React.forwardRef<HTMLElement, GridItemProps>(function GridItem(
100
+ {
101
+ as: Comp = 'div',
102
+ className,
103
+ style,
104
+ span,
105
+ rowSpan,
106
+ colStart,
107
+ colEnd,
108
+ ...boxProps
109
+ },
110
+ ref
111
+ ) {
112
+ const { style: resolved, rest } = splitBoxProps(boxProps);
113
+ const itemStyle: CSSProperties = {
114
+ gridColumn:
115
+ colStart != null || colEnd != null
116
+ ? `${colStart ?? 'auto'} / ${colEnd ?? (span != null ? `span ${span}` : 'auto')}`
117
+ : span != null
118
+ ? `span ${span} / span ${span}`
119
+ : undefined,
120
+ gridRow: rowSpan != null ? `span ${rowSpan} / span ${rowSpan}` : undefined,
121
+ };
122
+ return (
123
+ <Comp
124
+ ref={ref}
125
+ className={cn(className)}
126
+ style={{ ...resolved, ...itemStyle, ...style }}
127
+ {...rest}
128
+ />
129
+ );
130
+ });
131
+
132
+ export { Grid, GridItem };
@@ -0,0 +1,53 @@
1
+ import * as React from 'react';
2
+
3
+ import { Text, type TextProps, type TextSize, type TextWeight } from './text';
4
+
5
+ type Level = 1 | 2 | 3 | 4 | 5 | 6;
6
+
7
+ const DEFAULT_SIZE: Record<Level, TextSize> = {
8
+ 1: '5xl',
9
+ 2: '4xl',
10
+ 3: '2xl',
11
+ 4: 'xl',
12
+ 5: 'lg',
13
+ 6: 'base',
14
+ };
15
+
16
+ const DEFAULT_WEIGHT: Record<Level, TextWeight> = {
17
+ 1: 'bold',
18
+ 2: 'semibold',
19
+ 3: 'semibold',
20
+ 4: 'semibold',
21
+ 5: 'semibold',
22
+ 6: 'semibold',
23
+ };
24
+
25
+ export interface HeadingProps extends TextProps {
26
+ /** Heading level 1–6. Sets the tag and default size/weight. */
27
+ level?: Level;
28
+ }
29
+
30
+ /**
31
+ * Heading — semantic heading with design-system defaults per level. Override
32
+ * any token via props.
33
+ *
34
+ * <Heading level={2}>Section title</Heading>
35
+ */
36
+ const Heading = React.forwardRef<HTMLElement, HeadingProps>(function Heading(
37
+ { level = 2, size, weight, tracking = 'tight', leading = 'tight', ...props },
38
+ ref
39
+ ) {
40
+ return (
41
+ <Text
42
+ ref={ref}
43
+ as={`h${level}`}
44
+ size={size ?? DEFAULT_SIZE[level]}
45
+ weight={weight ?? DEFAULT_WEIGHT[level]}
46
+ tracking={tracking}
47
+ leading={leading}
48
+ {...props}
49
+ />
50
+ );
51
+ });
52
+
53
+ export { Heading };