@citizenplane/pimp 18.19.10 → 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.10",
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>
@@ -60,6 +60,8 @@ import CpSegmentedControl from './CpSegmentedControl.vue'
60
60
  import CpSelect from './CpSelect.vue'
61
61
  import CpSelectableButton from './CpSelectableButton.vue'
62
62
  import CpSelectMenu from './CpSelectMenu.vue'
63
+ import CpStepper from './CpStepper.vue'
64
+ import CpStepperMobile from './CpStepperMobile.vue'
63
65
  import CpSwitch from './CpSwitch.vue'
64
66
  import CpTable from './CpTable.vue'
65
67
  import CpTableColumnEditor from './CpTableColumnEditor.vue'
@@ -131,6 +133,8 @@ const Components = {
131
133
  CpRadio,
132
134
  CpRadioGroup,
133
135
  CpSelectableButton,
136
+ CpStepper,
137
+ CpStepperMobile,
134
138
  CpSwitch,
135
139
  CpSegmentedControl,
136
140
  CpTable,
@@ -191,6 +195,7 @@ const Pimp: Plugin = {
191
195
  },
192
196
  }
193
197
 
198
+ export type { CpStepperStep } from '@/constants/CpStepperTypes'
194
199
  export type {
195
200
  CpSeatMapConfig,
196
201
  CpSeatMapSeatSelection,
@@ -214,6 +219,7 @@ export type {
214
219
 
215
220
  export { CpAncillaryCategoryTypes, isSwitchCategory } from '@/constants/CpAncillaryCategoryTypes'
216
221
  export { CpAncillaryItemTypes } from '@/constants/CpAncillaryItemTypes'
222
+ export { CpStepperTransitionDirections } from '@/constants/CpStepperTypes'
217
223
  export {
218
224
  CpSeatMapTransitionDirections,
219
225
  DEFAULT_CP_SEAT_MAP_CONFIG,
@@ -272,6 +278,8 @@ export {
272
278
  CpSelect,
273
279
  CpSelectableButton,
274
280
  CpSelectMenu,
281
+ CpStepper,
282
+ CpStepperMobile,
275
283
  CpSwitch,
276
284
  CpTable,
277
285
  CpTableColumnEditor,
@@ -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
+ }