@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@citizenplane/pimp",
3
- "version": "17.0.12",
3
+ "version": "18.0.0",
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,245 @@
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
+ block-scroll
18
+ close-on-escape
19
+ dismissable
20
+ position="bottom"
21
+ :pt="drawerPt"
22
+ :show-close-icon="false"
23
+ @after-show="focusCloseButton"
24
+ @hide="onHide"
25
+ >
26
+ <div class="cpMenu__drawerToolbar">
27
+ <cp-button
28
+ ref="closeButton"
29
+ appearance="tertiary"
30
+ aria-label="Close drawer"
31
+ color="neutral"
32
+ is-square
33
+ size="sm"
34
+ @click="hide"
35
+ >
36
+ <template #leading-icon>
37
+ <cp-icon size="20" type="x" />
38
+ </template>
39
+ </cp-button>
40
+ </div>
41
+ <cp-menu-list v-if="hasItems" :id="menuId" :items="items" @async-complete="onItemClick" @click="onItemClick" />
42
+ <slot v-else />
43
+ </primevue-drawer>
44
+
45
+ <primevue-popover
46
+ v-else
47
+ ref="popover"
48
+ close-on-escape
49
+ dismissable
50
+ :pt="popoverPt"
51
+ @hide="onHide"
52
+ @show="isOpen = true"
53
+ >
54
+ <cp-menu-list v-if="hasItems" :id="menuId" :items="items" @async-complete="onItemClick" @click="onItemClick" />
55
+ <slot v-else />
56
+ </primevue-popover>
57
+ </div>
58
+ </template>
59
+
60
+ <script setup lang="ts">
61
+ import PrimevueDrawer from 'primevue/drawer'
62
+ import PrimevuePopover from 'primevue/popover'
63
+ import { computed, nextTick, onUnmounted, ref, useId } from 'vue'
64
+
65
+ import type { MenuItem } from 'primevue/menuitem'
66
+
67
+ import CpButton from '@/components/CpButton.vue'
68
+ import CpIcon from '@/components/CpIcon.vue'
69
+ import CpMenuList from '@/components/CpMenuList.vue'
70
+
71
+ import { getKeyboardFocusableElements } from '@/helpers/dom'
72
+
73
+ export interface Props {
74
+ class?: string
75
+ forcePopover?: boolean
76
+ items?: MenuItem[]
77
+ keepOpenOnClick?: boolean
78
+ }
79
+
80
+ const props = withDefaults(defineProps<Props>(), {
81
+ class: undefined,
82
+ items: undefined,
83
+ keepOpenOnClick: false,
84
+ })
85
+
86
+ const MOBILE_BREAKPOINT_PX = 640
87
+
88
+ const trigger = ref<HTMLElement | null>(null)
89
+ const popover = ref<InstanceType<typeof PrimevuePopover>>()
90
+ const closeButton = ref<InstanceType<typeof CpButton> | null>(null)
91
+ const isOpen = ref(false)
92
+ const menuId = useId()
93
+
94
+ const mediaQuery = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT_PX}px)`)
95
+ const isMobileViewport = ref(mediaQuery.matches)
96
+ const onMediaChange = (event: MediaQueryListEvent) => {
97
+ isMobileViewport.value = event.matches
98
+ }
99
+ mediaQuery.addEventListener('change', onMediaChange)
100
+ onUnmounted(() => mediaQuery.removeEventListener('change', onMediaChange))
101
+
102
+ const isDrawer = computed(() => isMobileViewport.value && !props.forcePopover)
103
+ const hasItems = computed(() => props.items?.length)
104
+ const popupType = computed(() => (hasItems.value ? 'menu' : 'dialog'))
105
+
106
+ const popoverPt = {
107
+ root: { class: ['cpMenu__overlay', props.class] },
108
+ content: { class: 'cpMenu__overlayContent' },
109
+ transition: { name: 'scale-elastic', duration: 100 },
110
+ }
111
+
112
+ const drawerPt = {
113
+ root: { class: 'cpMenu__drawer' },
114
+ content: { class: 'cpMenu__drawerContent' },
115
+ mask: { class: 'cpMenu__drawerMask' },
116
+ header: { class: 'cpMenu__drawerHeader' },
117
+ }
118
+
119
+ const show = (event: Event) => {
120
+ if (isDrawer.value) isOpen.value = true
121
+ else popover.value?.show(event, trigger.value)
122
+ }
123
+
124
+ const hide = () => {
125
+ if (isDrawer.value) isOpen.value = false
126
+ else popover.value?.hide()
127
+ }
128
+
129
+ const onItemClick = () => {
130
+ if (!props.keepOpenOnClick) hide()
131
+ }
132
+
133
+ const toggle = (event: Event) => {
134
+ if (isDrawer.value) isOpen.value = !isOpen.value
135
+ else popover.value?.toggle(event, trigger.value)
136
+ }
137
+
138
+ const focusTrigger = () => {
139
+ if (!trigger.value) return
140
+ const [first] = getKeyboardFocusableElements(trigger.value) as HTMLElement[]
141
+ first?.focus()
142
+ }
143
+
144
+ const focusCloseButton = () => {
145
+ nextTick(() => (closeButton.value?.$el as HTMLElement | undefined)?.focus())
146
+ }
147
+
148
+ const onHide = () => {
149
+ isOpen.value = false
150
+ focusTrigger()
151
+ }
152
+
153
+ defineExpose({ show, hide, toggle })
154
+ </script>
155
+
156
+ <style lang="scss">
157
+ .cpMenu {
158
+ display: contents;
159
+
160
+ &__trigger {
161
+ display: inline-flex;
162
+ cursor: pointer;
163
+ }
164
+
165
+ &__overlay {
166
+ position: absolute;
167
+ z-index: 22;
168
+ margin-top: var(--cp-spacing-lg);
169
+ min-width: calc(var(--cp-dimensions-1) * 62.5);
170
+ border-radius: var(--cp-radius-md);
171
+ background-color: var(--cp-background-primary);
172
+ box-shadow: var(--cp-shadows-overlay);
173
+ will-change: opacity, transform;
174
+ transform-origin: top;
175
+ }
176
+
177
+ &__overlayContent {
178
+ padding: var(--cp-spacing-sm) 0;
179
+ }
180
+
181
+ &__drawerMask {
182
+ position: fixed;
183
+ inset: 0;
184
+ z-index: 21;
185
+ display: flex;
186
+ align-items: flex-end;
187
+ justify-content: center;
188
+ background-color: var(--cp-background-overlay);
189
+ }
190
+
191
+ &__drawer {
192
+ position: relative;
193
+ z-index: 22;
194
+ width: 100%;
195
+ max-height: 85vh;
196
+ display: flex;
197
+ flex-direction: column;
198
+ background-color: var(--cp-background-primary);
199
+ border-radius: var(--cp-radius-lg) var(--cp-radius-lg) 0 0;
200
+ box-shadow: var(--cp-shadows-overlay);
201
+ padding-bottom: env(safe-area-inset-bottom);
202
+ }
203
+
204
+ &__drawerHeader {
205
+ display: none;
206
+ }
207
+
208
+ &__drawerContent {
209
+ flex: 1;
210
+ overflow-y: auto;
211
+ padding-bottom: var(--cp-spacing-sm);
212
+ }
213
+
214
+ &__drawerToolbar {
215
+ display: flex;
216
+ justify-content: flex-end;
217
+ padding: var(--cp-spacing-xs) var(--cp-spacing-sm);
218
+ }
219
+ }
220
+
221
+ .scale-elastic-enter-active,
222
+ .scale-elastic-leave-active {
223
+ transition:
224
+ scale 200ms var(--cp-easing-elastic),
225
+ opacity 200ms ease;
226
+ }
227
+
228
+ .scale-elastic-enter-from,
229
+ .scale-elastic-leave-to {
230
+ opacity: 0;
231
+ scale: 0.9;
232
+ }
233
+
234
+ .p-drawer-enter-active,
235
+ .p-drawer-leave-active {
236
+ transition:
237
+ transform 300ms ease,
238
+ opacity 250ms ease;
239
+ }
240
+
241
+ .p-drawer-enter-from,
242
+ .p-drawer-leave-to {
243
+ transform: translateY(100%);
244
+ }
245
+ </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>
@@ -73,6 +73,7 @@ const dynamicClasses = computed(() => {
73
73
 
74
74
  <style lang="scss">
75
75
  .cpSelectableButton {
76
+ display: inline-flex;
76
77
  border-radius: var(--cp-selectable-border-radius);
77
78
  padding: var(--cp-selectable-border-padding);
78
79
  font-size: var(--cp-selectable-font-size);
@@ -179,7 +180,7 @@ const dynamicClasses = computed(() => {
179
180
  --cp-selectable-line-height: var(--cp-line-height-sm);
180
181
  --cp-selectable-body-padding: var(--cp-spacing-xs) var(--cp-spacing-sm);
181
182
  --cp-selectable-body-gap: var(--cp-spacing-sm);
182
- --cp-selectable-body-border-radius: var(--cp-radius-sm);
183
+ --cp-selectable-body-border-radius: fn.px-to-rem(5);
183
184
  --cp-selectable-icon-size: var(--cp-dimensions-4);
184
185
  }
185
186
 
@@ -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,