diff --git a/eslint.config.mjs b/eslint.config.mjs index 2d6b1a8c2..88e97365a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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 `` / `` / `` supplied, so a bare + * `size.value` silently drops `` 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.` 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 `.value ?? props.X` when reading a `useFormField` / `useFieldGroup` ref' + }, + schema: [], + messages: { + unresolved: 'Reading `{{ local }}` without a `?? {{ propsVar }}.{{ key }}` fallback drops `` and `app.config` defaults. Chain it, or read a computed that already does.', + inTemplate: 'Binding the raw `{{ local }}` in the template drops `` 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.` 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 `.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.` 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'], diff --git a/src/runtime/components/Checkbox.vue b/src/runtime/components/Checkbox.vue index f14d3f2f7..ba385b8be 100644 --- a/src/runtime/components/Checkbox.vue +++ b/src/runtime/components/Checkbox.vue @@ -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>(_props) +const { id: _id, emitFormChange, emitFormInput, size: formFieldSize, color: formFieldColor, highlight: formFieldHighlight, name, disabled: formFieldDisabled, ariaAttrs } = useFormField>(_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 })) diff --git a/src/runtime/components/CheckboxGroup.vue b/src/runtime/components/CheckboxGroup.vue index 07c5c523f..0415be82d 100644 --- a/src/runtime/components/CheckboxGroup.vue +++ b/src/runtime/components/CheckboxGroup.vue @@ -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>(_props, { bind: false }) +const { emitFormChange, emitFormInput, color: formFieldColor, highlight: formFieldHighlight, name, size: formFieldSize, id: _id, disabled: formFieldDisabled, ariaAttrs } = useFormField>(_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 })) diff --git a/src/runtime/components/FileUpload.vue b/src/runtime/components/FileUpload.vue index 8cf392652..df84303dd 100644 --- a/src/runtime/components/FileUpload.vue +++ b/src/runtime/components/FileUpload.vue @@ -188,8 +188,16 @@ const { isDragging, open, inputRef, dropzoneRef } = useFileUpload({ dropzone: props.dropzone, onUpdate }) -const { emitFormInput, emitFormChange, id, name, color, highlight, disabled, ariaAttrs } = useFormField(_props) +const { emitFormInput, emitFormChange, id, name, size: formFieldSize, color: formFieldColor, highlight: formFieldHighlight, disabled: formFieldDisabled, ariaAttrs } = useFormField(_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({ diff --git a/src/runtime/components/Input.vue b/src/runtime/components/Input.vue index 0a03e26e0..dfa0c1d1b 100644 --- a/src/runtime/components/Input.vue +++ b/src/runtime/components/Input.vue @@ -66,7 +66,7 @@ export interface InputSlots {