mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-01-15 12:39:35 +01:00
Compare commits
46 Commits
nuxthub-ci
...
v2.16.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc52bffccf | ||
|
|
eb2601d4da | ||
|
|
f726b5f094 | ||
|
|
37ce62acb9 | ||
|
|
f97b728968 | ||
|
|
7e6ba78681 | ||
|
|
ed5c74dc17 | ||
|
|
bb3ea40218 | ||
|
|
821e15b696 | ||
|
|
bd3fa8658f | ||
|
|
82d619b2a7 | ||
|
|
8d9d9736ba | ||
|
|
3fca66857d | ||
|
|
4853520eb3 | ||
|
|
5481dab53d | ||
|
|
6f60fa9a98 | ||
|
|
cba9ad78db | ||
|
|
bbc8f4e6ac | ||
|
|
ed3a3babdb | ||
|
|
4415d4111e | ||
|
|
c75c0152ce | ||
|
|
993bb89e02 | ||
|
|
9f01145bc6 | ||
|
|
c1d9e0ecd4 | ||
|
|
f610c96a0b | ||
|
|
8b546600db | ||
|
|
f08471ccda | ||
|
|
d19d7077e4 | ||
|
|
07a4d13c0f | ||
|
|
9e90d1768b | ||
|
|
91e5002050 | ||
|
|
eb68d0d453 | ||
|
|
2bdb5d2b42 | ||
|
|
b62cd7905d | ||
|
|
58faa1053b | ||
|
|
e909884d03 | ||
|
|
5e84fd0570 | ||
|
|
98c0f567fc | ||
|
|
379d20fc3c | ||
|
|
c12f94653e | ||
|
|
2392b4aa40 | ||
|
|
36055ba978 | ||
|
|
73541f2d4f | ||
|
|
03030ab1db | ||
|
|
c98d6e31c0 | ||
|
|
49b73aa024 |
56
CHANGELOG.md
56
CHANGELOG.md
@@ -1,5 +1,61 @@
|
||||
# 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)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **Accordion:** add `unmount` prop to allow lazy mounting for heavy components ([#1590](https://github.com/nuxt/ui/issues/1590)) ([91e5002](https://github.com/nuxt/ui/commit/91e50020507ac66992dfb52b3e0ad1a1ae5614b5))
|
||||
* **Table:** add `checkbox` ui config ([#1409](https://github.com/nuxt/ui/issues/1409)) ([8b54660](https://github.com/nuxt/ui/commit/8b546600dbfbff187d9c5be1b35ea1772e94f83f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **Breadcrumb:** missing `min-w-0` on wrapper to truncate ([9f01145](https://github.com/nuxt/ui/commit/9f01145bc674378371ff34d7110f3235b57d2459)), closes [#1650](https://github.com/nuxt/ui/issues/1650)
|
||||
* **Carousel:** next and prev buttons disabled ([#1619](https://github.com/nuxt/ui/issues/1619)) ([e909884](https://github.com/nuxt/ui/commit/e909884d0327bfd7b4d5551382123f8998beff6a))
|
||||
* **Popover/Dropdown:** prevent unintended closure on touchstart in mobile devices ([#1609](https://github.com/nuxt/ui/issues/1609)) ([2392b4a](https://github.com/nuxt/ui/commit/2392b4aa405430fc22766f130448a7cc5ced9a3a))
|
||||
* **Slideover:** remove dynamic component when closing ([#1615](https://github.com/nuxt/ui/issues/1615)) ([58faa10](https://github.com/nuxt/ui/commit/58faa1053b9be3f627c3fcff1bcaa14850bb9e7f))
|
||||
* **Slideover:** wait for transition to complete to reset state ([#1624](https://github.com/nuxt/ui/issues/1624)) ([07a4d13](https://github.com/nuxt/ui/commit/07a4d13c0fcb05c87fb42e02a3a2d6c5c52ccf09))
|
||||
|
||||
## [2.15.1](https://github.com/nuxt/ui/compare/v2.15.0...v2.15.1) (2024-04-02)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **Avatar:** add `as` prop to use `NuxtImg` underneath ([49b73aa](https://github.com/nuxt/ui/commit/49b73aa024be14a9aa150a2804f4dcb18542fa49)), closes [#1577](https://github.com/nuxt/ui/issues/1577)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **Checkbox:** `[@change](https://github.com/change)` event value ([#1580](https://github.com/nuxt/ui/issues/1580)) ([c98d6e3](https://github.com/nuxt/ui/commit/c98d6e31c0e3f46b97957d5cf3de7f9da1f70c58))
|
||||
* **Divider:** add `w-full` only on horizontal wrapper ([#1565](https://github.com/nuxt/ui/issues/1565)) ([bd8b737](https://github.com/nuxt/ui/commit/bd8b737642280e6a83b67f9a27dd7a823a77e963))
|
||||
* **Dropdown:** missing `mouseenter` event on container ([7288953](https://github.com/nuxt/ui/commit/72889535e7e9763e7ebf59498f22c39bf09d6477))
|
||||
* **Input/SelectMenu:** handle `file` type and `change` events ([#1570](https://github.com/nuxt/ui/issues/1570)) ([878f707](https://github.com/nuxt/ui/commit/878f7078a28c5e70a662682d1293db466d518c7d))
|
||||
* **Popover:** missing `mouseenter` event on container ([8517897](https://github.com/nuxt/ui/commit/8517897c34adaa9e3624f867b43106deb59fcbe8)), closes [#1564](https://github.com/nuxt/ui/issues/1564)
|
||||
|
||||
## [2.15.0](https://github.com/nuxt/ui/compare/v2.14.2...v2.15.0) (2024-03-26)
|
||||
|
||||
|
||||
|
||||
19
README.md
19
README.md
@@ -27,24 +27,7 @@ Read more on [ui.nuxt.com](https://ui.nuxt.com)
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm install @nuxt/ui
|
||||
# yarn
|
||||
yarn add @nuxt/ui
|
||||
# pnpm
|
||||
pnpm add @nuxt/ui
|
||||
# bun
|
||||
bun add @nuxt/ui
|
||||
```
|
||||
|
||||
Then, register the module in your `nuxt.config.ts`:
|
||||
|
||||
```js
|
||||
export default defineNuxtConfig({
|
||||
modules: [
|
||||
'@nuxt/ui'
|
||||
]
|
||||
})
|
||||
npx nuxi@latest module add ui
|
||||
```
|
||||
|
||||
If you want latest updates, please use `@nuxt/ui-edge` in your `package.json`:
|
||||
|
||||
@@ -72,7 +72,7 @@ const links = computed(() => {
|
||||
active: route.path.startsWith('/pro/getting-started') || route.path.startsWith('/pro/components') || route.path.startsWith('/pro/prose')
|
||||
}, {
|
||||
label: 'Pricing',
|
||||
icon: 'i-heroicons-credit-card',
|
||||
icon: 'i-heroicons-ticket',
|
||||
to: '/pro/pricing'
|
||||
}, {
|
||||
label: 'Templates',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div class="[&>div>pre]:!rounded-t-none [&>div>pre]:!mt-0">
|
||||
<div
|
||||
v-if="hasPreview"
|
||||
class="flex border border-gray-200 dark:border-gray-700 relative rounded-t-md"
|
||||
:class="[{ 'p-4': padding, 'rounded-b-md': !hasCode, 'border-b-0': hasCode, 'not-prose': !prose }, backgroundClass, extraClass]"
|
||||
>
|
||||
@@ -37,6 +38,10 @@ const props = defineProps({
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
hiddenPreview: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
hiddenCode: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
@@ -79,6 +84,7 @@ const data = await fetchContentExampleCode(camelName)
|
||||
const highlighter = useShikiHighlighter()
|
||||
|
||||
const hasCode = computed(() => !props.hiddenCode && (data?.code || instance.slots.code))
|
||||
const hasPreview = computed(() => !props.hiddenPreview && (props.component || instance.slots.default))
|
||||
|
||||
const { data: ast } = await useAsyncData(`content-example-${camelName}-ast`, () => transformContent('content:_markdown.md', `\`\`\`vue\n${data?.code ?? ''}\n\`\`\``, {
|
||||
markdown: {
|
||||
|
||||
@@ -61,9 +61,7 @@ const items = [{
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center">
|
||||
<code>$ npm i @nuxt/ui</code>
|
||||
<code>$ yarn add @nuxt/ui</code>
|
||||
<code>$ pnpm add @nuxt/ui</code>
|
||||
<code>$ npx nuxi@latest module add ui</code>
|
||||
</div>
|
||||
</template>
|
||||
</UAccordion>
|
||||
|
||||
@@ -12,7 +12,7 @@ const links = [{
|
||||
<template>
|
||||
<UBreadcrumb :links="links">
|
||||
<template #default="{ link, isActive, index }">
|
||||
<UBadge :color="isActive ? 'primary' : 'gray'" class="rounded-full">
|
||||
<UBadge :color="isActive ? 'primary' : 'gray'" class="rounded-full truncate">
|
||||
{{ index + 1 }}. {{ link.label }}
|
||||
</UBadge>
|
||||
</template>
|
||||
|
||||
@@ -5,13 +5,24 @@ defineProps({
|
||||
default: 0
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
|
||||
function onSuccess () {
|
||||
emit('success')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal>
|
||||
<UCard>
|
||||
<p>This modal was opened programmatically !</p>
|
||||
<p>Count: {{ count }}</p>
|
||||
<div class="space-y-2">
|
||||
<p>This modal was opened programmatically !</p>
|
||||
<p>Count: {{ count }}</p>
|
||||
<UButton @click="onSuccess">
|
||||
Click to emit a success event
|
||||
</UButton>
|
||||
</div>
|
||||
</UCard>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { ModalExampleComponent } from '#components'
|
||||
|
||||
const toast = useToast()
|
||||
const modal = useModal()
|
||||
const count = ref(0)
|
||||
|
||||
function openModal () {
|
||||
count.value += 1
|
||||
modal.open(ModalExampleComponent, {
|
||||
count: count.value
|
||||
count: count.value,
|
||||
onSuccess () {
|
||||
toast.add({
|
||||
title: 'Success !',
|
||||
id: 'modal-success'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
17
docs/components/content/examples/PaginationExampleTo.vue
Normal file
17
docs/components/content/examples/PaginationExampleTo.vue
Normal 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>
|
||||
@@ -186,7 +186,7 @@ const { data: todos, pending } = await useLazyAsyncData<{
|
||||
sort-desc-icon="i-heroicons-arrow-down"
|
||||
sort-mode="manual"
|
||||
class="w-full"
|
||||
:ui="{ td: { base: 'max-w-[0] truncate' } }"
|
||||
:ui="{ td: { base: 'max-w-[0] truncate' }, default: { checkbox: { color: 'gray' } } }"
|
||||
@select="select"
|
||||
>
|
||||
<template #completed-data="{ row }">
|
||||
|
||||
63
docs/components/content/examples/TableExampleCaptionSlot.vue
Normal file
63
docs/components/content/examples/TableExampleCaptionSlot.vue
Normal 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>
|
||||
@@ -5,29 +5,15 @@ description: 'Learn how to install and configure Nuxt UI in your application.'
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install `@nuxt/ui` dependency to your project:
|
||||
### Add to a Nuxt project
|
||||
|
||||
::code-group
|
||||
1. Add `@nuxt/ui` module to your project:
|
||||
|
||||
```bash [pnpm]
|
||||
pnpm add @nuxt/ui
|
||||
```bash
|
||||
npx nuxi@latest module add ui
|
||||
```
|
||||
|
||||
```bash [yarn]
|
||||
yarn add @nuxt/ui
|
||||
```
|
||||
|
||||
```bash [npm]
|
||||
npm install @nuxt/ui
|
||||
```
|
||||
|
||||
```bash [bun]
|
||||
bun add @nuxt/ui
|
||||
```
|
||||
|
||||
::
|
||||
|
||||
2. Add it to your `modules` section in your `nuxt.config`:
|
||||
2. Add it to the `modules` section in your `nuxt.config.ts`:
|
||||
|
||||
```ts [nuxt.config.ts]
|
||||
export default defineNuxtConfig({
|
||||
@@ -37,6 +23,19 @@ export default defineNuxtConfig({
|
||||
|
||||
That's it! You can now use all the components and composables in your Nuxt app ✨
|
||||
|
||||
### Use Nuxt starter
|
||||
|
||||
[Nuxt Starter](https://nuxt.new/) template makes it easy to get started with Nuxt UI.
|
||||
The Nuxt Starter template is available from the `nuxi init` command.
|
||||
|
||||
```bash
|
||||
npx nuxi@latest init -t ui
|
||||
```
|
||||
|
||||
|
||||
|
||||
Please check [nuxt/starter](https://github.com/nuxt/starter/tree/ui) for details.
|
||||
|
||||
## Modules
|
||||
|
||||
Nuxt UI will automatically install the [@nuxtjs/tailwindcss](https://tailwindcss.nuxtjs.org/), [@nuxtjs/color-mode](https://color-mode.nuxtjs.org/) and [nuxt-icon](https://github.com/nuxt-modules/icon) modules for you.
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
---
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -77,7 +77,13 @@ First of all, add the `Modals` component to your app, preferably inside `app.vue
|
||||
|
||||
Then, you can use the `useModal` composable to control your modals within your app.
|
||||
|
||||
:component-example{component="modal-example-composable"}
|
||||
<!-- For prerendering -->
|
||||
:component-example{component="modal-example-component" hiddenCode hiddenPreview }
|
||||
|
||||
::code-group{class="[&>div:last-child>div:first-child]:!rounded-t-none"}
|
||||
:component-example{component="modal-example-composable" label="app.vue" }
|
||||
:component-example{component="modal-example-component" hiddenPreview label="modal.vue" }
|
||||
::
|
||||
|
||||
Additionally, you can close the modal within the modal component by calling `modal.close()`.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -79,7 +79,7 @@ const links = computed(() => {
|
||||
active: route.path.startsWith('/pro/getting-started') || route.path.startsWith('/pro/components') || route.path.startsWith('/pro/prose')
|
||||
}, {
|
||||
label: 'Pricing',
|
||||
icon: 'i-heroicons-credit-card',
|
||||
icon: 'i-heroicons-ticket',
|
||||
to: '/pro/pricing'
|
||||
}, {
|
||||
label: 'Templates',
|
||||
|
||||
@@ -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)
|
||||
@@ -74,16 +74,6 @@ export default defineNuxtConfig({
|
||||
image: {
|
||||
provider: 'ipx'
|
||||
},
|
||||
fontMetrics: {
|
||||
fonts: ['DM Sans']
|
||||
},
|
||||
googleFonts: {
|
||||
display: 'swap',
|
||||
download: true,
|
||||
families: {
|
||||
'DM+Sans': [400, 500, 600, 700]
|
||||
}
|
||||
},
|
||||
nitro: {
|
||||
prerender: {
|
||||
routes: [
|
||||
|
||||
@@ -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.97",
|
||||
"@iconify-json/heroicons": "^1.1.21",
|
||||
"@iconify-json/simple-icons": "^1.1.101",
|
||||
"@nuxt/content": "^2.12.1",
|
||||
"@nuxt/eslint-config": "^0.2.0",
|
||||
"@nuxt/fonts": "^0.5.1",
|
||||
"@nuxt/image": "^1.4.0",
|
||||
"@nuxt/ui-pro": "npm:@nuxt/ui-pro-edge@1.1.0-28524317.f36a434",
|
||||
"@nuxtjs/plausible": "^0.2.4",
|
||||
"@octokit/rest": "^20.0.2",
|
||||
"@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.1",
|
||||
"@vueuse/nuxt": "^10.9.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"eslint": "^8.57.0",
|
||||
"joi": "^17.12.2",
|
||||
"nuxt": "^3.11.1",
|
||||
"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.3",
|
||||
"typescript": "^5.4.5",
|
||||
"ufo": "^1.5.3",
|
||||
"v-calendar": "^3.1.2",
|
||||
"valibot": "^0.30.0",
|
||||
"yup": "^1.4.0",
|
||||
"zod": "^3.22.4"
|
||||
"zod": "^3.23.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,10 +31,11 @@
|
||||
readonly
|
||||
autocomplete="off"
|
||||
icon="i-heroicons-command-line"
|
||||
input-class="select-none"
|
||||
class="w-72"
|
||||
input-class="focus:ring-1 focus:ring-gray-300 dark:focus:ring-gray-700"
|
||||
aria-label="Install @nuxt/ui"
|
||||
size="lg"
|
||||
:ui="{ base: 'disabled:cursor-default', icon: { trailing: { pointer: '' } } }"
|
||||
:ui="{ icon: { trailing: { pointer: '' } } }"
|
||||
>
|
||||
<template #trailing>
|
||||
<UButton
|
||||
@@ -435,7 +436,7 @@ useSeoMeta({
|
||||
twitterImage: 'https://ui.nuxt.com/social-card.png'
|
||||
})
|
||||
|
||||
const source = ref('npm i @nuxt/ui')
|
||||
const source = ref('npx nuxi@latest module add ui')
|
||||
const sectionRef = ref()
|
||||
const demoRef = ref()
|
||||
const start = ref(0)
|
||||
|
||||
49
package.json
49
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nuxt/ui",
|
||||
"version": "2.15.0",
|
||||
"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",
|
||||
"@nuxt/kit": "^3.11.1",
|
||||
"@nuxtjs/color-mode": "^3.3.3",
|
||||
"@nuxtjs/tailwindcss": "^6.11.4",
|
||||
"@headlessui/vue": "^1.7.21",
|
||||
"@iconify-json/heroicons": "^1.1.21",
|
||||
"@nuxt/kit": "^3.11.2",
|
||||
"@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.10",
|
||||
"@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",
|
||||
"tailwindcss": "^3.4.1"
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"tailwindcss": "^3.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxt/eslint-config": "^0.2.0",
|
||||
"@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.3.6",
|
||||
"joi": "^17.12.2",
|
||||
"nuxt": "^3.11.1",
|
||||
"release-it": "^17.1.1",
|
||||
"typescript": "^5.4.3",
|
||||
"happy-dom": "^14.10.1",
|
||||
"joi": "^17.13.1",
|
||||
"nuxt": "^3.11.2",
|
||||
"release-it": "^17.2.1",
|
||||
"typescript": "^5.4.5",
|
||||
"unbuild": "^2.0.0",
|
||||
"valibot": "^0.30.0",
|
||||
"vitest": "^1.4.0",
|
||||
"vitest": "^1.6.0",
|
||||
"vitest-environment-nuxt": "^1.0.0",
|
||||
"vue-tsc": "^2.0.7",
|
||||
"vue-tsc": "^2.0.16",
|
||||
"yup": "^1.4.0",
|
||||
"zod": "^3.22.4"
|
||||
"zod": "^3.23.6"
|
||||
},
|
||||
"resolutions": {
|
||||
"@nuxt/kit": "^3.11.1",
|
||||
"@nuxt/schema": "3.11.1",
|
||||
"tailwindcss": "3.4.1",
|
||||
"@headlessui/vue": "1.7.19",
|
||||
"vue": "3.4.21"
|
||||
"@nuxt/ui": "workspace:*",
|
||||
"@nuxt/module-builder": "0.5.5"
|
||||
}
|
||||
}
|
||||
|
||||
14
playground/package.json
Normal file
14
playground/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
18335
pnpm-lock.yaml
generated
18335
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
packages:
|
||||
- "docs"
|
||||
- "./"
|
||||
- "docs"
|
||||
- "playground"
|
||||
|
||||
102
src/module.ts
102
src/module.ts
@@ -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
|
||||
|
||||
|
||||
@@ -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" aria-label="Select all" @change="onChange" />
|
||||
<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"
|
||||
@@ -57,7 +68,7 @@
|
||||
<template v-else>
|
||||
<tr v-for="(row, index) in rows" :key="index" :class="[ui.tr.base, isSelected(row) && ui.tr.selected, $attrs.onSelect && ui.tr.active, row?.class]" @click="() => onSelect(row)">
|
||||
<td v-if="modelValue" :class="ui.checkbox.padding">
|
||||
<UCheckbox v-model="selected" :value="row" aria-label="Select row" @click.stop />
|
||||
<UCheckbox v-model="selected" :value="row" v-bind="ui.default.checkbox" aria-label="Select row" @click.stop />
|
||||
</td>
|
||||
|
||||
<td v-for="(column, subIndex) in columns" :key="subIndex" :class="[ui.td.base, ui.td.padding, ui.td.color, ui.td.font, ui.td.size, row[column.key]?.class]">
|
||||
@@ -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
|
||||
@@ -280,8 +304,8 @@ export default defineComponent({
|
||||
})
|
||||
}
|
||||
|
||||
function onChange (event: any) {
|
||||
if (event.target.checked) {
|
||||
function onChange (checked: boolean) {
|
||||
if (checked) {
|
||||
selectAllRows()
|
||||
} else {
|
||||
selected.value = []
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -39,13 +39,27 @@
|
||||
@before-leave="onBeforeLeave"
|
||||
@leave="onLeave"
|
||||
>
|
||||
<div v-show="open">
|
||||
<HDisclosurePanel :class="[ui.item.base, ui.item.size, ui.item.color, ui.item.padding]" static>
|
||||
<slot :name="item.slot || 'item'" :item="item" :index="index" :open="open" :close="close">
|
||||
{{ item.content }}
|
||||
</slot>
|
||||
</HDisclosurePanel>
|
||||
</div>
|
||||
<HDisclosurePanel
|
||||
v-if="unmount"
|
||||
:class="[ui.item.base, ui.item.size, ui.item.color, ui.item.padding]"
|
||||
unmount
|
||||
>
|
||||
<slot :name="item.slot || 'item'" :item="item" :index="index" :open="open" :close="close">
|
||||
{{ item.content }}
|
||||
</slot>
|
||||
</HDisclosurePanel>
|
||||
<template v-else>
|
||||
<div v-show="open">
|
||||
<HDisclosurePanel
|
||||
:class="[ui.item.base, ui.item.size, ui.item.color, ui.item.padding]"
|
||||
static
|
||||
>
|
||||
<slot :name="item.slot || 'item'" :item="item" :index="index" :open="open" :close="close">
|
||||
{{ item.content }}
|
||||
</slot>
|
||||
</HDisclosurePanel>
|
||||
</div>
|
||||
</template>
|
||||
</Transition>
|
||||
</HDisclosure>
|
||||
</div>
|
||||
@@ -91,6 +105,10 @@ export default defineComponent({
|
||||
type: String,
|
||||
default: () => config.default.openIcon
|
||||
},
|
||||
unmount: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
closeIcon: {
|
||||
type: String,
|
||||
default: () => config.default.closeIcon
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<template>
|
||||
<span :class="wrapperClass">
|
||||
<img
|
||||
<component
|
||||
:is="as"
|
||||
v-if="url && !error"
|
||||
:class="imgClass"
|
||||
:alt="alt"
|
||||
:src="url"
|
||||
v-bind="attrs"
|
||||
@error="onError"
|
||||
>
|
||||
/>
|
||||
<span v-else-if="text" :class="ui.text">{{ text }}</span>
|
||||
<UIcon v-else-if="icon" :name="icon" :class="iconClass" />
|
||||
<span v-else-if="placeholder" :class="ui.placeholder">{{ placeholder }}</span>
|
||||
@@ -39,6 +40,10 @@ export default defineComponent({
|
||||
},
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
as: {
|
||||
type: [String, Object],
|
||||
default: 'img'
|
||||
},
|
||||
src: {
|
||||
type: [String, Boolean],
|
||||
default: null
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { ref, toRef, toRefs, computed, defineComponent } from 'vue'
|
||||
import { ref, toRef, computed, defineComponent } from 'vue'
|
||||
import type { PropType } from 'vue'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { mergeConfig } from '../../utils'
|
||||
@@ -112,10 +112,9 @@ export default defineComponent({
|
||||
const carouselRef = ref<HTMLElement>()
|
||||
const itemWidth = ref(0)
|
||||
|
||||
const { x, arrivedState } = useScroll(carouselRef, { behavior: 'smooth' })
|
||||
const { width: carouselWidth } = useElementSize(carouselRef)
|
||||
const { x } = useScroll(carouselRef, { behavior: 'smooth' })
|
||||
|
||||
const { left: isFirst, right: isLast } = toRefs(arrivedState)
|
||||
const { width: carouselWidth } = useElementSize(carouselRef)
|
||||
|
||||
useCarouselScroll(carouselRef)
|
||||
|
||||
@@ -125,7 +124,13 @@ export default defineComponent({
|
||||
itemWidth.value = entry?.target?.firstElementChild?.clientWidth || 0
|
||||
})
|
||||
|
||||
const currentPage = computed(() => Math.round(x.value / itemWidth.value) + 1)
|
||||
const currentPage = computed(() => {
|
||||
if (!itemWidth.value) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return Math.round(x.value / itemWidth.value) + 1
|
||||
})
|
||||
|
||||
const pages = computed(() => {
|
||||
if (!itemWidth.value) {
|
||||
@@ -135,6 +140,9 @@ export default defineComponent({
|
||||
return props.items.length - Math.round(carouselWidth.value / itemWidth.value) + 1
|
||||
})
|
||||
|
||||
const isFirst = computed(() => currentPage.value <= 1)
|
||||
const isLast = computed(() => currentPage.value === pages.value)
|
||||
|
||||
function onClickNext () {
|
||||
x.value += itemWidth.value
|
||||
}
|
||||
|
||||
@@ -182,8 +182,8 @@ export default defineComponent({
|
||||
}
|
||||
})
|
||||
|
||||
function onTouchStart () {
|
||||
if (!menuApi.value) {
|
||||
function onTouchStart (event: TouchEvent) {
|
||||
if (!event.cancelable || !menuApi.value) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ export default defineComponent({
|
||||
})
|
||||
|
||||
const onChange = (event: Event) => {
|
||||
emit('change', (event.target as HTMLInputElement).value)
|
||||
emit('change', (event.target as HTMLInputElement).checked)
|
||||
emitFormChange()
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<TransitionRoot :appear="appear" :show="isOpen" as="template">
|
||||
<TransitionRoot :appear="appear" :show="isOpen" as="template" @after-leave="onAfterLeave">
|
||||
<HDialog :class="ui.wrapper" 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]" />
|
||||
@@ -82,7 +82,7 @@ export default defineComponent({
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue', 'close', 'close-prevented'],
|
||||
emits: ['update:modelValue', 'close', 'close-prevented', 'after-leave'],
|
||||
setup (props, { emit }) {
|
||||
const { ui, attrs } = useUI('modal', toRef(props, 'ui'), config, toRef(props, 'class'))
|
||||
|
||||
@@ -117,6 +117,10 @@ export default defineComponent({
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const onAfterLeave = () => {
|
||||
emit('after-leave')
|
||||
}
|
||||
|
||||
provideUseId(() => useId())
|
||||
|
||||
return {
|
||||
@@ -125,6 +129,7 @@ export default defineComponent({
|
||||
attrs,
|
||||
isOpen,
|
||||
transitionClass,
|
||||
onAfterLeave,
|
||||
close
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<template>
|
||||
<component :is="modalState.component" v-if="modalState" v-bind="modalState.props" v-model="isOpen" />
|
||||
<component
|
||||
:is="modalState.component"
|
||||
v-if="modalState"
|
||||
v-bind="modalState.props"
|
||||
v-model="isOpen"
|
||||
@after-leave="reset"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
@@ -8,5 +14,5 @@ import { useModal, modalInjectionKey } from '../../composables/useModal'
|
||||
|
||||
const modalState = inject(modalInjectionKey)
|
||||
|
||||
const { isOpen } = useModal()
|
||||
const { isOpen, reset } = useModal()
|
||||
</script>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -154,8 +154,8 @@ export default defineComponent({
|
||||
}
|
||||
})
|
||||
|
||||
function onTouchStart () {
|
||||
if (!popoverApi.value) {
|
||||
function onTouchStart (event: TouchEvent) {
|
||||
if (!event.cancelable || !popoverApi.value) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<TransitionRoot as="template" :appear="appear" :show="isOpen">
|
||||
<TransitionRoot as="template" :appear="appear" :show="isOpen" @after-leave="onAfterLeave">
|
||||
<HDialog :class="[ui.wrapper, { 'justify-end': side === 'right' }]" 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]" />
|
||||
@@ -71,7 +71,7 @@ export default defineComponent({
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue', 'close', 'close-prevented'],
|
||||
emits: ['update:modelValue', 'close', 'close-prevented', 'after-leave'],
|
||||
setup (props, { emit }) {
|
||||
const { ui, attrs } = useUI('slideover', toRef(props, 'ui'), config, toRef(props, 'class'))
|
||||
|
||||
@@ -109,6 +109,10 @@ export default defineComponent({
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const onAfterLeave = () => {
|
||||
emit('after-leave')
|
||||
}
|
||||
|
||||
provideUseId(() => useId())
|
||||
|
||||
return {
|
||||
@@ -117,6 +121,7 @@ export default defineComponent({
|
||||
attrs,
|
||||
isOpen,
|
||||
transitionClass,
|
||||
onAfterLeave,
|
||||
close
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
v-if="slideoverState"
|
||||
v-bind="slideoverState.props"
|
||||
v-model="isOpen"
|
||||
@after-leave="reset"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -13,5 +14,5 @@ import { useSlideover, slidOverInjectionKey } from '../../composables/useSlideov
|
||||
|
||||
const slideoverState = inject(slidOverInjectionKey)
|
||||
|
||||
const { isOpen } = useSlideover()
|
||||
const { isOpen, reset } = useSlideover()
|
||||
</script>
|
||||
|
||||
@@ -8,19 +8,29 @@ export const modalInjectionKey: InjectionKey<ShallowRef<ModalState>> = Symbol('n
|
||||
|
||||
function _useModal () {
|
||||
const modalState = inject(modalInjectionKey)
|
||||
|
||||
|
||||
const isOpen = ref(false)
|
||||
|
||||
function open<T extends Component> (component: T, props?: Modal & ComponentProps<T>) {
|
||||
if (!modalState) {
|
||||
throw new Error('useModal() is called without provider')
|
||||
}
|
||||
|
||||
modalState.value = {
|
||||
component,
|
||||
props: props ?? {}
|
||||
}
|
||||
|
||||
isOpen.value = true
|
||||
}
|
||||
|
||||
function close () {
|
||||
async function close () {
|
||||
if (!modalState) return
|
||||
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function reset () {
|
||||
modalState.value = {
|
||||
component: 'div',
|
||||
props: {}
|
||||
@@ -31,6 +41,8 @@ function _useModal () {
|
||||
* Allows updating the modal props
|
||||
*/
|
||||
function patch <T extends Component = {}> (props: Partial<Modal & ComponentProps<T>>) {
|
||||
if (!modalState) return
|
||||
|
||||
modalState.value = {
|
||||
...modalState.value,
|
||||
props: {
|
||||
@@ -41,11 +53,12 @@ function _useModal () {
|
||||
}
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
open,
|
||||
close,
|
||||
patch
|
||||
reset,
|
||||
patch,
|
||||
isOpen
|
||||
}
|
||||
}
|
||||
|
||||
export const useModal = createSharedComposable(_useModal)
|
||||
export const useModal = createSharedComposable(_useModal)
|
||||
|
||||
@@ -1,55 +1,64 @@
|
||||
import { ref, inject } from 'vue'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
import type { ShallowRef, Component, InjectionKey } from 'vue'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
import type { ComponentProps } from '../types/component'
|
||||
import type { Slideover, SlideoverState } from '../types/slideover'
|
||||
|
||||
export const slidOverInjectionKey: InjectionKey<ShallowRef<SlideoverState>> =
|
||||
Symbol('nuxt-ui.slideover')
|
||||
export const slidOverInjectionKey: InjectionKey<ShallowRef<SlideoverState>> = Symbol('nuxt-ui.slideover')
|
||||
|
||||
function _useSlideover () {
|
||||
const slideoverState = inject(slidOverInjectionKey)
|
||||
const isOpen = ref(false)
|
||||
const slideoverState = inject(slidOverInjectionKey)
|
||||
|
||||
function open<T extends Component> (component: T, props?: Slideover & ComponentProps<T>) {
|
||||
if (!slideoverState) {
|
||||
throw new Error('useSlideover() is called without provider')
|
||||
}
|
||||
const isOpen = ref(false)
|
||||
|
||||
slideoverState.value = {
|
||||
component,
|
||||
props: props ?? {}
|
||||
}
|
||||
|
||||
isOpen.value = true
|
||||
function open<T extends Component> (component: T, props?: Slideover & ComponentProps<T>) {
|
||||
if (!slideoverState) {
|
||||
throw new Error('useSlideover() is called without provider')
|
||||
}
|
||||
|
||||
function close () {
|
||||
if (!slideoverState) return
|
||||
|
||||
isOpen.value = false
|
||||
slideoverState.value = {
|
||||
component,
|
||||
props: props ?? {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows updating the slideover props
|
||||
*/
|
||||
function patch<T extends Component = {}> (props: Partial<Slideover & ComponentProps<T>>) {
|
||||
if (!slideoverState) return
|
||||
isOpen.value = true
|
||||
}
|
||||
|
||||
slideoverState.value = {
|
||||
...slideoverState.value,
|
||||
props: {
|
||||
...slideoverState.value.props,
|
||||
...props
|
||||
}
|
||||
}
|
||||
async function close () {
|
||||
if (!slideoverState) return
|
||||
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function reset () {
|
||||
slideoverState.value = {
|
||||
component: 'div',
|
||||
props: {}
|
||||
}
|
||||
return {
|
||||
open,
|
||||
close,
|
||||
patch,
|
||||
isOpen
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows updating the slideover props
|
||||
*/
|
||||
function patch<T extends Component = {}> (props: Partial<Slideover & ComponentProps<T>>) {
|
||||
if (!slideoverState) return
|
||||
|
||||
slideoverState.value = {
|
||||
...slideoverState.value,
|
||||
props: {
|
||||
...slideoverState.value.props,
|
||||
...props
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
open,
|
||||
close,
|
||||
reset,
|
||||
patch,
|
||||
isOpen
|
||||
}
|
||||
}
|
||||
|
||||
export const useSlideover = createSharedComposable(_useSlideover)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
23
src/runtime/types/slideover.d.ts
vendored
23
src/runtime/types/slideover.d.ts
vendored
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
@@ -50,6 +51,9 @@ export default {
|
||||
variant: 'ghost' as const,
|
||||
class: '-m-1.5'
|
||||
},
|
||||
checkbox: {
|
||||
color: 'primary' as const
|
||||
},
|
||||
progress: {
|
||||
color: 'primary' as const,
|
||||
animation: 'carousel' as const
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export default {
|
||||
wrapper: 'relative',
|
||||
wrapper: 'relative min-w-0',
|
||||
ol: 'flex items-center gap-x-1.5',
|
||||
li: 'flex items-center gap-x-1.5 text-gray-500 dark:text-gray-400 text-sm leading-6 min-w-0',
|
||||
base: 'flex items-center gap-x-1.5 group font-semibold min-w-0',
|
||||
|
||||
@@ -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
87
src/tailwind.ts
Normal 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))
|
||||
}
|
||||
@@ -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
88
test/colors.spec.ts
Normal 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
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user