@wherabouts/react-ui 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/styles.css CHANGED
@@ -76,14 +76,14 @@
76
76
  display: block;
77
77
  width: 100%;
78
78
  padding: var(--wherabouts-spacing-sm) var(--wherabouts-spacing-md);
79
- border: var(--wherabouts-border-width) solid
80
- var(--wherabouts-color-border);
81
- border-radius: var(--wherabouts-border-radius);
82
- background-color: var(--wherabouts-color-input-bg);
83
- color: var(--wherabouts-color-input-text);
84
79
  font-size: var(--wherabouts-font-size-base);
85
80
  font-weight: var(--wherabouts-font-weight-normal);
86
- transition: border-color var(--wherabouts-transition-fast),
81
+ color: var(--wherabouts-color-input-text);
82
+ background-color: var(--wherabouts-color-input-bg);
83
+ border: var(--wherabouts-border-width) solid var(--wherabouts-color-border);
84
+ border-radius: var(--wherabouts-border-radius);
85
+ transition:
86
+ border-color var(--wherabouts-transition-fast),
87
87
  box-shadow var(--wherabouts-transition-fast);
88
88
  }
89
89
 
@@ -94,14 +94,13 @@
94
94
  [data-slot="address-input"]:focus {
95
95
  outline: none;
96
96
  border-color: var(--wherabouts-color-focus-ring);
97
- box-shadow: 0 0 0 3px
98
- hsl(221.2 83.2% 53.3% / 0.1);
97
+ box-shadow: 0 0 0 3px hsl(221.2 83.2% 53.3% / 0.1);
99
98
  }
100
99
 
101
100
  [data-slot="address-input"]:disabled {
102
- opacity: 0.5;
103
101
  cursor: not-allowed;
104
102
  background-color: hsl(0 0% 95% / 0.5);
103
+ opacity: 0.5;
105
104
  }
106
105
 
107
106
  [data-slot="address-input"][aria-invalid="true"] {
@@ -109,30 +108,35 @@
109
108
  }
110
109
 
111
110
  [data-slot="address-input"][aria-invalid="true"]:focus {
112
- box-shadow: 0 0 0 3px
113
- hsl(0 84.2% 60.2% / 0.1);
111
+ box-shadow: 0 0 0 3px hsl(0 84.2% 60.2% / 0.1);
114
112
  }
115
113
 
116
- /* ── Dropdown Listbox ──────────────────────────────────────── */
117
- [data-slot="address-listbox"] {
118
- position: absolute;
119
- top: 100%;
120
- left: 0;
121
- right: 0;
122
- margin-top: var(--wherabouts-spacing-xs);
123
- max-height: 300px;
124
- overflow-y: auto;
114
+ /* ── Dropdown Container ────────────────────────────────────── */
115
+ /* The component positions this (position: fixed) inline and portals it to
116
+ <body>, so no ancestor overflow can clip it. This rule supplies the visual
117
+ chrome and clips the inner scroll area to the rounded corners. */
118
+ [data-slot="address-dropdown"] {
125
119
  z-index: var(--wherabouts-z-dropdown);
126
- list-style: none;
127
- padding: var(--wherabouts-spacing-sm);
128
- margin: var(--wherabouts-spacing-xs) 0 0 0;
120
+ overflow: hidden;
121
+ background-color: var(--wherabouts-color-dropdown-bg);
129
122
  border: var(--wherabouts-border-width) solid
130
123
  var(--wherabouts-color-dropdown-border);
131
124
  border-radius: var(--wherabouts-border-radius);
132
- background-color: var(--wherabouts-color-dropdown-bg);
133
125
  box-shadow: var(--wherabouts-color-dropdown-shadow);
134
126
  }
135
127
 
128
+ /* ── Dropdown Listbox ──────────────────────────────────────── */
129
+ /* The scroll area itself. It lives in normal flow inside the fixed container
130
+ above (NOT absolutely positioned), so a long suggestion list scrolls here at
131
+ max-height instead of extending past the container and the viewport. */
132
+ [data-slot="address-listbox"] {
133
+ max-height: 300px;
134
+ padding: var(--wherabouts-spacing-sm);
135
+ margin: 0;
136
+ overflow-y: auto;
137
+ list-style: none;
138
+ }
139
+
136
140
  [data-slot="address-listbox"]::-webkit-scrollbar {
137
141
  width: 8px;
138
142
  }
@@ -146,19 +150,28 @@
146
150
  border-radius: 4px;
147
151
  }
148
152
 
153
+ /* Branding footer — pinned below the scroll area, inside the dropdown border. */
154
+ [data-slot="address-powered-by"] {
155
+ padding: var(--wherabouts-spacing-sm) var(--wherabouts-spacing-md);
156
+ font-size: var(--wherabouts-font-size-sm);
157
+ color: var(--wherabouts-color-text-muted);
158
+ border-top: var(--wherabouts-border-width) solid
159
+ var(--wherabouts-color-dropdown-border);
160
+ }
161
+
149
162
  /* ── Suggestion Item ───────────────────────────────────────── */
150
163
  [data-slot="address-item"] {
164
+ display: flex;
165
+ align-items: center;
166
+ min-height: 44px;
151
167
  padding: var(--wherabouts-spacing-sm) var(--wherabouts-spacing-md);
152
168
  margin: 2px 0;
153
- border-radius: var(--wherabouts-border-radius-sm);
154
- background-color: var(--wherabouts-color-item-bg);
169
+ font-size: var(--wherabouts-font-size-base);
155
170
  color: var(--wherabouts-color-item-text);
156
171
  cursor: pointer;
157
- font-size: var(--wherabouts-font-size-base);
172
+ background-color: var(--wherabouts-color-item-bg);
173
+ border-radius: var(--wherabouts-border-radius-sm);
158
174
  transition: background-color var(--wherabouts-transition-fast);
159
- min-height: 44px;
160
- display: flex;
161
- align-items: center;
162
175
  }
163
176
 
164
177
  [data-slot="address-item"]:hover {
@@ -173,11 +186,11 @@
173
186
  [data-slot="address-item"][data-status="loading"],
174
187
  [data-slot="address-item"][data-status="error"],
175
188
  [data-slot="address-item"][data-status="empty"] {
176
- cursor: default;
177
189
  justify-content: center;
190
+ min-height: 40px;
178
191
  font-size: var(--wherabouts-font-size-sm);
179
192
  color: var(--wherabouts-color-text-muted);
180
- min-height: 40px;
193
+ cursor: default;
181
194
  }
182
195
 
183
196
  [data-slot="address-item"][data-status="error"] {
@@ -199,15 +212,15 @@
199
212
  }
200
213
 
201
214
  [data-slot="address-form-field"] label [aria-hidden="true"] {
202
- color: var(--wherabouts-color-error);
203
215
  margin-left: 2px;
216
+ color: var(--wherabouts-color-error);
204
217
  }
205
218
 
206
219
  [data-slot="address-form-field"] [role="alert"] {
207
220
  display: block;
221
+ margin-top: 2px;
208
222
  font-size: var(--wherabouts-font-size-sm);
209
223
  color: var(--wherabouts-color-error);
210
- margin-top: 2px;
211
224
  }
212
225
 
213
226
  /* ── Field Group ───────────────────────────────────────────── */
@@ -246,13 +259,12 @@
246
259
  display: block;
247
260
  width: 100%;
248
261
  padding: var(--wherabouts-spacing-sm) var(--wherabouts-spacing-md);
249
- border: var(--wherabouts-border-width) solid
250
- var(--wherabouts-color-border);
251
- border-radius: var(--wherabouts-border-radius);
252
- background-color: hsl(0 0% 97%);
253
- color: var(--wherabouts-color-input-text);
254
262
  font-size: var(--wherabouts-font-size-base);
263
+ color: var(--wherabouts-color-input-text);
255
264
  cursor: default;
265
+ background-color: hsl(0 0% 97%);
266
+ border: var(--wherabouts-border-width) solid var(--wherabouts-color-border);
267
+ border-radius: var(--wherabouts-border-radius);
256
268
  }
257
269
 
258
270
  .dark [data-slot="geocode-input"] {
@@ -278,7 +290,7 @@
278
290
  /* High contrast mode support */
279
291
  @media (prefers-contrast: more) {
280
292
  [data-slot="address-input"],
281
- [data-slot="address-listbox"] {
293
+ [data-slot="address-dropdown"] {
282
294
  border-width: 2px;
283
295
  }
284
296
 
package/docs/README.md ADDED
@@ -0,0 +1,16 @@
1
+ # @wherabouts/react-ui — Component documentation
2
+
3
+ Detailed, per-component guides with worked examples, full prop tables,
4
+ accessibility notes, and recipes. For a quick overview and install steps, see
5
+ the [package README](../README.md).
6
+
7
+ These docs mirror the package's [Storybook](../README.md#interactive-docs-storybook),
8
+ where every component has interactive, live examples.
9
+
10
+ ## Components
11
+
12
+ - [AddressAutocomplete](./address-autocomplete.md) — accessible, debounced address search.
13
+ - [AddressFormField](./address-form-field.md) — labelled form wrapper around the autocomplete.
14
+ - [ForwardGeocodeInput](./forward-geocode-input.md) — resolve free text to coordinates.
15
+ - [ReverseGeocodeInput](./reverse-geocode-input.md) — resolve coordinates to an address.
16
+ - [AddressFieldGroup](./address-field-group.md) — controlled street/suburb/state/postcode group.
@@ -0,0 +1,208 @@
1
+ # AddressAutocomplete
2
+
3
+ ## Summary
4
+
5
+ Accessible (WAI-ARIA combobox), debounced address search with keyboard navigation, proximity bias, session tokens, i18n strings, and customizable render slots. Provide a `client` created with `createWheraboutsClient`.
6
+
7
+ ## When to use / when not
8
+
9
+ **Use it when:**
10
+
11
+ - You need a freeform "start typing an address" search box backed by the Wherabouts
12
+ autocomplete API (checkout forms, shipping address capture, location search).
13
+ - You want built-in keyboard navigation (arrow keys, enter, escape) and ARIA semantics
14
+ without wiring up a combobox yourself.
15
+ - You want to bias results toward the user's location (geolocation or explicit lat/lng).
16
+
17
+ **Don't use it when:**
18
+
19
+ - You need a labelled form field with built-in error styling — use `AddressFormField`,
20
+ which wraps this component with a `<label>` and error text.
21
+ - You're editing an already-known, structured address (street/suburb/state/postcode) —
22
+ use `AddressFieldGroup` instead.
23
+ - You only need to resolve free text to coordinates without a suggestion dropdown — use
24
+ `ForwardGeocodeInput`.
25
+
26
+ ## Import & minimal example
27
+
28
+ ```tsx
29
+ import { createWheraboutsClient } from "@wherabouts/sdk";
30
+ import { AddressAutocomplete } from "@wherabouts/react-ui";
31
+ import "@wherabouts/react-ui/styles.css";
32
+
33
+ const client = createWheraboutsClient({ apiKey: import.meta.env.VITE_WHERABOUTS_KEY });
34
+
35
+ export function Checkout() {
36
+ return (
37
+ <AddressAutocomplete
38
+ client={client}
39
+ placeholder="Start typing an address…"
40
+ onSelect={(address) => console.log(address.formattedAddress)}
41
+ />
42
+ );
43
+ }
44
+ ```
45
+
46
+ ## Worked examples
47
+
48
+ ### 1. Controlled selection state
49
+
50
+ `AddressAutocomplete` manages its own input text internally; there is no `value` prop.
51
+ To keep the *selected* address as state in your component, store it from `onSelect` and
52
+ track the raw query text (if you need it) via `onQueryChange`:
53
+
54
+ ```tsx
55
+ function DeliveryAddress() {
56
+ const [selected, setSelected] = useState<AddressWithParsed | null>(null);
57
+ const [query, setQuery] = useState("");
58
+
59
+ return (
60
+ <div>
61
+ <AddressAutocomplete
62
+ client={client}
63
+ onQueryChange={setQuery}
64
+ onSelect={(address) => setSelected(address)}
65
+ placeholder="Delivery address"
66
+ />
67
+ {selected && (
68
+ <p>
69
+ Selected: {selected.streetAddress}, {selected.suburb} {selected.state}{" "}
70
+ {selected.postcode}
71
+ </p>
72
+ )}
73
+ </div>
74
+ );
75
+ }
76
+ ```
77
+
78
+ ### 2. TanStack Form wiring
79
+
80
+ Wire `onSelect` to a TanStack Form field's `setValue`, and surface field validation
81
+ errors via the `error` prop:
82
+
83
+ ```tsx
84
+ import { useForm } from "@tanstack/react-form";
85
+
86
+ function AddressFormExample() {
87
+ const form = useForm({
88
+ defaultValues: { address: null as AddressWithParsed | null },
89
+ });
90
+
91
+ return (
92
+ <form.Field name="address">
93
+ {(field) => (
94
+ <AddressAutocomplete
95
+ client={client}
96
+ placeholder="Shipping address"
97
+ onSelect={(address) => field.handleChange(address)}
98
+ error={field.state.meta.errors?.[0]}
99
+ required
100
+ />
101
+ )}
102
+ </form.Field>
103
+ );
104
+ }
105
+ ```
106
+
107
+ ### 3. Geolocation / proximity bias
108
+
109
+ Enable browser geolocation to bias suggestions toward the user's current position, or
110
+ pass explicit coordinates (e.g. from a previously selected map pin) instead:
111
+
112
+ ```tsx
113
+ // Browser geolocation
114
+ <AddressAutocomplete client={client} enableGeolocation onSelect={setAddress} />
115
+
116
+ // Explicit proximity (e.g. a map center), skips the geolocation prompt
117
+ <AddressAutocomplete
118
+ client={client}
119
+ userLat={-33.8688}
120
+ userLng={151.2093}
121
+ onSelect={setAddress}
122
+ />
123
+ ```
124
+
125
+ `userLat`/`userLng` take precedence over `enableGeolocation` when both are provided.
126
+
127
+ ### 4. Custom suggestion renderer
128
+
129
+ Replace the default street/suburb row with your own markup. `renderSuggestion` receives
130
+ the parsed `AddressWithParsed` result and whether it's the currently keyboard-active row:
131
+
132
+ ```tsx
133
+ <AddressAutocomplete
134
+ client={client}
135
+ onSelect={setAddress}
136
+ renderSuggestion={(address, isActive) => (
137
+ <span style={{ fontWeight: isActive ? 700 : 400 }}>
138
+ 📍 {address.formattedAddress}
139
+ </span>
140
+ )}
141
+ />
142
+ ```
143
+
144
+ ## Props
145
+
146
+ | Prop | Type | Default | Description |
147
+ |---|---|---|---|
148
+ | `client` | `WheraboutsClient` | — | **Required.** SDK client created with `createWheraboutsClient`. |
149
+ | `onSelect` | `(address: AddressWithParsed) => void` | — | Called when a suggestion is selected. |
150
+ | `onQueryChange` | `(query: string) => void` | — | Called as the input text changes. |
151
+ | `placeholder` | `string` | — | Input placeholder text. |
152
+ | `debounceMs` | `number` | `300` | Debounce in ms before querying the API. |
153
+ | `minCharsToSearch` | `number` | `2` | Minimum characters typed before searching. |
154
+ | `maxSuggestions` | `number` | `5` | Maximum number of suggestions to show. |
155
+ | `enableGeolocation` | `boolean` | `false` | Use the browser's geolocation to bias results by proximity. |
156
+ | `userLat` | `number` | — | Explicit latitude for proximity bias (instead of geolocation). |
157
+ | `userLng` | `number` | — | Explicit longitude for proximity bias (instead of geolocation). |
158
+ | `sessionToken` | `string` | — | Group a run of keystrokes into one billable search (see SDK `newSessionToken()`). |
159
+ | `disabled` | `boolean` | — | Disable the input. |
160
+ | `required` | `boolean` | — | Mark the input as required. |
161
+ | `error` | `string` | — | External error message to display. |
162
+ | `id` | `string` | `"wherabouts-autocomplete"` | id forwarded to the input element. |
163
+ | `className` | `string` | — | Class applied to the root container. |
164
+ | `i18nStrings` | `Partial<AddressI18nStrings>` | — | Override built-in UI strings (no results, retry, etc.). |
165
+ | `renderSuggestion` | `(address: AddressWithParsed, isActive: boolean) => ReactNode` | — | Render a custom suggestion row. |
166
+ | `renderEmpty` | `() => ReactNode` | — | Render a custom empty state. |
167
+ | `renderLoading` | `() => ReactNode` | — | Render a custom loading state. |
168
+ | `renderError` | `(error: Error \| null) => ReactNode` | — | Render a custom error state. |
169
+
170
+ ## Accessibility
171
+
172
+ - The input and suggestion list implement the WAI-ARIA 1.2 **combobox** pattern: the
173
+ input has `role="combobox"` semantics (via the `useCombobox` hook), is associated with
174
+ a listbox via `aria-controls`, and exposes `aria-expanded` for open/closed state.
175
+ - The currently keyboard-highlighted suggestion is tracked with `aria-activedescendant`
176
+ on the input, and `aria-selected` on the active `<li>` — this drives the `isActive`
177
+ flag passed to `renderSuggestion`.
178
+ - **Keyboard support:** Arrow Down/Up move the active suggestion, Enter selects the
179
+ active suggestion (calling `onSelect` and clearing the query), and Escape closes the
180
+ dropdown.
181
+ - The component does **not** render its own `<label>`. When using `AddressAutocomplete`
182
+ directly, pair it with a `<label htmlFor={id}>` (matching the `id` prop, default
183
+ `"wherabouts-autocomplete"`), or use `AddressFormField`, which does this for you.
184
+ - Loading, error, and empty states are announced as plain list items inside the same
185
+ listbox region so screen readers traversing the list encounter them in context.
186
+
187
+ ## Recipes & edge cases
188
+
189
+ - **Tuning `debounceMs` / `minCharsToSearch`:** The defaults (`debounceMs={300}`,
190
+ `minCharsToSearch={2}`) suit most forms. For high-traffic search-as-you-type UIs where
191
+ API cost matters, raise `debounceMs` (e.g. `400`) and `minCharsToSearch` (e.g. `4`) to
192
+ reduce request volume — see the `TunedSearch` Storybook story.
193
+ - **`sessionToken` for billing:** Each keystroke debounce fires a billable autocomplete
194
+ request. Generate a session token once per "search session" (e.g. when the user
195
+ focuses the field) with the SDK's `newSessionToken()` and pass it as `sessionToken` so
196
+ the API can group the whole keystroke sequence into a single billable session instead
197
+ of billing per request.
198
+ - **Error / empty / loading slots:** Use `renderError`, `renderEmpty`, and `renderLoading`
199
+ to match your app's design system instead of the built-in text rows. `renderError`
200
+ receives the underlying `Error` (or `null` if the error came from the external `error`
201
+ prop rather than the API call) so you can render API-specific messaging.
202
+ - **External vs. API errors:** The `error` prop (e.g. from form validation) and API
203
+ request failures both surface through the same error slot — the external `error`
204
+ prop takes precedence when both are present.
205
+ - **Geolocation denial:** If `enableGeolocation` is set and the user denies the browser
206
+ permission prompt, results simply fall back to unbiased search; there's no separate
207
+ error state for this today, so don't rely on `i18nStrings.geolocationError` being
208
+ surfaced automatically by this component.
@@ -0,0 +1,152 @@
1
+ # AddressFieldGroup
2
+
3
+ ## Summary
4
+
5
+ A controlled group of structured inputs (street, suburb, state, postcode) for editing a full address. Provide `value` and `onChange`.
6
+
7
+ ## When to use / when not
8
+
9
+ **Use it when:**
10
+
11
+ - You need the user to enter or confirm a full postal address split across discrete fields (street, suburb, state, postcode).
12
+ - You want to pre-fill address fields from an autocomplete selection and then let the user correct individual fields.
13
+ - You are building a checkout, registration, or profile form where a structured address is required for storage or validation.
14
+
15
+ **Don't use it when:**
16
+
17
+ - You only need the user to search for and select an address as a single value — use `AddressAutocomplete` or `AddressFormField` instead.
18
+ - You need reverse geocoding (coordinates → address display) — use `ReverseGeocodeInput` instead.
19
+ - You need forward geocoding (free text → coordinates) — use `ForwardGeocodeInput` instead.
20
+
21
+ ## Import & minimal example
22
+
23
+ ```tsx
24
+ import { useState } from "react";
25
+ import { createWheraboutsClient } from "@wherabouts/sdk";
26
+ import {
27
+ AddressFieldGroup,
28
+ type AddressFieldGroupValue,
29
+ } from "@wherabouts/react-ui";
30
+
31
+ const client = createWheraboutsClient({ apiKey: "..." });
32
+
33
+ const EMPTY: AddressFieldGroupValue = {
34
+ street: "",
35
+ suburb: "",
36
+ state: "",
37
+ postcode: "",
38
+ };
39
+
40
+ function MyForm() {
41
+ const [address, setAddress] = useState<AddressFieldGroupValue>(EMPTY);
42
+
43
+ return (
44
+ <AddressFieldGroup
45
+ client={client}
46
+ value={address}
47
+ onChange={setAddress}
48
+ />
49
+ );
50
+ }
51
+ ```
52
+
53
+ ## Worked examples
54
+
55
+ ### 1. Pre-fill from autocomplete, then allow manual correction
56
+
57
+ `AddressFieldGroup` renders an `AddressAutocomplete` at the top of the group. Selecting a suggestion fills all four fields automatically. The user can then edit individual fields:
58
+
59
+ ```tsx
60
+ const [address, setAddress] = useState<AddressFieldGroupValue>(EMPTY);
61
+
62
+ <AddressFieldGroup
63
+ client={client}
64
+ value={address}
65
+ onChange={setAddress}
66
+ />
67
+
68
+ <pre>{JSON.stringify(address, null, 2)}</pre>
69
+ ```
70
+
71
+ ### 2. Custom field labels (international / regional terminology)
72
+
73
+ Override any label to match your audience:
74
+
75
+ ```tsx
76
+ <AddressFieldGroup
77
+ client={client}
78
+ value={address}
79
+ onChange={setAddress}
80
+ streetLabel="Street address"
81
+ suburbLabel="City"
82
+ stateLabel="Region"
83
+ postcodeLabel="ZIP"
84
+ />
85
+ ```
86
+
87
+ ### 3. Disable all fields while submitting
88
+
89
+ Pass `disabled` to prevent edits during async operations:
90
+
91
+ ```tsx
92
+ const [submitting, setSubmitting] = useState(false);
93
+
94
+ <AddressFieldGroup
95
+ client={client}
96
+ value={address}
97
+ onChange={setAddress}
98
+ disabled={submitting}
99
+ />
100
+ ```
101
+
102
+ ### 4. Persist only when all fields are filled
103
+
104
+ Gate your submit handler on completeness:
105
+
106
+ ```tsx
107
+ const isComplete = Object.values(address).every((v) => v.trim().length > 0);
108
+
109
+ <button disabled={!isComplete} onClick={handleSubmit}>
110
+ Save address
111
+ </button>
112
+ ```
113
+
114
+ ## Props
115
+
116
+ | Prop | Type | Default | Description |
117
+ |---|---|---|---|
118
+ | `client` | `WheraboutsClient` | **Required** | SDK client created with `createWheraboutsClient`. |
119
+ | `value` | `AddressFieldGroupValue` | **Required** | Controlled value for the field group. |
120
+ | `onChange` | `(value: AddressFieldGroupValue) => void` | **Required** | Change handler called with the updated value on any field edit. |
121
+ | `streetLabel` | `string` | `"Street Address"` | Override the street address field label. |
122
+ | `suburbLabel` | `string` | `"Suburb"` | Override the suburb field label. |
123
+ | `stateLabel` | `string` | `"State"` | Override the state field label. |
124
+ | `postcodeLabel` | `string` | `"Postcode"` | Override the postcode field label. |
125
+ | `disabled` | `boolean` | `false` | Disables all fields. |
126
+ | `className` | `string` | — | Additional CSS class applied to the root container. |
127
+
128
+ ### AddressFieldGroupValue
129
+
130
+ ```ts
131
+ interface AddressFieldGroupValue {
132
+ street: string;
133
+ suburb: string;
134
+ state: string;
135
+ postcode: string;
136
+ }
137
+ ```
138
+
139
+ ## Accessibility
140
+
141
+ - Each field renders a native `<input>` with an explicit `<label>` wired via `htmlFor` — screen readers announce the field name correctly without additional markup.
142
+ - The embedded `AddressAutocomplete` at the top of the group provides its own accessible combobox behaviour; selecting a suggestion fills all four fields and moves focus back to the form flow.
143
+ - The `disabled` prop sets the HTML `disabled` attribute on every input, which is communicated to assistive technologies automatically.
144
+ - Field IDs are static (`field-street`, `field-suburb`, `field-state`, `field-postcode`). If you render multiple `AddressFieldGroup` instances on the same page, use a wrapping `<fieldset>` with a `<legend>` to distinguish them.
145
+
146
+ ## Recipes & edge cases
147
+
148
+ - **Autocomplete fills all four fields:** When the user selects a suggestion from the embedded `AddressAutocomplete`, `onChange` is called once with all four fields populated from the matched address. The user can then edit individual fields as needed.
149
+ - **Uncontrolled initialisation:** Initialise `value` with the `EMPTY` constant (or your own empty object) rather than leaving fields undefined — all four keys must be strings for controlled inputs to behave correctly.
150
+ - **Validation:** The component does not validate field content. Apply your own validation logic in `onChange` or before form submission.
151
+ - **Layout:** The street field spans the full width; suburb, state, and postcode share a two-column grid. Apply `className` to the root container to constrain width or add spacing.
152
+ - **Multiple instances on one page:** Wrap each instance in a `<fieldset>` + `<legend>` so screen readers distinguish between shipping and billing address groups, as static field IDs are shared.