> ## Oymyakon DS index
> Fetch the full index at: https://super-dollop-pzmo65r.pages.github.io/llms.txt
> HTML preview: https://super-dollop-pzmo65r.pages.github.io/phone-field.html
> Source: specs/components/phone-field/phone-field.md, specs/components/phone-field/phone-field-a11y.md, specs/components/phone-field/capabilities.json

---

# PhoneField · Oymyakon DS 3

> The phone-number field: the TextField anatomy with locked phone content — a country-code
> button in Slot#1, the selected code as the input's prefix, a contacts button in Slot#2, and a
> phone-specific input mask.

**Version:** 3.1.0 · **Status:** Draft · **Figma:** `[PhoneField] 3.1` (master `13282:22938`, set key `f69335a4…`, Specification frame `13282:7725`)

---

## 1. Description

PhoneField is the Phone Input of the text-field family — an assembly on the
[TextField](https://super-dollop-pzmo65r.pages.github.io/text-field.md) anatomy whose parts are pre-assigned: Slot#1 hosts the
**country-code button** (the selected country's flag; opens the country list), the
[BaseTextLine](https://super-dollop-pzmo65r.pages.github.io/base-text-line.md) core carries the **selected country's code
as its prefix** plus the phone digits, Slot#2 hosts the **contacts button** (opens the user's
contact list), and the Helper line sits below. The label defaults to "Phone number".

Two things are **outside** the component by the Figma contract:

- **The country-code selection list** — "not supported in the component and needs to be
  implemented manually"; the existing login-screen solution opens in a Bottom Sheet when the
  country-code button is pressed.
- **The contact picker** behind the contacts button.

The shipped builds have since moved past that line — a recorded drift: iOS presents its own
`DsCountryPicker` sheet (and the system `CNContactPicker`); Android's `WithCountryPicker` tier
hosts `DsCountryDialog`, while its bare `DsPhoneField` still only fires the callback. The web
reference follows the Figma contract: the buttons fire events, nothing is hosted.

Phone numbers use **their own mask implementation** — the BaseTextLine `X`/`9` reserved
characters do not apply (that contract's own exclusion); the code prefix and the digits are both
"part of the phone number input mask". Both platforms derive it from **libphonenumber** metadata
(iOS `PhoneNumberKit.PartialFormatter(withPrefix: false)`, Android `AsYouTypeFormatter` as a
display-only `OutputTransformation`) — never hand-authored per-country patterns.

### Platform implementations

| Platform | Entry point | Version | Notes |
|---|---|---|---|
| iOS | `Modules/DS3/DS3/Sources/PhoneTextField/` | 3.1 | `DsPhoneTextField` — the value binding is the full international number (`"+7916…"`); ships a **built-in** country sheet (`DsCountryPicker`, radius `s24`, search + cells) and the system contact picker |
| Android | `core/compose/…/ds/v3/textfield/phone/` | 3.1 (`@DsComponentInfo`) | Two tiers: `DsPhoneFieldWithCountryPicker` (hosts `DsCountryDialog`) over `DsPhoneField` (the flag tap only fires `onCountryPickerClick`); the state holds national digits only |
| Flutter | — | — | Not shipped |
| Web | `src/shared/shared.css` (`.text-field` + `.phone-field`) | 3.1.0 | The TextField block plus the phone-specific `.phone-field__country-button`; preview page `src/phone-field.njk` |

---

## 2. Anatomy

The TextField anatomy with locked content — differences only:

```
[PhoneField] (public master)             outer margins s4 vert / s16 horiz
└── PhoneField (.text-field.phone-field)   the TextField root, phone content locked
    ├── box (.text-field__box)
    │   ├── Slot#1 → country-code button (.phone-field__country-button)   s40 tap, the flag s24
    │   ├── StartText
    │   │     ├── label — "Phone number" by default
    │   │     └── BaseTextLine: prefix = the selected code (+7) · the digits · the caret
    │   └── Slot#2 → contacts button (.text-field__slot-button)           s48, phone-book s24
    └── Helper (.text-field__description)
```

| Part | Required | Description |
|---|---|---|
| **Country Code Button** | Off · **on by default** | Opens the country list (outside the component). Tap size `s40 × s40`, the flag icon `s24 × s24`. "A separate component, not part of a standard TextField" |
| **label** | Required | Default text "Phone number"; the standard compact/placeholder behaviour |
| **code prefix** | — | The selected country's code (`+7`) — the BaseTextLine prefix, part of the mask; appears with activity, stays when filled |
| **digits** | — | The entered number, formatted by the phone mask |
| **Contacts Button** | Off · **on by default** | Opens the user's contact list (outside the component). The `phone-book` icon, `s48` slot |
| **Helper** | Off · on by default | The Description contract from TextField — hint text, error text with the error icon |

### RTL layout

The chrome mirrors as in TextField; **entering a phone number in RTL remains in LTR** — the
BaseTextLine forced-LTR contract (`.base-text-line--ltr`) is always on for the code + digits.

---

## 3. Variants and sizes

| Axis | Values | Default | Web mechanism |
|---|---|---|---|
| **State** | `standard empty` · `active empty` · `active filled` · `standard filled` · `data loading` · `skeleton` (Figma: `rest …`) | `standard empty` | As TextField; **data loading is a State value here** (unlike TextField, where it is an overlay) |
| **Disabled** | off · on | off | `.text-field--disabled` |
| **Validation** | `none` · `error` | `none` | `.text-field--error` |
| **RTL** | off · on | off | `dir="rtl"`; the input stays LTR |
| **Slot#1** (country button) | off · on | **on** | Presence of `.phone-field__country-button` |
| **Slot#2** (contacts button) | off · on | **on** | Presence of the `.text-field__slot-button` in the end slot |
| **Helper** | off · on | **on** | Presence of `.text-field__description` |
| **Label mode** | `compact` · `placeholder` | `compact` | The TextField contract; default text "Phone number" |

The colour, border, skeleton, error and label-float contracts are TextField's — nothing
re-declared.

---

## 4. States

As TextField, with the phone content:

| State | What differs from TextField |
|---|---|
| **Standard empty** (Figma: rest empty) | The flag alone in Slot#1, the label centred; no code shown |
| **Active empty** | The label floats, the **code prefix (`+7`) appears** with the caret |
| **Active filled / Standard filled** | Code + digits (`+7 7770147860` — no digit grouping in the Figma staging); the flag stays |
| **Data loading** | A State value of the set: the loader replaces Slot#2 (the contacts button). **Neither platform ships a loader** — Android has none; iOS repurposes its `isLoading` environment for the skeleton path (`hasDsSkeleton`; the wiring sits in the shared base — the field's own source shows it hiding the Description). The Figma-staged state is a recorded gap |
| **Error** | Red border + the Helper turns `TextAndIcon/Error` with the error icon (Update 3.1); persists until the next validation |
| **Disabled** | Everything tints `TextAndIcon/Disabled`; "Rest filled state disabled" is staged |
| **Skeleton** | The whole box, as TextField |

- The touch area annotation scopes to **the input field of the component** — the two slot
  buttons carry their own `s40`/`s48` targets on top.

---

## 5. Animation and behavior

Everything is the TextField motion contract (Scale Medium label float and border fade, no press
animation on the field, shimmer, spinner) — see
[`text-field.md`](https://super-dollop-pzmo65r.pages.github.io/text-field.md) § 5 and
[`motion-rules.md`](https://super-dollop-pzmo65r.pages.github.io/motion.md). The slot buttons carry the
platform-native press feedback — the TextField press rule named Slot#1 (this component) as
exactly the case that requires it.

### Phone input behaviour

- The mask is **phone-specific** — libphonenumber as-you-type formatting, not the `X`/`9`
  reserved characters. The selected country's code is the mask's fixed head, rendered as the
  BaseTextLine prefix (edit-protected — the caret never enters it); the digits take the
  country's metadata grouping. The Figma staging shows the digits ungrouped; the runtime
  formats them.
- Switching the country (via the country-code button → the list) swaps the flag, the code
  prefix and the mask together. A **pasted** `+`-prefixed number re-parses and may switch the
  country; a bare national number never does. Android additionally strips the national trunk
  prefix (`8…` → RU national); iOS does not.
- Code + digits are pinned **LTR in every locale** (Android `forceLtr = true`; iOS carries no
  explicit RTL handling — flagged).
- Caret behaviour diverges: iOS forces the caret to the end after every change; Android maps it
  across the inserted separators, so mid-string edits keep their position.

### Recorded platform divergences

- **Value contract**: iOS binds the full international number; Android's state is national
  digits only (the code lives in `DsCountry`).
- **Digit cap**: iOS caps 15 *national* digits regardless of the code. Android gates the
  *national* digits at `15 − code.length + 1` — intended as E.164's 15-digit total, but the
  `+ 1` (the source comments it as "plus sign", which the digits-only state never contains)
  leaks one extra digit: the effective total is **16**. An off-by-one, flagged to the Android
  owners.
- **Clear button**: iOS default ON with the `close` icon in Primary (still the pre-3.2 icon —
  flagged); Android default OFF with `clear-filled` in Secondary. Both show it only focused +
  non-empty, and it displaces the contacts button.
- **Contacts button**: iOS default hidden; Android default shown — but gated behind
  `clearButtonEnabled`, so with the Android defaults it never renders (a shipped gating bug,
  flagged).
- **Prefix timing**: iOS shows the code only while editing; Android hands it to the base
  unconditionally. The web follows the Figma staging (appears with activity).
- **Skeleton**: iOS `isLoading` environment; Android the ambient `DsSkeleton` scope, no
  parameter.
- **Keyboard**: iOS `.phonePad` + an optional Done accessory ("a phone pad has no return key");
  Android `KeyboardType.Number` + `ImeAction.Done` → `onInputDone`.

---

## 6. Color tokens

TextField's table applies unchanged. Phone-specific defaults:

| Element | Token | Notes |
|---|---|---|
| Flag | — | The country flag is artwork (the Figma `flags/*` set) — no token re-pointing; disabled dims it to opacity 0.5 (both platforms) |
| Contacts icon | `var(--text-and-icon-primary)` | `phone-book` from the DS icons |
| Code prefix / digits | `var(--text-and-icon-primary)` | The BaseTextLine text/prefix slots |
| Label / Helper | `var(--text-and-icon-secondary)` | Default "Phone number" / hint |

---

## 7. Typography

TextField's table applies unchanged: label Main Body 16/20 ⇄ Compact Body 14/16, code + digits
Main Body, Helper Compact Body.

---

## 8. Spacing

TextField's model applies unchanged (box `s56`/`s20` both max ×130%, s8/s8/s0/s8/s4, `s16` with
slots off, Helper `s8`/`s16`/`s4`). Phone-specific values:

| Property | Token | Value @100% | Notes |
|---|---|---|---|
| Country-code button tap | `var(--sp-s40)` square | 40 | The Slot#1 geometry — the button IS the slot |
| Flag icon | `var(--sp-s24)` | 24 | The Figma binding is `SP/150% ratio`; **neither platform implements a cap on the flag** (both size it plain 24), and their other slot glyphs disagree with each other (iOS contacts/clear ×130, Android contacts ×150) — the scaling contract for phone glyphs is unresolved, flagged |
| Contacts button | `var(--sp-s48)` square | 48 | The Slot#2 geometry |

---

## 9. Usage context

### When to use PhoneField

- Any phone-number entry: login, registration, a contact form, a driver's or passenger's number.

### When not to use

- General text or numeric amounts — TextField (with a BaseTextLine prefix/suffix) covers those.
- One-time codes — the `InputCode` line.
- Rebuilding the country list inside the component — it lives outside by contract (the
  login-screen Bottom Sheet solution).

### Related components

- [TextField](https://super-dollop-pzmo65r.pages.github.io/text-field.md) — the anatomy and every shared contract.
- [BaseTextLine](https://super-dollop-pzmo65r.pages.github.io/base-text-line.md) — the code prefix, caret, forced-LTR rules.
- `[PasswordField]` — the third sibling of the family (not in the web DS yet).

---

## 10. Accessibility

Brief summary. Full spec: [`phone-field-a11y.md`](https://super-dollop-pzmo65r.pages.github.io/phone-field.md). The Figma voiced
previews are the contract:

- Country-code button: *[Choose country, Kazakhstan +7, Button, Double tap to activate]* — the
  action AND the current selection in one announcement.
- The field: *[Phone number, Edit box, Double tap to activate]*; while editing: *[Editing, Phone
  number, +7, 7770147860, Edit box]* — the code prefix joins the value.
- Contacts button: *[Address book, Button, Double tap to activate]*.
- **The web box is NOT an implicit `<label>`** here: Slot#1 is a real `<button>` preceding the
  input, which would hijack the implicit association — the box carries an explicit `for=` → the
  input's `id` (the rule recorded on TextField for exactly this component).
- Digits read verbatim (digit by digit), never as a large number.

---

## 11. Analytics and coverage contract

Contract per [`prototype-analytics-and-coverage.md`](https://github.com/inDriver/oymyakon-ds/blob/main/docs/prototype-analytics-and-coverage.md).

| Field | Value |
|---|---|
| `data-ds-component` | `phone-field` |
| Coverage unit | yes |
| Tap target model | The field (focus) + two internal button targets |
| Actions | `focus` on the field; `tap` on the country-code and contacts buttons |
| Internal targets | `country-code-button`, `contacts-button` — each with `data-ds-target` |
| Emits value | **Never the number** (the BaseTextLine rule — phone numbers are personal data); at most empty/filled, validation and country-code flags |

Attribute model: `data-ds-state` as TextField plus `data-loading`; `data-ds-variant` = the slot
shape (`plain` \| `country` \| `contacts` \| `country-contacts`).

```html
<div class="text-field phone-field" data-ds-component="phone-field" data-ds-component-id="login-phone"
     data-ds-variant="country-contacts" data-ds-state="standard-empty">
  <label class="text-field__box" for="login-phone-input">
    <span class="text-field__start-slot">
      <button class="phone-field__country-button" type="button"
              aria-label="Choose country, Kazakhstan +7"
              data-ds-action="tap" data-ds-target="country-code-button"><!-- flag --></button>
    </span>
    <span class="text-field__start-text">
      <span class="text-field__label" id="login-phone-label">Phone number</span>
      <span class="base-text-line base-text-line--ltr" data-ds-component="base-text-line">
        <span class="base-text-line__prefix" aria-hidden="true">+7</span>
        <input class="base-text-line__input" id="login-phone-input" type="tel" inputmode="tel"
               aria-labelledby="login-phone-label" aria-describedby="login-phone-desc">
      </span>
    </span>
    <span class="text-field__end-slot">
      <button class="text-field__slot-button" type="button" aria-label="Address book"
              data-ds-action="tap" data-ds-target="contacts-button"><!-- phone-book icon --></button>
    </span>
  </label>
  <span class="text-field__description" id="login-phone-desc">Description</span>
</div>
```

---

## Changelog

| Version | Date | Change |
|---|---|---|
| 3.1.0 | 2026-09-24 | Initial spec, aligned to the Figma line `[PhoneField] 3.1` (master `13282:22938` — the outer-margin wrapper hosting the PhoneField set: State (six values, **data loading included**) × Disabled × Validation × RTL + Helper/Slot#1/Slot#2 booleans). The TextField anatomy with locked phone content: the country-code button (s40 tap, s24 flag; the list itself is outside the component — the login-screen Bottom Sheet), the selected code as the BaseTextLine prefix (part of the phone mask, which is phone-specific — not X/9), the contacts button (`phone-book`, s48), Helper, default label "Phone number", RTL input pinned LTR, digits read verbatim. Update 3.1 (2026-02-24): DS 3.0 colours + the helper error icon. Cross-checked against both shipped builds (iOS `DsPhoneTextField`, Android `DsPhoneField`/`WithCountryPicker`, both 3.1): the mask is libphonenumber on both (no hand-authored patterns), the country button is the flag alone (no code text, no chevron), disabled dims the flag to 0.5, verbatim digit reading confirmed on both. Recorded divergences: value contract (full number vs national digits), digit caps (iOS 15 national; Android's formula leaks one digit past E.164's 15 — flagged), clear defaults and icons (iOS ON + `close`/Primary — pre-3.2; Android OFF + `clear-filled`/Secondary), contacts gating bug on Android, prefix timing, keyboards; the Figma-staged `data loading` State ships on neither platform (iOS `isLoading` = skeleton), and both builds have outgrown the "country list is outside the component" line (iOS built-in sheet; Android's WithCountryPicker tier) — drift flagged. Web build shipped: the TextField block + `.phone-field__country-button` + preview page; the box uses the explicit `for=`→`id` label pairing (the rule recorded on TextField for this component — Slot#1 is a real button). |

---

# PhoneField — Accessibility

Three focus areas in reading order: the country-code button, the field, the contacts button.
The Figma voiced previews are the contract:

- Country-code button: *[Choose country, Kazakhstan +7, Button, Double tap to activate]* — the
  action AND the current selection in one announcement; when the country changes, so does the
  announcement.
- The field: *[Phone number, Edit box, Double tap to activate]*; while editing: *[Editing, Phone
  number, +7, 7770147860, Edit box]* — the code prefix joins the announced value.
- Contacts button: *[Address book, Button, Double tap to activate]*.

Label rule: the buttons are named for the action plus the state ("Choose country, Kazakhstan
+7"), never for the picture ("Flag of Kazakhstan").

## Android · TalkBack

| Element | Label | Value | Trait | Hint |
|---|---|---|---|---|
| Country-code button | "Choose country" + the selected country and code | — | Button — own focus area, ripple on press | Double tap to activate |
| Field | "Phone number" (the label) | The code + the entered digits, read digit by digit | `EditText` (edit box) | The Helper joins the announcement |
| Contacts button | "Address book" | — | Button — own focus area | Double tap to activate |
| Flag | — | — | Part of the country button's announcement, never its own element | — |
| Helper | — | — | Read with the field; in error also the `error(...)` semantic | — |
| Skeleton | — | — | Not announced; the loading container carries the busy semantics | — |

- **Digits read verbatim** — the shipped build sets `inputContentDescription` = the label + a
  `VerbatimTtsAnnotation(code + digits)`: one character at a time, never "seven billion…".
- The data-loading state (a Figma State value) replaces the contacts button with a decorative
  loader; the field reports busy via the loading container. Neither platform ships the loader
  today — the web stages the Figma contract.
- **Disabled:** native semantics; both buttons disable with the field.

### Edge states

- **Error:** the Helper carries the error text and is re-announced with the field; red border +
  the error icon are the visual channel — never colour alone.
- **Country switch:** the button's announcement updates with the selection; the new code prefix
  is announced with the field's value on the next focus. ⚠ The shipped Android build joins the
  button's `contentDescription` with periods ("Choose country.Kazakhstan.+7"), which TalkBack
  reads with sentence pauses — the voiced preview's single comma phrase is the contract; flagged
  to the Android owners (iOS matches).
- **Skeleton:** the box is not announced; the loading container carries the busy semantics —
  the TextField pattern.
- **RTL:** the chrome mirrors; the code + digits stay LTR — announcements unchanged.
- **Reduced motion:** the TextField tokens zero; nothing announces.

---

## iOS · VoiceOver

| Element | Label | Value | Trait | Hint |
|---|---|---|---|---|
| Country-code button | "Choose country" + the selected country and code | — | Button | Double tap to activate |
| Field | The placeholder string (the shipped iOS pattern: `accessibilityLabel = placeholder`, the visible placeholder hidden) | `accessibilityValue` = the code + digits spelled one character per token — the verbatim mechanism | `Textfield` | The Helper joins the field's announcement |
| Contacts button | "Address book" | — | Button | — |
| Flag | — | — | Inside the button, not its own element | — |

### Edge states

- **Error / Disabled / Skeleton / Data loading:** platform-native semantics, the TextField
  contract; the loader is hidden from VoiceOver.
- **RTL / Reduced motion:** visual only — announcements unchanged.

---

## Web preview (reference)

- **The box is a `<label>` with an EXPLICIT `for=` → the input's `id`.** Slot#1 here is a real
  `<button>` that precedes the input in the DOM — with an implicit association the label's
  target would silently become that button (the rule recorded on TextField for exactly this
  component). Every PhoneField instance pairs `for`/`id`:

  ```html
  <label class="text-field__box" for="phone-input">
    <span class="text-field__start-slot">
      <button class="phone-field__country-button" type="button"
              aria-label="Choose country, Kazakhstan +7">…</button>
    </span>
    …
    <input class="base-text-line__input" id="phone-input" type="tel" inputmode="tel" …>
  </label>
  ```

- **`type="tel"` + `inputmode="tel"`** — the numeric telephone keyboard; the digits are content,
  the code prefix is the `aria-hidden` BaseTextLine prefix whose value is repeated in the
  country button's name (the code is never ONLY visual).
- **Web note — the announced value diverges from native (recorded).** Both platforms merge the
  code into the field's own spoken value and force per-character reading (iOS
  `accessibilityValue`, Android `VerbatimTtsAnnotation`). The web input's value is the bare
  digits: the field announces label + digits, and the code is heard on the country button — one
  swipe earlier, not merged. `type="tel"` digits read digit-by-digit as a browser/AT heuristic,
  not a guaranteed mechanism; there is no web `VerbatimTtsAnnotation` equivalent short of
  re-writing the value, which would corrupt editing. The divergence is accepted and recorded.
- **The country button's name carries the selection** — `aria-label="Choose country,
  Kazakhstan +7"` updates on switch; the flag artwork inside is decorative.
- **Buttons are separate tab stops** around the input; both get real `disabled` with the field.
- **Error / skeleton / focus ring / reduced motion:** the TextField web contract applies
  unchanged (`aria-invalid` + describedby, `aria-hidden` box + `aria-busy` container, the
  `:focus-within` border, tokens zeroed at `motion.css`).

### Colour and contrast

TextField's table applies unchanged (input/label/helper/error/borders — measured values
recorded there, including the error text's ≈ 3.5:1 Day shortfall, flagged). Phone-specific:

| Pairing | Tokens | Requirement |
|---|---|---|
| Flag artwork | — | None — the flag is identification, and the button's accessible name carries the meaning |
| Contacts icon | `--text-and-icon-primary` on `--surface-on-white` | ≥ 3:1 (non-text) |

---

## Machine contract — `specs/components/phone-field/capabilities.json`

Axes, allowed values, defaults, constraints and CSS/Figma bindings. Read this instead of guessing what the component can do.

```json
{
  "$schema": "../component-capabilities.schema.json",
  "id": "phone-field",
  "name": "PhoneField",
  "version": "3.1.0",
  "description": "The phone-number field: the TextField anatomy with locked phone content — a country-code button (the flag, s40 tap) in Slot#1, the selected code as the BaseTextLine prefix, libphonenumber formatting, a contacts button in Slot#2, the Helper below. The country list and the contact picker live outside the component. The root carries BOTH classes — .text-field (every TextField contract) plus .phone-field (the phone-specific block).",
  "files": {
    "spec": "specs/components/phone-field/phone-field.md",
    "a11y": "specs/components/phone-field/phone-field-a11y.md",
    "preview": "src/phone-field.njk",
    "css": "src/shared/shared.css"
  },
  "figma": {
    "library": "🕹️ Oymyakon 3.32.3 (components)",
    "fileKey": "7vdl5YkZFDWvh9QvSmydsH",
    "componentSets": {
      "PhoneField": {
        "key": "f69335a41c7c647bd26af2fb3e73b2f1156df567"
      }
    },
    "capturedAt": "2026-09-24"
  },
  "root": {
    "class": "phone-field",
    "dataDsComponent": "phone-field"
  },
  "anatomy": {
    "countryButton": {
      "class": "phone-field__country-button",
      "optional": true,
      "notes": "Slot#1 — the button IS the slot: s40 tap square, the country flag at s24 (artwork, no token; disabled dims it to 0.5 — the value both platforms ship). Opens the country list, which is OUTSIDE the component (the login-screen Bottom Sheet contract; the shipped builds host their own pickers — recorded drift). No code text, no chevron in the slot."
    },
    "codePrefix": {
      "class": "base-text-line__prefix",
      "notes": "The selected country's code (+7) — the BaseTextLine prefix, part of the phone mask, edit-protected; appears with input activity (the web behaviour lives in the .phone-field block)."
    },
    "contactsButton": {
      "class": "text-field__slot-button",
      "optional": true,
      "notes": "Slot#2 — the phone-book icon in the s48 square; opens the user's contact list (outside the component)."
    },
    "helper": {
      "class": "text-field__description",
      "optional": true,
      "notes": "The TextField Description contract; the error text with the error icon (Update 3.1)."
    }
  },
  "axes": {
    "state": {
      "title": "State",
      "type": "enum",
      "values": [
        "standard-empty",
        "active-empty",
        "active-filled",
        "standard-filled",
        "data-loading",
        "skeleton"
      ],
      "default": "standard-empty",
      "css": {
        "mechanism": "the TextField mechanisms; data-loading = the spinner in Slot#2 + aria-busy (a State value of THIS set, unlike TextField)"
      },
      "figma": {
        "kind": "variant-property",
        "property": "State",
        "values": {
          "standard-empty": "rest empty",
          "active-empty": "active empty",
          "active-filled": "active filled",
          "standard-filled": "rest filled",
          "data-loading": "data loading",
          "skeleton": "skeleton"
        },
        "notes": "Figma's 'rest' maps to 'standard' per spec-conventions."
      },
      "notes": "Neither platform ships the data-loading loader (Android has none; iOS repurposes isLoading for the skeleton path via the shared base) — a recorded gap; the web stages the Figma contract."
    },
    "disabled": {
      "title": "Disabled",
      "type": "boolean",
      "default": false,
      "css": {
        "modifier": ".text-field--disabled",
        "mechanism": "plus the input's and both buttons' disabled attribute; the flag dims to opacity 0.5"
      },
      "figma": {
        "kind": "variant-property",
        "property": "Disabled"
      }
    },
    "validation": {
      "title": "Validation",
      "type": "enum",
      "values": [
        "none",
        "error"
      ],
      "default": "none",
      "css": {
        "modifier": ".text-field--error"
      },
      "figma": {
        "kind": "variant-property",
        "property": "Validation"
      }
    },
    "rtl": {
      "title": "RTL",
      "type": "boolean",
      "default": false,
      "css": {
        "mechanism": "dir=\"rtl\" mirrors the chrome; the code + digits stay LTR — .base-text-line--ltr is locked on"
      },
      "figma": {
        "kind": "variant-property",
        "property": "RTL"
      }
    },
    "countryButtonSlot": {
      "title": "Slot#1 (country button)",
      "type": "boolean",
      "default": true,
      "css": {
        "mechanism": "presence of .phone-field__country-button"
      },
      "figma": {
        "kind": "boolean-property",
        "property": "🔴Slot#1"
      },
      "notes": "Platform defaults diverge from each other (iOS picker visible by default, Android's contacts/clear gating bug) — the Figma default (on) is the contract."
    },
    "contactsButtonSlot": {
      "title": "Slot#2 (contacts button)",
      "type": "boolean",
      "default": true,
      "css": {
        "mechanism": "presence of the slot button"
      },
      "figma": {
        "kind": "boolean-property",
        "property": "🔵Slot#2"
      }
    },
    "helperSlot": {
      "title": "Helper",
      "type": "boolean",
      "default": true,
      "css": {
        "mechanism": "presence of .text-field__description"
      },
      "figma": {
        "kind": "boolean-property",
        "property": "Helper"
      }
    },
    "labelMode": {
      "title": "Label mode",
      "type": "enum",
      "values": [
        "compact",
        "placeholder"
      ],
      "default": "compact",
      "css": {
        "modifier": ".text-field--placeholder-label"
      },
      "figma": {
        "kind": "none",
        "notes": "The TextField contract; the default label text is \"Phone number\"."
      }
    }
  },
  "analytics": {
    "dataDsComponent": "phone-field",
    "action": "focus",
    "valueAttr": "data-ds-state",
    "notes": "Coverage unit. Internal targets: country-code-button and contacts-button (data-ds-action=tap + data-ds-target each). Never emit the entered digits — a phone number is personal data; at most empty/filled, validation and country-code flags."
  },
  "rtl": {
    "supported": true,
    "notes": "A Figma variant axis: the chrome (slots, label, Helper) mirrors; entering a phone number in RTL remains in LTR — the forced-LTR BaseTextLine row is locked on (Android forceLtr=true; iOS carries no explicit handling, flagged)."
  },
  "constraints": [
    "Every TextField constraint applies (the anatomy, spacing, motion, no-truncation, error persistence).",
    "The mask is libphonenumber as-you-type formatting — never the X/9 reserved characters and never hand-authored per-country patterns; the code prefix is edit-protected.",
    "The country list and the contact picker are outside the component: the buttons fire events (the shipped builds host their own pickers — recorded drift).",
    "A pasted +-prefixed number may switch the country; a bare national number never does.",
    "The box pairs for= with the input's id EXPLICITLY — Slot#1 is a real button preceding the input (the rule recorded on TextField for this component).",
    "Never emit the entered digits through analytics."
  ]
}
```
