@fragments-sdk/ui 0.8.7 → 0.8.8

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.
@@ -0,0 +1,215 @@
1
+ @use '../../tokens/variables' as *;
2
+ @use '../../tokens/mixins' as *;
3
+
4
+ // Backdrop overlay
5
+ .backdrop {
6
+ position: fixed;
7
+ inset: 0;
8
+ background-color: var(--fui-backdrop, $fui-backdrop);
9
+ z-index: 50;
10
+
11
+ // Animation
12
+ opacity: 0;
13
+ transition: opacity var(--fui-transition-normal, $fui-transition-normal);
14
+
15
+ &[data-open] {
16
+ opacity: 1;
17
+ }
18
+
19
+ &[data-starting-style],
20
+ &[data-ending-style] {
21
+ opacity: 0;
22
+ }
23
+ }
24
+
25
+ // The popup panel
26
+ .popup {
27
+ @include surface-elevated;
28
+ @include text-base;
29
+
30
+ position: fixed;
31
+ z-index: 51;
32
+ display: flex;
33
+ flex-direction: column;
34
+ box-shadow: var(--fui-shadow-md, $fui-shadow-md);
35
+ overflow-y: auto;
36
+
37
+ // Animation
38
+ transition:
39
+ opacity var(--fui-transition-normal, $fui-transition-normal),
40
+ transform var(--fui-transition-normal, $fui-transition-normal);
41
+ opacity: 0;
42
+
43
+ &[data-open] {
44
+ opacity: 1;
45
+ transform: translate(0, 0);
46
+ }
47
+ }
48
+
49
+ // ============================================
50
+ // Side positioning
51
+ // ============================================
52
+
53
+ .side-right {
54
+ top: 0;
55
+ right: 0;
56
+ bottom: 0;
57
+ transform: translateX(100%);
58
+
59
+ &[data-starting-style],
60
+ &[data-ending-style] {
61
+ transform: translateX(100%);
62
+ }
63
+ }
64
+
65
+ .side-left {
66
+ top: 0;
67
+ left: 0;
68
+ bottom: 0;
69
+ transform: translateX(-100%);
70
+
71
+ &[data-starting-style],
72
+ &[data-ending-style] {
73
+ transform: translateX(-100%);
74
+ }
75
+ }
76
+
77
+ .side-top {
78
+ top: 0;
79
+ left: 0;
80
+ right: 0;
81
+ transform: translateY(-100%);
82
+
83
+ &[data-starting-style],
84
+ &[data-ending-style] {
85
+ transform: translateY(-100%);
86
+ }
87
+ }
88
+
89
+ .side-bottom {
90
+ bottom: 0;
91
+ left: 0;
92
+ right: 0;
93
+ transform: translateY(100%);
94
+
95
+ &[data-starting-style],
96
+ &[data-ending-style] {
97
+ transform: translateY(100%);
98
+ }
99
+ }
100
+
101
+ // ============================================
102
+ // Size variants — width for left/right, height for top/bottom
103
+ // ============================================
104
+
105
+ // Left/Right: size maps to width
106
+ .side-left,
107
+ .side-right {
108
+ &.size-sm { width: 20rem; }
109
+ &.size-md { width: 24rem; }
110
+ &.size-lg { width: 32rem; }
111
+ &.size-xl { width: 48rem; }
112
+ &.size-full { width: 100vw; }
113
+ }
114
+
115
+ // Top/Bottom: size maps to height
116
+ .side-top,
117
+ .side-bottom {
118
+ &.size-sm { height: 25vh; }
119
+ &.size-md { height: 40vh; }
120
+ &.size-lg { height: 60vh; }
121
+ &.size-xl { height: 80vh; }
122
+ &.size-full { height: 100vh; }
123
+ }
124
+
125
+ // ============================================
126
+ // Content sections (mirror Dialog)
127
+ // ============================================
128
+
129
+ // Header area
130
+ .header {
131
+ padding: var(--fui-padding-container-sm, $fui-padding-container-sm) var(--fui-padding-container-md, $fui-padding-container-md);
132
+ }
133
+
134
+ // Title
135
+ .title {
136
+ margin: 0;
137
+ font-size: var(--fui-font-size-lg, $fui-font-size-lg);
138
+ font-weight: var(--fui-font-weight-semibold, $fui-font-weight-semibold);
139
+ color: var(--fui-text-primary, $fui-text-primary);
140
+ line-height: var(--fui-line-height-tight, $fui-line-height-tight);
141
+ }
142
+
143
+ // Description
144
+ .description {
145
+ margin: var(--fui-space-1, $fui-space-1) 0 0;
146
+ font-size: var(--fui-font-size-sm, $fui-font-size-sm);
147
+ color: var(--fui-text-secondary, $fui-text-secondary);
148
+ line-height: var(--fui-line-height-normal, $fui-line-height-normal);
149
+ }
150
+
151
+ // Body content
152
+ .body {
153
+ flex: 1;
154
+ padding: 0 var(--fui-padding-container-md, $fui-padding-container-md) var(--fui-padding-container-sm, $fui-padding-container-sm);
155
+ overflow-y: auto;
156
+ }
157
+
158
+ // Footer for actions
159
+ .footer {
160
+ display: flex;
161
+ align-items: center;
162
+ justify-content: flex-end;
163
+ gap: var(--fui-space-1, $fui-space-1);
164
+ padding: var(--fui-padding-inline-sm, $fui-padding-inline-sm) var(--fui-padding-container-md, $fui-padding-container-md);
165
+ border-top: 1px solid var(--fui-border, $fui-border);
166
+ background-color: var(--fui-bg-secondary, $fui-bg-secondary);
167
+ }
168
+
169
+ // Close button (X in corner)
170
+ .close {
171
+ @include button-reset;
172
+ @include interactive-base;
173
+
174
+ position: absolute;
175
+ top: var(--fui-space-3, $fui-space-3);
176
+ right: var(--fui-space-3, $fui-space-3);
177
+ display: flex;
178
+ align-items: center;
179
+ justify-content: center;
180
+ width: 2rem;
181
+ height: 2rem;
182
+ border-radius: var(--fui-radius-md, $fui-radius-md);
183
+ color: var(--fui-text-secondary, $fui-text-secondary);
184
+ z-index: 1;
185
+
186
+ &:hover {
187
+ background-color: var(--fui-bg-hover, $fui-bg-hover);
188
+ color: var(--fui-text-primary, $fui-text-primary);
189
+ }
190
+
191
+ svg {
192
+ width: 1rem;
193
+ height: 1rem;
194
+ }
195
+ }
196
+
197
+ // ============================================
198
+ // Accessibility: Reduced Motion
199
+ // ============================================
200
+
201
+ @media (prefers-reduced-motion: reduce) {
202
+ .backdrop {
203
+ transition: none;
204
+ }
205
+
206
+ .popup {
207
+ transition: none;
208
+ transform: none;
209
+
210
+ &[data-starting-style],
211
+ &[data-ending-style] {
212
+ transform: none;
213
+ }
214
+ }
215
+ }
@@ -0,0 +1,227 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render, screen, userEvent, waitFor, expectNoA11yViolations } from '../../test/utils';
3
+ import { Drawer } from './index';
4
+
5
+ function renderDrawer(
6
+ props: Partial<React.ComponentProps<typeof Drawer>> = {},
7
+ contentProps: Partial<React.ComponentProps<typeof Drawer.Content>> = {},
8
+ ) {
9
+ return render(
10
+ <Drawer {...props}>
11
+ <Drawer.Trigger>Open Drawer</Drawer.Trigger>
12
+ <Drawer.Content {...contentProps}>
13
+ <Drawer.Header>
14
+ <Drawer.Title>Drawer Title</Drawer.Title>
15
+ <Drawer.Close />
16
+ </Drawer.Header>
17
+ <Drawer.Body>
18
+ <Drawer.Description>Drawer description text</Drawer.Description>
19
+ <p>Body content</p>
20
+ </Drawer.Body>
21
+ <Drawer.Footer>
22
+ <Drawer.Close asChild>
23
+ <button>Cancel</button>
24
+ </Drawer.Close>
25
+ </Drawer.Footer>
26
+ </Drawer.Content>
27
+ </Drawer>
28
+ );
29
+ }
30
+
31
+ describe('Drawer', () => {
32
+ it('opens when trigger is clicked', async () => {
33
+ const user = userEvent.setup();
34
+ renderDrawer();
35
+
36
+ expect(screen.queryByText('Drawer Title')).not.toBeInTheDocument();
37
+
38
+ await user.click(screen.getByRole('button', { name: /open drawer/i }));
39
+ await waitFor(() => {
40
+ expect(screen.getByText('Drawer Title')).toBeInTheDocument();
41
+ });
42
+ });
43
+
44
+ it('closes when close button is clicked', async () => {
45
+ const user = userEvent.setup();
46
+ renderDrawer({ defaultOpen: true });
47
+
48
+ await waitFor(() => {
49
+ expect(screen.getByText('Drawer Title')).toBeInTheDocument();
50
+ });
51
+
52
+ const closeButton = screen.getByRole('button', { name: /close drawer/i });
53
+ await user.click(closeButton);
54
+
55
+ await waitFor(() => {
56
+ expect(screen.queryByText('Drawer Title')).not.toBeInTheDocument();
57
+ });
58
+ });
59
+
60
+ it('renders title and description', async () => {
61
+ renderDrawer({ defaultOpen: true });
62
+
63
+ await waitFor(() => {
64
+ expect(screen.getByText('Drawer Title')).toBeInTheDocument();
65
+ expect(screen.getByText('Drawer description text')).toBeInTheDocument();
66
+ });
67
+ });
68
+
69
+ it('renders compound sub-components (Header, Body, Footer)', async () => {
70
+ renderDrawer({ defaultOpen: true });
71
+
72
+ await waitFor(() => {
73
+ expect(screen.getByText('Body content')).toBeInTheDocument();
74
+ expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
75
+ });
76
+ });
77
+
78
+ it('supports side prop', async () => {
79
+ renderDrawer({ defaultOpen: true }, { side: 'left' });
80
+
81
+ await waitFor(() => {
82
+ expect(screen.getByText('Drawer Title')).toBeInTheDocument();
83
+ });
84
+ });
85
+
86
+ it('supports size prop', async () => {
87
+ renderDrawer({ defaultOpen: true }, { size: 'lg' });
88
+
89
+ await waitFor(() => {
90
+ expect(screen.getByText('Drawer Title')).toBeInTheDocument();
91
+ });
92
+ });
93
+
94
+ it('has no accessibility violations when open', async () => {
95
+ const { container } = renderDrawer({ defaultOpen: true });
96
+
97
+ await waitFor(() => {
98
+ expect(screen.getByText('Drawer Title')).toBeInTheDocument();
99
+ });
100
+
101
+ await expectNoA11yViolations(container, {
102
+ // Base UI focus guard spans have role="button" without labels.
103
+ disabledRules: ['aria-command-name'],
104
+ });
105
+ });
106
+
107
+ describe('keyboard & focus', () => {
108
+ it('Escape closes drawer', async () => {
109
+ const user = userEvent.setup();
110
+ renderDrawer({ defaultOpen: true });
111
+
112
+ await waitFor(() => {
113
+ expect(screen.getByText('Drawer Title')).toBeInTheDocument();
114
+ });
115
+
116
+ await user.keyboard('{Escape}');
117
+
118
+ await waitFor(() => {
119
+ expect(screen.queryByText('Drawer Title')).not.toBeInTheDocument();
120
+ });
121
+ });
122
+
123
+ it('focus moves into drawer on open', async () => {
124
+ const user = userEvent.setup();
125
+ renderDrawer();
126
+
127
+ await user.click(screen.getByRole('button', { name: /open drawer/i }));
128
+
129
+ await waitFor(() => {
130
+ expect(screen.getByText('Drawer Title')).toBeInTheDocument();
131
+ });
132
+
133
+ await waitFor(() => {
134
+ const dialog = screen.getByRole('dialog');
135
+ expect(dialog.contains(document.activeElement)).toBe(true);
136
+ });
137
+ });
138
+
139
+ it('focus returns to trigger on close', async () => {
140
+ const user = userEvent.setup();
141
+ renderDrawer();
142
+
143
+ const trigger = screen.getByRole('button', { name: /open drawer/i });
144
+ await user.click(trigger);
145
+
146
+ await waitFor(() => {
147
+ expect(screen.getByText('Drawer Title')).toBeInTheDocument();
148
+ });
149
+
150
+ await user.keyboard('{Escape}');
151
+
152
+ await waitFor(() => {
153
+ expect(screen.queryByText('Drawer Title')).not.toBeInTheDocument();
154
+ });
155
+
156
+ await waitFor(() => {
157
+ expect(trigger).toHaveFocus();
158
+ });
159
+ });
160
+
161
+ it('Tab cycles within drawer (focus trap)', async () => {
162
+ const user = userEvent.setup();
163
+ renderDrawer({ defaultOpen: true });
164
+
165
+ await waitFor(() => {
166
+ expect(screen.getByText('Drawer Title')).toBeInTheDocument();
167
+ });
168
+
169
+ const dialog = screen.getByRole('dialog');
170
+
171
+ // Tab through focusable elements — focus should stay inside drawer
172
+ await user.tab();
173
+ await user.tab();
174
+ await user.tab();
175
+
176
+ await waitFor(() => {
177
+ expect(dialog.contains(document.activeElement)).toBe(true);
178
+ });
179
+ });
180
+
181
+ it('Shift+Tab cycles backward', async () => {
182
+ const user = userEvent.setup();
183
+ renderDrawer({ defaultOpen: true });
184
+
185
+ await waitFor(() => {
186
+ expect(screen.getByText('Drawer Title')).toBeInTheDocument();
187
+ });
188
+
189
+ const dialog = screen.getByRole('dialog');
190
+
191
+ await user.keyboard('{Shift>}{Tab}{/Shift}');
192
+ await user.keyboard('{Shift>}{Tab}{/Shift}');
193
+ await user.keyboard('{Shift>}{Tab}{/Shift}');
194
+
195
+ await waitFor(() => {
196
+ expect(dialog.contains(document.activeElement)).toBe(true);
197
+ });
198
+ });
199
+
200
+ it('role="dialog" is present when open', async () => {
201
+ renderDrawer({ defaultOpen: true });
202
+
203
+ await waitFor(() => {
204
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
205
+ });
206
+ });
207
+
208
+ it('backdrop click closes drawer', async () => {
209
+ const user = userEvent.setup();
210
+ renderDrawer({ defaultOpen: true });
211
+
212
+ await waitFor(() => {
213
+ expect(screen.getByText('Drawer Title')).toBeInTheDocument();
214
+ });
215
+
216
+ // Click the backdrop (outside the drawer content)
217
+ const backdrop = document.querySelector('[data-open]');
218
+ if (backdrop) {
219
+ await user.click(backdrop as HTMLElement);
220
+ }
221
+
222
+ await waitFor(() => {
223
+ expect(screen.queryByText('Drawer Title')).not.toBeInTheDocument();
224
+ });
225
+ });
226
+ });
227
+ });
@@ -0,0 +1,239 @@
1
+ import * as React from 'react';
2
+ import { Dialog as BaseDialog } from '@base-ui/react/dialog';
3
+ import styles from './Drawer.module.scss';
4
+ // Import globals to ensure CSS variables are defined
5
+ import '../../styles/globals.scss';
6
+
7
+ // ============================================
8
+ // Types
9
+ // ============================================
10
+
11
+ export interface DrawerProps {
12
+ children: React.ReactNode;
13
+ open?: boolean;
14
+ defaultOpen?: boolean;
15
+ onOpenChange?: (open: boolean) => void;
16
+ modal?: boolean;
17
+ }
18
+
19
+ export interface DrawerContentProps extends React.HTMLAttributes<HTMLDivElement> {
20
+ children: React.ReactNode;
21
+ side?: 'left' | 'right' | 'top' | 'bottom';
22
+ size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
23
+ }
24
+
25
+ export interface DrawerTriggerProps {
26
+ children: React.ReactNode;
27
+ asChild?: boolean;
28
+ className?: string;
29
+ }
30
+
31
+ export interface DrawerHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
32
+ children: React.ReactNode;
33
+ }
34
+
35
+ export interface DrawerTitleProps {
36
+ children: React.ReactNode;
37
+ className?: string;
38
+ }
39
+
40
+ export interface DrawerDescriptionProps {
41
+ children: React.ReactNode;
42
+ className?: string;
43
+ }
44
+
45
+ export interface DrawerBodyProps extends React.HTMLAttributes<HTMLDivElement> {
46
+ children: React.ReactNode;
47
+ }
48
+
49
+ export interface DrawerFooterProps extends React.HTMLAttributes<HTMLDivElement> {
50
+ children: React.ReactNode;
51
+ }
52
+
53
+ export interface DrawerCloseProps {
54
+ children?: React.ReactNode;
55
+ asChild?: boolean;
56
+ className?: string;
57
+ }
58
+
59
+ // ============================================
60
+ // Close Icon
61
+ // ============================================
62
+
63
+ function CloseIcon() {
64
+ return (
65
+ <svg
66
+ xmlns="http://www.w3.org/2000/svg"
67
+ width="16"
68
+ height="16"
69
+ viewBox="0 0 24 24"
70
+ fill="none"
71
+ stroke="currentColor"
72
+ strokeWidth="2"
73
+ strokeLinecap="round"
74
+ strokeLinejoin="round"
75
+ aria-hidden="true"
76
+ >
77
+ <line x1="18" y1="6" x2="6" y2="18" />
78
+ <line x1="6" y1="6" x2="18" y2="18" />
79
+ </svg>
80
+ );
81
+ }
82
+
83
+ // ============================================
84
+ // Components
85
+ // ============================================
86
+
87
+ function DrawerRoot({
88
+ children,
89
+ open,
90
+ defaultOpen,
91
+ onOpenChange,
92
+ modal = true,
93
+ }: DrawerProps) {
94
+ return (
95
+ <BaseDialog.Root
96
+ open={open}
97
+ defaultOpen={defaultOpen}
98
+ onOpenChange={onOpenChange}
99
+ modal={modal}
100
+ >
101
+ {children}
102
+ </BaseDialog.Root>
103
+ );
104
+ }
105
+
106
+ function DrawerTrigger({
107
+ children,
108
+ asChild,
109
+ className,
110
+ }: DrawerTriggerProps) {
111
+ if (asChild) {
112
+ return (
113
+ <BaseDialog.Trigger className={className} render={children as React.ReactElement}>
114
+ {null}
115
+ </BaseDialog.Trigger>
116
+ );
117
+ }
118
+
119
+ return (
120
+ <BaseDialog.Trigger className={className}>
121
+ {children}
122
+ </BaseDialog.Trigger>
123
+ );
124
+ }
125
+
126
+ function DrawerContent({
127
+ children,
128
+ side = 'right',
129
+ size = 'md',
130
+ className,
131
+ ...htmlProps
132
+ }: DrawerContentProps) {
133
+ const popupClasses = [
134
+ styles.popup,
135
+ styles[`side-${side}`],
136
+ styles[`size-${size}`],
137
+ className,
138
+ ]
139
+ .filter(Boolean)
140
+ .join(' ');
141
+
142
+ return (
143
+ <BaseDialog.Portal>
144
+ <BaseDialog.Backdrop className={styles.backdrop} />
145
+ <BaseDialog.Popup initialFocus {...htmlProps} data-side={side} className={popupClasses}>
146
+ {children}
147
+ </BaseDialog.Popup>
148
+ </BaseDialog.Portal>
149
+ );
150
+ }
151
+
152
+ function DrawerHeader({ children, className, ...htmlProps }: DrawerHeaderProps) {
153
+ const classes = [styles.header, className].filter(Boolean).join(' ');
154
+ return <div {...htmlProps} className={classes}>{children}</div>;
155
+ }
156
+
157
+ function DrawerTitle({ children, className }: DrawerTitleProps) {
158
+ const classes = [styles.title, className].filter(Boolean).join(' ');
159
+ return <BaseDialog.Title className={classes}>{children}</BaseDialog.Title>;
160
+ }
161
+
162
+ function DrawerDescription({ children, className }: DrawerDescriptionProps) {
163
+ const classes = [styles.description, className].filter(Boolean).join(' ');
164
+ return (
165
+ <BaseDialog.Description className={classes}>
166
+ {children}
167
+ </BaseDialog.Description>
168
+ );
169
+ }
170
+
171
+ function DrawerBody({ children, className, ...htmlProps }: DrawerBodyProps) {
172
+ const classes = [styles.body, className].filter(Boolean).join(' ');
173
+ return <div {...htmlProps} className={classes}>{children}</div>;
174
+ }
175
+
176
+ function DrawerFooter({ children, className, ...htmlProps }: DrawerFooterProps) {
177
+ const classes = [styles.footer, className].filter(Boolean).join(' ');
178
+ return <div {...htmlProps} className={classes}>{children}</div>;
179
+ }
180
+
181
+ function DrawerClose({ children, asChild, className }: DrawerCloseProps) {
182
+ if (!children) {
183
+ return (
184
+ <BaseDialog.Close
185
+ data-drawer-close
186
+ aria-label="Close drawer"
187
+ className={[styles.close, className].filter(Boolean).join(' ')}
188
+ >
189
+ <CloseIcon />
190
+ </BaseDialog.Close>
191
+ );
192
+ }
193
+
194
+ if (asChild) {
195
+ return (
196
+ <BaseDialog.Close
197
+ data-drawer-close
198
+ className={className}
199
+ render={children as React.ReactElement}
200
+ >
201
+ {null}
202
+ </BaseDialog.Close>
203
+ );
204
+ }
205
+
206
+ return (
207
+ <BaseDialog.Close data-drawer-close className={className}>
208
+ {children}
209
+ </BaseDialog.Close>
210
+ );
211
+ }
212
+
213
+ // ============================================
214
+ // Export compound component
215
+ // ============================================
216
+
217
+ export const Drawer = Object.assign(DrawerRoot, {
218
+ Trigger: DrawerTrigger,
219
+ Content: DrawerContent,
220
+ Header: DrawerHeader,
221
+ Title: DrawerTitle,
222
+ Description: DrawerDescription,
223
+ Body: DrawerBody,
224
+ Footer: DrawerFooter,
225
+ Close: DrawerClose,
226
+ });
227
+
228
+ // Re-export individual components for tree-shaking
229
+ export {
230
+ DrawerRoot,
231
+ DrawerTrigger,
232
+ DrawerContent,
233
+ DrawerHeader,
234
+ DrawerTitle,
235
+ DrawerDescription,
236
+ DrawerBody,
237
+ DrawerFooter,
238
+ DrawerClose,
239
+ };