@citizenplane/pimp 18.13.4 → 18.14.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.
@@ -0,0 +1,221 @@
1
+ import { useResizeObserver, useWindowSize } from '@vueuse/core'
2
+ import type { FullGestureState } from '@vueuse/gesture'
3
+ import { rubberbandIfOutOfBounds } from '@vueuse/gesture'
4
+ import type { ShallowRef } from 'vue'
5
+ import { computed, ref, watch } from 'vue'
6
+
7
+ type SnapPoint = number | `${number}%`
8
+
9
+ type ElementRef = Readonly<ShallowRef<HTMLElement | null>>
10
+
11
+ interface Options {
12
+ canSwipeClose: boolean
13
+ expandOnContentDrag: boolean
14
+ halfAndExpand: boolean
15
+ preventClose: boolean
16
+ snapPoints: SnapPoint[]
17
+ swipeCloseThreshold: string
18
+ }
19
+
20
+ interface Context {
21
+ contentRef: ElementRef
22
+ footerRef: ElementRef
23
+ headerRef: ElementRef
24
+ onDismiss: () => void
25
+ scrollRef: ElementRef
26
+ }
27
+
28
+ const RUBBERBAND_TENSION = 0.25
29
+ const FLICK_CLOSE_TOLERANCE = 10
30
+
31
+ export const useBottomSheetDrag = (options: Options, context: Context) => {
32
+ const { contentRef, footerRef, headerRef, onDismiss, scrollRef } = context
33
+
34
+ const { height: windowHeight } = useWindowSize()
35
+
36
+ const height = ref<number | null>(null)
37
+ const translateY = ref(0)
38
+ const isDragging = ref(false)
39
+ const naturalHeight = ref(0)
40
+ const currentSnapPointIndex = ref(0)
41
+
42
+ let dragStartHeight = 0
43
+ let contentDragOffset = 0
44
+ let isContentDragActive = false
45
+
46
+ const measureNaturalHeight = () => {
47
+ const parts = [headerRef, contentRef, footerRef]
48
+ const total = parts.reduce((sum, part) => sum + (part.value?.getBoundingClientRect().height ?? 0), 0)
49
+
50
+ naturalHeight.value = Math.ceil(total)
51
+ }
52
+
53
+ const resolvedSnapPoints = computed<SnapPoint[]>(() => {
54
+ if (options.snapPoints.length) return options.snapPoints
55
+ if (options.halfAndExpand) return ['50%', '100%']
56
+
57
+ return [naturalHeight.value]
58
+ })
59
+
60
+ const snapPointHeights = computed(() =>
61
+ resolvedSnapPoints.value.map((snapPoint) =>
62
+ typeof snapPoint === 'number'
63
+ ? Math.min(snapPoint, windowHeight.value)
64
+ : (windowHeight.value * Number.parseFloat(snapPoint)) / 100,
65
+ ),
66
+ )
67
+
68
+ const minSnapPointHeight = computed(() => Math.min(...snapPointHeights.value))
69
+ const maxSnapPointHeight = computed(() => Math.max(...snapPointHeights.value))
70
+
71
+ const closestSnapPointIndex = computed(() => {
72
+ const distances = snapPointHeights.value.map((snapPointHeight) => Math.abs(snapPointHeight - (height.value ?? 0)))
73
+
74
+ return distances.indexOf(Math.min(...distances))
75
+ })
76
+
77
+ const swipeCloseDistance = computed(() => {
78
+ const sheetHeight = height.value ?? minSnapPointHeight.value
79
+ const threshold = Number.parseFloat(options.swipeCloseThreshold)
80
+
81
+ if (Number.isNaN(threshold)) return sheetHeight / 2
82
+ if (options.swipeCloseThreshold.includes('%')) return (sheetHeight * threshold) / 100
83
+
84
+ return threshold
85
+ })
86
+
87
+ const snapTo = (snapPointIndex: number) => {
88
+ const snapPointHeight = snapPointHeights.value[snapPointIndex]
89
+ if (snapPointHeight === undefined) return
90
+
91
+ currentSnapPointIndex.value = snapPointIndex
92
+ height.value = snapPointHeight
93
+ translateY.value = 0
94
+ }
95
+
96
+ const snapToSmallest = () => snapTo(snapPointHeights.value.indexOf(minSnapPointHeight.value))
97
+
98
+ const reset = () => {
99
+ height.value = null
100
+ translateY.value = 0
101
+ isDragging.value = false
102
+ }
103
+
104
+ const startDrag = () => {
105
+ isDragging.value = true
106
+ dragStartHeight = height.value ?? minSnapPointHeight.value
107
+ }
108
+
109
+ const applyDragOffset = (offsetY: number) => {
110
+ const targetHeight = dragStartHeight - offsetY
111
+ const minHeight = minSnapPointHeight.value
112
+
113
+ if (targetHeight >= minHeight) {
114
+ height.value = rubberbandIfOutOfBounds(targetHeight, 0, maxSnapPointHeight.value, RUBBERBAND_TENSION)
115
+ translateY.value = 0
116
+ return
117
+ }
118
+
119
+ const overshoot = minHeight - targetHeight
120
+
121
+ height.value = minHeight
122
+ translateY.value = options.canSwipeClose
123
+ ? overshoot
124
+ : rubberbandIfOutOfBounds(overshoot, -minHeight, 0, RUBBERBAND_TENSION)
125
+ }
126
+
127
+ const shouldCloseAfterDrag = (swipeY: number) => {
128
+ if (!options.canSwipeClose || options.preventClose) return false
129
+
130
+ const isFlickedDown = swipeY > 0 && (height.value ?? 0) <= minSnapPointHeight.value + FLICK_CLOSE_TOLERANCE
131
+
132
+ return isFlickedDown || translateY.value > swipeCloseDistance.value
133
+ }
134
+
135
+ const snapPointIndexInSwipeDirection = (swipeY: number) => {
136
+ const currentHeight = height.value ?? 0
137
+ const isSwipingDown = swipeY > 0
138
+ const reachable = snapPointHeights.value.filter((snapPointHeight) =>
139
+ isSwipingDown ? snapPointHeight < currentHeight - 1 : snapPointHeight > currentHeight + 1,
140
+ )
141
+
142
+ if (!reachable.length) return closestSnapPointIndex.value
143
+
144
+ return snapPointHeights.value.indexOf(isSwipingDown ? Math.max(...reachable) : Math.min(...reachable))
145
+ }
146
+
147
+ const endDrag = (swipeY: number) => {
148
+ isDragging.value = false
149
+
150
+ if (shouldCloseAfterDrag(swipeY)) {
151
+ onDismiss()
152
+ return
153
+ }
154
+
155
+ const isSwipe = swipeY !== 0 && snapPointHeights.value.length > 1
156
+
157
+ snapTo(isSwipe ? snapPointIndexInSwipeDirection(swipeY) : closestSnapPointIndex.value)
158
+ }
159
+
160
+ const onHandleDrag = ({ first, last, movement, swipe }: FullGestureState<'drag'>) => {
161
+ if (first) startDrag()
162
+ else if (last) endDrag(swipe[1])
163
+ else applyDragOffset(movement[1])
164
+ }
165
+
166
+ const canStartContentDrag = (movementY: number) => {
167
+ if (!options.expandOnContentDrag) return false
168
+ if ((scrollRef.value?.scrollTop ?? 0) > 0) return false
169
+ if (movementY > 0) return true
170
+
171
+ return snapPointHeights.value.length > 1 && (height.value ?? 0) < maxSnapPointHeight.value
172
+ }
173
+
174
+ const onContentDrag = ({ first, last, movement, swipe }: FullGestureState<'drag'>) => {
175
+ if (first) {
176
+ isContentDragActive = false
177
+ return
178
+ }
179
+
180
+ if (last) {
181
+ if (isContentDragActive) endDrag(swipe[1])
182
+ isContentDragActive = false
183
+ return
184
+ }
185
+
186
+ if (!isContentDragActive) {
187
+ if (!canStartContentDrag(movement[1])) return
188
+
189
+ isContentDragActive = true
190
+ contentDragOffset = movement[1]
191
+ startDrag()
192
+ }
193
+
194
+ applyDragOffset(movement[1] - contentDragOffset)
195
+ }
196
+
197
+ const onScrollTouchMove = (event: TouchEvent) => {
198
+ if (!isContentDragActive || !event.cancelable) return
199
+ event.preventDefault()
200
+ }
201
+
202
+ useResizeObserver([headerRef, contentRef, footerRef], measureNaturalHeight)
203
+
204
+ watch(snapPointHeights, () => {
205
+ if (height.value === null || isDragging.value) return
206
+ snapTo(currentSnapPointIndex.value)
207
+ })
208
+
209
+ return {
210
+ height,
211
+ isDragging,
212
+ measureNaturalHeight,
213
+ onContentDrag,
214
+ onHandleDrag,
215
+ onScrollTouchMove,
216
+ reset,
217
+ snapToSmallest,
218
+ translateY,
219
+ windowHeight,
220
+ }
221
+ }
@@ -15,7 +15,8 @@ const meta = {
15
15
  argTypes: {
16
16
  animationDuration: {
17
17
  control: 'number',
18
- description: 'Animation duration in milliseconds',
18
+ description:
19
+ 'Duration of the spring used to present the sheet, in milliseconds. Leaving reuses a third of it on a dry curve.',
19
20
  },
20
21
  canBackdropClose: {
21
22
  control: 'boolean',
@@ -25,14 +26,27 @@ const meta = {
25
26
  control: 'boolean',
26
27
  description: 'Enable swipe-to-close gesture',
27
28
  },
29
+ expandOnContentDrag: {
30
+ control: 'boolean',
31
+ description: 'Let a drag started on the body move the sheet instead of scrolling it',
32
+ },
28
33
  preventClose: {
29
34
  control: 'boolean',
30
35
  description: 'Prevent the sheet from emitting close when dismissed',
31
36
  },
37
+ snapPoints: {
38
+ control: 'object',
39
+ description: 'Heights the sheet snaps to, in pixels or in percentage of the viewport height',
40
+ },
32
41
  swipeCloseThreshold: {
33
42
  control: 'text',
34
43
  description: 'Translation threshold after which the sheet closes (px or %)',
35
44
  },
45
+ teleportTo: {
46
+ control: 'text',
47
+ description:
48
+ 'Selector the sheet teleports to. Defaults to Nuxt SSR-safe `#teleports` container, falling back to `body` when the host does not provide it.',
49
+ },
36
50
  onClose: { action: 'close' },
37
51
  onOpen: { action: 'open' },
38
52
  },
@@ -47,7 +61,7 @@ type Story = StoryObj<typeof meta>
47
61
  */
48
62
  export const Default: Story = {
49
63
  args: {
50
- animationDuration: 300,
64
+ animationDuration: 600,
51
65
  canBackdropClose: true,
52
66
  canSwipeClose: true,
53
67
  swipeCloseThreshold: '20%',
@@ -128,6 +142,176 @@ export const NoBackdropClose: Story = {
128
142
  }),
129
143
  }
130
144
 
145
+ /**
146
+ * With `expandOnContentDrag`, a drag started on the body of the sheet moves the sheet
147
+ * itself instead of scrolling it. The sheet opens on its smallest snap point (50%):
148
+ * drag the list upwards to expand it to full height, then drag it back down — once the
149
+ * list is scrolled to the top — to collapse it and eventually close it. Anywhere else in
150
+ * the scroll, the body scrolls as usual.
151
+ *
152
+ * Turn the `expandOnContentDrag` control off to compare: the body then only ever scrolls,
153
+ * and the sheet can only be resized from its header or its footer.
154
+ */
155
+ export const ExpandOnContentDrag: Story = {
156
+ args: {
157
+ expandOnContentDrag: true,
158
+ snapPoints: ['50%', '100%'],
159
+ },
160
+ render: (args) => ({
161
+ setup() {
162
+ const isVisible = ref(false)
163
+ const destinations = [
164
+ 'Paris',
165
+ 'London',
166
+ 'Berlin',
167
+ 'Madrid',
168
+ 'Rome',
169
+ 'Lisbon',
170
+ 'Amsterdam',
171
+ 'Vienna',
172
+ 'Prague',
173
+ 'Copenhagen',
174
+ 'Stockholm',
175
+ 'Oslo',
176
+ 'Helsinki',
177
+ 'Dublin',
178
+ 'Brussels',
179
+ 'Zurich',
180
+ 'Warsaw',
181
+ 'Budapest',
182
+ 'Athens',
183
+ 'Porto',
184
+ 'Paris',
185
+ 'London',
186
+ 'Berlin',
187
+ 'Madrid',
188
+ 'Rome',
189
+ 'Lisbon',
190
+ 'Amsterdam',
191
+ 'Vienna',
192
+ 'Prague',
193
+ 'Copenhagen',
194
+ 'Stockholm',
195
+ 'Oslo',
196
+ 'Helsinki',
197
+ 'Dublin',
198
+ 'Brussels',
199
+ 'Zurich',
200
+ 'Warsaw',
201
+ 'Budapest',
202
+ 'Athens',
203
+ 'Porto',
204
+ 'Paris',
205
+ 'London',
206
+ 'Berlin',
207
+ 'Madrid',
208
+ 'Rome',
209
+ 'Lisbon',
210
+ 'Amsterdam',
211
+ 'Vienna',
212
+ 'Prague',
213
+ 'Copenhagen',
214
+ 'Stockholm',
215
+ 'Oslo',
216
+ 'Helsinki',
217
+ 'Dublin',
218
+ 'Brussels',
219
+ 'Zurich',
220
+ 'Warsaw',
221
+ 'Budapest',
222
+ 'Athens',
223
+ 'Porto',
224
+ ]
225
+ return { args, isVisible, destinations }
226
+ },
227
+ template: `
228
+ <CpButton @click="isVisible = true">Open Bottom Sheet</CpButton>
229
+ <CpBottomSheet
230
+ v-bind="args"
231
+ v-model="isVisible"
232
+ @close="isVisible = false"
233
+ >
234
+ <template #header><strong>Drag the list to resize the sheet</strong></template>
235
+ <p v-for="destination in destinations" :key="destination">{{ destination }}</p>
236
+ <template #footer>
237
+ <CpButton @click="isVisible = false">Close</CpButton>
238
+ </template>
239
+ </CpBottomSheet>
240
+ `,
241
+ }),
242
+ }
243
+
244
+ /**
245
+ * Sheets can be nested by declaring one inside the slots of another. Every sheet teleports
246
+ * to the same container, so the child is inserted after its parent and stacks on top of it
247
+ * with its own backdrop — no `z-index` juggling required.
248
+ *
249
+ * Each level keeps its own props, height and gestures: dismiss the child by tapping its
250
+ * backdrop, swiping it down or using its actions, and the parent is revealed untouched.
251
+ * Closing the parent unmounts the whole chain.
252
+ *
253
+ * The nested levels declare no snap points, so each one measures its own header, body and
254
+ * footer and shows them in full. Only the parent is given a height, wide enough to stay
255
+ * visible above its children.
256
+ */
257
+ export const Nested: Story = {
258
+ args: {
259
+ canBackdropClose: true,
260
+ canSwipeClose: true,
261
+ snapPoints: ['70%'],
262
+ },
263
+ render: (args) => ({
264
+ setup() {
265
+ const isVisible = ref(false)
266
+ const isPassengerVisible = ref(false)
267
+ const isSeatVisible = ref(false)
268
+ const passengers = ['Ada Lovelace', 'Grace Hopper', 'Alan Turing']
269
+ const selectedPassenger = ref(passengers[0])
270
+ return { args, isVisible, isPassengerVisible, isSeatVisible, passengers, selectedPassenger }
271
+ },
272
+ template: `
273
+ <CpButton @click="isVisible = true">Open Bottom Sheet</CpButton>
274
+ <CpBottomSheet
275
+ v-bind="args"
276
+ v-model="isVisible"
277
+ @close="isVisible = false"
278
+ >
279
+ <template #header><strong>Level 1 — Booking</strong></template>
280
+ <p>This is the parent sheet. Open the next level to stack a second sheet on top of it.</p>
281
+ <p>Traveller: <strong>{{ selectedPassenger }}</strong></p>
282
+
283
+ <CpBottomSheet v-model="isPassengerVisible" @close="isPassengerVisible = false">
284
+ <template #header><strong>Level 2 — Passengers</strong></template>
285
+ <p>Pick a traveller, or go one level deeper.</p>
286
+ <CpMenuItem
287
+ v-for="passenger in passengers"
288
+ :key="passenger"
289
+ :is-selected="passenger === selectedPassenger"
290
+ :label="passenger"
291
+ @click="selectedPassenger = passenger"
292
+ />
293
+
294
+ <CpBottomSheet v-model="isSeatVisible" @close="isSeatVisible = false">
295
+ <template #header><strong>Level 3 — Seat</strong></template>
296
+ <p>Third level. Dismiss it to fall back on level 2, still open underneath.</p>
297
+ <template #footer>
298
+ <CpButton @click="isSeatVisible = false">Confirm seat</CpButton>
299
+ </template>
300
+ </CpBottomSheet>
301
+
302
+ <template #footer>
303
+ <CpButton @click="isSeatVisible = true">Choose a seat</CpButton>
304
+ </template>
305
+ </CpBottomSheet>
306
+
307
+ <template #footer>
308
+ <CpButton @click="isPassengerVisible = true">Select passenger</CpButton>
309
+ </template>
310
+ </CpBottomSheet>
311
+ `,
312
+ }),
313
+ }
314
+
131
315
  /**
132
316
  * Disable swipe-to-close — the sheet can only be dismissed
133
317
  * by tapping the backdrop or using a close action.
@@ -16,10 +16,6 @@ export default meta
16
16
 
17
17
  type Story = StoryObj
18
18
 
19
- /**
20
- * Cubic-bezier curves used for the design-system transitions. Hover (or
21
- * focus) any track to play the animation and compare curves side by side.
22
- */
23
19
  export const Curves: Story = {
24
20
  render: () => ({
25
21
  setup() {