# useZipTextBox

[← Back to Composables README](https://github.com/NantHealth/featherk/blob/integration/packages/composables/README.md)

Composable for a multi-mask ZIP code input that pairs with the Kendo Vue `TextBox` component. It handles both 5-digit (`XXXXX`) and ZIP+4 (`XXXXX-XXXX`) formats, automatically inserting the dash when the user types a 6th digit and stripping non-digit characters. Caret position is preserved across reformats so the typing experience feels natural.

Kendo Vue's `MaskedTextBox` does not handle dynamic mask switching between a 5-digit and 9-digit ZIP format gracefully. This composable solves that by managing formatting, validation state, and payload construction explicitly — leaving the `TextBox` as a simple controlled input.

## Related

- Looking for a full address form orchestration composable? See [`useUSAddress`](https://github.com/NantHealth/featherk/blob/integration/packages/composables/docs/address/useUSAddress.md).

## Prerequisites

- Vue 3 Composition API
- `@progress/kendo-vue-inputs` (for `TextBox`)

## Quick Start

```vue
<script setup lang="ts">
import { TextBox } from "@progress/kendo-vue-inputs";
import { Error } from "@progress/kendo-vue-labels";
import { useZipTextBox } from "@featherk/composables/address";

const {
  zipDisplay,
  errorMessage,
  textBoxValid,
  textBoxValidate,
  wrapperClass,
  showZipError,
  zipRaw,
  submitAndValidate,
  handleZipInput,
} = useZipTextBox({
  id: "zip",         // required — must match the TextBox id attribute
  initialValue: "",  // optional pre-populated value (digits or formatted)
  errorMessage: "Enter a valid ZIP code.",  // optional custom message
});

const handleSubmit = () => {
  if (submitAndValidate()) {
    console.log({ zip: zipRaw.value }); // e.g. { zip: "902101234" }
  }
};
</script>

<template>
  <TextBox
    id="zip"
    :wrapperClass="wrapperClass"
    :value="zipDisplay"
    :valid="textBoxValid"
    :validate="textBoxValidate"
    :validityStyles="textBoxValidate"
    placeholder="XXXXX or XXXXX-XXXX"
    @input="handleZipInput"
  />
  <Error for="zip" v-if="showZipError">{{ errorMessage }}</Error>

  <button @click="handleSubmit">Submit</button>
</template>
```

## Options

| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| `id` | `string` | **Yes** | — | The `id` attribute of the `TextBox` input. Must match the `id` prop passed to `<TextBox>`. Used by `submitAndValidate({ focus: true })` to focus the field on validation failure. |
| `initialValue` | `string` | No | `""` | Pre-populate the field. Digits and formatted strings are both accepted; non-digit characters are stripped. |
| `errorMessage` | `string` | No | `"Enter a valid ZIP code with exactly 5 or 9 digits."` | Custom validation error message. Whitespace-only values fall back to the default. |

## Return Value

### State (writable `Ref`)

| Property | Type | Description |
|----------|------|-------------|
| `zipDisplay` | `Ref<string>` | The formatted display value bound to the `TextBox`. Either `XXXXX` or `XXXXX-XXXX`. |
| `submitted` | `Ref<boolean>` | Whether `submitAndValidate()` has been called. Drives error visibility. Use `submitted.value` directly (e.g. to reset) — not typically bound in the template. |
| `errorMessage` | `Ref<string>` | Active error message (custom or default). |

### Bindings (readonly `string`)

| Property | Type | Description |
|----------|------|-------------|
| `wrapperClass` | `string` | Bind to `:wrapperClass` on `<TextBox>`. Kendo applies it to its `.k-textbox` wrapper element on every render, so it survives validity state changes. Value is always `"fk-zip-textbox"`. |

### Derived (readonly `ComputedRef`)

| Property | Type | Description |
|----------|------|-------------|
| `zipRaw` | `ComputedRef<string>` | Digits-only ZIP (strips the dash). Use this for form payloads and API calls. |
| `isZipValid` | `ComputedRef<boolean>` | `true` when `zipRaw` is exactly 5 or 9 digits. |
| `showZipError` | `ComputedRef<boolean>` | `true` when submitted and the value is invalid. Bind to `v-if` on an `<Error>`. |
| `textBoxValid` | `ComputedRef<boolean>` | Bind to `:valid` on `TextBox`. `false` only after submit with an invalid value. |
| `textBoxValidate` | `ComputedRef<boolean>` | `true` once `submitAndValidate()` has been called. Bind to both `:validate` and `:validityStyles` on `TextBox` — covers both props since they share the same semantic (has the user attempted submission?). |

### Actions

| Method | Signature | Description |
|--------|-----------|-------------|
| `handleZipInput` | `(...args: unknown[]) => void` | Bind to `@input` on `TextBox`. Reformats the value, strips non-digits, inserts/removes the dash, and restores the caret. |
| `submitAndValidate` | `(opts?: { focus?: boolean }) => boolean` | Sets `submitted` to `true` and returns `isZipValid`. Pass `{ focus: true }` to automatically focus the input on failure — useful when this is the first (or only) invalid field. See multi-field note below. |
| `buildPayload` | `() => ZipPayload` | Returns `{ zip: string }` with the raw digits-only value. Safe to pass directly to an API. |

> **Multi-field forms:** When validating several ZIP fields, call `submitAndValidate()` on all of them first (so every field shows its error state simultaneously), then call `submitAndValidate({ focus: true })` on the first failure to direct the user:
>
> ```ts
> const valid1 = zip.submitAndValidate();
> const valid2 = zip2.submitAndValidate();
> if (!valid1) zip.submitAndValidate({ focus: true });
> else if (!valid2) zip2.submitAndValidate({ focus: true });
> ```
>
> Calling `submitAndValidate({ focus: true })` on an already-valid field is a no-op — focus is only applied when the value is invalid.

## Types

```ts
export type ZipPayload = {
  zip: string; // digits only, 5 or 9 characters when valid
};

export type UseZipTextBoxOptions = {
  id: string;           // required
  initialValue?: string;
  errorMessage?: string;
};

export type UseZipTextBoxBindings = {
  readonly wrapperClass: string; // always "fk-zip-textbox"
};
```

## Formatting Behaviour

| User input | `zipDisplay` | `zipRaw` | `isZipValid` |
|------------|-------------|----------|--------------|
| `""` | `""` | `""` | `false` |
| `"1234"` | `"1234"` | `"1234"` | `false` |
| `"12345"` | `"12345"` | `"12345"` | `true` |
| `"123456"` | `"12345-6"` | `"123456"` | `false` |
| `"123456789"` | `"12345-6789"` | `"123456789"` | `true` |
| `"12345-6789"` | `"12345-6789"` | `"123456789"` | `true` |
| `"1234567890"` (10 digits) | `"12345-6789"` | `"123456789"` | `true` (truncated to 9) |

## Caret Behaviour

When the user types into the middle of the value, `handleZipInput` captures the logical digit-position of the caret _before_ the reformat, then restores it to the equivalent display position _after_ `nextTick`. This ensures:

- Typing `6` at the end of `12345` correctly places the caret after the newly inserted dash: `12345-|6`
- Deleting or editing mid-value does not jump the caret to the end

## Notes

- The composable does **not** emit intermediate states. `buildPayload()` always returns raw digits regardless of validity; you control whether to use it by checking the return value of `submitAndValidate()`.
- This composable is a building block for `useUSAddress`, which composes `useZipTextBox` alongside address line, city, and state field handlers.
