Skip to content

Commit ed11ef9

Browse files
authored
Merge pull request #1613 from LouisMazel/feat/maz-table-animated
feat(maz-ui): MazTable - add min-width to header items and animated rows
2 parents dd147a2 + 00ff7f4 commit ed11ef9

3 files changed

Lines changed: 178 additions & 4 deletions

File tree

apps/docs/src/components/maz-table.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ description: MazTable is designed to be a reusable data table with advanced feat
1919
4. Row Selection (prop `select-value="key"`): There is a dedicated column for selection with a checkbox for each row. Users can individually or collectively select/deselect rows.
2020
5. Customizable Page Size: Users can choose the number of items to display per page using a dropdown list.
2121
6. Loading Indicator (prop `loading`): A loading indicator (MazLoadingBar) is displayed when data is being loaded.
22+
7. Animated rows (props `animated-rows` + `row-key`): rows slide to their new position when the order changes (FLIP animation). Respects `prefers-reduced-motion`.
2223

2324
## Available models
2425

@@ -369,6 +370,67 @@ v-model:page-size="{{pageSize ?? 'undefined'}}"
369370

370371
</ComponentDemo>
371372

373+
## Animated rows
374+
375+
Enable `animated-rows` to make rows slide to their new position when the order changes (FLIP animation) - perfect for live leaderboards or sortable lists. Provide a stable `row-key` (a field uniquely identifying each row) so every row keeps its identity across reorders. The animation is automatically disabled when the user prefers reduced motion.
376+
377+
<ComponentDemo>
378+
<MazBtn class="vp-raw" size="sm" @click="shuffleRows">Shuffle</MazBtn>
379+
<br />
380+
<br />
381+
<MazTable
382+
class="vp-raw"
383+
size="sm"
384+
animated-rows
385+
row-key="id"
386+
:headers="[
387+
{ label: '#', key: 'id', align: 'center', width: '3rem' },
388+
{ label: 'Name', key: 'name' },
389+
{ label: 'Score', key: 'score', align: 'center' },
390+
]"
391+
:rows="players"
392+
/>
393+
394+
<template #code>
395+
396+
```vue
397+
<template>
398+
<MazBtn @click="shuffleRows">
399+
Shuffle
400+
</MazBtn>
401+
<MazTable
402+
animated-rows
403+
row-key="id"
404+
:headers="[
405+
{ label: '#', key: 'id', align: 'center', width: '3rem' },
406+
{ label: 'Name', key: 'name' },
407+
{ label: 'Score', key: 'score', align: 'center' },
408+
]"
409+
:rows="players"
410+
/>
411+
</template>
412+
413+
<script lang="ts" setup>
414+
import { MazTable } from 'maz-ui/components'
415+
import { ref } from 'vue'
416+
417+
const players = ref([
418+
{ id: 1, name: 'John', score: 12 },
419+
{ id: 2, name: 'Jane', score: 24 },
420+
{ id: 3, name: 'Alice', score: 8 },
421+
{ id: 4, name: 'Bob', score: 31 },
422+
])
423+
424+
function shuffleRows() {
425+
players.value = [...players.value].sort(() => Math.random() - 0.5)
426+
}
427+
</script>
428+
```
429+
430+
</template>
431+
432+
</ComponentDemo>
433+
372434
## Loading
373435

374436
Enable the loading state with the prop `loading`
@@ -516,6 +578,17 @@ Available sizes: `'mini' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'`
516578
const searchQuery = ref<string>()
517579
const pageSize = ref<number>(10)
518580
const page = ref<number>(1)
581+
582+
const players = ref([
583+
{ id: 1, name: 'John', score: 12 },
584+
{ id: 2, name: 'Jane', score: 24 },
585+
{ id: 3, name: 'Alice', score: 8 },
586+
{ id: 4, name: 'Bob', score: 31 },
587+
])
588+
589+
function shuffleRows() {
590+
players.value = [...players.value].sort(() => Math.random() - 0.5)
591+
}
519592
</script>
520593

521594
## Types
@@ -545,6 +618,7 @@ export interface MazTableHeadersEnriched {
545618
srOnly?: boolean
546619
width?: string
547620
maxWidth?: string
621+
minWidth?: string
548622
classes?: ThHTMLAttributes['class']
549623
scope?: ThHTMLAttributes['scope']
550624
align?: ThHTMLAttributes['align']

packages/lib/src/components/MazTable.vue

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export interface MazTableHeadersEnriched {
1414
srOnly?: boolean
1515
width?: string
1616
maxWidth?: string
17+
minWidth?: string
1718
classes?: NonRecursiveClassValue
1819
scope?: ThHTMLAttributes['scope']
1920
align?: ThHTMLAttributes['align']
@@ -242,6 +243,20 @@ export interface MazTableProps<T extends MazTableRow<T>> {
242243
* @default false
243244
*/
244245
scrollable?: boolean
246+
/**
247+
* Field name used as a stable key for each row (e.g. an id). Gives every row a
248+
* stable identity so reordering is tracked correctly - required for `animatedRows`
249+
* to look right. Falls back to the row index when not provided.
250+
* @type {string}
251+
*/
252+
rowKey?: string
253+
/**
254+
* Animate row reordering with a FLIP transition (rows slide to their new position).
255+
* Provide a stable `rowKey` for meaningful results. Respects `prefers-reduced-motion`.
256+
* @type {boolean}
257+
* @default false
258+
*/
259+
animatedRows?: boolean
245260
}
246261
247262
export interface MazTableProvide {
@@ -270,6 +285,7 @@ import {
270285
provide,
271286
ref,
272287
toRef,
288+
TransitionGroup,
273289
useSlots,
274290
watch,
275291
} from 'vue'
@@ -310,6 +326,8 @@ const {
310326
color = 'primary',
311327
translations,
312328
scrollable = false,
329+
rowKey,
330+
animatedRows = false,
313331
} = defineProps<MazTableProps<T>>()
314332
315333
const emits = defineEmits<{
@@ -575,6 +593,10 @@ function getNormalizedRows(): T[] {
575593
)
576594
}
577595
596+
function getRowKey(row: T, index: number): string | number {
597+
return rowKey && row[rowKey] != null ? row[rowKey] : index
598+
}
599+
578600
function sortColumn(columnIndex: number) {
579601
if (columnIndex === sortedColumnIndex.value) {
580602
const sortTypeValue = sortType.value === 'DESC' ? 'ASC' : undefined
@@ -707,7 +729,7 @@ onBeforeMount(() => {
707729
:rowspan="header.rowspan"
708730
:colspan="header.colspan"
709731
:headers="header.thHeaders"
710-
:style="{ width: header.width, textAlign: header.align }"
732+
:style="{ width: header.width, textAlign: header.align, maxWidth: header.maxWidth, minWidth: header.minWidth }"
711733
class="maz:group"
712734
:class="[
713735
{ '--hidden': header.hidden, '--sortable': header.sortable ?? sortable },
@@ -759,12 +781,16 @@ onBeforeMount(() => {
759781

760782
<MazLoadingBar v-if="loading" :color class="maz:absolute!" />
761783

762-
<tbody :class="{ '--divider': hasDivider }">
784+
<component
785+
:is="animatedRows ? TransitionGroup : 'tbody'"
786+
v-bind="animatedRows ? { tag: 'tbody', name: 'm-table-row', moveClass: 'm-table-row-move' } : {}"
787+
:class="{ '--divider': hasDivider }"
788+
>
763789
<slot>
764790
<template v-if="rowsFiltered.length > 0">
765791
<MazTableRowComponent
766792
v-for="(row, rowIndex) in rowsFiltered"
767-
:key="rowIndex"
793+
:key="getRowKey(row, rowIndex)"
768794
:class="row.classes"
769795
@click="row.action && row.action(row)"
770796
>
@@ -837,7 +863,7 @@ onBeforeMount(() => {
837863
</MazTableRowComponent>
838864
</template>
839865
</slot>
840-
</tbody>
866+
</component>
841867
</table>
842868
</div>
843869

@@ -1146,4 +1172,17 @@ onBeforeMount(() => {
11461172
}
11471173
}
11481174
}
1175+
1176+
/* Reordering animation (FLIP) when `animatedRows` is enabled. The class is set by
1177+
TransitionGroup on the child row element, hence `:deep`. Disabled when the user
1178+
prefers reduced motion. */
1179+
:deep(.m-table-row-move) {
1180+
transition: transform 0.45s cubic-bezier(0.22, 1, 0.36, 1);
1181+
}
1182+
1183+
@media (prefers-reduced-motion: reduce) {
1184+
:deep(.m-table-row-move) {
1185+
transition: none;
1186+
}
1187+
}
11491188
</style>

packages/lib/tests/specs/components/MazTable.spec.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,4 +450,65 @@ describe('given MazTable component', () => {
450450
})
451451
})
452452
})
453+
454+
describe('when animatedRows is enabled', () => {
455+
const baseProps = {
456+
animatedRows: true,
457+
rowKey: 'id',
458+
divider: true,
459+
headers: [
460+
{ label: 'Id', key: 'id' },
461+
{ label: 'Firstname', key: 'firstname' },
462+
],
463+
rows: [
464+
{ id: 1, firstname: 'John' },
465+
{ id: 2, firstname: 'Jane' },
466+
{ id: 3, firstname: 'Alice' },
467+
],
468+
}
469+
470+
it('renders every row inside a tbody container', async () => {
471+
const wrapper = mount(MazTable, { props: baseProps as any })
472+
473+
await vi.dynamicImportSettled()
474+
475+
const tbody = wrapper.find('tbody')
476+
expect(tbody.exists()).toBe(true)
477+
// The dynamic container keeps the divider class binding.
478+
expect(tbody.classes()).toContain('--divider')
479+
expect(wrapper.findAll('tbody tr')).toHaveLength(3)
480+
})
481+
482+
it('reflects the new order when rows are reordered (stable rowKey path)', async () => {
483+
const wrapper = mount(MazTable, {
484+
props: {
485+
animatedRows: true,
486+
rowKey: 'id',
487+
headers: [{ label: 'Id', key: 'id' }],
488+
rows: [{ id: 1 }, { id: 2 }, { id: 3 }],
489+
} as any,
490+
})
491+
492+
await vi.dynamicImportSettled()
493+
expect(wrapper.findAll('tbody tr td').map(td => td.text())).toStrictEqual(['1', '2', '3'])
494+
495+
await wrapper.setProps({ rows: [{ id: 3 }, { id: 1 }, { id: 2 }] })
496+
await vi.dynamicImportSettled()
497+
498+
expect(wrapper.findAll('tbody tr td').map(td => td.text())).toStrictEqual(['3', '1', '2'])
499+
})
500+
501+
it('still renders when no rowKey is provided (falls back to index)', async () => {
502+
const wrapper = mount(MazTable, {
503+
props: {
504+
animatedRows: true,
505+
headers: [{ label: 'Id', key: 'id' }],
506+
rows: [{ id: 1 }, { id: 2 }],
507+
} as any,
508+
})
509+
510+
await vi.dynamicImportSettled()
511+
expect(wrapper.findAll('tbody tr')).toHaveLength(2)
512+
})
513+
})
453514
})

0 commit comments

Comments
 (0)