From 94134e23666e7d57b0ba761f921e5b0654c1db78 Mon Sep 17 00:00:00 2001 From: HBS999 Date: Wed, 6 Nov 2024 14:13:34 +0300 Subject: [PATCH] feat: PinInput component --- apps/docs/src/examples/pin-input.module.css | 89 ++++ apps/docs/src/examples/pin-input.tsx | 185 ++++++++ apps/docs/src/routes/docs/core.tsx | 5 + .../routes/docs/core/components/pin-input.mdx | 435 ++++++++++++++++++ packages/core/src/index.tsx | 1 + packages/core/src/pin-input/index.tsx | 83 ++++ .../core/src/pin-input/pin-input-context.tsx | 43 ++ .../core/src/pin-input/pin-input-control.tsx | 47 ++ .../src/pin-input/pin-input-hidden-input.tsx | 28 ++ .../core/src/pin-input/pin-input-input.tsx | 323 +++++++++++++ .../core/src/pin-input/pin-input-label.tsx | 27 ++ .../core/src/pin-input/pin-input-root.tsx | 290 ++++++++++++ packages/core/src/pin-input/pin-input.intl.ts | 6 + 13 files changed, 1562 insertions(+) create mode 100644 apps/docs/src/examples/pin-input.module.css create mode 100644 apps/docs/src/examples/pin-input.tsx create mode 100644 apps/docs/src/routes/docs/core/components/pin-input.mdx create mode 100644 packages/core/src/pin-input/index.tsx create mode 100644 packages/core/src/pin-input/pin-input-context.tsx create mode 100644 packages/core/src/pin-input/pin-input-control.tsx create mode 100644 packages/core/src/pin-input/pin-input-hidden-input.tsx create mode 100644 packages/core/src/pin-input/pin-input-input.tsx create mode 100644 packages/core/src/pin-input/pin-input-label.tsx create mode 100644 packages/core/src/pin-input/pin-input-root.tsx create mode 100644 packages/core/src/pin-input/pin-input.intl.ts diff --git a/apps/docs/src/examples/pin-input.module.css b/apps/docs/src/examples/pin-input.module.css new file mode 100644 index 00000000..952ff0b2 --- /dev/null +++ b/apps/docs/src/examples/pin-input.module.css @@ -0,0 +1,89 @@ +.pin-input { + display: flex; + flex-direction: column; + gap: 8px; +} + +.pin-input__label { + color: hsl(240 6% 10%); + font-size: 14px; + font-weight: 500; + user-select: none; +} + +.pin-input__description { + color: hsl(240 5% 26%); + font-size: 12px; + user-select: none; +} + +.pin-input__error-message { + color: hsl(0 72% 51%); + font-size: 12px; + user-select: none; +} + +.pin-input__control { + display: flex; + gap: 8px; +} + +.pin-input__input { + width: 40px; + height: 40px; + border-radius: 6px; + font-size: 16px; + outline: none; + background-color: white; + text-align: center; + border: 1px solid hsl(240 6% 90%); + color: hsl(240 4% 16%); + transition: + border-color 250ms, + color 250ms; +} + +.pin-input__input:hover { + border-color: hsl(240 5% 65%); +} + +.pin-input__input:focus { + outline: 2px solid hsl(200 98% 39%); + outline-offset: 2px; +} + +.pin-input__input::placeholder { + color: hsl(240 4% 46%); +} + +.pin-input__input[data-invalid] { + border-color: hsl(0 72% 51%); + color: hsl(0 72% 51%); +} + +[data-kb-theme="dark"] .pin-input__label { + color: hsl(240 5% 84%); +} + +[data-kb-theme="dark"] .pin-input__input { + background-color: hsl(240 4% 16%); + border: 1px solid hsl(240 5% 34%); + color: hsl(0 100% 100% / 0.9); +} + +[data-kb-theme="dark"] .pin-input__input:hover { + border-color: hsl(240 4% 46%); +} + +[data-kb-theme="dark"] .pin-input__input::placeholder { + color: hsl(0 100% 100% / 0.5); +} + +[data-kb-theme="dark"] .pin-input__input[data-invalid] { + border-color: hsl(0 72% 51%); + color: hsl(0 72% 51%); +} + +[data-kb-theme="dark"] .pin-input__description { + color: hsl(240 5% 65%); +} diff --git a/apps/docs/src/examples/pin-input.tsx b/apps/docs/src/examples/pin-input.tsx new file mode 100644 index 00000000..b56d87ff --- /dev/null +++ b/apps/docs/src/examples/pin-input.tsx @@ -0,0 +1,185 @@ +import { Index, createSignal } from "solid-js"; +import { PinInput } from "../../../../packages/core/src/pin-input"; + +import style from "./pin-input.module.css"; + +export function BasicExample() { + return ( + + PIN + + + {(_) => } + + + + ); +} + +export function DefaultValueExample() { + return ( + + + + {(_) => } + + + + ); +} + +export function ControlledExample() { + const [value, setValue] = createSignal([]); + + return ( + <> + + + + {(_) => } + + + +

PIN code is: {value().join("")}

+ + ); +} + +export function PlaceholderExample() { + return ( + + + + {(_) => } + + + + ); +} + +export function BlurOnCompleteExample() { + return ( + + + + {(_) => } + + + + ); +} + +export function OTPExample() { + return ( + + + + {(_) => } + + + + ); +} + +export function MaskExample() { + return ( + + + + {(_) => } + + + + ); +} + +export function TypeExample() { + return ( + + + + {(_) => } + + + + ); +} + +export function DescriptionExample() { + return ( + + PIN + + + {(_) => } + + + + Enter your 3 digit PIN code. + + + ); +} + +export function ErrorMessageExample() { + const [value, setValue] = createSignal([]); + + return ( + element === "9") ? "valid" : "invalid" + } + > + PIN + + + {(_) => } + + + + Incorrect PIN. Please try again. + + + ); +} + +export function HTMLFormExample() { + let formRef: HTMLFormElement | undefined; + + const onSubmit = (e: SubmitEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const formData = new FormData(formRef); + + alert(JSON.stringify(Object.fromEntries(formData), null, 2)); + }; + + return ( +
+ + + + {(_) => } + + + + +
+ + +
+
+ ); +} diff --git a/apps/docs/src/routes/docs/core.tsx b/apps/docs/src/routes/docs/core.tsx index cd2a5c19..9323c414 100644 --- a/apps/docs/src/routes/docs/core.tsx +++ b/apps/docs/src/routes/docs/core.tsx @@ -152,6 +152,11 @@ const CORE_NAV_SECTIONS: NavSection[] = [ title: "Pagination", href: "/docs/core/components/pagination", }, + { + title: "Pin Input", + href: "/docs/core/components/pin-input", + status: "new", + }, { title: "Popover", href: "/docs/core/components/popover", diff --git a/apps/docs/src/routes/docs/core/components/pin-input.mdx b/apps/docs/src/routes/docs/core/components/pin-input.mdx new file mode 100644 index 00000000..a3e3808f --- /dev/null +++ b/apps/docs/src/routes/docs/core/components/pin-input.mdx @@ -0,0 +1,435 @@ +import { Preview, TabsSnippets, Kbd } from "../../../../components"; +import { + BasicExample, + DefaultValueExample, + ControlledExample, + PlaceholderExample, + BlurOnCompleteExample, + OTPExample, + MaskExample, + TypeExample, + DescriptionExample, + ErrorMessageExample, + HTMLFormExample, +} from "../../../../examples/pin-input"; + +# Pin Input + +The pin input is optimized for entering a sequence of digits or letters. The input fields allow one character at a time. When the digit or letter is entered, focus transfers to the next input in the sequence, until every input is filled. + +## Import + +```ts +import { PinInput } from "@kobalte/core/pin-input"; +// or +import { Root, Label, ... } from "@kobalte/core/pin-input"; +``` + +## Features + +- Automatically focuses the next field on typing and focuses the previous field on deletion. +- Supports numeric and alphanumeric values. +- Support for masking value (for sensitive data). +- Support for copy/paste to autofill all fields. +- Supports fast paste SMS-code. + +## Anatomy + +The pin input consists of: + +- **PinInput**: The root container for the pin input. +- **PinInput.Label**: The label that gives the user information on the pin input. +- **PinInput.Control**: The container for the input fields. +- **PinInput.Input**: The input fields of the pin input. +- **PinInput.HiddenInput**: The native html input that is visually hidden in the pin input. +- **PinInput.Description**: The description that gives the user more information on the pin input. +- **PinInput.ErrorMessage**: The error message that gives the user information about how to fix a validation error on the pin input. + +```tsx + + + + + + + + + +``` + +## Example + + + + + + + + index.tsx + style.css + + {/* */} + + ```tsx + import { PinInput } from "@kobalte/core/pin-input"; + import "./style.css"; + + function App() { + return ( + + + PIN + + + + {(_) => ( + + )} + + + + ); + } + ``` + + + +```css +.pin-input { + display: flex; + flex-direction: column; + gap: 8px; +} + +.pin-input\_\_label { +color: hsl(240 6% 10%); +font-size: 14px; +font-weight: 500; +user-select: none; +} + +.pin-input\_\_control { +display: flex; +gap: 8px; +} + +.pin-input\_\_input { +width: 40px; +height: 40px; +border-radius: 6px; +font-size: 16px; +outline: none; +background-color: white; +text-align: center; +border: 1px solid hsl(240 6% 90%); +color: hsl(240 4% 16%); +transition: +border-color 250ms, +color 250ms; +} + +.pin-input\_\_input:hover { +border-color: hsl(240 5% 65%); +} + +.pin-input\_\_input:focus { +outline: 2px solid hsl(200 98% 39%); +outline-offset: 2px; +} + +.pin-input\_\_input::placeholder { +color: hsl(240 4% 46%); +} + +```` + + + {/* */} + + +## Usage + +### Default value + +Set the initial value of the pin input using the `defaultValue` prop. + + + + + +```tsx {0} + + + + {(_) => ( + + )} + + + +```` + +### Controlled value + +The `value` prop can be used to make the pin input controlled. The `onChange` event is fired on input change. + + + + + +```tsx {3,7} +import { createSignal } from "solid-js"; + +function ControlledExample() { + const [value, setValue] = createSignal([]); + + return ( + <> + + + {_ => } + + +

PIN code is: {value().join("")}

+ + ); +} +``` + +### Placeholder + +Customize the default pin input placeholder for each input using the `placeholder` prop. + + + + + +```tsx{0} + + + + {(_) => } + + + +``` + +### Blur On Complete + +By default, the last input maintains focus when filled, and the `onComplete` callback is invoked. To blur the last input when the user completes the input, set the prop `blurOnComplete` to true. + + + + + +```tsx{0} + + + + {(_) => } + + + +``` + +### OTP + +Trigger smartphone OTP auto-suggestion by setting the `otp` prop to true. + + + + + +```tsx{0} + + + + {(_) => } + + + +``` + +### Mask + +When collecting private or sensitive information using the pin input, you might need to mask the value entered, similar to \\. Set the `mask` prop to true. + + + + + +```tsx{0} + + + + {(_) => } + + + +``` + +### Type + +By default, the pin input accepts only numeric values but you can choose between `numeric`, `alphanumeric` and `alphabetic` values. Use the `type` prop to change the input mode. + + + + + +```tsx{0} + + + + {(_) => } + + + +``` + +### Description + +The `PinInput.Description` component can be used to associate additional help text with the pin input. + + + + + +```tsx {7-9} + + PIN + + {_ => } + + Enter your 3 digit PIN code. + +``` + +### Error message + +The `PinInput.ErrorMessage` component can be used to help the user fix a validation error. It should be combined with the `validationState` prop to semantically mark the pin input as invalid for assistive technologies. + +By default, it will render only when the `validationState` prop is set to `invalid`, use the `forceMount` prop to always render the error message (ex: for usage with animation libraries). + + + + + +```tsx {9,19-21} +import { createSignal } from "solid-js"; + +function ErrorMessageExample() { + const [value, setValue] = createSignal([]); + + return ( + element === "9") ? "valid" : "invalid"} + > + PIN + + {_ => } + + Incorrect PIN. Please try again. + + ); +} +``` + +### HTML forms + +The `name` prop can be used for integration with HTML forms. + + + + + +```tsx {7,13} +function HTMLFormExample() { + const onSubmit = (e: SubmitEvent) => { + // handle form submission. + }; + + return ( +
+ + + {_ => } + + + +
+ + +
+
+ ); +} +``` + +## API Reference + +### PinInput + +`PinInput` is equivalent to the `Root` import from `@kobalte/core/pin-input`. + +| Prop | Description | +| :-------------- | :--------------------------------------------------------------------------------------------------------------------------------------------- | +| value | `string[]`
The value of the the pin input. | +| defaultValue | `string[]`
The initial value of the pin input when it is first rendered. Use when you do not need to control the state of the pin input. | +| onChange | `(value: string[]) => void`
Function called on input change. | +| onComplete | `(value: string[]) => void`
Function called when all inputs have valid values. | +| blurOnComplete | `boolean`
Whether to blur the input when the value is complete. | +| mask | `boolean`
If `true`, the input's value will be masked just like `type=password`. | +| otp | `boolean`
If `true`, the pin input component signals to its fields that they should use `autocomplete="one-time-code"`. | +| pattern | `string`
The regular expression that the user-entered input value is checked against. | +| placeholder | `string`
The placeholder text for the input. | +| selectOnFocus | `boolean`
Whether to select input value when input is focused. | +| type | `'numeric' \| 'alphabetic'` \| 'alphanumeric'`
The type of value the pin-input should allow. | +| name | `string`
The name of the pin input. Submitted with its owning form as part of a name/value pair. | +| validationState | `'valid' \| 'invalid'`
Whether the pin input should display its "valid" or "invalid" visual styling. | +| required | `boolean`
Whether the pin input is required. | +| disabled | `boolean`
Whether the pin input is disabled. | +| readOnly | `boolean`
Whether the pin input is read only. | +| translations | `PinInputIntlTranslations`
The localized strings of the component. | + +| Data attribute | Description | +| :------------- | :----------------------------------------------------------------------- | +| data-valid | Present when the pin input is valid according to the validation rules. | +| data-invalid | Present when the pin input is invalid according to the validation rules. | +| data-required | Present when the pin input is required. | +| data-disabled | Present when the pin input is disabled. | +| data-readonly | Present when the pin input is read only. | +| data-complete | Present when the pin input value is complete. | + +`PinInput.Label`, `PinInput.Input`, `PinInput.Description` and `PinInput.ErrorMesssage` share the same data-attributes. + +### PinInput.ErrorMessage + +| Prop | Description | +| :--------- | :-------------------------------------------------------------------------------------------------------------------------------------- | +| forceMount | `boolean`
Used to force mounting when more control is needed. Useful when controlling animation with SolidJS animation libraries. | + +## Rendered elements + +| Component | Default rendered element | +| :---------------------- | :----------------------- | +| `PinInput` | `div` | +| `PinInput.Label` | `span` | +| `PinInput.Control` | `div` | +| `PinInput.Input` | `input` | +| `PinInput.HiddenInput` | `input` | +| `PinInput.Description` | `div` | +| `PinInput.ErrorMessage` | `div` | + +## Accessibility + +### Keyboard Interactions + +| Key | Description | +| :--------------------- | :---------------------------------------------------------------------------- | +| ArrowLeft | Moves focus to the previous input. | +| ArrowRight | Moves focus to the next input. | +| Backspace | Deletes the value in the current input and moves focus to the previous input. | +| Delete | Deletes the value in the current input. | +| Control + V | Pastes the value into the input fields. | diff --git a/packages/core/src/index.tsx b/packages/core/src/index.tsx index f6a5ae0b..81a4e406 100644 --- a/packages/core/src/index.tsx +++ b/packages/core/src/index.tsx @@ -31,6 +31,7 @@ export * as Listbox from "./listbox"; export * as Menubar from "./menubar"; export * as NumberField from "./number-field"; export * as Pagination from "./pagination"; +export * as PinInput from "./pin-input"; export * as Popover from "./popover"; export * as Progress from "./progress"; export * as RadioGroup from "./radio-group"; diff --git a/packages/core/src/pin-input/index.tsx b/packages/core/src/pin-input/index.tsx new file mode 100644 index 00000000..9d2ad55d --- /dev/null +++ b/packages/core/src/pin-input/index.tsx @@ -0,0 +1,83 @@ +import { + FormControlDescription as Description, + FormControlErrorMessage as ErrorMessage, + type FormControlDescriptionCommonProps as PinInputDescriptionCommonProps, + type FormControlDescriptionOptions as PinInputDescriptionOptions, + type FormControlDescriptionProps as PinInputDescriptionProps, + type FormControlDescriptionRenderProps as PinInputDescriptionRenderProps, + type FormControlErrorMessageCommonProps as PinInputErrorMessageCommonProps, + type FormControlErrorMessageOptions as PinInputErrorMessageOptions, + type FormControlErrorMessageProps as PinInputErrorMessageProps, + type FormControlErrorMessageRenderProps as PinInputErrorMessageRenderProps, +} from "../form-control"; +import { + PinInputControl as Control, + type PinInputControlCommonProps, + type PinInputControlOptions, + type PinInputControlProps, + type PinInputControlRenderProps, +} from "./pin-input-control"; +import { + PinInputHiddenInput as HiddenInput, + type PinInputHiddenInputProps, +} from "./pin-input-hidden-input"; +import { + PinInputInput as Input, + type PinInputInputCommonProps, + type PinInputInputOptions, + type PinInputInputProps, + type PinInputInputRenderProps, +} from "./pin-input-input"; +import { + PinInputLabel as Label, + type PinInputLabelCommonProps, + type PinInputLabelOptions, + type PinInputLabelProps, + type PinInputLabelRenderProps, +} from "./pin-input-label"; +import { + type PinInputRootCommonProps, + type PinInputRootOptions, + type PinInputRootProps, + type PinInputRootRenderProps, + PinInputRoot as Root, +} from "./pin-input-root"; + +export type { + PinInputControlCommonProps, + PinInputControlOptions, + PinInputControlProps, + PinInputControlRenderProps, + PinInputDescriptionOptions, + PinInputDescriptionCommonProps, + PinInputDescriptionRenderProps, + PinInputDescriptionProps, + PinInputErrorMessageOptions, + PinInputErrorMessageCommonProps, + PinInputErrorMessageRenderProps, + PinInputErrorMessageProps, + PinInputHiddenInputProps, + PinInputInputOptions, + PinInputInputCommonProps, + PinInputInputRenderProps, + PinInputInputProps, + PinInputLabelOptions, + PinInputLabelCommonProps, + PinInputLabelRenderProps, + PinInputLabelProps, + PinInputRootOptions, + PinInputRootCommonProps, + PinInputRootRenderProps, + PinInputRootProps, +}; + +export { Description, ErrorMessage, Control, HiddenInput, Input, Label, Root }; + +export const PinInput = Object.assign(Root, { + Description, + ErrorMessage, + Control, + HiddenInput, + Input, + Label, +}); diff --git a/packages/core/src/pin-input/pin-input-context.tsx b/packages/core/src/pin-input/pin-input-context.tsx new file mode 100644 index 00000000..4c6e114c --- /dev/null +++ b/packages/core/src/pin-input/pin-input-context.tsx @@ -0,0 +1,43 @@ +import { + type Accessor, + type Setter, + createContext, + useContext, +} from "solid-js"; +import type { FormControlDataSet } from "../form-control"; +import type { CollectionItemWithRef } from "../primitives"; +import type { PinInputIntlTranslations } from "./pin-input.intl"; + +export interface PinInputDataSet extends FormControlDataSet { + "data-complete": string | undefined; +} + +export interface PinInputContextValue { + dataset: Accessor; + value: Accessor; + setValue: Setter; + mask: Accessor; + otp: Accessor; + pattern: Accessor; + placeholder: Accessor; + type: Accessor<"numeric" | "alphabetic" | "alphanumeric">; + focusedIndex: Accessor; + setFocusedIndex: Setter; + inputs: Accessor; + setInputs: Setter; + translations: Accessor; +} + +export const PinInputContext = createContext(); + +export function usePinInputContext() { + const context = useContext(PinInputContext); + + if (context === undefined) { + throw new Error( + "[kobalte]: `usePinInputContext` must be used within a `PinInput` component", + ); + } + + return context; +} diff --git a/packages/core/src/pin-input/pin-input-control.tsx b/packages/core/src/pin-input/pin-input-control.tsx new file mode 100644 index 00000000..cae35fa7 --- /dev/null +++ b/packages/core/src/pin-input/pin-input-control.tsx @@ -0,0 +1,47 @@ +import { mergeDefaultProps } from "@kobalte/utils"; +import type { ValidComponent } from "solid-js"; +import { useFormControlContext } from "../form-control"; +import { + type ElementOf, + Polymorphic, + type PolymorphicProps, +} from "../polymorphic"; + +export interface PinInputControlOptions {} + +export interface PinInputControlCommonProps< + T extends HTMLElement = HTMLElement, +> { + id: string; +} + +export interface PinInputControlRenderProps extends PinInputControlCommonProps { + role: "presentation"; +} + +export type PinInputControlProps< + T extends ValidComponent | HTMLElement = HTMLElement, +> = PinInputControlOptions & Partial>>; + +export function PinInputControl( + props: PolymorphicProps>, +) { + const formControlContext = useFormControlContext(); + + const defaultId = `${formControlContext.generateId("control")}`; + + const mergedProps = mergeDefaultProps( + { + id: defaultId, + }, + props as PinInputControlProps, + ); + + return ( + + as="div" + role="presentation" + {...mergedProps} + /> + ); +} diff --git a/packages/core/src/pin-input/pin-input-hidden-input.tsx b/packages/core/src/pin-input/pin-input-hidden-input.tsx new file mode 100644 index 00000000..c1642653 --- /dev/null +++ b/packages/core/src/pin-input/pin-input-hidden-input.tsx @@ -0,0 +1,28 @@ +import { visuallyHiddenStyles } from "@kobalte/utils"; +import type { ComponentProps } from "solid-js"; + +import { useFormControlContext } from "../form-control"; +import { usePinInputContext } from "./pin-input-context"; + +export interface PinInputHiddenInputProps extends ComponentProps<"input"> {} + +export function PinInputHiddenInput(props: PinInputHiddenInputProps) { + const formControlContext = useFormControlContext(); + const context = usePinInputContext(); + + return ( + // biome-ignore lint/a11y/noAriaHiddenOnFocusable: it is not focusable. + + ); +} diff --git a/packages/core/src/pin-input/pin-input-input.tsx b/packages/core/src/pin-input/pin-input-input.tsx new file mode 100644 index 00000000..20c08ceb --- /dev/null +++ b/packages/core/src/pin-input/pin-input-input.tsx @@ -0,0 +1,323 @@ +import { + EventKey, + callHandler, + mergeDefaultProps, + mergeRefs, +} from "@kobalte/utils"; +import { + type JSX, + type ValidComponent, + batch, + createUniqueId, + splitProps, +} from "solid-js"; + +import { + FORM_CONTROL_FIELD_PROP_NAMES, + createFormControlField, + useFormControlContext, +} from "../form-control"; +import { + type ElementOf, + Polymorphic, + type PolymorphicProps, +} from "../polymorphic"; +import type { CollectionItemWithRef } from "../primitives"; +import { createDomCollectionItem } from "../primitives/create-dom-collection"; +import { type PinInputDataSet, usePinInputContext } from "./pin-input-context"; + +export interface PinInputInputOptions {} + +export interface PinInputInputCommonProps< + T extends HTMLElement = HTMLInputElement, +> { + id: string; + ref: T | ((el: T) => void); + onBeforeInput: JSX.EventHandlerUnion; + onInput: JSX.EventHandlerUnion; + onKeyDown: JSX.EventHandlerUnion; + onFocus: JSX.EventHandlerUnion; + onBlur: JSX.EventHandlerUnion; + "aria-label": string | undefined; + "aria-labelledby": string | undefined; + "aria-describedby": string | undefined; +} + +export interface PinInputInputRenderProps + extends PinInputInputCommonProps, + PinInputDataSet { + type: string; + value: string; + inputMode: string; + placeholder: string; + autoCapitalize: "none"; + autoComplete: string; + required: boolean | undefined; + disabled: boolean | undefined; + readonly: boolean | undefined; + "aria-invalid": boolean | undefined; + "aria-required": boolean | undefined; + "aria-disabled": boolean | undefined; + "aria-readonly": boolean | undefined; +} + +export type PinInputInputProps< + T extends ValidComponent | HTMLElement = HTMLInputElement, +> = PinInputInputOptions & Partial>>; + +export function PinInputInput( + props: PolymorphicProps>, +) { + let ref: HTMLElement | undefined; + + const context = usePinInputContext(); + const formControlContext = useFormControlContext(); + + const mergedProps = mergeDefaultProps( + { + id: `${formControlContext.generateId("input")}-${createUniqueId()}`, + }, + props as PinInputInputProps, + ); + + const [local, formControlFieldProps, others] = splitProps( + mergedProps, + ["ref", "onBeforeInput", "onInput", "onKeyDown", "onFocus", "onBlur"], + FORM_CONTROL_FIELD_PROP_NAMES, + ); + + const { fieldProps } = createFormControlField(formControlFieldProps); + + createDomCollectionItem({ + getItem: () => ({ + ref: () => ref, + disabled: formControlContext.isDisabled()!, + key: fieldProps.id(), + textValue: "", + type: "item", + }), + }); + + const index = () => + ref ? context.inputs().findIndex((v) => v.ref() === ref) : -1; + + const inputType = () => (context.type() === "numeric" ? "tel" : "text"); + + const hasValue = () => context.value()[context.focusedIndex()] !== ""; + const valueLength = () => context.value().length; + const filledValueLength = () => + context.value().filter((v) => v?.trim() !== "").length; + const valueAsString = () => context.value().join(""); + const focusedValue = () => context.value()[context.focusedIndex()] || ""; + const isFinalValue = () => + filledValueLength() + 1 === valueLength() && + context.value().findIndex((v) => v.trim() === "") === + context.focusedIndex(); + + const setFocusedValue = (value: string) => { + const nextValue = getNextValue(focusedValue(), value); + context.setValue((prev) => { + return [...replaceIndex(prev, context.focusedIndex(), nextValue)]; + }); + }; + const setPrevFocusedIndex = () => + context.setFocusedIndex(Math.max(context.focusedIndex() - 1, 0)); + const setNextFocusedIndex = () => + context.setFocusedIndex( + Math.min(context.focusedIndex() + 1, valueLength() - 1), + ); + const clearFocusedValue = () => + context.setValue((prev) => [ + ...replaceIndex(prev, context.focusedIndex(), ""), + ]); + + const ariaLabel = () => + [ + fieldProps.ariaLabel(), + context.translations().inputLabel(index(), valueLength()), + ] + .filter(Boolean) + .join(", "); + + const isValidType = (value: string) => { + if (!context.type()) return true; + return !!REGEX[context.type()]?.test(value); + }; + + const isValidValue = (value: string) => { + if (!context.pattern()) return isValidType(value); + const regex = new RegExp(context.pattern()!, "g"); + return regex.test(value); + }; + + const onBeforeInput: JSX.EventHandlerUnion = ( + e, + ) => { + callHandler(e, local.onBeforeInput); + const target = e.currentTarget as HTMLInputElement; + try { + const value = getBeforeInputValue(e); + const isValid = isValidValue(value); + if (!isValid) { + e.preventDefault(); + } + if (value.length > 2) { + target.setSelectionRange(0, 1, "forward"); + } + } catch {} + }; + + const onInput: JSX.EventHandlerUnion = (e) => { + callHandler(e, local.onInput); + const target = e.currentTarget as HTMLInputElement; + + if (e.inputType === "insertFromPaste" || target.value.length > 2) { + const startIndex = Math.min(context.focusedIndex(), filledValueLength()); + const left = + startIndex > 0 + ? valueAsString().substring(0, context.focusedIndex()) + : ""; + const right = target.value.substring(0, valueLength() - startIndex); + const value = left + right; + batch(() => { + context.setValue(value.split("")); + context.setFocusedIndex( + Math.min(filledValueLength(), valueLength() - 1), + ); + }); + + target.value = target.value[0]; + e.preventDefault(); + return; + } + + if (e.inputType === "deleteContentBackward") { + if (hasValue()) { + clearFocusedValue(); + } else { + setPrevFocusedIndex(); + clearFocusedValue(); + } + return; + } + if (isFinalValue()) { + setFocusedValue(target.value); + } else { + batch(() => { + setFocusedValue(target.value); + setNextFocusedIndex(); + }); + } + target.value = target.value[0]; + }; + + const onKeyDown: JSX.EventHandlerUnion = (e) => { + if (formControlContext.isDisabled() || formControlContext.isReadOnly()) + return; + callHandler(e, local.onKeyDown); + + switch (e.key) { + case EventKey.ArrowLeft: + e.preventDefault(); + setPrevFocusedIndex(); + break; + case EventKey.ArrowRight: + e.preventDefault(); + setNextFocusedIndex(); + break; + case "Delete": + e.preventDefault(); + if (hasValue()) { + clearFocusedValue(); + } + break; + case "Backspace": + e.preventDefault(); + if (hasValue()) { + clearFocusedValue(); + } else { + setPrevFocusedIndex(); + clearFocusedValue(); + } + break; + } + }; + + const onFocus: JSX.EventHandlerUnion = (e) => { + callHandler(e, local.onFocus); + context.setFocusedIndex(index()); + }; + + const onBlur: JSX.EventHandlerUnion = (e) => { + callHandler(e, local.onBlur); + context.setFocusedIndex(-1); + }; + + return ( + + as="input" + id={fieldProps.id()} + ref={mergeRefs((el) => (ref = el), local.ref)} + type={context.mask() ? "password" : inputType()} + value={context.value()[index()] ?? ""} + inputMode={ + context.otp() || context.type() === "numeric" ? "numeric" : "text" + } + placeholder={ + context.focusedIndex() === index() ? "" : context.placeholder() + } + autoCapitalize="none" + autoComplete={context.otp() ? "one-time-code" : "off"} + required={formControlContext.isRequired()} + disabled={formControlContext.isDisabled()} + readonly={formControlContext.isReadOnly()} + aria-label={ariaLabel()} + aria-labelledby={fieldProps.ariaLabelledBy()} + aria-describedby={fieldProps.ariaDescribedBy()} + aria-invalid={ + formControlContext.validationState() === "invalid" || undefined + } + aria-required={formControlContext.isRequired()} + aria-disabled={formControlContext.isDisabled()} + aria-readonly={formControlContext.isReadOnly()} + onBeforeInput={onBeforeInput} + onInput={onInput} + onKeyDown={onKeyDown} + onFocus={onFocus} + onBlur={onBlur} + {...context.dataset()} + {...others} + /> + ); +} + +const REGEX = { + numeric: /^[0-9]+$/, + alphabetic: /^[A-Za-z]+$/, + alphanumeric: /^[a-zA-Z0-9]+$/i, +}; + +function replaceIndex(array: T[], index: number, value: T) { + if (array[index] === value) { + return array; + } + + return [...array.slice(0, index), value, ...array.slice(index + 1)]; +} + +function getBeforeInputValue(event: Pick) { + const { selectionStart, selectionEnd, value } = + event.currentTarget as HTMLInputElement; + return ( + value.slice(0, selectionStart!) + + (event as any).data + + value.slice(selectionEnd!) + ); +} + +function getNextValue(current: string, next: string) { + let nextValue = next; + if (current[0] === next[0]) nextValue = next[1]; + else if (current[0] === next[1]) nextValue = next[0]; + return nextValue.split("")[nextValue.length - 1]; +} diff --git a/packages/core/src/pin-input/pin-input-label.tsx b/packages/core/src/pin-input/pin-input-label.tsx new file mode 100644 index 00000000..68cb7ca0 --- /dev/null +++ b/packages/core/src/pin-input/pin-input-label.tsx @@ -0,0 +1,27 @@ +import type { Component, ValidComponent } from "solid-js"; + +import { FormControlLabel } from "../form-control"; +import type { ElementOf, PolymorphicProps } from "../polymorphic"; + +export interface PinInputLabelOptions {} + +export interface PinInputLabelCommonProps< + T extends HTMLElement = HTMLElement, +> {} + +export interface PinInputLabelRenderProps extends PinInputLabelCommonProps {} + +export type PinInputLabelProps< + T extends ValidComponent | HTMLElement = HTMLElement, +> = PinInputLabelOptions & Partial>>; + +export function PinInputLabel( + props: PolymorphicProps>, +) { + return ( + > + as="span" + {...(props as PinInputLabelProps)} + /> + ); +} diff --git a/packages/core/src/pin-input/pin-input-root.tsx b/packages/core/src/pin-input/pin-input-root.tsx new file mode 100644 index 00000000..d15da37c --- /dev/null +++ b/packages/core/src/pin-input/pin-input-root.tsx @@ -0,0 +1,290 @@ +import { + type ValidationState, + access, + mergeDefaultProps, + mergeRefs, +} from "@kobalte/utils"; +import { + type Accessor, + type ValidComponent, + createEffect, + createSignal, + createUniqueId, + on, + splitProps, +} from "solid-js"; + +import { + FORM_CONTROL_PROP_NAMES, + FormControlContext, + type FormControlDataSet, + createFormControl, +} from "../form-control"; +import { + type ElementOf, + Polymorphic, + type PolymorphicProps, +} from "../polymorphic"; +import { + type CollectionItemWithRef, + createControllableArraySignal, + createFormResetListener, +} from "../primitives"; +import { createDomCollection } from "../primitives/create-dom-collection"; +import { + PinInputContext, + type PinInputContextValue, + type PinInputDataSet, +} from "./pin-input-context"; +import { + PIN_INPUT_INTL_TRANSLATIONS, + type PinInputIntlTranslations, +} from "./pin-input.intl"; + +export interface PinInputRootOptions { + /** The value of the the pin input. */ + value?: string[]; + + /** + * The initial value of the pin input when it is first rendered. + * Use when you do not need to control the state of the pin input. + */ + defaultValue?: string[]; + + /** Function called on input change. */ + onChange?: (value: string[]) => void; + + /** Function called when all inputs have valid values. */ + onComplete?: (value: string[]) => void; + + /** Whether to blur the input when the value is complete. */ + blurOnComplete?: boolean; + + /** If `true`, the input's value will be masked just like `type=password`. */ + mask?: boolean; + + /** If `true`, the pin input component signals to its fields that they should use `autocomplete="one-time-code"`. */ + otp?: boolean; + + /** The regular expression that the user-entered input value is checked against. */ + pattern?: string; + + /** The placeholder text for the input. */ + placeholder?: string; + + /** Whether to select input value when input is focused. */ + selectOnFocus?: boolean; + + /** The type of value the pin-input should allow. */ + type?: "numeric" | "alphabetic" | "alphanumeric"; + + /** + * A unique identifier for the component. + * The id is used to generate id attributes for nested components. + * If no id prop is provided, a generated id will be used. + */ + id?: string; + + /** + * The name of the pin input. + * Submitted with its owning form as part of a name/value pair. + */ + name?: string; + + /** Whether the pin input should display its "valid" or "invalid" visual styling. */ + validationState?: ValidationState; + + /** Whether the pin input is required. */ + required?: boolean; + + /** Whether the pin input is disabled. */ + disabled?: boolean; + + /** Whether the pin input is read only. */ + readOnly?: boolean; + + /** The localized strings of the component. */ + translations?: PinInputIntlTranslations; +} + +export interface PinInputRootCommonProps { + id: string; + ref: T | ((el: T) => void); + "aria-labelledby": string | undefined; + "aria-describedby": string | undefined; + "aria-label"?: string; +} + +export interface PinInputRootRenderProps + extends PinInputRootCommonProps, + FormControlDataSet { + role: "group"; + "aria-invalid": boolean | undefined; + "aria-required": boolean | undefined; + "aria-disabled": boolean | undefined; + "aria-readonly": boolean | undefined; +} + +export type PinInputRootProps< + T extends ValidComponent | HTMLElement = HTMLElement, +> = PinInputRootOptions & Partial>>; + +export function PinInputRoot( + props: PolymorphicProps>, +) { + let ref: HTMLElement | undefined; + + const defaultId = `pininput-${createUniqueId()}`; + + const mergedProps = mergeDefaultProps( + { + id: defaultId, + otp: false, + placeholder: "○", + type: "numeric", + translations: PIN_INPUT_INTL_TRANSLATIONS, + }, + props as PinInputRootProps, + ); + + const [local, formControlProps, others] = splitProps( + mergedProps, + [ + "ref", + "value", + "defaultValue", + "onChange", + "onComplete", + "blurOnComplete", + "mask", + "otp", + "pattern", + "placeholder", + "selectOnFocus", + "type", + "aria-labelledby", + "aria-describedby", + "translations", + ], + FORM_CONTROL_PROP_NAMES, + ); + + const [inputs, setInputs] = createSignal([]); + const { DomCollectionProvider } = createDomCollection({ + items: inputs, + onItemsChange: setInputs, + }); + + const [value, setValue] = createControllableArraySignal({ + value: () => local.value, + defaultValue: () => local.defaultValue, + onChange: (value) => local.onChange?.(value), + }); + + const { formControlContext } = createFormControl(formControlProps); + + createFormResetListener( + () => ref, + () => setValue(local.defaultValue ?? []), + ); + + const ariaLabelledBy = () => { + return formControlContext.getAriaLabelledBy( + access(formControlProps.id), + others["aria-label"], + local["aria-labelledby"], + ); + }; + + const ariaDescribedBy = () => { + return formControlContext.getAriaDescribedBy(local["aria-describedby"]); + }; + + const [focusedIndex, setFocusedIndex] = createSignal(-1); + const isValueComplete = () => + context.value().length === value().filter((v) => v?.trim() !== "").length; + + createEffect( + on( + focusedIndex, + () => { + if (focusedIndex() === -1) return; + const inputEl = inputs()[focusedIndex()].ref() as HTMLInputElement; + inputEl.focus(); + if (local.selectOnFocus) { + inputEl.select(); + } + }, + { defer: true }, + ), + ); + + createEffect( + on( + isValueComplete, + () => { + if (!isValueComplete()) return; + local.onComplete?.(value()); + if (local.blurOnComplete) { + (inputs()[focusedIndex()].ref() as HTMLInputElement).blur(); + } + }, + { defer: true }, + ), + ); + + createEffect(() => { + if (value().length < inputs().length) { + const maxLength = Math.max(value().length, inputs().length); + setValue((prev) => + (prev ?? []).concat(Array(maxLength - value().length).fill("")), + ); + } + }); + + const dataset: Accessor = () => ({ + ...formControlContext.dataset(), + "data-complete": isValueComplete() ? "" : undefined, + }); + + const context: PinInputContextValue = { + dataset, + value, + setValue, + mask: () => local.mask, + otp: () => local.otp, + placeholder: () => local.placeholder!, + pattern: () => local.pattern, + type: () => local.type!, + focusedIndex, + setFocusedIndex, + inputs, + setInputs, + translations: () => local.translations!, + }; + + return ( + + + + + as="div" + ref={mergeRefs((el) => (ref = el), local.ref)} + role="group" + id={access(formControlProps.id)!} + aria-invalid={ + formControlContext.validationState() === "invalid" || undefined + } + aria-required={formControlContext.isRequired() || undefined} + aria-disabled={formControlContext.isDisabled() || undefined} + aria-readonly={formControlContext.isReadOnly() || undefined} + aria-labelledby={ariaLabelledBy()} + aria-describedby={ariaDescribedBy()} + {...dataset()} + {...others} + /> + + + + ); +} diff --git a/packages/core/src/pin-input/pin-input.intl.ts b/packages/core/src/pin-input/pin-input.intl.ts new file mode 100644 index 00000000..c9a86b58 --- /dev/null +++ b/packages/core/src/pin-input/pin-input.intl.ts @@ -0,0 +1,6 @@ +export const PIN_INPUT_INTL_TRANSLATIONS = { + inputLabel: (index: number, length: number) => + `pin code ${index + 1} of ${length}`, +}; + +export type PinInputIntlTranslations = typeof PIN_INPUT_INTL_TRANSLATIONS;