@citizenplane/pimp 9.10.0 → 9.11.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": "9.10.0",
3
+ "version": "9.11.0",
4
4
  "scripts": {
5
5
  "dev": "storybook dev -p 8080",
6
6
  "build-storybook": "storybook build --output-dir ./docs",
@@ -1,5 +1,6 @@
1
1
  @use '../variables/colors' as colors;
2
2
  @use '../helpers/functions' as fn;
3
+ @use '../variables/easings' as easings;
3
4
 
4
5
  // Popover
5
6
  @mixin popover-desktop($transitionName) {
@@ -34,6 +35,26 @@
34
35
  aspect-ratio: 1 / 1;
35
36
  }
36
37
 
38
+ // Floating Vue
39
+ @mixin hide-tooltip-arrow() {
40
+ .v-popper__arrow-container {
41
+ display: none;
42
+ }
43
+ }
44
+
45
+ @mixin v-dropdown-transition() {
46
+ .v-popper__wrapper {
47
+ transition:
48
+ scale 200ms easings.$easing-elastic,
49
+ opacity 200ms ease;
50
+ }
51
+
52
+ .v-popper__popper--hidden .v-popper__wrapper {
53
+ opacity: 0;
54
+ scale: 0.9;
55
+ }
56
+ }
57
+
37
58
  // Media queries
38
59
  @mixin media-query-min($breakpoint) {
39
60
  @media (min-width: $breakpoint) {
@@ -17,7 +17,7 @@
17
17
  <thead class="cpTable__header">
18
18
  <tr class="cpTable__row cpTable__row--header">
19
19
  <th
20
- v-for="column in normalizedColumns"
20
+ v-for="column in visibleColumns"
21
21
  :key="column.id"
22
22
  class="cpTable__column"
23
23
  :style="getColumnStyle(column)"
@@ -26,7 +26,10 @@
26
26
  {{ column.name }}
27
27
  </slot>
28
28
  </th>
29
- <th v-show="enableRowOptions" class="cpTable__column cpTable__column--isOptions"><span /></th>
29
+ <th v-show="displayOptionsColumn" class="cpTable__column cpTable__column--isOptions">
30
+ <cp-table-column-editor v-if="enableColumnEdition" v-model="columnsModel" :columns="normalizedColumns" />
31
+ <span v-show="enableRowOptions" />
32
+ </th>
30
33
  </tr>
31
34
  </thead>
32
35
  <tbody class="cpTable__body">
@@ -54,8 +57,8 @@
54
57
  <template v-else>{{ cellValue }}</template>
55
58
  </slot>
56
59
  </td>
57
- <td v-show="areRowOptionsEnabled(rowData)" class="cpTable__cell cpTable__cell--isOptions">
58
- <div class="cpTable__actions">
60
+ <td v-show="displayOptionsCell(rowData)" class="cpTable__cell cpTable__cell--isOptions">
61
+ <div v-if="enableRowOptions" class="cpTable__actions">
59
62
  <slot name="row-quick-actions" :row="rowData">
60
63
  <button
61
64
  v-for="option in quickOptions"
@@ -119,6 +122,8 @@
119
122
  <script setup lang="ts">
120
123
  import { ref, computed, useId } from 'vue'
121
124
 
125
+ import { CpTableColumnObject } from '@/constants/CpTableColumn'
126
+
122
127
  import CpContextualMenu from '@/components/CpContextualMenu.vue'
123
128
  import CpTableEmptyState from '@/components/CpTableEmptyState.vue'
124
129
 
@@ -132,13 +137,6 @@ interface Emits {
132
137
  (e: 'onPreviousClick'): void
133
138
  }
134
139
 
135
- interface ColumnDefinition {
136
- id: string
137
- name: string
138
- textAlign?: 'left' | 'center' | 'right'
139
- width?: number
140
- }
141
-
142
140
  interface PaginationServer {
143
141
  activePage: number
144
142
  total: number
@@ -170,9 +168,10 @@ interface GroupByData {
170
168
  interface Props {
171
169
  areRowsClickable?: boolean
172
170
  caption?: string
173
- columns?: string[] | ColumnDefinition[]
171
+ columns?: string[] | CpTableColumnObject[]
174
172
  data: (Record<string, unknown> | GroupByData)[]
175
173
  emptyCellPlaceholder?: string
174
+ enableColumnEdition?: boolean
176
175
  enableRowOptions?: boolean
177
176
  isLoading?: boolean
178
177
  noResultPlaceholder?: string
@@ -190,6 +189,7 @@ const props = withDefaults(defineProps<Props>(), {
190
189
  noResultPlaceholder: 'No results found',
191
190
  isLoading: false,
192
191
  enableRowOptions: false,
192
+ enableColumnEdition: false,
193
193
  quickOptionsLimit: 3,
194
194
  rowOptions: () => [],
195
195
  })
@@ -216,6 +216,8 @@ const quickOptions = computed(() => {
216
216
  return props.rowOptions
217
217
  })
218
218
 
219
+ const displayOptionsColumn = computed(() => props.enableRowOptions || props.enableColumnEdition)
220
+
219
221
  const currentRowData = ref<Record<string, unknown>>({})
220
222
  const contextualMenuItems = computed(() => {
221
223
  return props.rowOptions.map((option) => ({
@@ -229,7 +231,7 @@ const containerDOMElement = computed(() => cpTableContainer.value)
229
231
  const mainClasses = computed(() => ({ 'cpTable--isLoading': props.isLoading }))
230
232
  const containerClasses = computed(() => ({ 'cpTable__container--hasPagination': hasPagination.value }))
231
233
 
232
- const normalizedColumns = computed<ColumnDefinition[]>(() => {
234
+ const normalizedColumns = computed<CpTableColumnObject[]>(() => {
233
235
  if (!props.columns) return []
234
236
  const columns = props.columns.length ? [...props.columns] : [...columnsFromRows.value]
235
237
 
@@ -248,7 +250,13 @@ const normalizedColumns = computed<ColumnDefinition[]>(() => {
248
250
  })
249
251
  })
250
252
 
251
- const numberOfColumns = computed(() => normalizedColumns.value.length)
253
+ const visibleColumns = computed(() => normalizedColumns.value.filter(({ id }) => columnsModel.value.includes(id)))
254
+
255
+ const getAllColumnIds = () => normalizedColumns.value.map(({ id }) => id)
256
+
257
+ const columnsModel = ref<string[]>(getAllColumnIds())
258
+
259
+ const numberOfColumns = computed(() => visibleColumns.value.length)
252
260
 
253
261
  const hasGroupBy = computed(() => {
254
262
  if (!props.data.length) return false
@@ -405,10 +413,10 @@ const handleNavigationClick = (isNext = true) => {
405
413
  }
406
414
 
407
415
  const normalizeRowData = ({
408
- columns = normalizedColumns.value,
416
+ columns = visibleColumns.value,
409
417
  rowPayload,
410
418
  }: {
411
- columns?: ColumnDefinition[]
419
+ columns?: CpTableColumnObject[]
412
420
  rowPayload: Record<string, unknown>
413
421
  }) => {
414
422
  if (!Array.isArray(rowPayload)) {
@@ -426,10 +434,10 @@ const normalizeRowData = ({
426
434
  }
427
435
 
428
436
  const mapCellToColumn = ({
429
- columns = normalizedColumns.value,
437
+ columns = visibleColumns.value,
430
438
  rowPayload,
431
439
  }: {
432
- columns?: ColumnDefinition[]
440
+ columns?: CpTableColumnObject[]
433
441
  rowPayload: Record<string, unknown>
434
442
  }) => {
435
443
  if (isFullWidthRow(rowPayload)) return rowPayload
@@ -458,7 +466,7 @@ const resetScrollPosition = () => {
458
466
  }
459
467
  }
460
468
 
461
- const getColumnStyle = (columnPayload: ColumnDefinition) => {
469
+ const getColumnStyle = (columnPayload: CpTableColumnObject) => {
462
470
  const formattedWidth = columnPayload?.width && `${columnPayload.width}px`
463
471
 
464
472
  return {
@@ -470,7 +478,7 @@ const getColumnStyle = (columnPayload: ColumnDefinition) => {
470
478
  const getCellStyle = (cellKey: RESERVED_KEYS, cellIndex: number) => {
471
479
  if (isFullWidthCell(cellKey)) return null
472
480
  return {
473
- textAlign: normalizedColumns.value[cellIndex]?.textAlign,
481
+ textAlign: visibleColumns.value[cellIndex]?.textAlign,
474
482
  }
475
483
  }
476
484
 
@@ -487,7 +495,7 @@ const getCellClasses = (cellKey: RESERVED_KEYS) => {
487
495
  }
488
496
 
489
497
  const getColspan = (cellKey: RESERVED_KEYS) => {
490
- const numberOfColumnsValue = props.enableRowOptions ? numberOfColumns.value + 1 : numberOfColumns.value
498
+ const numberOfColumnsValue = displayOptionsColumn.value ? numberOfColumns.value + 1 : numberOfColumns.value
491
499
  return isFullWidthCell(cellKey) ? numberOfColumnsValue : undefined
492
500
  }
493
501
 
@@ -501,7 +509,11 @@ const isRowSelected = (rowIndex: number) => {
501
509
  const row = rawVisibleRows.value[rowIndex] as Record<string, unknown>
502
510
  return row?.[RESERVED_KEYS.IS_SELECTED] || false
503
511
  }
504
- const areRowOptionsEnabled = (rowData: Record<string, unknown>) => props.enableRowOptions && !isFullWidthRow(rowData)
512
+
513
+ const displayOptionsCell = (rowData: Record<string, unknown>) => {
514
+ if (isFullWidthRow(rowData)) return false
515
+ return props.enableColumnEdition || props.enableRowOptions
516
+ }
505
517
 
506
518
  const resetPagination = () => (pageNumber.value = 0)
507
519
 
@@ -619,11 +631,11 @@ defineExpose({ hideContextualMenu, resetPagination, currentRowData })
619
631
  position: sticky;
620
632
  top: 0;
621
633
  z-index: 3;
622
- background-color: colors.$neutral-light;
623
634
  padding: sp.$space sp.$space-md;
624
635
  text-align: left;
625
636
  white-space: nowrap;
626
637
  font-size: fn.px-to-em(12);
638
+ line-height: fn.px-to-rem(16);
627
639
  font-weight: normal;
628
640
  color: colors.$neutral-dark-1;
629
641
 
@@ -641,6 +653,11 @@ defineExpose({ hideContextualMenu, resetPagination, currentRowData })
641
653
  height: fn.px-to-rem(1);
642
654
  background-color: colors.$border-color;
643
655
  }
656
+
657
+ &--isOptions {
658
+ right: 0;
659
+ padding: 0;
660
+ }
644
661
  }
645
662
 
646
663
  &__body {
@@ -0,0 +1,208 @@
1
+ <template>
2
+ <div class="cpTableColumnEditor">
3
+ <v-dropdown
4
+ v-model:shown="isDropdownVisible"
5
+ :delay="0"
6
+ placement="bottom-end"
7
+ popper-class="cpTableColumnEditor__dropdown"
8
+ @apply-show="handleDropdownShown"
9
+ >
10
+ <cp-button
11
+ appearance="minimal"
12
+ class="cpTableColumnEditor__trigger"
13
+ :class="triggerDynamicClass"
14
+ is-square
15
+ size="xs"
16
+ >
17
+ <template #leading-icon>
18
+ <cp-icon size="16" type="more-vertical" />
19
+ </template>
20
+ </cp-button>
21
+ <template #popper>
22
+ <div class="cpTableColumnEditor__inner">
23
+ <div class="cpTableColumnEditor__header">
24
+ <cp-input
25
+ ref="searchInputRef"
26
+ v-model="searchQuery"
27
+ class="cpTableColumnEditor__search"
28
+ is-search
29
+ placeholder="Search for a column..."
30
+ size="sm"
31
+ />
32
+ </div>
33
+ <div v-if="hasProtectedColumns" class="cpTableColumnEditor__row">
34
+ <div class="cpTableColumnEditor__heading">Fixed columns</div>
35
+ <div v-for="column in protectedColumns" :key="column.id" class="cpTableColumnEditor__column">
36
+ <span>{{ column.name }}</span>
37
+ </div>
38
+ </div>
39
+ <div class="cpTableColumnEditor__divider" />
40
+ <div class="cpTableColumnEditor__row">
41
+ <div class="cpTableColumnEditor__heading">Shown in table</div>
42
+ <div class="cpTableColumnEditor__column">
43
+ <template v-if="hasFilteredVisibleColumns">
44
+ <cp-checkbox
45
+ v-for="column in filteredVisibleColumns"
46
+ :key="column.id"
47
+ v-model="selectedColumnIds"
48
+ :checkbox-label="column.name"
49
+ :checkbox-value="column.id"
50
+ class="cpTableColumnEditor__checkbox"
51
+ color="purple"
52
+ :value="column.id"
53
+ />
54
+ </template>
55
+ <span v-else class="cpTableColumnEditor__empty">No columns found</span>
56
+ </div>
57
+ </div>
58
+ <template v-if="hasHiddenColumns">
59
+ <div class="cpTableColumnEditor__divider" />
60
+ <div class="cpTableColumnEditor__row">
61
+ <div class="cpTableColumnEditor__heading">Hidden in table</div>
62
+ <div class="cpTableColumnEditor__column">
63
+ <cp-checkbox
64
+ v-for="column in hiddenColumns"
65
+ :key="column.id"
66
+ v-model="selectedColumnIds"
67
+ :checkbox-label="column.name"
68
+ :checkbox-value="column.id"
69
+ class="cpTableColumnEditor__checkbox"
70
+ color="purple"
71
+ :value="column.id"
72
+ />
73
+ </div>
74
+ </div>
75
+ </template>
76
+ </div>
77
+ </template>
78
+ </v-dropdown>
79
+ </div>
80
+ </template>
81
+
82
+ <script setup lang="ts">
83
+ import { computed, ref, useTemplateRef } from 'vue'
84
+
85
+ import { CpTableColumnObject } from '@/constants/CpTableColumn'
86
+
87
+ import { focusOnDOMElement } from '@/helpers/dom'
88
+
89
+ interface Props {
90
+ columns: CpTableColumnObject[]
91
+ modelValue: string[]
92
+ }
93
+
94
+ const props = defineProps<Props>()
95
+
96
+ const isDropdownVisible = ref(false)
97
+ const selectedColumnIds = defineModel<string[]>()
98
+
99
+ const searchQuery = ref('')
100
+ const searchInputRef = useTemplateRef('searchInputRef')
101
+
102
+ const triggerDynamicClass = computed(() => {
103
+ return {
104
+ 'cpTableColumnEditor__trigger--isOpen': isDropdownVisible.value,
105
+ }
106
+ })
107
+
108
+ const protectedColumns = computed(() => {
109
+ const filteredProtectedColumns = props.columns.filter((column) => isColumnProtected(column))
110
+ if (filteredProtectedColumns.length) return filteredProtectedColumns
111
+
112
+ const firstColumn = props.columns[0]
113
+ return [firstColumn]
114
+ })
115
+
116
+ const hasProtectedColumns = computed(() => !!protectedColumns.value.length)
117
+
118
+ const filteredVisibleColumns = computed(() => {
119
+ return props.columns.filter((column) => {
120
+ const isMatchingSearch = column.name.toLowerCase().includes(searchQuery.value.toLowerCase())
121
+ const isProtected = protectedColumns.value.some(({ id }) => id === column.id)
122
+
123
+ const conditions = [isMatchingSearch, isColumnSelected(column), !isProtected]
124
+ return conditions.every((condition) => condition)
125
+ })
126
+ })
127
+
128
+ const hasFilteredVisibleColumns = computed(() => !!filteredVisibleColumns.value.length)
129
+
130
+ const hiddenColumns = computed(() => props.columns.filter((column) => !isColumnSelected(column)))
131
+ const hasHiddenColumns = computed(() => !!hiddenColumns.value.length)
132
+
133
+ const isColumnProtected = (column) => column.isProtected || false
134
+ const isColumnSelected = (column) => selectedColumnIds.value?.includes(column.id)
135
+
136
+ const handleDropdownShown = () => {
137
+ if (!searchInputRef.value?.$el) return
138
+ setTimeout(() => focusOnDOMElement(searchInputRef.value?.$el), 50)
139
+ }
140
+ </script>
141
+
142
+ <style lang="scss">
143
+ .cpTableColumnEditor {
144
+ text-align: right;
145
+
146
+ &__trigger {
147
+ @extend %u-focus-outline;
148
+
149
+ border-radius: fn.px-to-rem(8);
150
+
151
+ &--isOpen,
152
+ &:hover,
153
+ &:focus-within {
154
+ background-color: colors.$neutral-dark-5;
155
+ }
156
+ }
157
+
158
+ &__dropdown {
159
+ @include mx.hide-tooltip-arrow;
160
+ @include mx.v-dropdown-transition;
161
+ }
162
+
163
+ &__inner {
164
+ display: flex;
165
+ flex-direction: column;
166
+ gap: sp.$space;
167
+ padding-block: sp.$space;
168
+ }
169
+
170
+ &__header {
171
+ padding-inline: sp.$space-md;
172
+ }
173
+
174
+ &__heading,
175
+ &__column:not(:has(.cpTableColumnEditor__checkbox)) {
176
+ padding: sp.$space-sm sp.$space-md;
177
+ }
178
+
179
+ &__heading {
180
+ font-size: fn.px-to-rem(12);
181
+ line-height: fn.px-to-rem(24);
182
+ color: colors.$neutral-dark-1;
183
+ }
184
+
185
+ &__column,
186
+ &__empty {
187
+ font-size: fn.px-to-rem(14);
188
+ }
189
+
190
+ &__column {
191
+ text-transform: capitalize;
192
+ }
193
+
194
+ &__empty {
195
+ text-transform: initial;
196
+ }
197
+
198
+ &__divider {
199
+ height: fn.px-to-rem(1);
200
+ background-color: colors.$neutral-dark-5;
201
+ }
202
+
203
+ &__checkbox {
204
+ color: colors.$neutral-dark;
205
+ padding: sp.$space-sm sp.$space;
206
+ }
207
+ }
208
+ </style>
@@ -33,6 +33,7 @@ import CpSelect from './CpSelect.vue'
33
33
  import CpSelectMenu from './CpSelectMenu.vue'
34
34
  import CpSwitch from './CpSwitch.vue'
35
35
  import CpTable from './CpTable.vue'
36
+ import CpTableColumnEditor from './CpTableColumnEditor.vue'
36
37
  import CpTelInput from './CpTelInput.vue'
37
38
  import CpTextarea from './CpTextarea.vue'
38
39
  import CpToaster from './CpToaster.vue'
@@ -75,6 +76,7 @@ const Components = {
75
76
  CpRadio,
76
77
  CpSwitch,
77
78
  CpTable,
79
+ CpTableColumnEditor,
78
80
  CpIcon,
79
81
  CpTelInput,
80
82
  CpTooltip,
@@ -0,0 +1,9 @@
1
+ export interface CpTableColumnObject {
2
+ id: string
3
+ isProtected?: boolean
4
+ name: string
5
+ textAlign?: 'left' | 'center' | 'right'
6
+ width?: number
7
+ }
8
+
9
+ export type CpTableColumn = CpTableColumnObject | string
@@ -43,3 +43,18 @@ export const handleTrapFocus = (event: KeyboardEvent, DOMElement?: HTMLElement |
43
43
  lastElement?.focus()
44
44
  }
45
45
  }
46
+
47
+ export const focusOnDOMElement = (DOMElement: Document | HTMLElement | null, inputTag = 'input'): void => {
48
+ if (!DOMElement) return
49
+
50
+ if (DOMElement instanceof HTMLInputElement) {
51
+ DOMElement.focus()
52
+ return
53
+ }
54
+
55
+ const inputElement = DOMElement.querySelector(inputTag) as HTMLInputElement | null
56
+
57
+ if (!inputElement) return
58
+
59
+ inputElement.focus()
60
+ }
@@ -110,6 +110,19 @@ export const Default: Story = {
110
110
  }),
111
111
  }
112
112
 
113
+ export const EnableColumnEdition: Story = {
114
+ args: {
115
+ ...Default.args,
116
+ columns: [
117
+ { id: 'name', name: 'Name' },
118
+ { id: 'age', name: 'Age' },
119
+ { id: 'email', name: 'Email' },
120
+ { id: 'status', name: 'Status' },
121
+ ],
122
+ enableColumnEdition: true,
123
+ },
124
+ }
125
+
113
126
  export const ClickableRows: Story = {
114
127
  args: {
115
128
  ...Default.args,