@citizenplane/pimp 18.13.5 → 18.14.2

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.13.5",
3
+ "version": "18.14.2",
4
4
  "scripts": {
5
5
  "dev": "storybook dev -p 8081",
6
6
  "build-storybook": "storybook build --output-dir ./docs",
@@ -46,8 +46,8 @@
46
46
  "unplugin-vue-components": ">=0.26.0"
47
47
  },
48
48
  "dependencies": {
49
- "@douxcode/vue-spring-bottom-sheet": "3.2.0",
50
49
  "@vueuse/core": "14.3.0",
50
+ "@vueuse/gesture": "2.0.0",
51
51
  "feather-icons": "4.29.2",
52
52
  "floating-vue": "5.2.2",
53
53
  "luxon": "3.7.2",
@@ -1,6 +1,29 @@
1
1
  :root {
2
2
  --cp-easing-elastic: cubic-bezier(0.34, 1.66, 0.64, 1);
3
3
  --cp-easing-elastic-soft: cubic-bezier(0.34, 1.2, 0.64, 1);
4
+ --cp-easing-elastic-strong: linear(
5
+ 0,
6
+ 0.0802,
7
+ 0.2537,
8
+ 0.4497,
9
+ 0.6291,
10
+ 0.774,
11
+ 0.8805,
12
+ 0.9521,
13
+ 0.9955,
14
+ 1.0183,
15
+ 1.0273,
16
+ 1.0279,
17
+ 1.0242,
18
+ 1.0189,
19
+ 1.0134,
20
+ 1.0087,
21
+ 1.005,
22
+ 1.0024,
23
+ 1.0008,
24
+ 0.9998,
25
+ 1
26
+ );
4
27
  --cp-easing-ease-in-out-quart: cubic-bezier(0.76, 0, 0.24, 1);
5
28
  --cp-easing-ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
6
29
  }
@@ -1,31 +1,49 @@
1
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="expandOnContentDrag"
8
- :swipe-close-threshold="swipeCloseThreshold"
9
- teleport-defer
10
- :snap-points="resolvedSnapPoints"
11
- @closed="onBottomSheetClosed"
12
- @opened="onBottomSheetOpened"
13
- >
14
- <template #header>
15
- <slot name="header" />
16
- </template>
17
- <main class="cpBottomSheet__content" tabindex="0">
18
- <slot />
19
- </main>
20
- <template #footer>
21
- <slot name="footer" />
22
- </template>
23
- </vue-bottom-sheet>
2
+ <teleport :to="teleportTarget">
3
+ <div
4
+ v-if="isMounted"
5
+ aria-hidden="true"
6
+ class="cpBottomSheet__backdrop"
7
+ :style="backdropStyle"
8
+ @click="onBackdropClick"
9
+ />
10
+ <div
11
+ v-if="isMounted"
12
+ ref="sheetRef"
13
+ v-bind="$attrs"
14
+ aria-modal="true"
15
+ class="cpBottomSheet"
16
+ role="dialog"
17
+ :style="sheetStyle"
18
+ tabindex="-1"
19
+ @keydown.esc.stop="onSheetEscapeKeydown"
20
+ @keydown.tab="onSheetTabKeydown"
21
+ >
22
+ <header ref="headerRef" v-drag="onHandleDrag" class="cpBottomSheet__header">
23
+ <slot name="header" />
24
+ </header>
25
+ <div ref="scrollRef" v-drag="onContentDrag" class="cpBottomSheet__scroll" @touchmove="onScrollTouchMove">
26
+ <main ref="contentRef" class="cpBottomSheet__content" :class="contentClass" tabindex="0">
27
+ <slot />
28
+ </main>
29
+ </div>
30
+ <footer ref="footerRef" v-drag="onHandleDrag" class="cpBottomSheet__footer">
31
+ <slot name="footer" />
32
+ </footer>
33
+ </div>
34
+ </teleport>
24
35
  </template>
25
36
 
26
37
  <script setup lang="ts">
27
- import VueBottomSheet from '@douxcode/vue-spring-bottom-sheet'
28
- import { computed, useTemplateRef, watch } from 'vue'
38
+ import { defaultDocument, defaultWindow, useEventListener } from '@vueuse/core'
39
+ import { dragDirective } from '@vueuse/gesture'
40
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue'
41
+
42
+ import { useBottomSheetDrag } from '@/composables/useBottomSheetDrag'
43
+
44
+ import { handleTrapFocus } from '@/helpers/dom'
45
+
46
+ type SheetState = 'closed' | 'closing' | 'open' | 'opening'
29
47
 
30
48
  interface Emits {
31
49
  (e: 'close' | 'open'): void
@@ -35,125 +53,298 @@ interface Props {
35
53
  animationDuration?: number
36
54
  canBackdropClose?: boolean
37
55
  canSwipeClose?: boolean
56
+ contentClass?: string
38
57
  expandOnContentDrag?: boolean
39
58
  halfAndExpand?: boolean
40
59
  preventClose?: boolean
41
60
  snapPoints?: Array<number | `${number}%`>
42
61
  swipeCloseThreshold?: string
62
+ teleportTo?: string
43
63
  }
44
64
 
45
65
  const props = withDefaults(defineProps<Props>(), {
46
- animationDuration: 300,
66
+ animationDuration: 600,
67
+ canBackdropClose: true,
68
+ canSwipeClose: true,
47
69
  expandOnContentDrag: true,
48
70
  halfAndExpand: false,
49
71
  snapPoints: () => [],
50
72
  swipeCloseThreshold: '40%',
73
+ teleportTo: '#teleports',
51
74
  })
52
75
 
53
76
  const emit = defineEmits<Emits>()
54
77
 
55
- const bottomSheetRef = useTemplateRef('bottomSheetRef')
78
+ defineOptions({ inheritAttrs: false })
79
+
80
+ const vDrag = dragDirective()
81
+
82
+ const EXIT_DURATION_RATIO = 1 / 3
83
+ const BACKDROP_FADE_DURATION = 300
84
+
56
85
  const isVisibleModel = defineModel<boolean>()
57
86
 
58
- const resolvedSnapPoints = computed((): Array<number | `${number}%`> | undefined => {
59
- if (props.snapPoints?.length) return props.snapPoints
60
- return props.halfAndExpand ? ([`${50}%`, `${100}%`] as Array<number | `${number}%`>) : undefined
87
+ const sheetRef = useTemplateRef<HTMLElement>('sheetRef')
88
+ const headerRef = useTemplateRef<HTMLElement>('headerRef')
89
+ const contentRef = useTemplateRef<HTMLElement>('contentRef')
90
+ const footerRef = useTemplateRef<HTMLElement>('footerRef')
91
+ const scrollRef = useTemplateRef<HTMLElement>('scrollRef')
92
+
93
+ const state = ref<SheetState>('closed')
94
+ const keyboardInset = ref(0)
95
+
96
+ const isMounted = computed(() => state.value !== 'closed')
97
+
98
+ const {
99
+ height,
100
+ isDragging,
101
+ measureNaturalHeight,
102
+ onContentDrag,
103
+ onHandleDrag,
104
+ onScrollTouchMove,
105
+ reset: resetDrag,
106
+ snapToSmallest,
107
+ translateY,
108
+ windowHeight,
109
+ } = useBottomSheetDrag(props, {
110
+ contentRef,
111
+ footerRef,
112
+ headerRef,
113
+ onDismiss: () => dismissSheet(),
114
+ scrollRef,
61
115
  })
62
116
 
63
- // Set right before syncing a self-close down to the model, so the resulting
64
- // watch trigger doesn't re-issue a close() the underlying sheet already ran.
65
- let skipNextToggle = false
117
+ const teleportTarget = computed(() => {
118
+ if (!defaultDocument) return props.teleportTo
119
+ return defaultDocument.querySelector(props.teleportTo) ? props.teleportTo : 'body'
120
+ })
66
121
 
67
- const toggleBottomSheet = () => {
68
- if (skipNextToggle) {
69
- skipNextToggle = false
70
- return
122
+ const exitDuration = computed(() => Math.round(props.animationDuration * EXIT_DURATION_RATIO))
123
+
124
+ const backdropStyle = computed(() => {
125
+ const fadeIn = `opacity ${BACKDROP_FADE_DURATION}ms ease-out`
126
+ const fadeOut = `opacity ${exitDuration.value}ms var(--cp-bottomsheet-exit)`
127
+
128
+ return {
129
+ opacity: state.value === 'open' ? 1 : 0,
130
+ transition: state.value === 'closing' ? fadeOut : fadeIn,
71
131
  }
132
+ })
72
133
 
73
- if (isVisibleModel.value) {
74
- bottomSheetRef.value?.open()
75
- return
134
+ const sheetStyle = computed(() => {
135
+ const spring = `${props.animationDuration}ms var(--cp-easing-elastic-strong)`
136
+ const exit = `${exitDuration.value}ms var(--cp-bottomsheet-exit)`
137
+ const timing = state.value === 'closing' ? exit : spring
138
+
139
+ return {
140
+ height: height.value === null ? undefined : `${Math.min(height.value + keyboardInset.value, windowHeight.value)}px`,
141
+ paddingBottom: `${keyboardInset.value}px`,
142
+ transform: state.value === 'open' ? `translate3d(0, ${translateY.value}px, 0)` : 'translate3d(0, 100%, 0)',
143
+ transition: isDragging.value ? 'none' : `transform ${timing}, height ${timing}`,
76
144
  }
145
+ })
77
146
 
78
- bottomSheetRef.value?.close()
79
- }
147
+ let animationTimer: ReturnType<typeof setTimeout> | undefined
80
148
 
81
- watch(isVisibleModel, () => toggleBottomSheet())
149
+ const scheduleAfterAnimation = (duration: number, onDone: () => void) => {
150
+ clearTimeout(animationTimer)
151
+ animationTimer = setTimeout(onDone, duration)
152
+ }
82
153
 
83
- const onBottomSheetOpened = () => emit('open')
154
+ const updateKeyboardInset = () => {
155
+ const viewport = defaultWindow?.visualViewport
84
156
 
85
- const onBottomSheetClosed = () => {
86
- if (props.preventClose) {
87
- // The underlying sheet already closed itself (e.g. Escape key, which it
88
- // never gates behind canBackdropClose/canSwipeClose) despite preventClose:
89
- // reopen it so the visual state matches the model we never touched.
90
- if (isVisibleModel.value) bottomSheetRef.value?.open()
157
+ if (!viewport || !isMounted.value) {
158
+ keyboardInset.value = 0
91
159
  return
92
160
  }
93
161
 
94
- if (isVisibleModel.value) {
95
- skipNextToggle = true
96
- isVisibleModel.value = false
97
- }
162
+ const overlap = (defaultWindow?.innerHeight ?? 0) - viewport.height - viewport.offsetTop
98
163
 
99
- emit('close')
164
+ keyboardInset.value = Math.round(Math.max(0, overlap))
100
165
  }
101
166
 
102
- defineExpose({
103
- open: () => bottomSheetRef.value?.open(),
104
- close: () => bottomSheetRef.value?.close(),
105
- })
106
- </script>
167
+ let previouslyFocusedElement: HTMLElement | null = null
107
168
 
108
- <style lang="scss">
109
- .cpBottomSheet {
110
- &__content {
111
- @extend %u-layout-box-padding;
112
- }
169
+ const focusSheet = () => {
170
+ previouslyFocusedElement = (defaultDocument?.activeElement as HTMLElement | null) ?? null
171
+ sheetRef.value?.focus({ preventScroll: true })
113
172
  }
114
173
 
115
- /* Package style overrides */
116
-
117
- /* stylelint-disable */
174
+ const restoreFocus = () => {
175
+ const element = previouslyFocusedElement
176
+ previouslyFocusedElement = null
118
177
 
119
- :root {
120
- --vsbs-backdrop-bg: var(--cp-background-overlay);
178
+ if (element?.isConnected) element.focus({ preventScroll: true })
121
179
  }
122
180
 
123
- [data-vsbs-container] {
124
- z-index: 5 !important;
125
- }
181
+ const openSheet = async () => {
182
+ if (state.value === 'opening' || state.value === 'open') return
126
183
 
127
- [data-vsbs-content] {
128
- // override default padding for vue-bottom sheet content div
129
- padding: 0 !important;
130
- }
184
+ clearTimeout(animationTimer)
185
+ state.value = 'opening'
186
+
187
+ await nextTick()
188
+ if (state.value !== 'opening') return
131
189
 
132
- [data-vsbs-sheet] [data-vsbs-header] {
133
- padding: var(--cp-spacing-2xl) var(--cp-spacing-xl) var(--cp-spacing-xl) var(--cp-spacing-xl);
190
+ measureNaturalHeight()
191
+ updateKeyboardInset()
192
+ snapToSmallest()
193
+ focusSheet()
194
+
195
+ state.value = 'open'
196
+ scheduleAfterAnimation(props.animationDuration, () => emit('open'))
134
197
  }
135
198
 
136
- [data-vsbs-sheet] {
137
- overflow: hidden;
138
- max-height: var(--cp-bottomsheet-max-height, calc(100% - 65px - env(safe-area-inset-top, 0px))) !important;
199
+ const closeSheet = () => {
200
+ if (state.value === 'closed' || state.value === 'closing') return
201
+
202
+ state.value = 'closing'
203
+ isDragging.value = false
204
+ restoreFocus()
205
+
206
+ scheduleAfterAnimation(exitDuration.value, () => {
207
+ if (state.value !== 'closing') return
208
+
209
+ state.value = 'closed'
210
+ keyboardInset.value = 0
211
+ resetDrag()
212
+ emit('close')
213
+ })
139
214
  }
140
215
 
141
- [data-vsbs-scroll] {
142
- -webkit-overflow-scrolling: touch;
143
- touch-action: pan-y;
144
- overscroll-behavior: unset !important;
216
+ const dismissSheet = () => {
217
+ if (props.preventClose) return
218
+
219
+ isVisibleModel.value = false
220
+ closeSheet()
145
221
  }
146
222
 
147
- [data-vsbs-footer] {
148
- padding-bottom: var(--cp-bottomsheet-footer-padding-bottom, calc(16px + env(safe-area-inset-bottom, 0px))) !important;
223
+ const onBackdropClick = () => {
224
+ if (props.canBackdropClose) dismissSheet()
149
225
  }
150
226
 
151
- [data-vsbs-footer]:is(.vsbs-enter-active) {
152
- pointer-events: none;
227
+ const onSheetTabKeydown = (event: KeyboardEvent) => handleTrapFocus(event, sheetRef.value)
228
+
229
+ const onSheetEscapeKeydown = () => {
230
+ if (state.value === 'open') dismissSheet()
153
231
  }
154
232
 
155
- [data-vsbs-footer]:has(div:empty) {
156
- display: none;
233
+ useEventListener('resize', updateKeyboardInset)
234
+ useEventListener(() => defaultWindow?.visualViewport, ['resize', 'scroll'], updateKeyboardInset)
235
+
236
+ watch(isVisibleModel, (isVisible) => (isVisible ? openSheet() : closeSheet()))
237
+
238
+ onMounted(() => {
239
+ if (isVisibleModel.value) openSheet()
240
+ })
241
+
242
+ onBeforeUnmount(() => clearTimeout(animationTimer))
243
+
244
+ defineExpose({
245
+ open: () => {
246
+ isVisibleModel.value = true
247
+ openSheet()
248
+ },
249
+ close: () => {
250
+ isVisibleModel.value = false
251
+ closeSheet()
252
+ },
253
+ })
254
+ </script>
255
+
256
+ <style lang="scss">
257
+ $sheet-border-radius: var(--cp-radius-xl);
258
+ $sheet-divider-color: rgba(46, 59, 66, 12.5%);
259
+
260
+ .cpBottomSheet {
261
+ &,
262
+ &__backdrop {
263
+ --cp-bottomsheet-exit: cubic-bezier(0.4, 0, 1, 1);
264
+ }
265
+
266
+ position: fixed;
267
+ right: 0;
268
+ bottom: 0;
269
+ left: 0;
270
+ display: flex;
271
+ overflow: hidden;
272
+ width: 100%;
273
+ max-width: fn.px-to-rem(640);
274
+ max-height: var(--cp-bottomsheet-max-height, calc(100% - 32px - env(safe-area-inset-top, 0px)));
275
+ box-sizing: border-box;
276
+ flex-direction: column;
277
+ background-color: var(--cp-background-primary);
278
+ border-top-left-radius: $sheet-border-radius;
279
+ border-top-right-radius: $sheet-border-radius;
280
+ box-shadow: 0 fn.px-to-rem(40) 0 var(--cp-background-primary);
281
+ margin-inline: auto;
282
+ pointer-events: all;
283
+ will-change: height, transform;
284
+
285
+ &__backdrop {
286
+ position: fixed;
287
+ background-color: var(--cp-background-overlay);
288
+ inset: 0;
289
+ pointer-events: auto;
290
+ user-select: none;
291
+ will-change: opacity;
292
+ }
293
+
294
+ &__header,
295
+ &__footer {
296
+ flex-shrink: 0;
297
+ touch-action: none;
298
+ user-select: none;
299
+ }
300
+
301
+ &__header {
302
+ padding: var(--cp-spacing-2xl) var(--cp-spacing-xl) var(--cp-spacing-xl) var(--cp-spacing-xl);
303
+ border-top-left-radius: $sheet-border-radius;
304
+ border-top-right-radius: $sheet-border-radius;
305
+ box-shadow: 0 1px 0 $sheet-divider-color;
306
+
307
+ &:empty {
308
+ box-shadow: none;
309
+ }
310
+
311
+ &::before {
312
+ position: absolute;
313
+ top: fn.px-to-rem(8);
314
+ left: 50%;
315
+ display: block;
316
+ width: fn.px-to-rem(36);
317
+ height: fn.px-to-rem(4);
318
+ border-radius: var(--cp-radius-xs);
319
+ background-color: rgba(0, 0, 0, 28%);
320
+ content: '';
321
+ transform: translate3d(-50%, 0, 0);
322
+ }
323
+ }
324
+
325
+ &__scroll {
326
+ flex-grow: 1;
327
+ -webkit-overflow-scrolling: touch;
328
+ overflow-y: auto;
329
+ touch-action: pan-y;
330
+ }
331
+
332
+ &__content {
333
+ @extend %u-layout-box-padding;
334
+
335
+ user-select: none;
336
+ }
337
+
338
+ &__footer {
339
+ flex-grow: 0;
340
+ padding: var(--cp-spacing-xl);
341
+ padding-bottom: var(--cp-bottomsheet-footer-padding-bottom, calc(16px + env(safe-area-inset-bottom, 0px)));
342
+ box-shadow: 0 -1px 0 $sheet-divider-color;
343
+
344
+ &:empty,
345
+ &:has(div:empty) {
346
+ display: none;
347
+ }
348
+ }
157
349
  }
158
- /* stylelint-enable */
159
350
  </style>
@@ -51,11 +51,11 @@ const isVisible = defineModel<boolean>('isVisible', { default: false })
51
51
  </script>
52
52
 
53
53
  <style lang="scss">
54
- html:has([data-vsbs-backdrop], .cpSeatMapEmergencyExitDialog) [data-vsbs-backdrop] {
54
+ html:has(.cpSeatMapEmergencyExitDialog) .cpBottomSheet__backdrop {
55
55
  z-index: 1;
56
56
  }
57
57
 
58
- [data-vsbs-sheet]:has(.cpSeatMapEmergencyExitDialog) {
58
+ .cpBottomSheet:has(.cpSeatMapEmergencyExitDialog) {
59
59
  z-index: 2;
60
60
  }
61
61
 
@@ -1,7 +1,6 @@
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'
5
4
 
6
5
  import '@/assets/main.css'
7
6