@citizenplane/pimp 18.24.0 → 18.25.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": "18.24.0",
3
+ "version": "18.25.0",
4
4
  "scripts": {
5
5
  "dev": "storybook dev -p 8081",
6
6
  "build-storybook": "storybook build --output-dir ./docs",
@@ -75,6 +75,7 @@
75
75
  "@vitest/coverage-v8": "4.1.9",
76
76
  "@vue/test-utils": "2.4.11",
77
77
  "commitlint-config-gitmoji": "2.3.1",
78
+ "flag-icons": "7.5.0",
78
79
  "happy-dom": "20.14.5",
79
80
  "husky": "9.1.7",
80
81
  "lint-staged": "17.2.0",
@@ -0,0 +1,226 @@
1
+ <template>
2
+ <div class="cpDynamicMenu">
3
+ <cp-menu v-if="!isMobile" ref="menuRef" v-bind="menuProps" @hide="onMenuHide" @show="onMenuShow">
4
+ <template #trigger>
5
+ <slot name="trigger" />
6
+ </template>
7
+ <slot />
8
+ </cp-menu>
9
+ <template v-else>
10
+ <div
11
+ ref="triggerRef"
12
+ :aria-controls="menuId"
13
+ :aria-expanded="isSheetVisible"
14
+ :aria-haspopup="popupType"
15
+ class="cpDynamicMenu__trigger"
16
+ @click="toggleSheet"
17
+ >
18
+ <slot name="trigger" />
19
+ </div>
20
+ <cp-bottom-sheet v-model="isSheetVisible" v-bind="bottomSheetProps">
21
+ <template v-if="hasSheetTitle" #header>
22
+ <slot name="sheet-title">
23
+ <cp-heading class="cpDynamicMenu__sheetTitle" heading-level="h3">{{ sheetTitle }}</cp-heading>
24
+ </slot>
25
+ </template>
26
+ <cp-menu-list v-if="hasItems" :id="menuId" :items="items" @async-complete="onItemClick" @click="onItemClick" />
27
+ <slot v-else />
28
+ <template #footer>
29
+ <slot name="footer" />
30
+ </template>
31
+ </cp-bottom-sheet>
32
+ </template>
33
+ </div>
34
+ </template>
35
+
36
+ <script setup lang="ts">
37
+ import type { MenuItem } from 'primevue/menuitem'
38
+ import { computed, ref, useId, useSlots, useTemplateRef, watch } from 'vue'
39
+
40
+ import type { CpMenuPlacement } from '@/components/CpMenu.vue'
41
+
42
+ import { useIsBreakpoint } from '@/composables/useIsBreakpoint'
43
+
44
+ import CpBottomSheet from '@/components/CpBottomSheet.vue'
45
+ import CpHeading from '@/components/CpHeading.vue'
46
+ import CpMenu from '@/components/CpMenu.vue'
47
+ import CpMenuList from '@/components/CpMenuList.vue'
48
+
49
+ import { Breakpoints } from '@/constants/layout'
50
+ import { getKeyboardFocusableElements } from '@/helpers/dom'
51
+
52
+ interface Props {
53
+ animationDuration?: number
54
+ breakpoint?: number
55
+ class?: string
56
+ contentClass?: string
57
+ expandOnContentDrag?: boolean
58
+ halfAndExpand?: boolean
59
+ items?: MenuItem[]
60
+ keepOpenOnClick?: boolean
61
+ placement?: CpMenuPlacement
62
+ preventClose?: boolean
63
+ sheetTitle?: string
64
+ snapPoints?: Array<number | `${number}%`>
65
+ swipeCloseThreshold?: string
66
+ }
67
+
68
+ const props = defineProps<Props>()
69
+
70
+ const emits = defineEmits<{
71
+ hide: []
72
+ show: []
73
+ }>()
74
+
75
+ const slots = useSlots()
76
+
77
+ const isMobile = useIsBreakpoint(props.breakpoint ?? Breakpoints.MOBILE)
78
+
79
+ const menuRef = useTemplateRef('menuRef')
80
+ const triggerRef = useTemplateRef<HTMLElement>('triggerRef')
81
+
82
+ const isMenuOpen = ref(false)
83
+ const isSheetVisible = ref(false)
84
+ const menuId = useId()
85
+
86
+ const hasItems = computed(() => Boolean(props.items?.length))
87
+ const hasSheetTitle = computed(() => Boolean(props.sheetTitle || slots['sheet-title']))
88
+
89
+ const popupType = computed(() => {
90
+ if (hasItems.value) return 'menu'
91
+ return 'dialog'
92
+ })
93
+
94
+ const menuProps = computed(() => ({
95
+ class: props.class,
96
+ forcePopover: true,
97
+ items: props.items,
98
+ keepOpenOnClick: props.keepOpenOnClick,
99
+ placement: props.placement,
100
+ }))
101
+
102
+ const sheetContentClass = computed(() => {
103
+ if (!hasItems.value) return props.contentClass
104
+ if (!props.contentClass) return 'cpDynamicMenu__menuListContent'
105
+ return `cpDynamicMenu__menuListContent ${props.contentClass}`
106
+ })
107
+
108
+ const sheetBaseClass = computed(() => {
109
+ if (hasSheetTitle.value) return 'cpDynamicMenu__sheet'
110
+ return 'cpDynamicMenu__sheet cpDynamicMenu__sheet--hasNoTitle'
111
+ })
112
+
113
+ const sheetClass = computed(() => {
114
+ if (!props.class) return sheetBaseClass.value
115
+ return `${sheetBaseClass.value} ${props.class}`
116
+ })
117
+
118
+ const bottomSheetProps = computed(() => ({
119
+ animationDuration: props.animationDuration,
120
+ canBackdropClose: !props.preventClose,
121
+ canSwipeClose: !props.preventClose,
122
+ class: sheetClass.value,
123
+ contentClass: sheetContentClass.value,
124
+ expandOnContentDrag: props.expandOnContentDrag,
125
+ halfAndExpand: props.halfAndExpand,
126
+ preventClose: props.preventClose,
127
+ snapPoints: props.snapPoints,
128
+ swipeCloseThreshold: props.swipeCloseThreshold,
129
+ }))
130
+
131
+ const focusTrigger = () => {
132
+ if (!triggerRef.value) return
133
+ const [firstFocusableElement] = getKeyboardFocusableElements(triggerRef.value) as HTMLElement[]
134
+ firstFocusableElement?.focus()
135
+ }
136
+
137
+ // CpMenu emits show/hide twice when driven imperatively, so only real state changes are relayed.
138
+ const onMenuShow = () => {
139
+ if (isMenuOpen.value) return
140
+ isMenuOpen.value = true
141
+ emits('show')
142
+ }
143
+
144
+ const onMenuHide = () => {
145
+ if (!isMenuOpen.value) return
146
+ isMenuOpen.value = false
147
+ emits('hide')
148
+ }
149
+
150
+ const toggleSheet = () => {
151
+ isSheetVisible.value = !isSheetVisible.value
152
+ }
153
+
154
+ const onItemClick = () => {
155
+ if (props.keepOpenOnClick) return
156
+ isSheetVisible.value = false
157
+ }
158
+
159
+ const show = (event: Event) => {
160
+ if (isMobile.value) {
161
+ isSheetVisible.value = true
162
+ return
163
+ }
164
+ menuRef.value?.show(event)
165
+ }
166
+
167
+ const hide = () => {
168
+ if (isMobile.value) {
169
+ isSheetVisible.value = false
170
+ return
171
+ }
172
+ menuRef.value?.hide()
173
+ }
174
+
175
+ const toggle = (event: Event) => {
176
+ if (isMobile.value) {
177
+ toggleSheet()
178
+ return
179
+ }
180
+ menuRef.value?.toggle(event)
181
+ }
182
+
183
+ watch(isSheetVisible, (isVisible) => {
184
+ if (isVisible) {
185
+ emits('show')
186
+ return
187
+ }
188
+ emits('hide')
189
+ focusTrigger()
190
+ })
191
+
192
+ watch(isMobile, (isMobileViewport) => {
193
+ if (!isMobileViewport) {
194
+ isSheetVisible.value = false
195
+ return
196
+ }
197
+ if (isMenuOpen.value) menuRef.value?.hide()
198
+ })
199
+
200
+ defineExpose({ show, hide, toggle })
201
+ </script>
202
+
203
+ <style lang="scss">
204
+ .cpDynamicMenu {
205
+ display: contents;
206
+
207
+ &__trigger {
208
+ display: inline-flex;
209
+ cursor: pointer;
210
+ }
211
+
212
+ &__sheetTitle {
213
+ font-size: var(--cp-text-size-2xl);
214
+ font-weight: 700;
215
+ line-height: var(--cp-line-height-2xl);
216
+ }
217
+
218
+ &__menuListContent.cpBottomSheet__content {
219
+ padding: var(--cp-spacing-sm) 0;
220
+ }
221
+
222
+ &__sheet--hasNoTitle .cpBottomSheet__header {
223
+ padding-bottom: 0;
224
+ }
225
+ }
226
+ </style>
@@ -0,0 +1,151 @@
1
+ <template>
2
+ <cp-dynamic-menu
3
+ ref="dynamicMenuRef"
4
+ :breakpoint="breakpoint"
5
+ :class="props.class"
6
+ content-class="cpLanguageSwitcher__content"
7
+ :placement="placement"
8
+ >
9
+ <template #trigger>
10
+ <slot name="trigger">
11
+ <cp-button appearance="tertiary" color="accent" size="sm">
12
+ <template #leading-icon>
13
+ <cp-icon type="globe" />
14
+ </template>
15
+ {{ uppercasedCurrentCode }}
16
+ </cp-button>
17
+ </slot>
18
+ </template>
19
+ <ul class="cpLanguageSwitcher__list">
20
+ <li v-for="lang in langs" :key="lang.code">
21
+ <button
22
+ :aria-current="getAriaCurrent(lang.code)"
23
+ class="cpLanguageSwitcher__lang"
24
+ :class="getLangClass(lang.code)"
25
+ type="button"
26
+ @click="selectLanguage(lang.code)"
27
+ >
28
+ <span class="cpLanguageSwitcher__left">
29
+ <span v-if="lang.flag" class="cpLanguageSwitcher__flag" :class="getFlagClass(lang.flag)" />
30
+ <span class="cpLanguageSwitcher__name">{{ lang.name }}</span>
31
+ </span>
32
+ <cp-icon v-if="isCurrentLanguage(lang.code)" class="cpLanguageSwitcher__check" size="20" type="check" />
33
+ </button>
34
+ </li>
35
+ </ul>
36
+ </cp-dynamic-menu>
37
+ </template>
38
+
39
+ <script setup lang="ts">
40
+ import { computed, useTemplateRef } from 'vue'
41
+
42
+ import type { CpMenuPlacement } from '@/components/CpMenu.vue'
43
+
44
+ import CpButton from '@/components/CpButton.vue'
45
+ import CpDynamicMenu from '@/components/CpDynamicMenu.vue'
46
+ import CpIcon from '@/components/CpIcon.vue'
47
+
48
+ export interface LanguageOption {
49
+ code: string
50
+ flag?: string
51
+ name: string
52
+ }
53
+
54
+ interface Props {
55
+ breakpoint?: number
56
+ class?: string
57
+ langs: LanguageOption[]
58
+ placement?: CpMenuPlacement
59
+ }
60
+
61
+ const props = defineProps<Props>()
62
+
63
+ const currentCode = defineModel<string>()
64
+
65
+ const dynamicMenuRef = useTemplateRef('dynamicMenuRef')
66
+
67
+ const uppercasedCurrentCode = computed(() => {
68
+ if (!currentCode.value) return ''
69
+ return currentCode.value.toUpperCase()
70
+ })
71
+
72
+ const isCurrentLanguage = (code: string) => code === currentCode.value
73
+
74
+ const getLangClass = (code: string) => ({ 'cpLanguageSwitcher__lang--isSelected': isCurrentLanguage(code) })
75
+
76
+ // flag-icons is a devDependency only: the consumer app loads flag-icons.min.css itself.
77
+ const getFlagClass = (flag: string) => `fi fi-${flag.toLowerCase()}`
78
+
79
+ const getAriaCurrent = (code: string) => {
80
+ if (!isCurrentLanguage(code)) return undefined
81
+ return 'true'
82
+ }
83
+
84
+ const selectLanguage = (code: string) => {
85
+ if (!isCurrentLanguage(code)) currentCode.value = code
86
+ dynamicMenuRef.value?.hide()
87
+ }
88
+ </script>
89
+
90
+ <style lang="scss">
91
+ .cpLanguageSwitcher {
92
+ &__content.cpBottomSheet__content {
93
+ padding: var(--cp-spacing-sm) 0;
94
+ }
95
+
96
+ &__list {
97
+ display: flex;
98
+ flex-direction: column;
99
+ padding: 0 var(--cp-spacing-sm);
100
+ margin: 0;
101
+ gap: var(--cp-spacing-sm);
102
+ list-style: none;
103
+ }
104
+
105
+ &__lang {
106
+ @extend %u-focus-outline;
107
+
108
+ display: flex;
109
+ width: 100%;
110
+ align-items: center;
111
+ justify-content: space-between;
112
+ padding: var(--cp-spacing-lg) var(--cp-spacing-xl) var(--cp-spacing-lg) var(--cp-spacing-lg);
113
+ border: none;
114
+ border-radius: var(--cp-radius-md);
115
+ background-color: transparent;
116
+ color: var(--cp-text-primary);
117
+ cursor: pointer;
118
+ gap: var(--cp-spacing-md);
119
+
120
+ &:hover {
121
+ background-color: var(--cp-background-primary-hover);
122
+ }
123
+
124
+ &--isSelected {
125
+ background-color: var(--cp-background-accent-primary);
126
+ }
127
+ }
128
+
129
+ &__left {
130
+ display: flex;
131
+ align-items: center;
132
+ gap: var(--cp-spacing-lg);
133
+ }
134
+
135
+ &__flag {
136
+ flex: none;
137
+ border-radius: var(--cp-radius-sm);
138
+ }
139
+
140
+ &__name {
141
+ font-size: var(--cp-text-size-md);
142
+ font-weight: 500;
143
+ line-height: var(--cp-line-height-md);
144
+ }
145
+
146
+ &__check {
147
+ flex: none;
148
+ color: var(--cp-text-accent-primary);
149
+ }
150
+ }
151
+ </style>
@@ -36,12 +36,14 @@ import CpDate from './CpDate.vue'
36
36
  import CpDatepicker from './CpDatepicker.vue'
37
37
  import CpDialog from './CpDialog.vue'
38
38
  import CpDynamicDialog from './CpDynamicDialog.vue'
39
+ import CpDynamicMenu from './CpDynamicMenu.vue'
39
40
  import CpFlightTabs from './CpFlightTabs.vue'
40
41
  import CpHeading from './CpHeading.vue'
41
42
  import CpIcon from './CpIcon.vue'
42
43
  import CpIllustration from './CpIllustration.vue'
43
44
  import CpInput from './CpInput.vue'
44
45
  import CpItemActions from './CpItemActions.vue'
46
+ import CpLanguageSwitcher from './CpLanguageSwitcher.vue'
45
47
  import CpLoader from './CpLoader.vue'
46
48
  import CpMenu from './CpMenu.vue'
47
49
  import CpMenuItem from './CpMenuItem.vue'
@@ -112,11 +114,13 @@ const Components = {
112
114
  CpButtonGroup,
113
115
  CpDialog,
114
116
  CpDynamicDialog,
117
+ CpDynamicMenu,
115
118
  CpDate,
116
119
  CpContextualMenu,
117
120
  CpMenu,
118
121
  CpMenuItem,
119
122
  CpMenuList,
123
+ CpLanguageSwitcher,
120
124
  CpItemActions,
121
125
  CpCoreDatepicker,
122
126
  CpDatepicker,
@@ -203,6 +207,7 @@ const Pimp: Plugin = {
203
207
  },
204
208
  }
205
209
 
210
+ export type { LanguageOption } from '@/components/CpLanguageSwitcher.vue'
206
211
  export type { CpAncillaryFlightSelectionFlight } from '@/constants/CpAncillaryFlightSelectionTypes'
207
212
  export type { CpBasketCategory, CpBasketGroup, CpBasketRow } from '@/constants/CpBasketTypes'
208
213
  export type { CpFlightTabsFlight } from '@/constants/CpFlightTabsTypes'
@@ -266,12 +271,14 @@ export {
266
271
  CpDatepicker,
267
272
  CpDialog,
268
273
  CpDynamicDialog,
274
+ CpDynamicMenu,
269
275
  CpFlightTabs,
270
276
  CpHeading,
271
277
  CpIcon,
272
278
  CpIllustration,
273
279
  CpInput,
274
280
  CpItemActions,
281
+ CpLanguageSwitcher,
275
282
  CpLoader,
276
283
  CpMenu,
277
284
  CpMenuItem,