mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-01-15 20:48:12 +01:00
Compare commits
26 Commits
v2.19.1
...
issue-1057
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21939ed333 | ||
|
|
5a414eb55a | ||
|
|
3a5960fb58 | ||
|
|
acecff40ec | ||
|
|
1fd5fac295 | ||
|
|
b23f2decfc | ||
|
|
7154254ac2 | ||
|
|
49f85d55c5 | ||
|
|
97037864b3 | ||
|
|
0abccabc26 | ||
|
|
ac323c4ccc | ||
|
|
d4e408cfd8 | ||
|
|
f3bf69c233 | ||
|
|
d6daf466ac | ||
|
|
6e66990372 | ||
|
|
a78203ce49 | ||
|
|
592da565fe | ||
|
|
56e28d80db | ||
|
|
24e61ccc8b | ||
|
|
c9e6256e7f | ||
|
|
ce955d24f1 | ||
|
|
bf580863af | ||
|
|
f38a217032 | ||
|
|
717a027bad | ||
|
|
159acd664c | ||
|
|
212f7df35b |
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## [2.19.2](https://github.com/nuxt/ui/compare/v2.19.1...v2.19.2) (2024-11-05)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **Button:** put back `target` override ([212f7df](https://github.com/nuxt/ui/commit/212f7df35b9f81d189e1ee3e34f6fd2234cf52fe))
|
||||
|
||||
## [2.19.1](https://github.com/nuxt/ui/compare/v2.19.0...v2.19.1) (2024-11-05)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -20,7 +20,7 @@ Its goal is to provide everything related to UI when building a Nuxt app. This i
|
||||
- Keyboard shortcuts
|
||||
- Bundled icons
|
||||
- Fully typed
|
||||
- [Figma Kit](https://www.figma.com/community/file/1288455405058138934)
|
||||
- [Figma Kit](https://www.figma.com/community/file/1436401057300493073)
|
||||
|
||||
Read more on [ui.nuxt.com](https://ui.nuxt.com)
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ const { $ui } = useNuxtApp()
|
||||
const links = [{
|
||||
icon: 'i-simple-icons-figma',
|
||||
label: 'Figma Kit',
|
||||
to: 'https://www.figma.com/community/file/1288455405058138934',
|
||||
to: 'https://www.figma.com/community/file/1436401057300493073',
|
||||
target: '_blank'
|
||||
}, {
|
||||
label: 'Playground',
|
||||
|
||||
102
docs/components/content/examples/SelectMenuExampleClearable.vue
Normal file
102
docs/components/content/examples/SelectMenuExampleClearable.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
const options = ref([
|
||||
{ id: 1, name: 'bug', color: 'd73a4a' },
|
||||
{ id: 2, name: 'documentation', color: '0075ca' },
|
||||
{ id: 3, name: 'duplicate', color: 'cfd3d7' },
|
||||
{ id: 4, name: 'enhancement', color: 'a2eeef' },
|
||||
{ id: 5, name: 'good first issue', color: '7057ff' },
|
||||
{ id: 6, name: 'help wanted', color: '008672' },
|
||||
{ id: 7, name: 'invalid', color: 'e4e669' },
|
||||
{ id: 8, name: 'question', color: 'd876e3' },
|
||||
{ id: 9, name: 'wontfix', color: 'ffffff' }
|
||||
])
|
||||
|
||||
const selected = ref([])
|
||||
|
||||
const labels = computed({
|
||||
get: () => selected.value,
|
||||
set: async (labels) => {
|
||||
const promises = labels.map(async (label) => {
|
||||
if (label.id) {
|
||||
return label
|
||||
}
|
||||
|
||||
// In a real app, you would make an API call to create the label
|
||||
const response = {
|
||||
id: options.value.length + 1,
|
||||
name: label.name,
|
||||
color: generateColorFromString(label.name)
|
||||
}
|
||||
|
||||
options.value.push(response)
|
||||
|
||||
return response
|
||||
})
|
||||
|
||||
selected.value = await Promise.all(promises)
|
||||
}
|
||||
})
|
||||
|
||||
function hashCode(str) {
|
||||
let hash = 0
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash)
|
||||
}
|
||||
return hash
|
||||
}
|
||||
|
||||
function intToRGB(i) {
|
||||
const c = (i & 0x00FFFFFF)
|
||||
.toString(16)
|
||||
.toUpperCase()
|
||||
|
||||
return '00000'.substring(0, 6 - c.length) + c
|
||||
}
|
||||
|
||||
function generateColorFromString(str) {
|
||||
return intToRGB(hashCode(str))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<USelectMenu
|
||||
v-model="labels"
|
||||
by="id"
|
||||
name="labels"
|
||||
:options="options"
|
||||
option-attribute="name"
|
||||
clearable
|
||||
multiple
|
||||
searchable
|
||||
creatable
|
||||
>
|
||||
<template #label>
|
||||
<template v-if="labels.length">
|
||||
<span class="flex items-center -space-x-1">
|
||||
<span v-for="label of labels" :key="label.id" class="flex-shrink-0 w-2 h-2 mt-px rounded-full" :style="{ background: `#${label.color}` }" />
|
||||
</span>
|
||||
<span>{{ labels.length }} label{{ labels.length > 1 ? 's' : '' }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-gray-500 dark:text-gray-400 truncate">Select labels</span>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template #option="{ option }">
|
||||
<span
|
||||
class="flex-shrink-0 w-2 h-2 mt-px rounded-full"
|
||||
:style="{ background: `#${option.color}` }"
|
||||
/>
|
||||
<span class="truncate">{{ option.name }}</span>
|
||||
</template>
|
||||
|
||||
<template #option-create="{ option }">
|
||||
<span class="flex-shrink-0">New label:</span>
|
||||
<span
|
||||
class="flex-shrink-0 w-2 h-2 mt-px rounded-full -mx-1"
|
||||
:style="{ background: `#${generateColorFromString(option.name)}` }"
|
||||
/>
|
||||
<span class="block truncate">{{ option.name }}</span>
|
||||
</template>
|
||||
</USelectMenu>
|
||||
</template>
|
||||
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
const options = ref([
|
||||
{ id: 1, name: 'bug', color: 'd73a4a' },
|
||||
{ id: 2, name: 'documentation', color: '0075ca' },
|
||||
{ id: 3, name: 'duplicate', color: 'cfd3d7' },
|
||||
{ id: 4, name: 'enhancement', color: 'a2eeef' },
|
||||
{ id: 5, name: 'good first issue', color: '7057ff' },
|
||||
{ id: 6, name: 'help wanted', color: '008672' },
|
||||
{ id: 7, name: 'invalid', color: 'e4e669' },
|
||||
{ id: 8, name: 'question', color: 'd876e3' },
|
||||
{ id: 9, name: 'wontfix', color: 'ffffff' }
|
||||
])
|
||||
|
||||
const selected = ref([])
|
||||
|
||||
const labels = computed({
|
||||
get: () => selected.value,
|
||||
set: async (labels) => {
|
||||
const promises = labels.map(async (label) => {
|
||||
if (label.id) {
|
||||
return label
|
||||
}
|
||||
|
||||
// In a real app, you would make an API call to create the label
|
||||
const response = {
|
||||
id: options.value.length + 1,
|
||||
name: label.name,
|
||||
color: generateColorFromString(label.name)
|
||||
}
|
||||
|
||||
options.value.push(response)
|
||||
|
||||
return response
|
||||
})
|
||||
|
||||
selected.value = await Promise.all(promises)
|
||||
}
|
||||
})
|
||||
|
||||
function hashCode(str) {
|
||||
let hash = 0
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash)
|
||||
}
|
||||
return hash
|
||||
}
|
||||
|
||||
function intToRGB(i) {
|
||||
const c = (i & 0x00FFFFFF)
|
||||
.toString(16)
|
||||
.toUpperCase()
|
||||
|
||||
return '00000'.substring(0, 6 - c.length) + c
|
||||
}
|
||||
|
||||
function generateColorFromString(str) {
|
||||
return intToRGB(hashCode(str))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<USelectMenu
|
||||
v-model="labels"
|
||||
by="id"
|
||||
name="labels"
|
||||
:options="options"
|
||||
option-attribute="name"
|
||||
clearable
|
||||
multiple
|
||||
searchable
|
||||
creatable
|
||||
>
|
||||
<template #label>
|
||||
<template v-if="labels.length">
|
||||
<span class="flex items-center -space-x-1">
|
||||
<span v-for="label of labels" :key="label.id" class="flex-shrink-0 w-2 h-2 mt-px rounded-full" :style="{ background: `#${label.color}` }" />
|
||||
</span>
|
||||
<span>{{ labels.length }} label{{ labels.length > 1 ? 's' : '' }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-gray-500 dark:text-gray-400 truncate">Select labels</span>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template #option="{ option }">
|
||||
<span
|
||||
class="flex-shrink-0 w-2 h-2 mt-px rounded-full"
|
||||
:style="{ background: `#${option.color}` }"
|
||||
/>
|
||||
<span class="truncate">{{ option.name }}</span>
|
||||
</template>
|
||||
|
||||
<template #option-create="{ option }">
|
||||
<span class="flex-shrink-0">New label:</span>
|
||||
<span
|
||||
class="flex-shrink-0 w-2 h-2 mt-px rounded-full -mx-1"
|
||||
:style="{ background: `#${generateColorFromString(option.name)}` }"
|
||||
/>
|
||||
<span class="block truncate">{{ option.name }}</span>
|
||||
</template>
|
||||
<template #clearable="{ onClear }">
|
||||
<UButton icon="i-heroicons-trash-20-solid" size="xs" class="text-gray-400 dark:text-gray-500" variant="ghost" @click.capture.stop="onClear" />
|
||||
</template>
|
||||
</USelectMenu>
|
||||
</template>
|
||||
@@ -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"
|
||||
|
||||
66
docs/components/content/examples/TableExampleContextmenu.vue
Normal file
66
docs/components/content/examples/TableExampleContextmenu.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<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'
|
||||
}]
|
||||
|
||||
const virtualElement = ref({ getBoundingClientRect: () => ({}) })
|
||||
const contextMenuRow = ref()
|
||||
|
||||
function contextmenu(event: MouseEvent, row: any) {
|
||||
// Prevent the default context menu
|
||||
event.preventDefault()
|
||||
|
||||
virtualElement.value.getBoundingClientRect = () => ({
|
||||
width: 0,
|
||||
height: 0,
|
||||
top: event.clientY,
|
||||
left: event.clientX
|
||||
})
|
||||
|
||||
contextMenuRow.value = row
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<UTable :rows="people" @contextmenu.stop="contextmenu" />
|
||||
|
||||
<UContextMenu
|
||||
:virtual-element="virtualElement"
|
||||
:model-value="!!contextMenuRow"
|
||||
@update:model-value="contextMenuRow = null"
|
||||
>
|
||||
<div class="p-4">
|
||||
{{ contextMenuRow.id }} - {{ contextMenuRow.name }}
|
||||
</div>
|
||||
</UContextMenu>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -32,6 +32,11 @@ const attrs = {
|
||||
'is-dark': { selector: 'html', darkClass: 'dark' },
|
||||
'first-day-of-week': 2
|
||||
}
|
||||
|
||||
function onDayClick(_: any, event: MouseEvent): void {
|
||||
const target = event.target as HTMLElement
|
||||
target.blur()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -40,11 +45,13 @@ const attrs = {
|
||||
v-model.range="date"
|
||||
:columns="2"
|
||||
v-bind="{ ...attrs, ...$attrs }"
|
||||
@dayclick="onDayClick"
|
||||
/>
|
||||
<VCalendarDatePicker
|
||||
v-else
|
||||
v-model="date"
|
||||
v-bind="{ ...attrs, ...$attrs }"
|
||||
@dayclick="onDayClick"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Its goal is to provide everything related to UI when building a Nuxt app. This i
|
||||
- Keyboard shortcuts
|
||||
- Bundled icons
|
||||
- Fully typed
|
||||
- [Figma Kit](https://www.figma.com/community/file/1288455405058138934)
|
||||
- [Figma Kit](https://www.figma.com/community/file/1436401057300493073)
|
||||
|
||||
## Credits
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ slots:
|
||||
[Label]{.italic}
|
||||
::
|
||||
|
||||
### `help` :u-badge{label="New" class="align-middle ml-2 !rounded-full" variant="subtle"}
|
||||
### `help`
|
||||
|
||||
Like the `#label` slot, use the `#help` slot to override the content of the help text.
|
||||
|
||||
|
||||
@@ -70,6 +70,11 @@ const attrs = {
|
||||
'is-dark': { selector: 'html', darkClass: 'dark' },
|
||||
'first-day-of-week': 2
|
||||
}
|
||||
|
||||
function onDayClick(_: any, event: MouseEvent): void {
|
||||
const target = event.target as HTMLElement
|
||||
target.blur()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -78,11 +83,13 @@ const attrs = {
|
||||
v-model.range="date"
|
||||
:columns="2"
|
||||
v-bind="{ ...attrs, ...$attrs }"
|
||||
@dayclick="onDayClick"
|
||||
/>
|
||||
<VCalendarDatePicker
|
||||
v-else
|
||||
v-model="date"
|
||||
v-bind="{ ...attrs, ...$attrs }"
|
||||
@dayclick="onDayClick"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ slots:
|
||||
[Label]{.italic}
|
||||
::
|
||||
|
||||
### `help` :u-badge{label="New" class="align-middle ml-2 !rounded-full" variant="subtle"}
|
||||
### `help`
|
||||
|
||||
Like the `#label` slot, use the `#help` slot to override the content of the help text.
|
||||
|
||||
|
||||
@@ -156,7 +156,38 @@ Use the `searchableLazy` prop to control the immediacy of data requests.
|
||||
---
|
||||
component: 'select-menu-example-search-async'
|
||||
componentProps:
|
||||
class: 'w-full lg:w-48'
|
||||
class: 'w-full lg:w-48'
|
||||
---
|
||||
::
|
||||
|
||||
## Clearable
|
||||
The `clearable` prop allows users to easily remove their selected option(s) with a clear button.
|
||||
|
||||
::component-example
|
||||
---
|
||||
component: 'select-menu-example-clearable'
|
||||
componentProps:
|
||||
class: 'w-full lg:w-52'
|
||||
---
|
||||
::
|
||||
|
||||
|
||||
### Customization
|
||||
#### Slot Props
|
||||
The slot provides four key props:
|
||||
|
||||
| Prop | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `selected` | `Object` | The currently selected value/item in the component |
|
||||
| `disabled` | `Boolean` | Whether the component is in a disabled state |
|
||||
| `loading` | `Boolean` | Whether the component is in a loading state |
|
||||
| `onClear` | `Function` | Callback function to clear the selected value when the clear button is clicked |
|
||||
|
||||
::component-example
|
||||
---
|
||||
component: 'select-menu-example-clearable-customization'
|
||||
componentProps:
|
||||
class: 'w-full lg:w-52'
|
||||
---
|
||||
::
|
||||
|
||||
@@ -188,7 +219,7 @@ componentProps:
|
||||
---
|
||||
::
|
||||
|
||||
Pass a function to the `show-create-option-when` prop to control wether or not to show the create option. This function takes two arguments: the query (as the first argument) and an array of current results (as the second argument). It should return true to display the create option. :u-badge{label="New" class="!rounded-full" variant="subtle"}
|
||||
Pass a function to the `show-create-option-when` prop to control wether or not to show the create option. This function takes two arguments: the query (as the first argument) and an array of current results (as the second argument). It should return true to display the create option.
|
||||
|
||||
The example below shows how to make the create option visible when the query is at least three characters long and does not exactly match any of the current results (case insensitive).
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ Use the `columns` prop to configure which columns to display. It's an array of o
|
||||
- `sortable` - Whether the column is sortable. Defaults to `false`.
|
||||
- `direction` - The sort direction to use on first click. Defaults to `asc`.
|
||||
- `class` - The class to apply to the column cells.
|
||||
- `rowClass` - The class to apply to the data column cells. :u-badge{label="New" class="!rounded-full" variant="subtle"}
|
||||
- `rowClass` - The class to apply to the data column cells.
|
||||
- `sort` - Pass your own `sort` function. Defaults to a simple _greater than_ / _less than_ comparison.
|
||||
|
||||
Arguments for the `sort` function are: Value A, Value B, Direction - 'asc' or 'desc'
|
||||
@@ -285,6 +285,81 @@ 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.
|
||||
|
||||
You can use this to open a [ContextMenu](/components/context-menu) for that row.
|
||||
|
||||
::component-example{class="grid"}
|
||||
---
|
||||
extraClass: 'overflow-hidden'
|
||||
padding: false
|
||||
component: 'table-example-contextmenu'
|
||||
componentProps:
|
||||
class: 'flex-1 flex-col overflow-hidden'
|
||||
---
|
||||
::
|
||||
|
||||
### Searchable
|
||||
|
||||
You can easily use the [Input](/components/input) component to filter the rows.
|
||||
@@ -313,7 +388,7 @@ componentProps:
|
||||
---
|
||||
::
|
||||
|
||||
### Expandable :u-badge{label="New" class="align-middle ml-2 !rounded-full" variant="subtle"}
|
||||
### Expandable
|
||||
|
||||
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.
|
||||
|
||||
@@ -377,7 +452,6 @@ Controls whether multiple rows can be expanded simultaneously in the table.
|
||||
<!-- Or simply -->
|
||||
<UTable />
|
||||
</template>
|
||||
|
||||
```
|
||||
|
||||
#### Disable Row Expansion
|
||||
@@ -518,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.
|
||||
|
||||
@@ -92,7 +92,7 @@ Use the `#default` slot to customize the content of the trigger buttons. You wil
|
||||
|
||||
:component-example{component="tabs-example-default-slot"}
|
||||
|
||||
### `icon` :u-badge{label="New" class="align-middle ml-2 !rounded-full" variant="subtle"}
|
||||
### `icon`
|
||||
|
||||
Use the `#icon` slot to customize the icon of the trigger buttons. You will have access to the `item`, `index`, `selected` and `disabled` in the slot scope.
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
"@nuxt/fonts": "^0.10.2",
|
||||
"@nuxt/image": "^1.8.1",
|
||||
"@nuxt/ui": "latest",
|
||||
"@nuxt/ui-pro": "npm:@nuxt/ui-pro-edge@1.4.4-28846941.4241122",
|
||||
"@nuxt/ui-pro": "^1.5.0",
|
||||
"@nuxtjs/plausible": "^1.0.3",
|
||||
"@octokit/rest": "^21.0.2",
|
||||
"@vueuse/nuxt": "^11.2.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"joi": "^17.13.3",
|
||||
"nuxt": "^3.14.0",
|
||||
"nuxt": "^3.14.159",
|
||||
"nuxt-cloudflare-analytics": "^1.0.8",
|
||||
"nuxt-component-meta": "^0.9.0",
|
||||
"nuxt-og-image": "^3.0.8",
|
||||
|
||||
@@ -98,7 +98,7 @@ const communityLinks = computed(() => [{
|
||||
const resourcesLinks = [{
|
||||
icon: 'i-simple-icons-figma',
|
||||
label: 'Figma Kit',
|
||||
to: 'https://www.figma.com/community/file/1288455405058138934',
|
||||
to: 'https://www.figma.com/community/file/1436401057300493073',
|
||||
target: '_blank'
|
||||
}, {
|
||||
label: 'Playground',
|
||||
|
||||
12
package.json
12
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@nuxt/ui",
|
||||
"description": "A UI Library for Modern Web Apps, powered by Vue & Tailwind CSS.",
|
||||
"version": "2.19.1",
|
||||
"version": "2.19.2",
|
||||
"packageManager": "pnpm@9.12.3",
|
||||
"repository": "nuxt/ui",
|
||||
"homepage": "https://ui.nuxt.com",
|
||||
@@ -35,8 +35,8 @@
|
||||
"@headlessui/tailwindcss": "^0.2.1",
|
||||
"@headlessui/vue": "^1.7.23",
|
||||
"@iconify-json/heroicons": "^1.2.1",
|
||||
"@nuxt/icon": "^1.6.1",
|
||||
"@nuxt/kit": "^3.14.0",
|
||||
"@nuxt/icon": "^1.7.2",
|
||||
"@nuxt/kit": "^3.14.159",
|
||||
"@nuxtjs/color-mode": "^3.5.2",
|
||||
"@nuxtjs/tailwindcss": "^6.12.2",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
@@ -64,7 +64,7 @@
|
||||
"eslint": "^9.14.0",
|
||||
"happy-dom": "^14.12.3",
|
||||
"joi": "^17.13.3",
|
||||
"nuxt": "^3.14.0",
|
||||
"nuxt": "^3.14.159",
|
||||
"release-it": "^17.10.0",
|
||||
"superstruct": "^2.0.2",
|
||||
"unbuild": "^2.0.0",
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxt/ui": "latest",
|
||||
"nuxt": "^3.14.0"
|
||||
"nuxt": "^3.14.159"
|
||||
}
|
||||
}
|
||||
|
||||
1661
pnpm-lock.yaml
generated
1661
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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 && ui.tr.active, row?.class]" @click="() => onSelect(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
|
||||
}
|
||||
@@ -363,6 +385,15 @@ export default defineComponent({
|
||||
$attrs.onSelect(row)
|
||||
}
|
||||
|
||||
function onContextmenu(event, row) {
|
||||
if (!$attrs.onContextmenu) {
|
||||
return
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
$attrs.onContextmenu(event, row)
|
||||
}
|
||||
|
||||
function selectAllRows() {
|
||||
// Create a new array to ensure reactivity
|
||||
const newSelected = [...selected.value]
|
||||
@@ -384,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)
|
||||
@@ -403,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],
|
||||
@@ -430,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,
|
||||
@@ -451,11 +496,14 @@ export default defineComponent({
|
||||
isSelected,
|
||||
onSort,
|
||||
onSelect,
|
||||
onContextmenu,
|
||||
onChange,
|
||||
getRowData,
|
||||
toggleOpened,
|
||||
getAriaSort,
|
||||
isExpanded
|
||||
isExpanded,
|
||||
shouldRenderColumnInFirstPlace,
|
||||
retriggerSlot
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { PropType } from 'vue'
|
||||
import { twMerge, twJoin } from 'tailwind-merge'
|
||||
import { useUI } from '../../composables/useUI'
|
||||
import { mergeConfig, getSlotsChildren } from '../../utils'
|
||||
import type { AvatarSize, Strategy } from '../../types/index'
|
||||
import type { AvatarSize, DeepPartial, Strategy } from '../../types/index'
|
||||
import UAvatar from './Avatar.vue'
|
||||
// @ts-expect-error
|
||||
import appConfig from '#build/app.config'
|
||||
@@ -32,7 +32,7 @@ export default defineComponent({
|
||||
default: () => ''
|
||||
},
|
||||
ui: {
|
||||
type: Object as PropType<Partial<typeof avatarGroupConfig> & { strategy?: Strategy }>,
|
||||
type: Object as PropType<DeepPartial<typeof avatarGroupConfig> & { strategy?: Strategy }>,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ import { twMerge, twJoin } from 'tailwind-merge'
|
||||
import { useUI } from '../../composables/useUI'
|
||||
import { mergeConfig, getSlotsChildren } from '../../utils'
|
||||
import { useProvideButtonGroup } from '../../composables/useButtonGroup'
|
||||
import type { ButtonSize, Strategy } from '../../types/index'
|
||||
import type { ButtonSize, DeepPartial, Strategy } from '../../types/index'
|
||||
// @ts-expect-error
|
||||
import appConfig from '#build/app.config'
|
||||
import { button, buttonGroup } from '#ui/ui.config'
|
||||
@@ -35,7 +35,7 @@ export default defineComponent({
|
||||
default: () => ''
|
||||
},
|
||||
ui: {
|
||||
type: Object as PropType<Partial<typeof buttonGroupConfig> & { strategy?: Strategy }>,
|
||||
type: Object as PropType<DeepPartial<typeof buttonGroupConfig> & { strategy?: Strategy }>,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ import { twJoin } from 'tailwind-merge'
|
||||
import UIcon from '../elements/Icon.vue'
|
||||
import { useUI } from '../../composables/useUI'
|
||||
import { mergeConfig, getSlotsChildren } from '../../utils'
|
||||
import type { Strategy, MeterSize } from '../../types/index'
|
||||
import type { DeepPartial, Strategy, MeterSize } from '../../types/index'
|
||||
import type Meter from './Meter.vue'
|
||||
// @ts-expect-error
|
||||
import appConfig from '#build/app.config'
|
||||
@@ -51,7 +51,7 @@ export default defineComponent({
|
||||
default: () => ''
|
||||
},
|
||||
ui: {
|
||||
type: Object as PropType<Partial<typeof meterGroupConfig> & { strategy?: Strategy }>,
|
||||
type: Object as PropType<DeepPartial<typeof meterGroupConfig> & { strategy?: Strategy }>,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -39,6 +39,18 @@
|
||||
<span v-if="label" :class="uiMenu.label">{{ label }}</span>
|
||||
<span v-else :class="uiMenu.label">{{ placeholder || ' ' }}</span>
|
||||
</slot>
|
||||
<span v-if="canClearValue" :class="clearableWrapperClass">
|
||||
<slot name="clearable" :selected="selected" :disabled="disabled" :loading="loading" @clear="onClear">
|
||||
<UButton
|
||||
:icon="clearableIcon"
|
||||
size="xs"
|
||||
class="p-0"
|
||||
:class="clearableButtonClass"
|
||||
variant="ghost"
|
||||
@click.capture.stop="onClear"
|
||||
/>
|
||||
</slot>
|
||||
</span>
|
||||
|
||||
<span v-if="(isTrailing && trailingIconName) || $slots.trailing" :class="trailingWrapperIconClass">
|
||||
<slot name="trailing" :selected="selected" :disabled="disabled" :loading="loading">
|
||||
@@ -71,7 +83,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]">
|
||||
@@ -140,6 +152,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'
|
||||
@@ -148,6 +161,7 @@ import { useFormGroup } from '../../composables/useFormGroup'
|
||||
import { get, mergeConfig } from '../../utils'
|
||||
import { useInjectButtonGroup } from '../../composables/useButtonGroup'
|
||||
import type { SelectSize, SelectColor, SelectVariant, PopperOptions, Strategy, DeepPartial } from '../../types/index'
|
||||
import type { Button } from '../../types/button'
|
||||
// @ts-expect-error
|
||||
import appConfig from '#build/app.config'
|
||||
import { select, selectMenu } from '#ui/ui.config'
|
||||
@@ -332,9 +346,18 @@ export default defineComponent({
|
||||
uiMenu: {
|
||||
type: Object as PropType<DeepPartial<typeof configMenu> & { strategy?: Strategy }>,
|
||||
default: () => ({})
|
||||
},
|
||||
clearable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
clearableIcon: {
|
||||
type: String,
|
||||
default: () => config.default.clerableIcon
|
||||
}
|
||||
|
||||
},
|
||||
emits: ['update:modelValue', 'update:query', 'open', 'close', 'change'],
|
||||
emits: ['update:modelValue', 'update:query', 'open', 'close', 'change', 'clear'],
|
||||
setup(props, { emit, slots }) {
|
||||
if (import.meta.dev && props.multiple && !Array.isArray(props.modelValue)) {
|
||||
console.warn(`[@nuxt/ui] The USelectMenu components needs to have a modelValue of type Array when using the multiple prop. Got '${typeof props.modelValue}' instead.`, props.modelValue)
|
||||
@@ -364,39 +387,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(() => {
|
||||
@@ -431,6 +468,23 @@ export default defineComponent({
|
||||
return props.leadingIcon || props.icon
|
||||
})
|
||||
|
||||
const canClearValue = computed(() => props.clearable && (Array.isArray(selected.value) ? selected.value.length > 0 : !!selected.value))
|
||||
|
||||
const clearableWrapperClass = computed(() => {
|
||||
return twJoin(
|
||||
ui.value.icon.clearable.wrapper,
|
||||
ui.value.icon.clearable.padding[size.value]
|
||||
)
|
||||
})
|
||||
|
||||
const clearableButtonClass = computed(() => {
|
||||
return twJoin(
|
||||
ui.value.icon.base,
|
||||
color.value && appConfig.ui.colors.includes(color.value) && ui.value.icon.color.replaceAll('{color}', color.value),
|
||||
props.loading && ui.value.icon.loading
|
||||
)
|
||||
})
|
||||
|
||||
const trailingIconName = computed(() => {
|
||||
if (props.loading && !isLeading.value) {
|
||||
return props.loadingIcon
|
||||
@@ -459,7 +513,6 @@ export default defineComponent({
|
||||
const trailingWrapperIconClass = computed(() => {
|
||||
return twJoin(
|
||||
ui.value.icon.trailing.wrapper,
|
||||
ui.value.icon.trailing.pointer,
|
||||
ui.value.icon.trailing.padding[size.value]
|
||||
)
|
||||
})
|
||||
@@ -534,7 +587,7 @@ export default defineComponent({
|
||||
return ['string', 'number'].includes(typeof props.modelValue) ? query.value : { [props.optionAttribute]: query.value }
|
||||
})
|
||||
|
||||
function clearOnClose() {
|
||||
function handleClearSearchOnClose() {
|
||||
if (props.clearSearchOnClose) {
|
||||
query.value = ''
|
||||
}
|
||||
@@ -544,7 +597,7 @@ export default defineComponent({
|
||||
if (value) {
|
||||
emit('open')
|
||||
} else {
|
||||
clearOnClose()
|
||||
handleClearSearchOnClose()
|
||||
emit('close')
|
||||
emitFormBlur()
|
||||
}
|
||||
@@ -564,6 +617,28 @@ export default defineComponent({
|
||||
query.value = event.target.value
|
||||
}
|
||||
|
||||
function onClear() {
|
||||
if (canClearValue.value) {
|
||||
emit('update:modelValue', props.multiple ? [] : null)
|
||||
emit('clear')
|
||||
emitFormChange()
|
||||
}
|
||||
}
|
||||
|
||||
function trailingSlotProps() {
|
||||
const slotProps: Record<string, any> = {
|
||||
selected: selected.value,
|
||||
loading: props.loading,
|
||||
disabled: props.disabled
|
||||
}
|
||||
|
||||
if (props.clearable) {
|
||||
slotProps.onClear = onClear
|
||||
}
|
||||
|
||||
return slotProps
|
||||
}
|
||||
|
||||
provideUseId(() => useId())
|
||||
|
||||
return {
|
||||
@@ -583,6 +658,7 @@ export default defineComponent({
|
||||
label,
|
||||
accessor,
|
||||
isLeading,
|
||||
onClear,
|
||||
isTrailing,
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
selectClass,
|
||||
@@ -597,7 +673,11 @@ export default defineComponent({
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
query,
|
||||
onUpdate,
|
||||
onQueryChange
|
||||
onQueryChange,
|
||||
trailingSlotProps,
|
||||
canClearValue,
|
||||
clearableWrapperClass,
|
||||
clearableButtonClass
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
1
src/runtime/types/button.d.ts
vendored
1
src/runtime/types/button.d.ts
vendored
@@ -26,4 +26,5 @@ export interface Button extends Link {
|
||||
leading?: boolean
|
||||
square?: boolean
|
||||
truncate?: boolean
|
||||
target?: string
|
||||
}
|
||||
|
||||
@@ -98,6 +98,18 @@ export default {
|
||||
'lg': 'px-3.5',
|
||||
'xl': 'px-3.5'
|
||||
}
|
||||
},
|
||||
clearable: {
|
||||
wrapper: 'absolute inset-y-0 end-6 flex items-center',
|
||||
pointer: 'pointer-events-auto',
|
||||
padding: {
|
||||
'2xs': 'px-2',
|
||||
'xs': 'px-2.5',
|
||||
'sm': 'px-2.5',
|
||||
'md': 'px-3',
|
||||
'lg': 'px-3.5',
|
||||
'xl': 'px-3.5'
|
||||
}
|
||||
}
|
||||
},
|
||||
default: {
|
||||
|
||||
@@ -9,6 +9,7 @@ export default {
|
||||
color: 'white',
|
||||
variant: 'outline',
|
||||
loadingIcon: 'i-heroicons-arrow-path-20-solid',
|
||||
trailingIcon: 'i-heroicons-chevron-down-20-solid'
|
||||
trailingIcon: 'i-heroicons-chevron-down-20-solid',
|
||||
clerableIcon: 'i-heroicons-x-mark-20-solid'
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user