@citizenplane/pimp 18.19.9 → 18.20.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.19.9",
3
+ "version": "18.20.0",
4
4
  "scripts": {
5
5
  "dev": "storybook dev -p 8081",
6
6
  "build-storybook": "storybook build --output-dir ./docs",
@@ -0,0 +1,136 @@
1
+ <template>
2
+ <div class="cpStepper">
3
+ <div class="cpStepper__wrapper">
4
+ <div v-for="(step, stepIndex) in steps" :key="step.id" class="cpStepper__stepWrapper">
5
+ <button
6
+ class="cpStepper__step"
7
+ :class="stepActiveClass(step)"
8
+ :disabled="isStepDisabled(stepIndex)"
9
+ type="button"
10
+ @click="handleStepClick(step)"
11
+ >
12
+ <span class="cpStepper__stepNumber">{{ stepIndex + 1 }}</span>
13
+ <span class="cpStepper__stepTitle">{{ step.label }}</span>
14
+ </button>
15
+ <cp-icon v-if="isNotLastStep(stepIndex)" class="cpStepper__chevron" size="16" type="chevron-right" />
16
+ </div>
17
+ </div>
18
+ </div>
19
+ </template>
20
+
21
+ <script setup lang="ts">
22
+ import type { CpStepperStep } from '@/constants/CpStepperTypes'
23
+
24
+ import CpIcon from '@/components/CpIcon.vue'
25
+
26
+ interface Props {
27
+ activeStepId?: string
28
+ reachedStepIndex?: number
29
+ steps: CpStepperStep[]
30
+ }
31
+
32
+ const props = withDefaults(defineProps<Props>(), {
33
+ activeStepId: undefined,
34
+ reachedStepIndex: 0,
35
+ })
36
+
37
+ const emit = defineEmits<{ select: [id: string] }>()
38
+
39
+ const isNotLastStep = (stepIndex: number) => stepIndex < props.steps.length - 1
40
+
41
+ const isStepDisabled = (stepIndex: number) => stepIndex > props.reachedStepIndex
42
+
43
+ const stepActiveClass = (step: CpStepperStep) => (step.id === props.activeStepId ? 'cpStepper__step--isActive' : '')
44
+
45
+ const handleStepClick = (step: CpStepperStep) => emit('select', step.id)
46
+ </script>
47
+
48
+ <style lang="scss">
49
+ .cpStepper {
50
+ min-width: 0;
51
+ flex: 1;
52
+ container-type: inline-size;
53
+
54
+ &__wrapper,
55
+ &__stepWrapper {
56
+ display: flex;
57
+ align-items: center;
58
+ justify-content: center;
59
+ gap: var(--cp-spacing-md);
60
+ }
61
+
62
+ &__stepWrapper {
63
+ @extend %u-text-ellipsis;
64
+ }
65
+
66
+ &__step {
67
+ @extend %u-text-ellipsis;
68
+
69
+ display: flex;
70
+ align-items: center;
71
+ padding: fn.px-to-rem(6);
72
+ border: fn.px-to-rem(1) solid var(--cp-border-soft);
73
+ border-radius: fn.px-to-rem(50);
74
+ color: var(--cp-text-secondary);
75
+ gap: var(--cp-spacing-sm);
76
+ outline-offset: fn.px-to-rem(-2);
77
+
78
+ @container (width < 60.5rem) {
79
+ padding: var(--cp-spacing-sm);
80
+ }
81
+
82
+ &--isActive {
83
+ border-color: transparent;
84
+ background-color: var(--cp-background-accent-secondary);
85
+ color: var(--cp-text-accent-primary);
86
+ }
87
+
88
+ &:not(:disabled) {
89
+ @extend %u-focus-outline;
90
+
91
+ border-radius: fn.px-to-rem(50);
92
+ }
93
+
94
+ &:disabled {
95
+ cursor: not-allowed;
96
+ }
97
+ }
98
+
99
+ &__stepNumber {
100
+ @include mx.square-sizing(20);
101
+
102
+ display: flex;
103
+ flex-shrink: 0;
104
+ align-items: center;
105
+ justify-content: center;
106
+ border-radius: 50%;
107
+ background-color: var(--cp-background-secondary);
108
+ font-size: var(--cp-text-size-xs);
109
+ font-weight: 500;
110
+ line-height: var(--cp-text-xs-line-height);
111
+ }
112
+
113
+ &__step--isActive &__stepNumber {
114
+ background-color: var(--cp-background-accent-solid);
115
+ color: var(--cp-text-white);
116
+ }
117
+
118
+ &__stepTitle {
119
+ @extend %u-text-ellipsis;
120
+
121
+ font-size: var(--cp-text-size-sm);
122
+ font-weight: 500;
123
+ line-height: var(--cp-text-sm-line-height);
124
+ padding-inline: var(--cp-spacing-sm);
125
+
126
+ @container (width < 60.5rem) {
127
+ display: none;
128
+ }
129
+ }
130
+
131
+ &__chevron {
132
+ flex-shrink: 0;
133
+ color: var(--cp-foreground-quaternary);
134
+ }
135
+ }
136
+ </style>
@@ -0,0 +1,164 @@
1
+ <template>
2
+ <div class="cpStepperMobile">
3
+ <button v-if="hasPreviousButton" class="cpStepperMobile__back" type="button" @click="emit('previous')">
4
+ <cp-icon size="24" type="chevron-left" />
5
+ </button>
6
+ <div class="cpStepperMobile__wrapper">
7
+ <cp-heading class="cpStepperMobile__title" heading-level="h3" :size="600">
8
+ <transition mode="out-in" :name="titleTransitionName">
9
+ <span :key="activeStepLabel" class="cpStepperMobile__titleInner">{{ activeStepLabel }}</span>
10
+ </transition>
11
+ </cp-heading>
12
+ <div class="cpStepperMobile__steps">
13
+ <div v-for="step in steps" :key="step.id" class="cpStepperMobile__step" :class="stepActiveClass(step)" />
14
+ </div>
15
+ </div>
16
+ </div>
17
+ </template>
18
+
19
+ <script setup lang="ts">
20
+ import { computed, ref, watch } from 'vue'
21
+
22
+ import CpHeading from '@/components/CpHeading.vue'
23
+ import CpIcon from '@/components/CpIcon.vue'
24
+
25
+ import { type CpStepperStep, CpStepperTransitionDirections } from '@/constants/CpStepperTypes'
26
+
27
+ interface Props {
28
+ activeStepId?: string
29
+ hasPreviousButton?: boolean
30
+ steps: CpStepperStep[]
31
+ }
32
+
33
+ const props = withDefaults(defineProps<Props>(), {
34
+ activeStepId: undefined,
35
+ hasPreviousButton: false,
36
+ })
37
+
38
+ const emit = defineEmits<{ previous: [] }>()
39
+
40
+ const activeStep = computed(() => props.steps.find((step) => step.id === props.activeStepId))
41
+ const activeStepLabel = computed(() => activeStep.value?.label)
42
+
43
+ const titleTransitionDirection = ref<CpStepperTransitionDirections>(CpStepperTransitionDirections.FORWARD)
44
+ const titleTransitionName = computed(() => `slide-${titleTransitionDirection.value}`)
45
+
46
+ const stepActiveClass = (step: CpStepperStep) =>
47
+ step.id === props.activeStepId ? 'cpStepperMobile__step--isActive' : ''
48
+
49
+ watch(
50
+ () => props.activeStepId,
51
+ (newStepId, oldStepId) => {
52
+ if (!newStepId || !oldStepId) return
53
+
54
+ const newIndex = props.steps.findIndex((step) => step.id === newStepId)
55
+ const oldIndex = props.steps.findIndex((step) => step.id === oldStepId)
56
+
57
+ titleTransitionDirection.value =
58
+ newIndex > oldIndex ? CpStepperTransitionDirections.BACKWARD : CpStepperTransitionDirections.FORWARD
59
+ },
60
+ )
61
+ </script>
62
+
63
+ <style lang="scss">
64
+ .cpStepperMobile {
65
+ display: flex;
66
+ align-items: center;
67
+ justify-content: center;
68
+
69
+ &__back {
70
+ @extend %u-focus-outline;
71
+
72
+ position: relative;
73
+ z-index: 2;
74
+ display: flex;
75
+ align-items: center;
76
+ justify-content: center;
77
+ color: var(--cp-foreground-accent-secondary);
78
+ }
79
+
80
+ &__wrapper {
81
+ display: flex;
82
+ flex: 1;
83
+ flex-direction: column;
84
+ align-items: center;
85
+ justify-content: center;
86
+ padding: var(--cp-spacing-sm) var(--cp-spacing-xl);
87
+ }
88
+
89
+ &__title {
90
+ overflow: hidden;
91
+ font-size: var(--cp-text-size-lg);
92
+ font-weight: 600;
93
+ line-height: var(--cp-text-lg-line-height);
94
+ }
95
+
96
+ &__titleInner {
97
+ display: flex;
98
+ }
99
+
100
+ &__steps {
101
+ display: flex;
102
+ width: 100%;
103
+ max-width: fn.px-to-rem(192);
104
+ min-height: fn.px-to-rem(20);
105
+ align-items: center;
106
+ justify-content: center;
107
+ gap: var(--cp-spacing-md);
108
+ }
109
+
110
+ &__step {
111
+ height: fn.px-to-rem(4);
112
+ flex: 0 0 fn.px-to-rem(24);
113
+ border-radius: fn.px-to-rem(4);
114
+ background-color: var(--cp-utility-accent-100);
115
+ transition:
116
+ flex-basis 500ms var(--cp-easing-elastic),
117
+ background-color 500ms ease;
118
+
119
+ &--isActive {
120
+ flex-basis: fn.px-to-rem(48);
121
+ background-color: var(--cp-background-accent-solid);
122
+ }
123
+ }
124
+ }
125
+
126
+ .slide-backward,
127
+ .slide-forward {
128
+ &-enter-active,
129
+ &-leave-active {
130
+ transition:
131
+ transform 200ms ease,
132
+ opacity 200ms ease;
133
+ }
134
+
135
+ &-enter-from,
136
+ &-leave-to {
137
+ opacity: 0;
138
+ }
139
+
140
+ &-enter-to {
141
+ transform: translate3d(0, 0, 0);
142
+ }
143
+ }
144
+
145
+ .slide-forward {
146
+ &-enter-from {
147
+ transform: translate3d(3vw, 0, 0);
148
+ }
149
+
150
+ &-leave-to {
151
+ transform: translate3d(-3vw, 0, 0);
152
+ }
153
+ }
154
+
155
+ .slide-backward {
156
+ &-enter-from {
157
+ transform: translate3d(-3vw, 0, 0);
158
+ }
159
+
160
+ &-leave-to {
161
+ transform: translate3d(3vw, 0, 0);
162
+ }
163
+ }
164
+ </style>
@@ -12,6 +12,7 @@ import type { App, Plugin } from 'vue'
12
12
  import { BindOnceDirective } from 'vue-bind-once'
13
13
  import VueTelInput from 'vue-tel-input'
14
14
 
15
+ import { useIsBreakpoint } from '../composables/useIsBreakpoint'
15
16
  import { useReducedMotion } from '../composables/useReducedMotion'
16
17
  import ClickOutside from '../directives/ClickOutside'
17
18
  import CpCoreDatepicker from '../libs/CoreDatepicker.vue'
@@ -59,6 +60,8 @@ import CpSegmentedControl from './CpSegmentedControl.vue'
59
60
  import CpSelect from './CpSelect.vue'
60
61
  import CpSelectableButton from './CpSelectableButton.vue'
61
62
  import CpSelectMenu from './CpSelectMenu.vue'
63
+ import CpStepper from './CpStepper.vue'
64
+ import CpStepperMobile from './CpStepperMobile.vue'
62
65
  import CpSwitch from './CpSwitch.vue'
63
66
  import CpTable from './CpTable.vue'
64
67
  import CpTableColumnEditor from './CpTableColumnEditor.vue'
@@ -130,6 +133,8 @@ const Components = {
130
133
  CpRadio,
131
134
  CpRadioGroup,
132
135
  CpSelectableButton,
136
+ CpStepper,
137
+ CpStepperMobile,
133
138
  CpSwitch,
134
139
  CpSegmentedControl,
135
140
  CpTable,
@@ -190,6 +195,7 @@ const Pimp: Plugin = {
190
195
  },
191
196
  }
192
197
 
198
+ export type { CpStepperStep } from '@/constants/CpStepperTypes'
193
199
  export type {
194
200
  CpSeatMapConfig,
195
201
  CpSeatMapSeatSelection,
@@ -213,6 +219,7 @@ export type {
213
219
 
214
220
  export { CpAncillaryCategoryTypes, isSwitchCategory } from '@/constants/CpAncillaryCategoryTypes'
215
221
  export { CpAncillaryItemTypes } from '@/constants/CpAncillaryItemTypes'
222
+ export { CpStepperTransitionDirections } from '@/constants/CpStepperTypes'
216
223
  export {
217
224
  CpSeatMapTransitionDirections,
218
225
  DEFAULT_CP_SEAT_MAP_CONFIG,
@@ -271,6 +278,8 @@ export {
271
278
  CpSelect,
272
279
  CpSelectableButton,
273
280
  CpSelectMenu,
281
+ CpStepper,
282
+ CpStepperMobile,
274
283
  CpSwitch,
275
284
  CpTable,
276
285
  CpTableColumnEditor,
@@ -303,6 +312,7 @@ export {
303
312
  IconSupplier,
304
313
  IconThirdParty,
305
314
  IconTooltip,
315
+ useIsBreakpoint,
306
316
  useReducedMotion,
307
317
  }
308
318
 
@@ -0,0 +1,9 @@
1
+ export interface CpStepperStep {
2
+ id: string
3
+ label: string
4
+ }
5
+
6
+ export enum CpStepperTransitionDirections {
7
+ BACKWARD = 'backward',
8
+ FORWARD = 'forward',
9
+ }
@@ -0,0 +1,90 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3-vite'
2
+ import { ref } from 'vue'
3
+
4
+ import type { CpStepperStep } from '@/constants/CpStepperTypes'
5
+
6
+ import CpStepper from '@/components/CpStepper.vue'
7
+
8
+ import { docLabelStyle } from '@/stories/documentationStyles'
9
+
10
+ const galleryRowStyle = 'display: flex; flex-direction: column; gap: 10px; width: 100%;'
11
+
12
+ const steps: CpStepperStep[] = [
13
+ { id: 'passengers', label: 'Passengers' },
14
+ { id: 'safetyChecks', label: 'Safety checks' },
15
+ { id: 'seats', label: 'Seats' },
16
+ { id: 'extras', label: 'Extras' },
17
+ { id: 'confirmation', label: 'Confirmation' },
18
+ ]
19
+
20
+ const meta = {
21
+ title: 'Business/CpStepper',
22
+ component: CpStepper,
23
+ argTypes: {
24
+ activeStepId: {
25
+ control: 'select',
26
+ options: steps.map((step) => step.id),
27
+ description: 'id of the step to render as active',
28
+ },
29
+ reachedStepIndex: {
30
+ control: 'number',
31
+ description: 'Highest reached step index; steps beyond it render disabled',
32
+ },
33
+ },
34
+ } satisfies Meta<typeof CpStepper>
35
+
36
+ export default meta
37
+ type Story = StoryObj<typeof meta>
38
+
39
+ /**
40
+ * Clicking an enabled step updates the active step locally, the way a
41
+ * consumer would react to `select` by navigating.
42
+ */
43
+ export const Default: Story = {
44
+ args: {
45
+ steps,
46
+ activeStepId: 'seats',
47
+ reachedStepIndex: 2,
48
+ },
49
+ render: (args) => ({
50
+ components: { CpStepper },
51
+ setup() {
52
+ const activeStepId = ref(args.activeStepId)
53
+
54
+ const handleSelect = (id: string) => {
55
+ activeStepId.value = id
56
+ }
57
+
58
+ return { args, activeStepId, handleSelect }
59
+ },
60
+ template: `
61
+ <div style="width: 70rem;">
62
+ <CpStepper v-bind="args" :active-step-id="activeStepId" @select="handleSelect" />
63
+ </div>
64
+ `,
65
+ }),
66
+ }
67
+
68
+ /**
69
+ * Below the container width threshold (60.5rem, matching online-checkin's
70
+ * former `$breakpoint-large-tablet`), step titles collapse and only the
71
+ * numbered pills remain.
72
+ */
73
+ export const Narrow: Story = {
74
+ args: { ...Default.args },
75
+ parameters: { controls: { disable: true } },
76
+ render: () => ({
77
+ components: { CpStepper },
78
+ setup() {
79
+ return { steps, galleryRowStyle, docLabelStyle }
80
+ },
81
+ template: `
82
+ <div :style="galleryRowStyle">
83
+ <span :style="docLabelStyle">Container width: 28rem</span>
84
+ <div style="width: 28rem;">
85
+ <CpStepper :steps="steps" active-step-id="seats" :reached-step-index="2" />
86
+ </div>
87
+ </div>
88
+ `,
89
+ }),
90
+ }
@@ -0,0 +1,92 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3-vite'
2
+ import { computed, ref } from 'vue'
3
+
4
+ import type { CpStepperStep } from '@/constants/CpStepperTypes'
5
+
6
+ import CpButton from '@/components/CpButton.vue'
7
+ import CpStepperMobile from '@/components/CpStepperMobile.vue'
8
+
9
+ const steps: CpStepperStep[] = [
10
+ { id: 'passengers', label: 'Passengers' },
11
+ { id: 'safetyChecks', label: 'Safety checks' },
12
+ { id: 'seats', label: 'Seats' },
13
+ { id: 'extras', label: 'Extras' },
14
+ { id: 'confirmation', label: 'Confirmation' },
15
+ ]
16
+
17
+ const meta = {
18
+ title: 'Business/CpStepperMobile',
19
+ component: CpStepperMobile,
20
+ argTypes: {
21
+ activeStepId: {
22
+ control: 'select',
23
+ options: steps.map((step) => step.id),
24
+ description: 'id of the step to render as active',
25
+ },
26
+ hasPreviousButton: {
27
+ control: 'boolean',
28
+ description: 'Whether the back chevron button is rendered',
29
+ },
30
+ },
31
+ } satisfies Meta<typeof CpStepperMobile>
32
+
33
+ export default meta
34
+ type Story = StoryObj<typeof meta>
35
+
36
+ export const Default: Story = {
37
+ args: {
38
+ steps,
39
+ activeStepId: 'seats',
40
+ hasPreviousButton: true,
41
+ },
42
+ render: (args) => ({
43
+ components: { CpStepperMobile },
44
+ setup() {
45
+ return { args }
46
+ },
47
+ template: `
48
+ <div style="width: 24rem;">
49
+ <CpStepperMobile v-bind="args" />
50
+ </div>
51
+ `,
52
+ }),
53
+ }
54
+
55
+ /**
56
+ * Previous/Next move the active step index so the title's
57
+ * slide-forward / slide-backward transition plays in each direction.
58
+ */
59
+ export const Interactive: Story = {
60
+ args: { ...Default.args },
61
+ parameters: { controls: { disable: true } },
62
+ render: () => ({
63
+ components: { CpStepperMobile, CpButton },
64
+ setup() {
65
+ const activeIndex = ref(2)
66
+ const activeStepId = computed(() => steps[activeIndex.value].id)
67
+ const hasPreviousButton = computed(() => activeIndex.value > 0)
68
+ const isLastStep = computed(() => activeIndex.value === steps.length - 1)
69
+
70
+ const handlePrevious = () => {
71
+ activeIndex.value -= 1
72
+ }
73
+
74
+ const handleNext = () => {
75
+ activeIndex.value += 1
76
+ }
77
+
78
+ return { steps, activeStepId, hasPreviousButton, isLastStep, handlePrevious, handleNext }
79
+ },
80
+ template: `
81
+ <div style="display: flex; flex-direction: column; gap: 16px; width: 24rem;">
82
+ <CpStepperMobile
83
+ :steps="steps"
84
+ :active-step-id="activeStepId"
85
+ :has-previous-button="hasPreviousButton"
86
+ @previous="handlePrevious"
87
+ />
88
+ <CpButton :disabled="isLastStep" @click="handleNext">Next</CpButton>
89
+ </div>
90
+ `,
91
+ }),
92
+ }