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:
@@ -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;
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user