@citizenplane/pimp 18.14.4 → 18.16.1

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.
Files changed (59) hide show
  1. package/dist/components/CpMultiselect.vue.d.ts.map +1 -1
  2. package/dist/components/CpTextMorph.vue.d.ts +11 -0
  3. package/dist/components/CpTextMorph.vue.d.ts.map +1 -0
  4. package/dist/components/CpTripDetails.vue.d.ts +14 -0
  5. package/dist/components/CpTripDetails.vue.d.ts.map +1 -0
  6. package/dist/components/CpTripLegRouteInformation.vue.d.ts +11 -0
  7. package/dist/components/CpTripLegRouteInformation.vue.d.ts.map +1 -0
  8. package/dist/components/CpTripLegRouteLayover.vue.d.ts +13 -0
  9. package/dist/components/CpTripLegRouteLayover.vue.d.ts.map +1 -0
  10. package/dist/components/CpTripLegRoutePlace.vue.d.ts +22 -0
  11. package/dist/components/CpTripLegRoutePlace.vue.d.ts.map +1 -0
  12. package/dist/components/CpTripLegRouteSegment.vue.d.ts +18 -0
  13. package/dist/components/CpTripLegRouteSegment.vue.d.ts.map +1 -0
  14. package/dist/components/index.d.ts +8 -1
  15. package/dist/components/index.d.ts.map +1 -1
  16. package/dist/constants/tripDetails/Airline.d.ts +5 -0
  17. package/dist/constants/tripDetails/Airline.d.ts.map +1 -0
  18. package/dist/constants/tripDetails/Airport.d.ts +7 -0
  19. package/dist/constants/tripDetails/Airport.d.ts.map +1 -0
  20. package/dist/constants/tripDetails/CpTripDetailsConfig.d.ts +16 -0
  21. package/dist/constants/tripDetails/CpTripDetailsConfig.d.ts.map +1 -0
  22. package/dist/constants/tripDetails/Flight.d.ts +5 -0
  23. package/dist/constants/tripDetails/Flight.d.ts.map +1 -0
  24. package/dist/constants/tripDetails/FlightSegment.d.ts +15 -0
  25. package/dist/constants/tripDetails/FlightSegment.d.ts.map +1 -0
  26. package/dist/constants/tripDetails/FlightStopTypes.d.ts +7 -0
  27. package/dist/constants/tripDetails/FlightStopTypes.d.ts.map +1 -0
  28. package/dist/constants/tripDetails/Terminal.d.ts +5 -0
  29. package/dist/constants/tripDetails/Terminal.d.ts.map +1 -0
  30. package/dist/constants/tripDetails/index.d.ts +9 -0
  31. package/dist/constants/tripDetails/index.d.ts.map +1 -0
  32. package/dist/helpers/tripDetails.d.ts +23 -0
  33. package/dist/helpers/tripDetails.d.ts.map +1 -0
  34. package/dist/pimp.es.js +5496 -4508
  35. package/dist/pimp.umd.js +67 -34
  36. package/dist/style.css +1 -1
  37. package/package.json +2 -1
  38. package/src/assets/images/dot.svg +3 -0
  39. package/src/components/CpMultiselect.vue +23 -1
  40. package/src/components/CpTextMorph.vue +17 -0
  41. package/src/components/CpTripDetails.vue +64 -0
  42. package/src/components/CpTripLegRouteInformation.vue +224 -0
  43. package/src/components/CpTripLegRouteLayover.vue +63 -0
  44. package/src/components/CpTripLegRoutePlace.vue +196 -0
  45. package/src/components/CpTripLegRouteSegment.vue +99 -0
  46. package/src/components/index.ts +26 -0
  47. package/src/constants/tripDetails/Airline.ts +4 -0
  48. package/src/constants/tripDetails/Airport.ts +6 -0
  49. package/src/constants/tripDetails/CpTripDetailsConfig.ts +29 -0
  50. package/src/constants/tripDetails/Flight.ts +5 -0
  51. package/src/constants/tripDetails/FlightSegment.ts +15 -0
  52. package/src/constants/tripDetails/FlightStopTypes.ts +6 -0
  53. package/src/constants/tripDetails/Terminal.ts +4 -0
  54. package/src/constants/tripDetails/index.ts +9 -0
  55. package/src/helpers/tripDetails.ts +61 -0
  56. package/src/stories/CpMultiselect.stories.ts +30 -0
  57. package/src/stories/CpTextMorph.stories.ts +236 -0
  58. package/src/stories/CpTrip.stories.ts +147 -0
  59. package/src/stories/CpTripDetails.stories.ts +134 -0
@@ -0,0 +1,99 @@
1
+ <template>
2
+ <div class="cpTripLegRouteSegment">
3
+ <cp-trip-leg-route-layover
4
+ v-if="isLayoverSectionVisible"
5
+ :config="config"
6
+ :duration="stopDuration"
7
+ :locale="locale"
8
+ />
9
+ <cp-trip-leg-route-place
10
+ :city="origin.name"
11
+ class="cpTripLegRouteSegment__place"
12
+ :config="config"
13
+ :day-count-after-arrival="dayCountAfterArrival"
14
+ :iata="origin.iata_code"
15
+ :terminal="segment.origin_terminal"
16
+ :time="departureDate"
17
+ :type="firstAirportType"
18
+ />
19
+ <cp-trip-leg-route-information :config="config" :flight-duration="segmentDuration" :segment="segment" />
20
+ <cp-trip-leg-route-place
21
+ :city="destination.name"
22
+ class="cpTripLegRouteSegment__place"
23
+ :config="config"
24
+ :day-count-after-departure="dayCountAfterDeparture"
25
+ :iata="destination.iata_code"
26
+ :terminal="segment.destination_terminal"
27
+ :time="arrivalDate"
28
+ :type="secondAirportType"
29
+ />
30
+ </div>
31
+ </template>
32
+
33
+ <script setup lang="ts">
34
+ import { computed } from 'vue'
35
+
36
+ import type { CpTripDetailsConfig } from '@/constants/tripDetails/CpTripDetailsConfig'
37
+ import type { FlightSegment } from '@/constants/tripDetails/FlightSegment'
38
+ import type { TripDateTime, TripDuration } from '@/helpers/tripDetails'
39
+
40
+ import { FlightStopTypes } from '@/constants/tripDetails/FlightStopTypes'
41
+ import { fromGMTToLocal, getDayCountBetweenDates, getDuration, getFormattedDuration } from '@/helpers/tripDetails'
42
+ import CpTripLegRouteInformation from './CpTripLegRouteInformation.vue'
43
+ import CpTripLegRouteLayover from './CpTripLegRouteLayover.vue'
44
+ import CpTripLegRoutePlace from './CpTripLegRoutePlace.vue'
45
+
46
+ interface Props {
47
+ config: CpTripDetailsConfig
48
+ locale?: string
49
+ previousSegmentArrival: TripDateTime
50
+ segment: FlightSegment
51
+ segmentCount: number
52
+ segmentIndex: number
53
+ stopDuration: TripDuration | null
54
+ }
55
+
56
+ const props = withDefaults(defineProps<Props>(), {
57
+ locale: 'en',
58
+ })
59
+
60
+ const isFirstSegment = computed(() => props.segmentIndex === 0)
61
+ const isLayoverSectionVisible = computed(() => !isFirstSegment.value)
62
+ const origin = computed(() => props.segment.origin)
63
+ const destination = computed(() => props.segment.destination)
64
+ const isDirectFlight = computed(() => props.segmentCount === 1)
65
+ const isLastSegment = computed(() => props.segmentIndex === props.segmentCount - 1)
66
+
67
+ const firstAirportType = computed(() => {
68
+ if (isFirstSegment.value) return FlightStopTypes.ORIGIN
69
+ return FlightStopTypes.LAYOVER_ORIGIN
70
+ })
71
+
72
+ const secondAirportType = computed(() => {
73
+ if (isDirectFlight.value || isLastSegment.value) return FlightStopTypes.DESTINATION
74
+ return FlightStopTypes.LAYOVER_DESTINATION
75
+ })
76
+
77
+ const departureDate = computed(() =>
78
+ fromGMTToLocal(props.segment.departure_date, props.segment.origin.timezone, props.locale),
79
+ )
80
+ const arrivalDate = computed(() =>
81
+ fromGMTToLocal(props.segment.arrival_date, props.segment.destination.timezone, props.locale),
82
+ )
83
+
84
+ const dayCountAfterArrival = computed(() => {
85
+ return getDayCountBetweenDates({
86
+ departureDate: departureDate.value,
87
+ arrivalDate: props.previousSegmentArrival,
88
+ isArrival: true,
89
+ })
90
+ })
91
+
92
+ const dayCountAfterDeparture = computed(() => {
93
+ return getDayCountBetweenDates({ departureDate: departureDate.value, arrivalDate: arrivalDate.value })
94
+ })
95
+
96
+ const segmentDuration = computed(() =>
97
+ getFormattedDuration(getDuration(arrivalDate.value, departureDate.value), props.locale),
98
+ )
99
+ </script>
@@ -63,6 +63,7 @@ import CpTabs from './CpTabs.vue'
63
63
  import CpTelInput from './CpTelInput.vue'
64
64
  import CpText from './CpText.vue'
65
65
  import CpTextarea from './CpTextarea.vue'
66
+ import CpTextMorph from './CpTextMorph.vue'
66
67
  import CpToast from './CpToast.vue'
67
68
  import CpTooltip from './CpTooltip.vue'
68
69
  import CpTransitionCounter from './CpTransitionCounter.vue'
@@ -73,6 +74,11 @@ import CpTransitionSize from './CpTransitionSize.vue'
73
74
  import CpTransitionSlide from './CpTransitionSlide.vue'
74
75
  import CpTransitionTabContent from './CpTransitionTabContent.vue'
75
76
  import CpTrip from './CpTrip.vue'
77
+ import CpTripDetails from './CpTripDetails.vue'
78
+ import CpTripLegRouteInformation from './CpTripLegRouteInformation.vue'
79
+ import CpTripLegRouteLayover from './CpTripLegRouteLayover.vue'
80
+ import CpTripLegRoutePlace from './CpTripLegRoutePlace.vue'
81
+ import CpTripLegRouteSegment from './CpTripLegRouteSegment.vue'
76
82
  import IconAirline from './icons/IconAirline.vue'
77
83
  import IconCheckList from './icons/IconCheckList.vue'
78
84
  import IconCollapse from './icons/IconCollapse.vue'
@@ -125,6 +131,7 @@ const Components = {
125
131
  CpIcon,
126
132
  CpTelInput,
127
133
  CpTooltip,
134
+ CpTextMorph,
128
135
  CpAirlineLogo,
129
136
  IconAirline,
130
137
  IconOta,
@@ -143,6 +150,11 @@ const Components = {
143
150
  CpTransitionSlide,
144
151
  CpTransitionTabContent,
145
152
  CpTrip,
153
+ CpTripDetails,
154
+ CpTripLegRouteInformation,
155
+ CpTripLegRouteLayover,
156
+ CpTripLegRoutePlace,
157
+ CpTripLegRouteSegment,
146
158
  CpSeatMap,
147
159
  CpSeatMapEmergencyExitDialog,
148
160
  CpSeatMapGrid,
@@ -184,6 +196,14 @@ export type {
184
196
  SeatRowFacilityFlags,
185
197
  } from '@/constants/seatMap'
186
198
  export type { CpSeatMapSeatSelection as CpSeatMapSelection } from '@/constants/seatMap/CpSeatMapSeatSelection'
199
+ export type {
200
+ CpTripDetailsConfig,
201
+ Flight,
202
+ FlightAirline,
203
+ FlightAirport,
204
+ FlightSegment,
205
+ FlightTerminal,
206
+ } from '@/constants/tripDetails'
187
207
 
188
208
  export {
189
209
  CpSeatMapTransitionDirections,
@@ -194,6 +214,7 @@ export {
194
214
  SeatRowFacilityPosition,
195
215
  SeatStatuses,
196
216
  } from '@/constants/seatMap'
217
+ export { DEFAULT_CP_TRIP_DETAILS_CONFIG, FlightStopTypes } from '@/constants/tripDetails'
197
218
  // Consumers key `selections` with this, so it has to be the very same helper.
198
219
  export { displaySeat } from '@/helpers/seatMap'
199
220
  export {
@@ -256,6 +277,11 @@ export {
256
277
  CpTransitionSlide,
257
278
  CpTransitionTabContent,
258
279
  CpTrip,
280
+ CpTripDetails,
281
+ CpTripLegRouteInformation,
282
+ CpTripLegRouteLayover,
283
+ CpTripLegRoutePlace,
284
+ CpTripLegRouteSegment,
259
285
  IconAirline,
260
286
  IconCheckList,
261
287
  IconCollapse,
@@ -0,0 +1,4 @@
1
+ export interface FlightAirline {
2
+ iata_code: string
3
+ name: string
4
+ }
@@ -0,0 +1,6 @@
1
+ export interface FlightAirport {
2
+ city_name: string
3
+ iata_code: string
4
+ name: string
5
+ timezone: string
6
+ }
@@ -0,0 +1,29 @@
1
+ export interface CpTripDetailsConfig {
2
+ routeInformation: {
3
+ airline: string
4
+ connectionInfo: string
5
+ flightNo: string
6
+ technicalStop: string
7
+ }
8
+ routeLayover: {
9
+ layover: string
10
+ }
11
+ routePlace: {
12
+ terminalNotSpecified: string
13
+ }
14
+ }
15
+
16
+ export const DEFAULT_CP_TRIP_DETAILS_CONFIG: CpTripDetailsConfig = {
17
+ routeLayover: {
18
+ layover: 'layover',
19
+ },
20
+ routePlace: {
21
+ terminalNotSpecified: 'Terminal not specified',
22
+ },
23
+ routeInformation: {
24
+ connectionInfo: 'Connection info',
25
+ airline: 'airline',
26
+ flightNo: 'Flight No',
27
+ technicalStop: 'Technical stop',
28
+ },
29
+ }
@@ -0,0 +1,5 @@
1
+ import type { FlightSegment } from './FlightSegment'
2
+
3
+ export interface Flight {
4
+ segments: FlightSegment[]
5
+ }
@@ -0,0 +1,15 @@
1
+ import type { FlightAirline } from './Airline'
2
+ import type { FlightAirport } from './Airport'
3
+ import type { FlightTerminal } from './Terminal'
4
+
5
+ export interface FlightSegment {
6
+ airline: FlightAirline
7
+ arrival_date: string
8
+ departure_date: string
9
+ destination: FlightAirport
10
+ destination_terminal?: FlightTerminal
11
+ flight_number: string
12
+ origin: FlightAirport
13
+ origin_terminal?: FlightTerminal
14
+ via_airport?: Pick<FlightAirport, 'city_name' | 'iata_code'>
15
+ }
@@ -0,0 +1,6 @@
1
+ export enum FlightStopTypes {
2
+ DESTINATION = 'destination',
3
+ LAYOVER_DESTINATION = 'layover destination',
4
+ LAYOVER_ORIGIN = 'layover origin',
5
+ ORIGIN = 'origin',
6
+ }
@@ -0,0 +1,4 @@
1
+ export interface FlightTerminal {
2
+ label: string
3
+ terminal_code: string
4
+ }
@@ -0,0 +1,9 @@
1
+ export type { FlightAirline } from './Airline'
2
+ export type { FlightAirport } from './Airport'
3
+ export type { CpTripDetailsConfig } from './CpTripDetailsConfig'
4
+ export type { Flight } from './Flight'
5
+ export type { FlightSegment } from './FlightSegment'
6
+ export type { FlightTerminal } from './Terminal'
7
+
8
+ export { DEFAULT_CP_TRIP_DETAILS_CONFIG } from './CpTripDetailsConfig'
9
+ export { FlightStopTypes } from './FlightStopTypes'
@@ -0,0 +1,61 @@
1
+ import { DateTime, Duration } from 'luxon'
2
+
3
+ const DURATION_PLACEHOLDER = '-- h -- min'
4
+
5
+ // Luxon ships no types in Pimp (see src/types/luxon.d.ts) — this is the slice of
6
+ // DateTime/Duration's shape CpTripDetails actually relies on.
7
+ export interface TripDuration {
8
+ shiftTo(...units: string[]): TripDuration
9
+ toHuman(opts: { maximumFractionDigits?: number; unitDisplay?: string }): string
10
+ toISO(): string | null
11
+ toObject(): Record<string, number>
12
+ }
13
+
14
+ export interface TripDateTime {
15
+ diff(other: TripDateTime, units: string[]): TripDuration
16
+ toFormat(format: string): string
17
+ toISO(): string | null
18
+ }
19
+
20
+ export const fromGMTToLocal = (date: string, zone: string, locale = 'en'): TripDateTime => {
21
+ return DateTime.fromISO(date, { zone: 'utc' }).setZone(zone).setLocale(locale)
22
+ }
23
+
24
+ export const getDuration = (end: TripDateTime, start: TripDateTime): TripDuration => {
25
+ return end.diff(start, ['hours', 'minutes'])
26
+ }
27
+
28
+ export const getFormattedDuration = (duration?: TripDuration | null, locale = 'en'): string => {
29
+ if (!duration) return DURATION_PLACEHOLDER
30
+
31
+ // Concatenates values to make toHuman more readable (ex: 26h becomes 1d, 2hr)
32
+ const shiftedDuration = duration.shiftTo('hours', 'minutes').toObject()
33
+ const entries = Object.entries(shiftedDuration).filter(([, amount]) => amount)
34
+ const formattedDuration = Duration.fromObject(Object.fromEntries(entries), { locale })
35
+
36
+ return (
37
+ formattedDuration.toHuman({
38
+ unitDisplay: 'short',
39
+ maximumFractionDigits: 0,
40
+ }) || DURATION_PLACEHOLDER
41
+ )
42
+ }
43
+
44
+ export const getDayCountBetweenDates = ({
45
+ departureDate,
46
+ arrivalDate,
47
+ isArrival = false,
48
+ }: {
49
+ arrivalDate: TripDateTime
50
+ departureDate: TripDateTime
51
+ isArrival?: boolean
52
+ }): number => {
53
+ const departureDay = departureDate.toFormat('yyyy-LL-dd')
54
+ const arrivalDay = arrivalDate.toFormat('yyyy-LL-dd')
55
+
56
+ if (isArrival) {
57
+ return DateTime.fromISO(departureDay).diff(DateTime.fromISO(arrivalDay), 'days').days
58
+ }
59
+
60
+ return DateTime.fromISO(arrivalDay).diff(DateTime.fromISO(departureDay), 'days').days
61
+ }
@@ -109,6 +109,36 @@ export const Single: Story = {
109
109
  }),
110
110
  }
111
111
 
112
+ /**
113
+ * Multi-value selection with a supplier prefix badge. Selected options are
114
+ * rendered as chips.
115
+ */
116
+ export const Multiple: Story = {
117
+ args: {
118
+ placeholder: 'Select suppliers',
119
+ multiple: true,
120
+ options: supplierOptions,
121
+ trackBy: 'id',
122
+ },
123
+ render: (args) => ({
124
+ components: { CpMultiselect },
125
+ setup() {
126
+ const selectedSuppliers = ref([])
127
+
128
+ return { args, selectedSuppliers }
129
+ },
130
+ template: `
131
+ <div style="padding: 20px; max-width: 25rem;">
132
+ <CpMultiselect v-model="selectedSuppliers" v-bind="args">
133
+ <template #prefix>
134
+ <cp-badge color="magenta" is-square leading-icon="supplier" size="sm" />
135
+ </template>
136
+ </CpMultiselect>
137
+ </div>
138
+ `,
139
+ }),
140
+ }
141
+
112
142
  /**
113
143
  * Multi-value selection with an async search handler. Selected options are
114
144
  * rendered as chips and can be customized through `#tag-leading-icon` and
@@ -0,0 +1,236 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3-vite'
2
+ import { computed, ref } from 'vue'
3
+
4
+ import CpButton from '@/components/CpButton.vue'
5
+ import CpTextMorph from '@/components/CpTextMorph.vue'
6
+
7
+ const meta = {
8
+ title: 'Transitions/CpTextMorph',
9
+ component: CpTextMorph,
10
+ argTypes: {
11
+ text: {
12
+ control: 'text',
13
+ description: 'Text to display. Changing it morphs from the previous value.',
14
+ },
15
+ duration: {
16
+ control: 'number',
17
+ description: 'Duration of the morph in milliseconds.',
18
+ },
19
+ disabled: {
20
+ control: 'boolean',
21
+ description: 'Whether the morph is disabled.',
22
+ },
23
+ as: {
24
+ control: 'text',
25
+ description: 'HTML tag to render the component as.',
26
+ },
27
+ },
28
+ args: {
29
+ duration: 400,
30
+ disabled: false,
31
+ as: 'span',
32
+ },
33
+ } satisfies Meta<typeof CpTextMorph>
34
+
35
+ export default meta
36
+ type Story = StoryObj<typeof meta>
37
+
38
+ const wrapperStyle = 'display: flex; flex-direction: column; align-items: flex-start; gap: 24px;'
39
+
40
+ const singleLineStyle = 'font-size: 2rem; font-weight: 600; line-height: 1.2;'
41
+
42
+ const multiLineStyle = 'font-size: 1.25rem; line-height: 1.6;'
43
+
44
+ const singleLineTexts = ['Search for flights', 'Choose your seat']
45
+
46
+ /**
47
+ * Single line text. Use the button to swap the value and watch the
48
+ * characters morph from one label to the other.
49
+ */
50
+ export const Default: Story = {
51
+ args: {
52
+ text: singleLineTexts[0],
53
+ },
54
+ render: (args) => ({
55
+ components: { CpTextMorph, CpButton },
56
+ setup() {
57
+ const isMorphed = ref(false)
58
+ const text = computed(() => (isMorphed.value ? singleLineTexts[1] : args.text))
59
+
60
+ const toggle = () => {
61
+ isMorphed.value = !isMorphed.value
62
+ }
63
+
64
+ const buttonLabel = computed(() => (isMorphed.value ? 'Unmorph text' : 'Morph text'))
65
+
66
+ return { args, text, toggle, buttonLabel }
67
+ },
68
+ template: `
69
+ <div style="${wrapperStyle}">
70
+ <div style="${singleLineStyle}">
71
+ <CpTextMorph v-bind="args" :text="text" />
72
+ </div>
73
+ <CpButton appearance="primary" color="accent" @click="toggle">
74
+ <CpTextMorph v-bind="args" :text="buttonLabel" />
75
+ </CpButton>
76
+ </div>
77
+ `,
78
+ }),
79
+ }
80
+
81
+ /** Lines are explicit: torph keeps its content on a single line unless the value contains newlines. */
82
+ const multiLineTexts = [
83
+ [
84
+ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit,',
85
+ 'sed do eiusmod tempor incididunt ut labore et dolore',
86
+ 'magna aliqua veniam quis nostrud.',
87
+ ].join('\n'),
88
+ [
89
+ 'Lorem ipsum dolor sit amet, quis nostrud exercitation',
90
+ 'ullamco laboris nisi ut aliquip ex ea commodo dolore',
91
+ 'magna aliqua consectetur adipiscing elit.',
92
+ ].join('\n'),
93
+ ]
94
+
95
+ /**
96
+ * Multi line paragraph. Both values share most of their words, so the
97
+ * morph mostly moves existing words to their new position across lines
98
+ * instead of fading everything in and out.
99
+ */
100
+ export const MultiLine: Story = {
101
+ args: {
102
+ text: multiLineTexts[0],
103
+ },
104
+ render: (args) => ({
105
+ components: { CpTextMorph, CpButton },
106
+ setup() {
107
+ const isMorphed = ref(false)
108
+ const text = computed(() => (isMorphed.value ? multiLineTexts[1] : args.text))
109
+
110
+ const toggle = () => {
111
+ isMorphed.value = !isMorphed.value
112
+ }
113
+
114
+ return { args, text, toggle }
115
+ },
116
+ template: `
117
+ <div style="${wrapperStyle}">
118
+ <div style="${multiLineStyle}">
119
+ <CpTextMorph v-bind="args" :text="text" />
120
+ </div>
121
+ <CpButton appearance="primary" color="accent" @click="toggle">
122
+ Morph text
123
+ </CpButton>
124
+ </div>
125
+ `,
126
+ }),
127
+ }
128
+
129
+ const bookingSteps = [
130
+ {
131
+ title: 'Select your flight',
132
+ fields: ['Paris (CDG) → London (LHR)', 'Wednesday 19 August, 09:45', 'Economy, 1 passenger'],
133
+ },
134
+ {
135
+ title: 'Select your seat',
136
+ fields: ['Seat 14A, window', 'Extra legroom', 'Front of the cabin'],
137
+ },
138
+ {
139
+ title: 'Confirm your booking',
140
+ fields: ['Paris (CDG) → London (LHR)', 'Seat 14A, window', 'Total: 189,00 €'],
141
+ },
142
+ ]
143
+
144
+ const dialogFooterStyle = 'display: flex; justify-content: space-between; gap: 8px; width: 100%;'
145
+
146
+ const dialogFieldsStyle = 'display: flex; flex-direction: column; gap: 8px; margin: 0; padding: 0; list-style: none;'
147
+ /**
148
+ * Use case — a multi step dialog whose title morphs on every step
149
+ * instead of being swapped instantly. The step titles share words on
150
+ * purpose ("Select your …"), so only the changing part animates while
151
+ * the rest stays in place.
152
+ *
153
+ * The title is wrapped in an `h2` inside the `title` slot to keep the
154
+ * dialog heading semantics. Around it, several transitions run together:
155
+ * `CpTransitionCounter` rolls the step number, `CpTransitionSize`
156
+ * animates the width of the whole "Step 1 of 3" subtitle and the body
157
+ * height, and the footer keeps a single action button whose label morphs
158
+ * from "Next" to "Confirm" while its width follows.
159
+ */
160
+ export const MultiStepDialog: Story = {
161
+ args: {
162
+ text: bookingSteps[0].title,
163
+ },
164
+ render: (args) => ({
165
+ components: { CpTextMorph },
166
+ setup() {
167
+ const isOpen = ref(false)
168
+ const stepIndex = ref(0)
169
+
170
+ const step = computed(() => bookingSteps[stepIndex.value])
171
+ const stepLabel = computed(() => `Step ${stepIndex.value + 1} of ${bookingSteps.length}`)
172
+ const isLastStep = computed(() => stepIndex.value === bookingSteps.length - 1)
173
+ const nextLabel = computed(() => (isLastStep.value ? 'Confirm' : 'Next'))
174
+
175
+ const open = () => {
176
+ stepIndex.value = 0
177
+ isOpen.value = true
178
+ }
179
+
180
+ const close = () => {
181
+ isOpen.value = false
182
+ }
183
+
184
+ const goToNextStep = () => {
185
+ if (isLastStep.value) {
186
+ close()
187
+ return
188
+ }
189
+ stepIndex.value++
190
+ }
191
+
192
+ return {
193
+ args,
194
+ isOpen,
195
+ stepIndex,
196
+ step,
197
+ stepLabel,
198
+ nextLabel,
199
+ open,
200
+ close,
201
+ goToNextStep,
202
+ }
203
+ },
204
+ template: `
205
+ <CpButton appearance="primary" color="accent" @click="open">Book a flight</CpButton>
206
+ <CpTransitionDialog>
207
+ <CpDialog v-if="isOpen" :max-width="480" @close="close">
208
+ <template #title>
209
+ <h2><CpTextMorph v-bind="args" :text="step.title" /></h2>
210
+ </template>
211
+ <template #subtitle>
212
+ <CpTextMorph v-bind="args" :text="stepLabel" />
213
+ </template>
214
+ <ul style="${dialogFieldsStyle}">
215
+ <li v-for="field in step.fields" :key="field">{{ field }}</li>
216
+ </ul>
217
+ <template #footer>
218
+ <div style="${dialogFooterStyle}">
219
+ <CpButton
220
+ appearance="secondary"
221
+ color="neutral"
222
+ :disabled="stepIndex === 0"
223
+ @click="stepIndex--"
224
+ >
225
+ Back
226
+ </CpButton>
227
+ <CpButton appearance="primary" color="accent" @click="goToNextStep">
228
+ <CpTextMorph v-bind="args" :text="nextLabel" />
229
+ </CpButton>
230
+ </div>
231
+ </template>
232
+ </CpDialog>
233
+ </CpTransitionDialog>
234
+ `,
235
+ }),
236
+ }