Merge branch 'dev' into issue-1057

This commit is contained in:
kyyy
2024-11-12 17:17:46 +07:00
committed by GitHub
10 changed files with 1298 additions and 773 deletions

View File

@@ -1,6 +1,9 @@
<script lang="ts" setup>
// Columns
const columns = [{
key: 'select',
class: 'w-2'
}, {
key: 'id',
label: '#',
sortable: true
@@ -20,6 +23,7 @@ const columns = [{
const selectedColumns = ref(columns)
const columnsTable = computed(() => columns.filter(column => selectedColumns.value.includes(column)))
const excludeSelectColumn = computed(() => columns.filter(v => v.key !== 'select'))
// Selected Rows
const selectedRows = ref([])
@@ -153,7 +157,7 @@ const { data: todos, status } = await useLazyAsyncData<{
</UButton>
</UDropdown>
<USelectMenu v-model="selectedColumns" :options="columns" multiple>
<USelectMenu v-model="selectedColumns" :options="excludeSelectColumn" multiple>
<UButton
icon="i-heroicons-view-columns"
color="gray"

View File

@@ -0,0 +1,64 @@
<script setup lang="ts">
const people = [{
id: 1,
name: 'Lindsay Walton',
title: 'Front-end Developer',
email: 'lindsay.walton@example.com',
role: 'Member'
}, {
id: 2,
name: 'Courtney Henry',
title: 'Designer',
email: 'courtney.henry@example.com',
role: 'Admin'
}, {
id: 3,
name: 'Tom Cook',
title: 'Director of Product',
email: 'tom.cook@example.com',
role: 'Member'
}, {
id: 4,
name: 'Whitney Francis',
title: 'Copywriter',
email: 'whitney.francis@example.com',
role: 'Admin'
}, {
id: 5,
name: 'Leonard Krasner',
title: 'Senior Designer',
email: 'leonard.krasner@example.com',
role: 'Owner'
}, {
id: 6,
name: 'Floyd Miles',
title: 'Principal Designer',
email: 'floyd.miles@example.com',
role: 'Member'
}]
const selected = ref([people[1]])
const columns = [{
key: 'id',
label: 'ID'
}, {
key: 'name',
label: 'User name'
}, {
key: 'title',
label: 'Job position'
}, {
key: 'email',
label: 'Email'
}, {
key: 'role'
}, {
key: 'select',
class: 'w-2'
}]
</script>
<template>
<UTable v-model="selected" :rows="people" :columns="columns" />
</template>

View File

@@ -285,6 +285,65 @@ componentProps:
---
::
#### Event Selectable
The `UTable` component provides two key events for handling row selection:
##### ***@select:all***
The `@select:all` event is emitted when the header checkbox in a selectable table is toggled. This event returns a boolean value indicating whether all rows are selected (true) or deselected (false).
##### ***@update:modelValue***
The `@update:modelValue` event is emitted whenever the selection state changes, including both individual row selection and bulk selection. This event returns an array containing the currently selected rows.
Here's how to implement both events:
```vue
<script setup lang="ts">
const selected = ref([])
const onHandleSelectAll = (isSelected: boolean) => {
console.log('All rows selected:', isSelected)
}
const onUpdateSelection = (selectedRows: any[]) => {
console.log('Currently selected rows:', selectedRows)
}
</script>
<template>
<UTable
v-model="selected"
:rows="people"
@select:all="onHandleSelectAll"
@update:modelValue="onUpdateSelection"
/>
</template>
```
#### Single Select Mode
Control how the select function allows only one row to be selected at a time.
```vue
<template>
<!-- Allow only one row to be selectable at a time -->
<UTable :single-select="true" />
</template>
```
#### Checkbox Placement
You can customize the checkbox column position by using the `select` key in the `columns` configuration.
::component-example{class="grid"}
---
extraClass: 'overflow-hidden'
padding: false
component: 'table-example-dynamically-render-selectable'
componentProps:
class: 'flex-1'
---
::
### Contextmenu
Use the `contextmenu` listener on your Table to make the rows righ-clickable. The function will receive the original event as the first argument and the row as the second argument.
@@ -393,7 +452,6 @@ Controls whether multiple rows can be expanded simultaneously in the table.
<!-- Or simply -->
<UTable />
</template>
```
#### Disable Row Expansion
@@ -534,6 +592,82 @@ componentProps:
---
::
### `select-header`
This slot allows you to customize the checkbox appearance in the table header for selecting all rows at once while using feature [Selectable](#selectable).
#### Usage
```vue
<template>
<UTable v-model="selectable">
<template #select-header="{ checked, change, indeterminate }">
<!-- Place your custom component here -->
</template>
</UTable>
</template>
```
#### Props
| Prop | Type | Description |
|------|------|-------------|
| `checked` | `Boolean` | Indicates if all rows are selected |
| `change` | `Function` | Function to handle selection state changes. Must receive a boolean value (true/false) |
| `indeterminate` | `Boolean` | Indicates partial selection (when some rows are selected) |
#### Example
```vue
<template>
<UTable>
<!-- Header checkbox customization -->
<template #select-header="{ indeterminate, checked, change }">
<input
type="checkbox"
:indeterminate="indeterminate"
:checked="checked"
@change="e => change(e.target.checked)"
/>
</template>
</UTable>
</template>
```
### `select-data`
This slot allows you to customize the checkbox appearance for each row in the table while using feature [Selectable](#selectable).
#### Usage
```vue
<template>
<UTable v-model="selectable">
<template #select-data="{ checked, change }">
<!-- Place your custom component here -->
</template>
</UTable>
</template>
```
#### Props
| Prop | Type | Description |
|------|------|-------------|
| `checked` | `Boolean` | Indicates if the current row is selected |
| `change` | `Function` | Function to handle selection state changes. Must receive a boolean value (true/false) |
#### Example
```vue
<template>
<UTable>
<!-- Row checkbox customization -->
<template #select-data="{ checked, change }">
<input
type="checkbox"
:checked="checked"
@change="e => change(e.target.checked)"
/>
</template>
</UTable>
</template>
```
### `expand-action`
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.

View File

@@ -35,7 +35,7 @@
"@headlessui/tailwindcss": "^0.2.1",
"@headlessui/vue": "^1.7.23",
"@iconify-json/heroicons": "^1.2.1",
"@nuxt/icon": "^1.6.1",
"@nuxt/icon": "^1.7.2",
"@nuxt/kit": "^3.14.159",
"@nuxtjs/color-mode": "^3.5.2",
"@nuxtjs/tailwindcss": "^6.12.2",
@@ -80,6 +80,8 @@
"resolutions": {
"@nuxt/ui": "workspace:*",
"@nuxt/content": "2.13.2",
"@nuxtjs/mdc": "0.9.0"
"@nuxtjs/mdc": "0.9.0",
"nuxt": "3.13.2",
"@nuxt/kit": "3.13.2"
}
}

1653
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,28 +8,27 @@
</slot>
<thead :class="ui.thead">
<tr :class="ui.tr.base">
<th v-if="modelValue" scope="col" :class="ui.checkbox.padding">
<UCheckbox
:model-value="isAllRowChecked"
:indeterminate="indeterminate"
v-bind="ui.default.checkbox"
aria-label="Select all"
@change="onChange"
/>
</th>
<th v-if="expand" scope="col" :class="ui.tr.base">
<span class="sr-only">Expand</span>
</th>
<th
v-for="(column, index) in columns"
:key="index"
scope="col"
:class="[ui.th.base, ui.th.padding, ui.th.color, ui.th.font, ui.th.size, column.class]"
:class="[ui.th.base, ui.th.padding, ui.th.color, ui.th.font, ui.th.size, column.key === 'select' && ui.checkbox.padding, column.class]"
:aria-sort="getAriaSort(column)"
>
<slot :name="`${column.key}-header`" :column="column" :sort="sort" :on-sort="onSort">
<slot v-if="!singleSelect && modelValue && (column.key === 'select' || shouldRenderColumnInFirstPlace(index, 'select'))" name="select-header" :indeterminate="indeterminate" :checked="isAllRowChecked" :change="onChange">
<UCheckbox
:model-value="isAllRowChecked"
:indeterminate="indeterminate"
v-bind="ui.default.checkbox"
aria-label="Select all"
@change="onChange"
/>
</slot>
<slot v-else :name="`${column.key}-header`" :column="column" :sort="sort" :on-sort="onSort">
<UButton
v-if="column.sortable"
v-bind="{ ...(ui.default.sortButton || {}), ...sortButton }"
@@ -77,16 +76,7 @@
<template v-else>
<template v-for="(row, index) in rows" :key="index">
<tr :class="[ui.tr.base, isSelected(row) && ui.tr.selected, isExpanded(row) && ui.tr.expanded, ($attrs.onSelect || $attrs.onContextmenu) && ui.tr.active, row?.class]" @click="() => onSelect(row)" @contextmenu="(event) => onContextmenu(event, row)">
<td v-if="modelValue" :class="ui.checkbox.padding">
<UCheckbox
:model-value="isSelected(row)"
v-bind="ui.default.checkbox"
aria-label="Select row"
@change="onChangeCheckbox($event, row)"
@click.capture.stop="() => onSelect(row)"
/>
</td>
<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)" @contextmenu="(event) => onContextmenu(event, row)">
<td
v-if="expand"
:class="[ui.td.base, ui.td.padding, ui.td.color, ui.td.font, ui.td.size]"
@@ -102,8 +92,26 @@
@click.capture.stop="toggleOpened(row)"
/>
</td>
<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]">
<slot :name="`${column.key}-data`" :column="column" :row="row" :index="index" :get-row-data="(defaultValue) => getRowData(row, column.key, defaultValue)">
<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, column.key === 'select' && ui.checkbox.padding]">
<slot v-if="modelValue && (column.key === 'select' || shouldRenderColumnInFirstPlace(subIndex, 'select')) " name="select-data" :checked="isSelected(row)" :change="(ev: boolean) => onChangeCheckbox(ev, row)">
<UCheckbox
:model-value="isSelected(row)"
v-bind="ui.default.checkbox"
aria-label="Select row"
@change="onChangeCheckbox($event, row)"
@click.capture.stop="() => onSelect(row)"
/>
</slot>
<slot
v-else
:key="retriggerSlot"
:name="`${column.key}-data`"
:column="column"
:row="row"
:index="index"
:get-row-data="(defaultValue) => getRowData(row, column.key, defaultValue)"
>
{{ getRowData(row, column.key) }}
</slot>
</td>
@@ -125,11 +133,12 @@
</template>
<script lang="ts">
import { computed, defineComponent, toRaw, toRef } from 'vue'
import { computed, defineComponent, ref, toRaw, toRef, watch } from 'vue'
import type { PropType, AriaAttributes } from 'vue'
import { upperFirst } from 'scule'
import { defu } from 'defu'
import { useVModel } from '@vueuse/core'
import { isEqual } from 'ohash'
import UIcon from '../elements/Icon.vue'
import UButton from '../elements/Button.vue'
import UProgress from '../elements/Progress.vue'
@@ -144,7 +153,7 @@ import { table } from '#ui/ui.config'
const config = mergeConfig<typeof table>(appConfig.ui.strategy, appConfig.ui.table, table)
function defaultComparator<T>(a: T, z: T): boolean {
return JSON.stringify(a) === JSON.stringify(z)
return isEqual(a, z)
}
function defaultSort(a: any, b: any, direction: 'asc' | 'desc') {
@@ -159,6 +168,14 @@ function defaultSort(a: any, b: any, direction: 'asc' | 'desc') {
}
}
function getStringifiedSet(arr: TableRow[]) {
return new Set(arr.map(item => JSON.stringify(item)))
}
function accessor<T extends Record<string, any>>(key: string) {
return (obj: T) => get(obj, key)
}
export default defineComponent({
components: {
UIcon,
@@ -221,7 +238,7 @@ export default defineComponent({
default: false
},
loadingState: {
type: Object as PropType<{ icon: string, label: string }>,
type: Object as PropType<{ icon: string, label: string } | null>,
default: () => config.default.loadingState
},
emptyState: {
@@ -247,9 +264,13 @@ export default defineComponent({
multipleExpand: {
type: Boolean,
default: true
},
singleSelect: {
type: Boolean,
default: false
}
},
emits: ['update:modelValue', 'update:sort', 'update:expand'],
emits: ['update:modelValue', 'update:sort', 'update:expand', 'select:all'],
setup(props, { emit, attrs: $attrs }) {
const { ui, attrs } = useUI('table', toRef(props, 'ui'), config, toRef(props, 'class'))
@@ -264,6 +285,8 @@ export default defineComponent({
})
})
const retriggerSlot = ref(null)
const savedSort = { column: sort.value.column, direction: null }
const rows = computed(() => {
@@ -292,8 +315,6 @@ export default defineComponent({
}
})
const getStringifiedSet = (arr: TableRow[]) => new Set(arr.map(item => JSON.stringify(item)))
const totalRows = computed(() => props.rows.length)
const countCheckedRow = computed(() => {
@@ -328,10 +349,6 @@ export default defineComponent({
return props.by(a, z)
}
function accessor<T extends Record<string, any>>(key: string) {
return (obj: T) => get(obj, key)
}
function isSelected(row: TableRow) {
if (!props.modelValue) {
return false
@@ -355,6 +372,11 @@ export default defineComponent({
}
function onSelect(row: TableRow) {
const selection = window.getSelection()
if (selection && selection.toString().length > 0) {
return
}
if (!$attrs.onSelect) {
return
}
@@ -393,11 +415,12 @@ export default defineComponent({
} else {
selected.value = []
}
emit('select:all', checked)
}
function onChangeCheckbox(checked: boolean, row: TableRow) {
if (checked) {
selected.value.push(row)
selected.value = props.singleSelect ? [row] : [...selected.value, row]
} else {
const index = selected.value.findIndex(item => compare(item, row))
selected.value.splice(index, 1)
@@ -412,6 +435,13 @@ export default defineComponent({
return expand.value?.openedRows ? expand.value.openedRows.some(openedRow => compare(openedRow, row)) : false
}
function shouldRenderColumnInFirstPlace(index: number, key: string) {
if (!props.columns) {
return index === 0
}
return index === 0 && !props.columns.find(col => col.key === key)
}
function toggleOpened(row: TableRow) {
expand.value = {
openedRows: isExpanded(row) ? expand.value.openedRows.filter(v => !compare(v, row)) : props.multipleExpand ? [...expand.value.openedRows, row] : [row],
@@ -439,6 +469,12 @@ export default defineComponent({
return undefined
}
watch(rows, () => {
retriggerSlot.value = new Date()
}, {
deep: true
})
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
@@ -465,7 +501,9 @@ export default defineComponent({
getRowData,
toggleOpened,
getAriaSort,
isExpanded
isExpanded,
shouldRenderColumnInFirstPlace,
retriggerSlot
}
}
})

View File

@@ -252,10 +252,10 @@ async function validateJoiSchema(
schema: JoiSchema
): Promise<ValidateReturnSchema<typeof state>> {
try {
await schema.validateAsync(state, { abortEarly: false })
const result = await schema.validateAsync(state, { abortEarly: false })
return {
errors: null,
result: state
result
}
} catch (error) {
if (isJoiError(error)) {

View File

@@ -48,7 +48,7 @@
v-slot="{ active, selected, disabled: optionDisabled }"
:key="index"
as="template"
:value="valueAttribute ? option[valueAttribute] : option"
:value="valueAttribute ? accessor(option, valueAttribute) : option"
:disabled="option.disabled"
>
<li :class="[uiMenu.option.base, uiMenu.option.rounded, uiMenu.option.padding, uiMenu.option.size, uiMenu.option.color, active ? uiMenu.option.active : uiMenu.option.inactive, selected && uiMenu.option.selected, optionDisabled && uiMenu.option.disabled]">
@@ -104,6 +104,7 @@ import {
import { computedAsync, useDebounceFn } from '@vueuse/core'
import { defu } from 'defu'
import { twMerge, twJoin } from 'tailwind-merge'
import { isEqual } from 'ohash'
import UIcon from '../elements/Icon.vue'
import UAvatar from '../elements/Avatar.vue'
import { useUI } from '../../composables/useUI'
@@ -308,8 +309,28 @@ export default defineComponent({
return
}
function getValue(value: any) {
if (props.valueAttribute) {
return accessor(value, props.valueAttribute)
}
return value
}
function compareValues(value1: any, value2: any) {
if (props.by && typeof value1 === 'object' && typeof value2 === 'object') {
return isEqual(value1[props.by], value2[props.by])
}
return isEqual(value1, value2)
}
if (props.valueAttribute) {
const option = options.value.find(option => option[props.valueAttribute] === props.modelValue)
const option = options.value.find((option) => {
const optionValue = getValue(option)
return compareValues(optionValue, props.modelValue)
})
return option ? accessor(option, props.optionAttribute) : null
} else {
return ['string', 'number'].includes(typeof props.modelValue) ? props.modelValue : accessor(props.modelValue as Record<string, any>, props.optionAttribute)

View File

@@ -74,7 +74,7 @@
v-slot="{ active, selected: optionSelected, disabled: optionDisabled }"
:key="index"
as="template"
:value="valueAttribute ? option[valueAttribute] : option"
:value="valueAttribute ? accessor(option, valueAttribute) : option"
:disabled="option.disabled"
>
<li :class="[uiMenu.option.base, uiMenu.option.rounded, uiMenu.option.padding, uiMenu.option.size, uiMenu.option.color, active ? uiMenu.option.active : uiMenu.option.inactive, optionSelected && uiMenu.option.selected, optionDisabled && uiMenu.option.disabled]">
@@ -143,6 +143,7 @@ import {
import { computedAsync, useDebounceFn } from '@vueuse/core'
import { defu } from 'defu'
import { twMerge, twJoin } from 'tailwind-merge'
import { isEqual } from 'ohash'
import UIcon from '../elements/Icon.vue'
import UAvatar from '../elements/Avatar.vue'
import { useUI } from '../../composables/useUI'
@@ -379,39 +380,53 @@ export default defineComponent({
})
const selected = computed(() => {
function compareValues(value1: any, value2: any) {
if (props.by && typeof value1 === 'object' && typeof value2 === 'object') {
return isEqual(value1[props.by], value2[props.by])
}
return isEqual(value1, value2)
}
function getValue(value: any) {
if (props.valueAttribute) {
return accessor(value, props.valueAttribute)
}
return value
}
if (props.multiple) {
if (!Array.isArray(props.modelValue) || !props.modelValue.length) {
const modelValue = props.modelValue
if (!Array.isArray(modelValue) || !modelValue.length) {
return []
}
if (props.valueAttribute) {
return options.value.filter(option => (props.modelValue as any[]).includes(option[props.valueAttribute]))
}
return options.value.filter(option => (props.modelValue as any[]).includes(option))
return options.value.filter((option) => {
const optionValue = getValue(option)
return modelValue.some(value => compareValues(value, optionValue))
})
}
if (props.valueAttribute) {
return options.value.find(option => option[props.valueAttribute] === props.modelValue)
}
return options.value.find(option => option === props.modelValue)
return options.value.find((option) => {
const optionValue = getValue(option)
return compareValues(optionValue, toRaw(props.modelValue))
}) ?? props.modelValue
})
const label = computed(() => {
if (props.multiple) {
if (Array.isArray(props.modelValue) && props.modelValue.length) {
return `${selected.value.length} selected`
} else {
return null
}
} else if (props.modelValue !== undefined && props.modelValue !== null) {
if (props.valueAttribute) {
return accessor(selected.value, props.optionAttribute) ?? null
} else {
return ['string', 'number'].includes(typeof props.modelValue) ? props.modelValue : accessor(props.modelValue as Record<string, any>, props.optionAttribute)
}
if (!selected.value) return null
if (props.valueAttribute) {
return accessor(selected.value as Record<string, any>, props.optionAttribute)
}
return null
if (Array.isArray(props.modelValue) && props.modelValue.length) {
return `${props.modelValue.length} selected`
} else if (['string', 'number'].includes(typeof props.modelValue)) {
return props.modelValue
}
return accessor(props.modelValue as Record<string, any>, props.optionAttribute)
})
const selectClass = computed(() => {

View File

@@ -1,7 +1,7 @@
<template>
<Teleport to="body">
<div :class="wrapperClass" role="region" v-bind="attrs">
<div v-if="notifications.length" :class="ui.container">
<div v-if="notifications.length" :class="wrapperClass" role="region" v-bind="attrs">
<div :class="ui.container">
<div v-for="notification of notifications" :key="notification.id">
<UNotification
v-bind="notification"