diff --git a/README.md b/README.md index 4685131..bbdddcf 100644 --- a/README.md +++ b/README.md @@ -1,148 +1,163 @@ -# RIFM - React Input Format & Mask +# RIFM — React Input Format & Mask -Is a tiny (≈ 800b) component (and hook) to transform any input component -into formatted or masked input. +A small, dependency-free React hook and component for building formatted and masked inputs without losing the cursor position. -[Demo](https://trysound.github.io/rifm) +[Live demo](https://trysound.github.io/rifm) -## Highlights +## Why RIFM? -- Requires React 16.8+ -- Dependency free -- Tiny (≈ 800b) -- Supports any [input](https://trysound.github.io/rifm#material-ui). -- Can [mask](https://trysound.github.io/rifm#date-format) input, - [format](https://trysound.github.io/rifm#number-format) and [more](https://trysound.github.io/rifm#case-enforcement) -- Small readable source -- flow + typescript definitions +- Keeps the caret in the expected place while formatting +- Works with native inputs, textareas, and custom input components +- Supports formatting, masks, case enforcement, and other transformations +- Provides both a hook and a render-prop component +- Has no runtime dependencies and is about 1.4 kB gzipped +- Includes TypeScript declarations +- Supports React 16.8 and newer -## Example +## Install -Rifm offers both a Render Prop and a Hook API: +```sh +pnpm add rifm +``` -### Render Prop +You can also install it with `npm install rifm` or `yarn add rifm`. -```js -import { Rifm } from 'rifm'; -import { TextField } from '@material-ui/core'; +## Quick start -const numberFormat = (str: string) => { - const r = parseInt(str.replace(/[^\d]+/gi, ''), 10); - return r ? r.toLocaleString('en') : ''; -} +RIFM is controlled: keep the formatted value in state, pass it to `useRifm`, and attach the returned props to your input. -... - - const [value, setValue] = React.useState('') - - - {({ value, onChange }) => ( - - )} - - -... -``` +```tsx +import { useState } from "react"; +import { useRifm } from "rifm"; -### Hook +const formatInteger = (value: string) => { + const digits = value.replace(/\D/g, ""); + return digits.replace(/\B(?=(\d{3})+(?!\d))/g, ","); +}; -```js -import { useRifm } from 'rifm'; -import { TextField } from '@material-ui/core'; +export function PriceInput() { + const [value, setValue] = useState(""); + const rifm = useRifm({ + value, + onChange: setValue, + format: formatInteger, + }); -const numberFormat = (str: string) => { - const r = parseInt(str.replace(/[^\d]+/gi, ''), 10); - return r ? r.toLocaleString('en') : ''; + return ; } +``` -... +The `onChange` callback receives the formatted string—not the React change event. - const [value, setValue] = React.useState('') +> [!IMPORTANT] +> Use `type="text"` with an appropriate [`inputMode`](https://developer.mozilla.org/docs/Web/HTML/Global_attributes/inputmode). RIFM does not support `type="number"` or `type="date"` because those controls do not expose the selection APIs needed to restore the caret. - const rifm = useRifm({ - value, - onChange: setValue, - format: numberFormat - }) +## Render-prop API - +If hooks are not convenient, use the `Rifm` component. It accepts the same options and passes input props to its child function. -... -``` +```tsx +import { useState } from "react"; +import { Rifm } from "rifm"; -## Install +export function PriceInput() { + const [value, setValue] = useState(""); -```sh -pnpm add rifm + return ( + + {({ value, onChange }) => ( + + )} + + ); +} ``` +Because RIFM only supplies `value` and `onChange`, the input can be a native element or any component that accepts compatible props. + ## API -### Terminology +Both `useRifm(options)` and `` accept the following options. + +### Required options + +| Option | Type | Description | +| ---------- | --------------------------- | ------------------------------------- | +| `value` | `string` | The controlled input value. | +| `onChange` | `(value: string) => void` | Called with the next formatted value. | +| `format` | `(value: string) => string` | Formats the value after every edit. | + +### Optional options + +| Option | Type | Default | Description | +| ---------- | --------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------- | +| `accept` | `RegExp` | `/\d/g` | Matches characters whose order RIFM tracks when restoring the caret. Use a global regular expression. | +| `replace` | `(value: string) => string` | — | Post-processes the formatted value while preserving the caret, for example to enforce uppercase or replace text. | +| `append` | `(value: string) => string` | — | Post-processes insertions made at the end; useful when a formatter needs to append a separator. | +| `mask` | `boolean` | — | Enables replacement-style mask editing and caret movement across mask separators. | +| `children` | `(props) => ReactNode` | — | Required by `` only. Receives `{ value, onChange }`. | + +### Return value + +`useRifm` returns the same object passed to the `Rifm` child function: + +```ts +{ + value: string; + onChange: React.ChangeEventHandler; +} +``` -Rifm is based on simple idea (**\***): +Pass both properties to the underlying input. -- format operation applied to input value after edit doesn't change the order of some symbols before cursor +## Accepted characters and caret behavior -**\*** _This is not always true, but we solve some edge cases where it's not._ +RIFM restores the caret by tracking the characters matched by `accept`. The formatter may insert, remove, or move separators, but it should preserve the order of those accepted characters. -> Imagine you have simple integer number formatter with **\`** as thousands separator -> and current input state is _123\`4_**|**_67_ _("|" shows current cursor position)_. -> -> User press _5_ then formatted input must be equal to _1\`234\`5_**|**_67_. -> -> The overall order of elements has changed (was _1->2->3->\`->4->..._ became _1->\`->2->3->4..._) -> but the order of digits before cursor hasn't changed (was _1->2->3->4_ and hasn't changed). +For example, with digits as accepted characters: + +```text +Before typing: 1,234|67 +Type "5": 1,2345|67 +Formatted: 1,234,5|67 +``` -The same is true for float numbers formatting, dates and more. -Symbols with preserved order are different and depends on format. -We call this kind of symbols - **"accepted"** symbols. +The commas move, but the digits before the caret remain in the same order, so RIFM can find the correct position. Set `accept` when formatting something other than digits: -Rifm solves only one task - -find the right place for cursor after formatting. +```tsx +const rifm = useRifm({ + value, + onChange: setValue, + format: (value) => value, + replace: (value) => value.replace(/[^a-z ]/gi, "").toUpperCase(), + accept: /[a-z ]/gi, +}); +``` -Knowledge about what symbols are **"accepted"** and cursor position after any user action -is enough to find the final cursor position. +Use `replace` for transformations such as case enforcement that change accepted characters themselves. -Most operations which are not covered with above idea like -case enforcements, masks guides, floating point _","=>"."_ replacement -can be done using simple postprocessing step - replace. -This operation works well if you need to change input value without loosing cursor position. +## TypeScript -And finaly masks - masks are usually is format with replace editing mode + some small cursor visual hacks. +The hook and component can infer common input types. For an explicit custom element or component event type, provide the generic parameter: -### Input Props +```tsx +const inputProps = useRifm({ + value, + onChange: setValue, + format: formatInteger, +}); +``` -These are accepted by the Rifm component as props and the useRifm hook as named arguments. +## Development -| Prop | type | default | Description | -| ------------ | :---------------------------- | :------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **accept** | RegExp (optional) | /\d/g | Regular expression to detect **"accepted"** symbols | -| **format** | string => string | | format function | -| **value** | string | | input value | -| **onChange** | string => void | | event fired on input change | -| **children** | ({ value, onChange }) => Node | | value and onChange handler you need to pass to underlying input element | -| **mask** | boolean (optional) | | use replace input mode if true, use cursor visual hacks if prop provided | -| **replace** | string => string (optional) | | format postprocessor allows you to fully replace any/all symbol/s preserving cursor | -| **append** | string => string (optional) | | format postprocessor called only if cursor is in the last position and new symbols added, used for specific use-case to add non accepted symbol when you type | +```sh +pnpm install +pnpm test +pnpm run build +``` -### Output Props +The test suite includes TypeScript, unit, and browser tests. -These will be passed into the `children` render prop for the Rifm component as named arguments, and returned from the useRifm hook as an object. +## License -| Prop | type | default | Description | -| ------------ | :--------------------- | :------ | :--------------------------------------------------------------- | -| **value** | string | | A formatted string value to pass as a prop to your input element | -| **onChange** | SyntheticEvent => void | | The change handler to pass as a prop to your input element | +[MIT](LICENSE)