@evinvest/kitstart 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -4
- package/dist/react/AreaChips.d.ts +2 -1
- package/dist/react/AreaChips.js +2 -2
- package/dist/react/Coverage.d.ts +2 -1
- package/dist/react/Coverage.js +2 -1
- package/dist/react/Faq.d.ts +1 -1
- package/dist/react/Faq.js +2 -1
- package/dist/react/FormSelect.d.ts +45 -0
- package/dist/react/FormSelect.js +42 -0
- package/dist/react/LangSwitch.js +8 -1
- package/dist/react/MapFacade.d.ts +4 -1
- package/dist/react/MapFacade.js +2 -2
- package/dist/react/PlaceDirectory.d.ts +1 -1
- package/dist/react/PlaceDirectory.js +2 -1
- package/dist/react/StatusScreen.d.ts +2 -2
- package/dist/react/StatusScreen.js +2 -4
- package/dist/react/form-select-value.d.ts +20 -0
- package/dist/react/form-select-value.js +72 -0
- package/dist/react/index.d.ts +3 -2
- package/dist/react/index.js +2 -1
- package/dist/react/parts.d.ts +7 -0
- package/dist/react/parts.js +33 -1
- package/package.json +2 -2
- package/template/README.md +3 -0
- package/template/flake.nix +1 -1
- package/template/package.json +2 -2
- package/template/src/views/home/ui/PlaceHome.tsx +7 -9
- package/template/tests/e2e/quote-form.spec.ts +82 -0
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.
|
|
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,16 +98,77 @@ 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
|
|
105
105
|
kit's classes so the brand's utility wins. No descendant selectors: they
|
|
106
|
-
break silently when a widget's markup moves.
|
|
106
|
+
break silently when a widget's markup moves. A size in a part keeps the
|
|
107
|
+
kit's line height: tailwind-merge drops an earlier `leading-*` for any later
|
|
108
|
+
font size, so the widgets put their `leading-*` after the brand's classes —
|
|
109
|
+
`code: "text-[88px]"` stays `leading-none`. To change the line height, name
|
|
110
|
+
it (`"text-[88px] leading-tight"`, or `"text-lg/7"`). The header's language
|
|
111
|
+
switch is `StatusScreen`'s `lang`: below `md` it takes a row of its own
|
|
112
|
+
(`order-last w-full`); `lang: "order-none w-auto"` keeps one row.
|
|
113
|
+
`QuoteFormShell` is headless: it owns the hidden fields and the
|
|
107
114
|
honeypot the funnel reads; the visible fields are the brand's children.
|
|
108
115
|
`StatusScreen` takes the brand's name and marks as props, so the client error
|
|
109
116
|
boundary never imports the site config.
|
|
110
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
|
+
|
|
111
172
|
Tailwind v4 does not scan `node_modules`; the brand's `globals.css` names the
|
|
112
173
|
package:
|
|
113
174
|
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Where the van goes, as chips. A visitor's first question is whether you
|
|
3
3
|
* come to them at all; a service-area business has no map to show instead.
|
|
4
4
|
*/
|
|
5
|
-
export declare function AreaChips({ areas, className }: {
|
|
5
|
+
export declare function AreaChips({ areas, className, chipClassName }: {
|
|
6
6
|
areas: readonly string[];
|
|
7
7
|
className?: string;
|
|
8
|
+
chipClassName?: string;
|
|
8
9
|
}): import("react").JSX.Element | null;
|
package/dist/react/AreaChips.js
CHANGED
|
@@ -4,8 +4,8 @@ import { cn } from "@evinvest/uikit";
|
|
|
4
4
|
* Where the van goes, as chips. A visitor's first question is whether you
|
|
5
5
|
* come to them at all; a service-area business has no map to show instead.
|
|
6
6
|
*/
|
|
7
|
-
export function AreaChips({ areas, className }) {
|
|
7
|
+
export function AreaChips({ areas, className, chipClassName }) {
|
|
8
8
|
if (areas.length === 0)
|
|
9
9
|
return null;
|
|
10
|
-
return (_jsx("ul", { className: cn("flex flex-wrap gap-2 md:gap-2.5", className), children: areas.map(area => (_jsx("li", { className: "rounded-full border border-border bg-muted px-4 py-2 text-sm font-medium text-ink-mid", children: area }, area))) }));
|
|
10
|
+
return (_jsx("ul", { className: cn("flex flex-wrap gap-2 md:gap-2.5", className), children: areas.map(area => (_jsx("li", { className: cn("rounded-full border border-border bg-muted px-4 py-2 text-sm font-medium text-ink-mid", chipClassName), children: area }, area))) }));
|
|
11
11
|
}
|
package/dist/react/Coverage.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
import { type Place } from "../core/place/types.js";
|
|
3
3
|
import type { PartClassNames } from "./parts.js";
|
|
4
|
-
|
|
4
|
+
/** `chip` is each commune in `chips`; `mapShow` and `mapAddress` are the map button's two lines. */
|
|
5
|
+
export type CoveragePart = "chips" | "chip" | "landmark" | "map" | "mapShow" | "mapAddress";
|
|
5
6
|
/**
|
|
6
7
|
* Where a place works: its communes as chips, and — for a storefront only —
|
|
7
8
|
* its landmark and a map behind a click. A service-area business has no
|
package/dist/react/Coverage.js
CHANGED
|
@@ -6,5 +6,6 @@ import { MapFacade } from "./MapFacade.js";
|
|
|
6
6
|
export function Coverage({ place, locale, map, head, className, classNames: c }) {
|
|
7
7
|
const front = storefrontOf(place);
|
|
8
8
|
const address = front && `${front.address.street}, ${front.address.postalCode} ${front.address.locality}`;
|
|
9
|
-
|
|
9
|
+
const mapParts = { ...(c?.mapShow ? { show: c.mapShow } : {}), ...(c?.mapAddress ? { address: c.mapAddress } : {}) };
|
|
10
|
+
return (_jsxs("div", { className: cn("flex flex-col gap-6 md:gap-8", className), children: [head, _jsx(AreaChips, { areas: servedLocalities(place), ...(c?.chips ? { className: c.chips } : {}), ...(c?.chip ? { chipClassName: c.chip } : {}) }), front?.landmark && _jsx("p", { className: cn("text-sm text-ink-soft", c?.landmark), children: front.landmark[locale] }), address && map && _jsx(MapFacade, { query: `${place.gbpName}, ${address}`, title: map.title, show: map.show, address: address, ...(c?.map ? { className: c.map } : {}), classNames: mapParts })] }));
|
|
10
11
|
}
|
package/dist/react/Faq.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
import type { QuestionAnswer } from "../core/seo/ld.js";
|
|
3
|
-
import type
|
|
3
|
+
import { type PartClassNames } from "./parts.js";
|
|
4
4
|
export type FaqPart = "list" | "item" | "summary" | "question" | "icon" | "answer";
|
|
5
5
|
/**
|
|
6
6
|
* `<details>`, not an accordion component: it opens without hydration, is
|
package/dist/react/Faq.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { cn } from "@evinvest/uikit";
|
|
3
|
+
import { partWithLeading } from "./parts.js";
|
|
3
4
|
export function Faq({ items, head, id = "faq", className, classNames: c }) {
|
|
4
|
-
return (_jsxs("div", { id: id, className: cn("flex flex-col gap-6 md:gap-11", className), children: [head, _jsx("div", { className: cn("overflow-hidden rounded-xl border border-border bg-background", c?.list), children: items.map(item => (_jsxs("details", { className: cn("group border-b border-border last:border-b-0", c?.item), children: [_jsxs("summary", { className: cn("flex cursor-pointer list-none items-start gap-4 px-5 py-5 md:px-8 md:py-6 [&::-webkit-details-marker]:hidden", c?.summary), children: [_jsx("span", { className:
|
|
5
|
+
return (_jsxs("div", { id: id, className: cn("flex flex-col gap-6 md:gap-11", className), children: [head, _jsx("div", { className: cn("overflow-hidden rounded-xl border border-border bg-background", c?.list), children: items.map(item => (_jsxs("details", { className: cn("group border-b border-border last:border-b-0", c?.item), children: [_jsxs("summary", { className: cn("flex cursor-pointer list-none items-start gap-4 px-5 py-5 md:px-8 md:py-6 [&::-webkit-details-marker]:hidden", c?.summary), children: [_jsx("span", { className: partWithLeading("flex-1 font-display text-lg font-bold text-ink", "leading-snug", c?.question), children: item.q }), _jsx("span", { className: cn("text-lg text-primary-ink transition-transform group-open:rotate-45", c?.icon), "aria-hidden": "true", children: "+" })] }), _jsx("p", { className: partWithLeading("px-5 pb-5 text-base text-ink-soft md:px-8 md:pb-6", "leading-relaxed", c?.answer), children: item.a })] }, item.q))) })] }));
|
|
5
6
|
}
|
|
@@ -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
|
+
}
|
package/dist/react/LangSwitch.js
CHANGED
|
@@ -12,6 +12,13 @@ export function withLang(href, locale) {
|
|
|
12
12
|
return `//${url.host}${rest}`;
|
|
13
13
|
return /^[a-z][a-z0-9+.-]*:/i.test(href) ? `${url.origin}${rest}` : rest;
|
|
14
14
|
}
|
|
15
|
+
// A hit area around each code, centred on it by a pseudo-element so the visual
|
|
16
|
+
// size and the focus ring stay the link's own: 44 px tall (Apple's HIG), and
|
|
17
|
+
// 6 px wider on each side — about half the gap-dot-gap between codes, so
|
|
18
|
+
// neighbours never overlap and a tap always lands on the code it is nearest to.
|
|
19
|
+
// That still clears WCAG 2.5.8's 24 px; a full 44 px width would need wider
|
|
20
|
+
// spacing between the codes.
|
|
21
|
+
const HIT_AREA = "relative after:absolute after:top-1/2 after:left-1/2 after:h-full after:min-h-11 after:w-[calc(100%+0.75rem)] after:-translate-x-1/2 after:-translate-y-1/2";
|
|
15
22
|
export function LangSwitch({ current, locales, hrefs, labels, label = "Language", className }) {
|
|
16
|
-
return (_jsx("nav", { "aria-label": label, className: cn("flex items-center gap-1.5", className), children: locales.map((locale, i) => (_jsxs("span", { className: "flex items-center gap-1.5", children: [i > 0 && (_jsx("span", { "aria-hidden": "true", className: "opacity-40", children: "\u00B7" })), _jsx("a", { href: withLang(hrefs[locale], locale), hrefLang: locale, lang: locale, "aria-label": labels?.[locale], "aria-current": locale === current ? "true" : undefined, className: locale === current ? "font-semibold" : "opacity-60 hover:opacity-100", children: locale.toUpperCase() })] }, locale))) }));
|
|
23
|
+
return (_jsx("nav", { "aria-label": label, className: cn("flex items-center gap-1.5", className), children: locales.map((locale, i) => (_jsxs("span", { className: "flex items-center gap-1.5", children: [i > 0 && (_jsx("span", { "aria-hidden": "true", className: "opacity-40", children: "\u00B7" })), _jsx("a", { href: withLang(hrefs[locale], locale), hrefLang: locale, lang: locale, "aria-label": labels?.[locale], "aria-current": locale === current ? "true" : undefined, className: cn(HIT_AREA, locale === current ? "font-semibold" : "opacity-60 hover:opacity-100"), children: locale.toUpperCase() })] }, locale))) }));
|
|
17
24
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { PartClassNames } from "./parts.js";
|
|
2
|
+
export type MapFacadePart = "show" | "address";
|
|
1
3
|
/**
|
|
2
4
|
* Google's map behind a click. Until the visitor asks, the page holds a button
|
|
3
5
|
* and nothing else — no Google request, no cookie — so a cookieless page stays
|
|
@@ -14,5 +16,6 @@ export interface MapFacadeProps {
|
|
|
14
16
|
/** The address, printed under the call to action. */
|
|
15
17
|
address: string;
|
|
16
18
|
className?: string;
|
|
19
|
+
classNames?: PartClassNames<MapFacadePart>;
|
|
17
20
|
}
|
|
18
|
-
export declare function MapFacade({ query, title, show, address, className }: MapFacadeProps): import("react").JSX.Element;
|
|
21
|
+
export declare function MapFacade({ query, title, show, address, className, classNames: c }: MapFacadeProps): import("react").JSX.Element;
|
package/dist/react/MapFacade.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { ClickToLoad } from "@evinvest/marketing/click-to-load";
|
|
4
4
|
import { cn } from "@evinvest/uikit";
|
|
5
|
-
export function MapFacade({ query, title, show, address, className }) {
|
|
5
|
+
export function MapFacade({ query, title, show, address, className, classNames: c }) {
|
|
6
6
|
const src = `https://www.google.com/maps?q=${encodeURIComponent(query)}&output=embed`;
|
|
7
|
-
return (_jsx(ClickToLoad, { className: cn("relative aspect-video w-full overflow-hidden rounded-xl border border-border bg-muted md:aspect-[21/9]", className), placeholder: load => (_jsxs("button", { type: "button", onClick: load, className: "flex size-full flex-col items-center justify-center gap-2 px-6 text-center hover:bg-hover", children: [_jsx("span", { className: "font-display text-lg font-bold text-ink", children: show }), _jsx("span", { className: "text-sm text-ink-soft", children: address })] })), children: _jsx("iframe", { title: title, src: src, loading: "lazy", referrerPolicy: "no-referrer-when-downgrade", className: "absolute inset-0 size-full border-0" }) }));
|
|
7
|
+
return (_jsx(ClickToLoad, { className: cn("relative aspect-video w-full overflow-hidden rounded-xl border border-border bg-muted md:aspect-[21/9]", className), placeholder: load => (_jsxs("button", { type: "button", onClick: load, className: "flex size-full flex-col items-center justify-center gap-2 px-6 text-center hover:bg-hover", children: [_jsx("span", { className: cn("font-display text-lg font-bold text-ink", c?.show), children: show }), _jsx("span", { className: cn("text-sm text-ink-soft", c?.address), children: address })] })), children: _jsx("iframe", { title: title, src: src, loading: "lazy", referrerPolicy: "no-referrer-when-downgrade", className: "absolute inset-0 size-full border-0" }) }));
|
|
8
8
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
import { type Place } from "../core/place/types.js";
|
|
3
|
-
import type
|
|
3
|
+
import { type PartClassNames } from "./parts.js";
|
|
4
4
|
export type PlaceDirectoryPart = "list" | "card" | "name" | "address" | "phone" | "link";
|
|
5
5
|
/**
|
|
6
6
|
* The apex's one job on a `subdomains` site: send the visitor to their place.
|
|
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { cn } from "@evinvest/uikit";
|
|
3
3
|
import { telHref } from "@evinvest/marketing";
|
|
4
4
|
import { storefrontOf } from "../core/place/types.js";
|
|
5
|
+
import { partWithLeading } from "./parts.js";
|
|
5
6
|
export function PlaceDirectory(props) {
|
|
6
7
|
const { places, head, id = "points", className } = props;
|
|
7
8
|
return (_jsxs("div", { id: id, className: cn("flex flex-col gap-7 md:gap-10", className), children: [head, _jsx("ul", { className: cn("grid gap-4 sm:grid-cols-2 lg:grid-cols-3", props.classNames?.list), children: places.map(place => (_jsx(PlaceCard, { place: place, ...props }, place.slug))) })] }));
|
|
@@ -9,5 +10,5 @@ export function PlaceDirectory(props) {
|
|
|
9
10
|
function PlaceCard({ place, locale, hrefOf, phoneOf, openLabel, classNames: c }) {
|
|
10
11
|
const phone = phoneOf(place);
|
|
11
12
|
const front = storefrontOf(place);
|
|
12
|
-
return (_jsxs("li", { className: cn("flex flex-col gap-3 rounded-xl border border-border bg-card p-6 md:p-7", c?.card), children: [_jsx("p", { className: cn("font-display text-xl font-bold text-ink", c?.name), children: place.name[locale] }), front && (_jsxs("address", { className:
|
|
13
|
+
return (_jsxs("li", { className: cn("flex flex-col gap-3 rounded-xl border border-border bg-card p-6 md:p-7", c?.card), children: [_jsx("p", { className: cn("font-display text-xl font-bold text-ink", c?.name), children: place.name[locale] }), front && (_jsxs("address", { className: partWithLeading("text-sm not-italic text-ink-soft", "leading-relaxed", c?.address), children: [front.address.street, _jsx("br", {}), front.address.postalCode, " ", front.address.locality] })), phone && (_jsx("a", { href: telHref(phone), className: cn("font-display text-lg font-bold text-primary-ink", c?.phone), children: phone })), _jsxs("a", { href: hrefOf(place), className: cn("mt-auto text-sm font-medium text-ink underline-offset-4 hover:underline", c?.link), children: [openLabel, " \u2192"] })] }));
|
|
13
14
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
import type { CopySlice, StatusCopy, StatusScreenText } from "../core/content.js";
|
|
3
3
|
import type { StatusTarget } from "../core/status.js";
|
|
4
|
-
import type
|
|
5
|
-
export type StatusScreenPart = "header" | "logo" | "phone" | "main" | "eyebrow" | "code" | "headline" | "body" | "actions" | "primaryButton" | "secondaryButton" | "strip" | "footer";
|
|
4
|
+
import { type PartClassNames } from "./parts.js";
|
|
5
|
+
export type StatusScreenPart = "header" | "logo" | "lang" | "phone" | "main" | "eyebrow" | "code" | "headline" | "body" | "actions" | "primaryButton" | "secondaryButton" | "strip" | "footer";
|
|
6
6
|
/**
|
|
7
7
|
* The 404, the 500 and the post-submit confirmation: one screen over a
|
|
8
8
|
* `StatusCopy`, differing only in eyebrow, numeral, headline and buttons. A
|
|
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { telHref } from "@evinvest/marketing";
|
|
3
3
|
import { Button, Check, cn } from "@evinvest/uikit";
|
|
4
4
|
import { LangSwitch } from "./LangSwitch.js";
|
|
5
|
+
import { partWithLeading } from "./parts.js";
|
|
5
6
|
export function StatusScreen(props) {
|
|
6
7
|
const { copy, status, target, locales, labels, brandName, logo, mark, className, buttonClassName, classNames: c } = props;
|
|
7
8
|
const { t, f } = copy;
|
|
@@ -15,8 +16,5 @@ export function StatusScreen(props) {
|
|
|
15
16
|
// A brand with no phone falls back to "home" rather than a dead button.
|
|
16
17
|
const primary = action(status.primary) ?? action("home");
|
|
17
18
|
const secondary = action(status.secondary);
|
|
18
|
-
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: "order-last w-full text-sm font-medium text-ink-soft md:order-none md:w-auto" }), 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:
|
|
19
|
-
// The kit's outline border is `--border`, 14 % ink: 1.4:1 on the
|
|
20
|
-
// dark polarity. Half ink clears 3:1 for a control on either one.
|
|
21
|
-
_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(" · ") }) })] }));
|
|
22
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
|
+
}
|
package/dist/react/index.d.ts
CHANGED
|
@@ -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 `
|
|
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,8 +15,9 @@ 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
|
-
export { MapFacade, type MapFacadeProps } from "./MapFacade.js";
|
|
20
|
+
export { MapFacade, type MapFacadePart, type MapFacadeProps } from "./MapFacade.js";
|
|
20
21
|
export { PlaceDirectory, type PlaceDirectoryPart, type PlaceDirectoryProps } from "./PlaceDirectory.js";
|
|
21
22
|
export { PHONE_INPUT_PROPS, QuoteFormShell, type QuoteFormShellProps } from "./QuoteFormShell.js";
|
|
22
23
|
export { StatusScreen, type StatusScreenPart, type StatusScreenProps } from "./StatusScreen.js";
|
package/dist/react/index.js
CHANGED
|
@@ -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 `
|
|
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/dist/react/parts.d.ts
CHANGED
|
@@ -5,3 +5,10 @@
|
|
|
5
5
|
* when the widget's markup moves.
|
|
6
6
|
*/
|
|
7
7
|
export type PartClassNames<P extends string> = Readonly<Partial<Record<P, string>>>;
|
|
8
|
+
/**
|
|
9
|
+
* `cn(base, part)` for a part that has a line height: tailwind-merge drops an
|
|
10
|
+
* earlier `leading-*` for any later font size, so `text-[88px]` from a brand
|
|
11
|
+
* would silently take the kit's `leading-none` with it. `leading` goes after
|
|
12
|
+
* the brand's classes instead — unless they set a line height themselves.
|
|
13
|
+
*/
|
|
14
|
+
export declare function partWithLeading(base: string, leading: string, part: string | undefined): string;
|
package/dist/react/parts.js
CHANGED
|
@@ -1 +1,33 @@
|
|
|
1
|
-
|
|
1
|
+
import { cn } from "@evinvest/uikit";
|
|
2
|
+
// Probes no brand writes: a probe equal to a brand's class would merge into it
|
|
3
|
+
// and read as "no conflict".
|
|
4
|
+
const LEADING_PROBE = "leading-[0.001]";
|
|
5
|
+
const SIZE_PROBE = "text-[0.001px]";
|
|
6
|
+
const conflictsWith = (probe, token) => !cn(probe, token).split(" ").includes(probe);
|
|
7
|
+
/**
|
|
8
|
+
* Whether a part's classes set a line height of their own — a `leading-*`, or
|
|
9
|
+
* a size with one (`text-lg/7`) — at the base variant. tailwind-merge decides
|
|
10
|
+
* the conflicts, so variants and `!` are read the way the merge reads them.
|
|
11
|
+
*/
|
|
12
|
+
function setsLineHeight(part) {
|
|
13
|
+
return part
|
|
14
|
+
.split(/\s+/)
|
|
15
|
+
.filter(Boolean)
|
|
16
|
+
.some(token => {
|
|
17
|
+
if (!conflictsWith(LEADING_PROBE, token))
|
|
18
|
+
return false;
|
|
19
|
+
if (!conflictsWith(SIZE_PROBE, token))
|
|
20
|
+
return true;
|
|
21
|
+
// A size: it carries a line height only after a `/` outside brackets.
|
|
22
|
+
return token.replace(/\[[^\]]*\]/g, "").includes("/");
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* `cn(base, part)` for a part that has a line height: tailwind-merge drops an
|
|
27
|
+
* earlier `leading-*` for any later font size, so `text-[88px]` from a brand
|
|
28
|
+
* would silently take the kit's `leading-none` with it. `leading` goes after
|
|
29
|
+
* the brand's classes instead — unless they set a line height themselves.
|
|
30
|
+
*/
|
|
31
|
+
export function partWithLeading(base, leading, part) {
|
|
32
|
+
return part && setsLineHeight(part) ? cn(base, part) : cn(base, part, leading);
|
|
33
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evinvest/kitstart",
|
|
3
|
-
"version": "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.
|
|
83
|
+
"@evinvest/uikit": ">=0.21.0 <1",
|
|
84
84
|
"next": "^16",
|
|
85
85
|
"react": "^19",
|
|
86
86
|
"server-only": "*"
|
package/template/README.md
CHANGED
|
@@ -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
|
package/template/flake.nix
CHANGED
|
@@ -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.
|
|
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
|
|
package/template/package.json
CHANGED
|
@@ -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.
|
|
23
|
+
"@evinvest/kitstart": "^0.2.0",
|
|
24
24
|
"@evinvest/marketing": "^0.3.0",
|
|
25
|
-
"@evinvest/uikit": "^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
|
-
<
|
|
46
|
-
{LEAD.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
+
});
|