Compare commits

...

51 Commits

Author SHA1 Message Date
Benjamin Canac
b6736d1efd chore(release): v2.17.0 2024-06-13 11:24:50 +02:00
Benjamin Canac
f6e695ffc8 chore(deps): update 2024-06-13 11:15:33 +02:00
Benjamin Canac
e8898d15a6 fix(Alert/Notification): use div for description
Resolves #1551
2024-06-13 11:15:27 +02:00
Thibault Vlacich
f65aefb706 fix(Alert): base style not applied on icon (#1859)
Co-authored-by: Benjamin Canac <canacb1@gmail.com>
2024-06-12 10:25:18 +02:00
Benjamin Canac
9b9ccdb59e fix(SelectMenu): wrong placeholder color when modelValue is an empty string
Resolves #1862
2024-06-12 10:11:16 +02:00
renovate[bot]
688232215d chore(deps): update nuxt framework to ^3.12.1 (#1861)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-06-11 17:13:37 +02:00
Benjamin Canac
ebfb835033 fix(Breadcrumb): allow aria-current to be overrideable
Related to #1856
2024-06-11 12:27:44 +02:00
renovate[bot]
838d6c832f chore(deps): update nuxt framework to ^3.12.0 (#1860)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-06-11 12:15:16 +02:00
eduardo-faith
e7c2f7856c fix(Input): hide wrapper when type is hidden (#1797)
Co-authored-by: Benjamin Canac <canacb1@gmail.com>
2024-06-11 11:58:49 +02:00
Fabian Hiller
1d5bd89d58 feat(Form): update and migrate valibot to v0.31.0 (#1848)
Co-authored-by: Romain Hamel <romain@boilr.io>
Co-authored-by: Benjamin Canac <canacb1@gmail.com>
2024-06-11 11:40:44 +02:00
Benjamin Canac
6c124bb1ac fix(Select): remove defaults for value and text
Resolves #1702
2024-06-06 10:51:34 +02:00
Benjamin Canac
49174b7628 chore(deps): update @​egoist/tailwindcss-icons
Resolves #1840
2024-06-06 10:44:50 +02:00
Vitta
50ad14f9df feat(Slideover): handle top and bottom side (#1834) 2024-06-05 10:30:45 +02:00
Vitta
6e2678d1d8 feat(Tabs): add content prop to avoid the render of the HTML markup (#1831)
Co-authored-by: Benjamin Canac <canacb1@gmail.com>
2024-06-04 10:23:17 +02:00
Khaled Oghli
831c560a96 docs(slideover): add close button in some examples for mobile (#1827)
Co-authored-by: Benjamin Canac <canacb1@gmail.com>
2024-06-03 15:30:45 +02:00
Romain Hamel
06990beabf fix(Form): maintain other errors when using setErrors with a path (#1818)
Co-authored-by: Benjamin Canac <canacb1@gmail.com>
2024-06-03 13:59:26 +02:00
networdai
3ebff4d133 feat(Notification): allow ring customization with {color} (#1830)
Co-authored-by: Benjamin Canac <canacb1@gmail.com>
2024-06-03 11:45:36 +02:00
Khaled Oghli
d66cfa9d7d docs(table): ensure scroll and pagination visibility on mobile (#1828)
Co-authored-by: Benjamin Canac <canacb1@gmail.com>
2024-06-03 11:44:46 +02:00
Benjamin Canac
75c0d9e31f chore(deps): update 2024-06-03 11:40:39 +02:00
Benjamin Canac
6033872ef8 chore(deps): pin vue-tsc 2024-06-03 11:04:18 +02:00
Benjamin Canac
838cb7212a docs(alert): remove missing example 2024-06-03 11:00:42 +02:00
John Tanz
c8dd71c4f5 feat(Alert): add actions slot (#1785)
Co-authored-by: Benjamin Canac <canacb1@gmail.com>
2024-05-28 12:29:10 +02:00
Mukund Shah
4f0d00f7a6 fix(Carousel): prevent mouse click when dragging (#1781) 2024-05-15 12:18:51 +02:00
Benjamin Canac
3b975634e8 chore(package): define packageManager 2024-05-14 15:13:11 +02:00
Benjamin Canac
249bbd49dc fix(CommandPalette): hide empty-state when null
Resolves #1787
2024-05-14 14:57:19 +02:00
Milos Dimitrijevic
3c1602af37 docs(form): fix link to /form-group (#1777) 2024-05-10 16:29:22 +02:00
Benjamin Canac
e1ca6e0cde chore(deps): update @headlessui/vue
Resolves #1760
2024-05-10 11:41:53 +02:00
Benjamin Canac
3b3bd16afe docs(deps): update @nuxt/ui-pro 2024-05-10 11:25:45 +02:00
Benjamin Canac
fab9cbebd8 docs(context-menu): add missing @vueuse/core imports
Resolves #1762
2024-05-10 11:10:00 +02:00
Benjamin Canac
581b470cc7 fix(Link): typo in exactHash type
Resolves #1767
2024-05-09 11:38:39 +02:00
Benjamin Canac
24d30cd1f3 chore(deps): pin @headlessui/vue to 1.7.20
Resolves #1760
2024-05-09 11:38:10 +02:00
Benjamin Canac
cc52bffccf chore(release): v2.16.0 2024-05-07 14:19:28 +02:00
renovate[bot]
eb2601d4da chore(deps): update all non-major dependencies (#1752)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Benjamin Canac <canacb1@gmail.com>
2024-05-07 14:16:58 +02:00
renovate[bot]
f726b5f094 chore(deps): update all non-major dependencies (#1719)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Benjamin Canac <canacb1@gmail.com>
2024-05-06 17:00:48 +02:00
guylil
37ce62acb9 docs(FormGroup): incorrect icon in description slot example (#1749) 2024-05-06 12:22:48 +02:00
Romain Hamel
f97b728968 docs(Form): clarify when the @error event is triggered (#1747) 2024-05-06 10:39:05 +02:00
chenying
7e6ba78681 feat(InputMenu/SelectMenu): allow lazy search (#1705)
Co-authored-by: chenying <chenying@addcn.com>
Co-authored-by: Benjamin Canac <canacb1@gmail.com>
2024-04-26 15:43:29 +02:00
Benjamin Canac
ed5c74dc17 fix(Input)!: redesign file type without absolute positioning (#1712) 2024-04-25 17:12:06 +02:00
Benjamin Canac
bb3ea40218 chore(deps): remove resolutions + update 2024-04-25 13:00:16 +02:00
Inesh Bose
821e15b696 feat(module): HMR support with @nuxtjs/tailwindcss (#1665)
Co-authored-by: Benjamin Canac <canacb1@gmail.com>
2024-04-24 12:20:10 +02:00
Eugen Istoc
bd3fa8658f fix(Slideover): export and clean types (#1692)
Co-authored-by: Benjamin Canac <canacb1@gmail.com>
2024-04-22 11:13:34 +02:00
Moritz
82d619b2a7 feat(useToast): allow clearing all notifications (#1695) 2024-04-22 11:01:48 +02:00
Neil Richter
8d9d9736ba feat(Pagination): allow using links for pagination buttons (#1682) 2024-04-18 12:38:48 +02:00
Neil Richter
3fca66857d feat(Table): allow providing a <caption> (#1680) 2024-04-16 16:39:52 +02:00
renovate[bot]
4853520eb3 chore(deps): update devdependency @nuxt/test-utils to ^3.12.1 (#1677)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-16 11:11:35 +02:00
Benjamin Canac
5481dab53d fix(Breadcrumb): pass click event to ULink 2024-04-16 10:36:45 +02:00
Neil Richter
6f60fa9a98 fix(Table): provide aria-sort for sortable table headings (#1675) 2024-04-15 18:13:01 +02:00
Neil Richter
cba9ad78db fix(Notification): update timer when timeout prop changes (#1673) 2024-04-15 16:38:07 +02:00
Damian Głowala
bbc8f4e6ac chore(README): update installation section (#1659) 2024-04-15 10:28:51 +02:00
Neil Richter
ed3a3babdb feat(useToast): allow updating an existing notification (#1668) 2024-04-15 10:27:39 +02:00
renovate[bot]
4415d4111e chore(deps): update devdependency @nuxt/ui-pro to v1.1.0-28548740.d20325b (#1658)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-12 19:12:53 +02:00
53 changed files with 13458 additions and 10049 deletions

View File

@@ -1,5 +1,53 @@
# Changelog
## [2.17.0](https://github.com/nuxt/ui/compare/v2.16.0...v2.17.0) (2024-06-13)
### Features
* **Alert:** add `actions` slot ([#1785](https://github.com/nuxt/ui/issues/1785)) ([c8dd71c](https://github.com/nuxt/ui/commit/c8dd71c4f5a5239811b07b50f1dc802101af07d5))
* **Form:** update and migrate `valibot` to v0.31.0 ([#1848](https://github.com/nuxt/ui/issues/1848)) ([1d5bd89](https://github.com/nuxt/ui/commit/1d5bd89d5881163fc6dc917e138b9d8304dff6c4))
* **Notification:** allow ring customization with `{color}` ([#1830](https://github.com/nuxt/ui/issues/1830)) ([3ebff4d](https://github.com/nuxt/ui/commit/3ebff4d133372e339e2c4c439576e9e192b29cc3))
* **Slideover:** handle `top` and `bottom` side ([#1834](https://github.com/nuxt/ui/issues/1834)) ([50ad14f](https://github.com/nuxt/ui/commit/50ad14f9dffe4f76bef888cd10d30b417c75bca5))
* **Tabs:** add `content` prop to avoid the render of the HTML markup ([#1831](https://github.com/nuxt/ui/issues/1831)) ([6e2678d](https://github.com/nuxt/ui/commit/6e2678d1d8a498322eb3eff909ccbba55e40a2b7))
### Bug Fixes
* **Alert/Notification:** use `div` for description ([e8898d1](https://github.com/nuxt/ui/commit/e8898d15a667ba66e78828315e3cc4e92845cd3f)), closes [#1551](https://github.com/nuxt/ui/issues/1551)
* **Alert:** base style not applied on icon ([#1859](https://github.com/nuxt/ui/issues/1859)) ([f65aefb](https://github.com/nuxt/ui/commit/f65aefb7067c1c64c1355b5d699129e716ef1281))
* **Breadcrumb:** allow `aria-current` to be overrideable ([ebfb835](https://github.com/nuxt/ui/commit/ebfb8350339725c0a6f88c73f16bff01d61538c2)), closes [#1856](https://github.com/nuxt/ui/issues/1856)
* **Carousel:** prevent mouse click when dragging ([#1781](https://github.com/nuxt/ui/issues/1781)) ([4f0d00f](https://github.com/nuxt/ui/commit/4f0d00f7a6eebf05adceaf1e7c2869ad91949cf3))
* **CommandPalette:** hide `empty-state` when `null` ([249bbd4](https://github.com/nuxt/ui/commit/249bbd49dc8420603e8d561543d237abeb400908)), closes [#1787](https://github.com/nuxt/ui/issues/1787)
* **Form:** maintain other errors when using `setErrors` with a path ([#1818](https://github.com/nuxt/ui/issues/1818)) ([06990be](https://github.com/nuxt/ui/commit/06990beabf67f668322b4d3fb2ec93cc4f3bdcd4))
* **Input:** hide wrapper when type is `hidden` ([#1797](https://github.com/nuxt/ui/issues/1797)) ([e7c2f78](https://github.com/nuxt/ui/commit/e7c2f7856c05ed96f48c83d64d8e1d3f41ab58fe))
* **Link:** typo in `exactHash` type ([581b470](https://github.com/nuxt/ui/commit/581b470cc79c2315bb2d56e02a7c134a7861c616)), closes [#1767](https://github.com/nuxt/ui/issues/1767)
* **SelectMenu:** wrong placeholder color when `modelValue` is an empty string ([9b9ccdb](https://github.com/nuxt/ui/commit/9b9ccdb59e98fed096dd18809af646b10de46b9f)), closes [#1862](https://github.com/nuxt/ui/issues/1862)
* **Select:** remove defaults for `value` and `text` ([6c124bb](https://github.com/nuxt/ui/commit/6c124bb1ac2fef116161da56a3a8e5f92144ce3a)), closes [#1702](https://github.com/nuxt/ui/issues/1702)
## [2.16.0](https://github.com/nuxt/ui/compare/v2.15.2...v2.16.0) (2024-05-07)
### ⚠ BREAKING CHANGES
* **Input:** redesign `file` type without absolute positioning (#1712)
### Features
* **InputMenu/SelectMenu:** allow lazy search ([#1705](https://github.com/nuxt/ui/issues/1705)) ([7e6ba78](https://github.com/nuxt/ui/commit/7e6ba786816516ab5007a2ff15fc974cfdd796ab))
* **module:** HMR support with `@nuxtjs/tailwindcss` ([#1665](https://github.com/nuxt/ui/issues/1665)) ([821e15b](https://github.com/nuxt/ui/commit/821e15b696b03d0f5e20e001d39f86a8b3cec426))
* **Table:** allow providing a `<caption>` ([#1680](https://github.com/nuxt/ui/issues/1680)) ([3fca668](https://github.com/nuxt/ui/commit/3fca66857d3616bf24a1b0579c90179a7883869d))
* **useToast:** allow clearing all notifications ([#1695](https://github.com/nuxt/ui/issues/1695)) ([82d619b](https://github.com/nuxt/ui/commit/82d619b2a75b9d08f3f5b314d37c30d77d8341e9))
### Bug Fixes
* **Breadcrumb:** pass `click` event to `ULink` ([5481dab](https://github.com/nuxt/ui/commit/5481dab53dbe0b28188b4a16811f3e8816d93edf))
* **Input:** redesign `file` type without absolute positioning ([#1712](https://github.com/nuxt/ui/issues/1712)) ([ed5c74d](https://github.com/nuxt/ui/commit/ed5c74dc17df784485eabc39c83e62ada9210a49))
* **Notification:** update timer when timeout prop changes ([#1673](https://github.com/nuxt/ui/issues/1673)) ([cba9ad7](https://github.com/nuxt/ui/commit/cba9ad78db58cb9228bb9c96f0469d43bde2bf3e))
* **Slideover:** export and clean types ([#1692](https://github.com/nuxt/ui/issues/1692)) ([bd3fa86](https://github.com/nuxt/ui/commit/bd3fa8658f84fb7bd96d322968462c5eaa987b86))
* **Table:** provide `aria-sort` for sortable table headings ([#1675](https://github.com/nuxt/ui/issues/1675)) ([6f60fa9](https://github.com/nuxt/ui/commit/6f60fa9a980020f6a5afc2916e699a7f9a47e8ce))
## [2.15.2](https://github.com/nuxt/ui/compare/v2.15.1...v2.15.2) (2024-04-12)

View File

@@ -30,16 +30,6 @@ Read more on [ui.nuxt.com](https://ui.nuxt.com)
npx nuxi@latest module add ui
```
Then, register the module in your `nuxt.config.ts`:
```js
export default defineNuxtConfig({
modules: [
'@nuxt/ui'
]
})
```
If you want latest updates, please use `@nuxt/ui-edge` in your `package.json`:
```json

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import { useMouse, useWindowScroll } from '@vueuse/core'
const { x, y } = useMouse()
const { y: windowY } = useWindowScroll()

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import { useMouse, useWindowScroll } from '@vueuse/core'
const { x, y } = useMouse()
const { y: windowY } = useWindowScroll()

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import { useMouse, useWindowScroll } from '@vueuse/core'
const { x, y } = useMouse()
const { y: windowY } = useWindowScroll()

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import { useMouse, useWindowScroll } from '@vueuse/core'
const { x, y } = useMouse()
const { y: windowY } = useWindowScroll()

View File

@@ -1,13 +1,13 @@
<script setup lang="ts">
import { string, objectAsync, email, minLength, type Input } from 'valibot'
import * as v from 'valibot'
import type { FormSubmitEvent } from '#ui/types'
const schema = objectAsync({
email: string([email('Invalid email')]),
password: string([minLength(8, 'Must be at least 8 characters')])
const schema = v.object({
email: v.pipe(v.string(), v.email('Invalid email')),
password: v.pipe(v.string(), v.minLength(8, 'Must be at least 8 characters'))
})
type Schema = Input<typeof schema>
type Schema = v.InferOutput<typeof schema>
const state = reactive({
email: '',
@@ -21,7 +21,7 @@ async function onSubmit (event: FormSubmitEvent<Schema>) {
</script>
<template>
<UForm :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
<UForm :schema="v.safeParser(schema)" :state="state" class="space-y-4" @submit="onSubmit">
<UFormGroup label="Email" name="email">
<UInput v-model="state.email" />
</UFormGroup>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
const page = ref(1)
const items = ref(Array(50))
</script>
<template>
<UPagination
v-model="page"
:page-count="5"
:total="items.length"
:to="(page: number) => ({
query: { page },
// Hash is specified here to prevent the page from scrolling to the top
hash: '#links',
})"
/>
</template>

View File

@@ -8,6 +8,16 @@ const isOpen = ref(false)
<USlideover v-model="isOpen">
<div class="p-4 flex-1">
<UButton
color="gray"
variant="ghost"
size="sm"
icon="i-heroicons-x-mark-20-solid"
class="flex sm:hidden absolute end-5 top-5 z-10"
square
padded
@click="isOpen = false"
/>
<Placeholder class="h-full" />
</div>
</USlideover>

View File

@@ -7,8 +7,22 @@ const isOpen = ref(false)
<UButton label="Open" @click="isOpen = true" />
<USlideover v-model="isOpen">
<UCard class="flex flex-col flex-1" :ui="{ body: { base: 'flex-1' }, ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }">
<UCard
class="flex flex-col flex-1"
:ui="{ body: { base: 'flex-1' }, ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }"
>
<template #header>
<UButton
color="gray"
variant="ghost"
size="sm"
icon="i-heroicons-x-mark-20-solid"
class="flex sm:hidden absolute end-5 top-5 z-10"
square
padded
@click="isOpen = false"
/>
<Placeholder class="h-8" />
</template>

View File

@@ -8,6 +8,17 @@ const isOpen = ref(false)
<USlideover v-model="isOpen" :overlay="false">
<div class="p-4 flex-1">
<UButton
color="gray"
variant="ghost"
size="sm"
icon="i-heroicons-x-mark-20-solid"
class="flex sm:hidden absolute end-5 top-5 z-10"
square
padded
@click="isOpen = false"
/>
<Placeholder class="h-full" />
</div>
</USlideover>

View File

@@ -8,6 +8,17 @@ const isOpen = ref(false)
<USlideover v-model="isOpen" :transition="false">
<div class="p-4 flex-1">
<UButton
color="gray"
variant="ghost"
size="sm"
icon="i-heroicons-x-mark-20-solid"
class="flex sm:hidden absolute end-5 top-5 z-10"
square
padded
@click="isOpen = false"
/>
<Placeholder class="h-full" />
</div>
</USlideover>

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
const columns = [{
key: 'name',
label: 'Name'
}, {
key: 'title',
label: 'Title'
}, {
key: 'email',
label: 'Email'
}, {
key: 'role',
label: 'Role'
}, {
key: 'actions'
}]
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'
}]
</script>
<template>
<UTable :rows="people" :columns="columns">
<template #caption>
<caption>Employees of ACME</caption>
</template>
</UTable>
</template>

View File

@@ -175,6 +175,10 @@ Use the `#avatar` slot to customize the displayable avatar.
:component-example{component="alert-example-avatar"}
### `actions` :u-badge{label="New" class="align-middle ml-2 !rounded-full" variant="subtle"}
Use the `#actions` slot to add custom user interaction elements.
## Props
:component-props

View File

@@ -196,7 +196,7 @@ Use the `#description` slot to set the custom content for description.
::component-card
---
slots:
description: Write only valid email address <UIcon name="i-heroicons-arrow-right-20-solid" />
description: Write only valid email address <UIcon name="i-heroicons-information-circle" />
default: <UInput model-value="benjamincanac" placeholder="you@example.com" />
props:
label: 'Email'

View File

@@ -10,7 +10,7 @@ links:
Use the Form component to validate form data using schema libraries such as [Yup](https://github.com/jquense/yup), [Zod](https://github.com/colinhacks/zod), [Joi](https://github.com/hapijs/joi), [Valibot](https://valibot.dev/), or your own validation logic.
It works with the [FormGroup](/components/input) component to display error messages around form elements automatically.
It works with the [FormGroup](/components/form-group) component to display error messages around form elements automatically.
The form component requires two props:
- `state` - a reactive object holding the form's state.
@@ -63,7 +63,7 @@ The validation function must return a list of errors with the following attribut
- `path` - Path to the form element corresponding to the `name` attribute.
::callout{icon="i-heroicons-light-bulb"}
Note: this can be used alongside the `schema` prop to handle complex use cases.
Note that it can be used alongside the `schema` prop to handle complex use cases.
::
::component-example
@@ -184,13 +184,13 @@ Take a look at the component!
## Error event
You can listen to the `@error` event to handle errors. This event is triggered when the form is validated and contains an array of `FormError` objects with the following fields:
You can listen to the `@error` event to handle errors. This event is triggered when the form is submitted and contains an array of `FormError` objects with the following fields:
- `id` - the identifier of the form element.
- `path` - the path to the form element matching the `name`.
- `message` - the error message to display.
Here is an example of how to focus the first form element with an error:
Here's an example that focuses the first input element with an error after the form is submitted:
::component-example
---

View File

@@ -130,6 +130,8 @@ Pass a function to the `search` prop to customize the search behavior and filter
Use the `debounce` prop to adjust the delay of the function.
Use the `searchLazy` prop to control the immediacy of data requests. :u-badge{label="New" class="!rounded-full" variant="subtle"}
::component-example
---
component: 'input-menu-example-search-async'

View File

@@ -75,6 +75,8 @@ Some types have been implemented in their own components, such as [Checkbox](/co
::component-card
---
baseProps:
icon: 'i-heroicons-folder'
props:
type: 'file'
size: sm

View File

@@ -46,6 +46,12 @@ props:
---
::
### Links
Use the `to` property to transform buttons into links. Note that it must be a function that receives the page number and returns a route destination.
:component-example{component="pagination-example-to"}
### Disabled
Use the `disabled` prop to disable all the buttons.

View File

@@ -150,6 +150,8 @@ Pass a function to the `searchable` prop to customize the search behavior and fi
Use the `debounce` prop to adjust the delay of the function.
Use the `searchableLazy` prop to control the immediacy of data requests. :u-badge{label="New" class="!rounded-full" variant="subtle"}
::component-example
---
component: 'select-menu-example-search-async'

View File

@@ -49,7 +49,7 @@ extraClass: 'overflow-hidden'
padding: false
component: 'table-example-columns-selectable'
componentProps:
class: 'flex-1'
class: 'flex-1 flex-col overflow-hidden'
---
::
@@ -282,7 +282,7 @@ extraClass: 'overflow-hidden'
padding: false
component: 'table-example-searchable'
componentProps:
class: 'flex-1'
class: 'flex-1 flex-col overflow-hidden'
---
::
@@ -296,7 +296,7 @@ extraClass: 'overflow-hidden'
padding: false
component: 'table-example-paginable'
componentProps:
class: 'flex-1'
class: 'flex-1 flex-col overflow-hidden'
---
::
@@ -450,6 +450,19 @@ componentProps:
---
::
### `caption`
Use the `#caption` slot to customize the table's caption.
::component-example
---
padding: false
component: 'table-example-caption-slot'
componentProps:
class: 'flex-1'
---
::
## Props
:component-props

View File

@@ -63,6 +63,8 @@ componentProps:
---
::
You can use the `content` prop and set it to `false` to avoid the rendering of the HTML content if you don't need it.
### Control the selected index
Use a `v-model` to control the selected index.

View File

@@ -1,7 +1,7 @@
import { createResolver } from '@nuxt/kit'
import colors from 'tailwindcss/colors'
import module from '../src/module'
import { excludeColors } from '../src/colors'
import { excludeColors } from '../src/runtime/utils/colors'
import pkg from '../package.json'
const { resolve } = createResolver(import.meta.url)

View File

@@ -3,32 +3,30 @@
"private": true,
"type": "module",
"dependencies": {
"@nuxt/ui": "workspace:latest"
},
"devDependencies": {
"@iconify-json/heroicons": "^1.1.20",
"@iconify-json/simple-icons": "^1.1.99",
"@iconify-json/heroicons": "^1.1.21",
"@iconify-json/simple-icons": "^1.1.105",
"@nuxt/content": "^2.12.1",
"@nuxt/eslint-config": "^0.3.6",
"@nuxt/fonts": "^0.6.1",
"@nuxt/image": "^1.5.0",
"@nuxt/ui-pro": "npm:@nuxt/ui-pro-edge@1.1.0-28546155.4b9828b",
"@nuxt/eslint-config": "^0.3.13",
"@nuxt/fonts": "^0.7.0",
"@nuxt/image": "^1.7.0",
"@nuxt/ui": "latest",
"@nuxt/ui-pro": "npm:@nuxt/ui-pro-edge@1.2.0-28637819.42c6d9b",
"@nuxtjs/plausible": "^1.0.0",
"@octokit/rest": "^20.1.0",
"@vueuse/nuxt": "^10.9.0",
"@octokit/rest": "^20.1.1",
"@vueuse/nuxt": "^10.11.0",
"date-fns": "^3.6.0",
"eslint": "^8.57.0",
"joi": "^17.12.3",
"nuxt": "^3.11.2",
"joi": "^17.13.1",
"nuxt": "^3.12.1",
"nuxt-cloudflare-analytics": "^1.0.8",
"nuxt-component-meta": "^0.6.3",
"nuxt-component-meta": "^0.6.4",
"nuxt-og-image": "^2.2.4",
"prettier": "^3.2.5",
"prettier": "^3.3.2",
"typescript": "^5.4.5",
"ufo": "^1.5.3",
"v-calendar": "^3.1.2",
"valibot": "^0.30.0",
"valibot": "^0.31.1",
"yup": "^1.4.0",
"zod": "^3.22.4"
"zod": "^3.23.8"
}
}

View File

@@ -1,6 +1,7 @@
{
"name": "@nuxt/ui",
"version": "2.15.2",
"version": "2.17.0",
"packageManager": "pnpm@9.1.1",
"repository": "nuxt/ui",
"homepage": "https://ui.nuxt.com",
"type": "module",
@@ -33,55 +34,54 @@
"test": "vitest"
},
"dependencies": {
"@egoist/tailwindcss-icons": "^1.7.4",
"@headlessui/tailwindcss": "^0.2.0",
"@headlessui/vue": "^1.7.19",
"@iconify-json/heroicons": "^1.1.20",
"@nuxt/kit": "^3.11.2",
"@nuxtjs/color-mode": "^3.4.0",
"@nuxtjs/tailwindcss": "^6.11.4",
"@egoist/tailwindcss-icons": "^1.8.1",
"@headlessui/tailwindcss": "^0.2.1",
"@headlessui/vue": "^1.7.22",
"@iconify-json/heroicons": "^1.1.21",
"@nuxt/kit": "^3.12.1",
"@nuxtjs/color-mode": "^3.4.1",
"@nuxtjs/tailwindcss": "^6.12.0",
"@popperjs/core": "^2.11.8",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.12",
"@vueuse/core": "^10.9.0",
"@vueuse/integrations": "^10.9.0",
"@vueuse/math": "^10.9.0",
"@tailwindcss/typography": "^0.5.13",
"@vueuse/core": "^10.11.0",
"@vueuse/integrations": "^10.11.0",
"@vueuse/math": "^10.11.0",
"defu": "^6.1.4",
"fuse.js": "^6.6.2",
"nuxt-icon": "^0.6.10",
"ohash": "^1.1.3",
"pathe": "^1.1.2",
"scule": "^1.3.0",
"tailwind-merge": "^2.2.2",
"tailwindcss": "^3.4.3"
"tailwind-merge": "^2.3.0",
"tailwindcss": "^3.4.4"
},
"devDependencies": {
"@nuxt/eslint-config": "^0.3.6",
"@nuxt/eslint-config": "^0.3.13",
"@nuxt/module-builder": "^0.5.5",
"@nuxt/test-utils": "^3.12.0",
"@nuxt/test-utils": "^3.13.1",
"@release-it/conventional-changelog": "^8.0.1",
"@vue/test-utils": "^2.4.5",
"@vue/test-utils": "^2.4.6",
"eslint": "^8.57.0",
"happy-dom": "^14.7.1",
"joi": "^17.12.3",
"nuxt": "^3.11.2",
"release-it": "^17.2.0",
"happy-dom": "^14.10.1",
"joi": "^17.13.1",
"nuxt": "^3.12.1",
"release-it": "^17.3.0",
"typescript": "^5.4.5",
"unbuild": "^2.0.0",
"valibot": "^0.30.0",
"vitest": "^1.5.0",
"valibot30": "npm:valibot@0.30.0",
"valibot": "^0.31.1",
"vitest": "^1.6.0",
"vitest-environment-nuxt": "^1.0.0",
"vue-tsc": "^2.0.13",
"vue-tsc": "^2.0.16",
"yup": "^1.4.0",
"zod": "^3.22.4"
"zod": "^3.23.8"
},
"resolutions": {
"@nuxt/kit": "^3.11.2",
"@nuxt/schema": "3.11.2",
"tailwindcss": "^3.4.3",
"@headlessui/vue": "1.7.19",
"vue": "3.4.21"
"@nuxt/ui": "workspace:*",
"@nuxt/module-builder": "0.5.5",
"vue-tsc": "2.0.16"
}
}

14
playground/package.json Normal file
View File

@@ -0,0 +1,14 @@
{
"private": true,
"name": "@nuxt/ui-playground",
"type": "module",
"scripts": {
"dev": "nuxi dev",
"build": "nuxi build",
"generate": "nuxi generate"
},
"dependencies": {
"@nuxt/ui": "latest",
"nuxt": "^3.12.1"
}
}

22451
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,4 @@
packages:
- "docs"
- "./"
- "docs"
- "playground"

View File

@@ -1,14 +1,12 @@
import { createRequire } from 'node:module'
import { defineNuxtModule, installModule, addComponentsDir, addImportsDir, createResolver, addPlugin } from '@nuxt/kit'
import { defaultExtractor as createDefaultExtractor } from 'tailwindcss/lib/lib/defaultExtractor.js'
import { iconsPlugin, getIconCollections, type CollectionNames, type IconsPluginOptions } from '@egoist/tailwindcss-icons'
import type { CollectionNames, IconsPluginOptions } from '@egoist/tailwindcss-icons'
import { name, version } from '../package.json'
import createTemplates from './templates'
import { generateSafelist, excludeColors, customSafelistExtractor } from './colors'
import * as config from './runtime/ui.config'
import type { DeepPartial, Strategy } from './runtime/types/utils'
import installTailwind from './tailwind'
const defaultExtractor = createDefaultExtractor({ tailwindConfig: { separator: ':' } })
const _require = createRequire(import.meta.url)
const defaultColors = _require('tailwindcss/colors.js')
@@ -88,107 +86,13 @@ export default defineNuxtModule<ModuleOptions>({
nuxt.options.css.push(resolve(runtimeDir, 'ui.css'))
}
// @ts-ignore
nuxt.hook('tailwindcss:config', function (tailwindConfig) {
tailwindConfig.theme = tailwindConfig.theme || {}
tailwindConfig.theme.extend = tailwindConfig.theme.extend || {}
tailwindConfig.theme.extend.colors = tailwindConfig.theme.extend.colors || {}
const globalColors: any = {
...(tailwindConfig.theme.colors || defaultColors),
...tailwindConfig.theme.extend?.colors
}
// @ts-ignore
globalColors.primary = tailwindConfig.theme.extend.colors.primary = {
50: 'rgb(var(--color-primary-50) / <alpha-value>)',
100: 'rgb(var(--color-primary-100) / <alpha-value>)',
200: 'rgb(var(--color-primary-200) / <alpha-value>)',
300: 'rgb(var(--color-primary-300) / <alpha-value>)',
400: 'rgb(var(--color-primary-400) / <alpha-value>)',
500: 'rgb(var(--color-primary-500) / <alpha-value>)',
600: 'rgb(var(--color-primary-600) / <alpha-value>)',
700: 'rgb(var(--color-primary-700) / <alpha-value>)',
800: 'rgb(var(--color-primary-800) / <alpha-value>)',
900: 'rgb(var(--color-primary-900) / <alpha-value>)',
950: 'rgb(var(--color-primary-950) / <alpha-value>)',
DEFAULT: 'rgb(var(--color-primary-DEFAULT) / <alpha-value>)'
}
if (globalColors.gray) {
// @ts-ignore
globalColors.cool = tailwindConfig.theme.extend.colors.cool = defaultColors.gray
}
// @ts-ignore
globalColors.gray = tailwindConfig.theme.extend.colors.gray = {
50: 'rgb(var(--color-gray-50) / <alpha-value>)',
100: 'rgb(var(--color-gray-100) / <alpha-value>)',
200: 'rgb(var(--color-gray-200) / <alpha-value>)',
300: 'rgb(var(--color-gray-300) / <alpha-value>)',
400: 'rgb(var(--color-gray-400) / <alpha-value>)',
500: 'rgb(var(--color-gray-500) / <alpha-value>)',
600: 'rgb(var(--color-gray-600) / <alpha-value>)',
700: 'rgb(var(--color-gray-700) / <alpha-value>)',
800: 'rgb(var(--color-gray-800) / <alpha-value>)',
900: 'rgb(var(--color-gray-900) / <alpha-value>)',
950: 'rgb(var(--color-gray-950) / <alpha-value>)'
}
const colors = excludeColors(globalColors)
// @ts-ignore
nuxt.options.appConfig.ui = {
primary: 'green',
gray: 'cool',
colors,
strategy: 'merge'
}
tailwindConfig.safelist = tailwindConfig.safelist || []
tailwindConfig.safelist.push(...generateSafelist(options.safelistColors || [], colors))
})
createTemplates(nuxt)
// Modules
await installModule('nuxt-icon')
await installModule('@nuxtjs/color-mode', { classSuffix: '' })
await installModule('@nuxtjs/tailwindcss', {
exposeConfig: true,
config: {
darkMode: 'class',
plugins: [
require('@tailwindcss/forms')({ strategy: 'class' }),
require('@tailwindcss/aspect-ratio'),
require('@tailwindcss/typography'),
require('@tailwindcss/container-queries'),
require('@headlessui/tailwindcss'),
iconsPlugin(Array.isArray(options.icons) || options.icons === 'all' ? { collections: getIconCollections(options.icons) } : typeof options.icons === 'object' ? options.icons as IconsPluginOptions : {})
],
content: {
files: [
resolve(runtimeDir, 'components/**/*.{vue,mjs,ts}'),
resolve(runtimeDir, 'ui.config/**/*.{mjs,js,ts}')
],
transform: {
vue: (content) => {
return content.replaceAll(/(?:\r\n|\r|\n)/g, ' ')
}
},
extract: {
vue: (content) => {
return [
...defaultExtractor(content),
// @ts-ignore
...customSafelistExtractor(options.prefix, content, nuxt.options.appConfig.ui.colors, options.safelistColors)
]
}
}
}
}
})
await installTailwind(options, nuxt, resolve)
// Plugins

View File

@@ -1,13 +1,24 @@
<template>
<div :class="ui.wrapper" v-bind="attrs">
<table :class="[ui.base, ui.divide]">
<slot v-if="$slots.caption || caption" name="caption">
<caption :class="ui.caption">
{{ caption }}
</caption>
</slot>
<thead :class="ui.thead">
<tr :class="ui.tr.base">
<th v-if="modelValue" scope="col" :class="ui.checkbox.padding">
<UCheckbox :model-value="indeterminate || selected.length === rows.length" :indeterminate="indeterminate" v-bind="ui.default.checkbox" aria-label="Select all" @change="onChange" />
</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]">
<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]"
:aria-sort="getAriaSort(column)"
>
<slot :name="`${column.key}-header`" :column="column" :sort="sort" :on-sort="onSort">
<UButton
v-if="column.sortable"
@@ -74,7 +85,7 @@
<script lang="ts">
import { computed, defineComponent, toRaw, toRef } from 'vue'
import type { PropType } from 'vue'
import type { PropType, AriaAttributes } from 'vue'
import { upperFirst } from 'scule'
import { defu } from 'defu'
import { useVModel } from '@vueuse/core'
@@ -107,6 +118,15 @@ function defaultSort (a: any, b: any, direction: 'asc' | 'desc') {
}
}
interface Column {
key: string
sortable?: boolean
sort?: (a: any, b: any, direction: 'asc' | 'desc') => number
direction?: 'asc' | 'desc'
class?: string
[key: string]: any
}
export default defineComponent({
components: {
UIcon,
@@ -129,7 +149,7 @@ export default defineComponent({
default: () => []
},
columns: {
type: Array as PropType<{ key: string, sortable?: boolean, sort?: (a: any, b: any, direction: 'asc' | 'desc') => number, direction?: 'asc' | 'desc', class?: string, [key: string]: any }[]>,
type: Array as PropType<Column[]>,
default: null
},
columnAttribute: {
@@ -168,6 +188,10 @@ export default defineComponent({
type: Object as PropType<{ icon: string, label: string }>,
default: () => config.default.emptyState
},
caption: {
type: String,
default: null
},
progress: {
type: Object as PropType<{ color: ProgressColor, animation: ProgressAnimation }>,
default: () => config.default.progress
@@ -292,6 +316,26 @@ export default defineComponent({
return get(row, rowKey, defaultValue)
}
function getAriaSort (column: Column): AriaAttributes['aria-sort'] {
if (!column.sortable) {
return undefined
}
if (sort.value.column !== column.key) {
return 'none'
}
if (sort.value.direction === 'asc') {
return 'ascending'
}
if (sort.value.direction === 'desc') {
return 'descending'
}
return undefined
}
return {
// eslint-disable-next-line vue/no-dupe-keys
ui,
@@ -312,7 +356,8 @@ export default defineComponent({
onSort,
onSelect,
onChange,
getRowData
getRowData,
getAriaSort
}
}
})

View File

@@ -2,7 +2,7 @@
<div :class="alertClass" v-bind="attrs">
<div class="flex" :class="[ui.gap, { 'items-start': (description || $slots.description), 'items-center': !description && !$slots.description }]">
<slot name="icon" :icon="icon">
<UIcon v-if="icon" :name="icon" :ui="ui.icon.base" />
<UIcon v-if="icon" :name="icon" :class="ui.icon.base" />
</slot>
<slot name="avatar" :avatar="avatar">
<UAvatar v-if="avatar" v-bind="{ size: ui.avatar.size, ...avatar }" :class="ui.avatar.base" />
@@ -14,19 +14,23 @@
{{ title }}
</slot>
</p>
<p v-if="description || $slots.description" :class="twMerge(ui.description, !(title && $slots.title) && 'mt-0 leading-5')">
<div v-if="description || $slots.description" :class="twMerge(ui.description, !(title && $slots.title) && 'mt-0 leading-5')">
<slot name="description" :description="description">
{{ description }}
</slot>
</p>
</div>
<div v-if="(description || $slots.description) && actions.length" :class="ui.actions">
<UButton v-for="(action, index) of actions" :key="index" v-bind="{ ...(ui.default.actionButton || {}), ...action }" @click.stop="onAction(action)" />
<div v-if="(description || $slots.description) && (actions.length || $slots.actions)" :class="ui.actions">
<slot name="actions">
<UButton v-for="(action, index) of actions" :key="index" v-bind="{ ...(ui.default.actionButton || {}), ...action }" @click.stop="onAction(action)" />
</slot>
</div>
</div>
<div v-if="closeButton || (!description && !$slots.description && actions.length)" :class="twMerge(ui.actions, 'mt-0')">
<template v-if="!description && !$slots.description && actions.length">
<UButton v-for="(action, index) of actions" :key="index" v-bind="{ ...(ui.default.actionButton || {}), ...action }" @click.stop="onAction(action)" />
<template v-if="!description && !$slots.description && (actions.length || $slots.actions)">
<slot name="actions">
<UButton v-for="(action, index) of actions" :key="index" v-bind="{ ...(ui.default.actionButton || {}), ...action }" @click.stop="onAction(action)" />
</slot>
</template>
<UButton v-if="closeButton" aria-label="Close" v-bind="{ ...(ui.default.closeButton || {}), ...closeButton }" @click.stop="$emit('close')" />

View File

@@ -10,7 +10,8 @@ import { useEventBus } from '@vueuse/core'
import type { ZodSchema } from 'zod'
import type { ValidationError as JoiError, Schema as JoiSchema } from 'joi'
import type { ObjectSchema as YupObjectSchema, ValidationError as YupError } from 'yup'
import type { ObjectSchemaAsync as ValibotObjectSchema } from 'valibot'
import type { BaseSchema as ValibotSchema30, BaseSchemaAsync as ValibotSchemaAsync30 } from 'valibot30'
import type { GenericSchema as ValibotSchema, GenericSchemaAsync as ValibotSchemaAsync, SafeParser as ValibotSafeParser, SafeParserAsync as ValibotSafeParserAsync } from 'valibot'
import type { FormError, FormEvent, FormEventType, FormSubmitEvent, FormErrorEvent, Form } from '../../types/form'
import { useId } from '#imports'
@@ -25,11 +26,13 @@ class FormException extends Error {
export default defineComponent({
props: {
schema: {
type: Object as
type: [Object, Function] as
| PropType<ZodSchema>
| PropType<YupObjectSchema<any>>
| PropType<JoiSchema>
| PropType<ValibotObjectSchema<any>>,
| PropType<ValibotSchema30 | ValibotSchemaAsync30>
| PropType<ValibotSchema | ValibotSchemaAsync>
| PropType<ValibotSafeParser<any, any> | ValibotSafeParserAsync<any, any>>,
default: undefined
},
state: {
@@ -151,7 +154,6 @@ export default defineComponent({
validate,
errors,
setErrors (errs: FormError[], path?: string) {
errors.value = errs
if (path) {
errors.value = errors.value.filter(
(error) => error.path !== path
@@ -256,21 +258,19 @@ async function getJoiErrors (
}
}
function isValibotSchema (schema: any): schema is ValibotObjectSchema<any> {
return schema._parse !== undefined
function isValibotSchema (schema: any): schema is ValibotSchema30 | ValibotSchemaAsync30 | ValibotSchema | ValibotSchemaAsync | ValibotSafeParser<any, any> | ValibotSafeParserAsync<any, any> {
return '_parse' in schema || '_run' in schema || (typeof schema === 'function' && 'schema' in schema)
}
async function getValibotError (
state: any,
schema: ValibotObjectSchema<any>
schema: ValibotSchema30 | ValibotSchemaAsync30 | ValibotSchema | ValibotSchemaAsync | ValibotSafeParser<any, any> | ValibotSafeParserAsync<any, any>
): Promise<FormError[]> {
const result = await schema._parse(state)
if (result.issues) {
return result.issues.map((issue) => ({
path: issue.path?.map(p => p.key).join('.') || '',
message: issue.message
}))
}
return []
const result = await ('_parse' in schema ? schema._parse(state) : '_run' in schema ? schema._run({ typed: false, value: state }, {}) : schema(state))
return result.issues?.map((issue) => ({
// We know that the key for a form schema is always a string or a number
path: issue.path?.map((item) => item.key).join('.') || '',
message: issue.message
})) || []
}
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div :class="ui.wrapper">
<div :class="(type === 'hidden') ? 'hidden' : ui.wrapper">
<input
:id="inputId"
ref="input"
@@ -240,7 +240,7 @@ export default defineComponent({
ui.value.form,
rounded.value,
ui.value.placeholder,
props.type === 'file' && [ui.value.file.base, ui.value.file.padding[size.value]],
props.type === 'file' && ui.value.file.base,
ui.value.size[size.value],
props.padded ? ui.value.padding[size.value] : 'p-0',
variant?.replaceAll('{color}', color.value),

View File

@@ -249,6 +249,10 @@ export default defineComponent({
type: Array,
default: null
},
searchLazy: {
type: Boolean,
default: false
},
debounce: {
type: Number,
default: 200
@@ -407,6 +411,8 @@ export default defineComponent({
return child !== null && child !== undefined && String(child).search(new RegExp(query.value, 'i')) !== -1
})
})
}, [], {
lazy: props.searchLazy
})
watch(container, (value) => {

View File

@@ -199,11 +199,11 @@ export default defineComponent({
}
const guessOptionValue = (option: any) => {
return get(option, props.valueAttribute, get(option, props.optionAttribute))
return get(option, props.valueAttribute, '')
}
const guessOptionText = (option: any) => {
return get(option, props.optionAttribute, get(option, props.valueAttribute))
return get(option, props.optionAttribute, '')
}
const normalizeOption = (option: any) => {

View File

@@ -249,6 +249,10 @@ export default defineComponent({
type: String,
default: 'Search...'
},
searchableLazy: {
type: Boolean,
default: false
},
clearSearchOnClose: {
type: Boolean,
default: () => configMenu.default.clearSearchOnClose
@@ -387,7 +391,7 @@ export default defineComponent({
variant?.replaceAll('{color}', color.value),
(isLeading.value || slots.leading) && ui.value.leading.padding[size.value],
(isTrailing.value || slots.trailing) && ui.value.trailing.padding[size.value]
), props.placeholder && (props.modelValue === undefined && props.modelValue === null) && ui.value.placeholder, props.selectClass)
), props.placeholder && !props.modelValue && ui.value.placeholder, props.selectClass)
})
const isLeading = computed(() => {
@@ -470,6 +474,8 @@ export default defineComponent({
return child !== null && child !== undefined && String(child).search(new RegExp(query.value, 'i')) !== -1
})
})
}, [], {
lazy: props.searchableLazy
})
const createOption = computed(() => {

View File

@@ -5,8 +5,9 @@
<ULink
as="span"
:class="[ui.base, index === links.length - 1 ? ui.active : !!link.to ? ui.inactive : '']"
v-bind="getULinkProps(link)"
:aria-current="index === links.length - 1 ? 'page' : undefined"
v-bind="getULinkProps(link)"
@click="link.click"
>
<slot name="icon" :link="link" :index="index" :is-active="index === links.length - 1">
<UIcon

View File

@@ -321,7 +321,10 @@ export default defineComponent({
)
})
const emptyState = computed(() => ({ ...ui.value.default.emptyState, ...props.emptyState }))
const emptyState = computed(() => {
if (props.emptyState === null) return null
return { ...ui.value.default.emptyState, ...props.emptyState }
})
// Methods

View File

@@ -29,6 +29,7 @@
<UButton
v-for="(page, index) of displayedPages"
:key="`${page}-${index}`"
:to="typeof page === 'number' ? to?.(page) : null"
:size="size"
:disabled="disabled"
:label="`${page}`"
@@ -69,6 +70,7 @@
<script lang="ts">
import { computed, toRef, defineComponent } from 'vue'
import type { PropType } from 'vue'
import type { RouteLocationRaw } from '#vue-router'
import UButton from '../elements/Button.vue'
import { useUI } from '../../composables/useUI'
import { mergeConfig } from '../../utils'
@@ -117,6 +119,10 @@ export default defineComponent({
return Object.keys(buttonConfig.size).includes(value)
}
},
to: {
type: Function as PropType<(page: number) => RouteLocationRaw>,
default: null
},
activeButton: {
type: Object as PropType<Button>,
default: () => config.default.activeButton as Button

View File

@@ -32,7 +32,7 @@
</HTab>
</HTabList>
<HTabPanels :class="ui.container">
<HTabPanels v-if="content" :class="ui.container">
<HTabPanel v-for="(item, index) of items" :key="index" v-slot="{ selected }" :class="ui.base" :unmount="unmount">
<slot :name="item.slot || 'item'" :item="item" :index="index" :selected="selected">
{{ item.content }}
@@ -88,6 +88,10 @@ export default defineComponent({
type: Boolean,
default: false
},
content: {
type: Boolean,
default: true
},
class: {
type: [String, Object, Array] as PropType<any>,
default: () => ''

View File

@@ -18,11 +18,11 @@
{{ title }}
</slot>
</p>
<p v-if="(description || $slots.description)" :class="twMerge(ui.description, !(title && $slots.title) && 'mt-0 leading-5')">
<div v-if="(description || $slots.description)" :class="twMerge(ui.description, !(title && $slots.title) && 'mt-0 leading-5')">
<slot name="description" :description="description">
{{ description }}
</slot>
</p>
</div>
<div v-if="(description || $slots.description) && actions.length" :class="ui.actions">
<UButton v-for="(action, index) of actions" :key="index" v-bind="{ ...(ui.default.actionButton || {}), ...action }" @click.stop="onAction(action)" />
@@ -43,7 +43,7 @@
</template>
<script lang="ts">
import { ref, computed, toRef, onMounted, onUnmounted, watchEffect, defineComponent } from 'vue'
import { ref, computed, toRef, onMounted, onUnmounted, watch, watchEffect, defineComponent } from 'vue'
import type { PropType } from 'vue'
import { twMerge, twJoin } from 'tailwind-merge'
import UIcon from '../elements/Icon.vue'
@@ -123,7 +123,7 @@ export default defineComponent({
setup (props, { emit }) {
const { ui, attrs } = useUI('notification', toRef(props, 'ui'), config)
let timer: any = null
let timer: null | ReturnType<typeof useTimer> = null
const remaining = ref(props.timeout)
const wrapperClass = computed(() => {
@@ -131,7 +131,8 @@ export default defineComponent({
ui.value.wrapper,
ui.value.background?.replaceAll('{color}', props.color),
ui.value.rounded,
ui.value.shadow
ui.value.shadow,
ui.value.ring?.replaceAll('{color}', props.color)
), props.class)
})
@@ -191,7 +192,11 @@ export default defineComponent({
emit('close')
}
onMounted(() => {
function initTimer () {
if (timer) {
timer.stop()
}
if (!props.timeout) {
return
}
@@ -203,7 +208,11 @@ export default defineComponent({
watchEffect(() => {
remaining.value = timer.remaining.value
})
})
}
watch(() => props.timeout, initTimer)
onMounted(initTimer)
onUnmounted(() => {
if (timer) {

View File

@@ -1,12 +1,12 @@
<template>
<TransitionRoot as="template" :appear="appear" :show="isOpen" @after-leave="onAfterLeave">
<HDialog :class="[ui.wrapper, { 'justify-end': side === 'right' }]" v-bind="attrs" @close="close">
<HDialog :class="[ui.wrapper, { 'justify-end': side === 'right' }, { 'items-end': side === 'bottom' }]" v-bind="attrs" @close="close">
<TransitionChild v-if="overlay" as="template" :appear="appear" v-bind="ui.overlay.transition">
<div :class="[ui.overlay.base, ui.overlay.background]" />
</TransitionChild>
<TransitionChild as="template" :appear="appear" v-bind="transitionClass">
<HDialogPanel :class="[ui.base, ui.width, ui.background, ui.ring, ui.padding]">
<HDialogPanel :class="[ui.base, sideType === 'horizontal' ? [ui.width, 'h-full'] : [ui.height, 'w-full'], ui.background, ui.ring, ui.padding]">
<slot />
</HDialogPanel>
</TransitionChild>
@@ -46,9 +46,9 @@ export default defineComponent({
default: false
},
side: {
type: String as PropType<'left' | 'right'>,
type: String as PropType<'left' | 'right' | 'top' | 'bottom'>,
default: 'right',
validator: (value: string) => ['left', 'right'].includes(value)
validator: (value: string) => ['left', 'right', 'top', 'bottom'].includes(value)
},
overlay: {
type: Boolean,
@@ -89,14 +89,52 @@ export default defineComponent({
return {}
}
let enterFrom, leaveTo
switch (props.side) {
case 'left':
enterFrom = ui.value.translate.left
leaveTo = ui.value.translate.left
break
case 'right':
enterFrom = ui.value.translate.right
leaveTo = ui.value.translate.right
break
case 'top':
enterFrom = ui.value.translate.top
leaveTo = ui.value.translate.top
break
case 'bottom':
enterFrom = ui.value.translate.bottom
leaveTo = ui.value.translate.bottom
break
default:
enterFrom = ui.value.translate.right
leaveTo = ui.value.translate.right
}
return {
...ui.value.transition,
enterFrom: props.side === 'left' ? ui.value.translate.left : ui.value.translate.right,
enterFrom,
enterTo: ui.value.translate.base,
leaveFrom: ui.value.translate.base,
leaveTo: props.side === 'left' ? ui.value.translate.left : ui.value.translate.right
leaveTo
}
})
const sideType = computed(() => {
switch (props.side) {
case 'left':
return 'horizontal'
case 'right':
return 'horizontal'
case 'top':
return 'vertical'
case 'bottom':
return 'vertical'
default:
return 'right'
}
})
function close (value: boolean) {
if (props.preventClose) {
@@ -121,9 +159,10 @@ export default defineComponent({
attrs,
isOpen,
transitionClass,
sideType,
onAfterLeave,
close
}
}
})
</script>
</script>

View File

@@ -16,6 +16,7 @@ export const useCarouselScroll = (el: Ref<HTMLElement>) => {
function onMouseUp () {
el.value.style.removeProperty('scroll-behavior')
el.value.style.removeProperty('scroll-snap-type')
el.value.style.removeProperty('pointer-events')
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
@@ -24,6 +25,8 @@ export const useCarouselScroll = (el: Ref<HTMLElement>) => {
function onMouseMove (e) {
e.preventDefault()
el.value.style.pointerEvents = 'none'
const delta = e.pageX - x.value
x.value = e.pageX

View File

@@ -22,8 +22,22 @@ export function useToast () {
notifications.value = notifications.value.filter((n: Notification) => n.id !== id)
}
function update (id: string, notification: Partial<Notification>) {
const index = notifications.value.findIndex((n: Notification) => n.id === id)
if (index !== -1) {
const previous = notifications.value[index]
notifications.value.splice(index, 1, { ...previous, ...notification })
}
}
function clear () {
notifications.value = []
}
return {
add,
remove
remove,
update,
clear
}
}

View File

@@ -7,6 +7,6 @@ export interface Link extends NuxtLinkProps {
active?: boolean
exact?: boolean
exactQuery?: boolean
exactMatch?: boolean
exactHash?: boolean
inactiveClass?: string
}

View File

@@ -1,17 +1,16 @@
import type { Component } from 'vue'
interface Slideover {
ui?: any;
side?: 'right' | 'left';
transition?: boolean;
appear?: boolean;
overlay?: boolean;
preventClose?: boolean;
modelValue?: boolean;
export interface Slideover {
ui?: any
side?: 'right' | 'left'
transition?: boolean
appear?: boolean
overlay?: boolean
preventClose?: boolean
modelValue?: boolean
}
interface SlideoverState {
component: Component | string;
props: Slideover;
export interface SlideoverState {
component: Component | string
props: Slideover
}

View File

@@ -4,6 +4,7 @@ export default {
divide: 'divide-y divide-gray-300 dark:divide-gray-700',
thead: 'relative',
tbody: 'divide-y divide-gray-200 dark:divide-gray-800',
caption: 'sr-only',
tr: {
base: '',
selected: 'bg-gray-50 dark:bg-gray-800/50',

View File

@@ -5,15 +5,7 @@ export default {
rounded: 'rounded-md',
placeholder: 'placeholder-gray-400 dark:placeholder-gray-500',
file: {
base: 'file:cursor-pointer file:rounded-l-md file:absolute file:left-0 file:inset-y-0 file:font-medium file:m-0 file:border-0 file:ring-1 file:ring-gray-300 dark:file:ring-gray-700 file:text-gray-900 dark:file:text-white file:bg-gray-50 hover:file:bg-gray-100 dark:file:bg-gray-800 dark:hover:file:bg-gray-700/50',
padding: {
'2xs': 'ps-[85px]',
xs: 'ps-[87px]',
sm: 'ps-[96px]',
md: 'ps-[98px]',
lg: 'ps-[100px]',
xl: 'ps-[109px]'
}
base: 'file:mr-1.5 file:font-medium file:text-gray-500 dark:file:text-gray-400 file:bg-transparent file:border-0 file:p-0 file:outline-none'
},
size: {
'2xs': 'text-xs',

View File

@@ -20,10 +20,13 @@ export default {
padding: '',
shadow: 'shadow-xl',
width: 'w-screen max-w-md',
height: 'h-screen max-h-96',
translate: {
base: 'translate-x-0',
left: '-translate-x-full rtl:translate-x-full',
right: 'translate-x-full rtl:-translate-x-full'
right: 'translate-x-full rtl:-translate-x-full',
top: '-translate-y-full',
bottom: 'translate-y-full'
},
// Syntax for `<TransitionRoot>` component https://headlessui.com/vue/transition#basic-example
transition: {

View File

@@ -1,5 +1,7 @@
import { omit } from './runtime/utils/lodash'
import { omit } from './lodash'
import { kebabCase, camelCase, upperFirst } from 'scule'
import type { Config as TWConfig } from 'tailwindcss'
import defaultColors from 'tailwindcss/colors.js'
const colorsToExclude = [
'inherit',
@@ -15,7 +17,7 @@ const colorsToExclude = [
'cool'
]
const safelistByComponent = {
const safelistByComponent: Record<string, (colors: string) => TWConfig['safelist']> = {
alert: (colorsAsRegex) => [{
pattern: new RegExp(`bg-(${colorsAsRegex})-50`)
}, {
@@ -217,13 +219,61 @@ const safelistComponentAliasesMap = {
const colorsAsRegex = (colors: string[]): string => colors.join('|')
export const excludeColors = (colors: object): string[] => {
type ColorConfig = Exclude<TWConfig['theme']['colors'], Function>
export const excludeColors = (colors: ColorConfig | typeof defaultColors): string[] => {
return Object.entries(omit(colors, colorsToExclude))
.filter(([, value]) => typeof value === 'object')
.map(([key]) => kebabCase(key))
}
export const generateSafelist = (colors: string[], globalColors) => {
export const setGlobalColors = (theme: TWConfig['theme']) => {
const globalColors: ColorConfig = {
...(theme.colors || defaultColors),
...theme.extend?.colors
}
// @ts-ignore
globalColors.primary = theme.extend.colors.primary = {
50: 'rgb(var(--color-primary-50) / <alpha-value>)',
100: 'rgb(var(--color-primary-100) / <alpha-value>)',
200: 'rgb(var(--color-primary-200) / <alpha-value>)',
300: 'rgb(var(--color-primary-300) / <alpha-value>)',
400: 'rgb(var(--color-primary-400) / <alpha-value>)',
500: 'rgb(var(--color-primary-500) / <alpha-value>)',
600: 'rgb(var(--color-primary-600) / <alpha-value>)',
700: 'rgb(var(--color-primary-700) / <alpha-value>)',
800: 'rgb(var(--color-primary-800) / <alpha-value>)',
900: 'rgb(var(--color-primary-900) / <alpha-value>)',
950: 'rgb(var(--color-primary-950) / <alpha-value>)',
DEFAULT: 'rgb(var(--color-primary-DEFAULT) / <alpha-value>)'
}
if (globalColors.gray) {
// @ts-ignore
globalColors.cool = theme.extend.colors.cool =
defaultColors.gray
}
// @ts-ignore
globalColors.gray = theme.extend.colors.gray = {
50: 'rgb(var(--color-gray-50) / <alpha-value>)',
100: 'rgb(var(--color-gray-100) / <alpha-value>)',
200: 'rgb(var(--color-gray-200) / <alpha-value>)',
300: 'rgb(var(--color-gray-300) / <alpha-value>)',
400: 'rgb(var(--color-gray-400) / <alpha-value>)',
500: 'rgb(var(--color-gray-500) / <alpha-value>)',
600: 'rgb(var(--color-gray-600) / <alpha-value>)',
700: 'rgb(var(--color-gray-700) / <alpha-value>)',
800: 'rgb(var(--color-gray-800) / <alpha-value>)',
900: 'rgb(var(--color-gray-900) / <alpha-value>)',
950: 'rgb(var(--color-gray-950) / <alpha-value>)'
}
return excludeColors(globalColors)
}
export const generateSafelist = (colors: string[], globalColors: string[]) => {
const baseSafelist = Object.keys(safelistByComponent).flatMap(component => safelistByComponent[component](colorsAsRegex(colors)))
// Ensure `red` color is safelisted for form elements so that `error` prop of `UFormGroup` always works
@@ -242,7 +292,8 @@ export const generateSafelist = (colors: string[], globalColors) => {
]
}
export const customSafelistExtractor = (prefix, content: string, colors: string[], safelistColors: string[]) => {
type SafelistFn = Exclude<NonNullable<Extract<TWConfig['content'], { extract?: unknown }>['extract']>, Record<string, unknown>>
export const customSafelistExtractor = (prefix: string, content: string, colors: string[], safelistColors: string[]): ReturnType<SafelistFn> => {
const classes: string[] = []
const regex = /<([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z][A-Za-z0-9]*)*)\s+(?![^>]*:color\b)[^>]*\bcolor=["']([^"']+)["'][^>]*>/gs
@@ -268,7 +319,7 @@ export const customSafelistExtractor = (prefix, content: string, colors: string[
name = name.replace(prefix, '').toLowerCase()
const matchClasses = safelistByComponent[name](color).flatMap(group => {
return ['', ...(group.variants || [])].flatMap(variant => {
return typeof group === 'string' ? '' : ['', ...(group.variants || [])].flatMap(variant => {
const matches = group.pattern.source.match(/\(([^)]+)\)/g)
return matches.map(match => {

87
src/tailwind.ts Normal file
View File

@@ -0,0 +1,87 @@
import { join } from 'pathe'
import { defu } from 'defu'
import { addTemplate, createResolver, installModule, useNuxt } from '@nuxt/kit'
import { setGlobalColors } from './runtime/utils/colors'
import type { ModuleOptions } from './module'
export default async function installTailwind (
moduleOptions: ModuleOptions,
nuxt = useNuxt(),
resolve = createResolver(import.meta.url).resolve
) {
const runtimeDir = resolve('./runtime')
// 1. register hook
// @ts-ignore
nuxt.hook('tailwindcss:config', function (tailwindConfig) {
tailwindConfig.theme = tailwindConfig.theme || {}
tailwindConfig.theme.extend = tailwindConfig.theme.extend || {}
tailwindConfig.theme.extend.colors =
tailwindConfig.theme.extend.colors || {}
const colors = setGlobalColors(tailwindConfig.theme)
// @ts-ignore
nuxt.options.appConfig.ui = {
primary: 'green',
gray: 'cool',
colors,
strategy: 'merge'
}
})
// 2. add config template
const configTemplate = addTemplate({
filename: 'nuxtui-tailwind.config.cjs',
write: true,
getContents: ({ nuxt }) => `
const { defaultExtractor: createDefaultExtractor } = require('tailwindcss/lib/lib/defaultExtractor.js')
const { customSafelistExtractor, generateSafelist } = require(${JSON.stringify(resolve(runtimeDir, 'utils', 'colors'))})
const { iconsPlugin, getIconCollections } = require('@egoist/tailwindcss-icons')
const defaultExtractor = createDefaultExtractor({ tailwindConfig: { separator: ':' } })
module.exports = {
plugins: [
require('@tailwindcss/forms')({ strategy: 'class' }),
require('@tailwindcss/aspect-ratio'),
require('@tailwindcss/typography'),
require('@tailwindcss/container-queries'),
require('@headlessui/tailwindcss'),
iconsPlugin(${Array.isArray(moduleOptions.icons) || moduleOptions.icons === 'all' ? `{ collections: getIconCollections(${JSON.stringify(moduleOptions.icons)}) }` : typeof moduleOptions.icons === 'object' ? JSON.stringify(moduleOptions.icons) : {}})
],
content: {
files: [
${JSON.stringify(resolve(runtimeDir, 'components/**/*.{vue,mjs,ts}'))},
${JSON.stringify(resolve(runtimeDir, 'ui.config/**/*.{mjs,js,ts}'))}
],
transform: {
vue: (content) => {
return content.replaceAll(/(?:\\r\\n|\\r|\\n)/g, ' ')
}
},
extract: {
vue: (content) => {
return [
...defaultExtractor(content),
...customSafelistExtractor(${JSON.stringify(moduleOptions.prefix)}, content, ${JSON.stringify(nuxt.options.appConfig.ui.colors)}, ${JSON.stringify(moduleOptions.safelistColors)})
]
}
}
},
safelist: generateSafelist(${JSON.stringify(moduleOptions.safelistColors || [])}, ${JSON.stringify(nuxt.options.appConfig.ui.colors)}),
}
`
})
// 3. install module
await installModule('@nuxtjs/tailwindcss', defu({
exposeConfig: true,
config: { darkMode: 'class' },
configPath: [
configTemplate.dst,
join(nuxt.options.rootDir, 'tailwind.config')
]
}, nuxt.options.tailwindcss))
}

View File

@@ -56,76 +56,3 @@ describe('nuxt', () => {
})
})
})
describe('tailwindcss config', () => {
it.each([
/* format:
name,
tailwindcss config, safelistColors,
expected safelistPatterns (add "!" before a pattern to negate it)
*/
[
'default safelist',
{}, [],
['bg-(primary)-50', 'bg-(red)-500'] // these both should be in the safelist
],
[
'safelisting single new color',
{}, ['myColor'],
'bg-(myColor|primary)-50'
],
[
'reducing amount of theme colors',
{ theme: { colors: { plainBlue: '#00F' } } }, ['plainBlue'],
['bg-(plainBlue|primary)-50', '!', /orange/] // the word "orange" should _not_ be found in any safelist pattern
]
])('%s', async (_description, tailwindcss, safelistColors, safelistPatterns) => {
const { tailwindConfig } = await getTailwindCSSConfig({
ui: {
safelistColors
},
tailwindcss: {
config: tailwindcss
}
})
expect.extend({
toBeRegExp: (received, expected) => {
if (typeof expected === 'string' || expected instanceof String) {
return {
message: () => `expected ${received} to be exact regex ${expected}`,
pass: received.toString() === RegExp(expected as string).toString()
}
} else if (expected instanceof RegExp) {
return {
message: () => `expected ${received} to be a regex like ${expected.toString()}`,
pass: received.toString().match(expected)
}
}
return {
message: () => `expected ${received} to be a regex`,
pass: false
}
}
})
safelistPatterns = safelistPatterns instanceof Array ? safelistPatterns : [safelistPatterns]
let negate = false
for (const safelistPattern of safelistPatterns) {
if (safelistPattern === '!') {
// negate next!
negate = true
continue
}
if (negate) {
expect(tailwindConfig.safelist).not.toContainEqual({
pattern: expect.toBeRegExp(safelistPattern)
})
} else {
expect(tailwindConfig.safelist).toContainEqual({
pattern: expect.toBeRegExp(safelistPattern)
})
}
negate = false
}
})
})

88
test/colors.spec.ts Normal file
View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest'
import { excludeColors, generateSafelist, setGlobalColors } from '../src/runtime/utils/colors'
import defaultColors from 'tailwindcss/colors'
import type { Config as TWConfig } from 'tailwindcss'
describe('excludeColors', () => {
it('should exclude colors from the list', () => {
const twColors = {
red: defaultColors.red,
zinc: defaultColors.zinc,
blue: defaultColors.blue
}
expect(excludeColors(twColors).join(',')).toBe('red,blue')
})
})
describe('generateSafelist', () => {
it.each([
/* format:
name,
tailwindcss config, safelistColors,
expected safelistPatterns (add "!" before a pattern to negate it)
*/
[
'default safelist',
{}, [],
['bg-(primary)-50', 'bg-(red)-500'] // these both should be in the safelist
],
[
'safelisting single new color',
{}, ['myColor'],
'bg-(myColor|primary)-50'
],
[
'reducing amount of theme colors',
{ theme: { colors: { plainBlue: '#00F' } } }, ['plainBlue'],
['bg-(plainBlue|primary)-50', '!', /orange/] // the word "orange" should _not_ be found in any safelist pattern
]
])('%s', async (_description, tailwindConfig: Partial<TWConfig>, safelistColors, safelistPatterns) => {
safelistColors.push('primary')
tailwindConfig.theme = tailwindConfig.theme || {}
tailwindConfig.theme.extend = tailwindConfig.theme.extend || {}
tailwindConfig.theme.extend.colors =
tailwindConfig.theme.extend.colors || {}
const safelistConfig = generateSafelist(safelistColors, setGlobalColors(tailwindConfig.theme))
expect.extend({
toBeRegExp: (received, expected) => {
if (typeof expected === 'string' || expected instanceof String) {
return {
message: () => `expected ${received} to be exact regex ${expected}`,
pass: received.toString() === RegExp(expected as string).toString()
}
} else if (expected instanceof RegExp) {
return {
message: () => `expected ${received} to be a regex like ${expected.toString()}`,
pass: received.toString().match(expected)
}
}
return {
message: () => `expected ${received} to be a regex`,
pass: false
}
}
})
safelistPatterns = safelistPatterns instanceof Array ? safelistPatterns : [safelistPatterns]
let negate = false
for (const safelistPattern of safelistPatterns) {
if (safelistPattern === '!') {
// negate next!
negate = true
continue
}
if (negate) {
expect(safelistConfig).not.toContainEqual({
pattern: expect.toBeRegExp(safelistPattern)
})
} else {
expect(safelistConfig).toContainEqual({
pattern: expect.toBeRegExp(safelistPattern)
})
}
negate = false
}
})
})