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,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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as PinInputInput } from './PinInputInput.vue';
|
||||
export { default as PinInputRoot } from './PinInputRoot.vue';
|
||||
export * from './context';
|
||||
Reference in New Issue
Block a user