@citizenplane/pimp 17.0.13 → 18.0.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": "17.0.13",
3
+ "version": "18.0.1",
4
4
  "scripts": {
5
5
  "dev": "storybook dev -p 8081",
6
6
  "build-storybook": "storybook build --output-dir ./docs",
@@ -1,7 +1,7 @@
1
1
  <template>
2
2
  <context-menu ref="menu" :model="items" :pt="passThroughConfig">
3
3
  <template #item="{ item, props }">
4
- <cp-menu-item v-bind="{ ...item, ...props.action }" @on-async-command-complete="hide" />
4
+ <cp-menu-item v-bind="{ ...item, ...props.action }" :leading-icon="item.icon" @async-complete="hide" />
5
5
  </template>
6
6
  </context-menu>
7
7
  </template>
@@ -6,7 +6,7 @@
6
6
  v-bind="action"
7
7
  :key="index"
8
8
  hide-label
9
- @click.stop="action.command"
9
+ :leading-icon="action.icon"
10
10
  />
11
11
  </cp-button-group>
12
12
  <cp-contextual-menu
@@ -0,0 +1,252 @@
1
+ <template>
2
+ <div class="cpMenu">
3
+ <div
4
+ ref="trigger"
5
+ :aria-controls="menuId"
6
+ :aria-expanded="isOpen"
7
+ :aria-haspopup="popupType"
8
+ class="cpMenu__trigger"
9
+ @click="toggle"
10
+ >
11
+ <slot name="trigger" />
12
+ </div>
13
+
14
+ <primevue-drawer
15
+ v-if="isDrawer"
16
+ v-model:visible="isOpen"
17
+ :auto-z-index="false"
18
+ block-scroll
19
+ close-on-escape
20
+ dismissable
21
+ position="bottom"
22
+ :pt="drawerPt"
23
+ :show-close-icon="false"
24
+ @after-show="focusCloseButton"
25
+ @hide="onHide"
26
+ >
27
+ <div class="cpMenu__drawerToolbar">
28
+ <cp-button
29
+ ref="closeButton"
30
+ appearance="tertiary"
31
+ aria-label="Close drawer"
32
+ color="neutral"
33
+ is-square
34
+ size="md"
35
+ @click="hide"
36
+ >
37
+ <template #leading-icon>
38
+ <cp-icon type="x" />
39
+ </template>
40
+ </cp-button>
41
+ </div>
42
+ <cp-menu-list v-if="hasItems" :id="menuId" :items="items" @async-complete="onItemClick" @click="onItemClick" />
43
+ <slot v-else />
44
+ </primevue-drawer>
45
+
46
+ <primevue-popover
47
+ v-else
48
+ ref="popover"
49
+ close-on-escape
50
+ dismissable
51
+ :pt="popoverPt"
52
+ @hide="onHide"
53
+ @show="isOpen = true"
54
+ >
55
+ <cp-menu-list v-if="hasItems" :id="menuId" :items="items" @async-complete="onItemClick" @click="onItemClick" />
56
+ <slot v-else />
57
+ </primevue-popover>
58
+ </div>
59
+ </template>
60
+
61
+ <script setup lang="ts">
62
+ import PrimevueDrawer from 'primevue/drawer'
63
+ import PrimevuePopover from 'primevue/popover'
64
+ import { computed, nextTick, onUnmounted, ref, useId } from 'vue'
65
+
66
+ import type { MenuItem } from 'primevue/menuitem'
67
+
68
+ import CpButton from '@/components/CpButton.vue'
69
+ import CpIcon from '@/components/CpIcon.vue'
70
+ import CpMenuList from '@/components/CpMenuList.vue'
71
+
72
+ import { getKeyboardFocusableElements } from '@/helpers/dom'
73
+
74
+ export interface Props {
75
+ class?: string
76
+ forcePopover?: boolean
77
+ fullHeightDrawer?: boolean
78
+ items?: MenuItem[]
79
+ keepOpenOnClick?: boolean
80
+ }
81
+
82
+ const props = withDefaults(defineProps<Props>(), {
83
+ class: undefined,
84
+ fullHeightDrawer: false,
85
+ items: undefined,
86
+ keepOpenOnClick: false,
87
+ })
88
+
89
+ const MOBILE_BREAKPOINT_PX = 640
90
+
91
+ const trigger = ref<HTMLElement | null>(null)
92
+ const popover = ref<InstanceType<typeof PrimevuePopover>>()
93
+ const closeButton = ref<InstanceType<typeof CpButton> | null>(null)
94
+ const isOpen = ref(false)
95
+ const menuId = useId()
96
+
97
+ const mediaQuery = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT_PX}px)`)
98
+ const isMobileViewport = ref(mediaQuery.matches)
99
+ const onMediaChange = (event: MediaQueryListEvent) => {
100
+ isMobileViewport.value = event.matches
101
+ }
102
+ mediaQuery.addEventListener('change', onMediaChange)
103
+ onUnmounted(() => mediaQuery.removeEventListener('change', onMediaChange))
104
+
105
+ const isDrawer = computed(() => isMobileViewport.value && !props.forcePopover)
106
+ const hasItems = computed(() => props.items?.length)
107
+ const popupType = computed(() => (hasItems.value ? 'menu' : 'dialog'))
108
+
109
+ const popoverPt = {
110
+ root: { class: ['cpMenu__overlay', props.class] },
111
+ content: { class: 'cpMenu__overlayContent' },
112
+ transition: { name: 'scale-elastic', duration: 100 },
113
+ }
114
+
115
+ const drawerPt = {
116
+ root: { class: ['cpMenu__drawer', { 'cpMenu__drawer--fullHeight': props.fullHeightDrawer }] },
117
+ content: { class: 'cpMenu__drawerContent' },
118
+ mask: { class: 'cpMenu__drawerMask' },
119
+ header: { class: 'cpMenu__drawerHeader' },
120
+ }
121
+
122
+ const show = (event: Event) => {
123
+ if (isDrawer.value) isOpen.value = true
124
+ else popover.value?.show(event, trigger.value)
125
+ }
126
+
127
+ const hide = () => {
128
+ if (isDrawer.value) isOpen.value = false
129
+ else popover.value?.hide()
130
+ }
131
+
132
+ const onItemClick = () => {
133
+ if (!props.keepOpenOnClick) hide()
134
+ }
135
+
136
+ const toggle = (event: Event) => {
137
+ if (isDrawer.value) isOpen.value = !isOpen.value
138
+ else popover.value?.toggle(event, trigger.value)
139
+ }
140
+
141
+ const focusTrigger = () => {
142
+ if (!trigger.value) return
143
+ const [first] = getKeyboardFocusableElements(trigger.value) as HTMLElement[]
144
+ first?.focus()
145
+ }
146
+
147
+ const focusCloseButton = () => {
148
+ nextTick(() => (closeButton.value?.$el as HTMLElement | undefined)?.focus())
149
+ }
150
+
151
+ const onHide = () => {
152
+ isOpen.value = false
153
+ focusTrigger()
154
+ }
155
+
156
+ defineExpose({ show, hide, toggle })
157
+ </script>
158
+
159
+ <style lang="scss">
160
+ .cpMenu {
161
+ display: contents;
162
+
163
+ &__trigger {
164
+ display: inline-flex;
165
+ cursor: pointer;
166
+ }
167
+
168
+ &__overlay {
169
+ position: absolute;
170
+ margin-top: var(--cp-spacing-lg);
171
+ min-width: calc(var(--cp-dimensions-1) * 62.5);
172
+ border-radius: var(--cp-radius-md);
173
+ background-color: var(--cp-background-primary);
174
+ box-shadow: var(--cp-shadows-overlay);
175
+ will-change: opacity, transform;
176
+ transform-origin: top;
177
+ }
178
+
179
+ &__overlayContent {
180
+ padding: var(--cp-spacing-sm) 0;
181
+ }
182
+
183
+ &__drawerMask {
184
+ position: fixed;
185
+ inset: 0;
186
+ z-index: 2;
187
+ display: flex;
188
+ align-items: flex-end;
189
+ justify-content: center;
190
+ background-color: var(--cp-background-overlay);
191
+ }
192
+
193
+ &__drawer {
194
+ position: relative;
195
+ width: 100%;
196
+ max-height: 85vh;
197
+ display: flex;
198
+ flex-direction: column;
199
+ background-color: var(--cp-background-primary);
200
+ border-radius: var(--cp-radius-lg) var(--cp-radius-lg) 0 0;
201
+ box-shadow: var(--cp-shadows-overlay);
202
+ padding-bottom: env(safe-area-inset-bottom);
203
+ }
204
+
205
+ &__drawer--fullHeight {
206
+ height: 100%;
207
+ max-height: 100%;
208
+ border-radius: 0;
209
+ }
210
+
211
+ &__drawerHeader {
212
+ display: none;
213
+ }
214
+
215
+ &__drawerContent {
216
+ flex: 1;
217
+ overflow-y: auto;
218
+ padding-bottom: var(--cp-spacing-sm);
219
+ }
220
+
221
+ &__drawerToolbar {
222
+ display: flex;
223
+ justify-content: flex-end;
224
+ padding: var(--cp-spacing-md) var(--cp-spacing-xl);
225
+ }
226
+ }
227
+
228
+ .scale-elastic-enter-active,
229
+ .scale-elastic-leave-active {
230
+ transition:
231
+ scale 200ms var(--cp-easing-elastic),
232
+ opacity 200ms ease;
233
+ }
234
+
235
+ .scale-elastic-enter-from,
236
+ .scale-elastic-leave-to {
237
+ opacity: 0;
238
+ scale: 0.9;
239
+ }
240
+
241
+ .p-drawer-enter-active,
242
+ .p-drawer-leave-active {
243
+ transition:
244
+ transform 300ms ease,
245
+ opacity 250ms ease;
246
+ }
247
+
248
+ .p-drawer-enter-from,
249
+ .p-drawer-leave-to {
250
+ transform: translateY(100%);
251
+ }
252
+ </style>
@@ -5,67 +5,73 @@
5
5
  :class="dynamicClass"
6
6
  :disabled="disabled"
7
7
  type="button"
8
- @click="handleItemClick"
8
+ @click.stop="handleItemClick"
9
9
  >
10
10
  <transition :duration="100" mode="out-in" name="fade">
11
11
  <span v-if="isLoading" class="cpMenuItem__loaderWrapper">
12
- <cp-loader class="cpMenuItem__loader" color="#B2B2BD" />
12
+ <cp-loader class="cpMenuItem__loader" color="accent" size="2xs" />
13
13
  </span>
14
- <template v-else>
15
- <slot name="icon">
16
- <cp-icon class="cpMenuItem__icon" :type="icon" />
17
- </slot>
18
- </template>
14
+ <slot v-else-if="hasLeadingIcon" name="leading-icon">
15
+ <cp-icon :aria-label="ariaLabel" class="cpMenuItem__icon" size="16" :type="leadingIcon" />
16
+ </slot>
19
17
  </transition>
20
- <span v-if="displayLabel" v-tooltip="tooltip" class="cpMenuItem__label">{{ label }}</span>
18
+ <span v-if="!hideLabel" v-tooltip="tooltip" class="cpMenuItem__label">{{ label }}</span>
19
+ <slot v-if="hasTrailingIcon" name="trailing-icon">
20
+ <cp-icon :aria-label="ariaLabel" class="cpMenuItem__icon" size="16" :type="trailingIcon" />
21
+ </slot>
21
22
  </button>
22
23
  </div>
23
24
  </template>
24
25
 
25
26
  <script setup lang="ts">
26
- import { computed } from 'vue'
27
+ import { computed, useSlots } from 'vue'
27
28
 
28
29
  import type { MenuItem } from 'primevue/menuitem'
29
30
 
30
31
  interface Props {
31
- hideLabel?: boolean
32
32
  isAsync?: boolean
33
33
  isCritical?: boolean
34
- isDisabled?: boolean
35
34
  isLoading?: boolean
36
- reverseLabel?: boolean
35
+ isSelected?: boolean
36
+ leadingIcon?: string
37
37
  tooltip?: string
38
+ trailingIcon?: string
38
39
  }
39
40
 
40
- const props = withDefaults(defineProps<Props & Omit<MenuItem, 'class' | 'disabled' | 'key'>>(), {
41
+ const props = withDefaults(defineProps<Props & Omit<MenuItem, 'class' | 'key' | 'icon'>>(), {
41
42
  label: '',
42
43
  tooltip: '',
43
- icon: '',
44
44
  command: undefined,
45
+ leadingIcon: undefined,
46
+ trailingIcon: undefined,
47
+ isSelected: false,
48
+ hideLabel: false,
45
49
  })
46
50
 
47
- const emit = defineEmits(['onItemClick', 'onAsyncCommandComplete'])
51
+ const emit = defineEmits(['click', 'asyncComplete'])
52
+
53
+ const slots = useSlots()
48
54
 
49
55
  const dynamicClass = computed(() => ({
50
- 'cpMenuItem__button--reverseLabel': props.reverseLabel,
51
56
  'cpMenuItem__button--isCritical': props.isCritical,
57
+ 'cpMenuItem__button--isSelected': props.isSelected,
52
58
  }))
53
59
 
54
- const disabled = computed(() => props.isLoading || props.isDisabled)
55
-
56
- const displayLabel = computed(() => !props.hideLabel && props.label)
60
+ const disabled = computed(() => props.isLoading || props.disabled)
61
+ const hasLeadingIcon = computed(() => !!props.leadingIcon || !!slots['leading-icon'])
62
+ const hasTrailingIcon = computed(() => !!props.trailingIcon || !!slots['trailing-icon'])
63
+ const ariaLabel = computed(() => (props.hideLabel ? props.label : undefined))
57
64
 
58
65
  const handleItemClick = async (event: Event) => {
59
- if (props.isAsync && props.command) {
60
- // Stop the event from bubbling up to prevent menu auto close
61
- event.stopPropagation()
62
-
63
- await props.command({ originalEvent: event, item: props })
64
-
65
- emit('onAsyncCommandComplete')
66
+ if (!props.command) return
67
+
68
+ if (props.isAsync) {
69
+ await props.command(event)
70
+ emit('asyncComplete')
71
+ } else {
72
+ props.command(event)
73
+ emit('click')
66
74
  }
67
-
68
- emit('onItemClick')
69
75
  }
70
76
  </script>
71
77
 
@@ -73,78 +79,57 @@ const handleItemClick = async (event: Event) => {
73
79
  .cpMenuItem {
74
80
  padding: 0 var(--cp-spacing-sm);
75
81
 
76
- > * {
77
- padding: var(--cp-spacing-md) var(--cp-spacing-md);
78
- }
79
-
80
82
  &__button {
81
83
  @extend %u-focus-outline;
82
84
  @extend %u-text-ellipsis;
83
85
 
84
- [data-p-focused='true'] & {
85
- background-color: var(--cp-background-primary-hover);
86
- }
87
-
88
- [data-p-focused='true'] &--isCritical {
89
- background-color: var(--cp-background-error-primary-hover);
90
- }
86
+ --cp-menu-item-background-color: var(--cp-background-primary);
87
+ --cp-menu-item-background-color-hover: var(--cp-background-primary-hover);
88
+ --cp-menu-item-color: var(--cp-text-primary);
89
+ --cp-menu-item-color-hover: var(--cp-text-primary);
91
90
 
91
+ padding: var(--cp-spacing-sm-md) var(--cp-spacing-md);
92
92
  position: relative;
93
- color: var(--cp-text-primary);
93
+ color: var(--cp-menu-item-color);
94
+ background-color: var(--cp-menu-item-background-color);
94
95
  display: flex;
95
96
  width: 100%;
96
97
  align-items: center;
97
- gap: var(--cp-spacing-md);
98
+ gap: var(--cp-spacing-lg);
99
+ font-size: var(--cp-text-size-sm);
98
100
  line-height: var(--cp-line-height-sm);
99
101
  text-align: start;
102
+ transition:
103
+ background-color 100ms ease-in-out,
104
+ color 100ms ease-in-out;
100
105
 
101
- &--reverseLabel {
102
- flex-direction: row-reverse;
106
+ &:hover,
107
+ &:focus-visible {
108
+ background-color: var(--cp-menu-item-background-color-hover);
109
+ color: var(--cp-menu-item-color-hover);
103
110
  }
104
111
 
105
112
  &:disabled {
106
- color: var(--cp-foreground-disabled);
113
+ --cp-menu-item-background-color-hover: var(--cp-background-primary);
114
+ --cp-menu-item-color: var(--cp-text-disabled);
115
+ --cp-menu-item-color-hover: var(--cp-text-disabled);
107
116
  cursor: not-allowed;
108
117
  }
109
118
 
110
- &:hover,
111
- &:focus-visible {
112
- transition:
113
- background-color 0.1s ease-in-out,
114
- color 0.1s ease-in-out;
115
- }
116
-
117
- &:hover:not(:disabled):not(#{&}--isCritical),
118
- &:focus-visible:not(:disabled):not(#{&}--isCritical) {
119
- background-color: var(--cp-background-primary-hover);
120
- color: var(--cp-foreground-primary);
119
+ &--isCritical:not(:disabled) {
120
+ --cp-menu-item-background-color-hover: var(--cp-background-error-primary-hover);
121
+ --cp-menu-item-color: var(--cp-foreground-error-primary);
122
+ --cp-menu-item-color-hover: var(--cp-foreground-error-primary-hover);
121
123
  }
122
124
 
123
- &--isCritical {
124
- color: var(--cp-foreground-error-primary);
125
-
126
- &:is(:hover, :focus-visible):not(:disabled) {
127
- background-color: var(--cp-background-error-primary-hover);
128
- color: var(--cp-foreground-error-primary-hover);
129
- }
125
+ &--isSelected:not(:disabled) {
126
+ --cp-menu-item-background-color: var(--cp-background-accent-primary);
127
+ --cp-menu-item-background-color-hover: var(--cp-background-accent-primary);
128
+ --cp-menu-item-color: var(--cp-text-accent-primary);
129
+ --cp-menu-item-color-hover: var(--cp-text-accent-primary-hover);
130
130
  }
131
131
  }
132
132
 
133
- &__icon,
134
- &__loader {
135
- flex-shrink: 0;
136
- }
137
-
138
- &__icon,
139
- &__loaderWrapper {
140
- width: var(--cp-dimensions-4);
141
- height: var(--cp-dimensions-4);
142
- }
143
-
144
- &__button:is(:hover, :focus-visible) &__icon {
145
- color: currentColor;
146
- }
147
-
148
133
  &__label {
149
134
  flex: 1;
150
135
  }
@@ -155,10 +140,5 @@ const handleItemClick = async (event: Event) => {
155
140
  align-items: center;
156
141
  justify-content: center;
157
142
  }
158
-
159
- &__loader {
160
- width: calc(var(--cp-dimensions-1) * 5.5);
161
- height: calc(var(--cp-dimensions-1) * 5.5);
162
- }
163
143
  }
164
144
  </style>
@@ -0,0 +1,54 @@
1
+ <template>
2
+ <ul :id="id" class="cpMenuList">
3
+ <li v-for="(item, index) in items" :key="index" :class="getItemClass(item)" :role="getItemRole(item)">
4
+ <cp-menu-item
5
+ v-if="!item.separator"
6
+ v-bind="item"
7
+ role="menuitem"
8
+ @async-complete="emit('asyncComplete')"
9
+ @click="emit('click')"
10
+ />
11
+ </li>
12
+ </ul>
13
+ </template>
14
+
15
+ <script setup lang="ts">
16
+ import type { MenuItem } from 'primevue/menuitem'
17
+
18
+ import CpMenuItem from '@/components/CpMenuItem.vue'
19
+
20
+ interface Props {
21
+ id?: string
22
+ items?: MenuItem[]
23
+ }
24
+
25
+ withDefaults(defineProps<Props>(), {
26
+ id: undefined,
27
+ items: () => [],
28
+ })
29
+
30
+ const emit = defineEmits(['click', 'asyncComplete'])
31
+
32
+ const getItemClass = (item: MenuItem) => (item.separator ? 'cpMenuList__separator' : 'cpMenuList__item')
33
+ const getItemRole = (item: MenuItem) => (item.separator ? 'separator' : 'none')
34
+ </script>
35
+
36
+ <style lang="scss">
37
+ .cpMenuList {
38
+ margin: 0;
39
+ padding: 0;
40
+ list-style: none;
41
+ display: flex;
42
+ flex-direction: column;
43
+ gap: var(--cp-spacing-sm);
44
+
45
+ &__item {
46
+ display: block;
47
+ }
48
+
49
+ &__separator {
50
+ height: 1px;
51
+ background-color: var(--cp-border-soft);
52
+ }
53
+ }
54
+ </style>
@@ -33,7 +33,9 @@ import CpIcon from './CpIcon.vue'
33
33
  import CpInput from './CpInput.vue'
34
34
  import CpItemActions from './CpItemActions.vue'
35
35
  import CpLoader from './CpLoader.vue'
36
+ import CpMenu from './CpMenu.vue'
36
37
  import CpMenuItem from './CpMenuItem.vue'
38
+ import CpMenuList from './CpMenuList.vue'
37
39
  import CpMultiselect from './CpMultiselect.vue'
38
40
  import CpRadio from './CpRadio.vue'
39
41
  import CpRadioGroup from './CpRadioGroup.vue'
@@ -79,7 +81,9 @@ const Components = {
79
81
  CpDialog,
80
82
  CpDate,
81
83
  CpContextualMenu,
84
+ CpMenu,
82
85
  CpMenuItem,
86
+ CpMenuList,
83
87
  CpItemActions,
84
88
  CpCoreDatepicker,
85
89
  CpDatepicker,
@@ -152,7 +156,9 @@ export {
152
156
  CpDialog,
153
157
  CpDate,
154
158
  CpContextualMenu,
159
+ CpMenu,
155
160
  CpMenuItem,
161
+ CpMenuList,
156
162
  CpItemActions,
157
163
  CpCoreDatepicker,
158
164
  CpDatepicker,