Skip to content

Commit 1acd01a

Browse files
authoredNov 5, 2024··
feat(Table): improve expanded row (#2485)

File tree

7 files changed

+301
-25
lines changed

7 files changed

+301
-25
lines changed
 
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<script setup>
2+
const people = [{
3+
id: 1,
4+
name: 'Lindsay Walton',
5+
title: 'Front-end Developer',
6+
email: 'lindsay.walton@example.com',
7+
role: 'Member'
8+
}, {
9+
id: 2,
10+
name: 'Courtney Henry',
11+
title: 'Designer',
12+
email: 'courtney.henry@example.com',
13+
role: 'Admin',
14+
disabledExpand: true
15+
}, {
16+
id: 3,
17+
name: 'Tom Cook',
18+
title: 'Director of Product',
19+
email: 'tom.cook@example.com',
20+
role: 'Member'
21+
}, {
22+
id: 4,
23+
name: 'Whitney Francis',
24+
title: 'Copywriter',
25+
email: 'whitney.francis@example.com',
26+
role: 'Admin',
27+
disabledExpand: true
28+
}, {
29+
id: 5,
30+
name: 'Leonard Krasner',
31+
title: 'Senior Designer',
32+
email: 'leonard.krasner@example.com',
33+
role: 'Owner'
34+
}, {
35+
id: 6,
36+
name: 'Floyd Miles',
37+
title: 'Principal Designer',
38+
email: 'floyd.miles@example.com',
39+
role: 'Member',
40+
disabledExpand: true
41+
}]
42+
const columns = [
43+
{
44+
label: 'Name',
45+
key: 'name'
46+
},
47+
{
48+
label: 'title',
49+
key: 'title'
50+
},
51+
{
52+
label: 'Email',
53+
key: 'email'
54+
},
55+
{
56+
label: 'role',
57+
key: 'role'
58+
}
59+
]
60+
61+
const expand = ref({
62+
openedRows: [],
63+
row: null
64+
})
65+
</script>
66+
67+
<template>
68+
<UTable v-model:expand="expand" :rows="people" :columns="columns">
69+
<template #expand="{ row }">
70+
<div class="p-4">
71+
<pre>{{ row }}</pre>
72+
</div>
73+
</template>
74+
</UTable>
75+
</template>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<script setup lang='ts'>
2+
const people = [{
3+
id: 1,
4+
name: 'Lindsay Walton',
5+
title: 'Front-end Developer',
6+
email: 'lindsay.walton@example.com',
7+
role: 'Member',
8+
hasExpand: false
9+
}, {
10+
id: 2,
11+
name: 'Courtney Henry',
12+
title: 'Designer',
13+
email: 'courtney.henry@example.com',
14+
role: 'Admin',
15+
hasExpand: true
16+
}, {
17+
id: 3,
18+
name: 'Tom Cook',
19+
title: 'Director of Product',
20+
email: 'tom.cook@example.com',
21+
role: 'Member',
22+
hasExpand: false
23+
}, {
24+
id: 4,
25+
name: 'Whitney Francis',
26+
title: 'Copywriter',
27+
email: 'whitney.francis@example.com',
28+
role: 'Admin',
29+
hasExpand: true
30+
}, {
31+
id: 5,
32+
name: 'Leonard Krasner',
33+
title: 'Senior Designer',
34+
email: 'leonard.krasner@example.com',
35+
role: 'Owner',
36+
hasExpand: false
37+
}, {
38+
id: 6,
39+
name: 'Floyd Miles',
40+
title: 'Principal Designer',
41+
email: 'floyd.miles@example.com',
42+
role: 'Member',
43+
hasExpand: true
44+
}]
45+
46+
const expand = ref({
47+
openedRows: [people.find(v => v.hasExpand)],
48+
row: {}
49+
})
50+
</script>
51+
52+
<template>
53+
<UTable v-model:expand="expand" :rows="people">
54+
<template #expand="{ row }">
55+
<div class="p-4">
56+
<pre>{{ row }}</pre>
57+
</div>
58+
</template>
59+
<template #expand-action="{ row, isExpanded, toggle }">
60+
<UButton v-if="row.hasExpand" @click="toggle">
61+
{{ isExpanded ? 'collapse' : 'expand' }}
62+
</UButton>
63+
</template>
64+
</UTable>
65+
</template>

‎docs/components/content/examples/TableExampleExpandable.vue

+7-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<script setup>
1+
<script setup lang='ts'>
22
const people = [{
33
id: 1,
44
name: 'Lindsay Walton',
@@ -36,10 +36,15 @@ const people = [{
3636
email: 'floyd.miles@example.com',
3737
role: 'Member'
3838
}]
39+
40+
const expand = ref({
41+
openedRows: [people[0]],
42+
row: {}
43+
})
3944
</script>
4045

4146
<template>
42-
<UTable :rows="people">
47+
<UTable v-model:expand="expand" :rows="people">
4348
<template #expand="{ row }">
4449
<div class="p-4">
4550
<pre>{{ row }}</pre>

‎docs/content/2.components/table.md

+107-1
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,9 @@ componentProps:
315315

316316
### Expandable :u-badge{label="New" class="align-middle ml-2 !rounded-full" variant="subtle"}
317317

318-
You can use the `expand` slot to display extra information about a row. You will have access to the `row` property in the slot scope.
318+
You can use the `v-model:expand` to enables row expansion functionality in the table component. It maintains an object containing an `openedRows` an array and `row` an object, which tracks the indices of currently expanded rows.
319+
320+
When using the expand slot, you have access to the `row` property in the slot scope, which contains the data of the row that triggered the expand/collapse action. This allows you to customize the expanded content based on the row's data.
319321

320322
::component-example{class="grid"}
321323
---
@@ -327,6 +329,73 @@ componentProps:
327329
---
328330
::
329331

332+
#### Event expand
333+
334+
The `@update:expand` event is emitted when a row is expanded. This event provides the current state of expanded rows and the data of the row that triggered the event.
335+
336+
To use the `@update:expand` event, add it to your `UTable` component. The event handler will receive an object with the following properties:
337+
- `openedRows`: An array of indices of the currently expanded rows.
338+
- `row`: The row data that triggered the expand/collapse action.
339+
340+
```vue
341+
<script setup lang="ts">
342+
const { data, pending } = await useLazyFetch(() => `/api/users`)
343+
344+
const handleExpand = ({ openedRows, row }) => {
345+
console.log('opened Rows:', openedRows);
346+
console.log('Row Data:', row);
347+
};
348+
349+
const expand = ref({
350+
openedRows: [],
351+
row: null
352+
})
353+
354+
</script>
355+
<template>
356+
<UTable v-model="expand" :loading="pending" :rows="data" @update:expand="handleExpand">
357+
<template #expand="{ row }">
358+
<div class="p-4">
359+
<pre>{{ row }}</pre>
360+
</div>
361+
</template>
362+
</UTable>
363+
</template>
364+
```
365+
366+
#### Multiple expand
367+
Controls whether multiple rows can be expanded simultaneously in the table.
368+
369+
```vue
370+
<template>
371+
<!-- Allow only one row to be expanded at a time -->
372+
<UTable :multiple-expand="false" />
373+
374+
<!-- Default behavior: Allow multiple rows to be expanded simultaneously -->
375+
<UTable :multiple-expand="true" />
376+
377+
<!-- Or simply -->
378+
<UTable />
379+
</template>
380+
381+
```
382+
383+
#### Disable Row Expansion
384+
385+
You can disable the expansion functionality for specific rows in the UTable component by adding the `disabledExpand` property to your row data.
386+
387+
> Important: When using `disabledExpand`, you must define the `columns` prop for the UTable component. Otherwise, the table will render all properties as columns, including the `disabledExpand` property.
388+
389+
::component-example{class="grid"}
390+
---
391+
extraClass: 'overflow-hidden'
392+
padding: false
393+
component: 'table-example-disabled-expandable'
394+
componentProps:
395+
class: 'flex-1'
396+
---
397+
::
398+
330399
### Loading
331400

332401
Use the `loading` prop to indicate that data is currently loading with an indeterminate [Progress](/components/progress#indeterminate) bar.
@@ -449,6 +518,43 @@ componentProps:
449518
---
450519
::
451520

521+
### `expand-action`
522+
523+
The `#expand-action` slot allows you to customize the expansion control interface for expandable table rows. This feature provides a flexible way to implement custom expand/collapse functionality while maintaining access to essential row data and state.
524+
525+
#### Usage
526+
527+
```vue
528+
<template>
529+
<UTable>
530+
<template #expand-action="{ row, toggle, isExpanded }">
531+
<!-- Your custom expand action content -->
532+
</template>
533+
</UTable>
534+
</template>
535+
```
536+
537+
#### Slot Props
538+
539+
The slot provides three key props:
540+
541+
| Prop | Type | Description |
542+
|------|------|-------------|
543+
| `row` | `Object` | Contains the current row's data |
544+
| `toggle` | `Function` | Function to toggle the expanded state |
545+
| `isExpanded` | `Boolean` | Current expansion state of the row |
546+
547+
::component-example{class="grid"}
548+
---
549+
extraClass: 'overflow-hidden'
550+
padding: false
551+
component: 'table-example-expand-action-slot'
552+
componentProps:
553+
class: 'flex-1'
554+
---
555+
::
556+
557+
452558
### `loading-state`
453559

454560
Use the `#loading-state` slot to customize the loading state.

‎src/runtime/components/data/Table.vue

+41-22
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
/>
1919
</th>
2020

21-
<th v-if="$slots.expand" scope="col" :class="ui.tr.base">
21+
<th v-if="expand" scope="col" :class="ui.tr.base">
2222
<span class="sr-only">Expand</span>
2323
</th>
2424

@@ -50,7 +50,7 @@
5050
</thead>
5151
<tbody :class="ui.tbody">
5252
<tr v-if="loadingState && loading && !rows.length">
53-
<td :colspan="columns.length + (modelValue ? 1 : 0) + ($slots.expand ? 1 : 0)">
53+
<td :colspan="columns.length + (modelValue ? 1 : 0) + (expand ? 1 : 0)">
5454
<slot name="loading-state">
5555
<div :class="ui.loadingState.wrapper">
5656
<UIcon v-if="loadingState.icon" :name="loadingState.icon" :class="ui.loadingState.icon" aria-hidden="true" />
@@ -63,7 +63,7 @@
6363
</tr>
6464

6565
<tr v-else-if="emptyState && !rows.length">
66-
<td :colspan="columns.length + (modelValue ? 1 : 0) + ($slots.expand ? 1 : 0)">
66+
<td :colspan="columns.length + (modelValue ? 1 : 0) + (expand ? 1 : 0)">
6767
<slot name="empty-state">
6868
<div :class="ui.emptyState.wrapper">
6969
<UIcon v-if="emptyState.icon" :name="emptyState.icon" :class="ui.emptyState.icon" aria-hidden="true" />
@@ -77,7 +77,7 @@
7777

7878
<template v-else>
7979
<template v-for="(row, index) in rows" :key="index">
80-
<tr :class="[ui.tr.base, isSelected(row) && ui.tr.selected, $attrs.onSelect && ui.tr.active, row?.class]" @click="() => onSelect(row)">
80+
<tr :class="[ui.tr.base, isSelected(row) && ui.tr.selected, isExpanded(row) && ui.tr.expanded, $attrs.onSelect && ui.tr.active, row?.class]" @click="() => onSelect(row)">
8181
<td v-if="modelValue" :class="ui.checkbox.padding">
8282
<UCheckbox
8383
:model-value="isSelected(row)"
@@ -87,25 +87,28 @@
8787
@click.capture.stop="() => onSelect(row)"
8888
/>
8989
</td>
90-
9190
<td
92-
v-if="$slots.expand"
91+
v-if="expand"
9392
:class="[ui.td.base, ui.td.padding, ui.td.color, ui.td.font, ui.td.size]"
9493
>
94+
<template v-if="$slots['expand-action']">
95+
<slot name="expand-action" :row="row" :is-expanded="isExpanded(row)" :toggle="() => toggleOpened(row)" />
96+
</template>
9597
<UButton
98+
v-else
99+
:disabled="row.disabledExpand"
96100
v-bind="{ ...(ui.default.expandButton || {}), ...expandButton }"
97-
:ui="{ icon: { base: [ui.expand.icon, openedRows.includes(index) && 'rotate-180'].join(' ') } }"
98-
@click="toggleOpened(index)"
101+
:ui="{ icon: { base: [ui.expand.icon, isExpanded(row) && 'rotate-180'].join(' ') } }"
102+
@click.capture.stop="toggleOpened(row)"
99103
/>
100104
</td>
101-
102105
<td v-for="(column, subIndex) in columns" :key="subIndex" :class="[ui.td.base, ui.td.padding, ui.td.color, ui.td.font, ui.td.size, column?.rowClass, row[column.key]?.class]">
103106
<slot :name="`${column.key}-data`" :column="column" :row="row" :index="index" :get-row-data="(defaultValue) => getRowData(row, column.key, defaultValue)">
104107
{{ getRowData(row, column.key) }}
105108
</slot>
106109
</td>
107110
</tr>
108-
<tr v-if="openedRows.includes(index)">
111+
<tr v-if="isExpanded(row)">
109112
<td colspan="100%">
110113
<slot
111114
name="expand"
@@ -122,7 +125,7 @@
122125
</template>
123126

124127
<script lang="ts">
125-
import { ref, computed, defineComponent, toRaw, toRef } from 'vue'
128+
import { computed, defineComponent, toRaw, toRef } from 'vue'
126129
import type { PropType, AriaAttributes } from 'vue'
127130
import { upperFirst } from 'scule'
128131
import { defu } from 'defu'
@@ -133,7 +136,7 @@ import UProgress from '../elements/Progress.vue'
133136
import UCheckbox from '../forms/Checkbox.vue'
134137
import { useUI } from '../../composables/useUI'
135138
import { mergeConfig, get } from '../../utils'
136-
import type { TableRow, TableColumn, Strategy, Button, ProgressColor, ProgressAnimation, DeepPartial } from '../../types/index'
139+
import type { TableRow, TableColumn, Strategy, Button, ProgressColor, ProgressAnimation, DeepPartial, Expanded } from '../../types/index'
137140
// @ts-expect-error
138141
import appConfig from '#build/app.config'
139142
import { table } from '#ui/ui.config'
@@ -209,6 +212,10 @@ export default defineComponent({
209212
type: Object as PropType<Button>,
210213
default: () => config.default.expandButton as Button
211214
},
215+
expand: {
216+
type: Object as PropType<Expanded<TableRow>>,
217+
default: () => null
218+
},
212219
loading: {
213220
type: Boolean,
214221
default: false
@@ -236,17 +243,26 @@ export default defineComponent({
236243
ui: {
237244
type: Object as PropType<DeepPartial<typeof config> & { strategy?: Strategy }>,
238245
default: () => ({})
246+
},
247+
multipleExpand: {
248+
type: Boolean,
249+
default: true
239250
}
240251
},
241-
emits: ['update:modelValue', 'update:sort'],
252+
emits: ['update:modelValue', 'update:sort', 'update:expand'],
242253
setup(props, { emit, attrs: $attrs }) {
243254
const { ui, attrs } = useUI('table', toRef(props, 'ui'), config, toRef(props, 'class'))
244255
245256
const columns = computed(() => props.columns ?? Object.keys(props.rows[0] ?? {}).map(key => ({ key, label: upperFirst(key), sortable: false, class: undefined, sort: defaultSort }) as TableColumn))
246257
247258
const sort = useVModel(props, 'sort', emit, { passive: true, defaultValue: defu({}, props.sort, { column: null, direction: 'asc' }) })
248-
249-
const openedRows = ref([])
259+
const expand = useVModel(props, 'expand', emit, {
260+
passive: true,
261+
defaultValue: defu({}, props.expand, {
262+
openedRows: [],
263+
row: null
264+
})
265+
})
250266
251267
const savedSort = { column: sort.value.column, direction: null }
252268
@@ -383,11 +399,14 @@ export default defineComponent({
383399
return get(row, rowKey, defaultValue)
384400
}
385401
386-
function toggleOpened(index: number) {
387-
if (openedRows.value.includes(index)) {
388-
openedRows.value = openedRows.value.filter(i => i !== index)
389-
} else {
390-
openedRows.value.push(index)
402+
function isExpanded(row: TableRow) {
403+
return expand.value?.openedRows ? expand.value.openedRows.some(openedRow => compare(openedRow, row)) : false
404+
}
405+
406+
function toggleOpened(row: TableRow) {
407+
expand.value = {
408+
openedRows: isExpanded(row) ? expand.value.openedRows.filter(v => !compare(v, row)) : props.multipleExpand ? [...expand.value.openedRows, row] : [row],
409+
row
391410
}
392411
}
393412
@@ -429,14 +448,14 @@ export default defineComponent({
429448
loadingState,
430449
isAllRowChecked,
431450
onChangeCheckbox,
432-
openedRows,
433451
isSelected,
434452
onSort,
435453
onSelect,
436454
onChange,
437455
getRowData,
438456
toggleOpened,
439-
getAriaSort
457+
getAriaSort,
458+
isExpanded
440459
}
441460
}
442461
})

‎src/runtime/types/table.d.ts

+5
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,8 @@ export interface TableColumn {
1111
rowClass?: string
1212
[key: string]: any
1313
}
14+
15+
export interface Expanded<T> {
16+
openedRows: T[]
17+
row: T | null
18+
}

‎src/runtime/ui.config/data/table.ts

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export default {
88
tr: {
99
base: '',
1010
selected: 'bg-gray-50 dark:bg-gray-800/50',
11+
expanded: 'bg-gray-50 dark:bg-gray-800/50',
1112
active: 'hover:bg-gray-50 dark:hover:bg-gray-800/50 cursor-pointer'
1213
},
1314
th: {

0 commit comments

Comments
 (0)
Please sign in to comment.