@varialkit/drawer 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/docs.md ADDED
@@ -0,0 +1,78 @@
1
+ # Drawer
2
+
3
+ Drawer slides in from the side to show supporting tasks or context without leaving the page.
4
+ Use it for secondary flows, filters, and detailed panels.
5
+
6
+ ## How to Use
7
+
8
+ ```tsx
9
+ import { Drawer } from "@solara/drawer";
10
+
11
+ export function Example() {
12
+ return (
13
+ <Drawer isOpen onClose={() => {}}>
14
+ <Drawer.Header onClose={() => {}}>
15
+ <Drawer.Title>Filters</Drawer.Title>
16
+ </Drawer.Header>
17
+ <Drawer.Content>Drawer content goes here.</Drawer.Content>
18
+ <Drawer.Footer
19
+ primaryButton={{ label: "Apply", onClick: () => {} }}
20
+ secondaryButton={{ label: "Reset", onClick: () => {} }}
21
+ />
22
+ </Drawer>
23
+ );
24
+ }
25
+ ```
26
+
27
+ ## Best Practices
28
+
29
+ - Keep drawers task-focused and easy to dismiss.
30
+ - Use drawers for secondary workflows instead of primary actions.
31
+ - Avoid stacking multiple drawers.
32
+ - Use `sliding` when people need to resize the drawer from the exposed edge without shifting the main drawer surface.
33
+
34
+ ## Props
35
+
36
+ ### Drawer
37
+
38
+ | Prop | Type | Default | Description |
39
+ | --- | --- | --- | --- |
40
+ | `isOpen` | `boolean` | _Required_ | Controls visibility. |
41
+ | `onClose` | `() => void` | _Required_ | Close handler. |
42
+ | `position` | `"left" \| "right"` | `"right"` | Drawer side. |
43
+ | `overlay` | `boolean` | `true` | Show an overlay behind the drawer. |
44
+ | `closeOnOverlayClick` | `boolean` | `true` | Close on overlay click. |
45
+ | `closeOnEscape` | `boolean` | `true` | Close on Escape. |
46
+ | `size` | `"sm" \| "md" \| "lg" \| "xl" \| string \| number` | `"md"` | Drawer width. Can be a preset size or a custom CSS value (e.g., "500px"). |
47
+ | `sliding` | `boolean` | `false` | Enables a resize handle that sits just outside the drawer edge. Right drawers expose the handle on the left; left drawers expose it on the right. |
48
+ | `minWidth` | `number \| \`${number}px\`` | `280` | Minimum width when `sliding` is enabled. |
49
+ | `maxWidth` | `number \| \`${number}px\`` | `viewport width - 48px` | Maximum width when `sliding` is enabled. |
50
+ | `overlayClassName` | `string` | | Extra overlay class. |
51
+ | `allowContentOverflow` | `boolean` | `false` | Allow content to overflow. |
52
+ | `floating` | `boolean` | `false` | Renders the drawer with a margin around it. |
53
+ | `className` | `string` | | Custom class name. |
54
+
55
+ ### DrawerHeader
56
+
57
+ | Prop | Type | Default | Description |
58
+ | --- | --- | --- | --- |
59
+ | `showCloseButton` | `boolean` | `true` | Show close icon. |
60
+ | `closeButtonAriaLabel` | `string` | `"Close drawer"` | Close button aria label. |
61
+ | `rightContent` | `ReactNode` | | Right-side header content. |
62
+ | `subheader` | `ReactNode` | | Secondary header text. |
63
+ | `bordered` | `boolean` | `true` | Show header divider. |
64
+ | `onClose` | `() => void` | _Required_ | Close handler. |
65
+
66
+ ### DrawerContent
67
+
68
+ | Prop | Type | Default | Description |
69
+ | --- | --- | --- | --- |
70
+ | `noPadding` | `boolean` | `false` | Remove default padding. |
71
+
72
+ ### DrawerFooter
73
+
74
+ | Prop | Type | Default | Description |
75
+ | --- | --- | --- | --- |
76
+ | `primaryButton` | `FooterButtonProps` | | Primary action. |
77
+ | `secondaryButton` | `FooterButtonProps` | | Secondary action. |
78
+ | `dangerButton` | `FooterButtonProps` | | Destructive action. |
@@ -0,0 +1 @@
1
+ export { stories } from "../examples";
package/examples.tsx ADDED
@@ -0,0 +1,407 @@
1
+ import React from "react";
2
+ import { Drawer } from "./src/Drawer";
3
+ import type { DrawerProps } from "./src/Drawer.types";
4
+ import { Button } from "@solara/button";
5
+
6
+ type DrawerStoryProps = DrawerProps & {
7
+ noPadding?: boolean;
8
+ showSubheader?: boolean;
9
+ showRightContent?: boolean;
10
+ floating?: boolean;
11
+ };
12
+
13
+ const DrawerPlayground = (props: DrawerStoryProps) => {
14
+ const isControlled = typeof props.isOpen === "boolean";
15
+ const [open, setOpen] = React.useState(props.isOpen ?? false);
16
+
17
+ React.useEffect(() => {
18
+ if (isControlled) {
19
+ setOpen(props.isOpen as boolean);
20
+ }
21
+ }, [isControlled, props.isOpen]);
22
+
23
+ return (
24
+ <div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
25
+ <Button label="Open drawer" onClick={() => setOpen(true)} />
26
+ <Drawer
27
+ isOpen={open}
28
+ onClose={() => setOpen(false)}
29
+ position={props.position}
30
+ overlay={props.overlay}
31
+ closeOnOverlayClick={props.closeOnOverlayClick}
32
+ closeOnEscape={props.closeOnEscape}
33
+ size={props.size}
34
+ sliding={props.sliding}
35
+ minWidth={props.minWidth}
36
+ maxWidth={props.maxWidth}
37
+ allowContentOverflow={props.allowContentOverflow}
38
+ floating={props.floating}
39
+ >
40
+ <Drawer.Header
41
+ onClose={() => setOpen(false)}
42
+ subheader={
43
+ props.showSubheader ? (
44
+ <span style={{ fontSize: "0.75rem", color: "var(--color-text-secondary)" }}>
45
+ Updated just now
46
+ </span>
47
+ ) : undefined
48
+ }
49
+ rightContent={
50
+ props.showRightContent ? (
51
+ <span style={{ fontSize: "0.75rem", color: "var(--color-text-secondary)" }}>
52
+ 3 filters
53
+ </span>
54
+ ) : null
55
+ }
56
+ >
57
+ <Drawer.Title>Filters</Drawer.Title>
58
+ </Drawer.Header>
59
+ <Drawer.Content noPadding={props.noPadding}>
60
+ <div style={{ display: "grid", gap: "0.75rem" }}>
61
+ <p style={{ margin: 0 }}>
62
+ Add filters, secondary tasks, or contextual notes without leaving the current page.
63
+ </p>
64
+ <ul style={{ margin: 0, paddingLeft: "1.25rem" }}>
65
+ <li>Status: Active</li>
66
+ <li>Owner: Design</li>
67
+ <li>Last updated: 2 days ago</li>
68
+ </ul>
69
+ </div>
70
+ </Drawer.Content>
71
+ <Drawer.Footer
72
+ primaryButton={{ label: "Apply", onClick: () => setOpen(false) }}
73
+ secondaryButton={{ label: "Reset", onClick: () => setOpen(false) }}
74
+ />
75
+ </Drawer>
76
+ </div>
77
+ );
78
+ };
79
+
80
+ const DrawerSlidingStory = () => {
81
+ const [open, setOpen] = React.useState(false);
82
+ return (
83
+ <div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
84
+ <Button label="Open sliding drawer" onClick={() => setOpen(true)} />
85
+ <Drawer
86
+ isOpen={open}
87
+ onClose={() => setOpen(false)}
88
+ position="right"
89
+ size="lg"
90
+ sliding
91
+ minWidth={320}
92
+ maxWidth={720}
93
+ >
94
+ <Drawer.Header onClose={() => setOpen(false)}>
95
+ <Drawer.Title>Resizable drawer</Drawer.Title>
96
+ </Drawer.Header>
97
+ <Drawer.Content>
98
+ <p style={{ marginTop: 0 }}>
99
+ Drag the handle that sits outside the drawer edge to resize it without moving the main content area.
100
+ </p>
101
+ <p style={{ marginBottom: 0 }}>
102
+ On right drawers the handle appears on the left edge. On left drawers it appears on the right edge.
103
+ </p>
104
+ </Drawer.Content>
105
+ </Drawer>
106
+ </div>
107
+ );
108
+ };
109
+
110
+ const DrawerHeaderStory = () => {
111
+ const [open, setOpen] = React.useState(false);
112
+ return (
113
+ <div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
114
+ <Button label="Open drawer" onClick={() => setOpen(true)} />
115
+ <Drawer isOpen={open} onClose={() => setOpen(false)} overlay closeOnOverlayClick closeOnEscape>
116
+ <Drawer.Header
117
+ onClose={() => setOpen(false)}
118
+ subheader={<span style={{ fontSize: "0.75rem" }}>Header-only</span>}
119
+ rightContent={<span style={{ fontSize: "0.75rem" }}>3 filters</span>}
120
+ >
121
+ <Drawer.Title>Header example</Drawer.Title>
122
+ </Drawer.Header>
123
+ </Drawer>
124
+ </div>
125
+ );
126
+ };
127
+
128
+ const DrawerFooterStory = () => {
129
+ const [open, setOpen] = React.useState(false);
130
+ return (
131
+ <div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
132
+ <Button label="Open drawer" onClick={() => setOpen(true)} />
133
+ <Drawer isOpen={open} onClose={() => setOpen(false)} overlay closeOnOverlayClick closeOnEscape>
134
+ <Drawer.Footer
135
+ primaryButton={{ label: "Apply", onClick: () => setOpen(false) }}
136
+ secondaryButton={{ label: "Cancel", onClick: () => setOpen(false) }}
137
+ />
138
+ </Drawer>
139
+ </div>
140
+ );
141
+ };
142
+
143
+ const DrawerNoOverlayStory = () => {
144
+ const [open, setOpen] = React.useState(false);
145
+ return (
146
+ <div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
147
+ <Button label="Open drawer (no overlay)" onClick={() => setOpen(true)} />
148
+ <Drawer isOpen={open} onClose={() => setOpen(false)} overlay={false}>
149
+ <Drawer.Header onClose={() => setOpen(false)}>
150
+ <Drawer.Title>Drawer without overlay</Drawer.Title>
151
+ </Drawer.Header>
152
+ <Drawer.Content>
153
+ <p>This drawer does not have an overlay.</p>
154
+ </Drawer.Content>
155
+ </Drawer>
156
+ </div>
157
+ );
158
+ };
159
+
160
+ const DrawerFloatingStory = () => {
161
+ const [open, setOpen] = React.useState(false);
162
+ return (
163
+ <div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
164
+ <Button label="Open floating drawer" onClick={() => setOpen(true)} />
165
+ <Drawer isOpen={open} onClose={() => setOpen(false)} floating>
166
+ <Drawer.Header onClose={() => setOpen(false)}>
167
+ <Drawer.Title>Floating Drawer</Drawer.Title>
168
+ </Drawer.Header>
169
+ <Drawer.Content>
170
+ <p>This drawer has a margin around it.</p>
171
+ </Drawer.Content>
172
+ </Drawer>
173
+ </div>
174
+ );
175
+ };
176
+
177
+ const DrawerCustomWidthStory = () => {
178
+ const [open, setOpen] = React.useState(false);
179
+ return (
180
+ <div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
181
+ <Button label="Open drawer (custom width)" onClick={() => setOpen(true)} />
182
+ <Drawer isOpen={open} onClose={() => setOpen(false)} size="500px">
183
+ <Drawer.Header onClose={() => setOpen(false)}>
184
+ <Drawer.Title>Drawer with custom width</Drawer.Title>
185
+ </Drawer.Header>
186
+ <Drawer.Content>
187
+ <p>This drawer has a custom width of 500px.</p>
188
+ </Drawer.Content>
189
+ </Drawer>
190
+ </div>
191
+ );
192
+ };
193
+
194
+ export const stories = {
195
+ playground: {
196
+ title: "Playground",
197
+ description: "Adjust the Drawer props to explore behavior.",
198
+ render: (props: DrawerStoryProps) => <DrawerPlayground {...props} />,
199
+ controls: [
200
+ { name: "size", type: "select", options: ["sm", "md", "lg", "xl"] },
201
+ { name: "position", type: "select", options: ["left", "right"] },
202
+ { name: "floating", type: "boolean" },
203
+ { name: "sliding", type: "boolean" },
204
+ { name: "minWidth", type: "text" },
205
+ { name: "maxWidth", type: "text" },
206
+ { name: "overlay", type: "boolean" },
207
+ { name: "closeOnOverlayClick", type: "boolean" },
208
+ { name: "closeOnEscape", type: "boolean" },
209
+ { name: "allowContentOverflow", type: "boolean" },
210
+ { name: "noPadding", type: "boolean" },
211
+ { name: "showSubheader", type: "boolean" },
212
+ { name: "showRightContent", type: "boolean" },
213
+ { name: "isOpen", type: "boolean" },
214
+ ],
215
+ initialProps: {
216
+ size: "md",
217
+ position: "right",
218
+ floating: false,
219
+ sliding: false,
220
+ minWidth: 280,
221
+ maxWidth: 960,
222
+ overlay: true,
223
+ closeOnOverlayClick: true,
224
+ closeOnEscape: true,
225
+ allowContentOverflow: false,
226
+ noPadding: false,
227
+ showSubheader: true,
228
+ showRightContent: true,
229
+ isOpen: false,
230
+ },
231
+ },
232
+ sliding: {
233
+ title: "Sliding",
234
+ description: "Resizable drawer with an external drag handle anchored to the exposed edge.",
235
+ showProps: false,
236
+ render: () => <DrawerSlidingStory />,
237
+ code: `import React from "react";
238
+ import { Button } from "@solara/button";
239
+ import { Drawer } from "@solara/drawer";
240
+
241
+ export function Example() {
242
+ const [open, setOpen] = React.useState(false);
243
+
244
+ return (
245
+ <div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
246
+ <Button label="Open sliding drawer" onClick={() => setOpen(true)} />
247
+ <Drawer
248
+ isOpen={open}
249
+ onClose={() => setOpen(false)}
250
+ position="right"
251
+ size="lg"
252
+ sliding
253
+ minWidth={320}
254
+ maxWidth={720}
255
+ >
256
+ <Drawer.Header onClose={() => setOpen(false)}>
257
+ <Drawer.Title>Resizable drawer</Drawer.Title>
258
+ </Drawer.Header>
259
+ <Drawer.Content>
260
+ <p>Drag the outside handle to resize the drawer.</p>
261
+ </Drawer.Content>
262
+ </Drawer>
263
+ </div>
264
+ );
265
+ }
266
+ `,
267
+ },
268
+ header: {
269
+ title: "Header",
270
+ description: "Header-only configuration with subheader and right content.",
271
+ showProps: false,
272
+ render: () => <DrawerHeaderStory />,
273
+ code: `import React from "react";
274
+ import { Button } from "@solara/button";
275
+ import { Drawer } from "@solara/drawer";
276
+
277
+ export function Example() {
278
+ const [open, setOpen] = React.useState(false);
279
+
280
+ return (
281
+ <div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
282
+ <Button label="Open drawer" onClick={() => setOpen(true)} />
283
+ <Drawer isOpen={open} onClose={() => setOpen(false)} overlay closeOnOverlayClick closeOnEscape>
284
+ <Drawer.Header
285
+ onClose={() => setOpen(false)}
286
+ subheader={<span style={{ fontSize: "0.75rem" }}>Header-only</span>}
287
+ rightContent={<span style={{ fontSize: "0.75rem" }}>3 filters</span>}
288
+ >
289
+ <Drawer.Title>Header example</Drawer.Title>
290
+ </Drawer.Header>
291
+ </Drawer>
292
+ </div>
293
+ );
294
+ }
295
+ `,
296
+ },
297
+ footer: {
298
+ title: "Footer",
299
+ description: "Footer-only configuration with primary/secondary actions.",
300
+ showProps: false,
301
+ render: () => <DrawerFooterStory />,
302
+ code: `import React from "react";
303
+ import { Button } from "@solara/button";
304
+ import { Drawer } from "@solara/drawer";
305
+
306
+ export function Example() {
307
+ const [open, setOpen] = React.useState(false);
308
+
309
+ return (
310
+ <div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
311
+ <Button label="Open drawer" onClick={() => setOpen(true)} />
312
+ <Drawer isOpen={open} onClose={() => setOpen(false)} overlay closeOnOverlayClick closeOnEscape>
313
+ <Drawer.Footer
314
+ primaryButton={{ label: "Apply", onClick: () => setOpen(false) }}
315
+ secondaryButton={{ label: "Cancel", onClick: () => setOpen(false) }}
316
+ />
317
+ </Drawer>
318
+ </div>
319
+ );
320
+ }
321
+ `,
322
+ },
323
+ noOverlay: {
324
+ title: "No Overlay",
325
+ description: "Drawer without a background overlay.",
326
+ showProps: false,
327
+ render: () => <DrawerNoOverlayStory />,
328
+ code: `import React from "react";
329
+ import { Button } from "@solara/button";
330
+ import { Drawer } from "@solara/drawer";
331
+
332
+ export function Example() {
333
+ const [open, setOpen] = React.useState(false);
334
+
335
+ return (
336
+ <div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
337
+ <Button label="Open drawer (no overlay)" onClick={() => setOpen(true)} />
338
+ <Drawer isOpen={open} onClose={() => setOpen(false)} overlay={false}>
339
+ <Drawer.Header onClose={() => setOpen(false)}>
340
+ <Drawer.Title>Drawer without overlay</Drawer.Title>
341
+ </Drawer.Header>
342
+ <Drawer.Content>
343
+ <p>This drawer does not have an overlay.</p>
344
+ </Drawer.Content>
345
+ </Drawer>
346
+ </div>
347
+ );
348
+ }
349
+ `,
350
+ },
351
+ floating: {
352
+ title: "Floating",
353
+ description: "Drawer with a margin around it.",
354
+ showProps: false,
355
+ render: () => <DrawerFloatingStory />,
356
+ code: `import React from "react";
357
+ import { Button } from "@solara/button";
358
+ import { Drawer } from "@solara/drawer";
359
+
360
+ export function Example() {
361
+ const [open, setOpen] = React.useState(false);
362
+
363
+ return (
364
+ <div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
365
+ <Button label="Open floating drawer" onClick={() => setOpen(true)} />
366
+ <Drawer isOpen={open} onClose={() => setOpen(false)} floating>
367
+ <Drawer.Header onClose={() => setOpen(false)}>
368
+ <Drawer.Title>Floating Drawer</Drawer.Title>
369
+ </Drawer.Header>
370
+ <Drawer.Content>
371
+ <p>This drawer has a margin around it.</p>
372
+ </Drawer.Content>
373
+ </Drawer>
374
+ </div>
375
+ );
376
+ }
377
+ `,
378
+ },
379
+ customWidth: {
380
+ title: "Custom Width",
381
+ description: "Drawer with a custom width.",
382
+ showProps: false,
383
+ render: () => <DrawerCustomWidthStory />,
384
+ code: `import React from "react";
385
+ import { Button } from "@solara/button";
386
+ import { Drawer } from "@solara/drawer";
387
+
388
+ export function Example() {
389
+ const [open, setOpen] = React.useState(false);
390
+
391
+ return (
392
+ <div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
393
+ <Button label="Open drawer (custom width)" onClick={() => setOpen(true)} />
394
+ <Drawer isOpen={open} onClose={() => setOpen(false)} size="500px">
395
+ <Drawer.Header onClose={() => setOpen(false)}>
396
+ <Drawer.Title>Drawer with custom width</Drawer.Title>
397
+ </Drawer.Header>
398
+ <Drawer.Content>
399
+ <p>This drawer has a custom width of 500px.</p>
400
+ </Drawer.Content>
401
+ </Drawer>
402
+ </div>
403
+ );
404
+ }
405
+ `,
406
+ },
407
+ };
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@varialkit/drawer",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./examples": "./examples/index.tsx"
10
+ },
11
+ "dependencies": {
12
+ "@varialkit/button": "0.1.0"
13
+ },
14
+ "files": [
15
+ "src",
16
+ "docs.md",
17
+ "examples",
18
+ "examples.tsx"
19
+ ],
20
+ "peerDependencies": {
21
+ "react": "^19.0.0",
22
+ "react-dom": "^19.0.0"
23
+ },
24
+ "devDependencies": {
25
+ "@types/react": "19.0.10",
26
+ "react": "19.0.0",
27
+ "react-dom": "19.0.0"
28
+ }
29
+ }
@@ -0,0 +1,334 @@
1
+ @keyframes solara-drawer-fade-in {
2
+ from {
3
+ opacity: 0;
4
+ }
5
+
6
+ to {
7
+ opacity: 1;
8
+ }
9
+ }
10
+
11
+ @keyframes solara-drawer-slide-in-right {
12
+ from {
13
+ transform: translateX(32px);
14
+ }
15
+
16
+ to {
17
+ transform: translateX(0);
18
+ }
19
+ }
20
+
21
+ @keyframes solara-drawer-slide-in-left {
22
+ from {
23
+ transform: translateX(-32px);
24
+ }
25
+
26
+ to {
27
+ transform: translateX(0);
28
+ }
29
+ }
30
+
31
+ .solara-drawer__overlay {
32
+ position: fixed;
33
+ inset: 0;
34
+ background-color: rgba(var(--overlay-backdrop-rgb), var(--overlay-backdrop-opacity));
35
+ z-index: 3000;
36
+ backdrop-filter: blur(var(--overlay-backdrop-blur));
37
+ animation: solara-drawer-fade-in 0.2s ease-out;
38
+
39
+ @media (prefers-reduced-motion: reduce) {
40
+ animation: none;
41
+ }
42
+ }
43
+
44
+ .solara-drawer {
45
+ position: fixed;
46
+ top: 0;
47
+ bottom: 0;
48
+ width: 100%;
49
+ max-width: 28rem;
50
+ --drawer-surface-color: var(--color-surface-100);
51
+ --drawer-surface-color-rgb: var(--color-surface-100-rgb);
52
+ --surface-border-color: var(--color-divider-secondary);
53
+ --surface-border-color-rgb: var(--color-divider-secondary-rgb);
54
+ --surface-opacity: 1;
55
+ --surface-blur: 0px;
56
+ --surface-shadow: var(--shadow-md);
57
+ --drawer-resize-grip-color: color-mix(in srgb, var(--color-text-secondary) 78%, var(--color-divider-primary));
58
+ --drawer-resize-grip-hover-color: color-mix(in srgb, var(--color-text-primary) 72%, var(--color-divider-primary));
59
+ --drawer-resize-grip-ring: color-mix(in srgb, var(--color-surface-0) 92%, var(--color-divider-primary));
60
+ background-color: rgba(var(--drawer-surface-color-rgb), var(--surface-opacity));
61
+ border-left: 1px solid var(--surface-border-color);
62
+ border-right: 1px solid var(--surface-border-color);
63
+ box-shadow: var(--surface-shadow);
64
+ backdrop-filter: blur(var(--surface-blur));
65
+ display: flex;
66
+ flex-direction: column;
67
+ overflow: hidden;
68
+ animation: solara-drawer-slide-in-right 0.24s ease-out;
69
+
70
+ @media (prefers-reduced-motion: reduce) {
71
+ animation: none;
72
+ }
73
+ }
74
+
75
+ .solara-drawer--left {
76
+ left: 0;
77
+ border-left: none;
78
+ animation-name: solara-drawer-slide-in-left;
79
+ }
80
+
81
+ .solara-drawer--right {
82
+ right: 0;
83
+ border-right: none;
84
+ }
85
+
86
+ .solara-drawer--overflow {
87
+ overflow: visible;
88
+ }
89
+
90
+ .solara-drawer--sliding {
91
+ overflow: visible;
92
+ }
93
+
94
+ .solara-drawer--size-sm {
95
+ max-width: 20rem;
96
+ }
97
+
98
+ .solara-drawer--size-md {
99
+ max-width: 28rem;
100
+ }
101
+
102
+ .solara-drawer--size-lg {
103
+ max-width: 36rem;
104
+ }
105
+
106
+ .solara-drawer--size-xl {
107
+ max-width: 48rem;
108
+ }
109
+
110
+ @media (max-width: 640px) {
111
+ .solara-drawer {
112
+ max-width: calc(100vw - 1rem);
113
+ }
114
+ }
115
+
116
+ .solara-drawer--floating {
117
+ top: 1rem;
118
+ bottom: 1rem;
119
+ border-radius: var(--radius-lg);
120
+
121
+ &.solara-drawer--left {
122
+ left: 1rem;
123
+ }
124
+
125
+ &.solara-drawer--right {
126
+ right: 1rem;
127
+ }
128
+ }
129
+
130
+ .solara-drawer__resize-handle {
131
+ position: absolute;
132
+ top: 0;
133
+ bottom: 0;
134
+ width: 20px;
135
+ padding: 0;
136
+ border: none;
137
+ background: transparent;
138
+ cursor: ew-resize;
139
+ touch-action: none;
140
+ z-index: 2;
141
+ display: flex;
142
+ align-items: center;
143
+ justify-content: center;
144
+
145
+ &:focus-visible {
146
+ outline: none;
147
+ }
148
+ }
149
+
150
+ .solara-drawer__resize-grip {
151
+ display: block;
152
+ width: 6px;
153
+ height: 60px;
154
+ border-radius: 9999px;
155
+ background-color: var(--drawer-resize-grip-color);
156
+ box-shadow: 0 0 0 1px var(--drawer-resize-grip-ring);
157
+ transition: background-color 0.2s ease, transform 0.2s ease;
158
+ }
159
+
160
+ .solara-drawer__resize-handle:hover .solara-drawer__resize-grip,
161
+ .solara-drawer__resize-handle:focus-visible .solara-drawer__resize-grip,
162
+ .solara-drawer__resize-handle:active .solara-drawer__resize-grip {
163
+ background-color: var(--drawer-resize-grip-hover-color);
164
+ transform: scaleX(1.08);
165
+ }
166
+
167
+ .solara-drawer__resize-handle--left {
168
+ left: -18px;
169
+ }
170
+
171
+ .solara-drawer__resize-handle--right {
172
+ right: -18px;
173
+ }
174
+
175
+ .solara-drawer--floating .solara-drawer__resize-handle {
176
+ top: calc(var(--space-4) * -1);
177
+ bottom: calc(var(--space-4) * -1);
178
+ }
179
+
180
+ .solara-drawer__header {
181
+ padding: calc(var(--space-4) * var(--spacing-multiplier)) calc(var(--space-5) * var(--spacing-multiplier));
182
+ display: flex;
183
+ align-items: flex-start;
184
+ justify-content: space-between;
185
+ gap: calc(var(--space-3) * var(--spacing-multiplier));
186
+ }
187
+
188
+ .solara-drawer__header--bordered {
189
+ border-bottom: 1px solid var(--surface-border-color);
190
+ }
191
+
192
+ .solara-drawer__header-content {
193
+ flex: 1;
194
+ min-width: 0;
195
+ }
196
+
197
+ .solara-drawer__header-actions {
198
+ display: flex;
199
+ align-items: center;
200
+ gap: calc(var(--space-2) * var(--spacing-multiplier));
201
+ }
202
+
203
+ .solara-drawer__title {
204
+ margin: 0;
205
+ font-size: var(--font-size-h5-scaled);
206
+ line-height: var(--line-height-body-scaled);
207
+ font-weight: 600;
208
+ color: var(--color-text-primary);
209
+ }
210
+
211
+ .solara-drawer__subheader {
212
+ margin-top: calc(var(--space-1) * var(--spacing-multiplier));
213
+ color: var(--color-text-secondary);
214
+ font-size: var(--font-size-caption-scaled);
215
+ line-height: var(--line-height-caption-scaled);
216
+ }
217
+
218
+ .solara-drawer__close {
219
+ color: var(--color-text-secondary);
220
+ transition: color 0.2s ease, background-color 0.2s ease;
221
+ border-radius: 9999px;
222
+ width: 32px;
223
+ height: 32px;
224
+ display: flex;
225
+ align-items: center;
226
+ justify-content: center;
227
+ background: none;
228
+ border: none;
229
+ cursor: pointer;
230
+ padding: 0;
231
+ }
232
+
233
+ .solara-drawer__close:hover,
234
+ .solara-drawer__close:focus-visible {
235
+ color: var(--color-text-primary);
236
+ background-color: var(--color-surface-200);
237
+ outline: none;
238
+ }
239
+
240
+ .solara-drawer__content {
241
+ padding: calc(var(--space-4) * var(--spacing-multiplier)) calc(var(--space-5) * var(--spacing-multiplier));
242
+ overflow-y: auto;
243
+ overflow-x: visible;
244
+ flex: 1;
245
+ color: var(--color-text-secondary);
246
+ }
247
+
248
+ .solara-drawer__content--overflow {
249
+ overflow: visible;
250
+ }
251
+
252
+ .solara-drawer__content--no-padding {
253
+ padding: 0;
254
+ }
255
+
256
+ .solara-drawer__footer {
257
+ padding: calc(var(--space-4) * var(--spacing-multiplier)) calc(var(--space-5) * var(--spacing-multiplier));
258
+ border-top: 1px solid var(--surface-border-color);
259
+ background-color: rgba(var(--drawer-surface-color-rgb), var(--surface-opacity));
260
+ }
261
+
262
+ :root[data-surface-default="translucent"] .solara-drawer,
263
+ :root[data-surface-drawer="translucent"] .solara-drawer,
264
+ .solara-drawer[data-surface-style="translucent"] {
265
+ --surface-opacity: var(--opacity-translucent-medium);
266
+ --surface-blur: 0px;
267
+ --drawer-surface-color-rgb: var(--surface-translucent-tint-rgb);
268
+ }
269
+
270
+ :root[data-surface-default="glass"] .solara-drawer,
271
+ :root[data-surface-drawer="glass"] .solara-drawer,
272
+ .solara-drawer[data-surface-style="glass"] {
273
+ --surface-opacity: var(--opacity-translucent-heavy);
274
+ --surface-blur: var(--blur-medium);
275
+ --surface-border-color: rgba(var(--surface-border-color-rgb), var(--surface-border-alpha-glass));
276
+ --drawer-surface-color-rgb: var(--surface-translucent-tint-rgb);
277
+ }
278
+
279
+ .solara-drawer[data-surface-style="solid"] {
280
+ --surface-opacity: 1;
281
+ --surface-blur: 0px;
282
+ --surface-border-color: var(--color-divider-secondary);
283
+ }
284
+
285
+ :root[data-surface-drawer="solid"] .solara-drawer {
286
+ --surface-opacity: 1;
287
+ --surface-blur: 0px;
288
+ --surface-border-color: var(--color-divider-secondary);
289
+ }
290
+
291
+ :root[data-surface-default="translucent"] .solara-drawer__footer,
292
+ :root[data-surface-drawer="translucent"] .solara-drawer__footer,
293
+ .solara-drawer[data-surface-style="translucent"] .solara-drawer__footer,
294
+ :root[data-surface-default="glass"] .solara-drawer__footer,
295
+ :root[data-surface-drawer="glass"] .solara-drawer__footer,
296
+ .solara-drawer[data-surface-style="glass"] .solara-drawer__footer {
297
+ background-color: transparent;
298
+ }
299
+
300
+ :root[data-surface-default="translucent"] .solara-drawer,
301
+ :root[data-surface-drawer="translucent"] .solara-drawer,
302
+ .solara-drawer[data-surface-style="translucent"],
303
+ :root[data-surface-default="glass"] .solara-drawer,
304
+ :root[data-surface-drawer="glass"] .solara-drawer,
305
+ .solara-drawer[data-surface-style="glass"] {
306
+ overflow: hidden;
307
+ }
308
+
309
+ :root[data-surface-default="translucent"] .solara-drawer--sliding,
310
+ :root[data-surface-drawer="translucent"] .solara-drawer--sliding,
311
+ .solara-drawer--sliding[data-surface-style="translucent"],
312
+ :root[data-surface-default="glass"] .solara-drawer--sliding,
313
+ :root[data-surface-drawer="glass"] .solara-drawer--sliding,
314
+ .solara-drawer--sliding[data-surface-style="glass"] {
315
+ overflow: visible;
316
+ }
317
+
318
+ .solara-drawer__footer-actions {
319
+ display: flex;
320
+ justify-content: flex-end;
321
+ gap: calc(var(--space-3) * var(--spacing-multiplier));
322
+ flex-wrap: wrap;
323
+ }
324
+
325
+ @media (max-width: 640px) {
326
+ .solara-drawer__footer-actions {
327
+ flex-direction: column;
328
+ width: 100%;
329
+ }
330
+
331
+ .solara-drawer__footer-actions>* {
332
+ width: 100%;
333
+ }
334
+ }
package/src/Drawer.tsx ADDED
@@ -0,0 +1,450 @@
1
+ "use client";
2
+
3
+ import React, { createContext, forwardRef, useEffect, useMemo, useRef, useState } from "react";
4
+ import { createPortal } from "react-dom";
5
+ import { Button } from "@solara/button";
6
+ import type {
7
+ DrawerContentProps,
8
+ DrawerFooterProps,
9
+ DrawerHeaderProps,
10
+ DrawerProps,
11
+ DrawerTitleProps,
12
+ FooterButtonProps,
13
+ DrawerSize,
14
+ DrawerSide,
15
+ DrawerResizeConstraint,
16
+ } from "./Drawer.types";
17
+ import "./Drawer.scss";
18
+
19
+ const classNames = (...classes: Array<string | undefined | false | null>) =>
20
+ classes.filter(Boolean).join(" ");
21
+
22
+ const PRESET_DRAWER_WIDTHS: Record<DrawerSize, number> = {
23
+ sm: 320,
24
+ md: 448,
25
+ lg: 576,
26
+ xl: 768,
27
+ };
28
+
29
+ const DEFAULT_MIN_WIDTH = 280;
30
+ const DEFAULT_MAX_WIDTH_OFFSET = 48;
31
+
32
+ const parseResizeConstraint = (value: DrawerResizeConstraint | undefined) => {
33
+ if (typeof value === "number") return value;
34
+ if (typeof value === "string") {
35
+ const parsed = Number.parseFloat(value);
36
+ return Number.isFinite(parsed) ? parsed : undefined;
37
+ }
38
+ return undefined;
39
+ };
40
+
41
+ const getInitialDrawerWidth = (size: DrawerProps["size"]) => {
42
+ if (typeof size === "number") return size;
43
+ if (typeof size === "string") {
44
+ if (size in PRESET_DRAWER_WIDTHS) {
45
+ return PRESET_DRAWER_WIDTHS[size as DrawerSize];
46
+ }
47
+
48
+ const parsed = Number.parseFloat(size);
49
+ return Number.isFinite(parsed) ? parsed : undefined;
50
+ }
51
+
52
+ return PRESET_DRAWER_WIDTHS.md;
53
+ };
54
+
55
+ const clampDrawerWidth = (width: number, minWidth: number, maxWidth: number) =>
56
+ Math.min(Math.max(width, minWidth), maxWidth);
57
+
58
+ const DrawerContext = createContext<{ allowContentOverflow?: boolean }>({});
59
+
60
+ const CloseIcon = () => (
61
+ <svg viewBox="0 0 20 20" width="16" height="16" aria-hidden="true">
62
+ <path
63
+ d="M5 5l10 10M15 5L5 15"
64
+ stroke="currentColor"
65
+ strokeWidth="1.6"
66
+ strokeLinecap="round"
67
+ />
68
+ </svg>
69
+ );
70
+
71
+ const DrawerHeader = forwardRef<HTMLDivElement, DrawerHeaderProps>(
72
+ (
73
+ {
74
+ className,
75
+ children,
76
+ showCloseButton = true,
77
+ closeButtonAriaLabel = "Close drawer",
78
+ rightContent,
79
+ subheader,
80
+ bordered = true,
81
+ onClose,
82
+ ...props
83
+ },
84
+ ref
85
+ ) => (
86
+ <div
87
+ className={classNames(
88
+ "solara-drawer__header",
89
+ bordered ? "solara-drawer__header--bordered" : undefined,
90
+ className
91
+ )}
92
+ ref={ref}
93
+ {...props}
94
+ >
95
+ <div className="solara-drawer__header-content">
96
+ {children}
97
+ {subheader ? <div className="solara-drawer__subheader">{subheader}</div> : null}
98
+ </div>
99
+ <div className="solara-drawer__header-actions">
100
+ {rightContent}
101
+ {showCloseButton ? (
102
+ <button
103
+ type="button"
104
+ onClick={onClose}
105
+ aria-label={closeButtonAriaLabel}
106
+ className="solara-drawer__close"
107
+ >
108
+ <CloseIcon />
109
+ </button>
110
+ ) : null}
111
+ </div>
112
+ </div>
113
+ )
114
+ );
115
+
116
+ const DrawerTitle = forwardRef<HTMLHeadingElement, DrawerTitleProps>(
117
+ ({ className, ...props }, ref) => (
118
+ <h2 className={classNames("solara-drawer__title", className)} ref={ref} {...props} />
119
+ )
120
+ );
121
+
122
+ const DrawerContent = forwardRef<HTMLDivElement, DrawerContentProps>(
123
+ ({ className, noPadding, ...props }, ref) => {
124
+ const { allowContentOverflow } = React.useContext(DrawerContext);
125
+ return (
126
+ <div
127
+ className={classNames(
128
+ "solara-drawer__content",
129
+ allowContentOverflow ? "solara-drawer__content--overflow" : undefined,
130
+ noPadding ? "solara-drawer__content--no-padding" : undefined,
131
+ className
132
+ )}
133
+ ref={ref}
134
+ {...props}
135
+ />
136
+ );
137
+ }
138
+ );
139
+
140
+ const DrawerFooter = forwardRef<HTMLDivElement, DrawerFooterProps>(
141
+ ({ className, primaryButton, secondaryButton, dangerButton, ...props }, ref) => {
142
+ const renderButton = (button: FooterButtonProps, kind: "primary" | "secondary" | "danger") => {
143
+ const variant = kind === "primary" ? "primary" : "default";
144
+ const destructive = kind === "danger";
145
+ const isLoading = Boolean(button.loading);
146
+ const label = isLoading && button.loadingText ? button.loadingText : button.label;
147
+ const style = button.fullWidth ? { width: "100%" } : undefined;
148
+
149
+ return (
150
+ <Button
151
+ key={button.label}
152
+ type="button"
153
+ variant={variant}
154
+ destructive={destructive}
155
+ radius={button.radius}
156
+ onClick={button.onClick}
157
+ disabled={button.disabled || isLoading}
158
+ style={style}
159
+ label={label}
160
+ />
161
+ );
162
+ };
163
+
164
+ return (
165
+ <div className={classNames("solara-drawer__footer", className)} ref={ref} {...props}>
166
+ <div className="solara-drawer__footer-actions">
167
+ {secondaryButton ? renderButton(secondaryButton, "secondary") : null}
168
+ {dangerButton ? renderButton(dangerButton, "danger") : null}
169
+ {primaryButton ? renderButton(primaryButton, "primary") : null}
170
+ </div>
171
+ </div>
172
+ );
173
+ }
174
+ );
175
+
176
+ const DrawerComponent = forwardRef<HTMLDivElement, DrawerProps>(
177
+ (
178
+ {
179
+ isOpen,
180
+ onClose,
181
+ children,
182
+ position = "right",
183
+ overlay = true,
184
+ closeOnOverlayClick = true,
185
+ closeOnOutsideClick = true,
186
+ closeOnEscape = true,
187
+ size = "md",
188
+ sliding = false,
189
+ minWidth,
190
+ maxWidth,
191
+ className,
192
+ overlayClassName,
193
+ allowContentOverflow = false,
194
+ floating = false,
195
+ surfaceStyle,
196
+ style,
197
+ ...props
198
+ },
199
+ ref
200
+ ) => {
201
+ const drawerRef = useRef<HTMLDivElement>(null);
202
+ const dragStateRef = useRef<{ pointerId: number; rafId: number | null } | null>(null);
203
+ const [drawerWidth, setDrawerWidth] = useState<number | undefined>(() => getInitialDrawerWidth(size));
204
+ React.useImperativeHandle(ref, () => drawerRef.current as HTMLDivElement);
205
+
206
+ useEffect(() => {
207
+ if (!sliding) return;
208
+ setDrawerWidth(getInitialDrawerWidth(size));
209
+ }, [size, sliding]);
210
+
211
+ useEffect(() => {
212
+ if (!isOpen) return;
213
+ const originalOverflow = document.body.style.overflow;
214
+ document.body.style.overflow = "hidden";
215
+ return () => {
216
+ document.body.style.overflow = originalOverflow;
217
+ };
218
+ }, [isOpen]);
219
+
220
+ useEffect(() => {
221
+ if (!isOpen || !closeOnEscape) return;
222
+ const handleEscape = (event: KeyboardEvent) => {
223
+ if (event.key === "Escape") onClose();
224
+ };
225
+ document.addEventListener("keydown", handleEscape);
226
+ return () => document.removeEventListener("keydown", handleEscape);
227
+ }, [isOpen, closeOnEscape, onClose]);
228
+
229
+ useEffect(() => {
230
+ if (!isOpen || !closeOnOutsideClick) return;
231
+
232
+ const handleClickOutside = (event: MouseEvent) => {
233
+ if (drawerRef.current && !drawerRef.current.contains(event.target as Node)) {
234
+ onClose();
235
+ }
236
+ };
237
+
238
+ document.addEventListener("mousedown", handleClickOutside);
239
+ return () => document.removeEventListener("mousedown", handleClickOutside);
240
+ }, [isOpen, closeOnOutsideClick, onClose]);
241
+
242
+ const contextValue = useMemo(
243
+ () => ({
244
+ allowContentOverflow,
245
+ }),
246
+ [allowContentOverflow]
247
+ );
248
+
249
+ useEffect(() => {
250
+ return () => {
251
+ const dragState = dragStateRef.current;
252
+ if (dragState?.rafId !== null && dragState?.rafId !== undefined) {
253
+ window.cancelAnimationFrame(dragState.rafId);
254
+ }
255
+ };
256
+ }, []);
257
+
258
+ if (!isOpen) return null;
259
+
260
+ const isPresetSize = ["sm", "md", "lg", "xl"].includes(size as string);
261
+ const resolvedMinWidth = parseResizeConstraint(minWidth) ?? DEFAULT_MIN_WIDTH;
262
+ const viewportMaxWidth = typeof window === "undefined" ? Number.POSITIVE_INFINITY : window.innerWidth - DEFAULT_MAX_WIDTH_OFFSET;
263
+ const resolvedMaxWidth = parseResizeConstraint(maxWidth) ?? viewportMaxWidth;
264
+ const normalizedMaxWidth = Math.max(resolvedMinWidth, resolvedMaxWidth);
265
+
266
+ const drawerClasses = classNames(
267
+ "solara-drawer",
268
+ `solara-drawer--${position}`,
269
+ isPresetSize ? `solara-drawer--size-${size}` : undefined,
270
+ allowContentOverflow ? "solara-drawer--overflow" : undefined,
271
+ floating ? "solara-drawer--floating" : undefined,
272
+ sliding ? "solara-drawer--sliding" : undefined,
273
+ className
274
+ );
275
+
276
+ // Surface treatment stays theme-driven via data attributes and CSS variables.
277
+ const surfaceAttributes: Record<string, string | undefined> = {};
278
+ const surfaceStyleVars: React.CSSProperties & Record<string, string | undefined> = {};
279
+
280
+ if (surfaceStyle) {
281
+ if (typeof surfaceStyle === "string") {
282
+ surfaceAttributes["data-surface-style"] = surfaceStyle;
283
+ } else {
284
+ surfaceAttributes["data-surface-style"] = "custom";
285
+ if (surfaceStyle.opacity !== undefined) {
286
+ surfaceStyleVars["--surface-opacity"] = surfaceStyle.opacity.toString();
287
+ }
288
+ if (surfaceStyle.blur !== undefined) {
289
+ surfaceStyleVars["--surface-blur"] = `${surfaceStyle.blur}px`;
290
+ }
291
+ if (surfaceStyle.borderColor !== undefined) {
292
+ surfaceStyleVars["--surface-border-color"] = surfaceStyle.borderColor;
293
+ }
294
+ if (surfaceStyle.shadow !== undefined) {
295
+ surfaceStyleVars["--surface-shadow"] = surfaceStyle.shadow;
296
+ }
297
+ }
298
+ }
299
+
300
+ const resolvedWidth = sliding
301
+ ? clampDrawerWidth(drawerWidth ?? getInitialDrawerWidth(size) ?? PRESET_DRAWER_WIDTHS.md, resolvedMinWidth, normalizedMaxWidth)
302
+ : undefined;
303
+
304
+ const drawerStyle: React.CSSProperties = {
305
+ ...(!sliding && !isPresetSize ? { width: size } : {}),
306
+ ...(sliding ? { width: resolvedWidth, maxWidth: normalizedMaxWidth, minWidth: resolvedMinWidth } : {}),
307
+ };
308
+
309
+ // Dragging measures from the viewport edge the drawer is anchored to.
310
+ const updateWidthFromPointer = (clientX: number) => {
311
+ const nextWidth = position === "right" ? window.innerWidth - clientX : clientX;
312
+ setDrawerWidth(clampDrawerWidth(nextWidth, resolvedMinWidth, normalizedMaxWidth));
313
+ };
314
+
315
+ const handleResizePointerDown = (event: React.PointerEvent<HTMLButtonElement>) => {
316
+ if (!sliding) return;
317
+
318
+ event.preventDefault();
319
+ event.stopPropagation();
320
+
321
+ const target = event.currentTarget;
322
+ target.setPointerCapture(event.pointerId);
323
+
324
+ dragStateRef.current = { pointerId: event.pointerId, rafId: null };
325
+
326
+ // Use one animation frame per pointer tick burst to keep resize responsive without over-rendering.
327
+ const handlePointerMove = (moveEvent: PointerEvent) => {
328
+ if (!dragStateRef.current || moveEvent.pointerId !== dragStateRef.current.pointerId) return;
329
+
330
+ if (dragStateRef.current.rafId !== null) {
331
+ window.cancelAnimationFrame(dragStateRef.current.rafId);
332
+ }
333
+
334
+ dragStateRef.current.rafId = window.requestAnimationFrame(() => {
335
+ updateWidthFromPointer(moveEvent.clientX);
336
+ if (dragStateRef.current) {
337
+ dragStateRef.current.rafId = null;
338
+ }
339
+ });
340
+ };
341
+
342
+ const cleanupPointerSession = (pointerId: number) => {
343
+ const dragState = dragStateRef.current;
344
+ if (dragState?.rafId !== null && dragState?.rafId !== undefined) {
345
+ window.cancelAnimationFrame(dragState.rafId);
346
+ }
347
+
348
+ dragStateRef.current = null;
349
+ target.releasePointerCapture(pointerId);
350
+ window.removeEventListener("pointermove", handlePointerMove);
351
+ window.removeEventListener("pointerup", handlePointerUp);
352
+ window.removeEventListener("pointercancel", handlePointerCancel);
353
+ };
354
+
355
+ const handlePointerUp = (upEvent: PointerEvent) => {
356
+ if (upEvent.pointerId !== event.pointerId) return;
357
+ cleanupPointerSession(upEvent.pointerId);
358
+ };
359
+
360
+ const handlePointerCancel = (cancelEvent: PointerEvent) => {
361
+ if (cancelEvent.pointerId !== event.pointerId) return;
362
+ cleanupPointerSession(cancelEvent.pointerId);
363
+ };
364
+
365
+ window.addEventListener("pointermove", handlePointerMove);
366
+ window.addEventListener("pointerup", handlePointerUp);
367
+ window.addEventListener("pointercancel", handlePointerCancel);
368
+ };
369
+
370
+ const content = (
371
+ <div
372
+ ref={drawerRef}
373
+ className={drawerClasses}
374
+ style={{ ...drawerStyle, ...surfaceStyleVars, ...style }}
375
+ role="dialog"
376
+ aria-modal={overlay ? "true" : undefined}
377
+ onClick={(event) => event.stopPropagation()}
378
+ {...surfaceAttributes}
379
+ {...props}
380
+ >
381
+ {sliding ? (
382
+ <button
383
+ type="button"
384
+ aria-label={`Resize ${position} drawer`}
385
+ className={classNames(
386
+ "solara-drawer__resize-handle",
387
+ position === "right"
388
+ ? "solara-drawer__resize-handle--left"
389
+ : "solara-drawer__resize-handle--right"
390
+ )}
391
+ onPointerDown={handleResizePointerDown}
392
+ onClick={(event) => event.stopPropagation()}
393
+ >
394
+ <span aria-hidden="true" className="solara-drawer__resize-grip" />
395
+ </button>
396
+ ) : null}
397
+ <DrawerContext.Provider value={contextValue}>{children}</DrawerContext.Provider>
398
+ </div>
399
+ );
400
+
401
+ if (!overlay) {
402
+ return createPortal(content, document.body);
403
+ }
404
+
405
+ return createPortal(
406
+ <div
407
+ className={classNames("solara-drawer__overlay", overlayClassName)}
408
+ onClick={closeOnOverlayClick ? onClose : undefined}
409
+ >
410
+ {content}
411
+ </div>,
412
+ document.body
413
+ );
414
+ }
415
+ );
416
+
417
+ DrawerComponent.displayName = "Drawer";
418
+ DrawerHeader.displayName = "Drawer.Header";
419
+ DrawerTitle.displayName = "Drawer.Title";
420
+ DrawerContent.displayName = "Drawer.Content";
421
+ DrawerFooter.displayName = "Drawer.Footer";
422
+
423
+ interface DrawerCompoundComponent
424
+ extends React.ForwardRefExoticComponent<DrawerProps & React.RefAttributes<HTMLDivElement>> {
425
+ Header: typeof DrawerHeader;
426
+ Title: typeof DrawerTitle;
427
+ Content: typeof DrawerContent;
428
+ Footer: typeof DrawerFooter;
429
+ }
430
+
431
+ const Drawer = Object.assign(DrawerComponent, {
432
+ Header: DrawerHeader,
433
+ Title: DrawerTitle,
434
+ Content: DrawerContent,
435
+ Footer: DrawerFooter,
436
+ });
437
+
438
+ export { Drawer, DrawerHeader, DrawerTitle, DrawerContent, DrawerFooter };
439
+ export type {
440
+ DrawerProps,
441
+ DrawerHeaderProps,
442
+ DrawerTitleProps,
443
+ DrawerContentProps,
444
+ DrawerFooterProps,
445
+ DrawerSize,
446
+ DrawerSide,
447
+ FooterButtonProps,
448
+ };
449
+
450
+ export default Drawer as DrawerCompoundComponent;
@@ -0,0 +1,69 @@
1
+ import type React from "react";
2
+
3
+ export type DrawerSize = "sm" | "md" | "lg" | "xl";
4
+ export type DrawerSide = "left" | "right";
5
+
6
+ export type DrawerResizeConstraint = number | `${number}px`;
7
+
8
+ export type DrawerSurfaceStyle =
9
+ | "solid"
10
+ | "translucent"
11
+ | "glass"
12
+ | {
13
+ opacity?: number;
14
+ blur?: number;
15
+ borderColor?: string;
16
+ shadow?: string;
17
+ };
18
+
19
+ export interface FooterButtonProps {
20
+ label: string;
21
+ variant?: "primary" | "secondary" | "danger";
22
+ onClick: (event?: React.MouseEvent<HTMLButtonElement>) => void;
23
+ disabled?: boolean;
24
+ loading?: boolean;
25
+ loadingText?: string;
26
+ fullWidth?: boolean;
27
+ radius?: "default" | "none" | "full";
28
+ }
29
+
30
+ export interface DrawerHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
31
+ showCloseButton?: boolean;
32
+ closeButtonAriaLabel?: string;
33
+ rightContent?: React.ReactNode;
34
+ subheader?: React.ReactNode;
35
+ bordered?: boolean;
36
+ onClose: () => void;
37
+ }
38
+
39
+ export interface DrawerTitleProps extends React.HTMLAttributes<HTMLHeadingElement> { }
40
+
41
+ export interface DrawerContentProps extends React.HTMLAttributes<HTMLDivElement> {
42
+ noPadding?: boolean;
43
+ }
44
+
45
+ export interface DrawerFooterProps extends React.HTMLAttributes<HTMLDivElement> {
46
+ primaryButton?: FooterButtonProps;
47
+ secondaryButton?: FooterButtonProps;
48
+ dangerButton?: FooterButtonProps;
49
+ }
50
+
51
+ export interface DrawerProps extends React.HTMLAttributes<HTMLDivElement> {
52
+ isOpen: boolean;
53
+ onClose: () => void;
54
+ position?: DrawerSide;
55
+ overlay?: boolean;
56
+ closeOnOverlayClick?: boolean;
57
+ closeOnEscape?: boolean;
58
+ size?: DrawerSize | (string & {}) | number;
59
+ sliding?: boolean;
60
+ minWidth?: DrawerResizeConstraint;
61
+ maxWidth?: DrawerResizeConstraint;
62
+ overlayClassName?: string;
63
+ allowContentOverflow?: boolean;
64
+ children?: React.ReactNode;
65
+ floating?: boolean;
66
+ closeOnOutsideClick?: boolean;
67
+ /** Controls surface material treatment without changing layout tokens. */
68
+ surfaceStyle?: DrawerSurfaceStyle;
69
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ export { Drawer } from "./Drawer";
2
+ export type {
3
+ DrawerProps,
4
+ DrawerHeaderProps,
5
+ DrawerTitleProps,
6
+ DrawerContentProps,
7
+ DrawerFooterProps,
8
+ DrawerSize,
9
+ DrawerSide,
10
+ FooterButtonProps,
11
+ } from "./Drawer.types";