Skip to content

Commit 703bee0

Browse files
authored
docs: add cross-module recipe guides and upload progress documentation (#91)
1 parent d989327 commit 703bee0

8 files changed

Lines changed: 1414 additions & 10 deletions

File tree

README.md

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ tree-shakeable, and fully tested.
1717
- **Secure by default** — cryptographic randomness, input validation, XSS
1818
prevention
1919
- **Dual error handling** — throwing and Result-based APIs
20-
- **Quality-backed**4150+ tests, strict ESLint, full coverage
20+
- **Quality-backed**4250+ tests, strict ESLint, full coverage
2121

2222
## Modules
2323

@@ -58,14 +58,14 @@ tree-shakeable, and fully tested.
5858

5959
### Network & Communication
6060

61-
| Module | Key Classes | Description |
62-
| --------- | ----------------------------- | ----------------------------- |
63-
| network | `RetryQueue`, `NetworkStatus` | Retry queue with backoff |
64-
| offline | `OfflineQueue` | IndexedDB-backed offline sync |
65-
| websocket | `WebSocketManager` | WebSocket with auto-reconnect |
66-
| request | `RequestInterceptor` | Fetch middleware and auth |
67-
| url | `UrlBuilder` | URL building, query params |
68-
| broadcast | `BroadcastManager` | Cross-tab messaging |
61+
| Module | Key Classes | Description |
62+
| --------- | ----------------------------- | -------------------------------- |
63+
| network | `RetryQueue`, `NetworkStatus` | Retry queue with backoff |
64+
| offline | `OfflineQueue` | IndexedDB-backed offline sync |
65+
| websocket | `WebSocketManager` | WebSocket with auto-reconnect |
66+
| request | `RequestInterceptor` | Fetch middleware, auth, progress |
67+
| url | `UrlBuilder` | URL building, query params |
68+
| broadcast | `BroadcastManager` | Cross-tab messaging |
6969

7070
### Device & Environment
7171

@@ -197,6 +197,11 @@ const cleanup = IntersectionObserverWrapper.lazyLoad(
197197
| [geolocation](documentation/geolocation.md) | [websocket](documentation/websocket.md) |
198198
| [idle](documentation/idle.md) | [glossary](documentation/glossary.md) |
199199

200+
**Recipes:** [Offline-First App](documentation/recipes/offline-first-app.md) ·
201+
[Secure File Upload](documentation/recipes/secure-file-upload.md) ·
202+
[Accessible Form](documentation/recipes/accessible-form.md) ·
203+
[Resilient API Client](documentation/recipes/resilient-api-client.md)
204+
200205
**Guides:** [Browser Support](documentation/browser-support.md) ·
201206
[Error Handling](documentation/error-handling.md) ·
202207
[Security Guide](documentation/security-guide.md)

documentation/index.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,15 @@ security-first design.
4343
| [websocket](websocket.md) | WebSocket wrapper with auto-reconnection |
4444
| [a11y](a11y.md) | Accessibility utilities (ARIA, announcements, skip links) |
4545

46+
## Recipes
47+
48+
| Recipe | Modules |
49+
| ------------------------------------------------------- | ---------------------------------------------------- |
50+
| [Offline-First App](recipes/offline-first-app.md) | WebSocket + OfflineQueue + Cache + IndexedDB |
51+
| [Secure File Upload](recipes/secure-file-upload.md) | RequestInterceptor + StreamProgress + Sanitize + CSP |
52+
| [Accessible Form](recipes/accessible-form.md) | Form + Focus + Keyboard + A11y |
53+
| [Resilient API Client](recipes/resilient-api-client.md) | RequestInterceptor + RetryQueue + Cache (SWR) |
54+
4655
## Guides
4756

4857
| Guide | Description |
Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
# Recipe: Accessible Form in a Modal Dialog
2+
3+
Build a contact form inside a modal dialog with validation, focus management,
4+
keyboard navigation, and screen reader support.
5+
6+
## Modules Used
7+
8+
| Module | Purpose |
9+
| ------------------ | -------------------------------- |
10+
| `FormValidator` | Field validation and error state |
11+
| `FocusTrap` | Constrain focus within the modal |
12+
| `KeyboardShortcut` | Keyboard navigation and dismiss |
13+
| `LiveAnnouncer` | Screen reader announcements |
14+
| `AriaUtils` | ARIA attribute management |
15+
16+
## Step 1: Set Up the Modal and Focus Trap
17+
18+
Create the modal container and trap focus inside it so keyboard users cannot tab
19+
out into the background content.
20+
21+
```typescript
22+
import { FocusTrap } from '@zappzarapp/browser-utils/focus';
23+
import { AriaUtils } from '@zappzarapp/browser-utils/a11y';
24+
25+
const modal = document.getElementById('contact-modal')!;
26+
const trigger = document.getElementById('open-contact')!;
27+
28+
// Mark the modal with the correct ARIA role and label
29+
AriaUtils.setRole(modal, 'dialog');
30+
AriaUtils.set(modal, 'modal', 'true');
31+
AriaUtils.set(modal, 'labelledby', 'modal-title');
32+
33+
const trap = FocusTrap.create(modal, {
34+
initialFocus: '#contact-name',
35+
returnFocus: true,
36+
escapeDeactivates: true,
37+
onEscapeDeactivate: () => closeModal(),
38+
});
39+
```
40+
41+
## Step 2: Define Validation Rules
42+
43+
Configure the validator with rules for each field. Custom validators can handle
44+
more complex logic.
45+
46+
```typescript
47+
import { FormValidator } from '@zappzarapp/browser-utils/form';
48+
49+
const validator = FormValidator.create({
50+
name: { required: true, minLength: 2, maxLength: 100 },
51+
email: { required: true, email: true },
52+
subject: { required: true, minLength: 5, maxLength: 200 },
53+
message: { required: true, minLength: 10, maxLength: 2000 },
54+
});
55+
```
56+
57+
## Step 3: Wire Up Screen Reader Announcements
58+
59+
Use `LiveAnnouncer` to communicate validation errors and success states to
60+
assistive technology.
61+
62+
```typescript
63+
import { LiveAnnouncer } from '@zappzarapp/browser-utils/a11y';
64+
65+
const announcer = LiveAnnouncer.create();
66+
```
67+
68+
## Step 4: Register Keyboard Shortcuts
69+
70+
Add shortcuts for common actions within the modal context.
71+
72+
```typescript
73+
import {
74+
ShortcutManager,
75+
KeyboardShortcut,
76+
} from '@zappzarapp/browser-utils/keyboard';
77+
78+
// Ctrl+Enter to submit from anywhere in the form
79+
const cleanupSubmit = ShortcutManager.on(
80+
KeyboardShortcut.create({ key: 'Enter', ctrlKey: true }),
81+
() => submitForm()
82+
);
83+
```
84+
85+
## Step 5: Handle Validation Errors Accessibly
86+
87+
When validation fails, announce errors and move focus to the first invalid
88+
field.
89+
90+
```typescript
91+
import { FocusUtils } from '@zappzarapp/browser-utils/focus';
92+
93+
function displayErrors(
94+
form: HTMLFormElement,
95+
result: { valid: boolean; errors: Record<string, string[]> }
96+
): void {
97+
// Clear previous error states
98+
for (const field of form.elements) {
99+
if (field instanceof HTMLElement) {
100+
AriaUtils.remove(field, 'invalid');
101+
AriaUtils.remove(field, 'describedby');
102+
}
103+
}
104+
105+
if (result.valid) return;
106+
107+
const errorMessages: string[] = [];
108+
109+
for (const [fieldName, messages] of Object.entries(result.errors)) {
110+
const field = form.elements.namedItem(fieldName) as HTMLElement | null;
111+
const errorEl = document.getElementById(`${fieldName}-error`);
112+
113+
if (field && errorEl) {
114+
AriaUtils.set(field, 'invalid', 'true');
115+
AriaUtils.set(field, 'describedby', errorEl.id);
116+
errorEl.textContent = messages[0];
117+
errorMessages.push(`${fieldName}: ${messages[0]}`);
118+
}
119+
}
120+
121+
// Announce error summary to screen readers
122+
announcer.announce(
123+
`Form has ${errorMessages.length} error(s). ${errorMessages[0]}`,
124+
'assertive'
125+
);
126+
127+
// Focus the first invalid field
128+
const firstInvalid = form.querySelector('[aria-invalid="true"]');
129+
if (firstInvalid instanceof HTMLElement) {
130+
firstInvalid.focus();
131+
}
132+
}
133+
```
134+
135+
## Complete Example
136+
137+
Bringing all modules together into a working contact form modal.
138+
139+
```typescript
140+
import { FormValidator, FormSerializer } from '@zappzarapp/browser-utils/form';
141+
import { FocusTrap, FocusUtils } from '@zappzarapp/browser-utils/focus';
142+
import {
143+
ShortcutManager,
144+
KeyboardShortcut,
145+
} from '@zappzarapp/browser-utils/keyboard';
146+
import { AriaUtils, LiveAnnouncer } from '@zappzarapp/browser-utils/a11y';
147+
148+
// --- Elements ---
149+
const modal = document.getElementById('contact-modal')!;
150+
const form = modal.querySelector('form')! as HTMLFormElement;
151+
const trigger = document.getElementById('open-contact')!;
152+
153+
// --- ARIA setup ---
154+
AriaUtils.setRole(modal, 'dialog');
155+
AriaUtils.set(modal, 'modal', 'true');
156+
AriaUtils.set(modal, 'labelledby', 'modal-title');
157+
158+
// --- Modules ---
159+
const announcer = LiveAnnouncer.create();
160+
161+
const validator = FormValidator.create({
162+
name: { required: true, minLength: 2, maxLength: 100 },
163+
email: { required: true, email: true },
164+
subject: { required: true, minLength: 5 },
165+
message: { required: true, minLength: 10, maxLength: 2000 },
166+
});
167+
168+
const trap = FocusTrap.create(modal, {
169+
initialFocus: '#contact-name',
170+
returnFocus: true,
171+
escapeDeactivates: true,
172+
onEscapeDeactivate: () => closeModal(),
173+
});
174+
175+
// --- Open / Close ---
176+
function openModal(): void {
177+
modal.hidden = false;
178+
trap.activate();
179+
announcer.announce('Contact form opened.');
180+
}
181+
182+
function closeModal(): void {
183+
trap.deactivate();
184+
modal.hidden = true;
185+
announcer.announce('Contact form closed.');
186+
cleanupSubmitShortcut();
187+
}
188+
189+
trigger.addEventListener('click', openModal);
190+
191+
// --- Keyboard shortcut: Ctrl+Enter to submit ---
192+
const cleanupSubmitShortcut = ShortcutManager.on(
193+
KeyboardShortcut.create({ key: 'Enter', ctrlKey: true }),
194+
() => submitForm()
195+
);
196+
197+
// --- Real-time field validation ---
198+
const cleanupFieldChange = validator.onFieldChange(
199+
form,
200+
(fieldName, result) => {
201+
const field = form.elements.namedItem(fieldName) as HTMLElement | null;
202+
const errorEl = document.getElementById(`${fieldName}-error`);
203+
204+
if (!field || !errorEl) return;
205+
206+
if (result.valid) {
207+
AriaUtils.remove(field, 'invalid');
208+
errorEl.textContent = '';
209+
} else {
210+
AriaUtils.set(field, 'invalid', 'true');
211+
AriaUtils.set(field, 'describedby', errorEl.id);
212+
errorEl.textContent = result.errors[0];
213+
}
214+
}
215+
);
216+
217+
// --- Submit ---
218+
async function submitForm(): Promise<void> {
219+
const result = validator.validate(form);
220+
221+
if (!result.valid) {
222+
const count = Object.keys(result.errors).length;
223+
announcer.announce(
224+
`${count} validation error(s). Check the form.`,
225+
'assertive'
226+
);
227+
228+
const firstInvalid = form.querySelector('[aria-invalid="true"]');
229+
if (firstInvalid instanceof HTMLElement) firstInvalid.focus();
230+
return;
231+
}
232+
233+
const data = FormSerializer.toObject(form);
234+
announcer.announce('Submitting form...');
235+
236+
try {
237+
await fetch('/api/contact', {
238+
method: 'POST',
239+
headers: { 'Content-Type': 'application/json' },
240+
body: JSON.stringify(data),
241+
});
242+
announcer.announce('Message sent successfully.');
243+
closeModal();
244+
} catch {
245+
announcer.announce(
246+
'Failed to send message. Please try again.',
247+
'assertive'
248+
);
249+
}
250+
}
251+
252+
const cleanupSubmitHandler = validator.onSubmit(form, (result) => {
253+
if (result.valid) submitForm();
254+
});
255+
256+
// --- Cleanup (call when component unmounts) ---
257+
function destroy(): void {
258+
cleanupSubmitShortcut();
259+
cleanupFieldChange();
260+
cleanupSubmitHandler();
261+
announcer.destroy();
262+
trap.deactivate();
263+
}
264+
```
265+
266+
### Required HTML Structure
267+
268+
```html
269+
<button id="open-contact">Contact Us</button>
270+
271+
<div id="contact-modal" hidden>
272+
<h2 id="modal-title">Contact Us</h2>
273+
<form>
274+
<label for="contact-name">Name</label>
275+
<input id="contact-name" name="name" type="text" />
276+
<span id="name-error" role="alert"></span>
277+
278+
<label for="contact-email">Email</label>
279+
<input id="contact-email" name="email" type="email" />
280+
<span id="email-error" role="alert"></span>
281+
282+
<label for="contact-subject">Subject</label>
283+
<input id="contact-subject" name="subject" type="text" />
284+
<span id="subject-error" role="alert"></span>
285+
286+
<label for="contact-message">Message</label>
287+
<textarea id="contact-message" name="message" rows="5"></textarea>
288+
<span id="message-error" role="alert"></span>
289+
290+
<button type="submit">Send Message</button>
291+
</form>
292+
</div>
293+
```
294+
295+
## Testing Tips
296+
297+
- **Keyboard-only navigation:** Tab through the entire form without a mouse.
298+
Verify that focus stays trapped inside the modal and returns to the trigger
299+
button when closed.
300+
- **Screen reader:** Open the modal with VoiceOver, NVDA, or JAWS. Confirm that
301+
the dialog role, title, validation errors, and success/failure messages are
302+
announced.
303+
- **Escape key:** Press Escape to close the modal. Verify focus returns to the
304+
trigger element.
305+
- **Validation feedback:** Submit the form empty and confirm each field receives
306+
`aria-invalid="true"` and `aria-describedby` pointing to its error message.
307+
Verify the first invalid field receives focus.
308+
- **axe / Lighthouse:** Run an automated audit with
309+
[axe-core](https://github.com/dequelabs/axe-core) or Lighthouse to catch
310+
missing labels, contrast issues, and ARIA misuse.
311+
- **Ctrl+Enter:** Verify the keyboard shortcut submits the form from any focused
312+
field within the modal.

0 commit comments

Comments
 (0)