diff --git a/docs/components/content/ComponentCard.vue b/docs/components/content/ComponentCard.vue index 4ba5f5b..3987066 100644 --- a/docs/components/content/ComponentCard.vue +++ b/docs/components/content/ComponentCard.vue @@ -7,7 +7,7 @@ v-if="prop.type === 'boolean'" v-model="componentProps[prop.name]" :name="`prop-${prop.name}`" - variant="none" + tabindex="-1" :ui="{ wrapper: 'relative flex items-start justify-center' }" /> <USelectMenu @@ -18,6 +18,7 @@ variant="none" :ui="{ width: 'w-32 !-mt-px', rounded: 'rounded-b-md', wrapper: 'relative inline-flex' }" class="!py-0" + tabindex="-1" :popper="{ strategy: 'fixed', placement: 'bottom-start' }" /> <UInput @@ -28,6 +29,7 @@ variant="none" autocomplete="off" class="!py-0" + tabindex="-1" @update:model-value="val => componentProps[prop.name] = prop.type === 'number' ? Number(val) : val" /> </div> diff --git a/docs/components/content/examples/CheckboxExample.vue b/docs/components/content/examples/CheckboxExample.vue index d34d412..4196909 100644 --- a/docs/components/content/examples/CheckboxExample.vue +++ b/docs/components/content/examples/CheckboxExample.vue @@ -1,5 +1,5 @@ <script setup> -const selected = ref(false) +const selected = ref(true) </script> <template> diff --git a/docs/components/content/examples/RangeExample.vue b/docs/components/content/examples/RangeExample.vue new file mode 100644 index 0000000..a5aeda2 --- /dev/null +++ b/docs/components/content/examples/RangeExample.vue @@ -0,0 +1,7 @@ +<script setup> +const value = ref(50) +</script> + +<template> + <URange v-model="value" /> +</template> diff --git a/docs/content/1.getting-started/3.theming.md b/docs/content/1.getting-started/3.theming.md index 512ecf3..a5aaba3 100644 --- a/docs/content/1.getting-started/3.theming.md +++ b/docs/content/1.getting-started/3.theming.md @@ -33,7 +33,7 @@ Likewise, you can't define a `primary` color in your `tailwind.config.ts` as it We'd advise you to use those colors in your components and pages, e.g. `text-primary-500 dark:text-primary-400`, `bg-gray-100 dark:bg-gray-900`, etc. so your app automatically adapts when changing your `app.config.ts`. :: -Components having a `color` prop like [Avatar](/elements/avatar#chip), [Badge](/elements/badge#style), [Button](/elements/button#style), [Input](/elements/input#style) (inherited in [Select](/forms/select) and [SelectMenu](/forms/select-menu)) and [Notification](/overlays/notification#timeout) will use the `primary` color by default but will handle all the colors defined in your `tailwind.config.ts` or the default Tailwind CSS colors. +Components having a `color` prop like [Avatar](/elements/avatar#chip), [Badge](/elements/badge#style), [Button](/elements/button#style), [Input](/elements/input#style) (inherited in [Select](/forms/select) and [SelectMenu](/forms/select-menu)), [Radio](/forms/radio), [Checkbox](/forms/checkbox), [Toggle](/forms/toggle), [Range](/forms/range) and [Notification](/overlays/notification#timeout) will use the `primary` color by default but will handle all the colors defined in your `tailwind.config.ts` or the default Tailwind CSS colors. Variant classes of those components are defined with a syntax like `bg-{color}-500 dark:bg-{color}-400` so they can be used with any color. However, this means that Tailwind will not find those classes and therefore will not generate the corresponding CSS. diff --git a/docs/content/3.forms/5.checkbox.md b/docs/content/3.forms/5.checkbox.md index 9c1c634..c34d5fb 100644 --- a/docs/content/3.forms/5.checkbox.md +++ b/docs/content/3.forms/5.checkbox.md @@ -14,7 +14,7 @@ Use a `v-model` to make the Checkbox reactive. #code ```vue <script setup> -const selected = ref(false) +const selected = ref(true) </script> <template> @@ -36,6 +36,20 @@ props: --- :: +### Style + +Use the `color` prop to change the style of the Checkbox. + +::component-card +--- +baseProps: + name: 'checkbox2' + label: 'Label' +props: + color: 'primary' +--- +:: + ### Required Use the `required` prop to display a red star next to the label. @@ -43,7 +57,7 @@ Use the `required` prop to display a red star next to the label. ::component-card --- baseProps: - name: 'checkbox2' + name: 'checkbox3' props: label: 'Label' required: true @@ -57,7 +71,7 @@ Use the `help` prop to display some text under the Checkbox. ::component-card --- baseProps: - name: 'checkbox3' + name: 'checkbox4' props: label: 'Label' help: 'Please check this box' diff --git a/docs/content/3.forms/6.radio.md b/docs/content/3.forms/6.radio.md index 3ef937d..d806080 100644 --- a/docs/content/3.forms/6.radio.md +++ b/docs/content/3.forms/6.radio.md @@ -50,6 +50,20 @@ props: --- :: +### Style + +Use the `color` prop to change the style of the Radio. + +::component-card +--- +baseProps: + name: 'radio2' + label: 'Label' +props: + color: 'primary' +--- +:: + ### Required Use the `required` prop to display a red star next to the label. @@ -57,7 +71,7 @@ Use the `required` prop to display a red star next to the label. ::component-card --- baseProps: - name: 'radio2' + name: 'radio3' props: label: 'Label' required: true @@ -71,7 +85,7 @@ Use the `help` prop to display some text under the Radio. ::component-card --- baseProps: - name: 'radio3' + name: 'radio4' props: label: 'Label' help: 'Please choose one' @@ -85,7 +99,7 @@ Use the `disabled` prop to disable the Radio. ::component-card --- baseProps: - name: 'radio4' + name: 'radio5' value: true props: disabled: true diff --git a/docs/content/3.forms/7.toggle.md b/docs/content/3.forms/7.toggle.md index 8ede6d5..3994d64 100644 --- a/docs/content/3.forms/7.toggle.md +++ b/docs/content/3.forms/7.toggle.md @@ -26,6 +26,17 @@ const selected = ref(false) ``` :: +### Style + +Use the `color` prop to change the style of the Toggle. + +::component-card +--- +props: + color: 'primary' +--- +:: + ### Icon Use any icon from [Iconify](https://icones.js.org) by setting the `on-icon` and `off-icon` props by using this pattern: `i-{collection_name}-{icon_name}` or change it globally in `ui.toggle.default.onIcon` and `ui.toggle.default.offIcon`. diff --git a/docs/content/3.forms/8.range.md b/docs/content/3.forms/8.range.md new file mode 100644 index 0000000..1bf519e --- /dev/null +++ b/docs/content/3.forms/8.range.md @@ -0,0 +1,101 @@ +--- +github: true +description: Display a range field +navigation: + badge: "Edge" +--- + +## Usage + +Use a `v-model` to make the Range reactive. + +::component-example +#default +:range-example + +#code +```vue +<script setup> +const value = ref(50) +</script> + +<template> + <URange v-model="value" /> +</template> +``` +:: + +### Style + +Use the `color` prop to change the visual style of the Range. + +::component-card +--- +baseProps: + name: range' + placeholder: 'Search...' +props: + color: 'primary' +--- +:: + +### Size + +Use the `size` prop to change the size of the Range. + +::component-card +--- +baseProps: + name: 'range' +props: + size: 'md' +--- +:: + +### Disabled + +Use the `disabled` prop to disable the Range. + +::component-card +--- +baseProps: + name: 'range' +props: + disabled: true +--- +:: + +### Min and Max + +Use the `min` and `max` prop to configure the Range. + +::component-card +--- +baseProps: + name: 'range' +props: + min: 0 + max: 100 +--- +:: + +### Step + +Use the `step` prop to change the step increment. + +::component-card +--- +baseProps: + name: 'range' +props: + step: 20 +--- +:: + +## Props + +:component-props + +## Preset + +:component-preset diff --git a/docs/content/3.forms/9.form-group.md b/docs/content/3.forms/9.form-group.md new file mode 100644 index 0000000..2ffabb9 --- /dev/null +++ b/docs/content/3.forms/9.form-group.md @@ -0,0 +1,141 @@ +--- +github: + suffix: .ts +description: Display a label and additional informations around a form element. +--- + + +## Usage + +Use the FormGroup component around an [Input](/forms/input), [Textarea](/forms/textarea), [Select](/forms/select) or a [SelectMenu](/forms/select-menu) with the `name` prop to automatically associate a `<label>` element with the form element. + +::component-card +--- +props: + name: 'email' + label: 'Email' +code: >- + + <UInput placeholder="you@example.com" icon="i-heroicons-envelope" /> +--- + +#default +:u-input{placeholder="you@example.com" icon="i-heroicons-envelope"} +:: + +### Required + +Use the `required` prop to indicate that the form element is required. + +::component-card +--- +baseProps: + name: 'group-required' +props: + label: 'Email' + required: true +code: >- + + <UInput placeholder="you@example.com" icon="i-heroicons-envelope" /> +--- + +#default +:u-input{placeholder="you@example.com" icon="i-heroicons-envelope"} +:: + +### Description + +Use the `description` prop to display a description below the label. + +::component-card +--- +baseProps: + name: 'group-description' +props: + label: 'Email' + description: "We'll only use this for spam." +code: >- + + <UInput placeholder="you@example.com" icon="i-heroicons-envelope" /> +--- + +#default +:u-input{placeholder="you@example.com" icon="i-heroicons-envelope"} +:: + +### Hint + +Use the `hint` prop to display a hint above the form element. + +::component-card +--- +baseProps: + name: 'group-hint' +props: + label: 'Email' + hint: 'Optional' +code: >- + + <UInput placeholder="you@example.com" icon="i-heroicons-envelope" /> +--- + +#default +:u-input{placeholder="you@example.com" icon="i-heroicons-envelope"} +:: + +### Help + +Use the `help` prop to display an help message below the form element. + +::component-card +--- +baseProps: + name: 'group-help' +props: + label: 'Email' + help: 'We will never share your email with anyone else.' +code: >- + + <UInput placeholder="you@example.com" icon="i-heroicons-envelope" /> +--- + +#default +:u-input{placeholder="you@example.com" icon="i-heroicons-envelope"} +:: + +### Error + +Use the `error` prop to display an error message below the form element. + +When used together with the `help` prop, the `error` prop will take precedence. + +::component-card +--- +baseProps: + name: 'group-error' +props: + label: 'Email' + help: 'We will never share your email with anyone else.' + error: "Not a valid email address." +code: >- + + <UInput placeholder="you@example.com" trailing-icon="i-heroicons-exclamation-triangle-20-solid" /> +--- + +#default +:u-input{model-value="acidjazz" placeholder="you@example.com" trailing-icon="i-heroicons-exclamation-triangle-20-solid"} +:: + +You can also use the `error` prop as a boolean to mark the form element as invalid. + +::alert{icon="i-heroicons-light-bulb"} + The `error` prop will automatically set the `color` prop of the form element to `red`. +:: + +## Props + +:component-props + +## Preset + +:component-preset diff --git a/docs/content/6.overlays/6.notification.md b/docs/content/6.overlays/6.notification.md index d0a37af..65f017e 100644 --- a/docs/content/6.overlays/6.notification.md +++ b/docs/content/6.overlays/6.notification.md @@ -158,7 +158,7 @@ props: --- :: -### Color +### Style Use the `color` prop to change the progress and icon color of the Notification. diff --git a/docs/plugins/ui.ts b/docs/plugins/ui.ts index 45e82f3..c77c5c6 100644 --- a/docs/plugins/ui.ts +++ b/docs/plugins/ui.ts @@ -8,8 +8,8 @@ export default defineNuxtPlugin({ const appConfig = useAppConfig() const root = computed(() => { - const primary = colors[appConfig.ui.primary] - const gray = colors[appConfig.ui.gray] + const primary: Record<string, string> | undefined = colors[appConfig.ui.primary] + const gray: Record<string, string> | undefined = colors[appConfig.ui.gray] return `:root { ${Object.entries(primary || colors.green).map(([key, value]) => `--color-primary-${key}: ${hexToRgb(value)};`).join('\n')} diff --git a/playground/app.config.ts b/playground/app.config.ts new file mode 100644 index 0000000..e2a0c6c --- /dev/null +++ b/playground/app.config.ts @@ -0,0 +1,6 @@ +export default defineAppConfig({ + ui: { + primary: 'indigo', + gray: 'slate', + } +}) diff --git a/playground/app.vue b/playground/app.vue index 15acd15..ad8a890 100644 --- a/playground/app.vue +++ b/playground/app.vue @@ -1,6 +1,6 @@ <template> <div w-screen h-screen bg-gray-900 flex items-center justify-center text-white> - <u-button color="black" variant="outline"> + <u-button> hello there </u-button> </div> diff --git a/src/module.ts b/src/module.ts index 57324fb..8e84643 100644 --- a/src/module.ts +++ b/src/module.ts @@ -66,8 +66,8 @@ export default defineNuxtModule<ModuleOptions>({ }) const preset = presetUno() - const globalColors:any = preset.theme?.colors + globalColors.primary = { 50: 'rgb(var(--color-primary-50) / <alpha-value>)', 100: 'rgb(var(--color-primary-100) / <alpha-value>)', @@ -106,11 +106,12 @@ export default defineNuxtModule<ModuleOptions>({ 'truegray', 'true-gray', 'coolgray', 'cool-gray','bluegray', 'blue-gray' ].includes(color) ) + nuxt.options.appConfig.ui = { ...nuxt.options.appConfig.ui, primary: 'green', gray: 'slate', - colors: colors, + colors, } if (preset.theme?.colors) { diff --git a/src/runtime/app.config.ts b/src/runtime/app.config.ts index 197487b..c5f6e3e 100644 --- a/src/runtime/app.config.ts +++ b/src/runtime/app.config.ts @@ -327,10 +327,10 @@ const input = { }, color: { white: { - outline: 'shadow-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-white ring-1 ring-inset ring-gray-300 dark:ring-gray-700 focus:ring-2 focus:ring-primary-500 dark:focus:ring-primary-400', + outline: 'shadow-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-white ring-1 ring-inset ring-gray-300 dark:ring-gray-700 focus:ring-2 focus:ring-primary-500 dark:focus:ring-primary-400' }, gray: { - outline: 'shadow-sm bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white ring-1 ring-inset ring-gray-300 dark:ring-gray-700 focus:ring-2 focus:ring-primary-500 dark:focus:ring-primary-400', + outline: 'shadow-sm bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white ring-1 ring-inset ring-gray-300 dark:ring-gray-700 focus:ring-2 focus:ring-primary-500 dark:focus:ring-primary-400' } }, variant: { @@ -400,7 +400,7 @@ const textarea = { default: { size: 'sm', color: 'white', - variant: 'outline', + variant: 'outline' } } @@ -473,24 +473,40 @@ const selectMenu = { const radio = { wrapper: 'relative flex items-start', - base: 'h-4 w-4 text-primary-500 dark:text-primary-400 focus-visible:ring-2 focus-visible:ring-offset-2 bg-white dark:bg-gray-900 dark:checked:bg-current dark:checked:border-transparent focus-visible:ring-primary-500 dark:focus-visible:ring-primary-400 focus-visible:ring-offset-white dark:focus-visible:ring-offset-gray-900 border-gray-300 dark:border-gray-700 disabled:opacity-50 disabled:cursor-not-allowed focus:ring-0 focus:ring-transparent focus:ring-offset-transparent', + base: 'h-4 w-4 dark:checked:bg-current dark:checked:border-transparent disabled:opacity-50 disabled:cursor-not-allowed focus:ring-0 focus:ring-transparent focus:ring-offset-transparent', + color: 'text-{color}-500 dark:text-{color}-400', + background: 'bg-white dark:bg-gray-900', + border: 'border border-gray-300 dark:border-gray-700', + ring: 'focus-visible:ring-2 focus-visible:ring-{color}-500 dark:focus-visible:ring-{color}-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-gray-900', label: 'font-medium text-gray-700 dark:text-gray-200', required: 'text-red-500 dark:text-red-400', - help: 'text-gray-500 dark:text-gray-400' + help: 'text-gray-500 dark:text-gray-400', + default: { + color: 'primary' + } } const checkbox = { wrapper: 'relative flex items-start', - base: 'h-4 w-4 text-primary-500 dark:text-primary-400 focus-visible:ring-2 focus-visible:ring-offset-2 bg-white dark:bg-gray-900 dark:checked:bg-current dark:checked:border-transparent dark:indeterminate:bg-current dark:indeterminate:border-transparent focus-visible:ring-primary-500 dark:focus-visible:ring-primary-400 focus-visible:ring-offset-white dark:focus-visible:ring-offset-gray-900 border-gray-300 dark:border-gray-700 disabled:opacity-50 disabled:cursor-not-allowed focus:ring-0 focus:ring-transparent focus:ring-offset-transparent', + base: 'h-4 w-4 dark:checked:bg-current dark:checked:border-transparent dark:indeterminate:bg-current dark:indeterminate:border-transparent disabled:opacity-50 disabled:cursor-not-allowed focus:ring-0 focus:ring-transparent focus:ring-offset-transparent', rounded: 'rounded', + color: 'text-{color}-500 dark:text-{color}-400', + background: 'bg-white dark:bg-gray-900', + border: 'border border-gray-300 dark:border-gray-700', + ring: 'focus-visible:ring-2 focus-visible:ring-{color}-500 dark:focus-visible:ring-{color}-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-gray-900', label: 'font-medium text-gray-700 dark:text-gray-200', required: 'text-red-500 dark:text-red-400', - help: 'text-gray-500 dark:text-gray-400' + help: 'text-gray-500 dark:text-gray-400', + default: { + color: 'primary' + } } const toggle = { - base: 'relative inline-flex flex-shrink-0 h-5 w-9 border-2 border-transparent rounded-full cursor-pointer disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:focus-visible:ring-primary-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-gray-900', - active: 'bg-primary-500 dark:bg-primary-400', + base: 'relative inline-flex h-5 w-9 flex-shrink-0 border-2 border-transparent disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none', + rounded: 'rounded-full', + ring: 'focus-visible:ring-2 focus-visible:ring-{color}-500 dark:focus-visible:ring-{color}-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-gray-900', + active: 'bg-{color}-500 dark:bg-{color}-400', inactive: 'bg-gray-200 dark:bg-gray-700', container: { base: 'pointer-events-none relative inline-block h-4 w-4 rounded-full bg-white dark:bg-gray-900 shadow transform ring-0 transition ease-in-out duration-200', @@ -501,12 +517,46 @@ const toggle = { base: 'absolute inset-0 h-full w-full flex items-center justify-center transition-opacity', active: 'opacity-100 ease-in duration-200', inactive: 'opacity-0 ease-out duration-100', - on: 'h-3 w-3 text-primary-500 dark:text-primary-400', + on: 'h-3 w-3 text-{color}-500 dark:text-{color}-400', off: 'h-3 w-3 text-gray-400 dark:text-gray-500' }, default: { onIcon: null, - offIcon: null + offIcon: null, + color: 'primary' + } +} + +const range = { + wrapper: 'relative w-full', + base: 'w-full absolute appearance-none cursor-pointer disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none [&::-webkit-slider-runnable-track]:h-full [&::-moz-slider-runnable-track]:h-full', + background: 'bg-gray-200 dark:bg-gray-700', + rounded: 'rounded-lg', + ring: 'focus-visible:ring-2 focus-visible:ring-{color}-500 dark:focus-visible:ring-{color}-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-gray-900', + progress: { + base: 'absolute inset-0 h-full pointer-events-none', + rounded: 'rounded-l-lg', + background: 'bg-{color}-500 dark:bg-{color}-400' + }, + thumb: { + base: `[&::-webkit-slider-thumb]:relative [&::-moz-range-thumb]:relative [&::-webkit-slider-thumb]:z-[1] [&::-moz-range-thumb]:z-[1] [&::-webkit-slider-thumb]:appearance-none [&::-moz-range-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0`, + color: 'text-{color}-500 dark:text-{color}-400', + background: '[&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:dark:bg-gray-900 [&::-moz-range-thumb]:bg-current', + ring: '[&::-webkit-slider-thumb]:ring-2 [&::-webkit-slider-thumb]:ring-current', + size: { + sm: '[&::-webkit-slider-thumb]:h-3 [&::-moz-range-thumb]:h-3 [&::-webkit-slider-thumb]:w-3 [&::-moz-range-thumb]:w-3 [&::-webkit-slider-thumb]:-mt-1 [&::-moz-range-thumb]:-mt-1', + md: '[&::-webkit-slider-thumb]:h-4 [&::-moz-range-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-moz-range-thumb]:w-4 [&::-webkit-slider-thumb]:-mt-1 [&::-moz-range-thumb]:-mt-1', + lg: '[&::-webkit-slider-thumb]:h-5 [&::-moz-range-thumb]:h-5 [&::-webkit-slider-thumb]:w-5 [&::-moz-range-thumb]:w-5 [&::-webkit-slider-thumb]:-mt-1 [&::-moz-range-thumb]:-mt-1' + } + }, + size: { + sm: 'h-1', + md: 'h-2', + lg: 'h-3' + }, + default: { + size: 'md', + color: 'primary' } } @@ -872,6 +922,7 @@ export default { checkbox, radio, toggle, + range, card, container, skeleton, diff --git a/src/runtime/components/data/Table.vue b/src/runtime/components/data/Table.vue index 4e76385..b768831 100644 --- a/src/runtime/components/data/Table.vue +++ b/src/runtime/components/data/Table.vue @@ -77,7 +77,7 @@ import appConfig from '#build/app.config' // const appConfig = useAppConfig() -function defaultComparator<T>(a: T, z: T): boolean { +function defaultComparator<T> (a: T, z: T): boolean { return a === z } diff --git a/src/runtime/components/forms/Checkbox.vue b/src/runtime/components/forms/Checkbox.vue index 2b20be0..292b6eb 100644 --- a/src/runtime/components/forms/Checkbox.vue +++ b/src/runtime/components/forms/Checkbox.vue @@ -3,7 +3,7 @@ <div class="flex items-center h-5"> <input :id="name" - v-model="isChecked" + v-model="toggle" :name="name" :required="required" :value="value" @@ -12,10 +12,8 @@ :indeterminate="indeterminate" type="checkbox" class="form-checkbox" - :class="[ui.base, ui.rounded, ui.custom]" + :class="inputClass" v-bind="$attrs" - @focus="$emit('focus', $event)" - @blur="$emit('blur', $event)" > </div> <div v-if="label || $slots.label" class="ml-3 text-sm"> @@ -34,6 +32,7 @@ import { computed, defineComponent } from 'vue' import type { PropType } from 'vue' import { defu } from 'defu' +import { classNames } from '../../utils' import { useAppConfig } from '#imports' // TODO: Remove // @ts-expect-error @@ -80,19 +79,26 @@ export default defineComponent({ type: Boolean, default: false }, + color: { + type: String, + default: () => appConfig.ui.checkbox.default.color, + validator (value: string) { + return appConfig.ui.colors.includes(value) + } + }, ui: { type: Object as PropType<Partial<typeof appConfig.ui.checkbox>>, default: () => appConfig.ui.checkbox } }, - emits: ['update:modelValue', 'focus', 'blur'], + emits: ['update:modelValue'], setup (props, { emit }) { // TODO: Remove const appConfig = useAppConfig() const ui = computed<Partial<typeof appConfig.ui.checkbox>>(() => defu({}, props.ui, appConfig.ui.checkbox)) - const isChecked = computed({ + const toggle = computed({ get () { return props.modelValue }, @@ -101,10 +107,22 @@ export default defineComponent({ } }) + const inputClass = computed(() => { + return classNames( + ui.value.base, + ui.value.rounded, + ui.value.background, + ui.value.border, + ui.value.ring.replaceAll('{color}', props.color), + ui.value.color.replaceAll('{color}', props.color) + ) + }) + return { // eslint-disable-next-line vue/no-dupe-keys ui, - isChecked + toggle, + inputClass } } }) diff --git a/src/runtime/components/forms/Input.vue b/src/runtime/components/forms/Input.vue index c597a42..c992cde 100644 --- a/src/runtime/components/forms/Input.vue +++ b/src/runtime/components/forms/Input.vue @@ -13,8 +13,6 @@ :class="inputClass" v-bind="$attrs" @input="onInput" - @focus="$emit('focus', $event)" - @blur="$emit('blur', $event)" > <slot /> @@ -140,7 +138,7 @@ export default defineComponent({ default: () => appConfig.ui.input } }, - emits: ['update:modelValue', 'focus', 'blur'], + emits: ['update:modelValue'], setup (props, { emit, slots }) { // TODO: Remove const appConfig = useAppConfig() diff --git a/src/runtime/components/forms/Radio.vue b/src/runtime/components/forms/Radio.vue index 8232ee2..85ed506 100644 --- a/src/runtime/components/forms/Radio.vue +++ b/src/runtime/components/forms/Radio.vue @@ -3,17 +3,15 @@ <div class="flex items-center h-5"> <input :id="`${name}-${value}`" - v-model="isChecked" + v-model="pick" :name="name" :required="required" :value="value" :disabled="disabled" type="radio" class="form-radio" - :class="[ui.base, ui.custom]" + :class="inputClass" v-bind="$attrs" - @focus="$emit('focus', $event)" - @blur="$emit('blur', $event)" > </div> <div v-if="label || $slots.label" class="ml-3 text-sm"> @@ -32,6 +30,7 @@ import { computed, defineComponent } from 'vue' import type { PropType } from 'vue' import { defu } from 'defu' +import { classNames } from '../../utils' import { useAppConfig } from '#imports' // TODO: Remove // @ts-expect-error @@ -70,19 +69,26 @@ export default defineComponent({ type: Boolean, default: false }, + color: { + type: String, + default: () => appConfig.ui.radio.default.color, + validator (value: string) { + return appConfig.ui.colors.includes(value) + } + }, ui: { type: Object as PropType<Partial<typeof appConfig.ui.radio>>, default: () => appConfig.ui.radio } }, - emits: ['update:modelValue', 'focus', 'blur'], + emits: ['update:modelValue'], setup (props, { emit }) { // TODO: Remove const appConfig = useAppConfig() const ui = computed<Partial<typeof appConfig.ui.radio>>(() => defu({}, props.ui, appConfig.ui.radio)) - const isChecked = computed({ + const pick = computed({ get () { return props.modelValue }, @@ -91,10 +97,21 @@ export default defineComponent({ } }) + const inputClass = computed(() => { + return classNames( + ui.value.base, + ui.value.background, + ui.value.border, + ui.value.ring.replaceAll('{color}', props.color), + ui.value.color.replaceAll('{color}', props.color) + ) + }) + return { // eslint-disable-next-line vue/no-dupe-keys ui, - isChecked + pick, + inputClass } } }) diff --git a/src/runtime/components/forms/Range.vue b/src/runtime/components/forms/Range.vue new file mode 100644 index 0000000..d43f220 --- /dev/null +++ b/src/runtime/components/forms/Range.vue @@ -0,0 +1,148 @@ +<template> + <div :class="wrapperClass"> + <input + :id="name" + ref="input" + v-model.number="value" + :name="name" + :min="min" + :max="max" + :disabled="disabled" + :step="step" + type="range" + :class="[inputClass, thumbClass]" + v-bind="$attrs" + > + + <span :class="progressClass" :style="progressStyle" /> + </div> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue' +import type { PropType } from 'vue' +import { defu } from 'defu' +import { classNames } from '../../utils' +import { useAppConfig } from '#imports' +// TODO: Remove +// @ts-expect-error +import appConfig from '#build/app.config' + +export default defineComponent({ + inheritAttrs: false, + props: { + modelValue: { + type: Number, + default: 0 + }, + name: { + type: String, + default: null + }, + disabled: { + type: Boolean, + default: false + }, + min: { + type: Number, + default: 0 + }, + max: { + type: Number, + default: 100 + }, + step: { + type: Number, + default: 1 + }, + size: { + type: String, + default: () => appConfig.ui.range.default.size, + validator (value: string) { + return Object.keys(appConfig.ui.range.size).includes(value) + } + }, + color: { + type: String, + default: () => appConfig.ui.range.default.color, + validator (value: string) { + return appConfig.ui.colors.includes(value) + } + }, + ui: { + type: Object as PropType<Partial<typeof appConfig.ui.range>>, + default: () => appConfig.ui.range + } + }, + emits: ['update:modelValue'], + setup (props, { emit }) { + // TODO: Remove + const appConfig = useAppConfig() + + const ui = computed<Partial<typeof appConfig.ui.range>>(() => defu({}, props.ui, appConfig.ui.range)) + + const value = computed({ + get () { + return props.modelValue + }, + set (value) { + emit('update:modelValue', value) + } + }) + + const wrapperClass = computed(() => { + return classNames( + ui.value.wrapper, + ui.value.size[props.size] + ) + }) + + const inputClass = computed(() => { + return classNames( + ui.value.base, + ui.value.background, + ui.value.rounded, + ui.value.ring.replaceAll('{color}', props.color), + ui.value.size[props.size] + ) + }) + + const thumbClass = computed(() => { + return classNames( + ui.value.thumb.base, + // Intermediate class to allow thumb ring or background color (set to `current`) as it's impossible to safelist with arbitrary values + ui.value.thumb.color.replaceAll('{color}', props.color), + ui.value.thumb.ring, + ui.value.thumb.background, + ui.value.thumb.size[props.size] + ) + }) + + const progressClass = computed(() => { + return classNames( + ui.value.progress.base, + ui.value.progress.rounded, + ui.value.progress.background.replaceAll('{color}', props.color), + ui.value.size[props.size] + ) + }) + + const progressStyle = computed(() => { + return { + width: `${(props.modelValue / props.max) * 100}%` + } + }) + + return { + // eslint-disable-next-line vue/no-dupe-keys + ui, + value, + wrapperClass, + inputClass, + thumbClass, + progressClass, + progressStyle + } + } +}) +</script> diff --git a/src/runtime/components/forms/Select.vue b/src/runtime/components/forms/Select.vue index 256f315..976095a 100644 --- a/src/runtime/components/forms/Select.vue +++ b/src/runtime/components/forms/Select.vue @@ -165,7 +165,7 @@ export default defineComponent({ default: () => appConfig.ui.select } }, - emits: ['update:modelValue', 'focus', 'blur'], + emits: ['update:modelValue'], setup (props, { emit, slots }) { // TODO: Remove const appConfig = useAppConfig() diff --git a/src/runtime/components/forms/Textarea.vue b/src/runtime/components/forms/Textarea.vue index 496d9b6..d5a5ca8 100644 --- a/src/runtime/components/forms/Textarea.vue +++ b/src/runtime/components/forms/Textarea.vue @@ -13,8 +13,6 @@ :class="textareaClass" v-bind="$attrs" @input="onInput" - @focus="$emit('focus', $event)" - @blur="$emit('blur', $event)" /> </div> </template> @@ -103,7 +101,7 @@ export default defineComponent({ default: () => appConfig.ui.textarea } }, - emits: ['update:modelValue', 'focus', 'blur'], + emits: ['update:modelValue'], setup (props, { emit }) { const textarea = ref<HTMLTextAreaElement | null>(null) diff --git a/src/runtime/components/forms/Toggle.vue b/src/runtime/components/forms/Toggle.vue index 25dd59e..6403d48 100644 --- a/src/runtime/components/forms/Toggle.vue +++ b/src/runtime/components/forms/Toggle.vue @@ -3,14 +3,14 @@ v-model="active" :name="name" :disabled="disabled" - :class="[active ? ui.active : ui.inactive, ui.base]" + :class="switchClass" > <span :class="[active ? ui.container.active : ui.container.inactive, ui.container.base]"> <span v-if="onIcon" :class="[active ? ui.icon.active : ui.icon.inactive, ui.icon.base]" aria-hidden="true"> - <UIcon :name="onIcon" :class="ui.icon.on" /> + <UIcon :name="onIcon" :class="onIconClass" /> </span> <span v-if="offIcon" :class="[active ? ui.icon.inactive : ui.icon.active, ui.icon.base]" aria-hidden="true"> - <UIcon :name="offIcon" :class="ui.icon.off" /> + <UIcon :name="offIcon" :class="offIconClass" /> </span> </span> </Switch> @@ -22,6 +22,7 @@ import type { PropType } from 'vue' import { defu } from 'defu' import { Switch } from '@headlessui/vue' import UIcon from '../elements/Icon.vue' +import { classNames } from '../../utils' import { useAppConfig } from '#imports' // TODO: Remove // @ts-expect-error @@ -56,6 +57,13 @@ export default defineComponent({ type: String, default: () => appConfig.ui.toggle.default.offIcon }, + color: { + type: String, + default: () => appConfig.ui.toggle.default.color, + validator (value: string) { + return appConfig.ui.colors.includes(value) + } + }, ui: { type: Object as PropType<Partial<typeof appConfig.ui.toggle>>, default: () => appConfig.ui.toggle @@ -77,10 +85,34 @@ export default defineComponent({ } }) + const switchClass = computed(()=>{ + return classNames( + ui.value.base, + ui.value.rounded, + ui.value.ring.replaceAll('{color}', props.color), + (active.value ? ui.value.active : ui.value.inactive).replaceAll('{color}', props.color) + ) + }) + + const onIconClass = computed(()=>{ + return classNames( + ui.value.icon.on.replaceAll('{color}', props.color) + ) + }) + + const offIconClass = computed(()=>{ + return classNames( + ui.value.icon.off.replaceAll('{color}', props.color) + ) + }) + return { // eslint-disable-next-line vue/no-dupe-keys ui, - active + active, + switchClass, + onIconClass, + offIconClass } } }) diff --git a/src/runtime/components/navigation/CommandPalette.vue b/src/runtime/components/navigation/CommandPalette.vue index aa59bd6..9635250 100644 --- a/src/runtime/components/navigation/CommandPalette.vue +++ b/src/runtime/components/navigation/CommandPalette.vue @@ -78,8 +78,8 @@ import type { Group, Command } from '../../types/command-palette' import UIcon from '../elements/Icon.vue' import UButton from '../elements/Button.vue' import type { Button } from '../../types/button' -import { classNames } from '../../utils' import CommandPaletteGroup from './CommandPaletteGroup.vue' +import { classNames } from '../../utils' import { useAppConfig } from '#imports' // TODO: Remove // @ts-expect-error