@discourser/design-system 0.3.1 → 0.5.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/README.md +12 -4
- package/dist/styles.css +5126 -0
- package/guidelines/Guidelines.md +92 -41
- package/guidelines/components/accordion.md +732 -0
- package/guidelines/components/avatar.md +1015 -0
- package/guidelines/components/badge.md +728 -0
- package/guidelines/components/button.md +75 -40
- package/guidelines/components/card.md +84 -25
- package/guidelines/components/checkbox.md +671 -0
- package/guidelines/components/dialog.md +619 -31
- package/guidelines/components/drawer.md +1616 -0
- package/guidelines/components/heading.md +576 -0
- package/guidelines/components/icon-button.md +92 -37
- package/guidelines/components/input-addon.md +685 -0
- package/guidelines/components/input-group.md +830 -0
- package/guidelines/components/input.md +92 -37
- package/guidelines/components/popover.md +1271 -0
- package/guidelines/components/progress.md +836 -0
- package/guidelines/components/radio-group.md +852 -0
- package/guidelines/components/select.md +1662 -0
- package/guidelines/components/skeleton.md +802 -0
- package/guidelines/components/slider.md +911 -0
- package/guidelines/components/spinner.md +783 -0
- package/guidelines/components/switch.md +105 -38
- package/guidelines/components/tabs.md +1488 -0
- package/guidelines/components/textarea.md +495 -0
- package/guidelines/components/toast.md +784 -0
- package/guidelines/components/tooltip.md +912 -0
- package/guidelines/design-tokens/colors.md +309 -72
- package/guidelines/design-tokens/elevation.md +615 -45
- package/guidelines/design-tokens/spacing.md +654 -74
- package/guidelines/design-tokens/typography.md +432 -50
- package/guidelines/overview-components.md +60 -8
- package/guidelines/overview-imports.md +314 -0
- package/guidelines/overview-patterns.md +3852 -0
- package/package.json +4 -2
|
@@ -0,0 +1,1271 @@
|
|
|
1
|
+
# Popover
|
|
2
|
+
|
|
3
|
+
**Purpose:** A floating panel that appears near a trigger element to display contextual content, menus, forms, or additional information. Built on Ark UI's Popover primitive with intelligent positioning and smooth animations.
|
|
4
|
+
|
|
5
|
+
## When to Use This Component
|
|
6
|
+
|
|
7
|
+
Use Popover when you need to **display rich contextual content or interactive elements near a trigger** without navigating away from the current page.
|
|
8
|
+
|
|
9
|
+
### Decision Tree
|
|
10
|
+
|
|
11
|
+
| Scenario | Use Popover? | Alternative | Reasoning |
|
|
12
|
+
| ------------------------------------------ | ------------ | ----------- | -------------------------------------------------- |
|
|
13
|
+
| Contextual menus with actions | ✅ Yes | - | Popover shows rich content near the trigger |
|
|
14
|
+
| Forms or color pickers attached to element | ✅ Yes | - | Perfect for inline editing without page navigation |
|
|
15
|
+
| User profile preview on hover/click | ✅ Yes | - | Shows detailed info without full page load |
|
|
16
|
+
| Simple text hints or labels | ❌ No | Tooltip | Tooltip is lighter and better for brief help text |
|
|
17
|
+
| Critical actions requiring focus | ❌ No | Dialog | Dialog centers attention and is modal |
|
|
18
|
+
| Navigation menus (mobile) | ❌ No | Drawer | Drawer slides from edge, better for mobile menus |
|
|
19
|
+
|
|
20
|
+
### Component Comparison
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
// ✅ Popover - Rich contextual menu with form
|
|
24
|
+
<Popover.Root>
|
|
25
|
+
<Popover.Trigger asChild>
|
|
26
|
+
<Button>Add Comment</Button>
|
|
27
|
+
</Popover.Trigger>
|
|
28
|
+
<Popover.Positioner>
|
|
29
|
+
<Popover.Content>
|
|
30
|
+
<Popover.Title>New Comment</Popover.Title>
|
|
31
|
+
<Popover.Description>Add your thoughts below</Popover.Description>
|
|
32
|
+
<Textarea placeholder="Type your comment..." />
|
|
33
|
+
<Button>Submit</Button>
|
|
34
|
+
</Popover.Content>
|
|
35
|
+
</Popover.Positioner>
|
|
36
|
+
</Popover.Root>
|
|
37
|
+
|
|
38
|
+
// ❌ Don't use Popover for simple hints - Use Tooltip
|
|
39
|
+
<Popover.Root>
|
|
40
|
+
<Popover.Trigger asChild>
|
|
41
|
+
<IconButton><InfoIcon /></IconButton>
|
|
42
|
+
</Popover.Trigger>
|
|
43
|
+
<Popover.Positioner>
|
|
44
|
+
<Popover.Content>
|
|
45
|
+
This is a simple hint
|
|
46
|
+
</Popover.Content>
|
|
47
|
+
</Popover.Positioner>
|
|
48
|
+
</Popover.Root>
|
|
49
|
+
|
|
50
|
+
// ✅ Better: Use Tooltip for brief hints
|
|
51
|
+
<Tooltip content="This is a simple hint">
|
|
52
|
+
<IconButton><InfoIcon /></IconButton>
|
|
53
|
+
</Tooltip>
|
|
54
|
+
|
|
55
|
+
// ❌ Don't use Popover for critical modals - Use Dialog
|
|
56
|
+
<Popover.Root>
|
|
57
|
+
<Popover.Content>
|
|
58
|
+
<Popover.Title>Delete Account?</Popover.Title>
|
|
59
|
+
<Button>Confirm Delete</Button>
|
|
60
|
+
</Popover.Content>
|
|
61
|
+
</Popover.Root>
|
|
62
|
+
|
|
63
|
+
// ✅ Better: Use Dialog for important confirmations
|
|
64
|
+
<Dialog.Root>
|
|
65
|
+
<Dialog.Content>
|
|
66
|
+
<Dialog.Title>Delete Account?</Dialog.Title>
|
|
67
|
+
<Dialog.Description>This cannot be undone.</Dialog.Description>
|
|
68
|
+
<Dialog.Footer>
|
|
69
|
+
<Button variant="outlined">Cancel</Button>
|
|
70
|
+
<Button colorPalette="error">Delete</Button>
|
|
71
|
+
</Dialog.Footer>
|
|
72
|
+
</Dialog.Content>
|
|
73
|
+
</Dialog.Root>
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Import
|
|
77
|
+
|
|
78
|
+
```typescript
|
|
79
|
+
import { Popover } from '@discourser/design-system';
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Component Structure
|
|
83
|
+
|
|
84
|
+
Popover is a compound component built using Ark UI's Popover primitive. It consists of several parts that work together:
|
|
85
|
+
|
|
86
|
+
### Core Components
|
|
87
|
+
|
|
88
|
+
| Component | Purpose | Required |
|
|
89
|
+
| ---------------------- | --------------------------------------- | ----------- |
|
|
90
|
+
| `Popover.Root` | Main container and state manager | Yes |
|
|
91
|
+
| `Popover.Trigger` | Element that opens the popover | Yes |
|
|
92
|
+
| `Popover.Positioner` | Positions popover relative to trigger | Yes |
|
|
93
|
+
| `Popover.Content` | Main popover panel | Yes |
|
|
94
|
+
| `Popover.Title` | Popover heading (for accessibility) | Recommended |
|
|
95
|
+
| `Popover.Description` | Popover description (for accessibility) | Optional |
|
|
96
|
+
| `Popover.CloseTrigger` | Button to close popover | Optional |
|
|
97
|
+
|
|
98
|
+
### Layout Components
|
|
99
|
+
|
|
100
|
+
| Component | Purpose | Usage |
|
|
101
|
+
| ---------------- | -------------------------------------- | ----------- |
|
|
102
|
+
| `Popover.Header` | Top section for title and close button | Optional |
|
|
103
|
+
| `Popover.Body` | Main scrollable content area | Recommended |
|
|
104
|
+
| `Popover.Footer` | Bottom section for actions | Optional |
|
|
105
|
+
|
|
106
|
+
### Positioning Components
|
|
107
|
+
|
|
108
|
+
| Component | Purpose | Usage |
|
|
109
|
+
| ------------------- | --------------------------------------------- | ------------------------ |
|
|
110
|
+
| `Popover.Anchor` | Alternative anchor point (instead of trigger) | Optional |
|
|
111
|
+
| `Popover.Arrow` | Visual arrow pointing to trigger | Optional |
|
|
112
|
+
| `Popover.ArrowTip` | Arrow border styling | Auto-included with Arrow |
|
|
113
|
+
| `Popover.Indicator` | Visual indicator on trigger | Optional |
|
|
114
|
+
|
|
115
|
+
### Context
|
|
116
|
+
|
|
117
|
+
| Component | Purpose |
|
|
118
|
+
| ----------------- | ------------------------------------- |
|
|
119
|
+
| `Popover.Context` | Access popover state programmatically |
|
|
120
|
+
|
|
121
|
+
## Variants
|
|
122
|
+
|
|
123
|
+
Popover uses intelligent positioning based on available space. Unlike Drawer, it doesn't have visual variants but relies on positioning logic.
|
|
124
|
+
|
|
125
|
+
### Default Behavior
|
|
126
|
+
|
|
127
|
+
- **Width**: `sm` (384px/24rem) by default
|
|
128
|
+
- **Positioning**: Auto-adjusts based on viewport space
|
|
129
|
+
- **Animation**: Scale + fade (fast duration)
|
|
130
|
+
- **Arrow**: Optional, points to trigger element
|
|
131
|
+
- **Max Height**: Constrained by available viewport height
|
|
132
|
+
|
|
133
|
+
## Props
|
|
134
|
+
|
|
135
|
+
### Root Props
|
|
136
|
+
|
|
137
|
+
| Prop | Type | Default | Description |
|
|
138
|
+
| ------------------------ | -------------------------------------- | -------------- | --------------------------------------------- |
|
|
139
|
+
| `open` | `boolean` | - | Controlled open state |
|
|
140
|
+
| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) |
|
|
141
|
+
| `onOpenChange` | `(details: { open: boolean }) => void` | - | Callback when open state changes |
|
|
142
|
+
| `closeOnInteractOutside` | `boolean` | `true` | Close when clicking outside |
|
|
143
|
+
| `closeOnEscapeKeyDown` | `boolean` | `true` | Close when pressing Escape |
|
|
144
|
+
| `autoFocus` | `boolean` | `true` | Auto-focus first element when opened |
|
|
145
|
+
| `modal` | `boolean` | `false` | Whether popover is modal (blocks interaction) |
|
|
146
|
+
| `portalled` | `boolean` | `true` | Render in portal (outside DOM hierarchy) |
|
|
147
|
+
| `positioning` | `PositioningOptions` | - | Custom positioning configuration |
|
|
148
|
+
| `unmountOnExit` | `boolean` | `true` | Remove from DOM when closed |
|
|
149
|
+
| `lazyMount` | `boolean` | `true` | Mount content only when first opened |
|
|
150
|
+
| `id` | `string` | auto-generated | Unique ID for accessibility |
|
|
151
|
+
|
|
152
|
+
### Positioning Options
|
|
153
|
+
|
|
154
|
+
```typescript
|
|
155
|
+
interface PositioningOptions {
|
|
156
|
+
placement?:
|
|
157
|
+
| 'top'
|
|
158
|
+
| 'top-start'
|
|
159
|
+
| 'top-end'
|
|
160
|
+
| 'bottom'
|
|
161
|
+
| 'bottom-start'
|
|
162
|
+
| 'bottom-end'
|
|
163
|
+
| 'left'
|
|
164
|
+
| 'left-start'
|
|
165
|
+
| 'left-end'
|
|
166
|
+
| 'right'
|
|
167
|
+
| 'right-start'
|
|
168
|
+
| 'right-end';
|
|
169
|
+
offset?: { mainAxis?: number; crossAxis?: number };
|
|
170
|
+
gutter?: number;
|
|
171
|
+
flip?: boolean;
|
|
172
|
+
slide?: boolean;
|
|
173
|
+
overlap?: boolean;
|
|
174
|
+
sameWidth?: boolean;
|
|
175
|
+
fitViewport?: boolean;
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Content Props
|
|
180
|
+
|
|
181
|
+
All compound components accept standard HTML attributes plus styling props from Panda CSS.
|
|
182
|
+
|
|
183
|
+
## Examples
|
|
184
|
+
|
|
185
|
+
### Basic Usage
|
|
186
|
+
|
|
187
|
+
```typescript
|
|
188
|
+
import { Popover, Button } from '@discourser/design-system';
|
|
189
|
+
|
|
190
|
+
function BasicPopover() {
|
|
191
|
+
return (
|
|
192
|
+
<Popover.Root>
|
|
193
|
+
<Popover.Trigger asChild>
|
|
194
|
+
<Button variant="outlined">Open Popover</Button>
|
|
195
|
+
</Popover.Trigger>
|
|
196
|
+
|
|
197
|
+
<Popover.Positioner>
|
|
198
|
+
<Popover.Content>
|
|
199
|
+
<Popover.Arrow>
|
|
200
|
+
<Popover.ArrowTip />
|
|
201
|
+
</Popover.Arrow>
|
|
202
|
+
|
|
203
|
+
<Popover.Header>
|
|
204
|
+
<Popover.Title>Popover Title</Popover.Title>
|
|
205
|
+
</Popover.Header>
|
|
206
|
+
|
|
207
|
+
<Popover.Body>
|
|
208
|
+
<p>This is the popover content. It can contain any elements.</p>
|
|
209
|
+
</Popover.Body>
|
|
210
|
+
</Popover.Content>
|
|
211
|
+
</Popover.Positioner>
|
|
212
|
+
</Popover.Root>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### With Close Button
|
|
218
|
+
|
|
219
|
+
```typescript
|
|
220
|
+
import { Popover, Button, IconButton } from '@discourser/design-system';
|
|
221
|
+
import { XIcon } from 'your-icon-library';
|
|
222
|
+
|
|
223
|
+
function PopoverWithClose() {
|
|
224
|
+
return (
|
|
225
|
+
<Popover.Root>
|
|
226
|
+
<Popover.Trigger asChild>
|
|
227
|
+
<Button>Info</Button>
|
|
228
|
+
</Popover.Trigger>
|
|
229
|
+
|
|
230
|
+
<Popover.Positioner>
|
|
231
|
+
<Popover.Content>
|
|
232
|
+
<Popover.Arrow />
|
|
233
|
+
|
|
234
|
+
<Popover.Header>
|
|
235
|
+
<Popover.Title>Information</Popover.Title>
|
|
236
|
+
<Popover.Description>
|
|
237
|
+
Additional details about this feature
|
|
238
|
+
</Popover.Description>
|
|
239
|
+
<Popover.CloseTrigger asChild>
|
|
240
|
+
<IconButton aria-label="Close" variant="text" size="sm">
|
|
241
|
+
<XIcon />
|
|
242
|
+
</IconButton>
|
|
243
|
+
</Popover.CloseTrigger>
|
|
244
|
+
</Popover.Header>
|
|
245
|
+
|
|
246
|
+
<Popover.Body>
|
|
247
|
+
<p>Detailed explanation goes here...</p>
|
|
248
|
+
</Popover.Body>
|
|
249
|
+
|
|
250
|
+
<Popover.Footer>
|
|
251
|
+
<Button size="sm" variant="text">Learn More</Button>
|
|
252
|
+
</Popover.Footer>
|
|
253
|
+
</Popover.Content>
|
|
254
|
+
</Popover.Positioner>
|
|
255
|
+
</Popover.Root>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### Controlled Popover
|
|
261
|
+
|
|
262
|
+
```typescript
|
|
263
|
+
import { useState } from 'react';
|
|
264
|
+
import { Popover, Button } from '@discourser/design-system';
|
|
265
|
+
|
|
266
|
+
function ControlledPopover() {
|
|
267
|
+
const [open, setOpen] = useState(false);
|
|
268
|
+
|
|
269
|
+
const handleConfirm = () => {
|
|
270
|
+
console.log('Confirmed!');
|
|
271
|
+
setOpen(false);
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
return (
|
|
275
|
+
<>
|
|
276
|
+
<Button onClick={() => setOpen(true)}>Show Options</Button>
|
|
277
|
+
|
|
278
|
+
<Popover.Root open={open} onOpenChange={(details) => setOpen(details.open)}>
|
|
279
|
+
<Popover.Positioner>
|
|
280
|
+
<Popover.Content>
|
|
281
|
+
<Popover.Arrow />
|
|
282
|
+
|
|
283
|
+
<Popover.Header>
|
|
284
|
+
<Popover.Title>Choose Action</Popover.Title>
|
|
285
|
+
</Popover.Header>
|
|
286
|
+
|
|
287
|
+
<Popover.Body>
|
|
288
|
+
<p>Select an option below:</p>
|
|
289
|
+
</Popover.Body>
|
|
290
|
+
|
|
291
|
+
<Popover.Footer>
|
|
292
|
+
<Button variant="outlined" size="sm" onClick={() => setOpen(false)}>
|
|
293
|
+
Cancel
|
|
294
|
+
</Button>
|
|
295
|
+
<Button size="sm" onClick={handleConfirm}>
|
|
296
|
+
Confirm
|
|
297
|
+
</Button>
|
|
298
|
+
</Popover.Footer>
|
|
299
|
+
</Popover.Content>
|
|
300
|
+
</Popover.Positioner>
|
|
301
|
+
</Popover.Root>
|
|
302
|
+
</>
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
### Custom Positioning
|
|
308
|
+
|
|
309
|
+
```typescript
|
|
310
|
+
// Position at top
|
|
311
|
+
<Popover.Root positioning={{ placement: 'top' }}>
|
|
312
|
+
<Popover.Trigger asChild>
|
|
313
|
+
<Button>Top Popover</Button>
|
|
314
|
+
</Popover.Trigger>
|
|
315
|
+
<Popover.Positioner>
|
|
316
|
+
<Popover.Content>
|
|
317
|
+
<Popover.Arrow />
|
|
318
|
+
<Popover.Body>Content appears above trigger</Popover.Body>
|
|
319
|
+
</Popover.Content>
|
|
320
|
+
</Popover.Positioner>
|
|
321
|
+
</Popover.Root>
|
|
322
|
+
|
|
323
|
+
// Position at bottom-start (left-aligned)
|
|
324
|
+
<Popover.Root positioning={{ placement: 'bottom-start' }}>
|
|
325
|
+
<Popover.Trigger asChild>
|
|
326
|
+
<Button>Menu</Button>
|
|
327
|
+
</Popover.Trigger>
|
|
328
|
+
<Popover.Positioner>
|
|
329
|
+
<Popover.Content>
|
|
330
|
+
<Popover.Body>Aligned to left edge of trigger</Popover.Body>
|
|
331
|
+
</Popover.Content>
|
|
332
|
+
</Popover.Positioner>
|
|
333
|
+
</Popover.Root>
|
|
334
|
+
|
|
335
|
+
// Custom offset and gutter
|
|
336
|
+
<Popover.Root
|
|
337
|
+
positioning={{
|
|
338
|
+
placement: 'right',
|
|
339
|
+
gutter: 16, // Space from trigger
|
|
340
|
+
offset: { mainAxis: 10, crossAxis: 0 },
|
|
341
|
+
}}
|
|
342
|
+
>
|
|
343
|
+
<Popover.Trigger asChild>
|
|
344
|
+
<Button>Right Popover</Button>
|
|
345
|
+
</Popover.Trigger>
|
|
346
|
+
<Popover.Positioner>
|
|
347
|
+
<Popover.Content>
|
|
348
|
+
<Popover.Body>Custom spacing from trigger</Popover.Body>
|
|
349
|
+
</Popover.Content>
|
|
350
|
+
</Popover.Positioner>
|
|
351
|
+
</Popover.Root>
|
|
352
|
+
|
|
353
|
+
// Same width as trigger
|
|
354
|
+
<Popover.Root positioning={{ sameWidth: true }}>
|
|
355
|
+
<Popover.Trigger asChild>
|
|
356
|
+
<Button>Select Option</Button>
|
|
357
|
+
</Popover.Trigger>
|
|
358
|
+
<Popover.Positioner>
|
|
359
|
+
<Popover.Content>
|
|
360
|
+
<Popover.Body>Width matches trigger button</Popover.Body>
|
|
361
|
+
</Popover.Content>
|
|
362
|
+
</Popover.Positioner>
|
|
363
|
+
</Popover.Root>
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
### Form in Popover
|
|
367
|
+
|
|
368
|
+
```typescript
|
|
369
|
+
import { Popover, Button, Input } from '@discourser/design-system';
|
|
370
|
+
import { useState } from 'react';
|
|
371
|
+
|
|
372
|
+
function FormPopover() {
|
|
373
|
+
const [email, setEmail] = useState('');
|
|
374
|
+
const [submitted, setSubmitted] = useState(false);
|
|
375
|
+
|
|
376
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
377
|
+
e.preventDefault();
|
|
378
|
+
console.log('Email:', email);
|
|
379
|
+
setSubmitted(true);
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
return (
|
|
383
|
+
<Popover.Root>
|
|
384
|
+
<Popover.Trigger asChild>
|
|
385
|
+
<Button>Subscribe</Button>
|
|
386
|
+
</Popover.Trigger>
|
|
387
|
+
|
|
388
|
+
<Popover.Positioner>
|
|
389
|
+
<Popover.Content>
|
|
390
|
+
<Popover.Arrow />
|
|
391
|
+
|
|
392
|
+
{!submitted ? (
|
|
393
|
+
<form onSubmit={handleSubmit}>
|
|
394
|
+
<Popover.Header>
|
|
395
|
+
<Popover.Title>Newsletter Signup</Popover.Title>
|
|
396
|
+
<Popover.Description>
|
|
397
|
+
Get weekly updates delivered to your inbox
|
|
398
|
+
</Popover.Description>
|
|
399
|
+
</Popover.Header>
|
|
400
|
+
|
|
401
|
+
<Popover.Body>
|
|
402
|
+
<Input
|
|
403
|
+
type="email"
|
|
404
|
+
label="Email Address"
|
|
405
|
+
value={email}
|
|
406
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
407
|
+
required
|
|
408
|
+
/>
|
|
409
|
+
</Popover.Body>
|
|
410
|
+
|
|
411
|
+
<Popover.Footer>
|
|
412
|
+
<Popover.CloseTrigger asChild>
|
|
413
|
+
<Button variant="outlined" size="sm" type="button">
|
|
414
|
+
Cancel
|
|
415
|
+
</Button>
|
|
416
|
+
</Popover.CloseTrigger>
|
|
417
|
+
<Button size="sm" type="submit">
|
|
418
|
+
Subscribe
|
|
419
|
+
</Button>
|
|
420
|
+
</Popover.Footer>
|
|
421
|
+
</form>
|
|
422
|
+
) : (
|
|
423
|
+
<Popover.Body>
|
|
424
|
+
<p>Thank you for subscribing!</p>
|
|
425
|
+
</Popover.Body>
|
|
426
|
+
)}
|
|
427
|
+
</Popover.Content>
|
|
428
|
+
</Popover.Positioner>
|
|
429
|
+
</Popover.Root>
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
### Action Menu Popover
|
|
435
|
+
|
|
436
|
+
```typescript
|
|
437
|
+
import { Popover, Button } from '@discourser/design-system';
|
|
438
|
+
import { MoreVerticalIcon, EditIcon, DeleteIcon, ShareIcon } from 'your-icon-library';
|
|
439
|
+
|
|
440
|
+
function ActionMenuPopover() {
|
|
441
|
+
const actions = [
|
|
442
|
+
{ icon: <EditIcon />, label: 'Edit', onClick: () => console.log('Edit') },
|
|
443
|
+
{ icon: <ShareIcon />, label: 'Share', onClick: () => console.log('Share') },
|
|
444
|
+
{ icon: <DeleteIcon />, label: 'Delete', onClick: () => console.log('Delete') },
|
|
445
|
+
];
|
|
446
|
+
|
|
447
|
+
return (
|
|
448
|
+
<Popover.Root>
|
|
449
|
+
<Popover.Trigger asChild>
|
|
450
|
+
<Button variant="text" size="sm">
|
|
451
|
+
<MoreVerticalIcon />
|
|
452
|
+
</Button>
|
|
453
|
+
</Popover.Trigger>
|
|
454
|
+
|
|
455
|
+
<Popover.Positioner>
|
|
456
|
+
<Popover.Content>
|
|
457
|
+
<Popover.Body
|
|
458
|
+
className={css({
|
|
459
|
+
display: 'flex',
|
|
460
|
+
flexDirection: 'column',
|
|
461
|
+
gap: '1',
|
|
462
|
+
p: '2',
|
|
463
|
+
})}
|
|
464
|
+
>
|
|
465
|
+
{actions.map((action) => (
|
|
466
|
+
<Popover.CloseTrigger key={action.label} asChild>
|
|
467
|
+
<button
|
|
468
|
+
onClick={action.onClick}
|
|
469
|
+
className={css({
|
|
470
|
+
display: 'flex',
|
|
471
|
+
alignItems: 'center',
|
|
472
|
+
gap: '2',
|
|
473
|
+
p: '2',
|
|
474
|
+
borderRadius: 'md',
|
|
475
|
+
cursor: 'pointer',
|
|
476
|
+
bg: 'transparent',
|
|
477
|
+
color: 'fg.default',
|
|
478
|
+
textAlign: 'left',
|
|
479
|
+
width: '100%',
|
|
480
|
+
border: 'none',
|
|
481
|
+
_hover: { bg: 'gray.a3' },
|
|
482
|
+
})}
|
|
483
|
+
>
|
|
484
|
+
{action.icon}
|
|
485
|
+
<span>{action.label}</span>
|
|
486
|
+
</button>
|
|
487
|
+
</Popover.CloseTrigger>
|
|
488
|
+
))}
|
|
489
|
+
</Popover.Body>
|
|
490
|
+
</Popover.Content>
|
|
491
|
+
</Popover.Positioner>
|
|
492
|
+
</Popover.Root>
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
### User Profile Popover
|
|
498
|
+
|
|
499
|
+
```typescript
|
|
500
|
+
import { Popover, Button, Avatar } from '@discourser/design-system';
|
|
501
|
+
|
|
502
|
+
function UserProfilePopover({ user }) {
|
|
503
|
+
return (
|
|
504
|
+
<Popover.Root>
|
|
505
|
+
<Popover.Trigger asChild>
|
|
506
|
+
<button className={css({ cursor: 'pointer', border: 'none', bg: 'transparent' })}>
|
|
507
|
+
<Avatar src={user.avatar} name={user.name} />
|
|
508
|
+
</button>
|
|
509
|
+
</Popover.Trigger>
|
|
510
|
+
|
|
511
|
+
<Popover.Positioner>
|
|
512
|
+
<Popover.Content>
|
|
513
|
+
<Popover.Arrow />
|
|
514
|
+
|
|
515
|
+
<Popover.Header>
|
|
516
|
+
<div className={css({ display: 'flex', alignItems: 'center', gap: '3' })}>
|
|
517
|
+
<Avatar src={user.avatar} name={user.name} size="lg" />
|
|
518
|
+
<div>
|
|
519
|
+
<Popover.Title>{user.name}</Popover.Title>
|
|
520
|
+
<Popover.Description>{user.email}</Popover.Description>
|
|
521
|
+
</div>
|
|
522
|
+
</div>
|
|
523
|
+
</Popover.Header>
|
|
524
|
+
|
|
525
|
+
<Popover.Body>
|
|
526
|
+
<div className={css({ display: 'flex', flexDirection: 'column', gap: '2' })}>
|
|
527
|
+
<a href="/profile">View Profile</a>
|
|
528
|
+
<a href="/settings">Settings</a>
|
|
529
|
+
<a href="/help">Help & Support</a>
|
|
530
|
+
</div>
|
|
531
|
+
</Popover.Body>
|
|
532
|
+
|
|
533
|
+
<Popover.Footer>
|
|
534
|
+
<Button size="sm" variant="outlined" fullWidth>
|
|
535
|
+
Sign Out
|
|
536
|
+
</Button>
|
|
537
|
+
</Popover.Footer>
|
|
538
|
+
</Popover.Content>
|
|
539
|
+
</Popover.Positioner>
|
|
540
|
+
</Popover.Root>
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
### Using Anchor (Separate Trigger and Anchor)
|
|
546
|
+
|
|
547
|
+
```typescript
|
|
548
|
+
import { Popover, Button } from '@discourser/design-system';
|
|
549
|
+
import { useRef } from 'react';
|
|
550
|
+
|
|
551
|
+
function AnchorPopover() {
|
|
552
|
+
const anchorRef = useRef<HTMLDivElement>(null);
|
|
553
|
+
|
|
554
|
+
return (
|
|
555
|
+
<div>
|
|
556
|
+
<div ref={anchorRef} className={css({ p: '4', bg: 'gray.a3', borderRadius: 'md' })}>
|
|
557
|
+
This div is the anchor point
|
|
558
|
+
</div>
|
|
559
|
+
|
|
560
|
+
<Popover.Root>
|
|
561
|
+
<Popover.Anchor ref={anchorRef} />
|
|
562
|
+
|
|
563
|
+
<Popover.Trigger asChild>
|
|
564
|
+
<Button>Show Popover</Button>
|
|
565
|
+
</Popover.Trigger>
|
|
566
|
+
|
|
567
|
+
<Popover.Positioner>
|
|
568
|
+
<Popover.Content>
|
|
569
|
+
<Popover.Arrow />
|
|
570
|
+
<Popover.Body>
|
|
571
|
+
Popover appears near the anchor div, not the button
|
|
572
|
+
</Popover.Body>
|
|
573
|
+
</Popover.Content>
|
|
574
|
+
</Popover.Positioner>
|
|
575
|
+
</Popover.Root>
|
|
576
|
+
</div>
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
### Using Context
|
|
582
|
+
|
|
583
|
+
```typescript
|
|
584
|
+
import { Popover, Button } from '@discourser/design-system';
|
|
585
|
+
|
|
586
|
+
function PopoverWithContext() {
|
|
587
|
+
return (
|
|
588
|
+
<Popover.Root>
|
|
589
|
+
<Popover.Trigger asChild>
|
|
590
|
+
<Button>Open</Button>
|
|
591
|
+
</Popover.Trigger>
|
|
592
|
+
|
|
593
|
+
<Popover.Positioner>
|
|
594
|
+
<Popover.Content>
|
|
595
|
+
<Popover.Arrow />
|
|
596
|
+
|
|
597
|
+
<Popover.Body>
|
|
598
|
+
<Popover.Context>
|
|
599
|
+
{(context) => (
|
|
600
|
+
<div>
|
|
601
|
+
<p>Popover is {context.open ? 'open' : 'closed'}</p>
|
|
602
|
+
<Button size="sm" onClick={() => context.setOpen(false)}>
|
|
603
|
+
Close Programmatically
|
|
604
|
+
</Button>
|
|
605
|
+
</div>
|
|
606
|
+
)}
|
|
607
|
+
</Popover.Context>
|
|
608
|
+
</Popover.Body>
|
|
609
|
+
</Popover.Content>
|
|
610
|
+
</Popover.Positioner>
|
|
611
|
+
</Popover.Root>
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
### Hover Trigger Popover
|
|
617
|
+
|
|
618
|
+
```typescript
|
|
619
|
+
function HoverPopover() {
|
|
620
|
+
const [open, setOpen] = useState(false);
|
|
621
|
+
let timeoutId: NodeJS.Timeout;
|
|
622
|
+
|
|
623
|
+
const handleMouseEnter = () => {
|
|
624
|
+
clearTimeout(timeoutId);
|
|
625
|
+
setOpen(true);
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
const handleMouseLeave = () => {
|
|
629
|
+
timeoutId = setTimeout(() => setOpen(false), 300);
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
return (
|
|
633
|
+
<Popover.Root open={open} onOpenChange={(e) => setOpen(e.open)}>
|
|
634
|
+
<Popover.Trigger
|
|
635
|
+
asChild
|
|
636
|
+
onMouseEnter={handleMouseEnter}
|
|
637
|
+
onMouseLeave={handleMouseLeave}
|
|
638
|
+
>
|
|
639
|
+
<Button variant="text">Hover Me</Button>
|
|
640
|
+
</Popover.Trigger>
|
|
641
|
+
|
|
642
|
+
<Popover.Positioner>
|
|
643
|
+
<Popover.Content
|
|
644
|
+
onMouseEnter={handleMouseEnter}
|
|
645
|
+
onMouseLeave={handleMouseLeave}
|
|
646
|
+
>
|
|
647
|
+
<Popover.Arrow />
|
|
648
|
+
<Popover.Body>
|
|
649
|
+
<p>This popover appears on hover</p>
|
|
650
|
+
</Popover.Body>
|
|
651
|
+
</Popover.Content>
|
|
652
|
+
</Popover.Positioner>
|
|
653
|
+
</Popover.Root>
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
## Common Patterns
|
|
659
|
+
|
|
660
|
+
### Confirmation Popover
|
|
661
|
+
|
|
662
|
+
```typescript
|
|
663
|
+
function ConfirmationPopover({ onConfirm, children }) {
|
|
664
|
+
const [open, setOpen] = useState(false);
|
|
665
|
+
|
|
666
|
+
const handleConfirm = () => {
|
|
667
|
+
onConfirm();
|
|
668
|
+
setOpen(false);
|
|
669
|
+
};
|
|
670
|
+
|
|
671
|
+
return (
|
|
672
|
+
<Popover.Root open={open} onOpenChange={(e) => setOpen(e.open)}>
|
|
673
|
+
<Popover.Trigger asChild>
|
|
674
|
+
{children}
|
|
675
|
+
</Popover.Trigger>
|
|
676
|
+
|
|
677
|
+
<Popover.Positioner>
|
|
678
|
+
<Popover.Content>
|
|
679
|
+
<Popover.Arrow />
|
|
680
|
+
|
|
681
|
+
<Popover.Header>
|
|
682
|
+
<Popover.Title>Are you sure?</Popover.Title>
|
|
683
|
+
<Popover.Description>
|
|
684
|
+
This action cannot be undone
|
|
685
|
+
</Popover.Description>
|
|
686
|
+
</Popover.Header>
|
|
687
|
+
|
|
688
|
+
<Popover.Footer>
|
|
689
|
+
<Button variant="outlined" size="sm" onClick={() => setOpen(false)}>
|
|
690
|
+
Cancel
|
|
691
|
+
</Button>
|
|
692
|
+
<Button size="sm" onClick={handleConfirm}>
|
|
693
|
+
Confirm
|
|
694
|
+
</Button>
|
|
695
|
+
</Popover.Footer>
|
|
696
|
+
</Popover.Content>
|
|
697
|
+
</Popover.Positioner>
|
|
698
|
+
</Popover.Root>
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// Usage
|
|
703
|
+
<ConfirmationPopover onConfirm={() => console.log('Deleted')}>
|
|
704
|
+
<Button variant="tonal">Delete</Button>
|
|
705
|
+
</ConfirmationPopover>
|
|
706
|
+
```
|
|
707
|
+
|
|
708
|
+
### Color Picker Popover
|
|
709
|
+
|
|
710
|
+
```typescript
|
|
711
|
+
function ColorPickerPopover() {
|
|
712
|
+
const [color, setColor] = useState('#3b82f6');
|
|
713
|
+
|
|
714
|
+
const colors = [
|
|
715
|
+
'#ef4444', '#f59e0b', '#10b981', '#3b82f6',
|
|
716
|
+
'#8b5cf6', '#ec4899', '#64748b', '#000000',
|
|
717
|
+
];
|
|
718
|
+
|
|
719
|
+
return (
|
|
720
|
+
<Popover.Root>
|
|
721
|
+
<Popover.Trigger asChild>
|
|
722
|
+
<button
|
|
723
|
+
className={css({
|
|
724
|
+
width: '40px',
|
|
725
|
+
height: '40px',
|
|
726
|
+
borderRadius: 'md',
|
|
727
|
+
border: '2px solid',
|
|
728
|
+
borderColor: 'gray.a7',
|
|
729
|
+
cursor: 'pointer',
|
|
730
|
+
})}
|
|
731
|
+
style={{ backgroundColor: color }}
|
|
732
|
+
aria-label="Choose color"
|
|
733
|
+
/>
|
|
734
|
+
</Popover.Trigger>
|
|
735
|
+
|
|
736
|
+
<Popover.Positioner>
|
|
737
|
+
<Popover.Content>
|
|
738
|
+
<Popover.Arrow />
|
|
739
|
+
|
|
740
|
+
<Popover.Header>
|
|
741
|
+
<Popover.Title>Choose Color</Popover.Title>
|
|
742
|
+
</Popover.Header>
|
|
743
|
+
|
|
744
|
+
<Popover.Body>
|
|
745
|
+
<div className={css({ display: 'grid', gridTemplateColumns: '4', gap: '2' })}>
|
|
746
|
+
{colors.map((c) => (
|
|
747
|
+
<Popover.CloseTrigger key={c} asChild>
|
|
748
|
+
<button
|
|
749
|
+
onClick={() => setColor(c)}
|
|
750
|
+
className={css({
|
|
751
|
+
width: '40px',
|
|
752
|
+
height: '40px',
|
|
753
|
+
borderRadius: 'md',
|
|
754
|
+
border: '2px solid',
|
|
755
|
+
borderColor: color === c ? 'gray.12' : 'transparent',
|
|
756
|
+
cursor: 'pointer',
|
|
757
|
+
})}
|
|
758
|
+
style={{ backgroundColor: c }}
|
|
759
|
+
aria-label={`Color ${c}`}
|
|
760
|
+
/>
|
|
761
|
+
</Popover.CloseTrigger>
|
|
762
|
+
))}
|
|
763
|
+
</div>
|
|
764
|
+
</Popover.Body>
|
|
765
|
+
</Popover.Content>
|
|
766
|
+
</Popover.Positioner>
|
|
767
|
+
</Popover.Root>
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
### Date Picker Popover
|
|
773
|
+
|
|
774
|
+
```typescript
|
|
775
|
+
function DatePickerPopover() {
|
|
776
|
+
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
|
777
|
+
|
|
778
|
+
return (
|
|
779
|
+
<Popover.Root>
|
|
780
|
+
<Popover.Trigger asChild>
|
|
781
|
+
<Button variant="outlined" leftIcon={<CalendarIcon />}>
|
|
782
|
+
{selectedDate ? selectedDate.toLocaleDateString() : 'Select Date'}
|
|
783
|
+
</Button>
|
|
784
|
+
</Popover.Trigger>
|
|
785
|
+
|
|
786
|
+
<Popover.Positioner>
|
|
787
|
+
<Popover.Content>
|
|
788
|
+
<Popover.Arrow />
|
|
789
|
+
|
|
790
|
+
<Popover.Header>
|
|
791
|
+
<Popover.Title>Select Date</Popover.Title>
|
|
792
|
+
</Popover.Header>
|
|
793
|
+
|
|
794
|
+
<Popover.Body>
|
|
795
|
+
{/* Insert your date picker component here */}
|
|
796
|
+
<DatePickerComponent
|
|
797
|
+
value={selectedDate}
|
|
798
|
+
onChange={(date) => {
|
|
799
|
+
setSelectedDate(date);
|
|
800
|
+
}}
|
|
801
|
+
/>
|
|
802
|
+
</Popover.Body>
|
|
803
|
+
</Popover.Content>
|
|
804
|
+
</Popover.Positioner>
|
|
805
|
+
</Popover.Root>
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
```
|
|
809
|
+
|
|
810
|
+
### Share Menu Popover
|
|
811
|
+
|
|
812
|
+
```typescript
|
|
813
|
+
function SharePopover({ url, title }) {
|
|
814
|
+
const shareOptions = [
|
|
815
|
+
{ name: 'Twitter', icon: <TwitterIcon />, action: () => window.open(`https://twitter.com/intent/tweet?url=${url}&text=${title}`) },
|
|
816
|
+
{ name: 'Facebook', icon: <FacebookIcon />, action: () => window.open(`https://www.facebook.com/sharer/sharer.php?u=${url}`) },
|
|
817
|
+
{ name: 'LinkedIn', icon: <LinkedInIcon />, action: () => window.open(`https://www.linkedin.com/sharing/share-offsite/?url=${url}`) },
|
|
818
|
+
{ name: 'Copy Link', icon: <LinkIcon />, action: () => navigator.clipboard.writeText(url) },
|
|
819
|
+
];
|
|
820
|
+
|
|
821
|
+
return (
|
|
822
|
+
<Popover.Root>
|
|
823
|
+
<Popover.Trigger asChild>
|
|
824
|
+
<Button variant="outlined" leftIcon={<ShareIcon />}>
|
|
825
|
+
Share
|
|
826
|
+
</Button>
|
|
827
|
+
</Popover.Trigger>
|
|
828
|
+
|
|
829
|
+
<Popover.Positioner>
|
|
830
|
+
<Popover.Content>
|
|
831
|
+
<Popover.Arrow />
|
|
832
|
+
|
|
833
|
+
<Popover.Header>
|
|
834
|
+
<Popover.Title>Share this page</Popover.Title>
|
|
835
|
+
</Popover.Header>
|
|
836
|
+
|
|
837
|
+
<Popover.Body>
|
|
838
|
+
<div className={css({ display: 'flex', flexDirection: 'column', gap: '1' })}>
|
|
839
|
+
{shareOptions.map((option) => (
|
|
840
|
+
<Popover.CloseTrigger key={option.name} asChild>
|
|
841
|
+
<button
|
|
842
|
+
onClick={option.action}
|
|
843
|
+
className={css({
|
|
844
|
+
display: 'flex',
|
|
845
|
+
alignItems: 'center',
|
|
846
|
+
gap: '3',
|
|
847
|
+
p: '2',
|
|
848
|
+
borderRadius: 'md',
|
|
849
|
+
bg: 'transparent',
|
|
850
|
+
border: 'none',
|
|
851
|
+
cursor: 'pointer',
|
|
852
|
+
width: '100%',
|
|
853
|
+
textAlign: 'left',
|
|
854
|
+
_hover: { bg: 'gray.a3' },
|
|
855
|
+
})}
|
|
856
|
+
>
|
|
857
|
+
{option.icon}
|
|
858
|
+
<span>{option.name}</span>
|
|
859
|
+
</button>
|
|
860
|
+
</Popover.CloseTrigger>
|
|
861
|
+
))}
|
|
862
|
+
</div>
|
|
863
|
+
</Popover.Body>
|
|
864
|
+
</Popover.Content>
|
|
865
|
+
</Popover.Positioner>
|
|
866
|
+
</Popover.Root>
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
```
|
|
870
|
+
|
|
871
|
+
## DO NOT
|
|
872
|
+
|
|
873
|
+
```typescript
|
|
874
|
+
// ❌ Don't forget Positioner wrapper
|
|
875
|
+
<Popover.Root>
|
|
876
|
+
<Popover.Trigger asChild>
|
|
877
|
+
<Button>Open</Button>
|
|
878
|
+
</Popover.Trigger>
|
|
879
|
+
<Popover.Content>...</Popover.Content> // Missing Positioner
|
|
880
|
+
</Popover.Root>
|
|
881
|
+
|
|
882
|
+
// ❌ Don't use for critical alerts (use Dialog instead)
|
|
883
|
+
<Popover.Root>
|
|
884
|
+
<Popover.Content>
|
|
885
|
+
<Popover.Title>Error: Data Lost!</Popover.Title>
|
|
886
|
+
<Popover.Body>Your data has been permanently deleted</Popover.Body>
|
|
887
|
+
</Popover.Content>
|
|
888
|
+
</Popover.Root>
|
|
889
|
+
|
|
890
|
+
// ❌ Don't nest popovers
|
|
891
|
+
<Popover.Root>
|
|
892
|
+
<Popover.Content>
|
|
893
|
+
<Popover.Root> // Don't nest
|
|
894
|
+
<Popover.Content>...</Popover.Content>
|
|
895
|
+
</Popover.Root>
|
|
896
|
+
</Popover.Content>
|
|
897
|
+
</Popover.Root>
|
|
898
|
+
|
|
899
|
+
// ❌ Don't use for long-form content (use Drawer or Dialog)
|
|
900
|
+
<Popover.Root>
|
|
901
|
+
<Popover.Content>
|
|
902
|
+
<Popover.Body>
|
|
903
|
+
{/* Multiple paragraphs of text */}
|
|
904
|
+
{/* Large forms */}
|
|
905
|
+
{/* Complex layouts */}
|
|
906
|
+
</Popover.Body>
|
|
907
|
+
</Popover.Content>
|
|
908
|
+
</Popover.Root>
|
|
909
|
+
|
|
910
|
+
// ❌ Don't use without proper trigger
|
|
911
|
+
<Popover.Root open={true}> // Always controlled without trigger
|
|
912
|
+
<Popover.Content>...</Popover.Content>
|
|
913
|
+
</Popover.Root>
|
|
914
|
+
|
|
915
|
+
// ❌ Don't omit Title when using complex content
|
|
916
|
+
<Popover.Content>
|
|
917
|
+
<Popover.Body>
|
|
918
|
+
<form>
|
|
919
|
+
{/* Complex form without title */}
|
|
920
|
+
</form>
|
|
921
|
+
</Popover.Body>
|
|
922
|
+
</Popover.Content>
|
|
923
|
+
|
|
924
|
+
// ❌ Don't use fixed widths (let content dictate or use positioning.sameWidth)
|
|
925
|
+
<Popover.Content style={{ width: '500px' }}>
|
|
926
|
+
...
|
|
927
|
+
</Popover.Content>
|
|
928
|
+
|
|
929
|
+
// ✅ Correct usage
|
|
930
|
+
<Popover.Root>
|
|
931
|
+
<Popover.Trigger asChild>
|
|
932
|
+
<Button>Quick Actions</Button>
|
|
933
|
+
</Popover.Trigger>
|
|
934
|
+
|
|
935
|
+
<Popover.Positioner>
|
|
936
|
+
<Popover.Content>
|
|
937
|
+
<Popover.Arrow />
|
|
938
|
+
|
|
939
|
+
<Popover.Header>
|
|
940
|
+
<Popover.Title>Quick Actions</Popover.Title>
|
|
941
|
+
</Popover.Header>
|
|
942
|
+
|
|
943
|
+
<Popover.Body>
|
|
944
|
+
<div className={css({ display: 'flex', flexDirection: 'column', gap: '2' })}>
|
|
945
|
+
<Button size="sm" variant="text">Action 1</Button>
|
|
946
|
+
<Button size="sm" variant="text">Action 2</Button>
|
|
947
|
+
</div>
|
|
948
|
+
</Popover.Body>
|
|
949
|
+
</Popover.Content>
|
|
950
|
+
</Popover.Positioner>
|
|
951
|
+
</Popover.Root>
|
|
952
|
+
```
|
|
953
|
+
|
|
954
|
+
## Accessibility
|
|
955
|
+
|
|
956
|
+
The Popover component follows WCAG 2.1 Level AA standards:
|
|
957
|
+
|
|
958
|
+
- **Focus Management**:
|
|
959
|
+
- Auto-focus first focusable element (configurable)
|
|
960
|
+
- Focus returns to trigger on close
|
|
961
|
+
- Focus trap optional (modal mode)
|
|
962
|
+
- **Keyboard Navigation**:
|
|
963
|
+
- `Escape` key closes popover
|
|
964
|
+
- `Tab` cycles through focusable elements
|
|
965
|
+
- Arrow keys for menu-style popovers
|
|
966
|
+
- **Screen Reader Support**:
|
|
967
|
+
- Announced as dialog or menu
|
|
968
|
+
- Title provides accessible name
|
|
969
|
+
- Description provides context
|
|
970
|
+
- **ARIA Attributes**:
|
|
971
|
+
- `role="dialog"` on Content
|
|
972
|
+
- `aria-haspopup="dialog"` on Trigger
|
|
973
|
+
- `aria-expanded` reflects open state
|
|
974
|
+
- `aria-labelledby` references Title
|
|
975
|
+
- `aria-describedby` references Description
|
|
976
|
+
- **Pointer Interaction**:
|
|
977
|
+
- Click outside closes by default
|
|
978
|
+
- Hover support (requires custom implementation)
|
|
979
|
+
|
|
980
|
+
### Accessibility Best Practices
|
|
981
|
+
|
|
982
|
+
```typescript
|
|
983
|
+
// ✅ Always provide Title for complex content
|
|
984
|
+
<Popover.Content>
|
|
985
|
+
<Popover.Header>
|
|
986
|
+
<Popover.Title>Filter Options</Popover.Title>
|
|
987
|
+
</Popover.Header>
|
|
988
|
+
<Popover.Body>
|
|
989
|
+
{/* Filter form */}
|
|
990
|
+
</Popover.Body>
|
|
991
|
+
</Popover.Content>
|
|
992
|
+
|
|
993
|
+
// ✅ Add Description for additional context
|
|
994
|
+
<Popover.Header>
|
|
995
|
+
<Popover.Title>Export Data</Popover.Title>
|
|
996
|
+
<Popover.Description>
|
|
997
|
+
Choose format and date range for export
|
|
998
|
+
</Popover.Description>
|
|
999
|
+
</Popover.Header>
|
|
1000
|
+
|
|
1001
|
+
// ✅ Label icon-only triggers
|
|
1002
|
+
<Popover.Trigger asChild>
|
|
1003
|
+
<IconButton aria-label="More options">
|
|
1004
|
+
<MoreIcon />
|
|
1005
|
+
</IconButton>
|
|
1006
|
+
</Popover.Trigger>
|
|
1007
|
+
|
|
1008
|
+
// ✅ Label close buttons
|
|
1009
|
+
<Popover.CloseTrigger asChild>
|
|
1010
|
+
<IconButton aria-label="Close popover">
|
|
1011
|
+
<XIcon />
|
|
1012
|
+
</IconButton>
|
|
1013
|
+
</Popover.CloseTrigger>
|
|
1014
|
+
|
|
1015
|
+
// ✅ Use proper roles for menu-style popovers
|
|
1016
|
+
<Popover.Body role="menu">
|
|
1017
|
+
<button role="menuitem">Action 1</button>
|
|
1018
|
+
<button role="menuitem">Action 2</button>
|
|
1019
|
+
</Popover.Body>
|
|
1020
|
+
|
|
1021
|
+
// ✅ Announce dynamic changes
|
|
1022
|
+
<Popover.Body>
|
|
1023
|
+
<form aria-live="polite">
|
|
1024
|
+
{/* Form with validation messages */}
|
|
1025
|
+
</form>
|
|
1026
|
+
</Popover.Body>
|
|
1027
|
+
```
|
|
1028
|
+
|
|
1029
|
+
## Usage Guidelines
|
|
1030
|
+
|
|
1031
|
+
### When to Use Popover
|
|
1032
|
+
|
|
1033
|
+
| Use Case | Why Popover |
|
|
1034
|
+
| ------------------ | -------------------------------------------- |
|
|
1035
|
+
| Contextual menus | Small actions related to trigger element |
|
|
1036
|
+
| Quick forms | Short inputs (1-3 fields) without navigation |
|
|
1037
|
+
| Additional info | Tooltips with interactive content |
|
|
1038
|
+
| Color/date pickers | Compact UI widgets |
|
|
1039
|
+
| User profiles | Quick preview without full page |
|
|
1040
|
+
| Filter panels | Temporary filtering UI |
|
|
1041
|
+
| Share menus | Social sharing options |
|
|
1042
|
+
|
|
1043
|
+
### When NOT to Use Popover
|
|
1044
|
+
|
|
1045
|
+
| Use Case | Use Instead | Why |
|
|
1046
|
+
| ------------------ | --------------- | --------------------------------------- |
|
|
1047
|
+
| Critical alerts | Dialog | Center attention, harder to dismiss |
|
|
1048
|
+
| Long forms | Drawer/Page | More space, better UX for complex input |
|
|
1049
|
+
| Simple hints | Tooltip | Popover is too heavy for read-only text |
|
|
1050
|
+
| Primary navigation | Drawer/Menu | Better for main nav structure |
|
|
1051
|
+
| Full content | Page/Route | Popover is temporary/contextual |
|
|
1052
|
+
| Mobile sheets | Drawer (bottom) | Better mobile UX |
|
|
1053
|
+
|
|
1054
|
+
## Placement Guidelines
|
|
1055
|
+
|
|
1056
|
+
### Placement Options
|
|
1057
|
+
|
|
1058
|
+
| Placement | Best For | Arrow Position |
|
|
1059
|
+
| -------------- | ------------------------------- | ----------------- |
|
|
1060
|
+
| `top` | Content above trigger | Points down |
|
|
1061
|
+
| `top-start` | Left-aligned top | Points down-right |
|
|
1062
|
+
| `top-end` | Right-aligned top | Points down-left |
|
|
1063
|
+
| `bottom` | Content below trigger (default) | Points up |
|
|
1064
|
+
| `bottom-start` | Left-aligned bottom | Points up-right |
|
|
1065
|
+
| `bottom-end` | Right-aligned bottom | Points up-left |
|
|
1066
|
+
| `left` | Content left of trigger | Points right |
|
|
1067
|
+
| `left-start` | Top-aligned left | Points right-down |
|
|
1068
|
+
| `left-end` | Bottom-aligned left | Points right-up |
|
|
1069
|
+
| `right` | Content right of trigger | Points left |
|
|
1070
|
+
| `right-start` | Top-aligned right | Points left-down |
|
|
1071
|
+
| `right-end` | Bottom-aligned right | Points left-up |
|
|
1072
|
+
|
|
1073
|
+
**Auto-positioning**: Popover automatically flips to the opposite side if there's insufficient space.
|
|
1074
|
+
|
|
1075
|
+
## Content Guidelines
|
|
1076
|
+
|
|
1077
|
+
### Size Recommendations
|
|
1078
|
+
|
|
1079
|
+
| Content Type | Max Width | Max Height |
|
|
1080
|
+
| ------------------- | ---------- | ---------- |
|
|
1081
|
+
| Action menu | 280px | auto |
|
|
1082
|
+
| Quick form | 384px (sm) | auto |
|
|
1083
|
+
| Info panel | 384px (sm) | 400px |
|
|
1084
|
+
| Rich content | 480px | 500px |
|
|
1085
|
+
| Picker (color/date) | auto | auto |
|
|
1086
|
+
|
|
1087
|
+
### Content Structure
|
|
1088
|
+
|
|
1089
|
+
```typescript
|
|
1090
|
+
// Simple (no header/footer)
|
|
1091
|
+
<Popover.Content>
|
|
1092
|
+
<Popover.Arrow />
|
|
1093
|
+
<Popover.Body>
|
|
1094
|
+
Simple content
|
|
1095
|
+
</Popover.Body>
|
|
1096
|
+
</Popover.Content>
|
|
1097
|
+
|
|
1098
|
+
// Standard (with header)
|
|
1099
|
+
<Popover.Content>
|
|
1100
|
+
<Popover.Arrow />
|
|
1101
|
+
<Popover.Header>
|
|
1102
|
+
<Popover.Title>Title</Popover.Title>
|
|
1103
|
+
</Popover.Header>
|
|
1104
|
+
<Popover.Body>
|
|
1105
|
+
Content
|
|
1106
|
+
</Popover.Body>
|
|
1107
|
+
</Popover.Content>
|
|
1108
|
+
|
|
1109
|
+
// Complete (header + footer)
|
|
1110
|
+
<Popover.Content>
|
|
1111
|
+
<Popover.Arrow />
|
|
1112
|
+
<Popover.Header>
|
|
1113
|
+
<Popover.Title>Title</Popover.Title>
|
|
1114
|
+
<Popover.Description>Description</Popover.Description>
|
|
1115
|
+
</Popover.Header>
|
|
1116
|
+
<Popover.Body>
|
|
1117
|
+
Content
|
|
1118
|
+
</Popover.Body>
|
|
1119
|
+
<Popover.Footer>
|
|
1120
|
+
<Button>Action</Button>
|
|
1121
|
+
</Popover.Footer>
|
|
1122
|
+
</Popover.Content>
|
|
1123
|
+
```
|
|
1124
|
+
|
|
1125
|
+
## State Behaviors
|
|
1126
|
+
|
|
1127
|
+
| State | Visual Change | Behavior |
|
|
1128
|
+
| ----------------- | --------------------- | ------------------------ |
|
|
1129
|
+
| **Opening** | Scale up + fade in | Duration: fast (150ms) |
|
|
1130
|
+
| **Open** | Fully visible | Auto-focus first element |
|
|
1131
|
+
| **Closing** | Scale down + fade out | Duration: faster (100ms) |
|
|
1132
|
+
| **Closed** | Removed from DOM | Focus returns to trigger |
|
|
1133
|
+
| **Repositioning** | Smooth transition | Follows scroll/resize |
|
|
1134
|
+
|
|
1135
|
+
## Testing
|
|
1136
|
+
|
|
1137
|
+
When testing Popover components:
|
|
1138
|
+
|
|
1139
|
+
```typescript
|
|
1140
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
1141
|
+
import userEvent from '@testing-library/user-event';
|
|
1142
|
+
import { Popover, Button } from '@discourser/design-system';
|
|
1143
|
+
|
|
1144
|
+
test('popover opens and closes', async () => {
|
|
1145
|
+
const user = userEvent.setup();
|
|
1146
|
+
|
|
1147
|
+
render(
|
|
1148
|
+
<Popover.Root>
|
|
1149
|
+
<Popover.Trigger asChild>
|
|
1150
|
+
<Button>Open</Button>
|
|
1151
|
+
</Popover.Trigger>
|
|
1152
|
+
<Popover.Positioner>
|
|
1153
|
+
<Popover.Content>
|
|
1154
|
+
<Popover.Title>Test Popover</Popover.Title>
|
|
1155
|
+
<Popover.Body>Content</Popover.Body>
|
|
1156
|
+
<Popover.CloseTrigger asChild>
|
|
1157
|
+
<Button>Close</Button>
|
|
1158
|
+
</Popover.CloseTrigger>
|
|
1159
|
+
</Popover.Content>
|
|
1160
|
+
</Popover.Positioner>
|
|
1161
|
+
</Popover.Root>
|
|
1162
|
+
);
|
|
1163
|
+
|
|
1164
|
+
// Initially closed
|
|
1165
|
+
expect(screen.queryByText('Test Popover')).not.toBeInTheDocument();
|
|
1166
|
+
|
|
1167
|
+
// Open popover
|
|
1168
|
+
await user.click(screen.getByText('Open'));
|
|
1169
|
+
|
|
1170
|
+
await waitFor(() => {
|
|
1171
|
+
expect(screen.getByText('Test Popover')).toBeInTheDocument();
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
// Close popover
|
|
1175
|
+
await user.click(screen.getByText('Close'));
|
|
1176
|
+
|
|
1177
|
+
await waitFor(() => {
|
|
1178
|
+
expect(screen.queryByText('Test Popover')).not.toBeInTheDocument();
|
|
1179
|
+
});
|
|
1180
|
+
});
|
|
1181
|
+
|
|
1182
|
+
test('popover closes on outside click', async () => {
|
|
1183
|
+
const user = userEvent.setup();
|
|
1184
|
+
|
|
1185
|
+
render(
|
|
1186
|
+
<div>
|
|
1187
|
+
<button>Outside</button>
|
|
1188
|
+
<Popover.Root>
|
|
1189
|
+
<Popover.Trigger asChild>
|
|
1190
|
+
<Button>Open</Button>
|
|
1191
|
+
</Popover.Trigger>
|
|
1192
|
+
<Popover.Positioner>
|
|
1193
|
+
<Popover.Content>
|
|
1194
|
+
<Popover.Title>Test</Popover.Title>
|
|
1195
|
+
</Popover.Content>
|
|
1196
|
+
</Popover.Positioner>
|
|
1197
|
+
</Popover.Root>
|
|
1198
|
+
</div>
|
|
1199
|
+
);
|
|
1200
|
+
|
|
1201
|
+
await user.click(screen.getByText('Open'));
|
|
1202
|
+
expect(screen.getByText('Test')).toBeInTheDocument();
|
|
1203
|
+
|
|
1204
|
+
await user.click(screen.getByText('Outside'));
|
|
1205
|
+
|
|
1206
|
+
await waitFor(() => {
|
|
1207
|
+
expect(screen.queryByText('Test')).not.toBeInTheDocument();
|
|
1208
|
+
});
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1211
|
+
test('popover closes on escape key', async () => {
|
|
1212
|
+
const user = userEvent.setup();
|
|
1213
|
+
|
|
1214
|
+
render(
|
|
1215
|
+
<Popover.Root defaultOpen>
|
|
1216
|
+
<Popover.Positioner>
|
|
1217
|
+
<Popover.Content>
|
|
1218
|
+
<Popover.Title>Test</Popover.Title>
|
|
1219
|
+
</Popover.Content>
|
|
1220
|
+
</Popover.Positioner>
|
|
1221
|
+
</Popover.Root>
|
|
1222
|
+
);
|
|
1223
|
+
|
|
1224
|
+
expect(screen.getByText('Test')).toBeInTheDocument();
|
|
1225
|
+
|
|
1226
|
+
await user.keyboard('{Escape}');
|
|
1227
|
+
|
|
1228
|
+
await waitFor(() => {
|
|
1229
|
+
expect(screen.queryByText('Test')).not.toBeInTheDocument();
|
|
1230
|
+
});
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
test('controlled popover updates on state change', async () => {
|
|
1234
|
+
const user = userEvent.setup();
|
|
1235
|
+
|
|
1236
|
+
function ControlledTest() {
|
|
1237
|
+
const [open, setOpen] = useState(false);
|
|
1238
|
+
return (
|
|
1239
|
+
<>
|
|
1240
|
+
<button onClick={() => setOpen(true)}>External Open</button>
|
|
1241
|
+
<Popover.Root open={open} onOpenChange={(e) => setOpen(e.open)}>
|
|
1242
|
+
<Popover.Positioner>
|
|
1243
|
+
<Popover.Content>
|
|
1244
|
+
<Popover.Title>Controlled</Popover.Title>
|
|
1245
|
+
</Popover.Content>
|
|
1246
|
+
</Popover.Positioner>
|
|
1247
|
+
</Popover.Root>
|
|
1248
|
+
</>
|
|
1249
|
+
);
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
render(<ControlledTest />);
|
|
1253
|
+
|
|
1254
|
+
expect(screen.queryByText('Controlled')).not.toBeInTheDocument();
|
|
1255
|
+
|
|
1256
|
+
await user.click(screen.getByText('External Open'));
|
|
1257
|
+
|
|
1258
|
+
await waitFor(() => {
|
|
1259
|
+
expect(screen.getByText('Controlled')).toBeInTheDocument();
|
|
1260
|
+
});
|
|
1261
|
+
});
|
|
1262
|
+
```
|
|
1263
|
+
|
|
1264
|
+
## Related Components
|
|
1265
|
+
|
|
1266
|
+
- **Tooltip**: Use for simple, non-interactive hints
|
|
1267
|
+
- **Menu**: Use for dropdown menus with keyboard navigation
|
|
1268
|
+
- **Drawer**: Use for larger panels or mobile sheets
|
|
1269
|
+
- **Dialog**: Use for centered modals and critical alerts
|
|
1270
|
+
- **Select**: Use for form dropdowns with options
|
|
1271
|
+
- **Dropdown**: Use for trigger-based menus
|