fix(primitives): eslint/tsconfig migration, asChild refactor, type fixes

- Migrate to eslint flat config + composite tsconfig.
- Complete the asChild→as="template" refactor (remove asChild prop + :as-child
  bindings across components, matching Primitive's slot model).
- Fix test type errors and source type-safety (useGraceArea hull/point math,
  FocusScope/util ref typing).

Note: ~53 vue-tsc errors remain (HTML attr/event passthrough typing on
transparent wrapper components + a couple of duplicate-export naming
collisions) — not gated by CI (build/lint/test green); pending a
component-attribute-typing design decision.
This commit is contained in:
2026-06-07 16:29:56 +07:00
parent c7644ade69
commit 626fbc70d8
408 changed files with 27367 additions and 154 deletions
@@ -0,0 +1,33 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface NumberFieldDecrementProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
import { useForwardExpose } from '@robonen/vue';
import { useNumberFieldContext } from './context';
const { as = 'button' } = defineProps<NumberFieldDecrementProps>();
const { forwardRef } = useForwardExpose();
const ctx = useNumberFieldContext();
function onClick() {
ctx.decrement();
}
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:type="as === 'button' ? 'button' : undefined"
tabindex="-1"
aria-hidden="true"
:disabled="ctx.disabled.value || ctx.readonly.value || undefined"
:data-disabled="(ctx.disabled.value || ctx.readonly.value) ? '' : undefined"
@click="onClick"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,33 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface NumberFieldIncrementProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
import { useForwardExpose } from '@robonen/vue';
import { useNumberFieldContext } from './context';
const { as = 'button' } = defineProps<NumberFieldIncrementProps>();
const { forwardRef } = useForwardExpose();
const ctx = useNumberFieldContext();
function onClick() {
ctx.increment();
}
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:type="as === 'button' ? 'button' : undefined"
tabindex="-1"
aria-hidden="true"
:disabled="ctx.disabled.value || ctx.readonly.value || undefined"
:data-disabled="(ctx.disabled.value || ctx.readonly.value) ? '' : undefined"
@click="onClick"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,87 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface NumberFieldInputProps extends PrimitiveProps {
placeholder?: string;
name?: string;
required?: boolean;
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { useNumberFieldContext } from './context';
const props = defineProps<NumberFieldInputProps>();
const ctx = useNumberFieldContext();
const displayValue = computed(() => ctx.value.value === null ? '' : String(ctx.value.value));
function parse(raw: string): number | null {
const trimmed = raw.trim();
if (trimmed === '') return null;
const n = Number(trimmed);
return Number.isFinite(n) ? n : null;
}
function onInput(event: Event): void {
const target = event.target as HTMLInputElement;
ctx.setValue(parse(target.value));
}
function onKeyDown(event: KeyboardEvent): void {
if (ctx.disabled.value || ctx.readonly.value) return;
switch (event.key) {
case 'ArrowUp':
event.preventDefault();
ctx.increment();
break;
case 'ArrowDown':
event.preventDefault();
ctx.decrement();
break;
case 'PageUp':
event.preventDefault();
ctx.increment(ctx.step.value * 10);
break;
case 'PageDown':
event.preventDefault();
ctx.decrement(ctx.step.value * 10);
break;
case 'Home':
if (ctx.min.value !== undefined) {
event.preventDefault();
ctx.setValue(ctx.min.value);
}
break;
case 'End':
if (ctx.max.value !== undefined) {
event.preventDefault();
ctx.setValue(ctx.max.value);
}
break;
}
}
</script>
<template>
<input
:id="ctx.inputId"
role="spinbutton"
inputmode="decimal"
autocomplete="off"
:aria-valuemin="ctx.min.value"
:aria-valuemax="ctx.max.value"
:aria-valuenow="ctx.value.value ?? undefined"
:aria-disabled="ctx.disabled.value || undefined"
:aria-readonly="ctx.readonly.value || undefined"
:disabled="ctx.disabled.value || undefined"
:readonly="ctx.readonly.value || undefined"
:placeholder="props.placeholder"
:name="props.name"
:required="props.required || undefined"
:value="displayValue"
@input="onInput"
@keydown="onKeyDown"
>
</template>
@@ -0,0 +1,89 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface NumberFieldRootProps extends PrimitiveProps {
modelValue?: number | null;
defaultValue?: number | null;
min?: number;
max?: number;
step?: number;
disabled?: boolean;
readonly?: boolean;
}
export interface NumberFieldRootEmits {
'update:modelValue': [value: number | null];
valueChange: [value: number | null];
}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
import { ref, toRef, watch } from 'vue';
import { provideNumberFieldContext } from './context';
import { useForwardExpose } from '@robonen/vue';
import { clamp } from '@robonen/stdlib';
import { useId } from '../config-provider';
const { step = 1, disabled = false, readonly = false, min, max, modelValue, defaultValue, as = 'div' } = defineProps<NumberFieldRootProps>();
const { forwardRef } = useForwardExpose();
const emit = defineEmits<NumberFieldRootEmits>();
const localValue = ref<number | null>(
modelValue !== undefined ? modelValue : (defaultValue ?? null),
);
watch(() => modelValue, (v) => {
if (v === undefined) return;
if (v === localValue.value) return;
localValue.value = v;
});
function setValue(v: number | null): void {
if (disabled || readonly) return;
const next = v === null ? null : clamp(v, min ?? -Infinity, max ?? Infinity);
if (next === localValue.value) return;
localValue.value = next;
emit('update:modelValue', next);
emit('valueChange', next);
}
function increment(delta = step): void {
const base = localValue.value ?? min ?? 0;
setValue(base + delta);
}
function decrement(delta = step): void {
const base = localValue.value ?? min ?? 0;
setValue(base - delta);
}
const inputId = useId(undefined, 'number-field-input').value;
provideNumberFieldContext({
value: localValue,
// Identity passthroughs via `toRef` — reactive without `computed`'s effect/cache.
min: toRef(() => min),
max: toRef(() => max),
step: toRef(() => step),
disabled: toRef(() => disabled),
readonly: toRef(() => readonly),
increment,
decrement,
setValue,
inputId,
});
defineExpose({ value: localValue, increment, decrement, setValue });
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:data-disabled="disabled ? '' : undefined"
:data-readonly="readonly ? '' : undefined"
>
<slot :value="localValue" :increment="increment" :decrement="decrement" />
</Primitive>
</template>
@@ -0,0 +1,130 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import type { Component } from 'vue';
import { defineComponent, h, nextTick, ref } from 'vue';
import {
NumberFieldDecrement,
NumberFieldIncrement,
NumberFieldInput,
NumberFieldRoot,
} from '../index';
function mountField(props: Record<string, unknown> = {}) {
const model = ref<number | null | undefined>(undefined);
const Harness = defineComponent({
setup: () => () => h(NumberFieldRoot, {
modelValue: model.value,
'onUpdate:modelValue': (v: number | null) => { model.value = v; },
...props,
}, {
default: () => [
h(NumberFieldInput as Component, { id: 'inp' }),
h(NumberFieldIncrement, { id: 'inc' }, { default: () => '+' }),
h(NumberFieldDecrement, { id: 'dec' }, { default: () => '' }),
],
}),
});
return { wrapper: mount(Harness, { attachTo: document.body }), model };
}
function press(el: Element, key: string): void {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
}
describe('NumberField', () => {
it('input has role=spinbutton with ARIA attrs', () => {
const { wrapper } = mountField({ min: 0, max: 10, defaultValue: 5 });
const input = document.querySelector<HTMLInputElement>('#inp')!;
expect(input.getAttribute('role')).toBe('spinbutton');
expect(input.getAttribute('aria-valuemin')).toBe('0');
expect(input.getAttribute('aria-valuemax')).toBe('10');
expect(input.getAttribute('aria-valuenow')).toBe('5');
expect(input.value).toBe('5');
wrapper.unmount();
});
it('increment/decrement buttons change value', async () => {
const { wrapper, model } = mountField({ defaultValue: 0, step: 2 });
await nextTick();
(document.querySelector<HTMLButtonElement>('#inc')!).click();
await nextTick();
expect(model.value).toBe(2);
(document.querySelector<HTMLButtonElement>('#dec')!).click();
await nextTick();
expect(model.value).toBe(0);
wrapper.unmount();
});
it('ArrowUp / ArrowDown step, clamped by min/max', async () => {
const { wrapper, model } = mountField({ min: 0, max: 3, defaultValue: 2 });
await nextTick();
const input = document.querySelector<HTMLInputElement>('#inp')!;
press(input, 'ArrowUp');
await nextTick();
expect(model.value).toBe(3);
press(input, 'ArrowUp');
await nextTick();
expect(model.value).toBe(3);
press(input, 'ArrowDown');
press(input, 'ArrowDown');
press(input, 'ArrowDown');
press(input, 'ArrowDown');
await nextTick();
expect(model.value).toBe(0);
wrapper.unmount();
});
it('PageUp/PageDown step by 10×', async () => {
const { wrapper, model } = mountField({ defaultValue: 10, step: 1 });
await nextTick();
const input = document.querySelector<HTMLInputElement>('#inp')!;
press(input, 'PageUp');
await nextTick();
expect(model.value).toBe(20);
press(input, 'PageDown');
await nextTick();
expect(model.value).toBe(10);
wrapper.unmount();
});
it('Home/End jump to min/max when defined', async () => {
const { wrapper, model } = mountField({ min: 0, max: 100, defaultValue: 50 });
await nextTick();
const input = document.querySelector<HTMLInputElement>('#inp')!;
press(input, 'End');
await nextTick();
expect(model.value).toBe(100);
press(input, 'Home');
await nextTick();
expect(model.value).toBe(0);
wrapper.unmount();
});
it('typing updates value; invalid = null', async () => {
const { wrapper, model } = mountField({ defaultValue: 0 });
await nextTick();
const input = document.querySelector<HTMLInputElement>('#inp')!;
input.value = '42';
input.dispatchEvent(new Event('input', { bubbles: true }));
await nextTick();
expect(model.value).toBe(42);
input.value = '';
input.dispatchEvent(new Event('input', { bubbles: true }));
await nextTick();
expect(model.value).toBeNull();
input.value = 'abc';
input.dispatchEvent(new Event('input', { bubbles: true }));
await nextTick();
expect(model.value).toBeNull();
wrapper.unmount();
});
it('disabled blocks changes', async () => {
const { wrapper, model } = mountField({ disabled: true, defaultValue: 5 });
await nextTick();
(document.querySelector<HTMLButtonElement>('#inc')!).click();
await nextTick();
expect(model.value).toBeUndefined();
wrapper.unmount();
});
});
@@ -0,0 +1,20 @@
import type { Ref } from 'vue';
import { useContextFactory } from '@robonen/vue';
export interface NumberFieldContext {
value: Ref<number | null>;
min: Ref<number | undefined>;
max: Ref<number | undefined>;
step: Ref<number>;
disabled: Ref<boolean>;
readonly: Ref<boolean>;
increment: (delta?: number) => void;
decrement: (delta?: number) => void;
setValue: (v: number | null) => void;
inputId: string;
}
const ctx = useContextFactory<NumberFieldContext>('NumberFieldContext');
export const provideNumberFieldContext = ctx.provide;
export const useNumberFieldContext = ctx.inject;
+8
View File
@@ -0,0 +1,8 @@
export { default as NumberFieldDecrement } from './NumberFieldDecrement.vue';
export { default as NumberFieldIncrement } from './NumberFieldIncrement.vue';
export { default as NumberFieldInput } from './NumberFieldInput.vue';
export { default as NumberFieldRoot } from './NumberFieldRoot.vue';
export type { NumberFieldDecrementProps } from './NumberFieldDecrement.vue';
export type { NumberFieldIncrementProps } from './NumberFieldIncrement.vue';
export type { NumberFieldInputProps } from './NumberFieldInput.vue';
export type { NumberFieldRootEmits, NumberFieldRootProps } from './NumberFieldRoot.vue';