@citizenplane/pimp 18.23.3 → 18.23.5

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.23.3",
3
+ "version": "18.23.5",
4
4
  "scripts": {
5
5
  "dev": "storybook dev -p 8081",
6
6
  "build-storybook": "storybook build --output-dir ./docs",
@@ -11,6 +11,7 @@
11
11
  "lint:style:fix": "stylelint \"**/*.{css,scss,vue}\" --fix",
12
12
  "format": "biome format --write .",
13
13
  "types": "vue-tsc --noEmit",
14
+ "test": "vitest run",
14
15
  "commitlint": "commitlint --edit",
15
16
  "prepare": "husky"
16
17
  },
@@ -74,6 +75,7 @@
74
75
  "@vitest/coverage-v8": "4.1.9",
75
76
  "@vue/test-utils": "2.4.11",
76
77
  "commitlint-config-gitmoji": "2.3.1",
78
+ "happy-dom": "20.14.5",
77
79
  "husky": "9.1.7",
78
80
  "lint-staged": "17.2.0",
79
81
  "playwright": "1.62.0",
@@ -0,0 +1,90 @@
1
+ import { mount } from '@vue/test-utils'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import { defineComponent } from 'vue'
4
+
5
+ import type { CpBasketRow } from '@/constants/CpBasketTypes'
6
+
7
+ import CpBasket from '@/components/CpBasket.vue'
8
+
9
+ const seatsCategory = { id: 'seats', label: 'Seats', subtotal: '24,00 €' }
10
+ const promoCategory = { id: 'promo', label: 'Promo code', subtotal: '-10,00 €' }
11
+
12
+ const outboundGroup = { id: 'CDG-ORY', label: 'CDG → ORY' }
13
+
14
+ const textOnlyRows: CpBasketRow[] = [
15
+ { label: 'Seat 12A', value: '12,00 €', category: seatsCategory, group: outboundGroup },
16
+ { label: 'Carbon offset', value: '3,00 €', category: seatsCategory },
17
+ ]
18
+
19
+ const promoRows: CpBasketRow[] = [{ label: 'Promo SUMMER25 applied', value: '-10,00 €', category: promoCategory }]
20
+
21
+ const defaultProps = { total: '47,00 €', totalLabel: 'Total' }
22
+
23
+ describe('CpBasket', () => {
24
+ it('renders text-only rows as two morphing spans', () => {
25
+ const wrapper = mount(CpBasket, { props: { ...defaultProps, rows: textOnlyRows } })
26
+
27
+ const renderedRows = wrapper.findAll('.cpBasket__row')
28
+
29
+ expect(renderedRows).toHaveLength(2)
30
+ expect(renderedRows[0].element.innerHTML).toBe('<span class="">Seat 12A</span><span class="">12,00 €</span>')
31
+ expect(renderedRows[1].element.innerHTML).toBe('<span class="">Carbon offset</span><span class="">3,00 €</span>')
32
+ expect(wrapper.html()).toMatchSnapshot()
33
+ })
34
+
35
+ it('keeps the row markup untouched when only one cell slot is provided', () => {
36
+ const wrapper = mount(CpBasket, {
37
+ props: { ...defaultProps, rows: promoRows },
38
+ slots: { rowValue: '<span class="promoValue">{{ params.row.value }}</span>' },
39
+ })
40
+
41
+ const renderedRow = wrapper.get('.cpBasket__row')
42
+
43
+ expect(renderedRow.element.children).toHaveLength(2)
44
+ expect(renderedRow.element.children[0].outerHTML).toBe('<span class="">Promo SUMMER25 applied</span>')
45
+ expect(renderedRow.get('.promoValue').text()).toBe('-10,00 €')
46
+ })
47
+
48
+ it('reaches the consumer handler when a button in the label slot is clicked', async () => {
49
+ const removePromoCode = vi.fn()
50
+
51
+ const host = defineComponent({
52
+ components: { CpBasket },
53
+ setup: () => ({ defaultProps, promoRows, removePromoCode }),
54
+ template: `
55
+ <CpBasket v-bind="defaultProps" :rows="promoRows">
56
+ <template #rowLabel="{ row }">
57
+ <span>{{ row.label }} <button type="button" @click="removePromoCode">Remove</button></span>
58
+ </template>
59
+ </CpBasket>
60
+ `,
61
+ })
62
+
63
+ const wrapper = mount(host)
64
+
65
+ const removeButton = wrapper.get('.cpBasket__row button')
66
+
67
+ expect(removeButton.element.tagName).toBe('BUTTON')
68
+ expect(removeButton.element.closest('.cpBasket__row')).toBe(wrapper.get('.cpBasket__row').element)
69
+
70
+ await removeButton.trigger('click')
71
+
72
+ expect(removePromoCode).toHaveBeenCalledTimes(1)
73
+ })
74
+
75
+ it('right-aligns the value cell filled by the rowValue slot', () => {
76
+ const wrapper = mount(CpBasket, {
77
+ props: { ...defaultProps, rows: promoRows },
78
+ slots: { rowValue: '<span class="promoValue">{{ params.row.value }}</span>' },
79
+ attachTo: document.body,
80
+ })
81
+
82
+ const valueCell = wrapper.get('.promoValue').element
83
+ const valueCellStyle = window.getComputedStyle(valueCell)
84
+
85
+ expect(valueCellStyle.textAlign).toBe('right')
86
+ expect(valueCellStyle.fontVariantNumeric).toBe('tabular-nums')
87
+
88
+ wrapper.unmount()
89
+ })
90
+ })
@@ -16,12 +16,10 @@
16
16
  <cp-transition-list-items>
17
17
  <div v-for="group in category.groups" :key="group.id" class="cpBasket__group">
18
18
  <p v-if="group.label" class="cpBasket__route">{{ group.label }}</p>
19
- <cp-transition-list-items>
20
- <div v-for="row in group.rows" :key="row.key" :class="group.rowClass">
21
- <span>{{ row.label }}</span>
22
- <span>{{ row.value }}</span>
23
- </div>
24
- </cp-transition-list-items>
19
+ <div v-for="row in group.rows" :key="row.key" :class="group.rowClass">
20
+ <slot name="rowLabel" :row="row"><cp-text-morph :text="row.label" as="span" /></slot>
21
+ <slot name="rowValue" :row="row"><cp-text-morph :text="row.value" as="span" /></slot>
22
+ </div>
25
23
  </div>
26
24
  </cp-transition-list-items>
27
25
  </div>
@@ -46,6 +44,7 @@ import { computed, useSlots } from 'vue'
46
44
 
47
45
  import type { CpBasketCategory, CpBasketRow } from '@/constants/CpBasketTypes'
48
46
 
47
+ import CpTextMorph from '@/components/CpTextMorph.vue'
49
48
  import CpTransitionExpand from '@/components/CpTransitionExpand.vue'
50
49
  import CpTransitionListItems from '@/components/CpTransitionListItems.vue'
51
50
 
@@ -243,6 +242,11 @@ const hasSections = computed(() => visibleCategories.value.length > 0)
243
242
  &--isIndented {
244
243
  padding-left: var(--cp-spacing-md);
245
244
  }
245
+
246
+ & > :last-child {
247
+ font-variant-numeric: tabular-nums;
248
+ text-align: right;
249
+ }
246
250
  }
247
251
 
248
252
  &__total {
@@ -64,7 +64,7 @@ const increment = () => {
64
64
  align-items: center;
65
65
  padding: var(--cp-spacing-xs);
66
66
  border-radius: var(--cp-radius-md-lg);
67
- background: var(--cp-background-accent-primary);
67
+ background: var(--cp-background-secondary);
68
68
  gap: var(--cp-spacing-sm);
69
69
 
70
70
  &__button {
@@ -0,0 +1,36 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`CpBasket > renders text-only rows as two morphing spans 1`] = `
4
+ "<div class="cpBasket">
5
+ <!--v-if-->
6
+ <transition-stub data-v-19d824c7="" name="expand" appear="false" persisted="false" css="true">
7
+ <div>
8
+ <div class="cpBasket__details">
9
+ <div class="cpBasket__category">
10
+ <div class="cpBasket__categoryTitle">
11
+ <p>Seats</p>
12
+ <p>24,00 €</p>
13
+ </div>
14
+ <div class="cpBasket__groups">
15
+ <transition-group-stub name="list-items" appear="false" persisted="false" css="true">
16
+ <div class="cpBasket__group">
17
+ <p class="cpBasket__route">CDG → ORY</p>
18
+ <div class="cpBasket__row cpBasket__row--isIndented"><span class="">Seat 12A</span><span class="">12,00 €</span></div>
19
+ </div>
20
+ <div class="cpBasket__group">
21
+ <!--v-if-->
22
+ <div class="cpBasket__row"><span class="">Carbon offset</span><span class="">3,00 €</span></div>
23
+ </div>
24
+ </transition-group-stub>
25
+ </div>
26
+ </div>
27
+ </div>
28
+ </div>
29
+ </transition-stub>
30
+ <div class="cpBasket__total">
31
+ <p>Total</p>
32
+ <p class="cpBasket__totalValue">47,00 €</p>
33
+ </div>
34
+ <!--v-if-->
35
+ </div>"
36
+ `;
@@ -1,5 +1,5 @@
1
1
  import type { Args, Meta, StoryObj } from '@storybook/vue3-vite'
2
- import { ref } from 'vue'
2
+ import { computed, ref } from 'vue'
3
3
 
4
4
  import type { CpBasketRow } from '@/constants/CpBasketTypes'
5
5
 
@@ -99,6 +99,36 @@ const fakeRowPool: CpBasketRow[] = [
99
99
  { label: 'Priority boarding', value: '8,00 €', category: extrasCategory, group: null },
100
100
  ]
101
101
 
102
+ const morphBaseRows: CpBasketRow[] = [
103
+ { label: 'Seat 12A', value: '12,00 €', category: { ...seatsCategory, subtotal: '12,00 €' }, group: outboundGroup },
104
+ {
105
+ label: 'Cabin baggage 10kg',
106
+ value: '20,00 €',
107
+ category: { ...baggageCategory, subtotal: '20,00 €' },
108
+ group: outboundGroup,
109
+ },
110
+ { label: 'Carbon offset', value: '3,00 €', category: { ...extrasCategory, subtotal: '3,00 €' } },
111
+ ]
112
+
113
+ const morphDoubledRows: CpBasketRow[] = [
114
+ { label: 'Seat 12A', value: '24,00 €', category: { ...seatsCategory, subtotal: '24,00 €' }, group: outboundGroup },
115
+ {
116
+ label: 'Cabin baggage 10kg',
117
+ value: '40,00 €',
118
+ category: { ...baggageCategory, subtotal: '40,00 €' },
119
+ group: outboundGroup,
120
+ },
121
+ { label: 'Carbon offset', value: '6,00 €', category: { ...extrasCategory, subtotal: '6,00 €' } },
122
+ ]
123
+
124
+ const promoCategory = { id: 'promo', label: 'Promo code', subtotal: '-10,00 €' }
125
+
126
+ const promoRows: CpBasketRow[] = [
127
+ { label: 'Seat 12A', value: '12,00 €', category: seatsCategory, group: outboundGroup },
128
+ { label: '2 × Cabin baggage 10kg', value: '20,00 €', category: baggageCategory, group: outboundGroup },
129
+ { label: 'Promo SUMMER25 applied', value: '-10,00 €', category: promoCategory },
130
+ ]
131
+
102
132
  const flightsCategory = { id: 'flights', label: 'Flights', subtotal: '159,80 €' }
103
133
  const hiddenSeatsCategory = { id: 'seats', label: 'Seats', subtotal: '99,60 €' }
104
134
  const tripExtrasCategory = { id: 'tripExtras', label: 'Trip extras', subtotal: '269,40 €' }
@@ -321,3 +351,82 @@ export const TrailingSlot: Story = {
321
351
  `,
322
352
  }),
323
353
  }
354
+
355
+ /**
356
+ * The rows keep their identity while their amounts change: clicking "Double
357
+ * the quantities" only swaps `row.value`, so each value morphs in place
358
+ * through `cp-text-morph` instead of the row re-entering the list.
359
+ */
360
+ export const MorphingRowValues: Story = {
361
+ args: {
362
+ rows: morphBaseRows,
363
+ total: '35,00 €',
364
+ totalLabel: 'Total',
365
+ },
366
+ render: (args: Args) => ({
367
+ components: { CpBasket, CpButton },
368
+ setup() {
369
+ const isDoubled = ref(false)
370
+
371
+ const storyRows = computed<CpBasketRow[]>(() => (isDoubled.value ? morphDoubledRows : morphBaseRows))
372
+ const storyTotal = computed<string>(() => (isDoubled.value ? '70,00 €' : '35,00 €'))
373
+
374
+ const toggleQuantities = () => {
375
+ isDoubled.value = !isDoubled.value
376
+ }
377
+
378
+ return { args, storyRows, storyTotal, toggleQuantities }
379
+ },
380
+ template: `
381
+ <div style="display: flex; flex-direction: column; gap: 16px; max-width: 415px;">
382
+ <CpButton @click="toggleQuantities">Double the quantities</CpButton>
383
+ <CpBasket v-bind="args" :rows="storyRows" :total="storyTotal" />
384
+ </div>
385
+ `,
386
+ }),
387
+ }
388
+
389
+ /**
390
+ * `#rowLabel` and `#rowValue` let the consumer own one cell of a row while the
391
+ * rest of the basket keeps its default text rendering. Here the promo row
392
+ * carries a real `cp-button` next to its label and a success-toned negative
393
+ * amount, and clicking "Remove" drops the row from the list.
394
+ */
395
+ export const PromoCodeRow: Story = {
396
+ args: {
397
+ rows: promoRows,
398
+ total: '22,00 €',
399
+ totalLabel: 'Total',
400
+ },
401
+ render: (args: Args) => ({
402
+ components: { CpBasket, CpButton },
403
+ setup() {
404
+ const storyRows = ref<CpBasketRow[]>([...promoRows])
405
+
406
+ const removePromoCode = () => {
407
+ storyRows.value = storyRows.value.filter((row) => row.category.id !== promoCategory.id)
408
+ }
409
+
410
+ return { args, promoCategoryId: promoCategory.id, removePromoCode, storyRows }
411
+ },
412
+ template: `
413
+ <div style="max-width: 415px;">
414
+ <CpBasket v-bind="args" :rows="storyRows">
415
+ <template #rowLabel="{ row }">
416
+ <span v-if="row.category.id === promoCategoryId">
417
+ {{ row.label }}
418
+ <CpButton appearance="tertiary" color="accent" size="2xs" @click="removePromoCode">Remove</CpButton>
419
+ </span>
420
+ <span v-else>{{ row.label }}</span>
421
+ </template>
422
+ <template #rowValue="{ row }">
423
+ <span v-if="row.category.id === promoCategoryId" style="color: var(--cp-text-success-primary);">
424
+ {{ row.value }}
425
+ </span>
426
+ <span v-else>{{ row.value }}</span>
427
+ </template>
428
+ </CpBasket>
429
+ </div>
430
+ `,
431
+ }),
432
+ }
@@ -10,5 +10,14 @@
10
10
  "skipLibCheck": true
11
11
  },
12
12
  "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "src/**/*.d.ts"],
13
- "exclude": ["node_modules", "dist", "src/stories", "**/*.stories.ts", "**/*.stories.vue", "**/*.stories.tsx", "tests"]
13
+ "exclude": [
14
+ "node_modules",
15
+ "dist",
16
+ "src/stories",
17
+ "**/*.spec.ts",
18
+ "**/*.stories.ts",
19
+ "**/*.stories.vue",
20
+ "**/*.stories.tsx",
21
+ "tests"
22
+ ]
14
23
  }
@@ -0,0 +1,32 @@
1
+ import path from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+ import vue from '@vitejs/plugin-vue'
4
+ import { defineConfig } from 'vitest/config'
5
+
6
+ const dirname = path.dirname(fileURLToPath(import.meta.url))
7
+
8
+ export default defineConfig({
9
+ plugins: [vue()],
10
+ resolve: {
11
+ alias: {
12
+ '@': path.resolve(dirname, 'src'),
13
+ },
14
+ },
15
+ css: {
16
+ preprocessorOptions: {
17
+ scss: {
18
+ additionalData: `
19
+ @use "sass:math";
20
+ @use '@/assets/styles/helpers/functions' as fn;
21
+ @use '@/assets/styles/helpers/mixins' as mx;
22
+ @use '@/assets/styles/utilities';`,
23
+ api: 'modern-compiler',
24
+ },
25
+ },
26
+ },
27
+ test: {
28
+ css: true,
29
+ environment: 'happy-dom',
30
+ include: ['src/**/*.spec.ts'],
31
+ },
32
+ })