openpencil/packages/pen-core/src/element-builders/otp-input.ts

108 lines
3.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { ElementTree } from './helpers.js';
export interface OtpInputParams {
/** Number of code digits (slots). Clamped 4..8. Default 6. */
length?: number;
/**
* Optional digits to render inside filled slots. When provided,
* `digits[i]` fills slot `i`; omitted / shorter-than-length
* arrays leave the remaining slots empty. Pass an empty array
* (or omit) to render the blank "awaiting input" state.
*/
digits?: string[];
/** Index of the currently-focused slot (0-based). Default 0. */
focused_index?: number;
/** Slot size in px (square). Default 48. Clamped 32..80. */
slot_size?: number;
/** Gap between slots. Default 12. Clamped 0..24. */
gap?: number;
/** Primary accent color for the focused-slot border. Default #2563EB. */
accent_color?: string;
}
/**
* OTP / PIN code input — horizontal row of N square slots, each
* holding one digit. Common in 2FA verification and PIN unlock
* screens. Renders the caller-chosen state:
*
* - blank: all N slots empty (awaiting input)
* - partial: first M slots filled, the focused one outlined, the
* rest empty
* - full: all N slots filled (final submittable state)
*
* Structure:
* frame(horizontal, gap, role='otp-input', fit_content)
* └ frame(slot_size × slot_size, cornerRadius=8, border,
* role='otp-slot' | 'otp-slot-focused' | 'otp-slot-filled',
* layout=horizontal, center/center)
* └ text(digit, 20/600) [only when filled]
*
* Focused slot gets the accent-color border; filled slots get a
* solid slate-700 border; empty unfocused slots get a slate-300
* border. Caller composes this with a labeled form wrapper if
* needed (v0 purposefully does NOT embed the "Enter code" label
* — callers vary widely).
*/
export function buildOtpInput(params: OtpInputParams): ElementTree {
const length = Math.max(4, Math.min(8, Math.floor(params.length ?? 6)));
const digits = params.digits ?? [];
const focusedIndex = Math.max(0, Math.min(length - 1, Math.floor(params.focused_index ?? 0)));
const slotSize = Math.max(32, Math.min(80, Math.floor(params.slot_size ?? 48)));
const gap = Math.max(0, Math.min(24, Math.floor(params.gap ?? 12)));
const accent = params.accent_color ?? '#2563EB';
const children: ElementTree[] = [];
for (let i = 0; i < length; i += 1) {
const digit = digits[i];
const isFilled = typeof digit === 'string' && digit.length > 0;
const isFocused = i === focusedIndex && !isFilled;
const borderColor = isFocused ? accent : isFilled ? '#334155' : '#CBD5E1';
const role = isFocused ? 'otp-slot-focused' : isFilled ? 'otp-slot-filled' : 'otp-slot';
const slotChildren: ElementTree[] = isFilled
? [
{
type: 'text',
name: 'Digit',
role: 'otp-digit',
content: digit,
fontSize: 20,
fontWeight: 600,
fill: [{ type: 'solid', color: '#0F172A' }],
},
]
: [];
children.push({
type: 'frame',
name: `Slot ${i + 1}`,
role,
width: slotSize,
height: slotSize,
cornerRadius: 8,
layout: 'horizontal',
alignItems: 'center',
justifyContent: 'center',
fill: [{ type: 'solid', color: '#FFFFFF' }],
stroke: {
thickness: isFocused ? 2 : 1,
fill: [{ type: 'solid', color: borderColor }],
},
children: slotChildren,
});
}
return {
type: 'frame',
name: 'OTP Input',
role: 'otp-input',
width: 'fit_content',
height: 'fit_content',
layout: 'horizontal',
alignItems: 'center',
gap,
children,
};
}