@citizenplane/pimp 17.0.12 → 18.0.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.
@@ -0,0 +1,149 @@
1
+ import { computed, ref } from 'vue'
2
+
3
+ import type { Meta, StoryObj } from '@storybook/vue3-vite'
4
+
5
+ import CpButton from '@/components/CpButton.vue'
6
+ import CpMenu from '@/components/CpMenu.vue'
7
+
8
+ const meta = {
9
+ title: 'Molecules/CpMenu',
10
+ component: CpMenu,
11
+ parameters: {
12
+ docs: {
13
+ description: {
14
+ component:
15
+ 'A popover-based menu built on top of `primevue/popover`. The trigger is provided through the `trigger` slot — clicking it toggles the menu, which is anchored to the trigger element. Items can be passed through the `items` prop (with icons, separators, async commands, critical styling) or rendered via the default slot for arbitrary content.',
16
+ },
17
+ },
18
+ },
19
+ argTypes: {
20
+ items: {
21
+ control: 'object',
22
+ description: 'The menu items to display. Each item is a PrimeVue `MenuItem`.',
23
+ table: {
24
+ type: { summary: 'MenuItem[]' },
25
+ defaultValue: { summary: '[]' },
26
+ },
27
+ },
28
+ forcePopover: {
29
+ control: 'boolean',
30
+ description: 'Disable the bottom-drawer behavior on mobile and keep the popover anchored to the trigger.',
31
+ table: { defaultValue: { summary: 'false' } },
32
+ },
33
+ keepOpenOnClick: {
34
+ control: 'boolean',
35
+ description: 'Keep the menu open when clicking an item.',
36
+ table: { defaultValue: { summary: 'false' } },
37
+ },
38
+ },
39
+ decorators: [() => ({ template: '<div style="min-height: 60vh; padding: 80px;"><story /></div>' })],
40
+ } satisfies Meta<typeof CpMenu>
41
+
42
+ export default meta
43
+
44
+ type Story = StoryObj<typeof meta>
45
+
46
+ /**
47
+ * Click the trigger to toggle a menu featuring an async "Download" action,
48
+ * a separator and a critical "Delete" action.
49
+ */
50
+ export const Default: Story = {
51
+ render: () => ({
52
+ components: { CpButton, CpMenu },
53
+ setup() {
54
+ const isLoading = ref(false)
55
+
56
+ const items = computed(() => [
57
+ {
58
+ label: 'Edit',
59
+ leadingIcon: 'edit',
60
+ command: () => alert('Edit clicked'),
61
+ },
62
+ {
63
+ label: 'Download',
64
+ leadingIcon: 'download',
65
+ isLoading: isLoading.value,
66
+ isAsync: true,
67
+ command: async () => {
68
+ isLoading.value = true
69
+ await new Promise((resolve) => setTimeout(resolve, 2000))
70
+ isLoading.value = false
71
+ },
72
+ },
73
+ { separator: true },
74
+ {
75
+ label: 'Delete',
76
+ leadingIcon: 'trash-2',
77
+ isCritical: true,
78
+ command: () => alert('Delete clicked'),
79
+ },
80
+ ])
81
+
82
+ return { items }
83
+ },
84
+ template: `
85
+ <CpMenu :items="items" class="defaultMenu">
86
+ <template #trigger>
87
+ <CpButton>Open menu</CpButton>
88
+ </template>
89
+ </CpMenu>
90
+ `,
91
+ }),
92
+ }
93
+
94
+ /**
95
+ * With `forcePopover`, the menu stays anchored to its trigger even on
96
+ * mobile viewports — the bottom-drawer behavior (slide-up, backdrop,
97
+ * swipe-to-dismiss) is disabled. Use it when the popover content is
98
+ * compact and you don't want a fullscreen-ish drawer.
99
+ */
100
+ export const ForcePopoverOnMobile: Story = {
101
+ args: {
102
+ forcePopover: true,
103
+ },
104
+ render: (args) => ({
105
+ components: { CpButton, CpMenu },
106
+ setup() {
107
+ const items = [
108
+ { label: 'Edit', leadingIcon: 'edit', command: () => alert('Edit clicked') },
109
+ { label: 'Duplicate', leadingIcon: 'copy', command: () => alert('Duplicate clicked') },
110
+ { separator: true },
111
+ { label: 'Delete', leadingIcon: 'trash-2', isCritical: true, command: () => alert('Delete clicked') },
112
+ ]
113
+ return { args, items }
114
+ },
115
+ template: `
116
+ <CpMenu v-bind="args" :items="items">
117
+ <template #trigger>
118
+ <CpButton>Open menu</CpButton>
119
+ </template>
120
+ </CpMenu>
121
+ `,
122
+ }),
123
+ }
124
+
125
+ /**
126
+ * Use the default slot to render arbitrary content inside the popover —
127
+ * useful for forms, share panels, filters, etc.
128
+ */
129
+ export const CustomContent: Story = {
130
+ render: () => ({
131
+ components: { CpButton, CpMenu },
132
+ template: `
133
+ <CpMenu>
134
+ <template #trigger>
135
+ <CpButton>Share</CpButton>
136
+ </template>
137
+ <div style="display: flex; flex-direction: column; gap: 12px; padding: 16px; min-width: 260px;">
138
+ <strong>Share this document</strong>
139
+ <input
140
+ value="https://example.com/doc/abc"
141
+ readonly
142
+ style="padding: 6px 8px; border: 1px solid #ccc; border-radius: 6px;"
143
+ />
144
+ <CpButton>Copy link</CpButton>
145
+ </div>
146
+ </CpMenu>
147
+ `,
148
+ }),
149
+ }
@@ -1,83 +1,184 @@
1
- import type { Meta, StoryObj } from '@storybook/vue3-vite'
1
+ import { ref, computed } from 'vue'
2
2
 
3
+ import type { Args, Meta, StoryObj } from '@storybook/vue3-vite'
4
+
5
+ import CpButton from '@/components/CpButton.vue'
6
+ import CpIcon from '@/components/CpIcon.vue'
7
+ import CpMenu from '@/components/CpMenu.vue'
3
8
  import CpMenuItem from '@/components/CpMenuItem.vue'
4
9
 
5
- import { docLabelStyle, docRowColumnStyle } from '@/stories/documentationStyles'
10
+ import { docCellStyle, docLabelStyle, docRowWrapStyle } from '@/stories/documentationStyles'
6
11
 
7
- const menuItemCellStyle = 'display: flex; flex-direction: column; gap: 8px; width: 260px;'
8
- const menuItemFrameStyle = 'width: 260px; border: 1px solid #e5e5e5; border-radius: 8px;'
12
+ const menuContainerStyle =
13
+ 'display: flex; flex-direction: column; background: var(--cp-background-primary); border: 1px solid var(--cp-border-secondary, #e5e7eb); border-radius: 8px; padding: 4px 0; min-width: 240px;'
9
14
 
10
15
  const meta = {
11
16
  title: 'Atoms/CpMenuItem',
12
17
  component: CpMenuItem,
18
+ parameters: {
19
+ docs: {
20
+ description: {
21
+ component:
22
+ 'A single menu entry, typically used inside `CpMenu`. Renders a button with a leading and/or trailing icon, a label, and supports loading, critical, selected and disabled states. Sync and async commands are both supported through the `command` prop (set `isAsync` to keep the menu open and surface a loader while the promise resolves).',
23
+ },
24
+ },
25
+ },
13
26
  argTypes: {
14
27
  label: {
15
28
  control: 'text',
16
- description: 'Label text for the menu item',
29
+ description: 'The text displayed inside the item.',
17
30
  },
18
- icon: {
31
+ leadingIcon: {
19
32
  control: 'text',
20
- description: 'Icon name',
33
+ description: 'Icon name displayed before the label. Overridden by the `leading-icon` slot.',
21
34
  },
22
- isDisabled: {
23
- control: 'boolean',
24
- description: 'Whether the menu item is disabled',
35
+ trailingIcon: {
36
+ control: 'text',
37
+ description: 'Icon name displayed after the label. Overridden by the `trailing-icon` slot.',
38
+ },
39
+ tooltip: {
40
+ control: 'text',
41
+ description: 'Optional tooltip text shown on hover/focus of the label.',
25
42
  },
26
43
  isCritical: {
27
44
  control: 'boolean',
28
- description: 'Whether the menu item is critical',
45
+ description: 'Render the item with a destructive (red) styling.',
29
46
  },
30
47
  isLoading: {
31
48
  control: 'boolean',
32
- description: 'Whether the menu item is loading',
49
+ description: 'Show a loader in place of the leading icon and disable the item.',
33
50
  },
34
- isAsync: {
51
+ isSelected: {
35
52
  control: 'boolean',
36
- description: 'Whether the command is async',
53
+ description: 'Initial selected state. Toggled internally when clicked.',
37
54
  },
38
- reverseLabel: {
55
+ isAsync: {
39
56
  control: 'boolean',
40
- description: 'Whether to reverse icon/label order',
57
+ description:
58
+ 'When the `command` returns a promise, set this to keep the parent menu open and emit `onAsyncCommandComplete` once it resolves.',
41
59
  },
42
- hideLabel: {
60
+ disabled: {
43
61
  control: 'boolean',
44
- description: 'Whether to hide the label',
45
- },
46
- tooltip: {
47
- control: 'text',
48
- description: 'Tooltip text',
62
+ description: 'Disable interactions and apply a muted style.',
49
63
  },
50
64
  },
51
- tags: ['autodocs'],
65
+ decorators: [
66
+ () => ({
67
+ template: '<div style="padding: 16px;"><story /></div>',
68
+ }),
69
+ ],
52
70
  } satisfies Meta<typeof CpMenuItem>
53
71
 
54
72
  export default meta
73
+
55
74
  type Story = StoryObj<typeof meta>
56
75
 
76
+ const defaultRender = (args: Args) => ({
77
+ components: { CpMenuItem },
78
+ setup() {
79
+ return { args, menuContainerStyle }
80
+ },
81
+ template: `
82
+ <div :style="menuContainerStyle">
83
+ <CpMenuItem v-bind="args" />
84
+ </div>
85
+ `,
86
+ })
87
+
57
88
  /**
58
- * Default menu item. Use the controls to experiment with each prop in isolation.
89
+ * Default item: a label with a leading icon. Click it to see the selected
90
+ * state toggle briefly before resetting.
59
91
  */
60
92
  export const Default: Story = {
61
93
  args: {
62
- label: 'Edit',
63
- icon: 'edit-3',
64
- isDisabled: false,
94
+ label: 'Menu item',
95
+ leadingIcon: 'edit',
65
96
  isCritical: false,
66
97
  isLoading: false,
67
- isAsync: false,
68
- reverseLabel: false,
69
- hideLabel: false,
70
- tooltip: '',
71
- command: () => alert('Edit clicked'),
98
+ isSelected: false,
99
+ disabled: false,
72
100
  },
73
- render: (args) => ({
101
+ render: defaultRender,
102
+ }
103
+
104
+ /* -------------------------------------------------------------------------- */
105
+ /* Icons */
106
+ /* -------------------------------------------------------------------------- */
107
+
108
+ /**
109
+ * Different icon configurations: leading only, trailing only, both or none.
110
+ */
111
+ export const Icons: Story = {
112
+ parameters: { controls: { disable: true } },
113
+ render: () => ({
74
114
  components: { CpMenuItem },
75
115
  setup() {
76
- return { args }
116
+ return { docCellStyle, docLabelStyle, docRowWrapStyle, menuContainerStyle }
77
117
  },
78
118
  template: `
79
- <div style="width: 260px; border: 1px solid #e5e5e5; border-radius: 8px;">
80
- <CpMenuItem v-bind="args" />
119
+ <div :style="docRowWrapStyle">
120
+ <div :style="docCellStyle">
121
+ <span :style="docLabelStyle">Leading</span>
122
+ <div :style="menuContainerStyle">
123
+ <CpMenuItem label="Edit" leading-icon="edit" />
124
+ </div>
125
+ </div>
126
+ <div :style="docCellStyle">
127
+ <span :style="docLabelStyle">Trailing</span>
128
+ <div :style="menuContainerStyle">
129
+ <CpMenuItem label="Open" trailing-icon="external-link" />
130
+ </div>
131
+ </div>
132
+ <div :style="docCellStyle">
133
+ <span :style="docLabelStyle">Both</span>
134
+ <div :style="menuContainerStyle">
135
+ <CpMenuItem label="Settings" leading-icon="settings" trailing-icon="chevron-right" />
136
+ </div>
137
+ </div>
138
+ <div :style="docCellStyle">
139
+ <span :style="docLabelStyle">No icon</span>
140
+ <div :style="menuContainerStyle">
141
+ <CpMenuItem label="Plain item" />
142
+ </div>
143
+ </div>
144
+ </div>
145
+ `,
146
+ }),
147
+ }
148
+
149
+ /**
150
+ * The `leading-icon` and `trailing-icon` slots let you render arbitrary
151
+ * content (custom icons, badges, indicators...) instead of the default
152
+ * `CpIcon` rendered from the `leadingIcon` / `trailingIcon` props.
153
+ */
154
+ export const CustomIconSlots: Story = {
155
+ parameters: { controls: { disable: true } },
156
+ render: () => ({
157
+ components: { CpMenuItem, CpIcon },
158
+ setup() {
159
+ return { menuContainerStyle }
160
+ },
161
+ template: `
162
+ <div :style="menuContainerStyle">
163
+ <CpMenuItem label="With custom slots">
164
+ <template #leading-icon>
165
+ <span style="
166
+ display: inline-flex;
167
+ align-items: center;
168
+ justify-content: center;
169
+ width: 16px;
170
+ height: 16px;
171
+ border-radius: 999px;
172
+ background: var(--cp-background-accent-solid, #2563eb);
173
+ color: white;
174
+ font-size: 10px;
175
+ font-weight: 600;
176
+ ">A</span>
177
+ </template>
178
+ <template #trailing-icon>
179
+ <span style="font-size: 11px; color: var(--cp-foreground-secondary);">⌘K</span>
180
+ </template>
181
+ </CpMenuItem>
81
182
  </div>
82
183
  `,
83
184
  }),
@@ -88,43 +189,216 @@ export const Default: Story = {
88
189
  /* -------------------------------------------------------------------------- */
89
190
 
90
191
  /**
91
- * Every state available for a menu item: default, critical (destructive),
92
- * disabled and loading.
192
+ * The interactive states side by side: default, plus the explicit `disabled`,
193
+ * `isLoading`, `isSelected` and `isCritical` states. Hover/focus is shown by
194
+ * interacting with each item.
93
195
  */
94
196
  export const States: Story = {
95
197
  parameters: { controls: { disable: true } },
96
198
  render: () => ({
97
199
  components: { CpMenuItem },
98
200
  setup() {
99
- return { docRowColumnStyle, menuItemCellStyle, menuItemFrameStyle, docLabelStyle }
201
+ return { docCellStyle, docLabelStyle, docRowWrapStyle, menuContainerStyle }
100
202
  },
101
203
  template: `
102
- <div :style="docRowColumnStyle">
103
- <div :style="menuItemCellStyle">
204
+ <div :style="docRowWrapStyle">
205
+ <div :style="docCellStyle">
104
206
  <span :style="docLabelStyle">Default</span>
105
- <div :style="menuItemFrameStyle">
106
- <CpMenuItem label="Edit" icon="edit-3" />
207
+ <div :style="menuContainerStyle">
208
+ <CpMenuItem label="Default" leading-icon="edit" />
107
209
  </div>
108
210
  </div>
109
- <div :style="menuItemCellStyle">
110
- <span :style="docLabelStyle">Critical</span>
111
- <div :style="menuItemFrameStyle">
112
- <CpMenuItem label="Delete" icon="trash-2" :is-critical="true" />
211
+ <div :style="docCellStyle">
212
+ <span :style="docLabelStyle">Disabled</span>
213
+ <div :style="menuContainerStyle">
214
+ <CpMenuItem label="Disabled" leading-icon="edit" :disabled="true" />
113
215
  </div>
114
216
  </div>
115
- <div :style="menuItemCellStyle">
116
- <span :style="docLabelStyle">Disabled</span>
117
- <div :style="menuItemFrameStyle">
118
- <CpMenuItem label="Archive" icon="archive" :is-disabled="true" />
217
+ <div :style="docCellStyle">
218
+ <span :style="docLabelStyle">Critical</span>
219
+ <div :style="menuContainerStyle">
220
+ <CpMenuItem label="Delete" leading-icon="trash-2" :is-critical="true" />
119
221
  </div>
120
222
  </div>
121
- <div :style="menuItemCellStyle">
223
+ <div :style="docCellStyle">
122
224
  <span :style="docLabelStyle">Loading</span>
123
- <div :style="menuItemFrameStyle">
124
- <CpMenuItem label="Download" icon="download" :is-loading="true" />
225
+ <div :style="menuContainerStyle">
226
+ <CpMenuItem label="Loading" leading-icon="edit" :is-loading="true" />
125
227
  </div>
126
228
  </div>
229
+ <div :style="docCellStyle">
230
+ <span :style="docLabelStyle">Selected</span>
231
+ <div :style="menuContainerStyle">
232
+ <CpMenuItem label="Selected" leading-icon="check" :is-selected="true" />
233
+ </div>
234
+ </div>
235
+ </div>
236
+ `,
237
+ }),
238
+ }
239
+
240
+ /* -------------------------------------------------------------------------- */
241
+ /* Tooltip */
242
+ /* -------------------------------------------------------------------------- */
243
+
244
+ /**
245
+ * Hover the label to see the tooltip — useful when the label is truncated or
246
+ * when extra context is needed without taking visual space.
247
+ */
248
+ export const WithTooltip: Story = {
249
+ args: {
250
+ label: 'Hover me for more details',
251
+ leadingIcon: 'info',
252
+ tooltip: 'Here is some additional context shown on hover.',
253
+ },
254
+ render: defaultRender,
255
+ }
256
+
257
+ /* -------------------------------------------------------------------------- */
258
+ /* Commands */
259
+ /* -------------------------------------------------------------------------- */
260
+
261
+ /**
262
+ * Sync command: the `command` callback is invoked on click, then the `click`
263
+ * event is emitted so the parent (`CpMenu`/`CpMenuList`) can react (e.g.
264
+ * close the menu).
265
+ */
266
+ export const SyncCommand: Story = {
267
+ parameters: { controls: { disable: true } },
268
+ render: () => ({
269
+ components: { CpMenuItem },
270
+ setup() {
271
+ const lastAction = ref<string>('—')
272
+ const onCommand = () => {
273
+ lastAction.value = `Clicked at ${new Date().toLocaleTimeString()}`
274
+ }
275
+ return { lastAction, onCommand, menuContainerStyle }
276
+ },
277
+ template: `
278
+ <div style="display: flex; flex-direction: column; gap: 12px;">
279
+ <div :style="menuContainerStyle">
280
+ <CpMenuItem label="Run command" leading-icon="zap" :command="onCommand" />
281
+ </div>
282
+ <span style="font-size: 12px; color: var(--cp-foreground-secondary);">Last action: {{ lastAction }}</span>
127
283
  </div>
128
284
  `,
129
285
  }),
130
286
  }
287
+
288
+ /**
289
+ * Async command: combine `isAsync` with a returning promise to display a
290
+ * loader while the command resolves. The component automatically toggles
291
+ * `isLoading` from outside in this example. `onAsyncCommandComplete` is
292
+ * emitted once the promise settles.
293
+ */
294
+ export const AsyncCommand: Story = {
295
+ parameters: { controls: { disable: true } },
296
+ render: () => ({
297
+ components: { CpMenuItem },
298
+ setup() {
299
+ const isLoading = ref(false)
300
+ const completedCount = ref(0)
301
+
302
+ const onCommand = async () => {
303
+ isLoading.value = true
304
+ await new Promise((resolve) => setTimeout(resolve, 1500))
305
+ isLoading.value = false
306
+ }
307
+
308
+ const onAsyncCommandComplete = () => {
309
+ completedCount.value += 1
310
+ }
311
+
312
+ return { isLoading, completedCount, onCommand, onAsyncCommandComplete, menuContainerStyle }
313
+ },
314
+ template: `
315
+ <div style="display: flex; flex-direction: column; gap: 12px;">
316
+ <div :style="menuContainerStyle">
317
+ <CpMenuItem
318
+ label="Download report"
319
+ leading-icon="download"
320
+ :is-async="true"
321
+ :is-loading="isLoading"
322
+ :command="onCommand"
323
+ @on-async-command-complete="onAsyncCommandComplete"
324
+ />
325
+ </div>
326
+ <span style="font-size: 12px; color: var(--cp-foreground-secondary);">
327
+ Completed {{ completedCount }} time(s)
328
+ </span>
329
+ </div>
330
+ `,
331
+ }),
332
+ }
333
+
334
+ /* -------------------------------------------------------------------------- */
335
+ /* Composition */
336
+ /* -------------------------------------------------------------------------- */
337
+
338
+ /**
339
+ * Several `CpMenuItem`s stacked together to mimic a typical menu layout.
340
+ * In real apps this is usually wrapped by `CpMenu` / `CpMenuList`, but the
341
+ * primitive composes on its own too.
342
+ */
343
+ export const InCpMenu: Story = {
344
+ render: () => ({
345
+ components: { CpButton, CpMenu },
346
+ setup() {
347
+ const isLoading = ref(false)
348
+
349
+ const onClick = () => alert('Clicked')
350
+
351
+ const items = computed(() => [
352
+ {
353
+ label: 'Sync',
354
+ leadingIcon: 'edit',
355
+ command: onClick,
356
+ },
357
+ {
358
+ label: 'Async',
359
+ leadingIcon: 'download',
360
+ isLoading: isLoading.value,
361
+ isAsync: true,
362
+ command: async () => {
363
+ isLoading.value = true
364
+ await new Promise((resolve) => setTimeout(resolve, 2000))
365
+ isLoading.value = false
366
+ },
367
+ },
368
+ { separator: true },
369
+ {
370
+ label: 'Critical',
371
+ leadingIcon: 'trash-2',
372
+ isCritical: true,
373
+ command: onClick,
374
+ },
375
+ {
376
+ label: 'Disabled',
377
+ leadingIcon: 'edit',
378
+ disabled: true,
379
+ command: onClick,
380
+ },
381
+ {
382
+ label: 'Selected',
383
+ leadingIcon: 'check',
384
+ isSelected: true,
385
+ command: onClick,
386
+ },
387
+ {
388
+ label: 'Loading',
389
+ isLoading: true,
390
+ command: onClick,
391
+ },
392
+ ])
393
+
394
+ return { items }
395
+ },
396
+ template: `
397
+ <CpMenu :items="items" class="defaultMenu">
398
+ <template #trigger>
399
+ <CpButton>Open menu</CpButton>
400
+ </template>
401
+ </CpMenu>
402
+ `,
403
+ }),
404
+ }