fix(components): resolve theme props consistently in form controls (#6834)

This commit is contained in:
Benjamin Canac
2026-08-13 15:17:47 +02:00
committed by GitHub
parent 3a568b5e58
commit 7c74269387
21 changed files with 592 additions and 127 deletions
+154 -2
View File
@@ -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'],
+13 -4
View File
@@ -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
}))
+15 -3
View File
@@ -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
}))
+24 -10
View File
@@ -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>
+19 -7
View File
@@ -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
})
+19 -7
View File
@@ -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({
+25 -11
View File
@@ -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"
+21 -9
View File
@@ -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"
+13 -7
View File
@@ -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] })"
+19 -7
View File
@@ -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
+19 -7
View File
@@ -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
})
+14 -5
View File
@@ -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
}))
+19 -6
View File
@@ -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
})
+13 -4
View File
@@ -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,
+19 -7
View File
@@ -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
+26 -12
View File
@@ -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
+10 -3
View File
@@ -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
}))
+13 -4
View File
@@ -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
+24 -7
View File
@@ -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
+22 -5
View File
@@ -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,
+91
View File
@@ -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