Compare commits

..

29 Commits

Author SHA1 Message Date
rdjanuar
21939ed333 docs(SelectMenu): add section customization on clearable prop 2024-11-12 19:04:42 +07:00
rdjanuar
5a414eb55a feat(SelectMenu): add clearble 2024-11-12 18:47:39 +07:00
kyyy
3a5960fb58 Merge branch 'dev' into issue-1057 2024-11-12 17:17:46 +07:00
kyyy
acecff40ec fix(Form): use parsed value from joi instead of original state (#2587) 2024-11-11 19:29:46 +01:00
renovate[bot]
1fd5fac295 chore(deps): lock file maintenance (dev) (#2595)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-11 18:45:14 +01:00
kyyy
b23f2decfc fix(Table): data outdated when rows change (#2600) 2024-11-11 18:44:49 +01:00
kyyy
7154254ac2 fix(InputMenu/SelectMenu): use by prop to compare objects & support dot notation in value-attribute (#2566) 2024-11-10 19:44:20 +01:00
Benjamin Canac
49f85d55c5 chore(deps): set nuxt resolution to 3.13.2
Causes some `EMFILE: too many open files` errors
2024-11-10 18:19:48 +01:00
kyyy
97037864b3 fix(Table): prevent onClick while blocking element (#2592) 2024-11-10 16:59:34 +01:00
renovate[bot]
0abccabc26 chore(deps): update dependency @nuxt/icon to ^1.7.2 (dev) (#2591)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-10 14:45:02 +01:00
kyyy
ac323c4ccc feat(Table): add custom @select:all event (#2581) 2024-11-09 18:48:52 +01:00
kyyy
d4e408cfd8 fix(Notification): element renders even when no notification is present (#2561) 2024-11-09 11:24:13 +01:00
renovate[bot]
f3bf69c233 chore(deps): update dependency @nuxt/icon to ^1.7.0 (dev) (#2575)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-08 18:03:26 +01:00
kyyy
d6daf466ac feat(Table): allow dynamically render checkbox (#2549)
Co-authored-by: Benjamin Canac <canacb1@gmail.com>
2024-11-08 17:24:41 +01:00
kyyy
6e66990372 fix(Table): missing type on props loadingState (#2551) 2024-11-08 09:46:00 +01:00
rdjanuar
a78203ce49 up 2024-11-08 14:30:53 +07:00
rdjanuar
592da565fe feat(SelectMenu): add clearble 2024-11-08 14:02:54 +07:00
Benjamin Canac
56e28d80db docs: update figma links 2024-11-07 18:21:51 +01:00
renovate[bot]
24e61ccc8b chore(deps): update nuxt framework to ^3.14.159 (dev) (#2546)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-06 19:15:32 +01:00
Norman Feiß
c9e6256e7f feat(Table): add contextmenu handling to table rows (#2283) 2024-11-06 19:12:51 +01:00
Malik-Jouda
ce955d24f1 fix(date-picker): undefined dayIndex (#2545) 2024-11-06 12:25:27 +01:00
Snack
bf580863af fix(AvatarGroup/ButtonGroup/MeterGroup): allow deeply partial ui config (#2542) 2024-11-06 10:27:08 +01:00
Benjamin Canac
f38a217032 docs(deps): update @nuxt/ui-pro 2024-11-05 21:19:14 +01:00
Benjamin Canac
717a027bad docs: remove old badges 2024-11-05 21:18:09 +01:00
Benjamin Canac
159acd664c chore(release): v2.19.2 2024-11-05 19:36:54 +01:00
Benjamin Canac
212f7df35b fix(Button): put back target override 2024-11-05 19:26:37 +01:00
Benjamin Canac
d0d37a06d2 chore(release): v2.19.1 2024-11-05 18:06:03 +01:00
Benjamin Canac
cb6f5f2d71 fix(InputMenu/SelectMenu): regex breaks build 2024-11-05 17:57:49 +01:00
Benjamin Canac
22da1a839a docs(date-picker): improve component
Resolves #2082
2024-11-05 17:35:49 +01:00
32 changed files with 1838 additions and 782 deletions

View File

@@ -1,5 +1,17 @@
# 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
* **InputMenu/SelectMenu:** regex breaks build ([cb6f5f2](https://github.com/nuxt/ui/commit/cb6f5f2d71ea8bb526a8f958daec8e9871469b63))
## [2.19.0](https://github.com/nuxt/ui/compare/v2.18.7...v2.19.0) (2024-11-05)
### Features

View File

@@ -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)

View File

@@ -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',

View 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>

View File

@@ -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>

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,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>

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

@@ -1,5 +1,4 @@
<script setup lang="ts">
import { useBreakpoints, breakpointsTailwind } from '@vueuse/core'
import { DatePicker as VCalendarDatePicker } from 'v-calendar'
// @ts-ignore
import type { DatePickerDate, DatePickerRangeObject } from 'v-calendar/dist/types/src/use/datePicker'
@@ -26,10 +25,6 @@ const date = computed({
}
})
const breakpoints = useBreakpoints(breakpointsTailwind)
const smallerThanSm = breakpoints.smaller('sm')
const attrs = {
'transparent': true,
'borderless': true,
@@ -37,11 +32,27 @@ 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>
<VCalendarDatePicker v-if="date && (date as DatePickerRangeObject)?.start && (date as DatePickerRangeObject)?.end" v-model.range="date" :columns="smallerThanSm ? 1 : 2" :rows="smallerThanSm ? 2 : 1" v-bind="{ ...attrs, ...$attrs }" />
<VCalendarDatePicker v-else v-model="date" v-bind="{ ...attrs, ...$attrs }" />
<VCalendarDatePicker
v-if="date && (date as DatePickerRangeObject)?.start && (date as DatePickerRangeObject)?.end"
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>
<style>

View File

@@ -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

View File

@@ -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.

View File

@@ -38,9 +38,14 @@ The following example is styled based on the `primary` and `gray` colors and sup
```vue [components/DatePicker.vue]
<script setup lang="ts">
import { DatePicker as VCalendarDatePicker } from 'v-calendar'
// @ts-ignore
import type { DatePickerDate, DatePickerRangeObject } from 'v-calendar/dist/types/src/use/datePicker'
import 'v-calendar/dist/style.css'
defineOptions({
inheritAttrs: false
})
const props = defineProps({
modelValue: {
type: [Date, Object] as PropType<DatePickerDate | DatePickerRangeObject | null>,
@@ -59,17 +64,33 @@ const date = computed({
})
const attrs = {
transparent: true,
borderless: true,
color: 'primary',
'transparent': true,
'borderless': true,
'color': 'primary',
'is-dark': { selector: 'html', darkClass: 'dark' },
'first-day-of-week': 2,
'first-day-of-week': 2
}
function onDayClick(_: any, event: MouseEvent): void {
const target = event.target as HTMLElement
target.blur()
}
</script>
<template>
<VCalendarDatePicker v-if="date && (typeof date === 'object')" v-model.range="date" :columns="2" v-bind="{ ...attrs, ...$attrs }" />
<VCalendarDatePicker v-else v-model="date" v-bind="{ ...attrs, ...$attrs }" />
<VCalendarDatePicker
v-if="date && (date as DatePickerRangeObject)?.start && (date as DatePickerRangeObject)?.end"
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>
<style>

View File

@@ -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.

View File

@@ -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).

View File

@@ -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.

View File

@@ -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.

View File

@@ -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",

View File

@@ -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',

View File

@@ -1,7 +1,7 @@
{
"name": "@nuxt/ui",
"description": "A UI Library for Modern Web Apps, powered by Vue & Tailwind CSS.",
"version": "2.19.0",
"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"
}
}

View File

@@ -9,6 +9,6 @@
},
"dependencies": {
"@nuxt/ui": "latest",
"nuxt": "^3.14.0"
"nuxt": "^3.14.159"
}
}

1661
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 && 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
}
}
})

View File

@@ -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: () => ({})
}
},

View File

@@ -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: () => ({})
}
},

View File

@@ -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: () => ({})
}
},

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)
@@ -402,7 +423,7 @@ export default defineComponent({
})
function escapeRegExp(string: string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
return string.replace(/[.*+?^${}()|[\]\\]/g, match => `\\${match}`)
}
const filteredOptions = computed(() => {

View File

@@ -39,6 +39,18 @@
<span v-if="label" :class="uiMenu.label">{{ label }}</span>
<span v-else :class="uiMenu.label">{{ placeholder || '&nbsp;' }}</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]
)
})
@@ -486,7 +539,7 @@ export default defineComponent({
})
function escapeRegExp(string: string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
return string.replace(/[.*+?^${}()|[\]\\]/g, match => `\\${match}`)
}
function accessor<T extends Record<string, any>>(obj: T, key: string) {
@@ -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
}
}
})

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"

View File

@@ -26,4 +26,5 @@ export interface Button extends Link {
leading?: boolean
square?: boolean
truncate?: boolean
target?: string
}

View File

@@ -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: {

View File

@@ -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'
}
}