@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,502 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import styles from './Command.module.scss';
5
+ import '../../styles/globals.scss';
6
+
7
+ // ============================================
8
+ // Types
9
+ // ============================================
10
+
11
+ export interface CommandProps extends React.HTMLAttributes<HTMLDivElement> {
12
+ children: React.ReactNode;
13
+ /** Controlled search value */
14
+ search?: string;
15
+ /** Default search value */
16
+ defaultSearch?: string;
17
+ /** Called when search input changes */
18
+ onSearchChange?: (search: string) => void;
19
+ /** Custom filter function. Return 0 to hide, >0 to show (higher = better match).
20
+ Default: case-insensitive substring match on value + keywords */
21
+ filter?: (value: string, search: string, keywords?: string[]) => number;
22
+ /** Whether to loop keyboard navigation. Default: true */
23
+ loop?: boolean;
24
+ }
25
+
26
+ export interface CommandInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
27
+ className?: string;
28
+ }
29
+
30
+ export interface CommandListProps extends React.HTMLAttributes<HTMLDivElement> {
31
+ children: React.ReactNode;
32
+ }
33
+
34
+ export interface CommandItemProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onSelect'> {
35
+ children: React.ReactNode;
36
+ /** Value used for filtering (falls back to text content) */
37
+ value?: string;
38
+ /** Extra keywords for filtering */
39
+ keywords?: string[];
40
+ /** Whether this item is disabled */
41
+ disabled?: boolean;
42
+ /** Called when item is selected (Enter or click) */
43
+ onItemSelect?: () => void;
44
+ }
45
+
46
+ export interface CommandGroupProps extends React.HTMLAttributes<HTMLDivElement> {
47
+ children: React.ReactNode;
48
+ /** Group heading text */
49
+ heading?: string;
50
+ }
51
+
52
+ export interface CommandEmptyProps extends React.HTMLAttributes<HTMLDivElement> {
53
+ children: React.ReactNode;
54
+ }
55
+
56
+ export interface CommandSeparatorProps extends React.HTMLAttributes<HTMLDivElement> {}
57
+
58
+ // ============================================
59
+ // Default filter
60
+ // ============================================
61
+
62
+ function defaultFilter(value: string, search: string, keywords?: string[]): number {
63
+ if (!search) return 1;
64
+ const searchLower = search.toLowerCase();
65
+ const valueLower = value.toLowerCase();
66
+
67
+ if (valueLower.includes(searchLower)) return 1;
68
+
69
+ if (keywords) {
70
+ for (const keyword of keywords) {
71
+ if (keyword.toLowerCase().includes(searchLower)) return 1;
72
+ }
73
+ }
74
+
75
+ return 0;
76
+ }
77
+
78
+ // ============================================
79
+ // Context
80
+ // ============================================
81
+
82
+ interface ItemRegistration {
83
+ value: string;
84
+ keywords?: string[];
85
+ }
86
+
87
+ interface CommandContextValue {
88
+ search: string;
89
+ setSearch: (search: string) => void;
90
+ filter: (value: string, search: string, keywords?: string[]) => number;
91
+ scores: Map<string, number>;
92
+ registerItem: (id: string, registration: ItemRegistration) => void;
93
+ unregisterItem: (id: string) => void;
94
+ activeId: string | null;
95
+ setActiveId: (id: string | null) => void;
96
+ loop: boolean;
97
+ listRef: React.RefObject<HTMLDivElement | null>;
98
+ visibleCount: number;
99
+ }
100
+
101
+ const CommandContext = React.createContext<CommandContextValue | null>(null);
102
+
103
+ function useCommandContext() {
104
+ const ctx = React.useContext(CommandContext);
105
+ if (!ctx) throw new Error('Command sub-components must be used within <Command>');
106
+ return ctx;
107
+ }
108
+
109
+ // ============================================
110
+ // Search Icon
111
+ // ============================================
112
+
113
+ function SearchIcon() {
114
+ return (
115
+ <svg
116
+ xmlns="http://www.w3.org/2000/svg"
117
+ width="16"
118
+ height="16"
119
+ viewBox="0 0 24 24"
120
+ fill="none"
121
+ stroke="currentColor"
122
+ strokeWidth="2"
123
+ strokeLinecap="round"
124
+ strokeLinejoin="round"
125
+ aria-hidden="true"
126
+ >
127
+ <circle cx="11" cy="11" r="8" />
128
+ <line x1="21" y1="21" x2="16.65" y2="16.65" />
129
+ </svg>
130
+ );
131
+ }
132
+
133
+ // ============================================
134
+ // Components
135
+ // ============================================
136
+
137
+ function CommandRoot({
138
+ children,
139
+ search: controlledSearch,
140
+ defaultSearch = '',
141
+ onSearchChange,
142
+ filter = defaultFilter,
143
+ loop = true,
144
+ className,
145
+ ...htmlProps
146
+ }: CommandProps) {
147
+ const [uncontrolledSearch, setUncontrolledSearch] = React.useState(defaultSearch);
148
+ const isControlled = controlledSearch !== undefined;
149
+ const search = isControlled ? controlledSearch : uncontrolledSearch;
150
+
151
+ const [items, setItems] = React.useState<Map<string, ItemRegistration>>(new Map());
152
+ const [activeId, setActiveId] = React.useState<string | null>(null);
153
+ const listRef = React.useRef<HTMLDivElement | null>(null);
154
+
155
+ const setSearch = React.useCallback(
156
+ (value: string) => {
157
+ if (!isControlled) {
158
+ setUncontrolledSearch(value);
159
+ }
160
+ onSearchChange?.(value);
161
+ },
162
+ [isControlled, onSearchChange]
163
+ );
164
+
165
+ const registerItem = React.useCallback((id: string, registration: ItemRegistration) => {
166
+ setItems((prev) => {
167
+ const next = new Map(prev);
168
+ next.set(id, registration);
169
+ return next;
170
+ });
171
+ }, []);
172
+
173
+ const unregisterItem = React.useCallback((id: string) => {
174
+ setItems((prev) => {
175
+ const next = new Map(prev);
176
+ next.delete(id);
177
+ return next;
178
+ });
179
+ }, []);
180
+
181
+ // Compute scores for all items
182
+ const scores = React.useMemo(() => {
183
+ const result = new Map<string, number>();
184
+ for (const [id, registration] of items) {
185
+ const score = filter(registration.value, search, registration.keywords);
186
+ result.set(id, score);
187
+ }
188
+ return result;
189
+ }, [items, search, filter]);
190
+
191
+ const visibleCount = React.useMemo(() => {
192
+ let count = 0;
193
+ for (const score of scores.values()) {
194
+ if (score > 0) count++;
195
+ }
196
+ return count;
197
+ }, [scores]);
198
+
199
+ // Reset active when search changes
200
+ React.useEffect(() => {
201
+ setActiveId(null);
202
+ }, [search]);
203
+
204
+ const contextValue = React.useMemo<CommandContextValue>(
205
+ () => ({
206
+ search,
207
+ setSearch,
208
+ filter,
209
+ scores,
210
+ registerItem,
211
+ unregisterItem,
212
+ activeId,
213
+ setActiveId,
214
+ loop,
215
+ listRef,
216
+ visibleCount,
217
+ }),
218
+ [search, setSearch, filter, scores, registerItem, unregisterItem, activeId, loop, visibleCount]
219
+ );
220
+
221
+ return (
222
+ <CommandContext.Provider value={contextValue}>
223
+ <div
224
+ {...htmlProps}
225
+ className={[styles.command, className].filter(Boolean).join(' ')}
226
+ role="search"
227
+ >
228
+ {children}
229
+ </div>
230
+ </CommandContext.Provider>
231
+ );
232
+ }
233
+
234
+ function CommandInput({ className, ...htmlProps }: CommandInputProps) {
235
+ const { search, setSearch, listRef, setActiveId, activeId, loop } = useCommandContext();
236
+ const inputRef = React.useRef<HTMLInputElement>(null);
237
+
238
+ const getEnabledItems = React.useCallback(() => {
239
+ const list = listRef.current;
240
+ if (!list) return [];
241
+ return Array.from(
242
+ list.querySelectorAll<HTMLElement>('[data-command-item]:not([data-disabled="true"])')
243
+ ).filter((el) => el.style.display !== 'none');
244
+ }, [listRef]);
245
+
246
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
247
+ htmlProps.onKeyDown?.(event);
248
+ if (event.defaultPrevented) return;
249
+
250
+ const items = getEnabledItems();
251
+ if (items.length === 0) return;
252
+
253
+ const currentIndex = activeId
254
+ ? items.findIndex((item) => item.id === activeId)
255
+ : -1;
256
+
257
+ switch (event.key) {
258
+ case 'ArrowDown': {
259
+ event.preventDefault();
260
+ if (currentIndex < 0) {
261
+ setActiveId(items[0].id);
262
+ } else if (currentIndex < items.length - 1) {
263
+ setActiveId(items[currentIndex + 1].id);
264
+ } else if (loop) {
265
+ setActiveId(items[0].id);
266
+ }
267
+ break;
268
+ }
269
+ case 'ArrowUp': {
270
+ event.preventDefault();
271
+ if (currentIndex < 0) {
272
+ setActiveId(items[items.length - 1].id);
273
+ } else if (currentIndex > 0) {
274
+ setActiveId(items[currentIndex - 1].id);
275
+ } else if (loop) {
276
+ setActiveId(items[items.length - 1].id);
277
+ }
278
+ break;
279
+ }
280
+ case 'Home': {
281
+ event.preventDefault();
282
+ setActiveId(items[0].id);
283
+ break;
284
+ }
285
+ case 'End': {
286
+ event.preventDefault();
287
+ setActiveId(items[items.length - 1].id);
288
+ break;
289
+ }
290
+ case 'Enter': {
291
+ event.preventDefault();
292
+ if (activeId) {
293
+ const activeItem = items.find((item) => item.id === activeId);
294
+ if (activeItem) {
295
+ activeItem.click();
296
+ }
297
+ }
298
+ break;
299
+ }
300
+ }
301
+ };
302
+
303
+ return (
304
+ <div className={styles.inputWrapper}>
305
+ <SearchIcon />
306
+ <input
307
+ ref={inputRef}
308
+ type="text"
309
+ role="combobox"
310
+ aria-expanded={true}
311
+ aria-controls="command-list"
312
+ aria-autocomplete="list"
313
+ aria-activedescendant={activeId ?? undefined}
314
+ autoComplete="off"
315
+ autoCorrect="off"
316
+ spellCheck={false}
317
+ value={search}
318
+ onChange={(e) => setSearch(e.target.value)}
319
+ onKeyDown={handleKeyDown}
320
+ className={[styles.input, className].filter(Boolean).join(' ')}
321
+ {...htmlProps}
322
+ />
323
+ </div>
324
+ );
325
+ }
326
+
327
+ function CommandList({ children, className, ...htmlProps }: CommandListProps) {
328
+ const { listRef } = useCommandContext();
329
+
330
+ return (
331
+ <div
332
+ ref={listRef}
333
+ id="command-list"
334
+ role="listbox"
335
+ className={[styles.list, className].filter(Boolean).join(' ')}
336
+ {...htmlProps}
337
+ >
338
+ {children}
339
+ </div>
340
+ );
341
+ }
342
+
343
+ function CommandItem({
344
+ children,
345
+ value: valueProp,
346
+ keywords,
347
+ disabled = false,
348
+ onItemSelect,
349
+ className,
350
+ ...htmlProps
351
+ }: CommandItemProps) {
352
+ const { scores, registerItem, unregisterItem, activeId, setActiveId } = useCommandContext();
353
+ const generatedId = React.useId();
354
+ const itemId = (htmlProps.id as string | undefined) ?? `command-item-${generatedId}`;
355
+ const itemRef = React.useRef<HTMLDivElement>(null);
356
+
357
+ // Extract text content for filtering if no value prop
358
+ const textValue = React.useMemo(() => {
359
+ if (valueProp) return valueProp;
360
+ if (typeof children === 'string') return children;
361
+ return '';
362
+ }, [valueProp, children]);
363
+
364
+ // Register with context
365
+ React.useEffect(() => {
366
+ registerItem(itemId, { value: textValue, keywords });
367
+ return () => unregisterItem(itemId);
368
+ }, [itemId, textValue, keywords, registerItem, unregisterItem]);
369
+
370
+ const score = scores.get(itemId) ?? 1;
371
+ const isVisible = score > 0;
372
+ const isActive = activeId === itemId;
373
+
374
+ // Scroll active item into view
375
+ React.useEffect(() => {
376
+ if (isActive && itemRef.current) {
377
+ itemRef.current.scrollIntoView({ block: 'nearest' });
378
+ }
379
+ }, [isActive]);
380
+
381
+ const handleClick = () => {
382
+ if (disabled) return;
383
+ onItemSelect?.();
384
+ };
385
+
386
+ const handleMouseEnter = () => {
387
+ if (!disabled) {
388
+ setActiveId(itemId);
389
+ }
390
+ };
391
+
392
+ return (
393
+ <div
394
+ ref={itemRef}
395
+ {...htmlProps}
396
+ id={itemId}
397
+ role="option"
398
+ aria-selected={isActive}
399
+ aria-disabled={disabled}
400
+ data-command-item=""
401
+ data-active={isActive || undefined}
402
+ data-disabled={disabled || undefined}
403
+ onClick={handleClick}
404
+ onMouseEnter={handleMouseEnter}
405
+ className={[
406
+ styles.item,
407
+ isActive && styles.itemActive,
408
+ disabled && styles.itemDisabled,
409
+ className,
410
+ ]
411
+ .filter(Boolean)
412
+ .join(' ')}
413
+ style={{ display: isVisible ? undefined : 'none' }}
414
+ >
415
+ {children}
416
+ </div>
417
+ );
418
+ }
419
+
420
+ function CommandGroup({ children, heading, className, ...htmlProps }: CommandGroupProps) {
421
+ const labelId = React.useId();
422
+ const groupRef = React.useRef<HTMLDivElement>(null);
423
+ const { scores } = useCommandContext();
424
+ const [hasVisibleChildren, setHasVisibleChildren] = React.useState(true);
425
+
426
+ // Check if any children are visible after each score update
427
+ React.useEffect(() => {
428
+ if (!groupRef.current) return;
429
+ const items = groupRef.current.querySelectorAll<HTMLElement>('[data-command-item]');
430
+ const anyVisible = Array.from(items).some((item) => item.style.display !== 'none');
431
+ setHasVisibleChildren(anyVisible);
432
+ }, [scores]);
433
+
434
+ return (
435
+ <div
436
+ ref={groupRef}
437
+ {...htmlProps}
438
+ role="group"
439
+ aria-labelledby={heading ? labelId : undefined}
440
+ className={[styles.group, className].filter(Boolean).join(' ')}
441
+ style={{ display: hasVisibleChildren ? undefined : 'none' }}
442
+ >
443
+ {heading && (
444
+ <div id={labelId} className={styles.groupHeading}>
445
+ {heading}
446
+ </div>
447
+ )}
448
+ {children}
449
+ </div>
450
+ );
451
+ }
452
+
453
+ function CommandEmpty({ children, className, ...htmlProps }: CommandEmptyProps) {
454
+ const { visibleCount } = useCommandContext();
455
+
456
+ if (visibleCount > 0) return null;
457
+
458
+ return (
459
+ <div
460
+ {...htmlProps}
461
+ role="option"
462
+ aria-disabled="true"
463
+ aria-selected="false"
464
+ className={[styles.empty, className].filter(Boolean).join(' ')}
465
+ >
466
+ {children}
467
+ </div>
468
+ );
469
+ }
470
+
471
+ function CommandSeparator({ className, ...htmlProps }: CommandSeparatorProps) {
472
+ return (
473
+ <div
474
+ {...htmlProps}
475
+ role="separator"
476
+ className={[styles.separator, className].filter(Boolean).join(' ')}
477
+ />
478
+ );
479
+ }
480
+
481
+ // ============================================
482
+ // Export compound component
483
+ // ============================================
484
+
485
+ export const Command = Object.assign(CommandRoot, {
486
+ Input: CommandInput,
487
+ List: CommandList,
488
+ Item: CommandItem,
489
+ Group: CommandGroup,
490
+ Empty: CommandEmpty,
491
+ Separator: CommandSeparator,
492
+ });
493
+
494
+ export {
495
+ CommandRoot,
496
+ CommandInput,
497
+ CommandList,
498
+ CommandItem,
499
+ CommandGroup,
500
+ CommandEmpty,
501
+ CommandSeparator,
502
+ };
@@ -0,0 +1,206 @@
1
+ import React from 'react';
2
+ import { defineFragment } from '@fragments-sdk/cli/core';
3
+ import { Drawer } from '.';
4
+ import { Button } from '../Button';
5
+ import { Stack } from '../Stack';
6
+ import { Input } from '../Input';
7
+
8
+ export default defineFragment({
9
+ component: Drawer,
10
+
11
+ meta: {
12
+ name: 'Drawer',
13
+ description: 'A panel that slides in from screen edges. Extends the Dialog pattern with slide animations and edge positioning.',
14
+ category: 'feedback',
15
+ status: 'stable',
16
+ tags: ['drawer', 'sheet', 'panel', 'sidebar', 'slide-over'],
17
+ since: '0.8.2',
18
+ },
19
+
20
+ usage: {
21
+ when: [
22
+ 'Side panels for editing or creating content',
23
+ 'Mobile-style bottom sheets for actions',
24
+ 'Navigation panels that slide in from the left',
25
+ 'Detail views that overlay the main content',
26
+ ],
27
+ whenNot: [
28
+ 'Centered modal dialogs (use Dialog)',
29
+ 'Non-blocking notifications (use Toast)',
30
+ 'Permanent side navigation (use Sidebar)',
31
+ 'Small contextual menus (use Menu or Popover)',
32
+ ],
33
+ guidelines: [
34
+ 'Default to right side for content editing and forms',
35
+ 'Use left side for navigation drawers',
36
+ 'Use bottom for mobile-style action sheets',
37
+ 'Provide clear close affordance (X button or cancel)',
38
+ 'Keep drawer content focused on a single task',
39
+ ],
40
+ accessibility: [
41
+ 'Automatically traps focus within the drawer',
42
+ 'Closes on Escape key press',
43
+ 'Returns focus to trigger element on close',
44
+ 'Uses role="dialog" with proper aria attributes',
45
+ ],
46
+ },
47
+
48
+ props: {
49
+ children: {
50
+ type: 'node',
51
+ description: 'Drawer content (use Drawer.Content, Drawer.Header, etc.)',
52
+ required: true,
53
+ },
54
+ open: {
55
+ type: 'boolean',
56
+ description: 'Controlled open state',
57
+ },
58
+ defaultOpen: {
59
+ type: 'boolean',
60
+ description: 'Default open state (uncontrolled)',
61
+ default: 'false',
62
+ },
63
+ onOpenChange: {
64
+ type: 'function',
65
+ description: 'Called when open state changes',
66
+ },
67
+ modal: {
68
+ type: 'boolean',
69
+ description: 'Whether to render as modal (blocks interaction with rest of page)',
70
+ default: 'true',
71
+ },
72
+ },
73
+
74
+ relations: [
75
+ { component: 'Dialog', relationship: 'sibling', note: 'Use Dialog for centered modal overlays' },
76
+ { component: 'Sidebar', relationship: 'alternative', note: 'Use Sidebar for permanent side navigation' },
77
+ { component: 'Popover', relationship: 'alternative', note: 'Use Popover for small contextual content' },
78
+ ],
79
+
80
+ contract: {
81
+ propsSummary: [
82
+ 'open: boolean - controlled open state',
83
+ 'onOpenChange: (open) => void - open state handler',
84
+ 'modal: boolean - blocks page interaction (default: true)',
85
+ 'Drawer.Content side: left|right|top|bottom - slide direction (default: right)',
86
+ 'Drawer.Content size: sm|md|lg|xl|full - panel size',
87
+ ],
88
+ scenarioTags: [
89
+ 'overlay.drawer',
90
+ 'form.editor',
91
+ 'navigation.panel',
92
+ 'action.sheet',
93
+ ],
94
+ a11yRules: ['A11Y_DIALOG_FOCUS', 'A11Y_DIALOG_ESCAPE', 'A11Y_DIALOG_LABEL'],
95
+ },
96
+
97
+ ai: {
98
+ compositionPattern: 'compound',
99
+ subComponents: ['Trigger', 'Content', 'Close', 'Header', 'Title', 'Description', 'Body', 'Footer'],
100
+ requiredChildren: ['Content'],
101
+ commonPatterns: [
102
+ '<Drawer><Drawer.Trigger><Button>Open</Button></Drawer.Trigger><Drawer.Content><Drawer.Close /><Drawer.Header><Drawer.Title>{title}</Drawer.Title></Drawer.Header><Drawer.Body>{content}</Drawer.Body><Drawer.Footer><Drawer.Close asChild><Button variant="secondary">Cancel</Button></Drawer.Close><Button>Save</Button></Drawer.Footer></Drawer.Content></Drawer>',
103
+ ],
104
+ },
105
+
106
+ variants: [
107
+ {
108
+ name: 'Default',
109
+ description: 'Right-side drawer with header, body, and footer',
110
+ render: () => (
111
+ <Drawer>
112
+ <Drawer.Trigger asChild>
113
+ <Button>Open Drawer</Button>
114
+ </Drawer.Trigger>
115
+ <Drawer.Content>
116
+ <Drawer.Close />
117
+ <Drawer.Header>
118
+ <Drawer.Title>Drawer Title</Drawer.Title>
119
+ <Drawer.Description>
120
+ A panel sliding in from the right.
121
+ </Drawer.Description>
122
+ </Drawer.Header>
123
+ <Drawer.Body>
124
+ <p>Drawer content goes here.</p>
125
+ </Drawer.Body>
126
+ <Drawer.Footer>
127
+ <Drawer.Close asChild>
128
+ <Button variant="secondary">Cancel</Button>
129
+ </Drawer.Close>
130
+ <Button variant="primary">Save</Button>
131
+ </Drawer.Footer>
132
+ </Drawer.Content>
133
+ </Drawer>
134
+ ),
135
+ },
136
+ {
137
+ name: 'Left Side',
138
+ description: 'Left-side drawer for navigation',
139
+ render: () => (
140
+ <Drawer>
141
+ <Drawer.Trigger asChild>
142
+ <Button variant="secondary">Open Left</Button>
143
+ </Drawer.Trigger>
144
+ <Drawer.Content side="left">
145
+ <Drawer.Close />
146
+ <Drawer.Header>
147
+ <Drawer.Title>Navigation</Drawer.Title>
148
+ </Drawer.Header>
149
+ <Drawer.Body>
150
+ <p>Left-side drawer for navigation or filters.</p>
151
+ </Drawer.Body>
152
+ </Drawer.Content>
153
+ </Drawer>
154
+ ),
155
+ },
156
+ {
157
+ name: 'Bottom Sheet',
158
+ description: 'Bottom drawer for mobile-style actions',
159
+ render: () => (
160
+ <Drawer>
161
+ <Drawer.Trigger asChild>
162
+ <Button variant="secondary">Open Bottom Sheet</Button>
163
+ </Drawer.Trigger>
164
+ <Drawer.Content side="bottom" size="sm">
165
+ <Drawer.Header>
166
+ <Drawer.Title>Actions</Drawer.Title>
167
+ </Drawer.Header>
168
+ <Drawer.Body>
169
+ <p>Bottom sheet for mobile-style actions.</p>
170
+ </Drawer.Body>
171
+ </Drawer.Content>
172
+ </Drawer>
173
+ ),
174
+ },
175
+ {
176
+ name: 'With Form',
177
+ description: 'Drawer with a form layout',
178
+ render: () => (
179
+ <Drawer>
180
+ <Drawer.Trigger asChild>
181
+ <Button>Edit Settings</Button>
182
+ </Drawer.Trigger>
183
+ <Drawer.Content size="md">
184
+ <Drawer.Close />
185
+ <Drawer.Header>
186
+ <Drawer.Title>Settings</Drawer.Title>
187
+ <Drawer.Description>Update your preferences.</Drawer.Description>
188
+ </Drawer.Header>
189
+ <Drawer.Body>
190
+ <Stack gap="md">
191
+ <Input label="Display Name" placeholder="Enter name" />
192
+ <Input label="Email" type="email" placeholder="you@example.com" />
193
+ </Stack>
194
+ </Drawer.Body>
195
+ <Drawer.Footer>
196
+ <Drawer.Close asChild>
197
+ <Button variant="secondary">Cancel</Button>
198
+ </Drawer.Close>
199
+ <Button variant="primary">Save Changes</Button>
200
+ </Drawer.Footer>
201
+ </Drawer.Content>
202
+ </Drawer>
203
+ ),
204
+ },
205
+ ],
206
+ });