@nexxtmove/ui 0.1.29 → 0.1.30

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.
@@ -0,0 +1,220 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import Table from './Table.vue'
4
+
5
+ const columns = [
6
+ { key: 'name', label: 'Name', sortable: true },
7
+ { key: 'email', label: 'Email' },
8
+ ]
9
+
10
+ const rows = [
11
+ { name: 'Alice', email: 'alice@example.com' },
12
+ { name: 'Bob', email: 'bob@example.com' },
13
+ { name: 'Carol', email: 'carol@example.com' },
14
+ ]
15
+
16
+ describe('Table', () => {
17
+ describe('rendering', () => {
18
+ it('renders column headers', () => {
19
+ const wrapper = mount(Table, { props: { columns, rows } })
20
+ expect(wrapper.text()).toContain('Name')
21
+ expect(wrapper.text()).toContain('Email')
22
+ })
23
+
24
+ it('renders row data', () => {
25
+ const wrapper = mount(Table, { props: { columns, rows } })
26
+ expect(wrapper.text()).toContain('Alice')
27
+ expect(wrapper.text()).toContain('bob@example.com')
28
+ })
29
+
30
+ it('renders empty slot when rows is empty', () => {
31
+ const wrapper = mount(Table, { props: { columns, rows: [] } })
32
+ expect(wrapper.text()).toContain('Geen data beschikbaar')
33
+ })
34
+
35
+ it('renders custom empty slot content', () => {
36
+ const wrapper = mount(Table, {
37
+ props: { columns, rows: [] },
38
+ slots: { empty: 'Nothing here' },
39
+ })
40
+ expect(wrapper.text()).toContain('Nothing here')
41
+ })
42
+
43
+ it('renders cell slot content', () => {
44
+ const wrapper = mount(Table, {
45
+ props: { columns, rows },
46
+ slots: { 'cell-name': '<strong>custom</strong>' },
47
+ })
48
+ expect(wrapper.html()).toContain('<strong>custom</strong>')
49
+ })
50
+ })
51
+
52
+ describe('sorting', () => {
53
+ it('renders sort icon on sortable columns', () => {
54
+ const wrapper = mount(Table, { props: { columns, rows } })
55
+ const headers = wrapper.findAll('[role="columnheader"]')
56
+ const nameHeader = headers[0]
57
+ expect(nameHeader.find('i').exists()).toBe(true)
58
+ })
59
+
60
+ it('does not render sort icon on non-sortable columns', () => {
61
+ const wrapper = mount(Table, { props: { columns, rows } })
62
+ const headers = wrapper.findAll('[role="columnheader"]')
63
+ const emailHeader = headers[1]
64
+ expect(emailHeader.find('i').exists()).toBe(false)
65
+ })
66
+
67
+ it('emits sort event when clicking a sortable header', async () => {
68
+ const wrapper = mount(Table, { props: { columns, rows } })
69
+ const nameHeader = wrapper.findAll('[role="columnheader"]')[0]
70
+ await nameHeader.trigger('click')
71
+ expect(wrapper.emitted('sort')).toBeTruthy()
72
+ expect(wrapper.emitted('sort')![0]).toEqual(['name', 'asc'])
73
+ })
74
+
75
+ it('emits desc direction when clicking an already sorted column', async () => {
76
+ const wrapper = mount(Table, {
77
+ props: { columns, rows, sortKey: 'name', sortDirection: 'asc' },
78
+ })
79
+ const nameHeader = wrapper.findAll('[role="columnheader"]')[0]
80
+ await nameHeader.trigger('click')
81
+ expect(wrapper.emitted('sort')![0]).toEqual(['name', 'desc'])
82
+ })
83
+
84
+ it('does not emit sort when clicking a non-sortable header', async () => {
85
+ const wrapper = mount(Table, { props: { columns, rows } })
86
+ const emailHeader = wrapper.findAll('[role="columnheader"]')[1]
87
+ await emailHeader.trigger('click')
88
+ expect(wrapper.emitted('sort')).toBeFalsy()
89
+ })
90
+
91
+ it('sets aria-sort to ascending on the active sort column', () => {
92
+ const wrapper = mount(Table, {
93
+ props: { columns, rows, sortKey: 'name', sortDirection: 'asc' },
94
+ })
95
+ const nameHeader = wrapper.findAll('[role="columnheader"]')[0]
96
+ expect(nameHeader.attributes('aria-sort')).toBe('ascending')
97
+ })
98
+
99
+ it('sets aria-sort to descending on the active sort column', () => {
100
+ const wrapper = mount(Table, {
101
+ props: { columns, rows, sortKey: 'name', sortDirection: 'desc' },
102
+ })
103
+ const nameHeader = wrapper.findAll('[role="columnheader"]')[0]
104
+ expect(nameHeader.attributes('aria-sort')).toBe('descending')
105
+ })
106
+
107
+ it('sets aria-sort to none on an inactive sortable column', () => {
108
+ const wrapper = mount(Table, { props: { columns, rows } })
109
+ const nameHeader = wrapper.findAll('[role="columnheader"]')[0]
110
+ expect(nameHeader.attributes('aria-sort')).toBe('none')
111
+ })
112
+ })
113
+
114
+ describe('selection', () => {
115
+ it('does not render checkboxes when selectable is false', () => {
116
+ const wrapper = mount(Table, { props: { columns, rows } })
117
+ expect(wrapper.find('input[type="checkbox"]').exists()).toBe(false)
118
+ })
119
+
120
+ it('renders a checkbox per row and one in the header when selectable', () => {
121
+ const wrapper = mount(Table, {
122
+ props: { columns, rows, selectable: true, modelValue: [] },
123
+ })
124
+ const checkboxes = wrapper.findAll('input[type="checkbox"]')
125
+ expect(checkboxes).toHaveLength(rows.length + 1)
126
+ })
127
+
128
+ it('emits update:modelValue with all indices when select-all is clicked', async () => {
129
+ const wrapper = mount(Table, {
130
+ props: { columns, rows, selectable: true, modelValue: [] },
131
+ })
132
+ const headerCheckbox = wrapper.find('input[type="checkbox"]')
133
+ await headerCheckbox.setValue(true)
134
+ expect(wrapper.emitted('update:modelValue')![0]).toEqual([[0, 1, 2]])
135
+ })
136
+
137
+ it('emits update:modelValue with empty array when deselecting all', async () => {
138
+ const wrapper = mount(Table, {
139
+ props: { columns, rows, selectable: true, modelValue: [0, 1, 2] },
140
+ })
141
+ const headerCheckbox = wrapper.find('input[type="checkbox"]')
142
+ await headerCheckbox.setValue(false)
143
+ expect(wrapper.emitted('update:modelValue')![0]).toEqual([[]])
144
+ })
145
+
146
+ it('emits update:modelValue when a row checkbox is changed', async () => {
147
+ const wrapper = mount(Table, {
148
+ props: { columns, rows, selectable: true, modelValue: [] },
149
+ })
150
+ const rowCheckboxes = wrapper.findAll('input[type="checkbox"]').slice(1)
151
+ await rowCheckboxes[0].setValue(true)
152
+ expect(wrapper.emitted('update:modelValue')![0]).toEqual([[0]])
153
+ })
154
+
155
+ it('removes index from selection when an already-selected row checkbox is changed', async () => {
156
+ const wrapper = mount(Table, {
157
+ props: { columns, rows, selectable: true, modelValue: [0, 1] },
158
+ })
159
+ const rowCheckboxes = wrapper.findAll('input[type="checkbox"]').slice(1)
160
+ await rowCheckboxes[0].setValue(false)
161
+ expect(wrapper.emitted('update:modelValue')![0]).toEqual([[1]])
162
+ })
163
+ })
164
+
165
+ describe('row click selection', () => {
166
+ it('selects a row when clicking on it', async () => {
167
+ const wrapper = mount(Table, {
168
+ props: { columns, rows, selectable: true, modelValue: [] },
169
+ })
170
+ const dataRows = wrapper.findAll('[role="row"]').slice(1)
171
+ await dataRows[0].trigger('click')
172
+ expect(wrapper.emitted('update:modelValue')![0]).toEqual([[0]])
173
+ })
174
+
175
+ it('does not emit selection when clicking a button inside a row', async () => {
176
+ const wrapper = mount(Table, {
177
+ props: { columns, rows, selectable: true, modelValue: [] },
178
+ slots: { 'cell-name': '<button>action</button>' },
179
+ })
180
+ const button = wrapper.find('button')
181
+ await button.trigger('click')
182
+ expect(wrapper.emitted('update:modelValue')).toBeFalsy()
183
+ })
184
+
185
+ it('does not emit selection when clicking a link inside a row', async () => {
186
+ const wrapper = mount(Table, {
187
+ props: { columns, rows, selectable: true, modelValue: [] },
188
+ slots: { 'cell-name': '<a href="#">link</a>' },
189
+ })
190
+ const link = wrapper.find('a')
191
+ await link.trigger('click')
192
+ expect(wrapper.emitted('update:modelValue')).toBeFalsy()
193
+ })
194
+
195
+ it('does not select when selectable is false', async () => {
196
+ const wrapper = mount(Table, { props: { columns, rows } })
197
+ const dataRows = wrapper.findAll('[role="row"]').slice(1)
198
+ await dataRows[0].trigger('click')
199
+ expect(wrapper.emitted('update:modelValue')).toBeFalsy()
200
+ })
201
+ })
202
+
203
+ describe('keyboard navigation', () => {
204
+ it('moves focus to next row on ArrowDown', async () => {
205
+ const wrapper = mount(Table, { props: { columns, rows }, attachTo: document.body })
206
+ const dataRows = wrapper.findAll('[role="row"]').slice(1)
207
+ await dataRows[0].trigger('keydown', { key: 'ArrowDown' })
208
+ expect(document.activeElement).toBe(dataRows[1].element)
209
+ wrapper.unmount()
210
+ })
211
+
212
+ it('moves focus to previous row on ArrowUp', async () => {
213
+ const wrapper = mount(Table, { props: { columns, rows }, attachTo: document.body })
214
+ const dataRows = wrapper.findAll('[role="row"]').slice(1)
215
+ await dataRows[1].trigger('keydown', { key: 'ArrowUp' })
216
+ expect(document.activeElement).toBe(dataRows[0].element)
217
+ wrapper.unmount()
218
+ })
219
+ })
220
+ })
@@ -0,0 +1,227 @@
1
+ <script lang="ts" setup generic="TKey extends string">
2
+ import { computed, ref } from 'vue'
3
+ import Icon from '../Icon/Icon.vue'
4
+ import Checkbox from '../Checkbox/Checkbox.vue'
5
+
6
+ export interface TableColumn<K extends string = string> {
7
+ key: K
8
+ label: string
9
+ sortable?: boolean
10
+ width?: string
11
+ }
12
+
13
+ interface NexxtTableProps {
14
+ columns: TableColumn<TKey>[]
15
+ rows: Record<TKey, unknown>[]
16
+ sortKey?: TKey
17
+ sortDirection?: 'asc' | 'desc'
18
+ selectable?: boolean
19
+ modelValue?: number[]
20
+ }
21
+
22
+ const INTERACTIVE = ['a', 'button', 'input', 'select', 'textarea', 'label']
23
+
24
+ defineOptions({ name: 'NexxtTable' })
25
+
26
+ const props = withDefaults(defineProps<NexxtTableProps>(), {
27
+ sortDirection: 'asc',
28
+ })
29
+ const emit = defineEmits<{
30
+ sort: [key: string, direction: 'asc' | 'desc']
31
+ 'update:modelValue': [selected: number[]]
32
+ }>()
33
+
34
+ const selected = computed(() => props.modelValue ?? [])
35
+
36
+ const allSelected = computed(
37
+ () => props.rows.length > 0 && selected.value.length === props.rows.length,
38
+ )
39
+ const someSelected = computed(
40
+ () => selected.value.length > 0 && selected.value.length < props.rows.length,
41
+ )
42
+
43
+ const toggleAll = () => {
44
+ emit('update:modelValue', allSelected.value ? [] : props.rows.map((_, i) => i))
45
+ }
46
+
47
+ const toggleRow = (index: number) => {
48
+ const next = selected.value.includes(index)
49
+ ? selected.value.filter((i) => i !== index)
50
+ : [...selected.value, index]
51
+ emit('update:modelValue', next)
52
+ }
53
+
54
+ const columnTemplate = computed(() =>
55
+ props.columns.map((col) => `minmax(100px, ${col.width ?? '1fr'})`).join(' '),
56
+ )
57
+
58
+ const gridTemplate = computed(() =>
59
+ props.selectable ? `40px ${columnTemplate.value}` : columnTemplate.value,
60
+ )
61
+
62
+ const handleSort = (column: TableColumn) => {
63
+ if (!column.sortable) return
64
+ const newDirection =
65
+ props.sortKey === column.key && props.sortDirection === 'asc' ? 'desc' : 'asc'
66
+ emit('sort', column.key, newDirection)
67
+ }
68
+
69
+ const handleRowClick = (event: MouseEvent, rowIndex: number) => {
70
+ if (!props.selectable) return
71
+ const path = event.composedPath() as Element[]
72
+ const isInteractive = path.some(
73
+ (el) => el.tagName && INTERACTIVE.includes(el.tagName.toLowerCase()),
74
+ )
75
+ if (!isInteractive) toggleRow(rowIndex)
76
+ }
77
+
78
+ const getRowRoundedClass = (rowIndex: number) => {
79
+ if (!props.selectable || !selected.value.includes(rowIndex)) return ''
80
+ const prevSelected = selected.value.includes(rowIndex - 1)
81
+ const nextSelected = selected.value.includes(rowIndex + 1)
82
+ if (!prevSelected && !nextSelected) return 'rounded-md'
83
+ if (!prevSelected) return 'rounded-t-md'
84
+ if (!nextSelected) return 'rounded-b-md'
85
+ return ''
86
+ }
87
+
88
+ const rowRefs = ref<HTMLElement[]>([])
89
+
90
+ const handleRowKeydown = (event: KeyboardEvent, rowIndex: number) => {
91
+ if (event.key === 'ArrowDown') {
92
+ event.preventDefault()
93
+ rowRefs.value[Math.min(rowIndex + 1, props.rows.length - 1)]?.focus()
94
+ } else if (event.key === 'ArrowUp') {
95
+ event.preventDefault()
96
+ rowRefs.value[Math.max(rowIndex - 1, 0)]?.focus()
97
+ }
98
+ }
99
+ </script>
100
+
101
+ <template>
102
+ <div class="flex flex-col gap-3">
103
+ <div class="overflow-auto">
104
+ <div
105
+ role="grid"
106
+ :aria-colcount="columns.length + (selectable ? 1 : 0)"
107
+ :aria-rowcount="rows.length + 1"
108
+ class="w-full min-w-max"
109
+ >
110
+ <div class="sticky top-0 z-10 mb-0.5">
111
+ <div v-if="$slots.toolbar">
112
+ <slot name="toolbar" />
113
+ </div>
114
+ <div
115
+ class="grid rounded-md bg-gray-50"
116
+ :style="{ gridTemplateColumns: gridTemplate }"
117
+ role="row"
118
+ aria-rowindex="1"
119
+ >
120
+ <div
121
+ v-if="selectable"
122
+ class="flex items-center justify-center px-2 py-3"
123
+ role="columnheader"
124
+ aria-colindex="1"
125
+ >
126
+ <Checkbox
127
+ :model-value="allSelected"
128
+ :indeterminate="someSelected"
129
+ aria-label="Select all rows"
130
+ @update:model-value="toggleAll"
131
+ />
132
+ </div>
133
+ <div
134
+ v-for="(column, colIndex) in columns"
135
+ :key="column.key"
136
+ class="flex items-center gap-1.5 px-4 py-3 heading-4 text-slate-700"
137
+ :class="{
138
+ 'cursor-pointer select-none hover:text-slate-900 focus-visible:ring-2 focus-visible:ring-cornflower-blue-500 focus-visible:outline-none focus-visible:ring-inset':
139
+ column.sortable,
140
+ }"
141
+ role="columnheader"
142
+ :aria-colindex="colIndex + (selectable ? 2 : 1)"
143
+ :aria-sort="
144
+ column.sortable
145
+ ? sortKey === column.key
146
+ ? sortDirection === 'asc'
147
+ ? 'ascending'
148
+ : 'descending'
149
+ : 'none'
150
+ : undefined
151
+ "
152
+ :tabindex="column.sortable ? 0 : undefined"
153
+ @click="handleSort(column)"
154
+ @keydown.enter.prevent="handleSort(column)"
155
+ @keydown.space.prevent="handleSort(column)"
156
+ >
157
+ {{ column.label }}
158
+ <Icon
159
+ v-if="column.sortable"
160
+ :name="
161
+ sortKey === column.key
162
+ ? sortDirection === 'asc'
163
+ ? 'angle-down'
164
+ : 'angle-up'
165
+ : 'angles-up-down'
166
+ "
167
+ class="shrink-0 text-xs"
168
+ :class="sortKey === column.key ? 'text-cornflower-blue-600' : 'text-slate-400'"
169
+ />
170
+ </div>
171
+ </div>
172
+ </div>
173
+
174
+ <div role="rowgroup">
175
+ <div
176
+ v-for="(row, rowIndex) in rows"
177
+ :key="rowIndex"
178
+ ref="rowRefs"
179
+ class="grid border-b border-gray-100 transition-colors duration-150 focus-visible:bg-slate-50 focus-visible:ring-2 focus-visible:ring-cornflower-blue-500 focus-visible:outline-none focus-visible:ring-inset"
180
+ :class="[
181
+ {
182
+ 'hover:bg-slate-50': selectable,
183
+ 'border-white/80 bg-cornflower-blue-50': selectable && selected.includes(rowIndex),
184
+ },
185
+ getRowRoundedClass(rowIndex),
186
+ ]"
187
+ :style="{ gridTemplateColumns: gridTemplate }"
188
+ role="row"
189
+ :aria-rowindex="rowIndex + 2"
190
+ :aria-selected="selectable ? selected.includes(rowIndex) : undefined"
191
+ tabindex="0"
192
+ @click="handleRowClick($event, rowIndex)"
193
+ @keydown="handleRowKeydown($event, rowIndex)"
194
+ >
195
+ <div
196
+ v-if="selectable"
197
+ class="flex items-center justify-center px-2 py-4"
198
+ role="gridcell"
199
+ :aria-colindex="1"
200
+ >
201
+ <Checkbox
202
+ :model-value="selected.includes(rowIndex)"
203
+ :aria-label="`Select row ${rowIndex + 1}`"
204
+ @update:model-value="toggleRow(rowIndex)"
205
+ />
206
+ </div>
207
+ <div
208
+ v-for="(column, colIndex) in columns"
209
+ :key="column.key"
210
+ class="flex items-center truncate px-4 py-4 extra-small-normal text-slate-700"
211
+ role="gridcell"
212
+ :aria-colindex="colIndex + (selectable ? 2 : 1)"
213
+ >
214
+ <slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]">
215
+ {{ row[column.key] }}
216
+ </slot>
217
+ </div>
218
+ </div>
219
+
220
+ <div v-if="!rows.length" class="px-4 py-8 text-center text-sm text-slate-400" role="row">
221
+ <slot name="empty">Geen data beschikbaar</slot>
222
+ </div>
223
+ </div>
224
+ </div>
225
+ </div>
226
+ </div>
227
+ </template>
@@ -2,6 +2,7 @@
2
2
  "NexxtAnimatedNumber": "components/AnimatedNumber/AnimatedNumber.vue",
3
3
  "NexxtButton": "components/Button/Button.vue",
4
4
  "NexxtCalendar": "components/Calendar/Calendar.vue",
5
+ "NexxtCheckbox": "components/Checkbox/Checkbox.vue",
5
6
  "NexxtChip": "components/Chip/Chip.vue",
6
7
  "NexxtCustomerJourneyTile": "components/CustomerJourneyTile/CustomerJourneyTile.vue",
7
8
  "NexxtDatePicker": "components/DatePicker/DatePicker.vue",
@@ -15,6 +16,7 @@
15
16
  "NexxtSocialMediaTemplate": "components/SocialMediaTemplate/SocialMediaTemplate.vue",
16
17
  "NexxtSocialMediaType": "components/SocialMediaType/SocialMediaType.vue",
17
18
  "NexxtStepperHeader": "components/StepperHeader/StepperHeader.vue",
19
+ "NexxtTable": "components/Table/Table.vue",
18
20
  "NexxtTimelineEvent": "components/TimelineEvent/TimelineEvent.vue",
19
21
  "NexxtTimelinePhaseblock": "components/TimelinePhaseblock/TimelinePhaseblock.vue",
20
22
  "NexxtTooltip": "components/Tooltip/Tooltip.vue"
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  export { default as NexxtAnimatedNumber } from './components/AnimatedNumber/AnimatedNumber.vue'
3
3
  export { default as NexxtButton } from './components/Button/Button.vue'
4
4
  export { default as NexxtCalendar } from './components/Calendar/Calendar.vue'
5
+ export { default as NexxtCheckbox } from './components/Checkbox/Checkbox.vue'
5
6
  export { default as NexxtChip } from './components/Chip/Chip.vue'
6
7
  export { default as NexxtCustomerJourneyTile } from './components/CustomerJourneyTile/CustomerJourneyTile.vue'
7
8
  export { default as NexxtDatePicker } from './components/DatePicker/DatePicker.vue'
@@ -15,6 +16,7 @@ export { default as NexxtSocialMediaCustomTemplate } from './components/SocialMe
15
16
  export { default as NexxtSocialMediaTemplate } from './components/SocialMediaTemplate/SocialMediaTemplate.vue'
16
17
  export { default as NexxtSocialMediaType } from './components/SocialMediaType/SocialMediaType.vue'
17
18
  export { default as NexxtStepperHeader } from './components/StepperHeader/StepperHeader.vue'
19
+ export { default as NexxtTable } from './components/Table/Table.vue'
18
20
  export { default as NexxtTimelineEvent } from './components/TimelineEvent/TimelineEvent.vue'
19
21
  export { default as NexxtTimelinePhaseblock } from './components/TimelinePhaseblock/TimelinePhaseblock.vue'
20
22
  export { default as NexxtTooltip } from './components/Tooltip/Tooltip.vue'