@citizenplane/pimp 18.8.2 → 18.9.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.8.2",
3
+ "version": "18.9.0",
4
4
  "scripts": {
5
5
  "dev": "storybook dev -p 8081",
6
6
  "build-storybook": "storybook build --output-dir ./docs",
@@ -47,6 +47,7 @@
47
47
  "unplugin-vue-components": ">=0.26.0"
48
48
  },
49
49
  "dependencies": {
50
+ "@douxcode/vue-spring-bottom-sheet": "^3.2.0",
50
51
  "@vueuse/core": "^14.3.0",
51
52
  "feather-icons": "^4.29.2",
52
53
  "floating-vue": "^5.2.2",
@@ -0,0 +1,123 @@
1
+ <template>
2
+ <vue-bottom-sheet
3
+ ref="bottomSheetRef"
4
+ :can-backdrop-close="canBackdropClose"
5
+ :can-swipe-close="canSwipeClose"
6
+ :duration="animationDuration"
7
+ expand-on-content-drag
8
+ :swipe-close-threshold="swipeCloseThreshold"
9
+ teleport-defer
10
+ @closed="onBottomSheetClosed"
11
+ @opened="onBottomSheetOpened"
12
+ >
13
+ <template #header>
14
+ <slot name="header" />
15
+ </template>
16
+ <main class="cpBottomSheet__content" tabindex="0">
17
+ <slot />
18
+ </main>
19
+ <template #footer>
20
+ <slot name="footer" />
21
+ </template>
22
+ </vue-bottom-sheet>
23
+ </template>
24
+
25
+ <script setup lang="ts">
26
+ import VueBottomSheet from '@douxcode/vue-spring-bottom-sheet'
27
+ import { useTemplateRef, watch } from 'vue'
28
+
29
+ interface Emits {
30
+ (e: 'close' | 'open'): void
31
+ }
32
+
33
+ interface Props {
34
+ animationDuration?: number
35
+ canBackdropClose?: boolean
36
+ canSwipeClose?: boolean
37
+ preventClose?: boolean
38
+ swipeCloseThreshold?: string
39
+ }
40
+
41
+ const props = withDefaults(defineProps<Props>(), {
42
+ animationDuration: 300,
43
+ swipeCloseThreshold: '20%',
44
+ })
45
+
46
+ const emit = defineEmits<Emits>()
47
+
48
+ const bottomSheetRef = useTemplateRef('bottomSheetRef')
49
+ const isVisibleModel = defineModel<boolean>()
50
+
51
+ const toggleBottomSheet = () => {
52
+ if (isVisibleModel.value) {
53
+ bottomSheetRef.value?.open()
54
+ return
55
+ }
56
+
57
+ bottomSheetRef.value?.close()
58
+ }
59
+
60
+ watch(isVisibleModel, () => toggleBottomSheet())
61
+
62
+ const onBottomSheetOpened = () => emit('open')
63
+
64
+ const onBottomSheetClosed = () => {
65
+ if (props.preventClose) return
66
+
67
+ emit('close')
68
+ }
69
+
70
+ defineExpose({
71
+ open: () => bottomSheetRef.value?.open(),
72
+ close: () => bottomSheetRef.value?.close(),
73
+ })
74
+ </script>
75
+
76
+ <style lang="scss">
77
+ .cpBottomSheet {
78
+ &__content {
79
+ @extend %u-layout-box-padding;
80
+ }
81
+ }
82
+
83
+ /* Package style overrides */
84
+
85
+ /* stylelint-disable */
86
+
87
+ :root {
88
+ --vsbs-backdrop-bg: var(--cp-background-overlay);
89
+ }
90
+
91
+ [data-vsbs-container] {
92
+ z-index: 5 !important;
93
+ }
94
+
95
+ [data-vsbs-content] {
96
+ // override default padding for vue-bottom sheet content div
97
+ padding: 0 !important;
98
+ }
99
+
100
+ [data-vsbs-sheet] [data-vsbs-header] {
101
+ padding: var(--cp-spacing-2xl) var(--cp-spacing-xl) var(--cp-spacing-xl) var(--cp-spacing-xl);
102
+ }
103
+
104
+ [data-vsbs-sheet] {
105
+ overflow: hidden;
106
+ max-height: calc(100% - 65px) !important;
107
+ }
108
+
109
+ [data-vsbs-scroll] {
110
+ -webkit-overflow-scrolling: touch;
111
+ touch-action: pan-y;
112
+ overscroll-behavior: unset !important;
113
+ }
114
+
115
+ [data-vsbs-footer]:is(.vsbs-enter-active) {
116
+ pointer-events: none;
117
+ }
118
+
119
+ [data-vsbs-footer]:has(div:empty) {
120
+ display: none;
121
+ }
122
+ /* stylelint-enable */
123
+ </style>
@@ -0,0 +1,121 @@
1
+ <template>
2
+ <component :is="dynamicWrapperComponent">
3
+ <component
4
+ :is="dynamicComponent"
5
+ v-if="dynamicRenderingCondition"
6
+ v-bind="dynamicComponentProps"
7
+ ref="dynamicComponentRef"
8
+ @close="emit('close')"
9
+ >
10
+ <!-- Header slot is only for bottom sheet -->
11
+ <template v-if="title" #header>
12
+ <slot name="title">
13
+ <cp-heading class="cpDynamicDialog__title" heading-level="h3">{{ title }}</cp-heading>
14
+ </slot>
15
+ </template>
16
+ <template v-if="title" #title>
17
+ <slot name="title" />
18
+ </template>
19
+ <template v-if="subtitle" #subtitle>
20
+ <slot name="subtitle" />
21
+ </template>
22
+ <template #default>
23
+ <slot />
24
+ </template>
25
+ <template #footer>
26
+ <slot name="footer" />
27
+ </template>
28
+ </component>
29
+ </component>
30
+ </template>
31
+
32
+ <script setup lang="ts">
33
+ import { computed, nextTick, resolveComponent, useTemplateRef, watch } from 'vue'
34
+
35
+ import { useIsBreakpoint } from '@/composables/useIsBreakpoint'
36
+
37
+ interface Props {
38
+ class?: string
39
+ isClosableOnClickOutside?: boolean
40
+ maxWidth?: number | undefined
41
+ preventClose?: boolean
42
+ subtitle?: string
43
+ title?: string
44
+ }
45
+
46
+ const props = defineProps<Props>()
47
+
48
+ const emit = defineEmits<{ (e: 'close'): void }>()
49
+
50
+ const isMobile = useIsBreakpoint()
51
+
52
+ const dynamicComponentRef = useTemplateRef('dynamicComponentRef')
53
+
54
+ const isVisibleModel = defineModel<boolean>('isVisible', { default: false })
55
+
56
+ const ClientOnly = resolveComponent('client-only')
57
+ const CpTransitionDialog = resolveComponent('cp-transition-dialog')
58
+ const CpBottomSheet = resolveComponent('cp-bottom-sheet')
59
+ const CpDialog = resolveComponent('cp-dialog')
60
+
61
+ const dynamicWrapperComponent = computed(() => {
62
+ if (isMobile.value) return ClientOnly
63
+ return CpTransitionDialog
64
+ })
65
+
66
+ const dynamicComponent = computed(() => {
67
+ if (isMobile.value) return CpBottomSheet
68
+ return CpDialog
69
+ })
70
+
71
+ const dynamicRenderingCondition = computed(() => {
72
+ if (isMobile.value) return true
73
+ return isVisibleModel.value
74
+ })
75
+
76
+ const dynamicComponentProps = computed(() => {
77
+ if (isMobile.value) {
78
+ return {
79
+ modelValue: isVisibleModel.value,
80
+ contentClass: props.class,
81
+ canBackdropClose: !props.preventClose,
82
+ canSwipeClose: !props.preventClose,
83
+ preventClose: props.preventClose,
84
+ }
85
+ }
86
+
87
+ return {
88
+ maxWidth: props.maxWidth,
89
+ class: props.class,
90
+ preventClose: props.preventClose,
91
+ isClosableOnClickOutside: props.isClosableOnClickOutside,
92
+ ...(props.title && { title: props.title }),
93
+ ...(props.subtitle && { subtitle: props.subtitle }),
94
+ }
95
+ })
96
+
97
+ const openBottomSheet = async () => {
98
+ await nextTick()
99
+
100
+ if (!dynamicComponentRef.value) return
101
+
102
+ // @ts-expect-error - open does not exist on type {}
103
+ dynamicComponentRef.value?.open()
104
+ }
105
+
106
+ watch(isMobile, () => {
107
+ if (isMobile.value && isVisibleModel.value) {
108
+ openBottomSheet()
109
+ }
110
+ })
111
+ </script>
112
+
113
+ <style lang="scss">
114
+ .cpDynamicDialog {
115
+ &__title {
116
+ font-size: var(--cp-text-size-2xl);
117
+ font-weight: 700;
118
+ line-height: var(--cp-line-height-2xl);
119
+ }
120
+ }
121
+ </style>
@@ -96,6 +96,8 @@ const uniqueAirlines = computed(() => {
96
96
 
97
97
  <style lang="scss">
98
98
  .cpTrip {
99
+ width: 100%;
100
+
99
101
  &__header {
100
102
  display: flex;
101
103
  align-items: center;
@@ -126,6 +128,7 @@ const uniqueAirlines = computed(() => {
126
128
  position: relative;
127
129
  display: flex;
128
130
  flex-direction: column;
131
+ align-items: flex-start;
129
132
  justify-content: center;
130
133
 
131
134
  &:last-child {
@@ -1,6 +1,7 @@
1
1
  import 'vue-tel-input/vue-tel-input.css'
2
2
  import 'floating-vue/dist/style.css'
3
3
  import 'modern-normalize/modern-normalize.css'
4
+ import '@douxcode/vue-spring-bottom-sheet/dist/style.css'
4
5
 
5
6
  import '@/assets/main.css'
6
7
 
@@ -19,6 +20,7 @@ import CpAccordionGroup from './CpAccordionGroup.vue'
19
20
  import CpAirlineLogo from './CpAirlineLogo.vue'
20
21
  import CpAlert from './CpAlert.vue'
21
22
  import CpBadge from './CpBadge.vue'
23
+ import CpBottomSheet from './CpBottomSheet.vue'
22
24
  import CpButton from './CpButton.vue'
23
25
  import CpButtonGroup from './CpButtonGroup.vue'
24
26
  import CpButtonToggle from './CpButtonToggle.vue'
@@ -28,6 +30,7 @@ import CpContextualMenu from './CpContextualMenu.vue'
28
30
  import CpDate from './CpDate.vue'
29
31
  import CpDatepicker from './CpDatepicker.vue'
30
32
  import CpDialog from './CpDialog.vue'
33
+ import CpDynamicDialog from './CpDynamicDialog.vue'
31
34
  import CpHeading from './CpHeading.vue'
32
35
  import CpIcon from './CpIcon.vue'
33
36
  import CpInput from './CpInput.vue'
@@ -81,6 +84,7 @@ const Components = {
81
84
  CpButton,
82
85
  CpButtonGroup,
83
86
  CpDialog,
87
+ CpDynamicDialog,
84
88
  CpDate,
85
89
  CpContextualMenu,
86
90
  CpMenu,
@@ -153,6 +157,7 @@ export {
153
157
  CpAirlineLogo,
154
158
  CpAlert,
155
159
  CpBadge,
160
+ CpBottomSheet,
156
161
  CpButton,
157
162
  CpButtonGroup,
158
163
  CpButtonToggle,
@@ -0,0 +1,135 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3-vite'
2
+ import { ref } from 'vue'
3
+
4
+ import CpBottomSheet from '@/components/CpBottomSheet.vue'
5
+
6
+ const meta = {
7
+ title: 'Organisms/CpBottomSheet',
8
+ component: CpBottomSheet,
9
+ globals: {
10
+ viewport: { value: 'mobile1', isRotated: false },
11
+ },
12
+ parameters: {
13
+ layout: 'fullscreen',
14
+ },
15
+ argTypes: {
16
+ animationDuration: {
17
+ control: 'number',
18
+ description: 'Animation duration in milliseconds',
19
+ },
20
+ canBackdropClose: {
21
+ control: 'boolean',
22
+ description: 'Allow closing by tapping the backdrop',
23
+ },
24
+ canSwipeClose: {
25
+ control: 'boolean',
26
+ description: 'Enable swipe-to-close gesture',
27
+ },
28
+ preventClose: {
29
+ control: 'boolean',
30
+ description: 'Prevent the sheet from emitting close when dismissed',
31
+ },
32
+ swipeCloseThreshold: {
33
+ control: 'text',
34
+ description: 'Translation threshold after which the sheet closes (px or %)',
35
+ },
36
+ onClose: { action: 'close' },
37
+ onOpen: { action: 'open' },
38
+ },
39
+ } satisfies Meta<typeof CpBottomSheet>
40
+
41
+ export default meta
42
+ type Story = StoryObj<typeof meta>
43
+
44
+ /**
45
+ * Default bottom sheet featuring header, body and footer slots.
46
+ * Use the controls to experiment with each prop in isolation.
47
+ */
48
+ export const Default: Story = {
49
+ args: {
50
+ animationDuration: 300,
51
+ canBackdropClose: true,
52
+ canSwipeClose: true,
53
+ swipeCloseThreshold: '20%',
54
+ },
55
+ render: (args) => ({
56
+ setup() {
57
+ const isVisible = ref(false)
58
+ return { args, isVisible }
59
+ },
60
+ template: `
61
+ <CpButton @click="isVisible = true">Open Bottom Sheet</CpButton>
62
+ <CpBottomSheet
63
+ v-bind="args"
64
+ v-model="isVisible"
65
+ @close="isVisible = false"
66
+ >
67
+ <template #header><strong>Header slot</strong></template>
68
+ <p>This is the default slot content. You can put any content here.</p>
69
+ <template #footer>
70
+ <CpButton @click="isVisible = false">Close</CpButton>
71
+ </template>
72
+ </CpBottomSheet>
73
+ `,
74
+ }),
75
+ }
76
+
77
+ /**
78
+ * Disable backdrop tap to close — the sheet can only be dismissed
79
+ * programmatically or via swipe (when enabled).
80
+ */
81
+ export const NoBackdropClose: Story = {
82
+ args: {
83
+ canBackdropClose: false,
84
+ },
85
+ render: (args) => ({
86
+ setup() {
87
+ const isVisible = ref(false)
88
+ return { args, isVisible }
89
+ },
90
+ template: `
91
+ <CpButton @click="isVisible = true">Open Bottom Sheet</CpButton>
92
+ <CpBottomSheet
93
+ v-bind="args"
94
+ v-model="isVisible"
95
+ @close="isVisible = false"
96
+ >
97
+ <template #header><strong>No backdrop close</strong></template>
98
+ <p>Tap the backdrop — the sheet stays open. Use the button below or swipe down to dismiss.</p>
99
+ <template #footer>
100
+ <CpButton @click="isVisible = false">Close</CpButton>
101
+ </template>
102
+ </CpBottomSheet>
103
+ `,
104
+ }),
105
+ }
106
+
107
+ /**
108
+ * Disable swipe-to-close — the sheet can only be dismissed
109
+ * by tapping the backdrop or using a close action.
110
+ */
111
+ export const NoSwipeClose: Story = {
112
+ args: {
113
+ canSwipeClose: false,
114
+ },
115
+ render: (args) => ({
116
+ setup() {
117
+ const isVisible = ref(false)
118
+ return { args, isVisible }
119
+ },
120
+ template: `
121
+ <CpButton @click="isVisible = true">Open Bottom Sheet</CpButton>
122
+ <CpBottomSheet
123
+ v-bind="args"
124
+ v-model="isVisible"
125
+ @close="isVisible = false"
126
+ >
127
+ <template #header><strong>No swipe close</strong></template>
128
+ <p>Swipe down will not dismiss this sheet. Use the backdrop or the button below.</p>
129
+ <template #footer>
130
+ <CpButton @click="isVisible = false">Close</CpButton>
131
+ </template>
132
+ </CpBottomSheet>
133
+ `,
134
+ }),
135
+ }
@@ -0,0 +1,132 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3-vite'
2
+ import { getCurrentInstance, ref } from 'vue'
3
+
4
+ import CpDynamicDialog from '@/components/CpDynamicDialog.vue'
5
+
6
+ const ClientOnlyStub = { template: '<slot />' }
7
+
8
+ const registerClientOnly = () => {
9
+ const instance = getCurrentInstance()
10
+ instance?.appContext.app.component('client-only', ClientOnlyStub)
11
+ }
12
+
13
+ const STORY_TRIGGER_LAYOUT =
14
+ 'display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 60vh; width: 100%;'
15
+
16
+ const createStoryRender = (content: string) => (args: Record<string, unknown>) => ({
17
+ components: { CpDynamicDialog },
18
+ setup() {
19
+ registerClientOnly()
20
+ const isVisible = ref(false)
21
+ return { args, isVisible }
22
+ },
23
+ template: `
24
+ <div style="${STORY_TRIGGER_LAYOUT}">
25
+ <CpButton @click="isVisible = true">Open Dynamic Dialog</CpButton>
26
+ <p style="margin-top: 8px; text-align: center;">Resize me</p>
27
+ </div>
28
+ <CpDynamicDialog
29
+ v-bind="args"
30
+ v-model:is-visible="isVisible"
31
+ @close="isVisible = false"
32
+ >
33
+ ${content}
34
+ <template #footer>
35
+ <CpButton @click="isVisible = false">Close</CpButton>
36
+ </template>
37
+ </CpDynamicDialog>
38
+ `,
39
+ })
40
+
41
+ const meta = {
42
+ title: 'Organisms/CpDynamicDialog',
43
+ component: CpDynamicDialog,
44
+ parameters: {
45
+ layout: 'fullscreen',
46
+ docs: {
47
+ description: {
48
+ component:
49
+ 'Responsive dialog wrapper that renders `CpDialog` inside `CpTransitionDialog` on desktop and `CpBottomSheet` on mobile viewports. Visibility is controlled with `v-model:is-visible`.',
50
+ },
51
+ },
52
+ },
53
+ argTypes: {
54
+ maxWidth: {
55
+ control: 'number',
56
+ description: 'Maximum width of the desktop dialog (px)',
57
+ },
58
+ isClosableOnClickOutside: {
59
+ control: 'boolean',
60
+ description: 'Allow closing the desktop dialog by clicking the backdrop',
61
+ },
62
+ preventClose: {
63
+ control: 'boolean',
64
+ description: 'Prevent dismiss actions from emitting close',
65
+ },
66
+ title: {
67
+ control: 'text',
68
+ description: 'Title passed as a prop (desktop) or rendered in the bottom sheet header (mobile)',
69
+ },
70
+ subtitle: {
71
+ control: 'text',
72
+ description: 'Subtitle passed as a prop on desktop',
73
+ },
74
+ onClose: { action: 'close' },
75
+ },
76
+ } satisfies Meta<typeof CpDynamicDialog>
77
+
78
+ export default meta
79
+ type Story = StoryObj<typeof meta>
80
+
81
+ /**
82
+ * Desktop viewport — opens a centered dialog with title, subtitle, body and footer slots.
83
+ */
84
+ export const Default: Story = {
85
+ args: {
86
+ maxWidth: 600,
87
+ title: 'Dialog title',
88
+ subtitle: 'Dialog subtitle',
89
+ },
90
+ render: createStoryRender('<p>This is the default slot content. You can put any content here.</p>'),
91
+ }
92
+
93
+ /**
94
+ * Desktop viewport — the same API renders a dialog instead of a bottom sheet.
95
+ */
96
+ export const Desktop: Story = {
97
+ args: {
98
+ maxWidth: 600,
99
+ title: 'Dialog title',
100
+ subtitle: 'Dialog subtitle',
101
+ },
102
+ globals: {
103
+ viewport: { value: 'tablet', isRotated: false },
104
+ },
105
+ render: createStoryRender('<p>On desktop viewports, the dynamic dialog renders as a centered dialog.</p>'),
106
+ }
107
+
108
+ /**
109
+ * Mobile viewport — the same API renders a bottom sheet instead of a dialog.
110
+ */
111
+ export const Mobile: Story = {
112
+ args: {
113
+ title: 'Bottom sheet title',
114
+ },
115
+ globals: {
116
+ viewport: { value: 'mobile1', isRotated: false },
117
+ },
118
+ render: createStoryRender('<p>On mobile viewports, the dynamic dialog becomes a bottom sheet.</p>'),
119
+ }
120
+
121
+ /**
122
+ * Prevent the dialog or bottom sheet from being closed by the user.
123
+ */
124
+ export const PreventClose: Story = {
125
+ args: {
126
+ maxWidth: 600,
127
+ title: 'Dialog title',
128
+ subtitle: 'Dialog subtitle',
129
+ preventClose: true,
130
+ },
131
+ render: createStoryRender('<p>Dismiss actions are blocked — use the button below to close programmatically.</p>'),
132
+ }