@struq/skill 0.3.0 → 0.4.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/README.md +22 -36
- package/package.json +4 -2
- package/skills/struq/SKILL.md +139 -307
- package/skills/struq/blocks/CATALOG.md +426 -0
- package/skills/struq/blocks/backgrounds/background-aurora.tsx +818 -0
- package/skills/struq/blocks/backgrounds/background-facets.tsx +1283 -0
- package/skills/struq/blocks/backgrounds/background-silk.tsx +906 -0
- package/skills/struq/blocks/catalog.json +1302 -0
- package/skills/struq/blocks/footers/footer-contact.tsx +789 -0
- package/skills/struq/blocks/footers/footer-reveal.tsx +313 -0
- package/skills/struq/blocks/headers/header-bar.tsx +652 -0
- package/skills/struq/blocks/headers/menu-curtain.tsx +790 -0
- package/skills/struq/blocks/heroes/hero-framed.tsx +627 -0
- package/skills/struq/blocks/heroes/hero-index.tsx +855 -0
- package/skills/struq/blocks/heroes/hero-poster.tsx +1086 -0
- package/skills/struq/blocks/heroes/hero-split.tsx +685 -0
- package/skills/struq/blocks/heroes/hero-still.tsx +862 -0
- package/skills/struq/{recipes/components/struq → blocks/kit}/Cursor.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/HeroCanvas.tsx +4 -4
- package/skills/struq/{recipes/components/struq → blocks/kit}/HorizontalGallery.tsx +2 -2
- package/skills/struq/{recipes/components/struq → blocks/kit}/MagneticButton.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/MediaFrame.tsx +2 -2
- package/skills/struq/{recipes/components/struq → blocks/kit}/PinnedZoom.tsx +14 -4
- package/skills/struq/{recipes/components/struq → blocks/kit}/Reveal.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/SmoothScroll.tsx +1 -1
- package/skills/struq/{recipes/components/struq → blocks/kit}/SplitHeadline.tsx +1 -1
- package/skills/struq/blocks/kit/blocks.css +2102 -0
- package/skills/struq/blocks/kit/images.ts +17 -0
- package/skills/struq/blocks/kit/photo-read.ts +102 -0
- package/skills/struq/blocks/page-transitions/page-transition-gate.tsx +706 -0
- package/skills/struq/blocks/page-transitions/page-transition-slices.tsx +469 -0
- package/skills/struq/blocks/page-transitions/page-transition-stack.tsx +583 -0
- package/skills/struq/blocks/preloaders/preloader-gate.tsx +358 -0
- package/skills/struq/blocks/preloaders/preloader-slices-uncovered.ts +50 -0
- package/skills/struq/blocks/preloaders/preloader-slices.tsx +289 -0
- package/skills/struq/blocks/preloaders/preloader-stack.tsx +367 -0
- package/skills/struq/blocks/preloaders/preloader-wordmark.tsx +568 -0
- package/skills/struq/blocks/sections/about-studio.tsx +436 -0
- package/skills/struq/blocks/sections/approach.tsx +661 -0
- package/skills/struq/blocks/sections/contact-form.tsx +664 -0
- package/skills/struq/blocks/sections/cta-closing.tsx +763 -0
- package/skills/struq/blocks/sections/fanned-cards.tsx +547 -0
- package/skills/struq/blocks/sections/film-strip.tsx +385 -0
- package/skills/struq/blocks/sections/grid-assembly.tsx +616 -0
- package/skills/struq/blocks/sections/hover-list.tsx +467 -0
- package/skills/struq/blocks/sections/image-trail.tsx +432 -0
- package/skills/struq/blocks/sections/manifesto.tsx +691 -0
- package/skills/struq/blocks/sections/panel-accordion.tsx +380 -0
- package/skills/struq/blocks/sections/reveals.tsx +416 -0
- package/skills/struq/blocks/sections/split-pair.tsx +670 -0
- package/skills/struq/blocks/sections/sticky-chapters.tsx +256 -0
- package/skills/struq/blocks/sections/velocity-type.tsx +269 -0
- package/skills/struq/{cinematic/showpieces/toonstuk-fill-rows.tsx → blocks/showpieces/fill-rows.tsx} +1 -1
- package/skills/struq/{cinematic/showpieces/toonstuk-hero-opener.tsx → blocks/showpieces/hero-opener.tsx} +5 -5
- package/skills/struq/{cinematic/showpieces/toonstuk-image-to-fullbleed.tsx → blocks/showpieces/image-to-fullbleed.tsx} +16 -5
- package/skills/struq/blocks/showpieces/manifesto-scrub.tsx +48 -0
- package/skills/struq/{cinematic/showpieces/toonstuk-panel-wipe.tsx → blocks/showpieces/panel-wipe.tsx} +2 -2
- package/skills/struq/{cinematic/showpieces/toonstuk-pixel-resolve.tsx → blocks/showpieces/pixel-resolve.tsx} +1 -1
- package/skills/struq/{cinematic/showpieces/toonstuk-project-stack.tsx → blocks/showpieces/project-stack.tsx} +2 -2
- package/skills/struq/blocks/showpieces/scrub-connector.tsx +307 -0
- package/skills/struq/{cinematic/showpieces/toonstuk-wordmark-finale.tsx → blocks/showpieces/wordmark-finale.tsx} +1 -1
- package/skills/struq/blocks/transitions/zoom-through.tsx +613 -0
- package/skills/struq/canon/canon.json +627 -0
- package/skills/struq/cinematic/README.md +7 -18
- package/skills/struq/cinematic/examples/code/nextjs/app/components/LenisRoot.tsx +1 -1
- package/skills/struq/cinematic/examples/code/shared/styles.css +1 -1
- package/skills/struq/cinematic/examples/code/vite/src/LenisRoot.tsx +1 -1
- package/skills/struq/cinematic/references/01-core-mental-model.md +1 -1
- package/skills/struq/cinematic/references/02-vocabulary.md +1 -1
- package/skills/struq/cinematic/references/07-stack-integration.md +2 -2
- package/skills/struq/recipes/app/fonts.ts +12 -22
- package/skills/struq/recipes/app/globals.css +34 -177
- package/skills/struq/recipes/app/layout.tsx +13 -11
- package/skills/struq/references/craft.md +77 -0
- package/skills/struq/references/iconography.md +28 -230
- package/skills/struq/references/stack-targets.md +23 -74
- package/skills/struq/references/tells.md +52 -0
- package/skills/struq/references/triage.md +6 -7
- package/skills/struq/scripts/blocks.mjs +156 -0
- package/skills/struq/scripts/canon.mjs +106 -281
- package/skills/struq/scripts/check.mjs +92 -126
- package/skills/struq/scripts/images.mjs +2 -2
- package/skills/struq/scripts/log.mjs +6 -23
- package/skills/struq/scripts/tokens.mjs +222 -0
- package/skills/struq/cinematic/showpieces/README.md +0 -18
- package/skills/struq/cinematic/showpieces/toonstuk-manifesto-scrub.tsx +0 -81
- package/skills/struq/cinematic/showpieces/toonstuk-scrub-connector.tsx +0 -78
- package/skills/struq/references/anti-patterns.md +0 -330
- package/skills/struq/references/baseline-prompt.md +0 -122
- package/skills/struq/references/brand.md +0 -143
- package/skills/struq/references/brief-router.md +0 -218
- package/skills/struq/references/composition.md +0 -190
- package/skills/struq/references/depth-parallax.md +0 -235
- package/skills/struq/references/direction.md +0 -188
- package/skills/struq/references/feel-layer.md +0 -107
- package/skills/struq/references/gsap-api.md +0 -293
- package/skills/struq/references/hero-scenes.md +0 -107
- package/skills/struq/references/imagery.md +0 -125
- package/skills/struq/references/loaders.md +0 -287
- package/skills/struq/references/micro-interactions.md +0 -270
- package/skills/struq/references/mobile-composition.md +0 -70
- package/skills/struq/references/nav-header.md +0 -296
- package/skills/struq/references/niche-packets.md +0 -338
- package/skills/struq/references/page-architecture.md +0 -134
- package/skills/struq/references/page-entrances.md +0 -198
- package/skills/struq/references/performance.md +0 -235
- package/skills/struq/references/pinning-scrub.md +0 -340
- package/skills/struq/references/pitfalls.md +0 -290
- package/skills/struq/references/route-transitions.md +0 -281
- package/skills/struq/references/scaling.md +0 -151
- package/skills/struq/references/scroll-reveals.md +0 -279
- package/skills/struq/references/sections.md +0 -141
- package/skills/struq/references/seo.md +0 -219
- package/skills/struq/references/setup.md +0 -397
- package/skills/struq/references/showcase.md +0 -87
- package/skills/struq/references/struq-imagery.md +0 -107
- package/skills/struq/references/svg-motion.md +0 -390
- package/skills/struq/references/transitions.md +0 -160
- package/skills/struq/references/typography.md +0 -720
- package/skills/struq/references/vibe-presets.md +0 -287
- package/skills/struq/references/webgl-r3f.md +0 -274
- package/skills/struq/references/webgl-scenes.md +0 -593
- package/skills/struq/scripts/font-names.mjs +0 -40
- package/skills/struq/scripts/recipes.mjs +0 -135
- /package/skills/struq/{recipes/lib → blocks/kit}/gsap.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/contours.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/dots.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/ripple.ts +0 -0
- /package/skills/struq/{recipes/components/struq → blocks/kit}/scenes/types.ts +0 -0
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// contact-form (Struq section recipe)
|
|
3
|
+
// The one place a studio site asks for input: a name, an e-mail address, a short "Waar gaat het over?" and one
|
|
4
|
+
// optional choice (real radio inputs, styled as quiet rings). One submit, a text link with an arrow, never a filled
|
|
5
|
+
// button. Three compositions:
|
|
6
|
+
//
|
|
7
|
+
// - lines: a display line and a short note on the left, the fields on the right. Each label rests inside its field and
|
|
8
|
+
// lifts to a small label above it when the field is focused or filled; each field is only a baseline rule that draws
|
|
9
|
+
// in the accent from the left on focus (and draws back the way it came on leaving).
|
|
10
|
+
// - sentence: the form is a sentence the reader completes ("Ik ben [naam], bereikbaar op [e-mail], en ik wil het
|
|
11
|
+
// hebben over [onderwerp]"), the blanks inline at display size in a light weight, each growing with what is typed.
|
|
12
|
+
// Below 768 px the clauses stack, each fragment the label above its own field, in the same voice.
|
|
13
|
+
// - steps: one question at a time at display size. Enter or "Volgende" moves on, "Terug" goes back, the answers so far
|
|
14
|
+
// stay small above the question, a thin rule fills as the questions are answered. Every move puts the focus in the
|
|
15
|
+
// new question's field, so a screen reader hears the question as it arrives.
|
|
16
|
+
//
|
|
17
|
+
// <ContactForm variant="lines" action="/contact" onSubmit={(values) => fetch(...)} email="studio@noord.nl" />
|
|
18
|
+
//
|
|
19
|
+
// Every state is designed: rest, focus, typing, filled, invalid (said in plain Dutch beside the field once the reader
|
|
20
|
+
// leaves it or submits, never while typing; it clears as soon as the field is right), sending (the arrow travels, the
|
|
21
|
+
// fields are read-only), sent (the form gives way to a thank-you in the same place, which takes the focus) and failed
|
|
22
|
+
// (everything typed stays, the button says "Opnieuw versturen"). Every message has a slot of its own from the start,
|
|
23
|
+
// and the thank-you shares the form's cell, so no state moves the page.
|
|
24
|
+
//
|
|
25
|
+
// Without scripts it is a plain form: `action` and `method` post it, `required` and `type="email"` validate it
|
|
26
|
+
// natively, the steps stand stacked. The block itself never touches the network; `onSubmit` returns a promise and the
|
|
27
|
+
// block only reads whether it resolved. All motion is CSS transitions (blocks.css, "--- contact-form ---"), so an
|
|
28
|
+
// overtaken move reverses from where it is. Reduced motion: the same states as cuts and short fades.
|
|
29
|
+
import {
|
|
30
|
+
useEffect,
|
|
31
|
+
useId,
|
|
32
|
+
useRef,
|
|
33
|
+
useState,
|
|
34
|
+
useSyncExternalStore,
|
|
35
|
+
type ChangeEvent,
|
|
36
|
+
type FormEvent,
|
|
37
|
+
type KeyboardEvent,
|
|
38
|
+
type ReactNode,
|
|
39
|
+
} from "react";
|
|
40
|
+
|
|
41
|
+
export const CONTACT_FORM_VARIANTS = [
|
|
42
|
+
{ id: "lines", title: "Lijnen" },
|
|
43
|
+
{ id: "sentence", title: "Zin" },
|
|
44
|
+
{ id: "steps", title: "Stappen" },
|
|
45
|
+
] as const;
|
|
46
|
+
|
|
47
|
+
export type ContactFormVariant = (typeof CONTACT_FORM_VARIANTS)[number]["id"];
|
|
48
|
+
|
|
49
|
+
export type ContactFormValues = { naam: string; email: string; onderwerp: string; soort: string };
|
|
50
|
+
|
|
51
|
+
type Props = {
|
|
52
|
+
variant?: ContactFormVariant;
|
|
53
|
+
/** Where the form posts without scripts (and with scripts when no `onSubmit` is given). */
|
|
54
|
+
action: string;
|
|
55
|
+
method?: "post" | "get";
|
|
56
|
+
/** Sends the message. Resolve for sent, reject for failed; the block never calls the network itself. */
|
|
57
|
+
onSubmit?: (values: ContactFormValues) => Promise<unknown>;
|
|
58
|
+
/** lines: the display line beside the form. sentence and steps: the section's name for screen readers. */
|
|
59
|
+
title?: string;
|
|
60
|
+
/** lines: the short note under the display line. */
|
|
61
|
+
intro?: string;
|
|
62
|
+
/** lines: an address to mail instead. */
|
|
63
|
+
email?: string;
|
|
64
|
+
/** The optional choice. An empty list leaves it out. */
|
|
65
|
+
choices?: string[];
|
|
66
|
+
/** The thank-you's second line: what happens next. */
|
|
67
|
+
next?: string;
|
|
68
|
+
className?: string;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
type Field = "naam" | "email" | "onderwerp";
|
|
72
|
+
type Status = "idle" | "sending" | "sent" | "failed";
|
|
73
|
+
|
|
74
|
+
const FIELDS: Field[] = ["naam", "email", "onderwerp"];
|
|
75
|
+
const STEPS = ["naam", "email", "onderwerp", "soort"] as const;
|
|
76
|
+
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
|
|
77
|
+
const DEFAULT_CHOICES = ["Nieuwbouw", "Verbouwing", "Interieur", "Iets anders"];
|
|
78
|
+
|
|
79
|
+
/** What is wrong with a field, in plain Dutch, or null when it is right. */
|
|
80
|
+
function check(field: Field, v: ContactFormValues): string | null {
|
|
81
|
+
const s = v[field].trim();
|
|
82
|
+
if (field === "naam") return s ? null : "Vul je naam in.";
|
|
83
|
+
if (field === "email") {
|
|
84
|
+
if (!s) return "Vul je e-mailadres in.";
|
|
85
|
+
return EMAIL.test(s) ? null : "Dit lijkt nog geen e-mailadres.";
|
|
86
|
+
}
|
|
87
|
+
return s ? null : "Vertel kort waar het over gaat.";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// True once the block has hydrated: until then the form is the plain, natively validated one.
|
|
91
|
+
const noop = () => () => {};
|
|
92
|
+
const useLive = () =>
|
|
93
|
+
useSyncExternalStore(
|
|
94
|
+
noop,
|
|
95
|
+
() => true,
|
|
96
|
+
() => false,
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
/** A field that grows with its text: a hidden copy of the value sizes the cell the field fills. */
|
|
100
|
+
function Grow({
|
|
101
|
+
text,
|
|
102
|
+
multiline = false,
|
|
103
|
+
block = false,
|
|
104
|
+
children,
|
|
105
|
+
}: {
|
|
106
|
+
text: string;
|
|
107
|
+
multiline?: boolean;
|
|
108
|
+
block?: boolean;
|
|
109
|
+
children: ReactNode;
|
|
110
|
+
}) {
|
|
111
|
+
return (
|
|
112
|
+
<span
|
|
113
|
+
data-cf-grow=""
|
|
114
|
+
data-multiline={multiline ? "" : undefined}
|
|
115
|
+
data-block={block ? "" : undefined}
|
|
116
|
+
data-mirror={text}
|
|
117
|
+
>
|
|
118
|
+
{children}
|
|
119
|
+
<span aria-hidden data-cf-rule="" />
|
|
120
|
+
</span>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const Arrow = ({ back = false }: { back?: boolean }) => (
|
|
125
|
+
<svg
|
|
126
|
+
aria-hidden
|
|
127
|
+
viewBox="0 0 24 24"
|
|
128
|
+
fill="none"
|
|
129
|
+
stroke="currentColor"
|
|
130
|
+
strokeWidth="1.25"
|
|
131
|
+
strokeLinecap="round"
|
|
132
|
+
strokeLinejoin="round"
|
|
133
|
+
className={`h-[0.72em] w-[0.72em] shrink-0 ${back ? "rotate-180" : ""}`}
|
|
134
|
+
>
|
|
135
|
+
<path d="M3 12h17M14 5.5l6.5 6.5-6.5 6.5" />
|
|
136
|
+
</svg>
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
export function ContactForm({
|
|
140
|
+
variant = "lines",
|
|
141
|
+
action,
|
|
142
|
+
method = "post",
|
|
143
|
+
onSubmit,
|
|
144
|
+
title = "Vertel ons over je plek.",
|
|
145
|
+
intro = "Een huis, een verbouwing, een winkel die beter kan. Schrijf in een paar zinnen wat je voor ogen hebt; we lezen alles zelf.",
|
|
146
|
+
email,
|
|
147
|
+
choices = DEFAULT_CHOICES,
|
|
148
|
+
next = "We reageren binnen twee werkdagen.",
|
|
149
|
+
className = "",
|
|
150
|
+
}: Props) {
|
|
151
|
+
const uid = useId();
|
|
152
|
+
const id = (part: string) => `${uid}-${part}`;
|
|
153
|
+
const live = useLive();
|
|
154
|
+
const [values, setValues] = useState<ContactFormValues>({ naam: "", email: "", onderwerp: "", soort: "" });
|
|
155
|
+
const [shown, setShown] = useState<Partial<Record<Field, string>>>({});
|
|
156
|
+
const [status, setStatus] = useState<Status>("idle");
|
|
157
|
+
const [step, setStep] = useState(0);
|
|
158
|
+
const dirty = useRef(new Set<Field>());
|
|
159
|
+
// Every send carries an id; a result only lands while it is still the current one (and the block still mounted).
|
|
160
|
+
const attempt = useRef(0);
|
|
161
|
+
const focusNext = useRef<string | null>(null);
|
|
162
|
+
const formRef = useRef<HTMLFormElement>(null);
|
|
163
|
+
const thanksRef = useRef<HTMLDivElement>(null);
|
|
164
|
+
const sending = status === "sending";
|
|
165
|
+
const last = STEPS.length - 1;
|
|
166
|
+
const hasChoices = choices.length > 0;
|
|
167
|
+
|
|
168
|
+
// The focus moves after the state it waits for has been drawn: a step made current, the thank-you shown.
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
const target = focusNext.current;
|
|
171
|
+
if (!target) return;
|
|
172
|
+
focusNext.current = null;
|
|
173
|
+
if (target === "thanks") thanksRef.current?.focus({ preventScroll: true });
|
|
174
|
+
else document.getElementById(target)?.focus({ preventScroll: variant === "steps" });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
useEffect(
|
|
178
|
+
() => () => {
|
|
179
|
+
attempt.current += 1;
|
|
180
|
+
},
|
|
181
|
+
[],
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
function change(field: keyof ContactFormValues, value: string) {
|
|
185
|
+
if (sending) return;
|
|
186
|
+
const nextValues = { ...values, [field]: value };
|
|
187
|
+
setValues(nextValues);
|
|
188
|
+
if (field === "soort") return;
|
|
189
|
+
dirty.current.add(field);
|
|
190
|
+
// While typing a message only ever goes away, the moment the field is right; it is never added.
|
|
191
|
+
if (shown[field] && !check(field, nextValues)) setShown((s) => ({ ...s, [field]: undefined }));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function leave(field: Field) {
|
|
195
|
+
if (!dirty.current.has(field) && !shown[field]) return;
|
|
196
|
+
const msg = check(field, values) ?? undefined;
|
|
197
|
+
setShown((s) => (s[field] === msg ? s : { ...s, [field]: msg }));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const soortTarget = () => {
|
|
201
|
+
const i = choices.indexOf(values.soort);
|
|
202
|
+
return id(`soort-${i < 0 ? 0 : i}`);
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
function go(to: number) {
|
|
206
|
+
setStep(to);
|
|
207
|
+
focusNext.current = STEPS[to] === "soort" ? soortTarget() : id(STEPS[to]);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function advance() {
|
|
211
|
+
if (sending) return;
|
|
212
|
+
const key = STEPS[step];
|
|
213
|
+
if (key !== "soort") {
|
|
214
|
+
const msg = check(key, values);
|
|
215
|
+
if (msg) {
|
|
216
|
+
setShown((s) => ({ ...s, [key]: msg }));
|
|
217
|
+
focusNext.current = id(key);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (step >= last || (STEPS[step + 1] === "soort" && !hasChoices)) {
|
|
222
|
+
formRef.current?.requestSubmit();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
go(step + 1);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function stepKey(e: KeyboardEvent<HTMLElement>) {
|
|
229
|
+
if (e.key !== "Enter" || e.shiftKey || e.nativeEvent.isComposing) return;
|
|
230
|
+
e.preventDefault();
|
|
231
|
+
advance();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function submit(e: FormEvent<HTMLFormElement>) {
|
|
235
|
+
if (sending) {
|
|
236
|
+
e.preventDefault();
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const errors: Partial<Record<Field, string>> = {};
|
|
240
|
+
for (const f of FIELDS) errors[f] = check(f, values) ?? undefined;
|
|
241
|
+
const first = FIELDS.find((f) => errors[f]);
|
|
242
|
+
if (first) {
|
|
243
|
+
e.preventDefault();
|
|
244
|
+
setShown(errors);
|
|
245
|
+
if (variant === "steps") setStep(STEPS.indexOf(first));
|
|
246
|
+
focusNext.current = id(first);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
// Without a handler the browser posts the form to `action`, as it does without scripts.
|
|
250
|
+
if (!onSubmit) return;
|
|
251
|
+
e.preventDefault();
|
|
252
|
+
const mine = ++attempt.current;
|
|
253
|
+
setShown({});
|
|
254
|
+
setStatus("sending");
|
|
255
|
+
try {
|
|
256
|
+
await onSubmit({
|
|
257
|
+
naam: values.naam.trim(),
|
|
258
|
+
email: values.email.trim(),
|
|
259
|
+
onderwerp: values.onderwerp.trim(),
|
|
260
|
+
soort: values.soort,
|
|
261
|
+
});
|
|
262
|
+
if (mine !== attempt.current) return;
|
|
263
|
+
focusNext.current = "thanks";
|
|
264
|
+
setStatus("sent");
|
|
265
|
+
} catch {
|
|
266
|
+
if (mine !== attempt.current) return;
|
|
267
|
+
setStatus("failed");
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const fieldProps = (f: Field) => ({
|
|
272
|
+
id: id(f),
|
|
273
|
+
name: f,
|
|
274
|
+
value: values[f],
|
|
275
|
+
required: true,
|
|
276
|
+
readOnly: sending,
|
|
277
|
+
"aria-invalid": shown[f] ? true : undefined,
|
|
278
|
+
"aria-describedby": shown[f] ? id(`${f}-msg`) : undefined,
|
|
279
|
+
onChange: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => change(f, e.target.value),
|
|
280
|
+
onBlur: () => leave(f),
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const naamProps = { ...fieldProps("naam"), type: "text", autoComplete: "name", autoCapitalize: "words" } as const;
|
|
284
|
+
const emailProps = {
|
|
285
|
+
...fieldProps("email"),
|
|
286
|
+
type: "email",
|
|
287
|
+
inputMode: "email",
|
|
288
|
+
autoComplete: "email",
|
|
289
|
+
autoCapitalize: "off",
|
|
290
|
+
spellCheck: false,
|
|
291
|
+
} as const;
|
|
292
|
+
const onderwerpProps = { ...fieldProps("onderwerp"), rows: 1, cols: 1 } as const;
|
|
293
|
+
|
|
294
|
+
const msg = (f: Field) => (
|
|
295
|
+
<span id={id(`${f}-msg`)} data-cf-msg="">
|
|
296
|
+
{shown[f] ?? ""}
|
|
297
|
+
</span>
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
const firstName = values.naam.trim().split(/\s+/)[0] ?? "";
|
|
301
|
+
|
|
302
|
+
// The link-style button: large type, an arrow, a faint line at rest and a full one that draws on hover or focus.
|
|
303
|
+
const linkButton = (label: string, props: { type: "submit" | "button"; onClick?: () => void; send?: boolean }) => (
|
|
304
|
+
<button
|
|
305
|
+
type={props.type}
|
|
306
|
+
onClick={props.onClick}
|
|
307
|
+
data-cf-link=""
|
|
308
|
+
data-cf-send={props.send ? "" : undefined}
|
|
309
|
+
aria-disabled={sending || undefined}
|
|
310
|
+
className="group/cf relative inline-flex items-center gap-[0.42em] py-[0.2em] font-display text-[clamp(1.5rem,1.1rem+1.2vw,2.375rem)] leading-[1.1] tracking-[-0.01em]"
|
|
311
|
+
>
|
|
312
|
+
<span>{label}</span>
|
|
313
|
+
<span data-cf-arrow="" className="inline-flex overflow-hidden py-[0.1em]">
|
|
314
|
+
<Arrow />
|
|
315
|
+
</span>
|
|
316
|
+
<span aria-hidden className="absolute inset-x-0 bottom-0 h-[2px] bg-current opacity-25" />
|
|
317
|
+
<span
|
|
318
|
+
aria-hidden
|
|
319
|
+
data-cf-draw=""
|
|
320
|
+
className="absolute inset-x-0 bottom-0 h-[2px] origin-right scale-x-0 bg-current transition-transform duration-[650ms] ease-[cubic-bezier(0.16,1,0.3,1)] group-hover/cf:origin-left group-hover/cf:scale-x-100 group-focus-visible/cf:origin-left group-focus-visible/cf:scale-x-100 motion-reduce:transition-none"
|
|
321
|
+
/>
|
|
322
|
+
</button>
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
// While sending the label holds still; the travelling arrow and the faded fields say it (the status line says it
|
|
326
|
+
// to a screen reader). A label one syllable away would read as a glitch.
|
|
327
|
+
const sendLabel = status === "failed" && !sending ? "Opnieuw versturen" : "Verstuur";
|
|
328
|
+
const send = linkButton(sendLabel, { type: "submit", send: true });
|
|
329
|
+
|
|
330
|
+
// The failure is said where it happened, beside the button, in a slot that is always there.
|
|
331
|
+
const fail = (
|
|
332
|
+
<p role="alert" data-cf-fail="" className="mt-4 min-h-[3em] text-small text-ink md:min-h-[1.5em]">
|
|
333
|
+
{status === "failed" ? "Versturen lukte niet. Je tekst staat er nog; probeer het opnieuw." : ""}
|
|
334
|
+
</p>
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
const liveRegion = (
|
|
338
|
+
<p role="status" className="sr-only">
|
|
339
|
+
{sending ? "Je bericht wordt verstuurd." : status === "sent" ? "Verstuurd." : ""}
|
|
340
|
+
</p>
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
const radios = (size: "body" | "large", legend: ReactNode) =>
|
|
344
|
+
hasChoices ? (
|
|
345
|
+
<fieldset data-cf-choices="" className="min-w-0">
|
|
346
|
+
{legend}
|
|
347
|
+
<div className={`flex flex-wrap ${size === "large" ? "gap-x-10 gap-y-5" : "gap-x-8 gap-y-3"}`}>
|
|
348
|
+
{choices.map((c, i) => (
|
|
349
|
+
<label
|
|
350
|
+
key={c}
|
|
351
|
+
data-cf-choice=""
|
|
352
|
+
className={`inline-flex cursor-pointer items-center gap-[0.6em] ${size === "large" ? "font-display text-[clamp(1.375rem,1.05rem+1.2vw,2.25rem)] leading-[1.2]" : "text-body"}`}
|
|
353
|
+
>
|
|
354
|
+
<input
|
|
355
|
+
type="radio"
|
|
356
|
+
name="soort"
|
|
357
|
+
value={c}
|
|
358
|
+
id={id(`soort-${i}`)}
|
|
359
|
+
checked={values.soort === c}
|
|
360
|
+
onChange={() => change("soort", c)}
|
|
361
|
+
onKeyDown={variant === "steps" ? stepKey : undefined}
|
|
362
|
+
data-cf-radio=""
|
|
363
|
+
/>
|
|
364
|
+
<span>{c}</span>
|
|
365
|
+
</label>
|
|
366
|
+
))}
|
|
367
|
+
</div>
|
|
368
|
+
</fieldset>
|
|
369
|
+
) : null;
|
|
370
|
+
|
|
371
|
+
const thanks = (size: "lead" | "display") => (
|
|
372
|
+
<div
|
|
373
|
+
ref={thanksRef}
|
|
374
|
+
tabIndex={-1}
|
|
375
|
+
data-cf-thanks=""
|
|
376
|
+
inert={status !== "sent"}
|
|
377
|
+
className={`outline-none ${size === "display" ? "self-center" : "self-start"}`}
|
|
378
|
+
>
|
|
379
|
+
<p
|
|
380
|
+
className={`font-display ${size === "display" ? "text-[clamp(2.5rem,1.1rem+4.6vw,6rem)] leading-[1.02]" : "text-h2"} tracking-[-0.02em]`}
|
|
381
|
+
>
|
|
382
|
+
<span className="struq-line-mask block overflow-hidden">
|
|
383
|
+
<span data-cf-rise="" className="block">
|
|
384
|
+
{firstName ? `Dank je, ${firstName}.` : "Dank je."}
|
|
385
|
+
</span>
|
|
386
|
+
</span>
|
|
387
|
+
</p>
|
|
388
|
+
<p data-cf-soft="" className="mt-6 max-w-[40ch] text-lead text-ink-muted">
|
|
389
|
+
Je bericht is binnen. {next}
|
|
390
|
+
</p>
|
|
391
|
+
</div>
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
const formProps = {
|
|
395
|
+
ref: formRef,
|
|
396
|
+
action,
|
|
397
|
+
method,
|
|
398
|
+
noValidate: live,
|
|
399
|
+
onSubmit: submit,
|
|
400
|
+
inert: status === "sent",
|
|
401
|
+
"aria-busy": sending || undefined,
|
|
402
|
+
"aria-labelledby": id("title"),
|
|
403
|
+
} as const;
|
|
404
|
+
|
|
405
|
+
const root = (children: ReactNode, extra: string) => (
|
|
406
|
+
<section
|
|
407
|
+
data-contact-form={variant}
|
|
408
|
+
data-state={status}
|
|
409
|
+
data-ready={live ? "" : undefined}
|
|
410
|
+
aria-labelledby={id("title")}
|
|
411
|
+
className={`relative bg-surface-0 px-6 text-ink md:px-[4vw] ${extra} ${className}`}
|
|
412
|
+
>
|
|
413
|
+
{children}
|
|
414
|
+
{liveRegion}
|
|
415
|
+
</section>
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
if (variant === "sentence") {
|
|
419
|
+
return root(
|
|
420
|
+
<>
|
|
421
|
+
<h2 id={id("title")} className="sr-only">
|
|
422
|
+
{title}
|
|
423
|
+
</h2>
|
|
424
|
+
<div data-cf-stage="" className="mx-auto w-full max-w-[86rem]">
|
|
425
|
+
<form {...formProps} data-cf-form="">
|
|
426
|
+
<div data-cf-fields="">
|
|
427
|
+
<div data-cf-sentence="" className="font-display">
|
|
428
|
+
<span data-cf-clause="">
|
|
429
|
+
<label htmlFor={id("naam")}>
|
|
430
|
+
Ik ben<span className="sr-only"> (je naam)</span>
|
|
431
|
+
</label>{" "}
|
|
432
|
+
<span data-cf-blank="" data-invalid={shown.naam ? "" : undefined}>
|
|
433
|
+
<Grow text={values.naam || "je naam"}>
|
|
434
|
+
<input {...naamProps} placeholder="je naam" size={1} />
|
|
435
|
+
</Grow>
|
|
436
|
+
{msg("naam")}
|
|
437
|
+
</span>
|
|
438
|
+
<span data-cf-comma="">,</span>
|
|
439
|
+
</span>{" "}
|
|
440
|
+
<span data-cf-clause="">
|
|
441
|
+
<label htmlFor={id("email")}>
|
|
442
|
+
bereikbaar op<span className="sr-only"> (je e-mailadres)</span>
|
|
443
|
+
</label>{" "}
|
|
444
|
+
<span data-cf-blank="" data-invalid={shown.email ? "" : undefined}>
|
|
445
|
+
<Grow text={values.email || "je e-mailadres"}>
|
|
446
|
+
<input {...emailProps} placeholder="je e-mailadres" size={1} />
|
|
447
|
+
</Grow>
|
|
448
|
+
{msg("email")}
|
|
449
|
+
</span>
|
|
450
|
+
<span data-cf-comma="">,</span>
|
|
451
|
+
</span>{" "}
|
|
452
|
+
<span data-cf-clause="">
|
|
453
|
+
<label htmlFor={id("onderwerp")}>
|
|
454
|
+
en ik wil het hebben over<span className="sr-only"> (waar gaat het over?)</span>
|
|
455
|
+
</label>{" "}
|
|
456
|
+
<span data-cf-blank="" data-multiline="" data-invalid={shown.onderwerp ? "" : undefined}>
|
|
457
|
+
<Grow text={values.onderwerp || "je plek of je plan"} multiline>
|
|
458
|
+
<textarea {...onderwerpProps} placeholder="je plek of je plan" />
|
|
459
|
+
</Grow>
|
|
460
|
+
{msg("onderwerp")}
|
|
461
|
+
</span>
|
|
462
|
+
<span data-cf-comma="">.</span>
|
|
463
|
+
</span>
|
|
464
|
+
</div>
|
|
465
|
+
<div className="mt-[7svh]">
|
|
466
|
+
{radios("body", <legend className="mb-4 text-small text-ink-muted">Het gaat om</legend>)}
|
|
467
|
+
</div>
|
|
468
|
+
</div>
|
|
469
|
+
<div className="mt-[6svh]">
|
|
470
|
+
{send}
|
|
471
|
+
{fail}
|
|
472
|
+
</div>
|
|
473
|
+
</form>
|
|
474
|
+
{thanks("display")}
|
|
475
|
+
</div>
|
|
476
|
+
</>,
|
|
477
|
+
"py-[16svh]",
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (variant === "steps") {
|
|
482
|
+
const pos = (i: number) => (i < step ? "before" : i > step ? "after" : "current");
|
|
483
|
+
const progress = status === "sent" ? 1 : step / STEPS.length;
|
|
484
|
+
const answered = STEPS.slice(0, live ? step : 0)
|
|
485
|
+
.map((k) => values[k].trim())
|
|
486
|
+
.filter(Boolean)
|
|
487
|
+
// A list of answers, not a sentence: no capital in the middle of it.
|
|
488
|
+
.map((a, i) => (i === 0 ? a : a.charAt(0).toLowerCase() + a.slice(1)));
|
|
489
|
+
const question = "block font-display text-[clamp(2.25rem,1.1rem+3.8vw,5rem)] leading-[1.04] tracking-[-0.02em]";
|
|
490
|
+
const answer = "font-display text-[clamp(1.75rem,1rem+2.4vw,3.25rem)]";
|
|
491
|
+
const stepBox = (i: number, children: ReactNode) => (
|
|
492
|
+
<div
|
|
493
|
+
key={STEPS[i]}
|
|
494
|
+
data-cf-step=""
|
|
495
|
+
data-pos={live ? pos(i) : undefined}
|
|
496
|
+
inert={live && i !== step}
|
|
497
|
+
className="min-w-0"
|
|
498
|
+
>
|
|
499
|
+
{children}
|
|
500
|
+
</div>
|
|
501
|
+
);
|
|
502
|
+
return root(
|
|
503
|
+
<>
|
|
504
|
+
<h2 id={id("title")} className="sr-only">
|
|
505
|
+
{title}
|
|
506
|
+
</h2>
|
|
507
|
+
<div data-cf-stage="" className="mx-auto w-full max-w-[68rem]">
|
|
508
|
+
<form {...formProps} data-cf-form="">
|
|
509
|
+
<div aria-hidden data-cf-progress="" className="relative mb-[6svh] h-px overflow-hidden bg-line">
|
|
510
|
+
<span
|
|
511
|
+
data-cf-fill=""
|
|
512
|
+
className="absolute inset-0 origin-left bg-accent"
|
|
513
|
+
style={{ transform: `scaleX(${live ? progress : 0})` }}
|
|
514
|
+
/>
|
|
515
|
+
</div>
|
|
516
|
+
{/* The answers so far, small, and the way back. Always one line high, so nothing below moves. */}
|
|
517
|
+
<div className="mb-[4svh] flex min-h-[1.75em] items-baseline justify-between gap-6 text-small text-ink-muted">
|
|
518
|
+
<p data-cf-trail="" className="min-w-0 truncate">
|
|
519
|
+
{answered.join(", ")}
|
|
520
|
+
</p>
|
|
521
|
+
{live ? (
|
|
522
|
+
<button
|
|
523
|
+
type="button"
|
|
524
|
+
onClick={() => {
|
|
525
|
+
if (!sending && step > 0) go(step - 1);
|
|
526
|
+
}}
|
|
527
|
+
aria-disabled={sending || undefined}
|
|
528
|
+
className={`inline-flex shrink-0 items-center gap-2 underline decoration-1 underline-offset-4 ${step === 0 || status === "sent" ? "invisible" : ""}`}
|
|
529
|
+
>
|
|
530
|
+
<Arrow back />
|
|
531
|
+
Terug
|
|
532
|
+
</button>
|
|
533
|
+
) : null}
|
|
534
|
+
</div>
|
|
535
|
+
<div data-cf-steps="" data-cf-fields="">
|
|
536
|
+
{stepBox(
|
|
537
|
+
0,
|
|
538
|
+
<>
|
|
539
|
+
<label htmlFor={id("naam")} className={question}>
|
|
540
|
+
Hoe heet je?
|
|
541
|
+
</label>
|
|
542
|
+
<div className={`mt-[4svh] ${answer}`} data-invalid={shown.naam ? "" : undefined}>
|
|
543
|
+
<Grow text={values.naam} block>
|
|
544
|
+
<input {...naamProps} enterKeyHint="next" onKeyDown={stepKey} size={1} />
|
|
545
|
+
</Grow>
|
|
546
|
+
</div>
|
|
547
|
+
{msg("naam")}
|
|
548
|
+
</>,
|
|
549
|
+
)}
|
|
550
|
+
{stepBox(
|
|
551
|
+
1,
|
|
552
|
+
<>
|
|
553
|
+
<label htmlFor={id("email")} className={question}>
|
|
554
|
+
Op welk e-mailadres kunnen we je bereiken?
|
|
555
|
+
</label>
|
|
556
|
+
<div className={`mt-[4svh] ${answer}`} data-invalid={shown.email ? "" : undefined}>
|
|
557
|
+
<Grow text={values.email} block>
|
|
558
|
+
<input {...emailProps} enterKeyHint="next" onKeyDown={stepKey} size={1} />
|
|
559
|
+
</Grow>
|
|
560
|
+
</div>
|
|
561
|
+
{msg("email")}
|
|
562
|
+
</>,
|
|
563
|
+
)}
|
|
564
|
+
{stepBox(
|
|
565
|
+
2,
|
|
566
|
+
<>
|
|
567
|
+
<label htmlFor={id("onderwerp")} className={question}>
|
|
568
|
+
Waar gaat het over?
|
|
569
|
+
</label>
|
|
570
|
+
<div className={`mt-[4svh] ${answer}`} data-invalid={shown.onderwerp ? "" : undefined}>
|
|
571
|
+
<Grow text={values.onderwerp} multiline block>
|
|
572
|
+
<textarea {...onderwerpProps} enterKeyHint={hasChoices ? "next" : "send"} onKeyDown={stepKey} />
|
|
573
|
+
</Grow>
|
|
574
|
+
</div>
|
|
575
|
+
{msg("onderwerp")}
|
|
576
|
+
</>,
|
|
577
|
+
)}
|
|
578
|
+
{hasChoices
|
|
579
|
+
? stepBox(
|
|
580
|
+
3,
|
|
581
|
+
<>
|
|
582
|
+
{radios(
|
|
583
|
+
"large",
|
|
584
|
+
<legend className={`${question} mb-[4svh]`}>
|
|
585
|
+
Gaat het om nieuwbouw, een verbouwing of interieur?
|
|
586
|
+
</legend>,
|
|
587
|
+
)}
|
|
588
|
+
<p className="mt-5 text-small text-ink-muted">Dit mag je overslaan.</p>
|
|
589
|
+
</>,
|
|
590
|
+
)
|
|
591
|
+
: null}
|
|
592
|
+
</div>
|
|
593
|
+
<div className="mt-[5svh] flex flex-wrap items-baseline gap-x-6 gap-y-2">
|
|
594
|
+
{!live || step === last || (step === 2 && !hasChoices)
|
|
595
|
+
? send
|
|
596
|
+
: linkButton("Volgende", { type: "button", onClick: advance })}
|
|
597
|
+
{live && step < last ? (
|
|
598
|
+
<span className="hidden text-small text-ink-muted [@media(hover:hover)]:inline">of druk op Enter</span>
|
|
599
|
+
) : null}
|
|
600
|
+
</div>
|
|
601
|
+
{fail}
|
|
602
|
+
</form>
|
|
603
|
+
{thanks("display")}
|
|
604
|
+
</div>
|
|
605
|
+
</>,
|
|
606
|
+
"flex min-h-[90svh] flex-col justify-center py-[12svh]",
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
return root(
|
|
611
|
+
<div className="mx-auto grid w-full max-w-[96rem] gap-y-14 md:grid-cols-12 md:gap-x-[4vw]">
|
|
612
|
+
<div className="md:col-span-5">
|
|
613
|
+
<h2 id={id("title")} className="font-display max-w-[11ch] text-h2">
|
|
614
|
+
{title}
|
|
615
|
+
</h2>
|
|
616
|
+
{intro ? <p className="mt-8 max-w-[36ch] text-lead text-ink-muted">{intro}</p> : null}
|
|
617
|
+
{email ? (
|
|
618
|
+
<p className="mt-6 text-body text-ink-muted">
|
|
619
|
+
Liever mailen?{" "}
|
|
620
|
+
<a href={`mailto:${email}`} className="text-ink underline decoration-1 underline-offset-4">
|
|
621
|
+
{email}
|
|
622
|
+
</a>
|
|
623
|
+
</p>
|
|
624
|
+
) : null}
|
|
625
|
+
</div>
|
|
626
|
+
<div data-cf-stage="" className="md:col-span-7">
|
|
627
|
+
<form {...formProps} data-cf-form="">
|
|
628
|
+
<div data-cf-fields="" className="grid gap-x-10 gap-y-3 md:grid-cols-2">
|
|
629
|
+
<div data-cf-float="" data-invalid={shown.naam ? "" : undefined}>
|
|
630
|
+
<Grow text={values.naam} block>
|
|
631
|
+
<input {...naamProps} placeholder=" " size={1} />
|
|
632
|
+
</Grow>
|
|
633
|
+
<label htmlFor={id("naam")}>Naam</label>
|
|
634
|
+
{msg("naam")}
|
|
635
|
+
</div>
|
|
636
|
+
<div data-cf-float="" data-invalid={shown.email ? "" : undefined}>
|
|
637
|
+
<Grow text={values.email} block>
|
|
638
|
+
<input {...emailProps} placeholder=" " size={1} />
|
|
639
|
+
</Grow>
|
|
640
|
+
<label htmlFor={id("email")}>E-mailadres</label>
|
|
641
|
+
{msg("email")}
|
|
642
|
+
</div>
|
|
643
|
+
<div data-cf-float="" data-invalid={shown.onderwerp ? "" : undefined} className="md:col-span-2">
|
|
644
|
+
<Grow text={values.onderwerp} multiline block>
|
|
645
|
+
<textarea {...onderwerpProps} placeholder=" " />
|
|
646
|
+
</Grow>
|
|
647
|
+
<label htmlFor={id("onderwerp")}>Waar gaat het over?</label>
|
|
648
|
+
{msg("onderwerp")}
|
|
649
|
+
</div>
|
|
650
|
+
<div className="md:col-span-2">
|
|
651
|
+
{radios("body", <legend className="mb-4 text-small text-ink-muted">Het gaat om</legend>)}
|
|
652
|
+
</div>
|
|
653
|
+
</div>
|
|
654
|
+
<div className="mt-12">
|
|
655
|
+
{send}
|
|
656
|
+
{fail}
|
|
657
|
+
</div>
|
|
658
|
+
</form>
|
|
659
|
+
{thanks("lead")}
|
|
660
|
+
</div>
|
|
661
|
+
</div>,
|
|
662
|
+
"py-[16svh]",
|
|
663
|
+
);
|
|
664
|
+
}
|