@zoyth/simple-site-framework 1.1.0 → 1.1.1
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/components/index.d.mts +38 -1
- package/dist/components/index.d.ts +38 -1
- package/dist/components/index.js +290 -59
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +289 -59
- package/dist/components/index.mjs.map +1 -1
- package/docs/api/README.md +26 -3
- package/docs/components/BlogIndex.md +229 -0
- package/docs/components/BlogLayout.md +222 -0
- package/docs/components/NewsletterSignup.md +275 -0
- package/docs/components/accessibility/SkipLink.md +146 -0
- package/package.json +1 -1
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# NewsletterSignup
|
|
2
|
+
|
|
3
|
+
Email capture form with multiple layout variants, size options, client-side validation, honeypot spam protection, and bilingual support (EN/FR).
|
|
4
|
+
|
|
5
|
+
## Import
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
import { NewsletterSignup } from '@zoyth/simple-site-framework';
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
**Type:** Client Component (`'use client'`)
|
|
12
|
+
|
|
13
|
+
## Basic Usage
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
<NewsletterSignup
|
|
17
|
+
onSubmit={async (data) => {
|
|
18
|
+
const res = await fetch('/api/subscribe', {
|
|
19
|
+
method: 'POST',
|
|
20
|
+
body: JSON.stringify(data),
|
|
21
|
+
});
|
|
22
|
+
const json = await res.json();
|
|
23
|
+
return { success: json.ok };
|
|
24
|
+
}}
|
|
25
|
+
/>
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Props
|
|
29
|
+
|
|
30
|
+
### Required Props
|
|
31
|
+
|
|
32
|
+
| Prop | Type | Description |
|
|
33
|
+
|------|------|-------------|
|
|
34
|
+
| `onSubmit` | `(data: NewsletterSubmitData) => Promise<NewsletterResponse>` | Async handler called on form submission. Receives `{ email: string; name?: string }` and must return `{ success: boolean; message?: string; error?: string }` |
|
|
35
|
+
|
|
36
|
+
### Optional Props
|
|
37
|
+
|
|
38
|
+
| Prop | Type | Default | Description |
|
|
39
|
+
|------|------|---------|-------------|
|
|
40
|
+
| `variant` | `'inline' \| 'stacked' \| 'minimal' \| 'card'` | `'stacked'` | Layout variant (see Variants below) |
|
|
41
|
+
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Controls text size and input/button padding |
|
|
42
|
+
| `showName` | `boolean` | `false` | Show a name input field (hidden in `minimal` variant) |
|
|
43
|
+
| `showPrivacy` | `boolean` | `false` | Show a privacy policy checkbox that must be accepted |
|
|
44
|
+
| `privacyUrl` | `string` | `'/privacy'` | URL the privacy policy link points to |
|
|
45
|
+
| `locale` | `string` | `'en'` | Current locale. Controls all default labels and validation messages |
|
|
46
|
+
| `buttonText` | `LocalizedString \| string` | `'Subscribe'` / `'S'abonner'` | Custom submit button text |
|
|
47
|
+
| `emailPlaceholder` | `LocalizedString \| string` | `'you@example.com'` / `'vous@exemple.com'` | Custom email placeholder |
|
|
48
|
+
| `namePlaceholder` | `LocalizedString \| string` | `'Your name'` / `'Votre nom'` | Custom name field placeholder |
|
|
49
|
+
| `successMessage` | `LocalizedString \| string` | `'Thank you for subscribing!'` / `'Merci pour votre inscription !'` | Custom success message |
|
|
50
|
+
| `className` | `string` | - | Additional CSS classes on the outer container |
|
|
51
|
+
|
|
52
|
+
### Type Definitions
|
|
53
|
+
|
|
54
|
+
```typescript
|
|
55
|
+
interface NewsletterSubmitData {
|
|
56
|
+
email: string;
|
|
57
|
+
name?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface NewsletterResponse {
|
|
61
|
+
success: boolean;
|
|
62
|
+
message?: string;
|
|
63
|
+
error?: string;
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Variants
|
|
68
|
+
|
|
69
|
+
### Stacked (Default)
|
|
70
|
+
|
|
71
|
+
Fields and button arranged vertically with full-width inputs and labels:
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
<NewsletterSignup
|
|
75
|
+
onSubmit={handleSubscribe}
|
|
76
|
+
variant="stacked"
|
|
77
|
+
/>
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**Use for:** Sidebar widgets, dedicated signup sections, anywhere with limited horizontal space.
|
|
81
|
+
|
|
82
|
+
### Inline
|
|
83
|
+
|
|
84
|
+
Fields and button arranged horizontally in a single row:
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
<NewsletterSignup
|
|
88
|
+
onSubmit={handleSubscribe}
|
|
89
|
+
variant="inline"
|
|
90
|
+
/>
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**Use for:** Footer bars, hero sections, compact horizontal layouts. Labels are replaced with `aria-label` attributes for accessibility.
|
|
94
|
+
|
|
95
|
+
### Minimal
|
|
96
|
+
|
|
97
|
+
Email field and button only, no labels displayed:
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
<NewsletterSignup
|
|
101
|
+
onSubmit={handleSubscribe}
|
|
102
|
+
variant="minimal"
|
|
103
|
+
/>
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The `showName` prop is ignored in this variant. Accessibility labels are provided via `aria-label`.
|
|
107
|
+
|
|
108
|
+
**Use for:** Compact spaces, inline CTAs, anywhere a lightweight form is needed.
|
|
109
|
+
|
|
110
|
+
### Card
|
|
111
|
+
|
|
112
|
+
Wrapped in a bordered card with padding and subtle shadow:
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
<NewsletterSignup
|
|
116
|
+
onSubmit={handleSubscribe}
|
|
117
|
+
variant="card"
|
|
118
|
+
/>
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
**Use for:** Standalone signup blocks, sidebar cards, visually distinct callouts.
|
|
122
|
+
|
|
123
|
+
## Sizes
|
|
124
|
+
|
|
125
|
+
| Size | Text | Input Padding | Button Padding |
|
|
126
|
+
|------|------|---------------|----------------|
|
|
127
|
+
| `sm` | `text-sm` | `px-3 py-1.5` | `px-4 py-1.5` |
|
|
128
|
+
| `md` | `text-base` | `px-4 py-2` | `px-6 py-2` |
|
|
129
|
+
| `lg` | `text-lg` | `px-5 py-3` | `px-8 py-3` |
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
<NewsletterSignup onSubmit={handleSubscribe} size="lg" />
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Localization
|
|
136
|
+
|
|
137
|
+
All default labels adapt based on the `locale` prop:
|
|
138
|
+
|
|
139
|
+
| Label | English (`'en'`) | French (`'fr'`) |
|
|
140
|
+
|-------|-------------------|------------------|
|
|
141
|
+
| Subscribe button | "Subscribe" | "S'abonner" |
|
|
142
|
+
| Email label | "Email address" | "Adresse courriel" |
|
|
143
|
+
| Name label | "Name" | "Nom" |
|
|
144
|
+
| Email placeholder | "you@example.com" | "vous@exemple.com" |
|
|
145
|
+
| Name placeholder | "Your name" | "Votre nom" |
|
|
146
|
+
| Email required error | "Please enter your email address." | "Veuillez entrer votre adresse courriel." |
|
|
147
|
+
| Email invalid error | "Please enter a valid email address." | "Veuillez entrer une adresse courriel valide." |
|
|
148
|
+
| Privacy required error | "You must accept the privacy policy." | "Vous devez accepter la politique de confidentialite." |
|
|
149
|
+
| Privacy text | "I agree to the" | "J'accepte la" |
|
|
150
|
+
| Privacy link | "privacy policy" | "politique de confidentialite" |
|
|
151
|
+
| Success message | "Thank you for subscribing!" | "Merci pour votre inscription !" |
|
|
152
|
+
| Generic error | "Something went wrong. Please try again." | "Une erreur est survenue. Veuillez reessayer." |
|
|
153
|
+
| Submitting state | "Subscribing..." | "Inscription..." |
|
|
154
|
+
|
|
155
|
+
## Examples
|
|
156
|
+
|
|
157
|
+
### With Name Field
|
|
158
|
+
|
|
159
|
+
```typescript
|
|
160
|
+
<NewsletterSignup
|
|
161
|
+
onSubmit={handleSubscribe}
|
|
162
|
+
showName={true}
|
|
163
|
+
/>
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### With Privacy Checkbox
|
|
167
|
+
|
|
168
|
+
```typescript
|
|
169
|
+
<NewsletterSignup
|
|
170
|
+
onSubmit={handleSubscribe}
|
|
171
|
+
showPrivacy={true}
|
|
172
|
+
privacyUrl="/en/privacy-policy"
|
|
173
|
+
/>
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
The form will not submit until the checkbox is checked.
|
|
177
|
+
|
|
178
|
+
### French Locale
|
|
179
|
+
|
|
180
|
+
```typescript
|
|
181
|
+
<NewsletterSignup
|
|
182
|
+
onSubmit={handleSubscribe}
|
|
183
|
+
locale="fr"
|
|
184
|
+
variant="card"
|
|
185
|
+
/>
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Custom Text
|
|
189
|
+
|
|
190
|
+
```typescript
|
|
191
|
+
<NewsletterSignup
|
|
192
|
+
onSubmit={handleSubscribe}
|
|
193
|
+
buttonText={{ en: 'Join the list', fr: 'Rejoindre la liste' }}
|
|
194
|
+
emailPlaceholder="enter@your.email"
|
|
195
|
+
successMessage={{ en: 'You are in!', fr: 'Vous etes inscrit !' }}
|
|
196
|
+
/>
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### Inline in Footer
|
|
200
|
+
|
|
201
|
+
```typescript
|
|
202
|
+
<footer className="bg-gray-900 text-white py-12">
|
|
203
|
+
<div className="max-w-4xl mx-auto">
|
|
204
|
+
<h3 className="text-lg font-semibold mb-4">Stay updated</h3>
|
|
205
|
+
<NewsletterSignup
|
|
206
|
+
onSubmit={handleSubscribe}
|
|
207
|
+
variant="inline"
|
|
208
|
+
size="sm"
|
|
209
|
+
/>
|
|
210
|
+
</div>
|
|
211
|
+
</footer>
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### Card with All Options
|
|
215
|
+
|
|
216
|
+
```typescript
|
|
217
|
+
<NewsletterSignup
|
|
218
|
+
onSubmit={handleSubscribe}
|
|
219
|
+
variant="card"
|
|
220
|
+
size="lg"
|
|
221
|
+
showName={true}
|
|
222
|
+
showPrivacy={true}
|
|
223
|
+
privacyUrl="/privacy"
|
|
224
|
+
locale="en"
|
|
225
|
+
buttonText="Get Updates"
|
|
226
|
+
emailPlaceholder="your@email.com"
|
|
227
|
+
namePlaceholder="First name"
|
|
228
|
+
successMessage="Welcome aboard!"
|
|
229
|
+
/>
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
## Validation
|
|
233
|
+
|
|
234
|
+
The component performs client-side validation before calling `onSubmit`:
|
|
235
|
+
|
|
236
|
+
1. **Empty email** - shows "Please enter your email address."
|
|
237
|
+
2. **Invalid email format** - uses the regex `/^[^\s@]+@[^\s@]+\.[^\s@]+$/` and shows "Please enter a valid email address."
|
|
238
|
+
3. **Privacy checkbox** - if `showPrivacy` is `true` and unchecked, shows "You must accept the privacy policy."
|
|
239
|
+
|
|
240
|
+
Validation messages are localized based on the `locale` prop.
|
|
241
|
+
|
|
242
|
+
## Spam Protection
|
|
243
|
+
|
|
244
|
+
The component includes a hidden honeypot field. If a bot fills it in, the form silently does nothing on submit (no error, no API call). The honeypot field is:
|
|
245
|
+
|
|
246
|
+
- Hidden via CSS (`className="hidden"`)
|
|
247
|
+
- Marked with `aria-hidden="true"`
|
|
248
|
+
- Uses `tabIndex={-1}` to prevent keyboard focus
|
|
249
|
+
- Has `autoComplete="off"` to prevent browser autofill
|
|
250
|
+
|
|
251
|
+
## Form States
|
|
252
|
+
|
|
253
|
+
The component manages four internal states:
|
|
254
|
+
|
|
255
|
+
| State | Behavior |
|
|
256
|
+
|-------|----------|
|
|
257
|
+
| `idle` | Default state. Form is interactive |
|
|
258
|
+
| `submitting` | Inputs and button are disabled. Button text changes to "Subscribing..." |
|
|
259
|
+
| `success` | Form is replaced with the success message (rendered as a `role="status"` paragraph) |
|
|
260
|
+
| `error` | Error message appears below the form as a `role="alert"` paragraph |
|
|
261
|
+
|
|
262
|
+
## Accessibility
|
|
263
|
+
|
|
264
|
+
- Email input has `required` attribute
|
|
265
|
+
- Labels are rendered for `stacked` and `card` variants
|
|
266
|
+
- `aria-label` attributes are used for `inline` and `minimal` variants where visual labels are omitted
|
|
267
|
+
- Error messages use `role="alert"` for screen reader announcement
|
|
268
|
+
- Success message uses `role="status"`
|
|
269
|
+
- All inputs respect `disabled` state during submission
|
|
270
|
+
|
|
271
|
+
## See Also
|
|
272
|
+
|
|
273
|
+
- **[Button](./ui/Button.md)** - Button component used throughout the framework
|
|
274
|
+
- **[ContactSection](./sections/ContactSection.md)** - Full contact form section (for more complex form needs)
|
|
275
|
+
- **[BlogIndex](./BlogIndex.md)** - Blog listing page where a newsletter signup might be placed
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# SkipLink
|
|
2
|
+
|
|
3
|
+
Accessible skip navigation link for keyboard users. Hidden by default, it appears when focused via Tab and allows users to bypass repetitive navigation elements and jump directly to the main content area.
|
|
4
|
+
|
|
5
|
+
## Import
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
import { SkipLink } from '@zoyth/simple-site-framework';
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
**Type:** Client Component (`'use client'`)
|
|
12
|
+
|
|
13
|
+
**Source:** `src/components/a11y/SkipLink.tsx`
|
|
14
|
+
|
|
15
|
+
## Basic Usage
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
// In your root layout, before the header
|
|
19
|
+
<SkipLink href="#main-content">Skip to main content</SkipLink>
|
|
20
|
+
<Header />
|
|
21
|
+
<main id="main-content" tabIndex={-1}>
|
|
22
|
+
{children}
|
|
23
|
+
</main>
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Props
|
|
27
|
+
|
|
28
|
+
### Required Props
|
|
29
|
+
|
|
30
|
+
| Prop | Type | Description |
|
|
31
|
+
|------|------|-------------|
|
|
32
|
+
| `href` | `string` | CSS selector for the target element to skip to (e.g., `"#main-content"`) |
|
|
33
|
+
| `children` | `ReactNode` | Link text displayed when the component is focused |
|
|
34
|
+
|
|
35
|
+
### Optional Props
|
|
36
|
+
|
|
37
|
+
| Prop | Type | Default | Description |
|
|
38
|
+
|------|------|---------|-------------|
|
|
39
|
+
| `className` | `string` | - | Additional CSS classes merged with the default styles |
|
|
40
|
+
|
|
41
|
+
## How It Works
|
|
42
|
+
|
|
43
|
+
1. The link is positioned absolutely at the top-left of the page and translated off-screen (`-translate-y-full`).
|
|
44
|
+
2. When a keyboard user presses Tab, the link receives focus and transitions into view (`focus:translate-y-0`).
|
|
45
|
+
3. On click, the component prevents the default anchor behavior and instead:
|
|
46
|
+
- Finds the target element using `document.querySelector(href)`
|
|
47
|
+
- Calls `.focus()` on the target element
|
|
48
|
+
- Scrolls the target into view with `{ behavior: 'smooth', block: 'start' }`
|
|
49
|
+
|
|
50
|
+
This approach works even when the target element is not natively focusable, as long as it has `tabIndex={-1}`.
|
|
51
|
+
|
|
52
|
+
## Examples
|
|
53
|
+
|
|
54
|
+
### Standard Layout Integration
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
export default function RootLayout({ children }) {
|
|
58
|
+
return (
|
|
59
|
+
<html>
|
|
60
|
+
<body>
|
|
61
|
+
<SkipLink href="#main-content">Skip to main content</SkipLink>
|
|
62
|
+
<Header />
|
|
63
|
+
<main id="main-content" tabIndex={-1}>
|
|
64
|
+
{children}
|
|
65
|
+
</main>
|
|
66
|
+
<Footer />
|
|
67
|
+
</body>
|
|
68
|
+
</html>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### French Label
|
|
74
|
+
|
|
75
|
+
```typescript
|
|
76
|
+
<SkipLink href="#contenu-principal">
|
|
77
|
+
Aller au contenu principal
|
|
78
|
+
</SkipLink>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Multiple Skip Links
|
|
82
|
+
|
|
83
|
+
For pages with complex navigation, you can provide multiple skip links:
|
|
84
|
+
|
|
85
|
+
```typescript
|
|
86
|
+
<div>
|
|
87
|
+
<SkipLink href="#main-content">Skip to main content</SkipLink>
|
|
88
|
+
<SkipLink href="#sidebar">Skip to sidebar</SkipLink>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
{/* Later in the page */}
|
|
92
|
+
<main id="main-content" tabIndex={-1}>...</main>
|
|
93
|
+
<aside id="sidebar" tabIndex={-1}>...</aside>
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Custom Styling
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
<SkipLink
|
|
100
|
+
href="#main-content"
|
|
101
|
+
className="text-lg rounded-br-lg"
|
|
102
|
+
>
|
|
103
|
+
Skip to main content
|
|
104
|
+
</SkipLink>
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Default Styles
|
|
108
|
+
|
|
109
|
+
The component applies these styles by default:
|
|
110
|
+
|
|
111
|
+
| State | Styles |
|
|
112
|
+
|-------|--------|
|
|
113
|
+
| Hidden (default) | `absolute left-0 top-0 z-50 -translate-y-full` |
|
|
114
|
+
| Visible (focused) | `focus:translate-y-0` with `transition-transform` |
|
|
115
|
+
| Visual | `bg-primary text-white px-4 py-2 font-medium` |
|
|
116
|
+
| Focus ring | `focus:ring-2 focus:ring-offset-2 focus:ring-primary` with `focus:outline-none` |
|
|
117
|
+
|
|
118
|
+
Custom classes passed via `className` are merged using the `cn()` utility, so they can override any default.
|
|
119
|
+
|
|
120
|
+
## Target Element Setup
|
|
121
|
+
|
|
122
|
+
The target element must:
|
|
123
|
+
|
|
124
|
+
1. Have an `id` that matches the `href` value (without the `#`)
|
|
125
|
+
2. Have `tabIndex={-1}` so it can receive programmatic focus
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
{/* Correct */}
|
|
129
|
+
<main id="main-content" tabIndex={-1}>
|
|
130
|
+
|
|
131
|
+
{/* The href must include the # */}
|
|
132
|
+
<SkipLink href="#main-content">Skip to main content</SkipLink>
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Accessibility
|
|
136
|
+
|
|
137
|
+
- Meets WCAG 2.1 Success Criterion 2.4.1 (Bypass Blocks)
|
|
138
|
+
- Only visible to keyboard users (hidden from mouse/touch users)
|
|
139
|
+
- Uses semantic `<a>` element
|
|
140
|
+
- Focus ring provides clear visual indication
|
|
141
|
+
- Smooth scroll provides orientation context after the skip
|
|
142
|
+
|
|
143
|
+
## See Also
|
|
144
|
+
|
|
145
|
+
- **[Header](./layout/Header.md)** - Site header that the skip link typically bypasses
|
|
146
|
+
- **[PageLayout](./layout/PageLayout.md)** - Standard page wrapper where skip links are commonly placed
|