@citizenplane/pimp 18.25.0 → 18.25.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@citizenplane/pimp",
3
- "version": "18.25.0",
3
+ "version": "18.25.1",
4
4
  "scripts": {
5
5
  "dev": "storybook dev -p 8081",
6
6
  "build-storybook": "storybook build --output-dir ./docs",
@@ -63,10 +63,11 @@
63
63
  </template>
64
64
 
65
65
  <script setup lang="ts">
66
+ import { useResizeObserver } from '@vueuse/core'
66
67
  import PrimevueDrawer from 'primevue/drawer'
67
68
  import type { MenuItem } from 'primevue/menuitem'
68
69
  import PrimevuePopover from 'primevue/popover'
69
- import { computed, nextTick, ref, useId } from 'vue'
70
+ import { computed, nextTick, ref, shallowRef, useId } from 'vue'
70
71
 
71
72
  import { useIsBreakpoint } from '@/composables/useIsBreakpoint'
72
73
 
@@ -107,6 +108,7 @@ const trigger = ref<HTMLElement | null>(null)
107
108
  const popover = ref<InstanceType<typeof PrimevuePopover>>()
108
109
  const closeButton = ref<InstanceType<typeof CpButton> | null>(null)
109
110
  const isOpen = ref(false)
111
+ const observedPopoverElement = shallowRef<HTMLElement | null>(null)
110
112
  const menuId = useId()
111
113
 
112
114
  const isMobileViewport = useIsBreakpoint(Breakpoints.TABLET)
@@ -186,30 +188,56 @@ const focusCloseButton = () => {
186
188
 
187
189
  const onHide = () => {
188
190
  isOpen.value = false
191
+ observedPopoverElement.value = null
189
192
  focusTrigger()
190
193
  emits('hide')
191
194
  }
192
195
 
193
- const alignPopoverEnd = () => {
194
- if (!popover.value) return
196
+ const getPopoverElements = () => {
197
+ if (!popover.value) return null
195
198
 
196
199
  const { container: popoverElement, target: triggerElement } = popover.value as unknown as {
197
200
  container: HTMLElement | null
198
201
  target: HTMLElement | null
199
202
  }
200
203
 
201
- if (!popoverElement || !triggerElement) return
204
+ if (!popoverElement || !triggerElement) return null
205
+
206
+ return { popoverElement, triggerElement }
207
+ }
202
208
 
203
- const triggerWidth = triggerElement.offsetWidth
204
- const popoverWidth = popoverElement.offsetWidth
209
+ const getPopoverRightEdge = (popoverElement: HTMLElement) => {
210
+ const offsetParent = popoverElement.offsetParent as HTMLElement | null
211
+ const offsetParentLeft = offsetParent?.getBoundingClientRect().left ?? 0
205
212
 
206
- popoverElement.style.marginLeft = `${(popoverWidth - triggerWidth) * -1}px`
213
+ return offsetParentLeft + popoverElement.offsetLeft + popoverElement.offsetWidth
207
214
  }
208
215
 
216
+ const alignPopoverEnd = () => {
217
+ const elements = getPopoverElements()
218
+ if (!elements) return
219
+
220
+ const { popoverElement, triggerElement } = elements
221
+ popoverElement.style.marginLeft = ''
222
+
223
+ const gapAfterTriggerEnd = getPopoverRightEdge(popoverElement) - triggerElement.getBoundingClientRect().right
224
+ if (!gapAfterTriggerEnd) return
225
+
226
+ popoverElement.style.marginLeft = `${gapAfterTriggerEnd * -1}px`
227
+ }
228
+
229
+ useResizeObserver(observedPopoverElement, alignPopoverEnd)
230
+
209
231
  const onShow = async () => {
210
232
  isOpen.value = true
211
- if (isPopoverPlacementEnd.value) alignPopoverEnd()
212
233
  emits('show')
234
+
235
+ if (!isPopoverPlacementEnd.value) return
236
+
237
+ await nextTick()
238
+
239
+ alignPopoverEnd()
240
+ observedPopoverElement.value = getPopoverElements()?.popoverElement ?? null
213
241
  }
214
242
 
215
243
  defineExpose({ show, hide, toggle })
@@ -7,6 +7,9 @@ import CpLanguageSwitcher from '@/components/CpLanguageSwitcher.vue'
7
7
  const STORY_TRIGGER_LAYOUT =
8
8
  'display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 60vh; width: 100%;'
9
9
 
10
+ const STORY_EDGE_TRIGGER_LAYOUT =
11
+ 'display: flex; align-items: center; justify-content: flex-end; min-height: 60vh; width: 100%; padding: 0 16px;'
12
+
10
13
  const LANGS_WITH_FLAGS = [
11
14
  { code: 'fr', name: 'Français', flag: 'fr' },
12
15
  { code: 'en', name: 'English', flag: 'gb' },
@@ -113,6 +116,31 @@ export const WithoutFlags: Story = {
113
116
  }),
114
117
  }
115
118
 
119
+ /**
120
+ * `placement="end"` with the trigger pinned to the right edge of the canvas, the navbar
121
+ * case: PrimeVue's collision handling already right-aligns the popover there, so the
122
+ * popover's right edge must sit flush with the trigger's, not shifted left.
123
+ */
124
+ export const PlacementEndAtViewportEdge: Story = {
125
+ args: {
126
+ langs: LANGS_WITH_FLAGS,
127
+ modelValue: 'fr',
128
+ placement: 'end',
129
+ },
130
+ render: (args) => ({
131
+ components: { CpLanguageSwitcher },
132
+ setup() {
133
+ const currentCode = ref(args.modelValue)
134
+ return { args, currentCode }
135
+ },
136
+ template: `
137
+ <div style="${STORY_EDGE_TRIGGER_LAYOUT}">
138
+ <CpLanguageSwitcher v-bind="args" v-model="currentCode" />
139
+ </div>
140
+ `,
141
+ }),
142
+ }
143
+
116
144
  /**
117
145
  * Mobile viewport — the list moves into a bottom sheet. No `sheetTitle` is passed,
118
146
  * so the header collapses to its drag handle and the languages start right under it.
@@ -5,7 +5,7 @@ import CpButton from '@/components/CpButton.vue'
5
5
  import CpMenu from '@/components/CpMenu.vue'
6
6
 
7
7
  const meta = {
8
- title: 'Molecules/CpMenu',
8
+ title: 'Organisms/CpMenu',
9
9
  component: CpMenu,
10
10
  parameters: {
11
11
  docs: {
@@ -208,6 +208,42 @@ export const Placement: Story = {
208
208
  }),
209
209
  }
210
210
 
211
+ /**
212
+ * The same `placement="end"` menu with its trigger pinned to the right edge of the
213
+ * canvas, where PrimeVue's collision handling already right-aligns the popover on the
214
+ * trigger. The popover's right edge must sit flush with the trigger's, not shifted left.
215
+ */
216
+ export const PlacementEndAtViewportEdge: Story = {
217
+ args: {
218
+ forcePopover: true,
219
+ placement: 'end',
220
+ },
221
+ parameters: {
222
+ layout: 'fullscreen',
223
+ },
224
+ render: (args) => ({
225
+ components: { CpButton, CpMenu },
226
+ setup() {
227
+ const items = [
228
+ { label: 'Edit', leadingIcon: 'edit', command: () => alert('Edit clicked') },
229
+ { label: 'Duplicate', leadingIcon: 'copy', command: () => alert('Duplicate clicked') },
230
+ { separator: true },
231
+ { label: 'Delete', leadingIcon: 'trash-2', isCritical: true, command: () => alert('Delete clicked') },
232
+ ]
233
+ return { args, items }
234
+ },
235
+ template: `
236
+ <div style="display: flex; align-items: center; justify-content: flex-end; min-height: 320px; width: 100%;">
237
+ <CpMenu v-bind="args" :items="items">
238
+ <template #trigger>
239
+ <CpButton>Open menu</CpButton>
240
+ </template>
241
+ </CpMenu>
242
+ </div>
243
+ `,
244
+ }),
245
+ }
246
+
211
247
  /**
212
248
  * Use the default slot to render arbitrary content inside the popover —
213
249
  * useful for forms, share panels, filters, etc.