mirror of
https://github.com/nuxt/ui.git
synced 2026-09-14 19:51:10 +08:00
fix(components): resolve theme props consistently in form controls (#6834)
This commit is contained in:
+154
-2
@@ -139,6 +139,156 @@ const noBarePropRefs = {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flag reads of a `useFormField` / `useFieldGroup` ref that don't fall back to
|
||||
* the `useComponentProps` proxy.
|
||||
*
|
||||
* `size`, `color`, `highlight` and `disabled` come back holding only what the
|
||||
* wrapping `<UForm>` / `<UFormField>` / `<UFieldGroup>` supplied, so a bare
|
||||
* `size.value` silently drops `<UTheme :props>` and `app.config` defaults. The
|
||||
* fix is always the same shape, either inline or hoisted into a computed:
|
||||
*
|
||||
* ```ts
|
||||
* size: formFieldSize.value ?? props.size
|
||||
* const disabled = computed(() => formFieldDisabled.value ?? props.disabled)
|
||||
* ```
|
||||
*
|
||||
* So the rule allows a read only when it sits in a `??` chain that ends in a
|
||||
* `props.<key>` member access, and reports it everywhere else. Chaining two
|
||||
* refs (`fieldGroupSize.value ?? formFieldSize.value ?? props.size`) is fine.
|
||||
*
|
||||
* Templates are checked too, and more strictly: refs auto-unwrap there, so an
|
||||
* unresolved `:size="formFieldSize"` has no `.value` to key off and reads
|
||||
* exactly like the resolved `:size="size"`. Since the resolution always belongs
|
||||
* in setup anyway, any appearance of one of these refs in a template is
|
||||
* reported outright.
|
||||
*
|
||||
* Not auto-fixable: the right landing spot is often a shared computed rather
|
||||
* than the use site, and appending `?? props.x` to the wrong branch of a
|
||||
* ternary would change behaviour silently.
|
||||
*/
|
||||
const RESOLVABLE_FORM_FIELD_KEYS = new Set(['size', 'color', 'highlight', 'disabled'])
|
||||
const noUnresolvedFormFieldRefs = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Require `<ref>.value ?? props.X` when reading a `useFormField` / `useFieldGroup` ref'
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
unresolved: 'Reading `{{ local }}` without a `?? {{ propsVar }}.{{ key }}` fallback drops `<UTheme :props>` and `app.config` defaults. Chain it, or read a computed that already does.',
|
||||
inTemplate: 'Binding the raw `{{ local }}` in the template drops `<UTheme :props>` and `app.config` defaults, and refs auto-unwrap here so it is indistinguishable from a resolved one. Resolve it in setup with `computed(() => {{ local }}.value ?? {{ propsVar }}.{{ key }})` and bind that.'
|
||||
}
|
||||
},
|
||||
create(context) {
|
||||
const parserServices = context.sourceCode?.parserServices ?? context.parserServices
|
||||
let propsVar = 'props'
|
||||
// local binding name -> the prop key it must fall back to
|
||||
const formFieldRefs = new Map()
|
||||
|
||||
function isPropsAccess(node, key) {
|
||||
return !!node
|
||||
&& node.type === 'MemberExpression'
|
||||
&& !node.computed
|
||||
&& node.object.type === 'Identifier'
|
||||
&& node.object.name === propsVar
|
||||
&& node.property.type === 'Identifier'
|
||||
&& node.property.name === key
|
||||
}
|
||||
|
||||
// `a ?? b ?? props.size` parses as `(a ?? b) ?? props.size`, so the fallback
|
||||
// can sit at any depth on either spine. Flatten the whole chain and accept
|
||||
// it if `props.<key>` shows up anywhere in it — that also covers the
|
||||
// `?? props.size ?? 'md'` shape used for virtualizer estimates.
|
||||
function chainOperands(node, out = []) {
|
||||
if (node.type === 'LogicalExpression' && node.operator === '??') {
|
||||
chainOperands(node.left, out)
|
||||
chainOperands(node.right, out)
|
||||
} else {
|
||||
out.push(node)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const scriptVisitor = {
|
||||
'CallExpression[callee.name="useComponentProps"]'(node) {
|
||||
const decl = node.parent?.type === 'VariableDeclarator' ? node.parent : null
|
||||
if (decl?.id?.type === 'Identifier') {
|
||||
propsVar = decl.id.name
|
||||
}
|
||||
},
|
||||
':matches(CallExpression[callee.name="useFormField"], CallExpression[callee.name="useFieldGroup"])'(node) {
|
||||
const decl = node.parent?.type === 'VariableDeclarator' ? node.parent : null
|
||||
if (decl?.id?.type !== 'ObjectPattern') return
|
||||
|
||||
for (const prop of decl.id.properties) {
|
||||
if (prop.type !== 'Property' || prop.key.type !== 'Identifier') continue
|
||||
if (!RESOLVABLE_FORM_FIELD_KEYS.has(prop.key.name)) continue
|
||||
if (prop.value.type !== 'Identifier') continue
|
||||
formFieldRefs.set(prop.value.name, prop.key.name)
|
||||
}
|
||||
},
|
||||
// Matches `<local>.value`, the only way these refs are read in script.
|
||||
'MemberExpression[computed=false][property.name="value"]'(node) {
|
||||
if (node.object.type !== 'Identifier') return
|
||||
|
||||
const key = formFieldRefs.get(node.object.name)
|
||||
if (!key) return
|
||||
|
||||
// Climb to the outermost `??` so the whole chain is in scope, then look
|
||||
// for the `props.<key>` fallback in the operands that follow this read.
|
||||
// Only a fallback placed after it is a fallback: `props.size ?? size.value`
|
||||
// reads the other way round and would let a theme default win over the
|
||||
// wrapping FormField.
|
||||
let top = node
|
||||
while (top.parent?.type === 'LogicalExpression' && top.parent.operator === '??') {
|
||||
top = top.parent
|
||||
}
|
||||
|
||||
const operands = chainOperands(top)
|
||||
const index = operands.indexOf(node)
|
||||
|
||||
if (index !== -1 && operands.slice(index + 1).some(operand => isPropsAccess(operand, key))) {
|
||||
return
|
||||
}
|
||||
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'unresolved',
|
||||
data: { local: `${node.object.name}.value`, propsVar, key }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!parserServices?.defineTemplateBodyVisitor) {
|
||||
return scriptVisitor
|
||||
}
|
||||
|
||||
// Template visitors run after the script is fully traversed, so
|
||||
// `formFieldRefs` is populated by the time this fires.
|
||||
return parserServices.defineTemplateBodyVisitor(
|
||||
{
|
||||
VExpressionContainer(node) {
|
||||
for (const ref of node.references ?? []) {
|
||||
const name = ref.id?.name
|
||||
if (!name) continue
|
||||
|
||||
const key = formFieldRefs.get(name)
|
||||
if (!key) continue
|
||||
|
||||
context.report({
|
||||
node: ref.id,
|
||||
messageId: 'inTemplate',
|
||||
data: { local: name, propsVar, key }
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
scriptVisitor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default createConfigForNuxt({
|
||||
features: {
|
||||
tooling: true,
|
||||
@@ -160,12 +310,14 @@ export default createConfigForNuxt({
|
||||
plugins: {
|
||||
'nuxt-ui': {
|
||||
rules: {
|
||||
'no-bare-prop-refs': noBarePropRefs
|
||||
'no-bare-prop-refs': noBarePropRefs,
|
||||
'no-unresolved-form-field-refs': noUnresolvedFormFieldRefs
|
||||
}
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
'nuxt-ui/no-bare-prop-refs': 'error'
|
||||
'nuxt-ui/no-bare-prop-refs': 'error',
|
||||
'nuxt-ui/no-unresolved-form-field-refs': 'error'
|
||||
}
|
||||
}).append({
|
||||
files: ['src/runtime/components/**/*.vue', 'src/runtime/composables/**/*.ts'],
|
||||
|
||||
@@ -85,9 +85,18 @@ const appConfig = useAppConfig() as Checkbox['AppConfig']
|
||||
|
||||
const rootProps = useForwardProps(reactivePick(props, 'required', 'value', 'defaultValue', 'modelValue', 'trueValue', 'falseValue'), emits)
|
||||
|
||||
const { id: _id, emitFormChange, emitFormInput, size, color, highlight, name, disabled, ariaAttrs } = useFormField<CheckboxProps<T>>(_props)
|
||||
const { id: _id, emitFormChange, emitFormInput, size: formFieldSize, color: formFieldColor, highlight: formFieldHighlight, name, disabled: formFieldDisabled, ariaAttrs } = useFormField<CheckboxProps<T>>(_props)
|
||||
const id = _id.value ?? useId()
|
||||
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const color = computed(() => formFieldColor.value ?? props.color)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const highlight = computed(() => formFieldHighlight.value ?? props.highlight)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const size = computed(() => formFieldSize.value ?? props.size)
|
||||
|
||||
const disabled = computed(() => formFieldDisabled.value ?? props.disabled)
|
||||
|
||||
const attrs = useAttrs()
|
||||
// Omit `data-state` to prevent conflicts with parent components (e.g. TooltipTrigger)
|
||||
const forwardedAttrs = computed(() => {
|
||||
@@ -97,11 +106,11 @@ const forwardedAttrs = computed(() => {
|
||||
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const ui = computed(() => tv({ extend: theme, ...(appConfig.ui?.checkbox || {}) })({
|
||||
size: size.value ?? props.size,
|
||||
color: color.value ?? props.color,
|
||||
size: size.value,
|
||||
color: color.value,
|
||||
variant: props.variant,
|
||||
indicator: props.indicator,
|
||||
highlight: highlight.value ?? props.highlight,
|
||||
highlight: highlight.value,
|
||||
required: props.required,
|
||||
disabled: disabled.value
|
||||
}))
|
||||
|
||||
@@ -107,15 +107,27 @@ const rootProps = useForwardProps(reactivePick(props, 'as', 'modelValue', 'defau
|
||||
const checkboxProps = useForwardProps(reactivePick(props, 'variant', 'indicator', 'icon'))
|
||||
const getProxySlots = () => omit(slots, ['legend'])
|
||||
|
||||
const { emitFormChange, emitFormInput, color, highlight, name, size, id: _id, disabled, ariaAttrs } = useFormField<CheckboxGroupProps<T>>(_props, { bind: false })
|
||||
const { emitFormChange, emitFormInput, color: formFieldColor, highlight: formFieldHighlight, name, size: formFieldSize, id: _id, disabled: formFieldDisabled, ariaAttrs } = useFormField<CheckboxGroupProps<T>>(_props, { bind: false })
|
||||
const id = _id.value ?? useId()
|
||||
|
||||
// `color`, `size` and `highlight` are group-level only, they are not part of the item API, so
|
||||
// every child gets the group's resolved value. Resolving them here rather than at each binding
|
||||
// keeps the `tv()` call and the forwarding to `UCheckbox` in sync.
|
||||
|
||||
const color = computed(() => formFieldColor.value ?? props.color)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const size = computed(() => formFieldSize.value ?? props.size)
|
||||
|
||||
const highlight = computed(() => formFieldHighlight.value ?? props.highlight)
|
||||
|
||||
const disabled = computed(() => formFieldDisabled.value ?? props.disabled)
|
||||
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const ui = computed(() => tv({ extend: theme, ...(appConfig.ui?.checkboxGroup || {}) })({
|
||||
size: size.value ?? props.size,
|
||||
size: size.value,
|
||||
required: props.required,
|
||||
orientation: props.orientation,
|
||||
color: color.value ?? props.color,
|
||||
color: color.value,
|
||||
variant: props.variant,
|
||||
disabled: disabled.value
|
||||
}))
|
||||
|
||||
@@ -188,8 +188,16 @@ const { isDragging, open, inputRef, dropzoneRef } = useFileUpload({
|
||||
dropzone: props.dropzone,
|
||||
onUpdate
|
||||
})
|
||||
const { emitFormInput, emitFormChange, id, name, color, highlight, disabled, ariaAttrs } = useFormField<FileUploadProps>(_props)
|
||||
const { emitFormInput, emitFormChange, id, name, size: formFieldSize, color: formFieldColor, highlight: formFieldHighlight, disabled: formFieldDisabled, ariaAttrs } = useFormField<FileUploadProps>(_props)
|
||||
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const color = computed(() => formFieldColor.value ?? props.color)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const highlight = computed(() => formFieldHighlight.value ?? props.highlight)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const size = computed(() => formFieldSize.value ?? props.size)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const disabled = computed(() => formFieldDisabled.value ?? props.disabled)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const variant = computed(() => props.multiple ? 'area' : props.variant)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
@@ -210,14 +218,14 @@ const position = computed(() => {
|
||||
const ui = computed(() => tv({ extend: theme, ...(appConfig.ui?.fileUpload || {}) })({
|
||||
dropzone: props.dropzone,
|
||||
interactive: props.interactive,
|
||||
color: color.value ?? props.color,
|
||||
size: props.size,
|
||||
color: color.value,
|
||||
size: size.value,
|
||||
variant: variant.value,
|
||||
layout: layout.value,
|
||||
position: position.value,
|
||||
multiple: props.multiple,
|
||||
highlight: highlight.value ?? props.highlight,
|
||||
disabled: props.disabled
|
||||
highlight: highlight.value,
|
||||
disabled: disabled.value
|
||||
}))
|
||||
|
||||
function createObjectUrl(file: File): string | undefined {
|
||||
@@ -234,13 +242,19 @@ function formatFileSize(bytes: number): string {
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
const size = bytes / Math.pow(k, i)
|
||||
const formattedSize = i === 0 ? size.toString() : size.toFixed(0)
|
||||
const value = bytes / Math.pow(k, i)
|
||||
const formattedSize = i === 0 ? value.toString() : value.toFixed(0)
|
||||
|
||||
return `${formattedSize}${sizes[i]}`
|
||||
}
|
||||
|
||||
function onUpdate(files: File[], reset = false) {
|
||||
// `useDropZone` is registered on mount regardless of state, so a disabled
|
||||
// control would still accept dropped files without this guard.
|
||||
if (disabled.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (props.multiple) {
|
||||
if (reset) {
|
||||
modelValue.value = files as (M extends true ? File[] : File) | null
|
||||
@@ -307,7 +321,7 @@ defineExpose({
|
||||
:as="{ img: 'img' }"
|
||||
:src="createObjectUrl(file)"
|
||||
:icon="props.fileIcon || appConfig.ui.icons.file"
|
||||
:size="props.size"
|
||||
:size="size"
|
||||
data-slot="fileLeadingAvatar"
|
||||
:class="ui.fileLeadingAvatar({ class: props.ui?.fileLeadingAvatar })"
|
||||
/>
|
||||
@@ -337,7 +351,7 @@ defineExpose({
|
||||
size: 'xs'
|
||||
} : {
|
||||
variant: 'link',
|
||||
size: props.size
|
||||
size
|
||||
}),
|
||||
...typeof props.fileDelete === 'object' ? props.fileDelete : undefined
|
||||
}"
|
||||
@@ -380,7 +394,7 @@ defineExpose({
|
||||
<slot name="leading" :ui="ui">
|
||||
<template v-if="props.icon !== false">
|
||||
<UIcon v-if="variant === 'button'" :name="props.icon ?? appConfig.ui.icons.upload" data-slot="icon" :class="ui.icon({ class: props.ui?.icon })" />
|
||||
<UAvatar v-else :icon="props.icon ?? appConfig.ui.icons.upload" :size="props.size" data-slot="avatar" :class="ui.avatar({ class: props.ui?.avatar })" />
|
||||
<UAvatar v-else :icon="props.icon ?? appConfig.ui.icons.upload" :size="size" data-slot="avatar" :class="ui.avatar({ class: props.ui?.avatar })" />
|
||||
</template>
|
||||
</slot>
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ export interface InputSlots {
|
||||
</script>
|
||||
|
||||
<script setup lang="ts" generic="T extends InputValue, Mod extends ModelModifiers">
|
||||
import { useTemplateRef, computed, onMounted } from 'vue'
|
||||
import { useTemplateRef, computed, onMounted, onScopeDispose } from 'vue'
|
||||
import { Primitive } from 'reka-ui'
|
||||
import { useVModel } from '@vueuse/core'
|
||||
import { useAppConfig } from '#imports'
|
||||
@@ -96,20 +96,28 @@ const modelValue = useVModel<InputProps<T, Mod>, 'modelValue', 'update:modelValu
|
||||
|
||||
const appConfig = useAppConfig() as Input['AppConfig']
|
||||
|
||||
const { emitFormBlur, emitFormInput, emitFormChange, size: formFieldSize, color, id, name, highlight, disabled, emitFormFocus, ariaAttrs } = useFormField<InputProps<T>>(_props, { deferInputValidation: true })
|
||||
const { emitFormBlur, emitFormInput, emitFormChange, size: formFieldSize, color: formFieldColor, id, name, highlight: formFieldHighlight, disabled: formFieldDisabled, emitFormFocus, ariaAttrs } = useFormField<InputProps<T>>(_props, { deferInputValidation: true })
|
||||
|
||||
const { orientation, size: fieldGroupSize } = useFieldGroup<InputProps<T>>(_props)
|
||||
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(props)
|
||||
|
||||
const inputSize = computed(() => fieldGroupSize.value || formFieldSize.value)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const color = computed(() => formFieldColor.value ?? props.color)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const highlight = computed(() => formFieldHighlight.value ?? props.highlight)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const size = computed(() => fieldGroupSize.value ?? formFieldSize.value ?? props.size)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const disabled = computed(() => formFieldDisabled.value ?? props.disabled)
|
||||
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const ui = computed(() => tv({ extend: theme, ...(appConfig.ui?.input || {}) })({
|
||||
type: props.type as Input['variants']['type'],
|
||||
color: color.value ?? props.color,
|
||||
color: color.value,
|
||||
variant: props.variant,
|
||||
size: inputSize?.value ?? props.size,
|
||||
size: size.value,
|
||||
loading: props.loading,
|
||||
highlight: highlight.value ?? props.highlight,
|
||||
highlight: highlight.value,
|
||||
fixed: props.fixed,
|
||||
leading: isLeading.value || !!props.avatar || !!slots.leading,
|
||||
trailing: isTrailing.value || !!slots.trailing,
|
||||
@@ -173,12 +181,16 @@ function autoFocus() {
|
||||
}
|
||||
}
|
||||
|
||||
let autofocusTimeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
autofocusTimeoutId = setTimeout(() => {
|
||||
autoFocus()
|
||||
}, props.autofocusDelay)
|
||||
})
|
||||
|
||||
onScopeDispose(() => clearTimeout(autofocusTimeoutId))
|
||||
|
||||
defineExpose({
|
||||
inputRef
|
||||
})
|
||||
|
||||
@@ -70,7 +70,7 @@ export interface InputDateSlots {
|
||||
</script>
|
||||
|
||||
<script setup lang="ts" generic="R extends boolean">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, onScopeDispose, ref } from 'vue'
|
||||
import { useForwardProps } from '../composables/useForwardProps'
|
||||
import { DateField as SingleDateField, DateRangeField as RangeDateField } from 'reka-ui/namespaced'
|
||||
import { reactiveOmit, createReusableTemplate } from '@vueuse/core'
|
||||
@@ -96,7 +96,8 @@ const props = useComponentProps<InputDateProps<R>>('inputDate', _props)
|
||||
const appConfig = useAppConfig() as InputDate['AppConfig']
|
||||
|
||||
const rootProps = useForwardProps(reactiveOmit(props, 'id', 'name', 'range', 'modelValue', 'defaultValue', 'color', 'variant', 'size', 'highlight', 'fixed', 'disabled', 'autofocus', 'autofocusDelay', 'icon', 'avatar', 'leading', 'leadingIcon', 'trailing', 'trailingIcon', 'loading', 'loadingIcon', 'separatorIcon', 'class', 'ui'), emits)
|
||||
const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, size: formFieldSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField<InputDateProps<R>>(_props)
|
||||
const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, size: formFieldSize, color: formFieldColor, id, name, highlight: formFieldHighlight, disabled: formFieldDisabled, ariaAttrs } = useFormField<InputDateProps<R>>(_props)
|
||||
|
||||
const { orientation, size: fieldGroupSize } = useFieldGroup<InputDateProps<R>>(_props)
|
||||
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(props)
|
||||
|
||||
@@ -105,14 +106,21 @@ const [DefineSegmentsTemplate, ReuseSegmentsTemplate] = createReusableTemplate<{
|
||||
type?: 'start' | 'end'
|
||||
}>()
|
||||
|
||||
const inputSize = computed(() => fieldGroupSize.value || formFieldSize.value)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const color = computed(() => formFieldColor.value ?? props.color)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const highlight = computed(() => formFieldHighlight.value ?? props.highlight)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const size = computed(() => fieldGroupSize.value ?? formFieldSize.value ?? props.size)
|
||||
|
||||
const disabled = computed(() => formFieldDisabled.value ?? props.disabled)
|
||||
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const ui = computed(() => tv({ extend: theme, ...(appConfig.ui?.inputDate || {}) })({
|
||||
color: color.value ?? props.color,
|
||||
color: color.value,
|
||||
variant: props.variant,
|
||||
size: inputSize.value ?? props.size,
|
||||
highlight: highlight.value ?? props.highlight,
|
||||
size: size.value,
|
||||
highlight: highlight.value,
|
||||
fixed: props.fixed,
|
||||
loading: props.loading,
|
||||
leading: isLeading.value || !!props.avatar || !!slots.leading,
|
||||
@@ -152,12 +160,16 @@ function autoFocus() {
|
||||
}
|
||||
}
|
||||
|
||||
let autofocusTimeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
autofocusTimeoutId = setTimeout(() => {
|
||||
autoFocus()
|
||||
}, props.autofocusDelay)
|
||||
})
|
||||
|
||||
onScopeDispose(() => clearTimeout(autofocusTimeoutId))
|
||||
|
||||
const DateField = computed(() => props.range ? RangeDateField : SingleDateField)
|
||||
|
||||
defineExpose({
|
||||
|
||||
@@ -236,7 +236,7 @@ export interface InputMenuSlots<
|
||||
</script>
|
||||
|
||||
<script setup lang="ts" generic="T extends ArrayOrNested<InputMenuItem>, VK extends GetItemKeys<T> | undefined = undefined, M extends boolean = false, Mod extends Omit<ModelModifiers, 'lazy'> = Omit<ModelModifiers, 'lazy'>, C extends boolean | object = false">
|
||||
import { computed, ref, useAttrs, useTemplateRef, toRef, onMounted, toRaw, nextTick, watch } from 'vue'
|
||||
import { computed, ref, useAttrs, useTemplateRef, toRef, onMounted, onScopeDispose, toRaw, nextTick, watch } from 'vue'
|
||||
import { TagsInputRoot, TagsInputItem, TagsInputItemText, TagsInputItemDelete, TagsInputInput } from 'reka-ui'
|
||||
import { useForwardProps } from '../composables/useForwardProps'
|
||||
import { Combobox, Autocomplete } from 'reka-ui/namespaced'
|
||||
@@ -309,11 +309,12 @@ const virtualizerProps = toRef(() => {
|
||||
if (!props.virtualize) return false
|
||||
|
||||
return defu(typeof props.virtualize === 'boolean' ? {} : props.virtualize, {
|
||||
estimateSize: getEstimateSize(filteredItems.value, inputSize.value || 'md', props.descriptionKey as string, !!slots['item-description'])
|
||||
estimateSize: getEstimateSize(filteredItems.value, size.value ?? 'md', props.descriptionKey as string, !!slots['item-description'])
|
||||
})
|
||||
})
|
||||
|
||||
const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, size: formFieldSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField<InputProps>(_props)
|
||||
const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, size: formFieldSize, color: formFieldColor, id, name, highlight: formFieldHighlight, disabled: formFieldDisabled, ariaAttrs } = useFormField<InputProps>(_props)
|
||||
|
||||
const { orientation, size: fieldGroupSize } = useFieldGroup<InputProps>(_props)
|
||||
// Pass only the props the composable reads: `defu(props, ...)` copied every prop
|
||||
// through the `useComponentProps` proxy and subscribed this computed (and `ui`,
|
||||
@@ -329,7 +330,14 @@ const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponen
|
||||
loadingIcon: props.loadingIcon
|
||||
})))
|
||||
|
||||
const inputSize = computed(() => fieldGroupSize.value || formFieldSize.value)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const color = computed(() => formFieldColor.value ?? props.color)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const highlight = computed(() => formFieldHighlight.value ?? props.highlight)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const size = computed(() => fieldGroupSize.value ?? formFieldSize.value ?? props.size)
|
||||
|
||||
const disabled = computed(() => formFieldDisabled.value ?? props.disabled)
|
||||
|
||||
const [DefineCreateItemTemplate, ReuseCreateItemTemplate] = createReusableTemplate()
|
||||
const [DefineItemTemplate, ReuseItemTemplate] = createReusableTemplate<{ item: InputMenuItem, index: number }>({
|
||||
@@ -347,11 +355,11 @@ const [DefineItemTemplate, ReuseItemTemplate] = createReusableTemplate<{ item: I
|
||||
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const ui = computed(() => tv({ extend: theme, ...(appConfig.ui?.inputMenu || {}) })({
|
||||
color: color.value ?? props.color,
|
||||
color: color.value,
|
||||
variant: props.variant,
|
||||
size: inputSize?.value ?? props.size,
|
||||
size: size.value,
|
||||
loading: props.loading,
|
||||
highlight: highlight.value ?? props.highlight,
|
||||
highlight: highlight.value,
|
||||
fixed: props.fixed,
|
||||
leading: isLeading.value || !!props.avatar || !!slots.leading,
|
||||
trailing: isTrailing.value || !!slots.trailing,
|
||||
@@ -417,6 +425,8 @@ function autoFocus() {
|
||||
}
|
||||
}
|
||||
|
||||
let autofocusTimeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
if (isAutocomplete.value) {
|
||||
@@ -426,11 +436,13 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
autofocusTimeoutId = setTimeout(() => {
|
||||
autoFocus()
|
||||
}, props.autofocusDelay)
|
||||
})
|
||||
|
||||
onScopeDispose(() => clearTimeout(autofocusTimeoutId))
|
||||
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
if (isAutocomplete.value) {
|
||||
searchTerm.value = String(newValue ?? '')
|
||||
@@ -488,11 +500,13 @@ function onFocus(event: FocusEvent) {
|
||||
}
|
||||
|
||||
const isOpen = ref(false)
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
onScopeDispose(() => clearTimeout(timeoutId))
|
||||
|
||||
function onUpdateOpen(value: boolean) {
|
||||
isOpen.value = value
|
||||
|
||||
let timeoutId
|
||||
|
||||
if (!value) {
|
||||
const event = new FocusEvent('blur')
|
||||
|
||||
@@ -738,7 +752,7 @@ defineExpose({
|
||||
<UButton
|
||||
as="span"
|
||||
:icon="props.clearIcon || appConfig.ui.icons.close"
|
||||
:size="inputSize"
|
||||
:size="size"
|
||||
variant="link"
|
||||
color="neutral"
|
||||
tabindex="-1"
|
||||
|
||||
@@ -86,7 +86,7 @@ export interface InputNumberSlots {
|
||||
</script>
|
||||
|
||||
<script setup lang="ts" generic="T extends InputNumberValue = InputNumberValue, Mod extends Pick<ModelModifiers, 'optional'> = Pick<ModelModifiers, 'optional'>">
|
||||
import { onMounted, computed, useTemplateRef, toRef } from 'vue'
|
||||
import { onMounted, onScopeDispose, computed, useTemplateRef, toRef } from 'vue'
|
||||
import { NumberFieldRoot, NumberFieldInput, NumberFieldDecrement, NumberFieldIncrement } from 'reka-ui'
|
||||
import { useForwardProps } from '../composables/useForwardProps'
|
||||
import { reactivePick, useVModel } from '@vueuse/core'
|
||||
@@ -119,17 +119,25 @@ const appConfig = useAppConfig() as InputNumber['AppConfig']
|
||||
|
||||
const rootProps = useForwardProps(reactivePick(props, 'as', 'stepSnapping', 'formatOptions', 'disableWheelChange', 'invertWheelChange', 'required', 'readonly', 'focusOnChange', 'locale'), emits)
|
||||
|
||||
const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, id, color, size: formFieldSize, name, highlight, disabled, ariaAttrs } = useFormField<InputNumberProps<T, Mod>>(_props)
|
||||
const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, id, color: formFieldColor, size: formFieldSize, name, highlight: formFieldHighlight, disabled: formFieldDisabled, ariaAttrs } = useFormField<InputNumberProps<T, Mod>>(_props)
|
||||
|
||||
const { orientation, size: fieldGroupSize } = useFieldGroup<InputNumberProps<T, Mod>>(_props)
|
||||
|
||||
const inputSize = computed(() => fieldGroupSize.value || formFieldSize.value)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const color = computed(() => formFieldColor.value ?? props.color)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const highlight = computed(() => formFieldHighlight.value ?? props.highlight)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const size = computed(() => fieldGroupSize.value ?? formFieldSize.value ?? props.size)
|
||||
|
||||
const disabled = computed(() => formFieldDisabled.value ?? props.disabled)
|
||||
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const ui = computed(() => tv({ extend: theme, ...(appConfig.ui?.inputNumber || {}) })({
|
||||
color: color.value ?? props.color,
|
||||
color: color.value,
|
||||
variant: props.variant,
|
||||
size: inputSize.value ?? props.size,
|
||||
highlight: highlight.value ?? props.highlight,
|
||||
size: size.value,
|
||||
highlight: highlight.value,
|
||||
fixed: props.fixed,
|
||||
orientation: props.orientation,
|
||||
fieldGroup: orientation.value,
|
||||
@@ -168,12 +176,16 @@ function autoFocus() {
|
||||
}
|
||||
}
|
||||
|
||||
let autofocusTimeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
autofocusTimeoutId = setTimeout(() => {
|
||||
autoFocus()
|
||||
}, props.autofocusDelay)
|
||||
})
|
||||
|
||||
onScopeDispose(() => clearTimeout(autofocusTimeoutId))
|
||||
|
||||
defineExpose({
|
||||
inputRef: toRef(() => inputRef.value?.$el as HTMLInputElement)
|
||||
})
|
||||
@@ -211,7 +223,7 @@ defineExpose({
|
||||
<UButton
|
||||
:icon="incrementIcon"
|
||||
:color="color"
|
||||
:size="inputSize"
|
||||
:size="size"
|
||||
variant="link"
|
||||
:aria-label="t('inputNumber.increment')"
|
||||
v-bind="typeof props.increment === 'object' ? props.increment : undefined"
|
||||
@@ -226,7 +238,7 @@ defineExpose({
|
||||
<UButton
|
||||
:icon="decrementIcon"
|
||||
:color="color"
|
||||
:size="inputSize"
|
||||
:size="size"
|
||||
variant="link"
|
||||
:aria-label="t('inputNumber.decrement')"
|
||||
v-bind="typeof props.decrement === 'object' ? props.decrement : undefined"
|
||||
|
||||
@@ -90,18 +90,24 @@ const appConfig = useAppConfig() as InputRating['AppConfig']
|
||||
|
||||
const rootProps = useForwardProps(reactivePick(props, 'as', 'length', 'step', 'hoverable', 'clearable', 'required', 'modelValue', 'defaultValue'), emits)
|
||||
|
||||
const { id, emitFormChange, emitFormInput, size, color, name, disabled: formDisabled, ariaAttrs } = useFormField<InputRatingProps>(_props)
|
||||
const { id, emitFormChange, emitFormInput, size: formFieldSize, color: formFieldColor, name, disabled: formFieldDisabled, ariaAttrs } = useFormField<InputRatingProps>(_props)
|
||||
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const color = computed(() => formFieldColor.value ?? props.color)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const size = computed(() => formFieldSize.value ?? props.size)
|
||||
|
||||
const disabled = computed(() => formFieldDisabled.value ?? props.disabled)
|
||||
// `readonly` blocks interaction too, but only an explicit `disabled` dims the control.
|
||||
const disabled = computed(() => formDisabled.value || props.readonly)
|
||||
const rootDisabled = computed(() => disabled.value || props.readonly)
|
||||
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const ui = computed(() => tv({ extend: theme, ...(appConfig.ui?.inputRating || {}) })({
|
||||
size: size.value ?? props.size,
|
||||
color: color.value ?? props.color,
|
||||
size: size.value,
|
||||
color: color.value,
|
||||
orientation: props.orientation,
|
||||
readonly: props.readonly && !formDisabled.value,
|
||||
disabled: formDisabled.value
|
||||
readonly: props.readonly && !disabled.value,
|
||||
disabled: disabled.value
|
||||
}))
|
||||
|
||||
const starIcon = computed(() => props.icon ?? appConfig.ui.icons.star)
|
||||
@@ -122,7 +128,7 @@ function onUpdate(value: number) {
|
||||
data-slot="root"
|
||||
v-bind="{ ...rootProps, ...$attrs, ...ariaAttrs }"
|
||||
:name="name"
|
||||
:disabled="disabled"
|
||||
:disabled="rootDisabled"
|
||||
:aria-readonly="props.readonly || undefined"
|
||||
:orientation="props.orientation"
|
||||
:class="ui.root({ class: [props.ui?.root, props.class] })"
|
||||
|
||||
@@ -69,7 +69,7 @@ export interface InputTagsSlots<T extends InputTagItem = InputTagItem> {
|
||||
</script>
|
||||
|
||||
<script setup lang="ts" generic="T extends InputTagItem">
|
||||
import { computed, useTemplateRef, onMounted, toRaw, toRef } from 'vue'
|
||||
import { computed, useTemplateRef, onMounted, onScopeDispose, toRaw, toRef } from 'vue'
|
||||
import { TagsInputRoot, TagsInputItem, TagsInputItemText, TagsInputItemDelete, TagsInputInput } from 'reka-ui'
|
||||
import { useForwardProps } from '../composables/useForwardProps'
|
||||
import { reactivePick } from '@vueuse/core'
|
||||
@@ -97,19 +97,27 @@ const appConfig = useAppConfig() as InputTags['AppConfig']
|
||||
|
||||
const rootProps = useForwardProps(reactivePick(props, 'as', 'addOnPaste', 'addOnTab', 'addOnBlur', 'duplicate', 'delimiter', 'max', 'convertValue', 'displayValue', 'required'), emits)
|
||||
|
||||
const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, size: formFieldSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField<InputTagsProps>(_props)
|
||||
const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, size: formFieldSize, color: formFieldColor, id, name, highlight: formFieldHighlight, disabled: formFieldDisabled, ariaAttrs } = useFormField<InputTagsProps>(_props)
|
||||
|
||||
const { orientation, size: fieldGroupSize } = useFieldGroup<InputTagsProps>(_props)
|
||||
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(props)
|
||||
|
||||
const inputSize = computed(() => fieldGroupSize.value || formFieldSize.value)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const color = computed(() => formFieldColor.value ?? props.color)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const highlight = computed(() => formFieldHighlight.value ?? props.highlight)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const size = computed(() => fieldGroupSize.value ?? formFieldSize.value ?? props.size)
|
||||
|
||||
const disabled = computed(() => formFieldDisabled.value ?? props.disabled)
|
||||
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const ui = computed(() => tv({ extend: theme, ...(appConfig.ui?.inputTags || {}) })({
|
||||
color: color.value ?? props.color,
|
||||
color: color.value,
|
||||
variant: props.variant,
|
||||
size: inputSize?.value ?? props.size,
|
||||
size: size.value,
|
||||
loading: props.loading,
|
||||
highlight: highlight.value ?? props.highlight,
|
||||
highlight: highlight.value,
|
||||
fixed: props.fixed,
|
||||
leading: isLeading.value || !!props.avatar || !!slots.leading,
|
||||
trailing: isTrailing.value || !!slots.trailing,
|
||||
@@ -124,12 +132,16 @@ function autoFocus() {
|
||||
}
|
||||
}
|
||||
|
||||
let autofocusTimeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
autofocusTimeoutId = setTimeout(() => {
|
||||
autoFocus()
|
||||
}, props.autofocusDelay)
|
||||
})
|
||||
|
||||
onScopeDispose(() => clearTimeout(autofocusTimeoutId))
|
||||
|
||||
function onUpdate(value: T[]) {
|
||||
if (toRaw(props.modelValue) === value) {
|
||||
return
|
||||
|
||||
@@ -80,7 +80,7 @@ export interface InputTimeSlots {
|
||||
</script>
|
||||
|
||||
<script setup lang="ts" generic="R extends boolean">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, onScopeDispose, ref } from 'vue'
|
||||
import { TimeRangeFieldRoot, TimeRangeFieldInput } from 'reka-ui'
|
||||
import { useForwardProps } from '../composables/useForwardProps'
|
||||
import { TimeField as SingleTimeField } from 'reka-ui/namespaced'
|
||||
@@ -108,19 +108,27 @@ const appConfig = useAppConfig() as InputTime['AppConfig']
|
||||
|
||||
const rootProps = useForwardProps(reactiveOmit(props, 'id', 'name', 'range', 'modelValue', 'defaultValue', 'color', 'variant', 'size', 'highlight', 'fixed', 'disabled', 'autofocus', 'autofocusDelay', 'icon', 'avatar', 'leading', 'leadingIcon', 'trailing', 'trailingIcon', 'loading', 'loadingIcon', 'separatorIcon', 'class', 'ui'), emits)
|
||||
|
||||
const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, id, color, size: formFieldSize, name, highlight, disabled, ariaAttrs } = useFormField<InputTimeProps<R>>(_props)
|
||||
const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, id, color: formFieldColor, size: formFieldSize, name, highlight: formFieldHighlight, disabled: formFieldDisabled, ariaAttrs } = useFormField<InputTimeProps<R>>(_props)
|
||||
|
||||
const { orientation, size: fieldGroupSize } = useFieldGroup<InputTimeProps<R>>(_props)
|
||||
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(props)
|
||||
|
||||
const inputSize = computed(() => fieldGroupSize.value || formFieldSize.value)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const color = computed(() => formFieldColor.value ?? props.color)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const highlight = computed(() => formFieldHighlight.value ?? props.highlight)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const size = computed(() => fieldGroupSize.value ?? formFieldSize.value ?? props.size)
|
||||
|
||||
const disabled = computed(() => formFieldDisabled.value ?? props.disabled)
|
||||
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const ui = computed(() => tv({ extend: theme, ...(appConfig.ui?.inputTime || {}) })({
|
||||
color: color.value ?? props.color,
|
||||
color: color.value,
|
||||
variant: props.variant,
|
||||
size: inputSize.value ?? props.size,
|
||||
size: size.value,
|
||||
loading: props.loading,
|
||||
highlight: highlight.value ?? props.highlight,
|
||||
highlight: highlight.value,
|
||||
fixed: props.fixed,
|
||||
leading: isLeading.value || !!props.avatar || !!slots.leading,
|
||||
trailing: isTrailing.value || !!slots.trailing,
|
||||
@@ -169,12 +177,16 @@ function autoFocus() {
|
||||
}
|
||||
}
|
||||
|
||||
let autofocusTimeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
autofocusTimeoutId = setTimeout(() => {
|
||||
autoFocus()
|
||||
}, props.autofocusDelay)
|
||||
})
|
||||
|
||||
onScopeDispose(() => clearTimeout(autofocusTimeoutId))
|
||||
|
||||
defineExpose({
|
||||
inputsRef
|
||||
})
|
||||
|
||||
@@ -205,12 +205,21 @@ const virtualizerProps = toRef(() => {
|
||||
if (!props.virtualize) return false
|
||||
|
||||
return defu(typeof props.virtualize === 'boolean' ? {} : props.virtualize, {
|
||||
estimateSize: getEstimateSize(filteredItems.value, size.value || 'md', props.descriptionKey as string, !!slots['item-description'])
|
||||
estimateSize: getEstimateSize(filteredItems.value, size.value ?? 'md', props.descriptionKey as string, !!slots['item-description'])
|
||||
})
|
||||
})
|
||||
const inputProps = toRef(() => defu(typeof props.filter === 'object' ? props.filter : {}, { placeholder: t('listbox.search'), variant: 'none' }) as Omit<InputProps, 'modelValue' | 'defaultValue'>)
|
||||
|
||||
const { emitFormChange, emitFormInput, name, size, color, id, highlight, disabled, ariaAttrs } = useFormField<InputProps>(_props, { bind: false })
|
||||
const { emitFormChange, emitFormInput, name, size: formFieldSize, color: formFieldColor, id, highlight: formFieldHighlight, disabled: formFieldDisabled, ariaAttrs } = useFormField<InputProps>(_props, { bind: false })
|
||||
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const color = computed(() => formFieldColor.value ?? props.color)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const highlight = computed(() => formFieldHighlight.value ?? props.highlight)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const size = computed(() => formFieldSize.value ?? props.size)
|
||||
|
||||
const disabled = computed(() => formFieldDisabled.value ?? props.disabled)
|
||||
|
||||
const [DefineItemTemplate, ReuseItemTemplate] = createReusableTemplate<{ item: ListboxItem, index: number }>({
|
||||
props: {
|
||||
@@ -227,9 +236,9 @@ const [DefineItemTemplate, ReuseItemTemplate] = createReusableTemplate<{ item: L
|
||||
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const ui = computed(() => tv({ extend: theme, ...(appConfig.ui?.listbox || {}) })({
|
||||
color: color.value ?? props.color,
|
||||
size: size.value ?? props.size,
|
||||
highlight: highlight.value ?? props.highlight,
|
||||
color: color.value,
|
||||
size: size.value,
|
||||
highlight: highlight.value,
|
||||
disabled: disabled.value,
|
||||
virtualize: !!props.virtualize
|
||||
}))
|
||||
|
||||
@@ -61,7 +61,7 @@ export interface PinInputSlots {
|
||||
</script>
|
||||
|
||||
<script setup lang="ts" generic="T extends PinInputType">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, onScopeDispose } from 'vue'
|
||||
import { PinInputInput, PinInputRoot } from 'reka-ui'
|
||||
import { useForwardProps } from '../composables/useForwardProps'
|
||||
import { reactivePick } from '@vueuse/core'
|
||||
@@ -85,14 +85,23 @@ const appConfig = useAppConfig() as PinInput['AppConfig']
|
||||
|
||||
const rootProps = useForwardProps(reactivePick(props, 'disabled', 'id', 'mask', 'name', 'otp', 'required', 'type'), emits)
|
||||
|
||||
const { emitFormInput, emitFormFocus, emitFormChange, emitFormBlur, size, color, id, name, highlight, disabled, ariaAttrs } = useFormField<PinInputProps>(_props)
|
||||
const { emitFormInput, emitFormFocus, emitFormChange, emitFormBlur, size: formFieldSize, color: formFieldColor, id, name, highlight: formFieldHighlight, disabled: formFieldDisabled, ariaAttrs } = useFormField<PinInputProps>(_props)
|
||||
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const color = computed(() => formFieldColor.value ?? props.color)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const highlight = computed(() => formFieldHighlight.value ?? props.highlight)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const size = computed(() => formFieldSize.value ?? props.size)
|
||||
|
||||
const disabled = computed(() => formFieldDisabled.value ?? props.disabled)
|
||||
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const ui = computed(() => tv({ extend: theme, ...(appConfig.ui?.pinInput || {}) })({
|
||||
color: color.value ?? props.color,
|
||||
color: color.value,
|
||||
variant: props.variant,
|
||||
size: size.value ?? props.size,
|
||||
highlight: highlight.value ?? props.highlight,
|
||||
size: size.value,
|
||||
highlight: highlight.value,
|
||||
fixed: props.fixed
|
||||
}))
|
||||
|
||||
@@ -141,12 +150,16 @@ function shouldInsertSeparator(index: number) {
|
||||
return Number.isInteger(separator) && separator > 0 && position % separator === 0
|
||||
}
|
||||
|
||||
let autofocusTimeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
autofocusTimeoutId = setTimeout(() => {
|
||||
autoFocus()
|
||||
}, props.autofocusDelay)
|
||||
})
|
||||
|
||||
onScopeDispose(() => clearTimeout(autofocusTimeoutId))
|
||||
|
||||
defineExpose({
|
||||
inputsRef
|
||||
})
|
||||
|
||||
@@ -116,14 +116,23 @@ const appConfig = useAppConfig() as RadioGroup['AppConfig']
|
||||
|
||||
const rootProps = useForwardProps(reactivePick(props, 'as', 'loop', 'required'), emits)
|
||||
|
||||
const { emitFormChange, emitFormInput, color, name, size, highlight, id: _id, disabled, ariaAttrs } = useFormField<RadioGroupProps<T>>(_props, { bind: false })
|
||||
const { emitFormChange, emitFormInput, color: formFieldColor, name, size: formFieldSize, highlight: formFieldHighlight, id: _id, disabled: formFieldDisabled, ariaAttrs } = useFormField<RadioGroupProps<T>>(_props, { bind: false })
|
||||
const id = _id.value ?? useId()
|
||||
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const color = computed(() => formFieldColor.value ?? props.color)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const highlight = computed(() => formFieldHighlight.value ?? props.highlight)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const size = computed(() => formFieldSize.value ?? props.size)
|
||||
|
||||
const disabled = computed(() => formFieldDisabled.value ?? props.disabled)
|
||||
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const ui = computed(() => tv({ extend: theme, ...(appConfig.ui?.radioGroup || {}) })({
|
||||
size: size.value ?? props.size,
|
||||
color: color.value ?? props.color,
|
||||
highlight: highlight.value ?? props.highlight,
|
||||
size: size.value,
|
||||
color: color.value,
|
||||
highlight: highlight.value,
|
||||
disabled: disabled.value,
|
||||
required: props.required,
|
||||
orientation: props.orientation,
|
||||
|
||||
@@ -151,7 +151,7 @@ export interface SelectSlots<
|
||||
</script>
|
||||
|
||||
<script setup lang="ts" generic="T extends ArrayOrNested<SelectItem>, VK extends GetItemKeys<T> = 'value', M extends boolean = false, Mod extends Omit<ModelModifiers, 'lazy'> = Omit<ModelModifiers, 'lazy'>">
|
||||
import { useTemplateRef, computed, onMounted, toRef } from 'vue'
|
||||
import { useTemplateRef, computed, onMounted, onScopeDispose, toRef } from 'vue'
|
||||
import { SelectRoot, SelectArrow, SelectTrigger, SelectPortal, SelectContent, SelectViewport, SelectValue as RSelectValue, SelectLabel, SelectGroup, SelectItem as RSelectItem, SelectItemIndicator, SelectItemText, SelectSeparator } from 'reka-ui'
|
||||
import { defu } from 'defu'
|
||||
import { reactivePick } from '@vueuse/core'
|
||||
@@ -190,7 +190,8 @@ const position = computed(() => props.content?.position ?? appConfig.ui?.select?
|
||||
const contentProps = toRef(() => defu(props.content, { side: 'bottom', sideOffset: 8, collisionPadding: 8, position: position.value }) as SelectContentProps)
|
||||
const arrowProps = toRef(() => defu(props.arrow, { rounded: true }) as SelectArrowProps)
|
||||
|
||||
const { emitFormChange, emitFormInput, emitFormBlur, emitFormFocus, size: formFieldSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField<InputProps>(_props)
|
||||
const { emitFormChange, emitFormInput, emitFormBlur, emitFormFocus, size: formFieldSize, color: formFieldColor, id, name, highlight: formFieldHighlight, disabled: formFieldDisabled, ariaAttrs } = useFormField<InputProps>(_props)
|
||||
|
||||
const { orientation, size: fieldGroupSize } = useFieldGroup<InputProps>(_props)
|
||||
// Pass only the props the composable reads: `defu(props, ...)` copied every prop
|
||||
// through the `useComponentProps` proxy and subscribed this computed (and `ui`,
|
||||
@@ -206,17 +207,24 @@ const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponen
|
||||
loadingIcon: props.loadingIcon
|
||||
})))
|
||||
|
||||
const selectSize = computed(() => fieldGroupSize.value || formFieldSize.value)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const color = computed(() => formFieldColor.value ?? props.color)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const highlight = computed(() => formFieldHighlight.value ?? props.highlight)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const size = computed(() => fieldGroupSize.value ?? formFieldSize.value ?? props.size)
|
||||
|
||||
const disabled = computed(() => formFieldDisabled.value ?? props.disabled)
|
||||
|
||||
const isItemAligned = computed(() => position.value === 'item-aligned')
|
||||
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const ui = computed(() => tv({ extend: theme, ...(appConfig.ui?.select || {}) })({
|
||||
color: color.value ?? props.color,
|
||||
color: color.value,
|
||||
variant: props.variant,
|
||||
size: selectSize.value ?? props.size,
|
||||
size: size.value,
|
||||
loading: props.loading,
|
||||
highlight: highlight.value ?? props.highlight,
|
||||
highlight: highlight.value,
|
||||
leading: isLeading.value || !!props.avatar || !!slots.leading,
|
||||
trailing: isTrailing.value || !!slots.trailing,
|
||||
fieldGroup: orientation.value,
|
||||
@@ -262,12 +270,16 @@ function autoFocus() {
|
||||
}
|
||||
}
|
||||
|
||||
let autofocusTimeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
autofocusTimeoutId = setTimeout(() => {
|
||||
autoFocus()
|
||||
}, props.autofocusDelay)
|
||||
})
|
||||
|
||||
onScopeDispose(() => clearTimeout(autofocusTimeoutId))
|
||||
|
||||
function onUpdate(value: any) {
|
||||
if (props.modelModifiers?.trim && (typeof value === 'string' || value === null || value === undefined)) {
|
||||
value = value?.trim() ?? null
|
||||
|
||||
@@ -230,7 +230,7 @@ export interface SelectMenuSlots<
|
||||
</script>
|
||||
|
||||
<script setup lang="ts" generic="T extends ArrayOrNested<SelectMenuItem>, VK extends GetItemKeys<T> | undefined = undefined, M extends boolean = false, Mod extends Omit<ModelModifiers, 'lazy'> = Omit<ModelModifiers, 'lazy'>, C extends boolean | object = false">
|
||||
import { useTemplateRef, computed, ref, onMounted, toRef, toRaw, watch, nextTick } from 'vue'
|
||||
import { useTemplateRef, computed, ref, onMounted, onScopeDispose, toRef, toRaw, watch, nextTick } from 'vue'
|
||||
import { ComboboxRoot, ComboboxArrow, ComboboxAnchor, ComboboxInput, ComboboxTrigger, ComboboxCancel, ComboboxPortal, ComboboxContent, ComboboxEmpty, ComboboxGroup, ComboboxVirtualizer, ComboboxLabel, ComboboxSeparator, ComboboxItem, ComboboxItemIndicator, FocusScope } from 'reka-ui'
|
||||
import { useForwardProps } from '../composables/useForwardProps'
|
||||
import { defu } from 'defu'
|
||||
@@ -285,12 +285,13 @@ const virtualizerProps = toRef(() => {
|
||||
if (!props.virtualize) return false
|
||||
|
||||
return defu(typeof props.virtualize === 'boolean' ? {} : props.virtualize, {
|
||||
estimateSize: getEstimateSize(filteredItems.value, selectSize.value || 'md', props.descriptionKey as string, !!slots['item-description'])
|
||||
estimateSize: getEstimateSize(filteredItems.value, size.value ?? 'md', props.descriptionKey as string, !!slots['item-description'])
|
||||
})
|
||||
})
|
||||
const searchInputProps = toRef(() => defu(props.searchInput, { placeholder: t('selectMenu.search'), variant: 'none' }) as Omit<InputProps, 'modelValue' | 'defaultValue'>)
|
||||
|
||||
const { emitFormBlur, emitFormFocus, emitFormInput, emitFormChange, size: formFieldSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField<InputProps>(_props)
|
||||
const { emitFormBlur, emitFormFocus, emitFormInput, emitFormChange, size: formFieldSize, color: formFieldColor, id, name, highlight: formFieldHighlight, disabled: formFieldDisabled, ariaAttrs } = useFormField<InputProps>(_props)
|
||||
|
||||
const { orientation, size: fieldGroupSize } = useFieldGroup<InputProps>(_props)
|
||||
// Pass only the props the composable reads: `defu(props, ...)` copied every prop
|
||||
// through the `useComponentProps` proxy and subscribed this computed (and `ui`,
|
||||
@@ -306,7 +307,14 @@ const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponen
|
||||
loadingIcon: props.loadingIcon
|
||||
})))
|
||||
|
||||
const selectSize = computed(() => fieldGroupSize.value || formFieldSize.value)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const color = computed(() => formFieldColor.value ?? props.color)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const highlight = computed(() => formFieldHighlight.value ?? props.highlight)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const size = computed(() => fieldGroupSize.value ?? formFieldSize.value ?? props.size)
|
||||
|
||||
const disabled = computed(() => formFieldDisabled.value ?? props.disabled)
|
||||
|
||||
const [DefineCreateItemTemplate, ReuseCreateItemTemplate] = createReusableTemplate()
|
||||
const [DefineItemTemplate, ReuseItemTemplate] = createReusableTemplate<{ item: SelectMenuItem, index: number }>({
|
||||
@@ -324,11 +332,11 @@ const [DefineItemTemplate, ReuseItemTemplate] = createReusableTemplate<{ item: S
|
||||
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const ui = computed(() => tv({ extend: theme, ...(appConfig.ui?.selectMenu || {}) })({
|
||||
color: color.value ?? props.color,
|
||||
color: color.value,
|
||||
variant: props.variant,
|
||||
size: selectSize?.value ?? props.size,
|
||||
size: size.value,
|
||||
loading: props.loading,
|
||||
highlight: highlight.value ?? props.highlight,
|
||||
highlight: highlight.value,
|
||||
leading: isLeading.value || !!props.avatar || !!slots.leading,
|
||||
trailing: isTrailing.value || !!slots.trailing,
|
||||
fieldGroup: orientation.value,
|
||||
@@ -406,12 +414,16 @@ function autoFocus() {
|
||||
}
|
||||
}
|
||||
|
||||
let autofocusTimeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
autofocusTimeoutId = setTimeout(() => {
|
||||
autoFocus()
|
||||
}, props.autofocusDelay)
|
||||
})
|
||||
|
||||
onScopeDispose(() => clearTimeout(autofocusTimeoutId))
|
||||
|
||||
function onUpdate(value: any) {
|
||||
if (toRaw(props.modelValue) === value) {
|
||||
return
|
||||
@@ -445,11 +457,13 @@ function onUpdate(value: any) {
|
||||
}
|
||||
|
||||
const isOpen = ref(false)
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
onScopeDispose(() => clearTimeout(timeoutId))
|
||||
|
||||
function onUpdateOpen(value: boolean) {
|
||||
isOpen.value = value
|
||||
|
||||
let timeoutId
|
||||
|
||||
if (!value) {
|
||||
const event = new FocusEvent('blur')
|
||||
|
||||
@@ -649,7 +663,7 @@ defineExpose({
|
||||
<UButton
|
||||
as="span"
|
||||
:icon="props.clearIcon || appConfig.ui.icons.close"
|
||||
:size="selectSize"
|
||||
:size="size"
|
||||
variant="link"
|
||||
color="neutral"
|
||||
tabindex="-1"
|
||||
@@ -676,7 +690,7 @@ defineExpose({
|
||||
<UInput
|
||||
autofocus
|
||||
autocomplete="off"
|
||||
:size="selectSize"
|
||||
:size="size"
|
||||
v-bind="searchInputProps"
|
||||
:model-modifiers="{
|
||||
trim: props.modelModifiers?.trim
|
||||
|
||||
@@ -70,7 +70,14 @@ const appConfig = useAppConfig() as Slider['AppConfig']
|
||||
|
||||
const rootProps = useForwardProps(reactivePick(props, 'as', 'orientation', 'min', 'max', 'step', 'minStepsBetweenThumbs', 'inverted'), emits)
|
||||
|
||||
const { id, emitFormChange, emitFormInput, size, color, name, disabled, ariaAttrs } = useFormField<SliderProps>(_props)
|
||||
const { id, emitFormChange, emitFormInput, size: formFieldSize, color: formFieldColor, name, disabled: formFieldDisabled, ariaAttrs } = useFormField<SliderProps>(_props)
|
||||
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const color = computed(() => formFieldColor.value ?? props.color)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const size = computed(() => formFieldSize.value ?? props.size)
|
||||
|
||||
const disabled = computed(() => formFieldDisabled.value ?? props.disabled)
|
||||
|
||||
const defaultSliderValue = computed(() => {
|
||||
if (typeof props.defaultValue === 'number') {
|
||||
@@ -96,8 +103,8 @@ const thumbs = computed(() => sliderValue.value?.length ?? 1)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const ui = computed(() => tv({ extend: theme, ...(appConfig.ui?.slider || {}) })({
|
||||
disabled: disabled.value,
|
||||
size: size.value ?? props.size,
|
||||
color: color.value ?? props.color,
|
||||
size: size.value,
|
||||
color: color.value,
|
||||
orientation: props.orientation
|
||||
}))
|
||||
|
||||
|
||||
@@ -82,9 +82,18 @@ const appConfig = useAppConfig() as Switch['AppConfig']
|
||||
|
||||
const rootProps = useForwardProps(reactivePick(props, 'required', 'value', 'defaultValue', 'modelValue', 'trueValue', 'falseValue'), emits)
|
||||
|
||||
const { id: _id, emitFormChange, emitFormInput, size, color, highlight, name, disabled, ariaAttrs } = useFormField<SwitchProps<T>>(_props)
|
||||
const { id: _id, emitFormChange, emitFormInput, size: formFieldSize, color: formFieldColor, highlight: formFieldHighlight, name, disabled: formFieldDisabled, ariaAttrs } = useFormField<SwitchProps<T>>(_props)
|
||||
const id = _id.value ?? useId()
|
||||
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const color = computed(() => formFieldColor.value ?? props.color)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const highlight = computed(() => formFieldHighlight.value ?? props.highlight)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const size = computed(() => formFieldSize.value ?? props.size)
|
||||
|
||||
const disabled = computed(() => formFieldDisabled.value ?? props.disabled)
|
||||
|
||||
const attrs = useAttrs()
|
||||
// Omit `data-state` to prevent conflicts with parent components (e.g. TooltipTrigger)
|
||||
const forwardedAttrs = computed(() => {
|
||||
@@ -94,9 +103,9 @@ const forwardedAttrs = computed(() => {
|
||||
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const ui = computed(() => tv({ extend: theme, ...(appConfig.ui?.switch || {}) })({
|
||||
size: size.value ?? props.size,
|
||||
color: color.value ?? props.color,
|
||||
highlight: highlight.value ?? props.highlight,
|
||||
size: size.value,
|
||||
color: color.value,
|
||||
highlight: highlight.value,
|
||||
required: props.required,
|
||||
loading: props.loading,
|
||||
disabled: disabled.value || props.loading
|
||||
|
||||
@@ -67,7 +67,7 @@ export interface TextareaSlots {
|
||||
</script>
|
||||
|
||||
<script setup lang="ts" generic="T extends TextareaValue, Mod extends ModelModifiers = ModelModifiers">
|
||||
import { useTemplateRef, computed, onMounted, nextTick, watch } from 'vue'
|
||||
import { useTemplateRef, computed, onMounted, onScopeDispose, nextTick, watch } from 'vue'
|
||||
import { Primitive } from 'reka-ui'
|
||||
import { useVModel } from '@vueuse/core'
|
||||
import { useAppConfig } from '#imports'
|
||||
@@ -97,16 +97,25 @@ const modelValue = useVModel<TextareaProps<T, Mod>, 'modelValue', 'update:modelV
|
||||
|
||||
const appConfig = useAppConfig() as Textarea['AppConfig']
|
||||
|
||||
const { emitFormFocus, emitFormBlur, emitFormInput, emitFormChange, size, color, id, name, highlight, disabled, ariaAttrs } = useFormField<TextareaProps<T>>(_props, { deferInputValidation: true })
|
||||
const { emitFormFocus, emitFormBlur, emitFormInput, emitFormChange, size: formFieldSize, color: formFieldColor, id, name, highlight: formFieldHighlight, disabled: formFieldDisabled, ariaAttrs } = useFormField<TextareaProps<T>>(_props, { deferInputValidation: true })
|
||||
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const color = computed(() => formFieldColor.value ?? props.color)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const highlight = computed(() => formFieldHighlight.value ?? props.highlight)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const size = computed(() => formFieldSize.value ?? props.size)
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const disabled = computed(() => formFieldDisabled.value ?? props.disabled)
|
||||
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(props)
|
||||
|
||||
// eslint-disable-next-line vue/no-dupe-keys
|
||||
const ui = computed(() => tv({ extend: theme, ...(appConfig.ui?.textarea || {}) })({
|
||||
color: color.value ?? props.color,
|
||||
color: color.value,
|
||||
variant: props.variant,
|
||||
size: size?.value ?? props.size,
|
||||
size: size.value,
|
||||
loading: props.loading,
|
||||
highlight: highlight.value ?? props.highlight,
|
||||
highlight: highlight.value,
|
||||
fixed: props.fixed,
|
||||
autoresize: props.autoresize,
|
||||
leading: isLeading.value || !!props.avatar || !!slots.leading,
|
||||
@@ -198,17 +207,25 @@ watch(modelValue, () => {
|
||||
nextTick(autoResize)
|
||||
})
|
||||
|
||||
let autofocusTimeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
let autoresizeTimeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
autofocusTimeoutId = setTimeout(() => {
|
||||
autoFocus()
|
||||
}, props.autofocusDelay)
|
||||
|
||||
setTimeout(async () => {
|
||||
autoresizeTimeoutId = setTimeout(async () => {
|
||||
await nextTick()
|
||||
autoResize()
|
||||
}, props.autoresizeDelay)
|
||||
})
|
||||
|
||||
onScopeDispose(() => {
|
||||
clearTimeout(autofocusTimeoutId)
|
||||
clearTimeout(autoresizeTimeoutId)
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
textareaRef,
|
||||
autoResize
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { inject, computed, provide } from 'vue'
|
||||
import { inject, computed, provide, getCurrentScope, onScopeDispose } from 'vue'
|
||||
import type { InjectionKey, Ref, ComputedRef } from 'vue'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import type { UseEventBusReturn } from '@vueuse/core'
|
||||
@@ -39,10 +39,16 @@ export const formErrorsInjectionKey: InjectionKey<Readonly<Ref<FormErrorWithId[]
|
||||
* ```ts
|
||||
* size: size.value ?? props.size,
|
||||
* color: color.value ?? props.color,
|
||||
* highlight: highlight.value ?? props.highlight
|
||||
* highlight: highlight.value ?? props.highlight,
|
||||
* disabled: disabled.value ?? props.disabled
|
||||
* ```
|
||||
*
|
||||
* Final precedence: `explicit > FormField > <UTheme :props> > withDefaults > app.config > tv defaults`.
|
||||
* `highlight` and `disabled` are Boolean props, which Vue auto-casts to `false`
|
||||
* when unset, so they are normalized back to `undefined` here. Otherwise the
|
||||
* `??` above would short-circuit on `false` and the proxy would never be read.
|
||||
*
|
||||
* Final precedence: `explicit > FormField > <UTheme :props> > app.config > withDefaults > tv defaults`,
|
||||
* matching what `useComponentProps` resolves.
|
||||
*/
|
||||
export function useFormField<T>(props?: Props<T>, opts?: { bind?: boolean, deferInputValidation?: boolean }) {
|
||||
const formOptions = inject(formOptionsInjectionKey, undefined)
|
||||
@@ -81,8 +87,19 @@ export function useFormField<T>(props?: Props<T>, opts?: { bind?: boolean, defer
|
||||
emitFormEvent('change', formField?.value.name)
|
||||
}
|
||||
|
||||
// The trailing call still fires after teardown, which would validate a field
|
||||
// that is no longer rendered when the input unmounts inside the debounce window.
|
||||
let disposed = false
|
||||
if (getCurrentScope()) {
|
||||
onScopeDispose(() => {
|
||||
disposed = true
|
||||
})
|
||||
}
|
||||
|
||||
const emitFormInput = useDebounceFn(
|
||||
() => {
|
||||
if (disposed) return
|
||||
|
||||
emitFormEvent('input', formField?.value.name, !opts?.deferInputValidation || formField?.value.eagerValidation)
|
||||
},
|
||||
formField?.value.validateOnInputDelay ?? formOptions?.value.validateOnInputDelay ?? 0
|
||||
@@ -93,8 +110,8 @@ export function useFormField<T>(props?: Props<T>, opts?: { bind?: boolean, defer
|
||||
name: computed(() => props?.name ?? formField?.value.name),
|
||||
size: computed(() => props?.size ?? formField?.value.size),
|
||||
color: computed(() => formField?.value.error ? 'error' : props?.color),
|
||||
highlight: computed(() => formField?.value.error ? true : props?.highlight),
|
||||
disabled: computed(() => formOptions?.value.disabled || props?.disabled),
|
||||
highlight: computed(() => formField?.value.error ? true : (props?.highlight || undefined)),
|
||||
disabled: computed(() => formOptions?.value.disabled || props?.disabled || undefined),
|
||||
emitFormBlur,
|
||||
emitFormInput,
|
||||
emitFormChange,
|
||||
|
||||
@@ -10,7 +10,10 @@ import Badge from '../../src/runtime/components/Badge.vue'
|
||||
import Alert from '../../src/runtime/components/Alert.vue'
|
||||
import Input from '../../src/runtime/components/Input.vue'
|
||||
import Checkbox from '../../src/runtime/components/Checkbox.vue'
|
||||
import CheckboxGroup from '../../src/runtime/components/CheckboxGroup.vue'
|
||||
import FileUpload from '../../src/runtime/components/FileUpload.vue'
|
||||
import Tooltip from '../../src/runtime/components/Tooltip.vue'
|
||||
import Form from '../../src/runtime/components/Form.vue'
|
||||
import FormField from '../../src/runtime/components/FormField.vue'
|
||||
import FieldGroup from '../../src/runtime/components/FieldGroup.vue'
|
||||
import Avatar from '../../src/runtime/components/Avatar.vue'
|
||||
@@ -482,6 +485,94 @@ describe('Theme', () => {
|
||||
expect(wrapper.html()).not.toContain('focus-visible:ring-success')
|
||||
})
|
||||
|
||||
// `highlight` and `disabled` are Boolean props, which Vue auto-casts to
|
||||
// `false` when unset. `useFormField` normalizes them back to `undefined` so
|
||||
// the `highlight.value ?? props.highlight` fallback isn't short-circuited on
|
||||
// `false` — otherwise `<UTheme :props>` never reaches any form control.
|
||||
test(':props highlight reaches a form control', async () => {
|
||||
const wrapper = await mountSuspended({
|
||||
components: { Theme, Checkbox },
|
||||
template: `
|
||||
<Theme :props="{ checkbox: { highlight: true } }">
|
||||
<Checkbox label="Accept" />
|
||||
</Theme>
|
||||
`
|
||||
})
|
||||
|
||||
expect(wrapper.find('button[role="checkbox"]').classes()).toContain('ring-primary')
|
||||
expect(wrapper.find('button[role="checkbox"]').classes()).not.toContain('ring-accented')
|
||||
})
|
||||
|
||||
// A theme-provided `disabled` must disable the control, not only paint it as
|
||||
// disabled. Regressed when only the `tv()` call read the resolved value while
|
||||
// the template kept binding the raw `useFormField` ref.
|
||||
test(':props disabled disables a control instead of only styling it', async () => {
|
||||
const wrapper = await mountSuspended({
|
||||
components: { Theme, FileUpload },
|
||||
template: `
|
||||
<Theme :props="{ fileUpload: { disabled: true } }">
|
||||
<FileUpload />
|
||||
</Theme>
|
||||
`
|
||||
})
|
||||
|
||||
expect(wrapper.html()).toContain('cursor-not-allowed')
|
||||
expect(wrapper.find('input[type="file"]').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.find('[data-slot="base"]').attributes('tabindex')).toBe('-1')
|
||||
})
|
||||
|
||||
// `<UForm disabled>` is the closer context and must keep winning over an
|
||||
// explicit `:disabled="false"` resolved through the proxy.
|
||||
test('Form disabled wins over :props disabled', async () => {
|
||||
const wrapper = await mountSuspended({
|
||||
components: { Theme, Form, Checkbox },
|
||||
template: `
|
||||
<Theme :props="{ checkbox: { disabled: false } }">
|
||||
<Form :state="{}" disabled>
|
||||
<Checkbox label="Accept" />
|
||||
</Form>
|
||||
</Theme>
|
||||
`
|
||||
})
|
||||
|
||||
expect(wrapper.find('button[role="checkbox"]').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
// CheckboxGroup resolves `color`/`size`/`highlight` once and forwards them to
|
||||
// every child Checkbox, so a theme set on the group has to reach the items.
|
||||
test(':props color on a checkbox group reaches its items', async () => {
|
||||
const wrapper = await mountSuspended({
|
||||
components: { Theme, CheckboxGroup },
|
||||
template: `
|
||||
<Theme :props="{ checkboxGroup: { color: 'success' } }">
|
||||
<CheckboxGroup :items="[{ label: 'A', value: 'a' }]" :model-value="['a']" />
|
||||
</Theme>
|
||||
`
|
||||
})
|
||||
|
||||
expect(wrapper.html()).toContain('outline-success/25')
|
||||
expect(wrapper.html()).not.toContain('outline-primary/25')
|
||||
})
|
||||
|
||||
// The group resolves `color` once and hands it to every child, so a FormField
|
||||
// validation error has to beat `<UTheme :props>` on the items too, not just on
|
||||
// the group root.
|
||||
test('FormField error overrides :props color on a checkbox group', async () => {
|
||||
const wrapper = await mountSuspended({
|
||||
components: { Theme, FormField, CheckboxGroup },
|
||||
template: `
|
||||
<Theme :props="{ checkboxGroup: { color: 'success' } }">
|
||||
<FormField label="Pick one" error="This field is required">
|
||||
<CheckboxGroup :items="[{ label: 'A', value: 'a' }]" :model-value="['a']" />
|
||||
</FormField>
|
||||
</Theme>
|
||||
`
|
||||
})
|
||||
|
||||
expect(wrapper.html()).toContain('outline-error/25')
|
||||
expect(wrapper.html()).not.toContain('outline-success/25')
|
||||
})
|
||||
|
||||
// `useFieldGroup` shares the same closer-context-wins fallback as
|
||||
// `useFormField` (`_props.size ?? fieldGroup.size`). A child Button inside
|
||||
// `<UFieldGroup>` must take its size from the wrapping group, not from
|
||||
|
||||
Reference in New Issue
Block a user