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,133 @@
<script lang="ts">
const DIGIT_RE = /\d/;
const NON_DIGIT_G = /\D/g;
</script>
<script setup lang="ts">
import { computed, onBeforeUnmount, useTemplateRef, watch } from 'vue';
import { usePinInputContext } from './context';
interface Props {
index: number;
}
const props = defineProps<Props>();
const ctx = usePinInputContext();
const el = useTemplateRef<HTMLInputElement>('el');
watch(el, (curr, prev) => {
if (prev)
ctx.unregister(prev);
if (curr)
ctx.register(curr);
});
onBeforeUnmount(() => {
if (el.value)
ctx.unregister(el.value);
});
const displayed = computed(() => {
const ch = ctx.value.value[props.index] ?? '';
if (!ch)
return '';
return ctx.mask.value ? '•' : ch;
});
// `inputType` / `inputMode` were thin ternary wrappers — inline in template.
function onInput(e: Event): void {
const target = e.target as HTMLInputElement;
const raw = target.value;
// keep only the last typed character
let ch = raw.length > 0 ? raw[raw.length - 1]! : '';
if (ctx.type.value === 'number' && ch && !DIGIT_RE.test(ch))
ch = '';
ctx.setAt(props.index, ch);
// re-sync DOM input since we overwrite with displayed
target.value = ch ? (ctx.mask.value ? '•' : ch) : '';
if (ch && props.index < ctx.length.value - 1)
ctx.focusIndex(props.index + 1);
}
function onKeyDown(e: KeyboardEvent): void {
const i = props.index;
const n = ctx.length.value;
switch (e.key) {
case 'Backspace': {
const current = ctx.value.value[i] ?? '';
if (current) {
ctx.clearAt(i);
}
else if (i > 0) {
ctx.focusIndex(i - 1);
ctx.clearAt(i - 1);
}
e.preventDefault();
break;
}
case 'Delete': {
ctx.clearAt(i);
e.preventDefault();
break;
}
case 'ArrowLeft': {
if (i > 0)
ctx.focusIndex(i - 1);
e.preventDefault();
break;
}
case 'ArrowRight': {
if (i < n - 1)
ctx.focusIndex(i + 1);
e.preventDefault();
break;
}
case 'Home': {
ctx.focusIndex(0);
e.preventDefault();
break;
}
case 'End': {
ctx.focusIndex(n - 1);
e.preventDefault();
break;
}
}
}
function onPaste(e: ClipboardEvent): void {
const data = e.clipboardData?.getData('text') ?? '';
if (!data)
return;
e.preventDefault();
const chars = ctx.type.value === 'number'
? data.replace(NON_DIGIT_G, '').split('')
: data.split('');
let idx = props.index;
for (const ch of chars) {
if (idx >= ctx.length.value)
break;
ctx.setAt(idx, ch);
idx++;
}
ctx.focusIndex(Math.min(idx, ctx.length.value - 1));
}
</script>
<template>
<input
ref="el"
:type="ctx.mask.value ? 'password' : 'text'"
:inputmode="ctx.type.value === 'number' ? 'numeric' : 'text'"
:value="displayed"
:placeholder="ctx.placeholder.value"
:disabled="ctx.disabled.value"
:autocomplete="ctx.otp.value ? 'one-time-code' : 'off'"
:data-index="props.index"
maxlength="1"
@input="onInput"
@keydown="onKeyDown"
@paste="onPaste"
>
</template>
@@ -0,0 +1,148 @@
<script setup lang="ts">
import type { PrimitiveProps } from '../primitive';
import { Primitive } from '../primitive';
import { computed, ref, toRef, watch } from 'vue';
import { providePinInputContext } from './context';
import { useForwardExpose } from '@robonen/vue';
interface Props extends PrimitiveProps {
modelValue?: string[];
defaultValue?: string[];
length?: number;
mask?: boolean;
otp?: boolean;
type?: 'text' | 'number';
disabled?: boolean;
placeholder?: string;
}
const {
modelValue,
defaultValue,
length = 4,
mask = false,
otp = false,
type = 'text',
disabled = false,
placeholder = '',
as = 'div',
} = defineProps<Props>();
const { forwardRef } = useForwardExpose();
const emit = defineEmits<{
(e: 'update:modelValue', v: string[]): void;
(e: 'complete', v: string): void;
}>();
const lengthRef = computed(() => Math.max(1, length | 0));
function normalize(v: readonly string[] | undefined): string[] {
const out = Array.from<string>({ length: lengthRef.value }, () => '');
if (!v)
return out;
for (let i = 0; i < Math.min(v.length, lengthRef.value); i++)
out[i] = (v[i] ?? '').slice(0, 1);
return out;
}
const value = ref<string[]>(normalize(modelValue ?? defaultValue));
watch(() => modelValue, (v) => {
if (v === undefined)
return;
const nv = normalize(v);
if (nv.join('\u0000') !== value.value.join('\u0000'))
value.value = nv;
});
watch(lengthRef, (n) => {
if (value.value.length === n)
return;
const next = Array.from<string>({ length: n }, () => '');
for (let i = 0; i < Math.min(value.value.length, n); i++)
next[i] = value.value[i]!;
value.value = next;
});
const inputs = ref<HTMLInputElement[]>([]);
function register(el: HTMLInputElement): void {
if (!inputs.value.includes(el))
inputs.value.push(el);
}
function unregister(el: HTMLInputElement): void {
const i = inputs.value.indexOf(el);
if (i !== -1)
inputs.value.splice(i, 1);
}
function emitValue(v: string[]): void {
emit('update:modelValue', v);
if (v.every(ch => ch.length === 1))
emit('complete', v.join(''));
}
function setAt(index: number, char: string): void {
if (disabled)
return;
const ch = char.slice(0, 1);
if (ch && type === 'number' && !/\d/.test(ch))
return;
const next = value.value.slice();
next[index] = ch;
value.value = next;
emitValue(next);
}
function clearAt(index: number): void {
if (disabled)
return;
const next = value.value.slice();
next[index] = '';
value.value = next;
emitValue(next);
}
function focusIndex(index: number): void {
const el = inputs.value[index];
if (el) {
el.focus();
try {
el.select();
}
catch {
/* noop */
}
}
}
providePinInputContext({
value,
length: lengthRef,
mask: toRef(() => mask),
otp: toRef(() => otp),
type: toRef(() => type),
disabled: toRef(() => disabled),
placeholder: toRef(() => placeholder),
inputs,
register,
unregister,
setAt,
clearAt,
focusIndex,
});
defineSlots<{
default: (props: { value: string[]; isComplete: boolean }) => unknown;
}>();
const isComplete = computed(() => value.value.every(ch => ch.length === 1));
</script>
<template>
<Primitive :ref="forwardRef" :as="as" role="group" :data-disabled="disabled ? '' : undefined">
<slot :value="value" :is-complete="isComplete" />
</Primitive>
</template>
@@ -0,0 +1,125 @@
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 { PinInputInput, PinInputRoot } from '../index';
function mountPin(props: Record<string, unknown> = {}) {
const model = ref<string[] | undefined>(undefined);
const completed = ref<string | null>(null);
const Harness = defineComponent({
setup: () => () => h(PinInputRoot, {
modelValue: model.value,
length: 4,
'onUpdate:modelValue': (v: string[]) => { model.value = v; },
onComplete: (v: string) => { completed.value = v; },
...props,
}, {
default: () => [0, 1, 2, 3].map(i => h(PinInputInput as Component, { key: i, index: i })),
}),
});
const wrapper = mount(Harness, { attachTo: document.body });
return { wrapper, model, completed };
}
function inputs(): HTMLInputElement[] {
return Array.from(document.querySelectorAll<HTMLInputElement>('input[data-index]'));
}
function type(el: HTMLInputElement, ch: string): void {
el.value = ch;
el.dispatchEvent(new Event('input', { bubbles: true }));
}
function key(el: Element, k: string): void {
el.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }));
}
describe('PinInput', () => {
it('renders N inputs based on length', () => {
const { wrapper } = mountPin();
expect(inputs().length).toBe(4);
wrapper.unmount();
});
it('typing auto-advances focus and fires complete', async () => {
const { wrapper, model, completed } = mountPin();
await nextTick();
const [a, b, c, d] = inputs();
type(a!, '1');
await nextTick();
expect(document.activeElement).toBe(b);
type(b!, '2');
type(c!, '3');
type(d!, '4');
await nextTick();
expect(model.value).toEqual(['1', '2', '3', '4']);
expect(completed.value).toBe('1234');
wrapper.unmount();
});
it('Backspace on empty moves to previous and clears', async () => {
const { wrapper, model } = mountPin();
await nextTick();
const [a, b] = inputs();
type(a!, '1');
await nextTick();
b!.focus();
key(b!, 'Backspace');
await nextTick();
expect(document.activeElement).toBe(a);
expect(model.value![0]).toBe('');
wrapper.unmount();
});
it('ArrowLeft/ArrowRight navigate', async () => {
const { wrapper } = mountPin();
await nextTick();
const [a, b, c] = inputs();
b!.focus();
key(b!, 'ArrowLeft');
expect(document.activeElement).toBe(a);
key(a!, 'ArrowRight');
expect(document.activeElement).toBe(b);
key(b!, 'ArrowRight');
expect(document.activeElement).toBe(c);
wrapper.unmount();
});
it('type=number rejects non-digit input', async () => {
const { wrapper, model } = mountPin({ type: 'number' });
await nextTick();
const [a] = inputs();
type(a!, 'x');
await nextTick();
expect(model.value?.[0] ?? '').toBe('');
type(a!, '7');
await nextTick();
expect(model.value![0]).toBe('7');
wrapper.unmount();
});
it('paste fills across inputs', async () => {
const { wrapper, model, completed } = mountPin();
await nextTick();
const [a] = inputs();
a!.focus();
const event = new Event('paste', { bubbles: true, cancelable: true }) as unknown as ClipboardEvent;
Object.defineProperty(event, 'clipboardData', {
value: { getData: (_type: string) => '9876' },
});
a!.dispatchEvent(event);
await nextTick();
expect(model.value).toEqual(['9', '8', '7', '6']);
expect(completed.value).toBe('9876');
wrapper.unmount();
});
it('mask renders password type for each input', async () => {
const { wrapper } = mountPin({ mask: true });
await nextTick();
for (const el of inputs())
expect(el.getAttribute('type')).toBe('password');
wrapper.unmount();
});
});
+23
View File
@@ -0,0 +1,23 @@
import type { ComputedRef, Ref } from 'vue';
import { useContextFactory } from '@robonen/vue';
export interface PinInputContext {
value: Ref<string[]>;
length: ComputedRef<number>;
mask: ComputedRef<boolean>;
otp: ComputedRef<boolean>;
type: ComputedRef<'text' | 'number'>;
disabled: ComputedRef<boolean>;
placeholder: ComputedRef<string>;
inputs: Ref<HTMLInputElement[]>;
register: (el: HTMLInputElement) => void;
unregister: (el: HTMLInputElement) => void;
setAt: (index: number, char: string) => void;
clearAt: (index: number) => void;
focusIndex: (index: number) => void;
}
const ctx = useContextFactory<PinInputContext>('PinInputContext');
export const providePinInputContext = ctx.provide;
export const usePinInputContext = ctx.inject;
+3
View File
@@ -0,0 +1,3 @@
export { default as PinInputInput } from './PinInputInput.vue';
export { default as PinInputRoot } from './PinInputRoot.vue';
export * from './context';