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,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layout kit — compose UIs from props, not classes.
|
|
3
|
+
*
|
|
4
|
+
* Primitives: Box · Flex · HStack · VStack · Stack · Grid · GridItem ·
|
|
5
|
+
* Center · Spacer · Section · Divider · Container
|
|
6
|
+
* Typography: Text · Heading
|
|
7
|
+
* Composition: Navbar · AppTopbar · PageHeader
|
|
8
|
+
* Shells: AppShell · AuthLayout
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// Primitives
|
|
12
|
+
export { Box, type BoxProps } from './box';
|
|
13
|
+
export { Flex, HStack, VStack, type FlexProps, type StackProps } from './flex';
|
|
14
|
+
export { Stack } from './stack';
|
|
15
|
+
export { Grid, GridItem, type GridProps, type GridItemProps } from './grid';
|
|
16
|
+
export { Center } from './center';
|
|
17
|
+
export { Spacer } from './spacer';
|
|
18
|
+
export { Section, type SectionProps } from './section';
|
|
19
|
+
export { Divider, type DividerProps } from './divider';
|
|
20
|
+
export { Container, type ContainerProps } from './container';
|
|
21
|
+
|
|
22
|
+
// Typography
|
|
23
|
+
export {
|
|
24
|
+
Text,
|
|
25
|
+
type TextProps,
|
|
26
|
+
type TextSize,
|
|
27
|
+
type TextWeight,
|
|
28
|
+
type TextFamily,
|
|
29
|
+
} from './text';
|
|
30
|
+
export { Heading, type HeadingProps } from './heading';
|
|
31
|
+
|
|
32
|
+
// Style-prop vocabulary
|
|
33
|
+
export type {
|
|
34
|
+
Space,
|
|
35
|
+
Radius,
|
|
36
|
+
Shadow,
|
|
37
|
+
ColorToken,
|
|
38
|
+
Size,
|
|
39
|
+
BoxStyleProps,
|
|
40
|
+
} from './layout-style';
|
|
41
|
+
|
|
42
|
+
// Composition + shells
|
|
43
|
+
export { Navbar, BrandMark, type NavbarProps } from './navbar';
|
|
44
|
+
export { AppTopbar, type AppTopbarProps } from './app-topbar';
|
|
45
|
+
export { PageHeader, type PageHeaderProps } from './page-header';
|
|
46
|
+
export { AppShell, type AppShellProps } from './app-shell';
|
|
47
|
+
export { AuthLayout, type AuthLayoutProps } from './auth-layout';
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import type { CSSProperties } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared style-prop vocabulary for the layout primitives. Values resolve to the
|
|
5
|
+
* design-system tokens (spacing scale, radius, shadow, color CSS vars) so the
|
|
6
|
+
* primitives can be composed purely through props — no Tailwind classes needed.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/* 4px spacing scale (matches the DS --space-* tokens). */
|
|
10
|
+
export type Space = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 8 | 10 | 12 | 16 | 20 | 24;
|
|
11
|
+
|
|
12
|
+
const SPACE_REM: Record<Space, string> = {
|
|
13
|
+
0: '0',
|
|
14
|
+
1: '0.25rem',
|
|
15
|
+
2: '0.5rem',
|
|
16
|
+
3: '0.75rem',
|
|
17
|
+
4: '1rem',
|
|
18
|
+
5: '1.25rem',
|
|
19
|
+
6: '1.5rem',
|
|
20
|
+
8: '2rem',
|
|
21
|
+
10: '2.5rem',
|
|
22
|
+
12: '3rem',
|
|
23
|
+
16: '4rem',
|
|
24
|
+
20: '5rem',
|
|
25
|
+
24: '6rem',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type Radius = 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
|
|
29
|
+
|
|
30
|
+
const RADIUS: Record<Radius, string> = {
|
|
31
|
+
none: '0',
|
|
32
|
+
sm: 'var(--radius-sm)',
|
|
33
|
+
md: 'var(--radius-md)',
|
|
34
|
+
lg: 'var(--radius-lg)',
|
|
35
|
+
xl: 'var(--radius-xl)',
|
|
36
|
+
'2xl': 'var(--radius-2xl)',
|
|
37
|
+
full: '9999px',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type Shadow = 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
|
41
|
+
|
|
42
|
+
const SHADOW: Record<Shadow, string> = {
|
|
43
|
+
none: 'none',
|
|
44
|
+
xs: 'var(--shadow-xs)',
|
|
45
|
+
sm: 'var(--shadow-sm)',
|
|
46
|
+
md: 'var(--shadow-md)',
|
|
47
|
+
lg: 'var(--shadow-lg)',
|
|
48
|
+
xl: 'var(--shadow-xl)',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A design-token color name (e.g. 'primary', 'muted-foreground', 'vinta-600',
|
|
53
|
+
* 'border'). Resolves to `var(--<name>)`. Any raw CSS color string also works.
|
|
54
|
+
*/
|
|
55
|
+
export type ColorToken =
|
|
56
|
+
| 'background'
|
|
57
|
+
| 'foreground'
|
|
58
|
+
| 'card'
|
|
59
|
+
| 'card-foreground'
|
|
60
|
+
| 'popover'
|
|
61
|
+
| 'popover-foreground'
|
|
62
|
+
| 'primary'
|
|
63
|
+
| 'primary-foreground'
|
|
64
|
+
| 'secondary'
|
|
65
|
+
| 'secondary-foreground'
|
|
66
|
+
| 'muted'
|
|
67
|
+
| 'muted-foreground'
|
|
68
|
+
| 'accent'
|
|
69
|
+
| 'accent-foreground'
|
|
70
|
+
| 'destructive'
|
|
71
|
+
| 'destructive-foreground'
|
|
72
|
+
| 'success'
|
|
73
|
+
| 'warning'
|
|
74
|
+
| 'border'
|
|
75
|
+
| 'input'
|
|
76
|
+
| 'ring'
|
|
77
|
+
| `vinta-${50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 950}`
|
|
78
|
+
| `teal-${100 | 300 | 500 | 600 | 700}`
|
|
79
|
+
| (string & {});
|
|
80
|
+
|
|
81
|
+
export type Size = number | string;
|
|
82
|
+
|
|
83
|
+
function size(v: Size | undefined): string | undefined {
|
|
84
|
+
if (v == null) return undefined;
|
|
85
|
+
if (typeof v === 'number') return `${v}px`;
|
|
86
|
+
if (v === 'full') return '100%';
|
|
87
|
+
if (v === 'screen') return '100vw';
|
|
88
|
+
if (v === 'min') return 'min-content';
|
|
89
|
+
if (v === 'max') return 'max-content';
|
|
90
|
+
if (v === 'fit') return 'fit-content';
|
|
91
|
+
return v;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function heightSize(v: Size | undefined): string | undefined {
|
|
95
|
+
if (v === 'screen') return '100vh';
|
|
96
|
+
return size(v);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function spacing(v: Space | 'auto' | undefined): string | undefined {
|
|
100
|
+
if (v == null) return undefined;
|
|
101
|
+
if (v === 'auto') return 'auto';
|
|
102
|
+
return SPACE_REM[v];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function color(c: ColorToken | undefined): string | undefined {
|
|
106
|
+
if (c == null) return undefined;
|
|
107
|
+
// Raw CSS values (hex, rgb, var(), currentColor, transparent) pass through.
|
|
108
|
+
if (
|
|
109
|
+
c.startsWith('#') ||
|
|
110
|
+
c.startsWith('var(') ||
|
|
111
|
+
c.startsWith('rgb') ||
|
|
112
|
+
c.startsWith('hsl') ||
|
|
113
|
+
c.startsWith('oklch') ||
|
|
114
|
+
c === 'transparent' ||
|
|
115
|
+
c === 'currentColor' ||
|
|
116
|
+
c === 'inherit'
|
|
117
|
+
) {
|
|
118
|
+
return c;
|
|
119
|
+
}
|
|
120
|
+
return `var(--${c})`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Box style props supported by every primitive. */
|
|
124
|
+
export interface BoxStyleProps {
|
|
125
|
+
// padding
|
|
126
|
+
p?: Space;
|
|
127
|
+
px?: Space;
|
|
128
|
+
py?: Space;
|
|
129
|
+
pt?: Space;
|
|
130
|
+
pr?: Space;
|
|
131
|
+
pb?: Space;
|
|
132
|
+
pl?: Space;
|
|
133
|
+
// margin
|
|
134
|
+
m?: Space | 'auto';
|
|
135
|
+
mx?: Space | 'auto';
|
|
136
|
+
my?: Space | 'auto';
|
|
137
|
+
mt?: Space | 'auto';
|
|
138
|
+
mr?: Space | 'auto';
|
|
139
|
+
mb?: Space | 'auto';
|
|
140
|
+
ml?: Space | 'auto';
|
|
141
|
+
// color / surface
|
|
142
|
+
bg?: ColorToken;
|
|
143
|
+
color?: ColorToken;
|
|
144
|
+
radius?: Radius;
|
|
145
|
+
shadow?: Shadow;
|
|
146
|
+
border?: boolean | number;
|
|
147
|
+
borderColor?: ColorToken;
|
|
148
|
+
// sizing
|
|
149
|
+
width?: Size;
|
|
150
|
+
height?: Size;
|
|
151
|
+
minWidth?: Size;
|
|
152
|
+
maxWidth?: Size;
|
|
153
|
+
minHeight?: Size;
|
|
154
|
+
maxHeight?: Size;
|
|
155
|
+
// box behavior
|
|
156
|
+
display?: CSSProperties['display'];
|
|
157
|
+
overflow?: CSSProperties['overflow'];
|
|
158
|
+
position?: CSSProperties['position'];
|
|
159
|
+
// flex item
|
|
160
|
+
grow?: boolean | number;
|
|
161
|
+
shrink?: boolean | number;
|
|
162
|
+
basis?: Size;
|
|
163
|
+
alignSelf?: CSSProperties['alignSelf'];
|
|
164
|
+
// escape hatch
|
|
165
|
+
textAlign?: CSSProperties['textAlign'];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const BOX_KEYS: (keyof BoxStyleProps)[] = [
|
|
169
|
+
'p',
|
|
170
|
+
'px',
|
|
171
|
+
'py',
|
|
172
|
+
'pt',
|
|
173
|
+
'pr',
|
|
174
|
+
'pb',
|
|
175
|
+
'pl',
|
|
176
|
+
'm',
|
|
177
|
+
'mx',
|
|
178
|
+
'my',
|
|
179
|
+
'mt',
|
|
180
|
+
'mr',
|
|
181
|
+
'mb',
|
|
182
|
+
'ml',
|
|
183
|
+
'bg',
|
|
184
|
+
'color',
|
|
185
|
+
'radius',
|
|
186
|
+
'shadow',
|
|
187
|
+
'border',
|
|
188
|
+
'borderColor',
|
|
189
|
+
'width',
|
|
190
|
+
'height',
|
|
191
|
+
'minWidth',
|
|
192
|
+
'maxWidth',
|
|
193
|
+
'minHeight',
|
|
194
|
+
'maxHeight',
|
|
195
|
+
'display',
|
|
196
|
+
'overflow',
|
|
197
|
+
'position',
|
|
198
|
+
'grow',
|
|
199
|
+
'shrink',
|
|
200
|
+
'basis',
|
|
201
|
+
'alignSelf',
|
|
202
|
+
'textAlign',
|
|
203
|
+
];
|
|
204
|
+
|
|
205
|
+
function flexNum(v: boolean | number | undefined): number | undefined {
|
|
206
|
+
if (v == null) return undefined;
|
|
207
|
+
return v === true ? 1 : v === false ? 0 : v;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Build a CSSProperties object from box style props. */
|
|
211
|
+
export function boxStyle(props: BoxStyleProps): CSSProperties {
|
|
212
|
+
const s: CSSProperties = {};
|
|
213
|
+
|
|
214
|
+
// padding
|
|
215
|
+
if (props.p != null) s.padding = spacing(props.p);
|
|
216
|
+
if (props.px != null) {
|
|
217
|
+
s.paddingLeft = spacing(props.px);
|
|
218
|
+
s.paddingRight = spacing(props.px);
|
|
219
|
+
}
|
|
220
|
+
if (props.py != null) {
|
|
221
|
+
s.paddingTop = spacing(props.py);
|
|
222
|
+
s.paddingBottom = spacing(props.py);
|
|
223
|
+
}
|
|
224
|
+
if (props.pt != null) s.paddingTop = spacing(props.pt);
|
|
225
|
+
if (props.pr != null) s.paddingRight = spacing(props.pr);
|
|
226
|
+
if (props.pb != null) s.paddingBottom = spacing(props.pb);
|
|
227
|
+
if (props.pl != null) s.paddingLeft = spacing(props.pl);
|
|
228
|
+
|
|
229
|
+
// margin
|
|
230
|
+
if (props.m != null) s.margin = spacing(props.m);
|
|
231
|
+
if (props.mx != null) {
|
|
232
|
+
s.marginLeft = spacing(props.mx);
|
|
233
|
+
s.marginRight = spacing(props.mx);
|
|
234
|
+
}
|
|
235
|
+
if (props.my != null) {
|
|
236
|
+
s.marginTop = spacing(props.my);
|
|
237
|
+
s.marginBottom = spacing(props.my);
|
|
238
|
+
}
|
|
239
|
+
if (props.mt != null) s.marginTop = spacing(props.mt);
|
|
240
|
+
if (props.mr != null) s.marginRight = spacing(props.mr);
|
|
241
|
+
if (props.mb != null) s.marginBottom = spacing(props.mb);
|
|
242
|
+
if (props.ml != null) s.marginLeft = spacing(props.ml);
|
|
243
|
+
|
|
244
|
+
// surface
|
|
245
|
+
if (props.bg != null) s.backgroundColor = color(props.bg);
|
|
246
|
+
if (props.color != null) s.color = color(props.color);
|
|
247
|
+
if (props.radius != null) s.borderRadius = RADIUS[props.radius];
|
|
248
|
+
if (props.shadow != null) s.boxShadow = SHADOW[props.shadow];
|
|
249
|
+
if (props.border) {
|
|
250
|
+
const w = props.border === true ? 1 : props.border;
|
|
251
|
+
s.borderWidth = `${w}px`;
|
|
252
|
+
s.borderStyle = 'solid';
|
|
253
|
+
s.borderColor = color(props.borderColor ?? 'border');
|
|
254
|
+
} else if (props.borderColor != null) {
|
|
255
|
+
s.borderColor = color(props.borderColor);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// sizing
|
|
259
|
+
if (props.width != null) s.width = size(props.width);
|
|
260
|
+
if (props.height != null) s.height = heightSize(props.height);
|
|
261
|
+
if (props.minWidth != null) s.minWidth = size(props.minWidth);
|
|
262
|
+
if (props.maxWidth != null) s.maxWidth = size(props.maxWidth);
|
|
263
|
+
if (props.minHeight != null) s.minHeight = heightSize(props.minHeight);
|
|
264
|
+
if (props.maxHeight != null) s.maxHeight = heightSize(props.maxHeight);
|
|
265
|
+
|
|
266
|
+
// behavior
|
|
267
|
+
if (props.display != null) s.display = props.display;
|
|
268
|
+
if (props.overflow != null) s.overflow = props.overflow;
|
|
269
|
+
if (props.position != null) s.position = props.position;
|
|
270
|
+
if (props.textAlign != null) s.textAlign = props.textAlign;
|
|
271
|
+
|
|
272
|
+
// flex item
|
|
273
|
+
if (props.grow != null) s.flexGrow = flexNum(props.grow);
|
|
274
|
+
if (props.shrink != null) s.flexShrink = flexNum(props.shrink);
|
|
275
|
+
if (props.basis != null) s.flexBasis = size(props.basis);
|
|
276
|
+
if (props.alignSelf != null) s.alignSelf = props.alignSelf;
|
|
277
|
+
|
|
278
|
+
return s;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Split box style props out of a mixed props object. */
|
|
282
|
+
export function splitBoxProps<T extends BoxStyleProps>(
|
|
283
|
+
props: T
|
|
284
|
+
): { style: CSSProperties; rest: Omit<T, keyof BoxStyleProps> } {
|
|
285
|
+
const style = boxStyle(props);
|
|
286
|
+
const rest = { ...props } as Record<string, unknown>;
|
|
287
|
+
for (const k of BOX_KEYS) delete rest[k as string];
|
|
288
|
+
return { style, rest: rest as Omit<T, keyof BoxStyleProps> };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export { spacing as resolveSpace, size as resolveSize };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '../story-types';
|
|
2
|
+
import { PrototypeModeProvider } from 'vinta-ui-composer-prototype-mode';
|
|
3
|
+
|
|
4
|
+
import { Link } from './link';
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: 'Layout/Link',
|
|
8
|
+
component: Link,
|
|
9
|
+
tags: ['autodocs'],
|
|
10
|
+
// Leaf: the label is editable text, the target is a plain string the platform
|
|
11
|
+
// resolves. `className`/`style` are never exposed (§6).
|
|
12
|
+
argTypes: {
|
|
13
|
+
children: { control: 'text', description: 'Link label' },
|
|
14
|
+
linkTo: {
|
|
15
|
+
control: 'text',
|
|
16
|
+
description: 'Target screen the platform resolves',
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
args: { children: 'View booking', linkTo: '/bookings' },
|
|
20
|
+
} satisfies Meta<typeof Link>;
|
|
21
|
+
|
|
22
|
+
export default meta;
|
|
23
|
+
type Story = StoryObj<typeof meta>;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Default — no provider, so the context falls back to editor mode: INERT.
|
|
27
|
+
* This is what the composer canvas renders while you're editing.
|
|
28
|
+
*/
|
|
29
|
+
export const EditorInert: Story = {};
|
|
30
|
+
|
|
31
|
+
/** Viewer mode with a host `navigate` — the only state that actually routes. */
|
|
32
|
+
export const ViewerNavigating: Story = {
|
|
33
|
+
render: (args) => (
|
|
34
|
+
<PrototypeModeProvider
|
|
35
|
+
mode='viewer'
|
|
36
|
+
navigate={(to) => window.alert(`navigate → ${to}`)}
|
|
37
|
+
>
|
|
38
|
+
<Link {...args} />
|
|
39
|
+
</PrototypeModeProvider>
|
|
40
|
+
),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Viewer mode, but the target is unreachable (archived/unpublished) → INERT. */
|
|
44
|
+
export const ViewerUnreachable: Story = {
|
|
45
|
+
render: (args) => (
|
|
46
|
+
<PrototypeModeProvider
|
|
47
|
+
mode='viewer'
|
|
48
|
+
navigate={(to) => window.alert(`navigate → ${to}`)}
|
|
49
|
+
canNavigate={() => false}
|
|
50
|
+
>
|
|
51
|
+
<Link {...args} />
|
|
52
|
+
</PrototypeModeProvider>
|
|
53
|
+
),
|
|
54
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { PrototypeModeProvider } from 'vinta-ui-composer-prototype-mode';
|
|
5
|
+
|
|
6
|
+
import { Link } from './link';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The inert-vs-navigating contract (§8). An inert link renders an <a> with no
|
|
10
|
+
* href, so it is not exposed as a link at all — asserting on the `link` role is
|
|
11
|
+
* the sharpest way to pin that.
|
|
12
|
+
*/
|
|
13
|
+
describe('Link', () => {
|
|
14
|
+
it('is inert with no provider (editor is the default)', () => {
|
|
15
|
+
render(<Link linkTo='/bookings'>View booking</Link>);
|
|
16
|
+
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
|
17
|
+
expect(screen.getByText('View booking')).not.toHaveAttribute('href');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('is inert in editor mode even when a navigate is supplied', async () => {
|
|
21
|
+
const navigate = vi.fn();
|
|
22
|
+
render(
|
|
23
|
+
<PrototypeModeProvider mode='editor' navigate={navigate}>
|
|
24
|
+
<Link linkTo='/bookings'>View booking</Link>
|
|
25
|
+
</PrototypeModeProvider>
|
|
26
|
+
);
|
|
27
|
+
await userEvent.click(screen.getByText('View booking'));
|
|
28
|
+
expect(navigate).not.toHaveBeenCalled();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('navigates via the host callback in viewer mode', async () => {
|
|
32
|
+
const navigate = vi.fn();
|
|
33
|
+
render(
|
|
34
|
+
<PrototypeModeProvider mode='viewer' navigate={navigate}>
|
|
35
|
+
<Link linkTo='/bookings'>View booking</Link>
|
|
36
|
+
</PrototypeModeProvider>
|
|
37
|
+
);
|
|
38
|
+
const link = screen.getByRole('link', { name: 'View booking' });
|
|
39
|
+
expect(link).toHaveAttribute('href', '/bookings');
|
|
40
|
+
|
|
41
|
+
await userEvent.click(link);
|
|
42
|
+
expect(navigate).toHaveBeenCalledWith('/bookings');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('is inert in viewer mode when the target is unreachable', async () => {
|
|
46
|
+
const navigate = vi.fn();
|
|
47
|
+
render(
|
|
48
|
+
<PrototypeModeProvider
|
|
49
|
+
mode='viewer'
|
|
50
|
+
navigate={navigate}
|
|
51
|
+
canNavigate={() => false}
|
|
52
|
+
>
|
|
53
|
+
<Link linkTo='/archived'>Archived</Link>
|
|
54
|
+
</PrototypeModeProvider>
|
|
55
|
+
);
|
|
56
|
+
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
|
57
|
+
await userEvent.click(screen.getByText('Archived'));
|
|
58
|
+
expect(navigate).not.toHaveBeenCalled();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('prefetches the target on hover', async () => {
|
|
62
|
+
const prefetch = vi.fn();
|
|
63
|
+
render(
|
|
64
|
+
<PrototypeModeProvider
|
|
65
|
+
mode='viewer'
|
|
66
|
+
navigate={vi.fn()}
|
|
67
|
+
prefetch={prefetch}
|
|
68
|
+
>
|
|
69
|
+
<Link linkTo='/bookings'>View booking</Link>
|
|
70
|
+
</PrototypeModeProvider>
|
|
71
|
+
);
|
|
72
|
+
await userEvent.hover(screen.getByRole('link'));
|
|
73
|
+
expect(prefetch).toHaveBeenCalledWith('/bookings');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { usePrototypeMode } from 'vinta-ui-composer-prototype-mode';
|
|
3
|
+
|
|
4
|
+
import { cn } from '../lib/utils';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Link — the navigating primitive for the composer.
|
|
8
|
+
*
|
|
9
|
+
* The design system has no router of its own. This component reads the
|
|
10
|
+
* platform-owned `vinta-ui-composer-prototype-mode` context (a peer dependency
|
|
11
|
+
* kept EXTERNAL at bundle time and shimmed to the host's copy, exactly like
|
|
12
|
+
* `react`) to decide inert-vs-navigating:
|
|
13
|
+
*
|
|
14
|
+
* · editor mode (the default when no provider is present) → INERT. The
|
|
15
|
+
* composer canvas must never navigate when you click a link you're editing.
|
|
16
|
+
* · viewer mode → calls the host-supplied `navigate(linkTo)`.
|
|
17
|
+
* · viewer mode, but `canNavigate(linkTo)` is false (archived / unpublished /
|
|
18
|
+
* missing target) → INERT.
|
|
19
|
+
*
|
|
20
|
+
* Inert renders an `<a>` WITHOUT `href`, which is not a link to the
|
|
21
|
+
* accessibility tree and is not focusable — the correct "looks like a link,
|
|
22
|
+
* does nothing" state, rather than a real link we cancel on click.
|
|
23
|
+
*
|
|
24
|
+
* Deliberately NOT re-exported from `./index` — the schedule app must keep
|
|
25
|
+
* using `next/link` for real routing. This primitive exists for prototypes
|
|
26
|
+
* rendered by the composer, and is reachable via the `./layout/link` subpath
|
|
27
|
+
* (which is also how the platform's synthesized barrel discovers it).
|
|
28
|
+
*/
|
|
29
|
+
export interface LinkProps extends Omit<
|
|
30
|
+
React.AnchorHTMLAttributes<HTMLAnchorElement>,
|
|
31
|
+
'href'
|
|
32
|
+
> {
|
|
33
|
+
/** Target screen. The platform resolves this; we never route on our own. */
|
|
34
|
+
linkTo: string;
|
|
35
|
+
children?: React.ReactNode;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(function Link(
|
|
39
|
+
{ linkTo, children, className, onClick, onMouseEnter, onFocus, ...props },
|
|
40
|
+
ref
|
|
41
|
+
) {
|
|
42
|
+
const { mode, navigate, prefetch, canNavigate } = usePrototypeMode();
|
|
43
|
+
|
|
44
|
+
const reachable = canNavigate ? canNavigate(linkTo) : true;
|
|
45
|
+
const inert = mode !== 'viewer' || !navigate || !reachable;
|
|
46
|
+
|
|
47
|
+
const classes = cn(
|
|
48
|
+
'text-primary underline-offset-4 hover:underline',
|
|
49
|
+
inert && 'cursor-default',
|
|
50
|
+
className
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
if (inert) {
|
|
54
|
+
return (
|
|
55
|
+
<a ref={ref} className={classes} data-inert='' {...props}>
|
|
56
|
+
{children}
|
|
57
|
+
</a>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const warm = () => prefetch?.(linkTo);
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<a
|
|
65
|
+
ref={ref}
|
|
66
|
+
href={linkTo}
|
|
67
|
+
className={classes}
|
|
68
|
+
onMouseEnter={(e) => {
|
|
69
|
+
warm();
|
|
70
|
+
onMouseEnter?.(e);
|
|
71
|
+
}}
|
|
72
|
+
onFocus={(e) => {
|
|
73
|
+
warm();
|
|
74
|
+
onFocus?.(e);
|
|
75
|
+
}}
|
|
76
|
+
onClick={(e) => {
|
|
77
|
+
onClick?.(e);
|
|
78
|
+
// Let the browser own modified clicks (new tab/window) and anything a
|
|
79
|
+
// consumer already handled; otherwise hand off to the host's router.
|
|
80
|
+
if (
|
|
81
|
+
e.defaultPrevented ||
|
|
82
|
+
e.button !== 0 ||
|
|
83
|
+
e.metaKey ||
|
|
84
|
+
e.ctrlKey ||
|
|
85
|
+
e.shiftKey ||
|
|
86
|
+
e.altKey
|
|
87
|
+
) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
e.preventDefault();
|
|
91
|
+
navigate(linkTo);
|
|
92
|
+
}}
|
|
93
|
+
{...props}
|
|
94
|
+
>
|
|
95
|
+
{children}
|
|
96
|
+
</a>
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
export { Link };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '../story-types';
|
|
2
|
+
|
|
3
|
+
import { Navbar } from './navbar';
|
|
4
|
+
import { Link } from './link';
|
|
5
|
+
import { Button } from '../ui/button';
|
|
6
|
+
|
|
7
|
+
const meta = {
|
|
8
|
+
title: 'Composition/Navbar',
|
|
9
|
+
component: Navbar,
|
|
10
|
+
tags: ['autodocs'],
|
|
11
|
+
// Three real ReactNode props → three slots (§3). `width` is the only editable
|
|
12
|
+
// scalar; `className`/`style` stay unexposed (§6).
|
|
13
|
+
argTypes: {
|
|
14
|
+
width: { control: 'inline-radio', options: ['contained', 'full'] },
|
|
15
|
+
},
|
|
16
|
+
args: { width: 'contained' },
|
|
17
|
+
parameters: {
|
|
18
|
+
layout: 'fullscreen',
|
|
19
|
+
puck: { slots: ['brand', 'links', 'actions'] },
|
|
20
|
+
},
|
|
21
|
+
} satisfies Meta<typeof Navbar>;
|
|
22
|
+
|
|
23
|
+
export default meta;
|
|
24
|
+
type Story = StoryObj<typeof meta>;
|
|
25
|
+
|
|
26
|
+
// Nav links use the DS `Link` primitive, which is inert here (no
|
|
27
|
+
// PrototypeModeProvider ⇒ editor mode) — exactly how it renders on the
|
|
28
|
+
// composer canvas.
|
|
29
|
+
const Links = () => (
|
|
30
|
+
<>
|
|
31
|
+
{[
|
|
32
|
+
{ label: 'Product', to: '/product' },
|
|
33
|
+
{ label: 'Pricing', to: '/pricing' },
|
|
34
|
+
{ label: 'Docs', to: '/docs' },
|
|
35
|
+
].map((l) => (
|
|
36
|
+
<Link
|
|
37
|
+
key={l.label}
|
|
38
|
+
linkTo={l.to}
|
|
39
|
+
className='text-muted-foreground hover:bg-accent hover:text-foreground rounded-md px-3 py-2 text-sm font-medium no-underline hover:no-underline'
|
|
40
|
+
>
|
|
41
|
+
{l.label}
|
|
42
|
+
</Link>
|
|
43
|
+
))}
|
|
44
|
+
</>
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const NavbarDemo = (args: Partial<Parameters<typeof Navbar>[0]>) => (
|
|
48
|
+
<Navbar
|
|
49
|
+
{...args}
|
|
50
|
+
links={<Links />}
|
|
51
|
+
actions={
|
|
52
|
+
<>
|
|
53
|
+
<Button variant='ghost' size='sm'>
|
|
54
|
+
Log in
|
|
55
|
+
</Button>
|
|
56
|
+
<Button size='sm'>Sign up</Button>
|
|
57
|
+
</>
|
|
58
|
+
}
|
|
59
|
+
/>
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
export const Default: Story = {
|
|
63
|
+
render: (args) => <NavbarDemo {...args} />,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// Below @3xl links + actions collapse into a hamburger drawer.
|
|
67
|
+
export const Mobile: Story = {
|
|
68
|
+
globals: { viewport: { value: 'mobile' } },
|
|
69
|
+
render: (args) => <NavbarDemo {...args} />,
|
|
70
|
+
};
|