@evinvest/kitstart 0.2.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/README.md CHANGED
@@ -18,7 +18,7 @@ stays in the brand.
18
18
  | `@evinvest/kitstart/proxy` | edge | `createProxy(site)`, `PROXY_MATCHER` |
19
19
  | `@evinvest/kitstart/next` | Next server (routes, RSC) | `quoteRoute`, `sitemapRoute`, `robotsRoute`, `ogRoute`, `healthRoute`, `createPlaceLoader`, `loadLocale`, `placeMetadata` / `brandMetadata` / `statusMetadata`, `metadataBase` |
20
20
  | `@evinvest/kitstart/next/config` | `next.config.ts`, `vitest.config.ts` | `withLanding`, `buildEnv` and the `assets/` readers |
21
- | `@evinvest/kitstart/react` | either side | `LangSwitch`, `CallBar`, `StatusScreen`, `PlaceDirectory`, `AreaChips`, `Coverage`, `MapFacade` (client), `QuoteFormShell`, `Faq`, `AnalyticsBoundary` (client), plus the kit and marketing pieces a landing composes with |
21
+ | `@evinvest/kitstart/react` | either side | `LangSwitch`, `CallBar`, `StatusScreen`, `PlaceDirectory`, `AreaChips`, `Coverage`, `MapFacade` (client), `QuoteFormShell`, `FormSelect` (client), `Faq`, `AnalyticsBoundary` (client), plus the kit and marketing pieces a landing composes with |
22
22
  | `@evinvest/kitstart/testing` | a brand's vitest | `describeLandingContract(site, { globalsCss, proxySource, text })`, `describeLeadStoreContract(name, harness)`, `storefrontPlace`, `serviceAreaPlace`, `testLead` |
23
23
  | `@evinvest/kitstart/testing/e2e` | a brand's Playwright | `defineSectionSuite(sections)`, `settle(page, selector)`, `BREAKPOINTS` |
24
24
  | bin `kitstart-size` | plain node | `kitstart-size [<build root>] [--route …] [--budget …]`: first-load JS of a place page against `tests/bundle_budget.txt`; fails closed |
@@ -45,7 +45,7 @@ brand's `flake.nix` is its own config plus one call — `template/flake.nix` is
45
45
  the whole of one:
46
46
 
47
47
  ```nix
48
- inputs.ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.1.0"; # the version in package-lock.json
48
+ inputs.ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.2.0"; # the version in package-lock.json
49
49
  inputs.ev.inputs.v_flakes.follows = "v_flakes";
50
50
 
51
51
  landing = ev.lib.mkLanding {
@@ -98,7 +98,7 @@ the two must be one release. Pin the flake to the matching
98
98
  Structural only — where behaviour matters more than look. Marketing sections
99
99
  (hero, prices, reviews…) stay in the brand until two brands hold the same one.
100
100
  Each widget is a Server Component unless it needs the browser (`MapFacade`,
101
- `AnalyticsBoundary`), styled with the kit's token roles only, restyled through
101
+ `AnalyticsBoundary`, `FormSelect`), styled with the kit's token roles only, restyled through
102
102
  `className` — and, where a brand needs geometry inside one (`Faq`, `CallBar`,
103
103
  `StatusScreen`, `PlaceDirectory`, `Coverage`), through its named parts:
104
104
  `classNames={{ list: "rounded-none", answer: "px-2" }}`, merged after the
@@ -115,6 +115,60 @@ honeypot the funnel reads; the visible fields are the brand's children.
115
115
  `StatusScreen` takes the brand's name and marks as props, so the client error
116
116
  boundary never imports the site config.
117
117
 
118
+ ### `FormSelect`: a choice that posts without JavaScript
119
+
120
+ A form's select, for the fields inside `QuoteFormShell`. On the server and
121
+ without JavaScript it is the kit's `NativeSelect` — a real `<select name>`
122
+ the form posts as is. After hydration (`useSyncExternalStore`, so the
123
+ server's HTML and the first client render agree) it is the kit's `Select`:
124
+ the list drawn in the palette, never the platform's menu, and the value in an
125
+ input of the same `name`. Both states are one box — height, width, border,
126
+ radius and inset — so the swap moves nothing (checked by the template's e2e,
127
+ which CI runs: `npm run check:template -- --e2e`).
128
+
129
+ ```tsx
130
+ <Field className="flex flex-col gap-2">
131
+ <FieldLabel>{t.quoteLabels.subject}</FieldLabel>
132
+ <FormSelect
133
+ name={LEAD.wire.subject}
134
+ size="lg"
135
+ defaultValue={LEAD.subjects[0]}
136
+ options={LEAD.subjects.map(s => ({ value: s, label: t.subjects[s] }))}
137
+ />
138
+ </Field>
139
+ ```
140
+
141
+ | Prop | |
142
+ |---|---|
143
+ | `name`, `options: { value, label }[]` | the posted field and its choices (labels are text) |
144
+ | `defaultValue` | without it: empty under a `placeholder`, else the first option — the native rule |
145
+ | `placeholder` | an empty, unpickable choice shown until one is made |
146
+ | `required`, `disabled` | as on a `<select>`: required refuses the submit, disabled posts nothing |
147
+ | `size` | `sm` · `md` · `lg` (the landing's) — the kit's form scale |
148
+ | `id`, `aria-describedby`, `aria-invalid` | inside a `Field` the id comes from it: its `FieldLabel` names the `<select>` before hydration and the trigger after |
149
+ | `className` · `classNames={{ trigger, content, item }}` | the shared box · the control in both states, the list, its rows |
150
+ | `onValueChange` | the chosen value, after hydration |
151
+
152
+ - **The value survives the swap.** A choice made in the native select before
153
+ the script arrived is read in the hydration commit; a form `reset` puts the
154
+ default back in both states. With nothing chosen (under a `placeholder`) it
155
+ posts nothing, as a native select on its disabled placeholder does.
156
+ - **`required` still refuses the submit.** A `type="hidden"` input is never
157
+ validated, so under `required` the value rides in a transparent input under
158
+ the trigger, out of the tab order, the accessibility tree and autofill. An
159
+ invalid field's trigger takes `aria-invalid` (the error border). On a submit
160
+ — and only then — the form's first invalid field also takes focus with its
161
+ list open, where the browser's bubble would have pointed at nothing; a
162
+ script's `checkValidity()` marks it and moves nothing.
163
+ - **Keyboard and screen readers** are the kit's `Select` (its README has the
164
+ focus pattern): the arrows open it, opening lands on the chosen option,
165
+ letters jump, Enter chooses, Escape and Tab close with focus back on the
166
+ trigger; the trigger is a `combobox` named by the `FieldLabel`, and so is
167
+ its list, with the kit's focus ring.
168
+ - **Weight.** It is a client module and pulls the kit's `Select` into the
169
+ page: 3.8 KB gz of first-load JS on the template's place page (152,774 →
170
+ 156,565 B of its 158,000 B budget).
171
+
118
172
  Tailwind v4 does not scan `node_modules`; the brand's `globals.css` names the
119
173
  package:
120
174
 
@@ -0,0 +1,45 @@
1
+ import { type SelectTriggerSize } from "@evinvest/uikit";
2
+ import type { PartClassNames } from "./parts.js";
3
+ /**
4
+ * A form's choice that works before any script and looks like the kit after
5
+ * it. The server, and a browser without JavaScript, get the kit's
6
+ * `NativeSelect` — a real `<select name>` the form posts as is. Once the page
7
+ * has hydrated it becomes the kit's `Select`, drawn in the palette instead of
8
+ * the platform's menu, with the value in an input of the same `name`. Both
9
+ * are the same box (height, width, border, inset), so the swap moves nothing.
10
+ *
11
+ * Inside a `Field`, its label names whichever control is rendered: the
12
+ * `Field`'s id goes to the `<select>` first and to the trigger after.
13
+ */
14
+ export interface FormSelectOption {
15
+ value: string;
16
+ label: string;
17
+ }
18
+ /** `trigger`: the control in both states; `content` and `item`: the scripted list. */
19
+ export type FormSelectPart = "trigger" | "content" | "item";
20
+ export interface FormSelectProps {
21
+ name: string;
22
+ options: readonly FormSelectOption[];
23
+ /** Without it: empty under a `placeholder`, else the first option — a native select's own rule. */
24
+ defaultValue?: string | undefined;
25
+ /** An empty, unpickable choice shown until one is made. */
26
+ placeholder?: string | undefined;
27
+ required?: boolean | undefined;
28
+ disabled?: boolean | undefined;
29
+ size?: SelectTriggerSize | undefined;
30
+ id?: string | undefined;
31
+ /** The box both states share — what a layout sizes. */
32
+ className?: string | undefined;
33
+ classNames?: PartClassNames<FormSelectPart> | undefined;
34
+ onValueChange?: ((value: string) => void) | undefined;
35
+ "aria-describedby"?: string | undefined;
36
+ "aria-invalid"?: boolean | undefined;
37
+ }
38
+ export declare function FormSelect(props: FormSelectProps): import("react").JSX.Element;
39
+ /**
40
+ * Both states on demand — for the widget gallery, which renders on the server
41
+ * and would only ever see the native one. Not part of the package's surface.
42
+ */
43
+ export declare function FormSelectView({ scripted, ...props }: FormSelectProps & {
44
+ scripted: boolean;
45
+ }): import("react").JSX.Element;
@@ -0,0 +1,42 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { cn, NativeSelect, NativeSelectOption, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@evinvest/uikit";
4
+ import { useRef, useSyncExternalStore } from "react";
5
+ import { useFormSelectValue } from "./form-select-value.js";
6
+ const BOX = "relative inline-flex w-full";
7
+ // The trigger's geometry matched to `NativeSelect`'s at each size: full width,
8
+ // the same text size (16 px on phones, which keeps iOS from zooming).
9
+ // The chevron at full strength, as the native select draws it: the kit's
10
+ // trigger dims its own arrow.
11
+ const TRIGGER = "w-full min-w-0 text-ink [&>svg]:opacity-100";
12
+ const TRIGGER_TEXT = { sm: "text-base md:text-sm", md: "text-base md:text-sm", lg: "" };
13
+ const subscribe = () => () => { };
14
+ /** `false` on the server and while hydrating, `true` from the render after. */
15
+ const useHydrated = () => useSyncExternalStore(subscribe, () => true, () => false);
16
+ export function FormSelect(props) {
17
+ return _jsx(FormSelectView, { ...props, scripted: useHydrated() });
18
+ }
19
+ /**
20
+ * Both states on demand — for the widget gallery, which renders on the server
21
+ * and would only ever see the native one. Not part of the package's surface.
22
+ */
23
+ export function FormSelectView({ scripted, ...props }) {
24
+ const { name, options, placeholder, required, disabled, size = "md", id, className, classNames } = props;
25
+ const initial = props.defaultValue ?? (placeholder !== undefined ? "" : (options[0]?.value ?? ""));
26
+ const native = useRef(null);
27
+ // The box, not the trigger: `SelectTrigger` keeps its own ref for placing the list.
28
+ const box = useRef(null);
29
+ const state = useFormSelectValue({ initial, native, box, scripted, onValueChange: props.onValueChange });
30
+ const hint = placeholder !== undefined ? { placeholder } : {};
31
+ const aria = { "aria-describedby": props["aria-describedby"], "aria-invalid": props["aria-invalid"] || state.invalid || undefined };
32
+ if (!scripted) {
33
+ return (_jsx(NativeSelect, { ref: native, name: name, id: id, size: size, ...hint, required: required, disabled: disabled, defaultValue: initial, wrapperClassName: cn(BOX, className), className: classNames?.trigger, ...aria, children: options.map(o => (_jsx(NativeSelectOption, { value: o.value, children: o.label }, o.value))) }));
34
+ }
35
+ return (_jsxs("span", { ref: box, "data-slot": "form-select", className: cn(BOX, className), children: [_jsxs(Select, { value: state.value, onValueChange: state.choose, open: state.open, onOpenChange: state.setOpen, children: [_jsx(SelectTrigger, { id: id, size: size, disabled: disabled, "aria-required": required || undefined, className: cn(TRIGGER, TRIGGER_TEXT[size], classNames?.trigger), ...aria, children: _jsx(SelectValue, { ...hint }) }), _jsx(SelectContent, { className: cn("min-w-(--select-trigger-width)", classNames?.content), children: options.map(o => (_jsx(SelectItem, { value: o.value, className: cn(size === "lg" && "py-2.5 text-base", classNames?.item), children: o.label }, o.value))) })] }), required ? (
36
+ // Validated, so not `type="hidden"` (which the browser never checks);
37
+ // under the trigger, out of the tab order, the accessibility tree and
38
+ // the browser's autofill.
39
+ _jsx("input", { name: name, value: state.value, required: true, disabled: disabled, tabIndex: -1, "aria-hidden": "true", autoComplete: "off", onChange: () => { }, onInvalid: state.onInvalid, className: "pointer-events-none absolute inset-0 opacity-0" })) : (
40
+ // Nothing chosen posts nothing, as a native select on its disabled placeholder.
41
+ state.value !== "" && _jsx("input", { type: "hidden", name: name, value: state.value, disabled: disabled }))] }));
42
+ }
@@ -16,8 +16,5 @@ export function StatusScreen(props) {
16
16
  // A brand with no phone falls back to "home" rather than a dead button.
17
17
  const primary = action(status.primary) ?? action("home");
18
18
  const secondary = action(status.secondary);
19
- return (_jsxs("div", { className: cn("relative flex min-h-screen flex-col bg-background text-ink", className), children: [_jsxs("header", { className: cn("relative flex flex-wrap items-center gap-x-4 gap-y-2 border-b border-border px-5 py-3 md:flex-nowrap md:gap-5 md:px-12 md:py-5", c?.header), children: [_jsx("a", { href: target.home, "aria-label": brandName, className: cn("shrink-0 text-ink", c?.logo), children: logo ?? _jsx("span", { className: "font-display text-lg font-bold md:text-xl", children: brandName }) }), _jsx("div", { className: "flex-1" }), _jsx(LangSwitch, { current: copy.locale, locales: locales, hrefs: target.langHrefs, ...(labels ? { labels } : {}), className: cn("order-last w-full text-sm font-medium text-ink-soft md:order-none md:w-auto", c?.lang) }), target.phone && (_jsx("a", { href: telHref(target.phone), className: cn("whitespace-nowrap font-display text-base font-bold text-primary-ink md:text-xl", c?.phone), children: target.phone }))] }), _jsxs("main", { className: cn("relative flex flex-1 flex-col items-center gap-5 px-5 py-12 text-center md:gap-6 md:py-24", c?.main), children: [mark, _jsx("p", { className: cn("text-xs font-medium tracking-widest text-primary-ink", c?.eyebrow), children: status.eyebrow }), _jsx("p", { className: partWithLeading("font-display text-8xl font-bold tracking-tight text-ink md:text-9xl", "leading-none", c?.code), children: status.code }), _jsxs("h1", { className: partWithLeading("font-display text-2xl font-bold text-ink md:text-4xl", "leading-snug", c?.headline), children: [status.headline[0], _jsx("span", { className: "text-primary-ink", children: status.headline[1] })] }), _jsx("p", { className: partWithLeading("max-w-2xl text-base text-ink-soft md:text-lg", "leading-relaxed", c?.body), children: status.body(f) }), _jsxs("div", { className: cn("flex flex-col gap-3.5 sm:flex-row", c?.actions), children: [primary && (_jsx(Button, { href: primary.href, size: "xl", className: cn(buttonClassName, c?.primaryButton), children: primary.label })), secondary && secondary.href !== primary?.href && (
20
- // The kit's outline border is `--border`, 14 % ink: 1.4:1 on the
21
- // dark polarity. Half ink clears 3:1 for a control on either one.
22
- _jsx(Button, { href: secondary.href, size: "xl", variant: "outline", className: cn("border-ink/50", buttonClassName, c?.secondaryButton), children: secondary.label }))] }), _jsx("ul", { className: cn("flex flex-col items-center gap-3 text-sm sm:flex-row sm:gap-6", c?.strip), children: t.statusStrip.map(term => (_jsxs("li", { className: "flex items-center gap-2", children: [_jsx(Check, {}), _jsx("span", { className: "font-medium text-ink-soft", children: term })] }, term))) })] }), _jsx("footer", { className: cn("relative border-t border-border px-5 py-6 text-xs uppercase tracking-wider text-ink-soft md:px-12 md:py-7", c?.footer), children: _jsx("p", { children: t.facts(f).join(" · ") }) })] }));
19
+ return (_jsxs("div", { className: cn("relative flex min-h-screen flex-col bg-background text-ink", className), children: [_jsxs("header", { className: cn("relative flex flex-wrap items-center gap-x-4 gap-y-2 border-b border-border px-5 py-3 md:flex-nowrap md:gap-5 md:px-12 md:py-5", c?.header), children: [_jsx("a", { href: target.home, "aria-label": brandName, className: cn("shrink-0 text-ink", c?.logo), children: logo ?? _jsx("span", { className: "font-display text-lg font-bold md:text-xl", children: brandName }) }), _jsx("div", { className: "flex-1" }), _jsx(LangSwitch, { current: copy.locale, locales: locales, hrefs: target.langHrefs, ...(labels ? { labels } : {}), className: cn("order-last w-full text-sm font-medium text-ink-soft md:order-none md:w-auto", c?.lang) }), target.phone && (_jsx("a", { href: telHref(target.phone), className: cn("whitespace-nowrap font-display text-base font-bold text-primary-ink md:text-xl", c?.phone), children: target.phone }))] }), _jsxs("main", { className: cn("relative flex flex-1 flex-col items-center gap-5 px-5 py-12 text-center md:gap-6 md:py-24", c?.main), children: [mark, _jsx("p", { className: cn("text-xs font-medium tracking-widest text-primary-ink", c?.eyebrow), children: status.eyebrow }), _jsx("p", { className: partWithLeading("font-display text-8xl font-bold tracking-tight text-ink md:text-9xl", "leading-none", c?.code), children: status.code }), _jsxs("h1", { className: partWithLeading("font-display text-2xl font-bold text-ink md:text-4xl", "leading-snug", c?.headline), children: [status.headline[0], _jsx("span", { className: "text-primary-ink", children: status.headline[1] })] }), _jsx("p", { className: partWithLeading("max-w-2xl text-base text-ink-soft md:text-lg", "leading-relaxed", c?.body), children: status.body(f) }), _jsxs("div", { className: cn("flex flex-col gap-3.5 sm:flex-row", c?.actions), children: [primary && (_jsx(Button, { href: primary.href, size: "xl", className: cn(buttonClassName, c?.primaryButton), children: primary.label })), secondary && secondary.href !== primary?.href && (_jsx(Button, { href: secondary.href, size: "xl", variant: "outline", className: cn(buttonClassName, c?.secondaryButton), children: secondary.label }))] }), _jsx("ul", { className: cn("flex flex-col items-center gap-3 text-sm sm:flex-row sm:gap-6", c?.strip), children: t.statusStrip.map(term => (_jsxs("li", { className: "flex items-center gap-2", children: [_jsx(Check, {}), _jsx("span", { className: "font-medium text-ink-soft", children: term })] }, term))) })] }), _jsx("footer", { className: cn("relative border-t border-border px-5 py-6 text-xs uppercase tracking-wider text-ink-soft md:px-12 md:py-7", c?.footer), children: _jsx("p", { children: t.facts(f).join(" · ") }) })] }));
23
20
  }
@@ -0,0 +1,20 @@
1
+ import { type InvalidEvent, type RefObject } from "react";
2
+ /**
3
+ * The value `FormSelect` posts, across the swap from the native select and
4
+ * through the form's own life: a reset, and a required check that points at
5
+ * the kit's trigger instead of an input nobody sees.
6
+ */
7
+ export declare function useFormSelectValue(opts: {
8
+ initial: string;
9
+ native: RefObject<HTMLSelectElement | null>;
10
+ box: RefObject<HTMLElement | null>;
11
+ scripted: boolean;
12
+ onValueChange: ((value: string) => void) | undefined;
13
+ }): {
14
+ value: string;
15
+ choose: (next: string) => void;
16
+ invalid: boolean;
17
+ open: boolean;
18
+ setOpen: import("react").Dispatch<import("react").SetStateAction<boolean>>;
19
+ onInvalid: (e: InvalidEvent<HTMLInputElement>) => void;
20
+ };
@@ -0,0 +1,72 @@
1
+ "use client";
2
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
3
+ /**
4
+ * The value `FormSelect` posts, across the swap from the native select and
5
+ * through the form's own life: a reset, and a required check that points at
6
+ * the kit's trigger instead of an input nobody sees.
7
+ */
8
+ export function useFormSelectValue(opts) {
9
+ const { initial, native, box, scripted, onValueChange } = opts;
10
+ const [value, setValue] = useState(initial);
11
+ const [invalid, setInvalid] = useState(false);
12
+ const [open, setOpen] = useState(false);
13
+ const initialRef = useRef(initial);
14
+ initialRef.current = initial;
15
+ // A choice made in the native select before the script arrived survives the
16
+ // swap: read in the hydration commit, before the scripted render. Once —
17
+ // later renders never hold the native select.
18
+ useLayoutEffect(() => {
19
+ if (native.current)
20
+ setValue(native.current.value);
21
+ }, [native]);
22
+ // A form reset puts a native select back on its default; so does this.
23
+ // Interactive validation — the browser's own, on a submit — is told apart
24
+ // from a script's `checkValidity()` by the submit that starts it.
25
+ const submitting = useRef(false);
26
+ useEffect(() => {
27
+ const form = box.current?.closest("form");
28
+ if (!scripted || !form)
29
+ return;
30
+ const reset = () => setValue(initialRef.current);
31
+ // On the document, not the form: a submit button may sit outside it
32
+ // (`form="…"`), and the click may land on an icon inside the button.
33
+ const submit = (e) => {
34
+ const control = e.target?.closest?.("button, input");
35
+ if (!(control instanceof HTMLButtonElement || control instanceof HTMLInputElement))
36
+ return;
37
+ if (control.form !== form || control.type !== "submit")
38
+ return;
39
+ submitting.current = true;
40
+ // The invalid events of this submit fire before the task ends.
41
+ setTimeout(() => (submitting.current = false), 0);
42
+ };
43
+ form.addEventListener("reset", reset);
44
+ document.addEventListener("click", submit, true);
45
+ return () => {
46
+ form.removeEventListener("reset", reset);
47
+ document.removeEventListener("click", submit, true);
48
+ };
49
+ }, [box, scripted]);
50
+ const choose = (next) => {
51
+ setValue(next);
52
+ setInvalid(false);
53
+ onValueChange?.(next);
54
+ };
55
+ // The browser's bubble would point at an input nobody sees: the trigger
56
+ // takes the error state instead. On a submit, the form's first invalid
57
+ // field also takes focus with its list open, where the bubble would have
58
+ // been; a script's quiet `checkValidity()` moves nothing.
59
+ const onInvalid = (e) => {
60
+ e.preventDefault();
61
+ setInvalid(true);
62
+ if (!submitting.current)
63
+ return;
64
+ // `validity`, not `:invalid`: jsdom answers that selector by firing `invalid` again.
65
+ const first = Array.from(e.currentTarget.form?.elements ?? []).find(el => "validity" in el && !el.validity.valid);
66
+ if (first === e.currentTarget) {
67
+ box.current?.querySelector("button")?.focus();
68
+ setOpen(true);
69
+ }
70
+ };
71
+ return { value, choose, invalid, open, setOpen, onInvalid };
72
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `@evinvest/kitstart/react` — the structural widgets every landing shares,
3
3
  * where behaviour matters more than look. Server components by default; only
4
- * `MapFacade` and `AnalyticsBoundary` are client modules, each its own file
4
+ * `MapFacade`, `AnalyticsBoundary` and `FormSelect` are client modules, each its own file
5
5
  * with its own `"use client"`, so a page pays for what it renders.
6
6
  *
7
7
  * Styled with the kit's token roles only (`text-ink`, `border-border`,
@@ -15,6 +15,7 @@ export { AreaChips } from "./AreaChips.js";
15
15
  export { CallBar, type CallBarPart, type CallBarProps } from "./CallBar.js";
16
16
  export { Coverage, type CoveragePart, type CoverageProps } from "./Coverage.js";
17
17
  export { Faq, type FaqPart, type FaqProps } from "./Faq.js";
18
+ export { FormSelect, type FormSelectOption, type FormSelectPart, type FormSelectProps } from "./FormSelect.js";
18
19
  export { LangSwitch, withLang, type LangSwitchProps } from "./LangSwitch.js";
19
20
  export { MapFacade, type MapFacadePart, type MapFacadeProps } from "./MapFacade.js";
20
21
  export { PlaceDirectory, type PlaceDirectoryPart, type PlaceDirectoryProps } from "./PlaceDirectory.js";
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `@evinvest/kitstart/react` — the structural widgets every landing shares,
3
3
  * where behaviour matters more than look. Server components by default; only
4
- * `MapFacade` and `AnalyticsBoundary` are client modules, each its own file
4
+ * `MapFacade`, `AnalyticsBoundary` and `FormSelect` are client modules, each its own file
5
5
  * with its own `"use client"`, so a page pays for what it renders.
6
6
  *
7
7
  * Styled with the kit's token roles only (`text-ink`, `border-border`,
@@ -15,6 +15,7 @@ export { AreaChips } from "./AreaChips.js";
15
15
  export { CallBar } from "./CallBar.js";
16
16
  export { Coverage } from "./Coverage.js";
17
17
  export { Faq } from "./Faq.js";
18
+ export { FormSelect } from "./FormSelect.js";
18
19
  export { LangSwitch, withLang } from "./LangSwitch.js";
19
20
  export { MapFacade } from "./MapFacade.js";
20
21
  export { PlaceDirectory } from "./PlaceDirectory.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evinvest/kitstart",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Landing-site machinery for local-service brands: a site composition root, the Place/Presence/ServiceArea model with a publication gate, host/locale routing, schema.org and SEO builders, the lead funnel with its antispam, and analytics events. Pure core at '.', more subpaths per runtime.",
5
5
  "license": "BlueOak-1.0.0",
6
6
  "publishConfig": {
@@ -80,7 +80,7 @@
80
80
  "@evinvest/analytics": ">=0.3.0 <1",
81
81
  "@evinvest/i18n": ">=0.9.0 <1",
82
82
  "@evinvest/marketing": ">=0.3.0 <1",
83
- "@evinvest/uikit": ">=0.20.0 <1",
83
+ "@evinvest/uikit": ">=0.21.0 <1",
84
84
  "next": "^16",
85
85
  "react": "^19",
86
86
  "server-only": "*"
@@ -22,6 +22,9 @@ launch — `noindex` everywhere until `assets/card.toml` gets a `site`.
22
22
  8. `src/entities/content`: `Text extends CoreText`, one object per language,
23
23
  checked with `satisfies`. Every word on a page comes from here.
24
24
  9. `src/shared/config/lead.ts`: what the form asks (`subjects`, `wire`, `extras`).
25
+ A choice in the form is `FormSelect`: a native `<select>` until the page
26
+ hydrates (the form posts without JavaScript), the kit's list after — never
27
+ a bare `NativeSelect`, whose popup is the platform's menu.
25
28
  10. `src/views/`: the brand's sections around the package's widgets.
26
29
  11. Tests: `tests/contract.test.ts` runs the landing contract; add the copy's
27
30
  own, the e2e sections (`tests/e2e`, baselines from CI on Linux) and set
@@ -3,7 +3,7 @@
3
3
  v_flakes.url = "github:valeratrades/v_flakes?ref=v1.6";
4
4
  # The lib flake at the tag of the @evinvest/kitstart version in
5
5
  # package-lock.json — mkLanding refuses a mismatch.
6
- ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.1.0";
6
+ ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.2.0";
7
7
  ev.inputs.v_flakes.follows = "v_flakes";
8
8
  };
9
9
 
@@ -20,9 +20,9 @@
20
20
  "dependencies": {
21
21
  "@evinvest/analytics": "^0.3.0",
22
22
  "@evinvest/i18n": "^0.9.0",
23
- "@evinvest/kitstart": "^0.1.0",
23
+ "@evinvest/kitstart": "^0.2.0",
24
24
  "@evinvest/marketing": "^0.3.0",
25
- "@evinvest/uikit": "^0.20.0",
25
+ "@evinvest/uikit": "^0.21.0",
26
26
  "next": "16.3.6",
27
27
  "react": "19.3.0",
28
28
  "react-dom": "19.3.0",
@@ -7,10 +7,9 @@ import {
7
7
  Faq,
8
8
  Field,
9
9
  FieldLabel,
10
+ FormSelect,
10
11
  Input,
11
12
  JsonLd,
12
- NativeSelect,
13
- NativeSelectOption,
14
13
  PHONE_INPUT_PROPS,
15
14
  QuoteFormShell,
16
15
  Section,
@@ -42,13 +41,12 @@ export function PlaceHome({ view, copy, renderedAt }: { view: PlaceView<Locale>;
42
41
  <p className="font-display text-2xl font-bold text-ink">{t.quoteForm.title}</p>
43
42
  <Field className="flex flex-col gap-2">
44
43
  <FieldLabel>{t.quoteLabels.subject}</FieldLabel>
45
- <NativeSelect name={LEAD.wire.subject} size="lg" defaultValue={LEAD.subjects[0]}>
46
- {LEAD.subjects.map(s => (
47
- <NativeSelectOption key={s} value={s}>
48
- {t.subjects[s]}
49
- </NativeSelectOption>
50
- ))}
51
- </NativeSelect>
44
+ <FormSelect
45
+ name={LEAD.wire.subject}
46
+ size="lg"
47
+ defaultValue={LEAD.subjects[0]}
48
+ options={LEAD.subjects.map(s => ({ value: s, label: t.subjects[s] }))}
49
+ />
52
50
  </Field>
53
51
  <Field className="flex flex-col gap-2">
54
52
  <FieldLabel>{t.quoteLabels.locality}</FieldLabel>
@@ -0,0 +1,82 @@
1
+ import { expect, test, type Page } from "@playwright/test";
2
+
3
+ // The quote form's contract with the visitor: it posts before any script
4
+ // (the one standing in water may be on a bad connection), and once the script
5
+ // is there the choice is the kit's list, not the platform's menu — in the
6
+ // same box, so nothing moves when one becomes the other.
7
+ const SUBJECT = "Prestation";
8
+
9
+ async function fillAndSend(page: Page) {
10
+ await page.getByLabel("Ville ou code postal").fill("75011");
11
+ await page.getByLabel("Mobile").fill("0612345678");
12
+ await page.getByRole("button", { name: "Recevoir le prix" }).click();
13
+ }
14
+
15
+ test.describe("without JavaScript", () => {
16
+ // Reduced motion drops the page's smooth scroll, which without a script
17
+ // Playwright never sees settle before it clicks.
18
+ test.use({ javaScriptEnabled: false, reducedMotion: "reduce" });
19
+
20
+ test("the form posts the native select's value", async ({ page }) => {
21
+ await page.goto("/fr#quote");
22
+ const select = page.locator("#quote select[name=subject]");
23
+ await expect(select).toBeVisible();
24
+ await expect(page.getByLabel(SUBJECT)).toHaveJSProperty("tagName", "SELECT");
25
+ await select.selectOption("deep");
26
+ const posted = page.waitForRequest(r => r.method() === "POST" && new URL(r.url()).pathname === "/quote");
27
+ await fillAndSend(page);
28
+ expect(new URLSearchParams((await posted).postData() ?? "").get("subject")).toBe("deep");
29
+ await expect(page).toHaveURL(/\/fr\/thanks$/);
30
+ });
31
+ });
32
+
33
+ test.describe("with JavaScript", () => {
34
+ test("the choice is the kit's list, and the form posts it", async ({ page }) => {
35
+ await page.goto("/fr#quote");
36
+ const trigger = page.getByRole("combobox", { name: SUBJECT });
37
+ await expect(trigger).toBeVisible();
38
+ await expect(page.locator("#quote select")).toHaveCount(0);
39
+ await trigger.click();
40
+ const list = page.getByRole("listbox");
41
+ await expect(list).toBeVisible();
42
+ await list.getByRole("option", { name: "Grand ménage" }).click();
43
+ await expect(list).toBeHidden();
44
+ await expect(trigger).toHaveText("Grand ménage");
45
+ const posted = page.waitForRequest(r => r.method() === "POST" && new URL(r.url()).pathname === "/quote");
46
+ await fillAndSend(page);
47
+ expect(new URLSearchParams((await posted).postData() ?? "").get("subject")).toBe("deep");
48
+ await expect(page).toHaveURL(/\/fr\/thanks$/);
49
+ });
50
+
51
+ test("works from the keyboard", async ({ page }) => {
52
+ await page.goto("/fr#quote");
53
+ const trigger = page.getByRole("combobox", { name: SUBJECT });
54
+ await trigger.focus();
55
+ await page.keyboard.press("ArrowDown");
56
+ await expect(page.getByRole("option", { name: "Ménage courant" })).toBeFocused();
57
+ await page.keyboard.press("ArrowDown");
58
+ await page.keyboard.press("Enter");
59
+ await expect(page.getByRole("listbox")).toBeHidden();
60
+ await expect(trigger).toBeFocused();
61
+ await expect(trigger).toHaveText("Grand ménage");
62
+ await page.keyboard.press("Tab");
63
+ await expect(page.getByLabel("Ville ou code postal")).toBeFocused();
64
+ });
65
+ });
66
+
67
+ test("the field is the same box before and after hydration", async ({ browser }) => {
68
+ const box = async (javaScriptEnabled: boolean) => {
69
+ const context = await browser.newContext({ javaScriptEnabled });
70
+ const page = await context.newPage();
71
+ await page.goto("/fr#quote");
72
+ const control = javaScriptEnabled ? page.getByRole("combobox", { name: SUBJECT }) : page.locator("#quote select[name=subject]");
73
+ await expect(control).toBeVisible();
74
+ const rect = await control.evaluate(el => {
75
+ const r = el.getBoundingClientRect();
76
+ return { width: r.width, height: r.height, x: r.x, border: getComputedStyle(el).borderTopWidth, radius: getComputedStyle(el).borderTopLeftRadius };
77
+ });
78
+ await context.close();
79
+ return rect;
80
+ };
81
+ expect(await box(true)).toEqual(await box(false));
82
+ });