Compare commits

..

20 Commits

Author SHA1 Message Date
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
33 changed files with 10933 additions and 7751 deletions

View File

@@ -1,5 +1,28 @@
# Changelog
## [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

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

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

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

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

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

@@ -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,25 +3,23 @@
"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.101",
"@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.10",
"@nuxt/fonts": "^0.7.0",
"@nuxt/image": "^1.7.0",
"@nuxt/ui": "latest",
"@nuxt/ui-pro": "npm:@nuxt/ui-pro-edge@1.1.0-28584604.9227c85",
"@nuxtjs/plausible": "^1.0.0",
"@octokit/rest": "^20.1.0",
"@octokit/rest": "^20.1.1",
"@vueuse/nuxt": "^10.9.0",
"date-fns": "^3.6.0",
"eslint": "^8.57.0",
"joi": "^17.12.3",
"joi": "^17.13.1",
"nuxt": "^3.11.2",
"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",
"typescript": "^5.4.5",
@@ -29,6 +27,6 @@
"v-calendar": "^3.1.2",
"valibot": "^0.30.0",
"yup": "^1.4.0",
"zod": "^3.22.4"
"zod": "^3.23.6"
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@nuxt/ui",
"version": "2.15.2",
"version": "2.16.0",
"repository": "nuxt/ui",
"homepage": "https://ui.nuxt.com",
"type": "module",
@@ -33,18 +33,18 @@
"test": "vitest"
},
"dependencies": {
"@egoist/tailwindcss-icons": "^1.7.4",
"@egoist/tailwindcss-icons": "^1.8.0",
"@headlessui/tailwindcss": "^0.2.0",
"@headlessui/vue": "^1.7.19",
"@iconify-json/heroicons": "^1.1.20",
"@headlessui/vue": "^1.7.21",
"@iconify-json/heroicons": "^1.1.21",
"@nuxt/kit": "^3.11.2",
"@nuxtjs/color-mode": "^3.4.0",
"@nuxtjs/tailwindcss": "^6.11.4",
"@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",
"@tailwindcss/typography": "^0.5.13",
"@vueuse/core": "^10.9.0",
"@vueuse/integrations": "^10.9.0",
"@vueuse/math": "^10.9.0",
@@ -54,34 +54,31 @@
"ohash": "^1.1.3",
"pathe": "^1.1.2",
"scule": "^1.3.0",
"tailwind-merge": "^2.2.2",
"tailwind-merge": "^2.3.0",
"tailwindcss": "^3.4.3"
},
"devDependencies": {
"@nuxt/eslint-config": "^0.3.6",
"@nuxt/eslint-config": "^0.3.10",
"@nuxt/module-builder": "^0.5.5",
"@nuxt/test-utils": "^3.12.0",
"@nuxt/test-utils": "^3.12.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",
"happy-dom": "^14.10.1",
"joi": "^17.13.1",
"nuxt": "^3.11.2",
"release-it": "^17.2.0",
"release-it": "^17.2.1",
"typescript": "^5.4.5",
"unbuild": "^2.0.0",
"valibot": "^0.30.0",
"vitest": "^1.5.0",
"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.6"
},
"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"
}
}

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.11.2"
}
}

17901
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

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

@@ -249,6 +249,10 @@ export default defineComponent({
type: String,
default: 'Search...'
},
searchableLazy: {
type: Boolean,
default: false
},
clearSearchOnClose: {
type: Boolean,
default: () => configMenu.default.clearSearchOnClose
@@ -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

@@ -7,6 +7,7 @@
: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"
@click="link.click"
>
<slot name="icon" :link="link" :index="index" :is-active="index === links.length - 1">
<UIcon

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

@@ -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(() => {
@@ -191,7 +191,11 @@ export default defineComponent({
emit('close')
}
onMounted(() => {
function initTimer () {
if (timer) {
timer.stop()
}
if (!props.timeout) {
return
}
@@ -203,7 +207,11 @@ export default defineComponent({
watchEffect(() => {
remaining.value = timer.remaining.value
})
})
}
watch(() => props.timeout, initTimer)
onMounted(initTimer)
onUnmounted(() => {
if (timer) {

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

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

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