@citizenplane/pimp 18.11.0 → 18.12.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.
@@ -1,6 +1,7 @@
1
1
  import type { Meta, StoryObj } from '@storybook/vue3-vite'
2
2
  import { action } from 'storybook/actions'
3
3
  import { computed, ref, watch } from 'vue'
4
+ import type { ComponentProps } from 'vue-component-type-helpers'
4
5
 
5
6
  import type { CpSeatMapSeatSelection } from '@/constants/seatMap/CpSeatMapSeatSelection'
6
7
  import type { Seat } from '@/constants/seatMap/Seat'
@@ -10,30 +11,57 @@ import type { SeatRow } from '@/constants/seatMap/SeatRow'
10
11
  import type { SeatRowFacilityFlags } from '@/constants/seatMap/SeatRowFacilityFlags'
11
12
 
12
13
  import CpSeatMap from '@/components/CpSeatMap.vue'
13
- import CpSelect from '@/components/CpSelect.vue'
14
14
 
15
15
  import { IataTravelerTypes } from '@/constants/seatMap/IataTravelerTypes'
16
16
  import { SeatStatuses } from '@/constants/seatMap/SeatStatuses'
17
17
  import { displaySeat } from '@/helpers/seatMap'
18
18
 
19
+ const AIRCRAFT_LABELS = {
20
+ 'atr-72': 'ATR 72 — 2+2',
21
+ a320: 'A320 — 3+3',
22
+ b777: 'B777 — 3+4+3',
23
+ } as const
24
+
25
+ type AircraftId = keyof typeof AIRCRAFT_LABELS
26
+
27
+ /**
28
+ * `aircraft` is a story-only knob: the component has no such prop, the render
29
+ * function turns it into the matching `seatMap`, which is therefore hidden.
30
+ */
31
+ type CpSeatMapArgs = Omit<ComponentProps<typeof CpSeatMap>, 'seatMap'> & {
32
+ aircraft: AircraftId
33
+ seatMap?: SeatMap
34
+ }
35
+
19
36
  const meta = {
20
37
  title: 'Business/CpSeatMap',
21
38
  component: CpSeatMap,
22
39
  parameters: {
23
40
  layout: 'padded',
24
41
  },
42
+ args: {
43
+ aircraft: 'a320',
44
+ },
45
+ argTypes: {
46
+ aircraft: {
47
+ control: { type: 'select', labels: AIRCRAFT_LABELS },
48
+ options: Object.keys(AIRCRAFT_LABELS),
49
+ description: 'Story-only: picks the seat map handed to the component.',
50
+ },
51
+ seatMap: { control: false, table: { disable: true } },
52
+ },
25
53
  // The plane wings sit 180px outside the cabin, so the story needs side room.
26
54
  decorators: [
27
55
  () => ({
28
56
  template:
29
- '<div style="margin: 0 auto; padding: 40px; border-radius: 8px; background-color: #fff;"><story/></div>',
57
+ '<div style="display: flex; justify-content: center;margin: 0 auto; padding: 40px; border-radius: 8px; background-color: #fff;"><story/></div>',
30
58
  }),
31
59
  ],
32
- } satisfies Meta<typeof CpSeatMap>
60
+ } satisfies Meta<CpSeatMapArgs>
33
61
 
34
62
  export default meta
35
63
 
36
- type Story = StoryObj<typeof meta>
64
+ type Story = StoryObj<CpSeatMapArgs>
37
65
 
38
66
  const EXTRA_LEGROOM = {
39
67
  amount: 12,
@@ -244,18 +272,12 @@ const b777 = makeSeatMap('B777', [
244
272
  },
245
273
  ])
246
274
 
247
- const AIRCRAFT: Record<string, SeatMap> = {
275
+ const AIRCRAFT: Record<AircraftId, SeatMap> = {
248
276
  'atr-72': atr72,
249
277
  a320,
250
278
  b777,
251
279
  }
252
280
 
253
- const AIRCRAFT_OPTIONS = [
254
- { label: 'ATR 72 — 2+2', value: 'atr-72' },
255
- { label: 'A320 — 3+3', value: 'a320' },
256
- { label: 'B777 — 3+4+3', value: 'b777' },
257
- ]
258
-
259
281
  const selection = (
260
282
  row: number,
261
283
  column: string,
@@ -265,27 +287,36 @@ const selection = (
265
287
  })
266
288
 
267
289
  /**
268
- * Every story picks its aircraft, so one map can be compared against another.
290
+ * The `aircraft` control picks the map, so one aircraft can be compared against
291
+ * another from the controls panel.
269
292
  *
270
293
  * `isInteractive` wires the click back into `selections`, which is what a consumer
271
294
  * has to do: the map only reports the seat, it never remembers it.
272
295
  */
273
- const withAircraftPicker =
296
+ const withAircraftControl =
274
297
  (isInteractive = false) =>
275
- (args: NonNullable<Story['args']>) => ({
276
- components: { CpSeatMap, CpSelect },
298
+ (args: CpSeatMapArgs) => ({
299
+ components: { CpSeatMap },
277
300
  setup() {
278
- const aircraft = ref('a320')
279
- const seatMap = computed(() => AIRCRAFT[aircraft.value] as SeatMap)
301
+ const seatMap = computed(() => AIRCRAFT[args.aircraft])
280
302
 
281
303
  const ownSelections = ref<Record<string, CpSeatMapSeatSelection>>({})
282
304
  const selections = computed(() => (isInteractive ? ownSelections.value : args.selections))
283
305
 
284
- // A seat key from one aircraft rarely exists on the next one.
285
- watch(aircraft, () => {
286
- ownSelections.value = {}
306
+ // `aircraft` isn't a component prop, so it's kept off `v-bind`.
307
+ const componentArgs = computed(() => {
308
+ const { aircraft, seatMap: _, ...rest } = args
309
+ return rest
287
310
  })
288
311
 
312
+ // A seat key from one aircraft rarely exists on the next one.
313
+ watch(
314
+ () => args.aircraft,
315
+ () => {
316
+ ownSelections.value = {}
317
+ },
318
+ )
319
+
289
320
  const handleSelect = (seat: Seat, rowNumber: number) => {
290
321
  action('select')(seat, rowNumber)
291
322
  if (!isInteractive) return
@@ -295,16 +326,10 @@ const withAircraftPicker =
295
326
  ownSelections.value = key in ownSelections.value ? {} : { [key]: { initials: 'RN' } }
296
327
  }
297
328
 
298
- return { args, aircraft, seatMap, selections, handleSelect, aircraftOptions: AIRCRAFT_OPTIONS }
329
+ return { componentArgs, seatMap, selections, handleSelect }
299
330
  },
300
- // The plane nose bleeds upwards out of the cabin, so the picker is lifted above it.
301
331
  template: `
302
- <div style="display: flex; flex-direction: column; gap: 24px; width:fit-content; margin: 0 auto;">
303
- <div style="position: absolute; top: 24px; left: 24px; z-index: 1; max-width: 280px; background-color: #fff;">
304
- <cp-select v-model="aircraft" hide-default-value label="Aircraft" :options="aircraftOptions" />
305
- </div>
306
- <cp-seat-map v-bind="args" :seat-map="seatMap" :selections="selections" @select="handleSelect" />
307
- </div>
332
+ <cp-seat-map v-bind="componentArgs" :seat-map="seatMap" :selections="selections" @select="handleSelect" />
308
333
  `,
309
334
  })
310
335
 
@@ -314,10 +339,7 @@ const withAircraftPicker =
314
339
  * blocked and paid-for seats read from the map.
315
340
  */
316
341
  export const Default: Story = {
317
- args: {
318
- seatMap: a320,
319
- },
320
- render: withAircraftPicker(true),
342
+ render: withAircraftControl(true),
321
343
  }
322
344
 
323
345
  /**
@@ -326,14 +348,13 @@ export const Default: Story = {
326
348
  */
327
349
  export const WithSelection: Story = {
328
350
  args: {
329
- seatMap: a320,
330
351
  selections: {
331
352
  ...selection(7, 'B', { initials: 'JD' }),
332
353
  ...selection(8, 'C', { initials: 'AM' }),
333
354
  },
334
355
  onSelect: action('select'),
335
356
  },
336
- render: withAircraftPicker(),
357
+ render: withAircraftControl(),
337
358
  }
338
359
 
339
360
  /**
@@ -342,7 +363,6 @@ export const WithSelection: Story = {
342
363
  */
343
364
  export const WithLinkedInfant: Story = {
344
365
  args: {
345
- seatMap: a320,
346
366
  selections: {
347
367
  ...selection(7, 'B', {
348
368
  initials: 'JD',
@@ -353,5 +373,5 @@ export const WithLinkedInfant: Story = {
353
373
  },
354
374
  onSelect: action('select'),
355
375
  },
356
- render: withAircraftPicker(),
376
+ render: withAircraftControl(),
357
377
  }
@@ -0,0 +1,261 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3-vite'
2
+ import { computed, ref, watch } from 'vue'
3
+
4
+ import CpSegmentedControl from '@/components/CpSegmentedControl.vue'
5
+
6
+ import { docCellStyle, docLabelStyle, docRowColumnStyle } from '@/stories/documentationStyles'
7
+
8
+ const segmentedControlSizes = ['sm', 'md'] as const
9
+
10
+ const meta = {
11
+ title: 'Molecules/CpSegmentedControl',
12
+ component: CpSegmentedControl,
13
+ argTypes: {
14
+ modelValue: {
15
+ control: 'text',
16
+ description: 'The selected option value',
17
+ },
18
+ name: {
19
+ control: 'text',
20
+ description: 'Name attribute for the radio group',
21
+ },
22
+ options: {
23
+ control: 'object',
24
+ description: 'Array of segmented options',
25
+ },
26
+ size: {
27
+ control: 'select',
28
+ options: segmentedControlSizes,
29
+ description: 'The size of the segmented control',
30
+ },
31
+ isDisabled: {
32
+ control: 'boolean',
33
+ description: 'Whether the control is disabled',
34
+ },
35
+ isFullWidth: {
36
+ control: 'boolean',
37
+ description: 'Whether each option stretches to fill the available width',
38
+ },
39
+ isInvalid: {
40
+ control: 'boolean',
41
+ description: 'Whether the control is in an invalid state',
42
+ },
43
+ orientation: {
44
+ control: 'select',
45
+ options: ['horizontal', 'vertical'],
46
+ description: 'The orientation of the control',
47
+ },
48
+ },
49
+ } satisfies Meta<typeof CpSegmentedControl>
50
+
51
+ export default meta
52
+ type Story = StoryObj<typeof meta>
53
+
54
+ const sampleOptions = [
55
+ { value: 'option1', label: 'Option 1', leadingIcon: 'dashed-circle', trailingIcon: 'dashed-circle' },
56
+ { value: 'option2', label: 'Option 2', leadingIcon: 'dashed-circle', trailingIcon: 'dashed-circle' },
57
+ { value: 'option3', label: 'Option 3', leadingIcon: 'dashed-circle', trailingIcon: 'dashed-circle' },
58
+ ]
59
+
60
+ const createRender = (label: string) => (args: { modelValue?: string; isFullWidth?: boolean }) => ({
61
+ components: { CpSegmentedControl },
62
+ setup() {
63
+ const value = ref(args.modelValue)
64
+
65
+ watch(
66
+ () => args.modelValue,
67
+ (modelValue) => {
68
+ value.value = modelValue
69
+ },
70
+ )
71
+
72
+ const boundArgs = computed(() => {
73
+ const { modelValue: _modelValue, ...rest } = args
74
+ return rest
75
+ })
76
+
77
+ const cellStyle = args.isFullWidth ? `${docCellStyle} width: 100%;` : docCellStyle
78
+
79
+ return { boundArgs, value, label, cellStyle, docLabelStyle, docRowColumnStyle }
80
+ },
81
+ template: `
82
+ <div :style="docRowColumnStyle">
83
+ <div :style="cellStyle">
84
+ <span :style="docLabelStyle">{{ label }}</span>
85
+ <CpSegmentedControl v-bind="boundArgs" v-model="value" />
86
+ <p :style="docLabelStyle">modelValue: <code>{{ value }}</code></p>
87
+ </div>
88
+ </div>
89
+ `,
90
+ })
91
+
92
+ /**
93
+ * Default segmented control. Use the controls to experiment with each prop
94
+ * in isolation.
95
+ */
96
+ export const Default: Story = {
97
+ args: {
98
+ modelValue: 'option1',
99
+ name: 'segmented-control-default',
100
+ options: sampleOptions,
101
+ size: 'md',
102
+ isFullWidth: false,
103
+ isInvalid: false,
104
+ },
105
+ render: createRender('Default'),
106
+ }
107
+
108
+ /**
109
+ * Options rendered with leading and trailing icons.
110
+ */
111
+ export const WithIcons: Story = {
112
+ args: {
113
+ ...Default.args,
114
+ name: 'segmented-control-icons',
115
+ options: [
116
+ { value: 'list', label: 'List', leadingIcon: 'dashed-circle', trailingIcon: 'dashed-circle' },
117
+ { value: 'grid', label: 'Grid', leadingIcon: 'dashed-circle', trailingIcon: 'dashed-circle' },
118
+ { value: 'map', label: 'Map', leadingIcon: 'dashed-circle', trailingIcon: 'dashed-circle' },
119
+ ],
120
+ modelValue: 'list',
121
+ },
122
+ render: createRender('With icons'),
123
+ }
124
+
125
+ /**
126
+ * Available sizes side by side — `sm` and `md`.
127
+ */
128
+ export const Sizes: Story = {
129
+ args: {
130
+ ...Default.args,
131
+ name: 'segmented-control-sizes',
132
+ },
133
+ parameters: { controls: { disable: true } },
134
+ render: () => ({
135
+ components: { CpSegmentedControl },
136
+ setup() {
137
+ const values = ref({
138
+ sm: 'option1',
139
+ md: 'option1',
140
+ })
141
+
142
+ return { values, sampleOptions, segmentedControlSizes, docCellStyle, docLabelStyle, docRowColumnStyle }
143
+ },
144
+ template: `
145
+ <div :style="docRowColumnStyle">
146
+ <div v-for="size in segmentedControlSizes" :key="size" :style="docCellStyle">
147
+ <span :style="docLabelStyle">{{ size }}</span>
148
+ <CpSegmentedControl
149
+ v-model="values[size]"
150
+ :name="'segmented-control-sizes-' + size"
151
+ :options="sampleOptions"
152
+ :size="size"
153
+ />
154
+ <p :style="docLabelStyle">modelValue: <code>{{ values[size] }}</code></p>
155
+ </div>
156
+ </div>
157
+ `,
158
+ }),
159
+ }
160
+
161
+ /**
162
+ * Stretch each option so the control fills its container with
163
+ * `isFullWidth`.
164
+ */
165
+ export const FullWidth: Story = {
166
+ args: {
167
+ ...Default.args,
168
+ name: 'segmented-control-full-width',
169
+ isFullWidth: true,
170
+ },
171
+ parameters: {
172
+ layout: 'padded',
173
+ },
174
+ decorators: [
175
+ () => ({
176
+ template: '<div style="width: 100%;"><story /></div>',
177
+ }),
178
+ ],
179
+ render: (args) => ({
180
+ components: { CpSegmentedControl },
181
+ setup() {
182
+ const value = ref(args.modelValue)
183
+
184
+ watch(
185
+ () => args.modelValue,
186
+ (modelValue) => {
187
+ value.value = modelValue
188
+ },
189
+ )
190
+
191
+ const boundArgs = computed(() => {
192
+ const { modelValue: _modelValue, ...rest } = args
193
+ return rest
194
+ })
195
+
196
+ const cellStyle = `${docCellStyle} width: 100%;`
197
+
198
+ return { boundArgs, value, cellStyle, docLabelStyle, docRowColumnStyle }
199
+ },
200
+ template: `
201
+ <div :style="docRowColumnStyle">
202
+ <div :style="cellStyle">
203
+ <span :style="docLabelStyle">Full width</span>
204
+ <CpSegmentedControl v-bind="boundArgs" v-model="value" />
205
+ <p :style="docLabelStyle">modelValue: <code>{{ value }}</code></p>
206
+ </div>
207
+ </div>
208
+ `,
209
+ }),
210
+ }
211
+
212
+ /**
213
+ * Disable the whole control.
214
+ */
215
+ export const WithDisabledOption: Story = {
216
+ args: {
217
+ ...Default.args,
218
+ name: 'segmented-control-disabled',
219
+ isDisabled: true,
220
+ modelValue: undefined,
221
+ },
222
+ render: createRender('Disabled'),
223
+ }
224
+
225
+ /**
226
+ * A disabled option can still be the current value. Use this pattern for
227
+ * read-only pre-selections.
228
+ */
229
+ export const CheckedDisabled: Story = {
230
+ args: {
231
+ ...Default.args,
232
+ name: 'segmented-control-checked-disabled',
233
+ isDisabled: true,
234
+ modelValue: 'option1',
235
+ },
236
+ render: createRender('Checked disabled'),
237
+ }
238
+
239
+ /**
240
+ * Error state — highlights the control border when validation fails.
241
+ */
242
+ export const Invalid: Story = {
243
+ args: {
244
+ ...Default.args,
245
+ name: 'segmented-control-invalid',
246
+ isInvalid: true,
247
+ },
248
+ render: createRender('Invalid'),
249
+ }
250
+
251
+ /**
252
+ * Vertical orientation
253
+ */
254
+ export const Vertical: Story = {
255
+ args: {
256
+ ...Default.args,
257
+ name: 'segmented-control-vertical',
258
+ orientation: 'vertical',
259
+ },
260
+ render: createRender('Vertical'),
261
+ }