@evinvest/kitstart 0.18.0 → 0.19.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 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`, `bookingRoute`, `confirmRoute`, `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`, `FormSelect` (client), `LeadCapture` (client), `LeadBooking` (client) and its adapters, `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) with `loadLazyParts` for tests, `LeadCapture` (client), `LeadBooking` (client) and its adapters, `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 the target in `tests/bundle_budget.txt` (passes with a warning up to its tolerance, 20 % by default — see [The bundle budget](#the-bundle-budget)); 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.18.0"; # the version in package-lock.json
48
+ inputs.ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.19.1"; # the version in package-lock.json
49
49
  inputs.ev.inputs.v_flakes.follows = "v_flakes";
50
50
 
51
51
  landing = ev.lib.mkLanding {
@@ -143,12 +143,16 @@ boundary never imports the site config.
143
143
 
144
144
  A form's select, for the fields inside `QuoteFormShell`. On the server and
145
145
  without JavaScript it is the kit's `NativeSelect` — a real `<select name>`
146
- the form posts as is. After hydration (`useSyncExternalStore`, so the
147
- server's HTML and the first client render agree) it is the kit's `Select`:
148
- the list drawn in the palette, never the platform's menu, and the value in an
149
- input of the same `name`. Both states are one box — height, width, border,
150
- radius and inset — so the swap moves nothing (checked by the template's e2e,
151
- which CI runs: `npm run check:template -- --e2e`).
146
+ the form posts as is. Hydrated, it stays that select, live — a choice there
147
+ reaches `onValueChange` — until the kit's `Select` is here: a chunk of its
148
+ own, asked for once the page is idle, or sooner when a mouse moves over the
149
+ field or it takes the focus. Then it is the kit's `Select`: the list drawn in
150
+ the palette, never the platform's menu, and the value in an input of the
151
+ same `name`. A press before that opens the platform's list at once, never a
152
+ wait on the chunk; a select holding the focus (its picker may be open) is
153
+ swapped only once the focus leaves. Both states are one box — height, width,
154
+ border, radius and inset — so the swap moves nothing (checked by the
155
+ template's e2e, which CI runs: `npm run check:template -- --e2e`).
152
156
 
153
157
  ```tsx
154
158
  <Field className="flex flex-col gap-2">
@@ -190,9 +194,11 @@ which CI runs: `npm run check:template -- --e2e`).
190
194
  letters jump, Enter chooses, Escape and Tab close with focus back on the
191
195
  trigger; the trigger is a `combobox` named by the `FieldLabel`, and so is
192
196
  its list, with the kit's focus ring.
193
- - **Weight.** It is a client module and pulls the kit's `Select` into the
194
- page: 3.8 KB gz of first-load JS on the template's place page (152,774 →
195
- 156,565 B of its 158,000 B budget).
197
+ - **Weight.** The kit's `Select` — the list's placing, dismissal and keys —
198
+ is out of the first load, in `FormSelectKit`'s chunk; the native select and
199
+ the value's plumbing stay. With `@evinvest/uikit` before 0.28 a page whose
200
+ form uses the kit's `Field` still loads `Select` up front: that `Field`
201
+ imported `SelectTrigger` to recognise the control it labels.
196
202
  - **Controlled, as an `<input>`.** Under `value` the prop is the value: a
197
203
  pick only calls `onValueChange`, and the parent's state (or a reset of its
198
204
  own, `setSubject("")` back to the placeholder) is what moves it. A form
@@ -1,4 +1,5 @@
1
1
  export { PRICING_CURRENCY, PRICING_FORMAT, PRICING_LIMITS, type AddOption, type DiscountOption, type MultiplyOption, type NeedPricing, type Price, type PriceLine, type PricingInput, type PricingInputKind, type PricingInputs, type PricingLabels, type PricingModel, } from "./model.js";
2
2
  export { CENTS_BUCKETS, centsBucket, mulBp, priceOf, roundTo } from "./price.js";
3
- export { labelOf, parsePricingModel, PricingModelError, pricingProblems, pricingProblemsFor } from "./validate.js";
3
+ export { labelOf } from "./label.js";
4
+ export { parsePricingModel, PricingModelError, pricingProblems, pricingProblemsFor } from "./validate.js";
4
5
  export { answeredUnknown, ESTIMATE_FIELD_PREFIX, ESTIMATE_UNKNOWN, estimateField, flowOf, LEAD_FLOWS, readEstimateInputs, type LeadFlow, type LeadFlows } from "./flow.js";
@@ -1,4 +1,5 @@
1
1
  export { PRICING_CURRENCY, PRICING_FORMAT, PRICING_LIMITS, } from "./model.js";
2
2
  export { CENTS_BUCKETS, centsBucket, mulBp, priceOf, roundTo } from "./price.js";
3
- export { labelOf, parsePricingModel, PricingModelError, pricingProblems, pricingProblemsFor } from "./validate.js";
3
+ export { labelOf } from "./label.js";
4
+ export { parsePricingModel, PricingModelError, pricingProblems, pricingProblemsFor } from "./validate.js";
4
5
  export { answeredUnknown, ESTIMATE_FIELD_PREFIX, ESTIMATE_UNKNOWN, estimateField, flowOf, LEAD_FLOWS, readEstimateInputs } from "./flow.js";
@@ -0,0 +1,7 @@
1
+ import type { PricingLabels } from "./model.js";
2
+ /**
3
+ * The label for `locale`, or the first one the model has. Apart from the
4
+ * validator: the form names its answers with it in the browser, where the
5
+ * validator's weight has no use.
6
+ */
7
+ export declare function labelOf(labels: PricingLabels, locale: string): string;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The label for `locale`, or the first one the model has. Apart from the
3
+ * validator: the form names its answers with it in the browser, where the
4
+ * validator's weight has no use.
5
+ */
6
+ export function labelOf(labels, locale) {
7
+ return (Object.hasOwn(labels, locale) ? labels[locale] : undefined) ?? Object.values(labels)[0] ?? "";
8
+ }
@@ -1,4 +1,4 @@
1
- import { type PricingLabels, type PricingModel } from "./model.js";
1
+ import { type PricingModel } from "./model.js";
2
2
  /**
3
3
  * A model that does not validate, with every reason found. A model is taken
4
4
  * whole or not at all: one bad field refuses it, so a page never prices from
@@ -23,5 +23,3 @@ export declare function parsePricingModel(value: unknown): PricingModel;
23
23
  * must not show an option with no words.
24
24
  */
25
25
  export declare function pricingProblemsFor(value: unknown, locales: readonly string[]): string[];
26
- /** The label for `locale`, or the first one the model has. */
27
- export declare function labelOf(labels: PricingLabels, locale: string): string;
@@ -238,7 +238,3 @@ export function pricingProblemsFor(value, locales) {
238
238
  }
239
239
  return out;
240
240
  }
241
- /** The label for `locale`, or the first one the model has. */
242
- export function labelOf(labels, locale) {
243
- return (Object.hasOwn(labels, locale) ? labels[locale] : undefined) ?? Object.values(labels)[0] ?? "";
244
- }
@@ -4,9 +4,11 @@ import type { PartClassNames } from "./parts.js";
4
4
  * A form's choice that works before any script and looks like the kit after
5
5
  * it. The server, and a browser without JavaScript, get the kit's
6
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.
7
+ * has hydrated and is idle, or as soon as the visitor reaches for the field,
8
+ * it becomes the kit's `Select`, drawn in the palette instead of the
9
+ * platform's menu, with the value in an input of the same `name`; until then
10
+ * the native select is live, its choices reported. Both are the same box
11
+ * (height, width, border, inset), so the swap moves nothing.
10
12
  *
11
13
  * Inside a `Field`, its label names whichever control is rendered: the
12
14
  * `Field`'s id goes to the `<select>` first and to the trigger after.
@@ -1,45 +1,91 @@
1
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";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { cn, NativeSelect, NativeSelectOption } from "@evinvest/uikit";
4
+ import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
5
5
  import { useFormSelectValue } from "./form-select-value.js";
6
+ import { lazyPart } from "./lazy-part.js";
6
7
  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: "" };
8
+ /** The scripted state, in a chunk of its own (`FormSelectKit`). */
9
+ const KIT = lazyPart(() => import("./FormSelectKit.js").then(m => m.FormSelectKit));
13
10
  const subscribe = () => () => { };
14
11
  /** `false` on the server and while hydrating, `true` from the render after. */
15
12
  const useHydrated = () => useSyncExternalStore(subscribe, () => true, () => false);
16
13
  export function FormSelect(props) {
17
- return _jsx(FormSelectView, { ...props, scripted: useHydrated() });
14
+ const hydrated = useHydrated();
15
+ const kit = useSyncExternalStore(KIT.subscribe, KIT.isLoaded, () => false);
16
+ return _jsx(FormSelectField, { ...props, hydrated: hydrated, kit: hydrated && kit });
18
17
  }
19
18
  /**
20
19
  * Both states on demand — for the widget gallery, which renders on the server
21
20
  * and would only ever see the native one. Not part of the package's surface.
22
21
  */
23
22
  export function FormSelectView({ scripted, ...props }) {
23
+ return _jsx(FormSelectField, { ...props, hydrated: scripted, kit: scripted });
24
+ }
25
+ /** Asks for the scripted state when the page has nothing better to do. */
26
+ function whenIdle(run) {
27
+ if (typeof window.requestIdleCallback === "function") {
28
+ const handle = window.requestIdleCallback(run, { timeout: 2000 });
29
+ return () => window.cancelIdleCallback(handle);
30
+ }
31
+ const handle = window.setTimeout(run, 200);
32
+ return () => window.clearTimeout(handle);
33
+ }
34
+ /**
35
+ * The native select until the scripted one is here. `hydrated`: the page's
36
+ * script runs — the native select reports its choices and asks for the
37
+ * scripted state on idle, or sooner on the visitor's reach (a mouse over it,
38
+ * the focus). `kit`: the scripted state is loaded. A press before that opens
39
+ * the platform's list at once, as before the script: never a wait on a chunk.
40
+ */
41
+ function FormSelectField({ hydrated, kit, ...props }) {
24
42
  const { name, options, placeholder, required, disabled, size = "md", id, className, classNames } = props;
25
43
  const initial = props.defaultValue ?? (placeholder !== undefined ? "" : (options[0]?.value ?? ""));
26
44
  const native = useRef(null);
27
45
  // The box, not the trigger: `SelectTrigger` keeps its own ref for placing the list.
28
46
  const box = useRef(null);
29
- const state = useFormSelectValue({ initial, controlled: props.value, native, box, scripted, onValueChange: props.onValueChange });
47
+ // The native select has the focus — its own list may be open (a phone's
48
+ // picker): it stays until the focus leaves, rather than vanish under the finger.
49
+ const [focused, setFocused] = useState(false);
50
+ const scripted = kit && !focused;
51
+ const state = useFormSelectValue({ initial, controlled: props.value, native, box, live: hydrated, scripted, onValueChange: props.onValueChange });
30
52
  const hint = placeholder !== undefined ? { placeholder } : {};
31
53
  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,
34
- // Uncontrolled even under `value`: before the script nothing could
35
- // follow a change, and a pick made then must survive into the swap.
36
- defaultValue: state.value, wrapperClassName: cn(BOX, className), className: classNames?.trigger, ...aria, children: options.map(o => (_jsx(NativeSelectOption, { value: o.value, children: o.label }, o.value))) }));
37
- }
38
- 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: 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 ? (
39
- // Validated, so not `type="hidden"` (which the browser never checks);
40
- // under the trigger, out of the tab order, the accessibility tree and
41
- // the browser's autofill.
42
- _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" })) : (
43
- // Nothing chosen posts nothing, as a native select on its disabled placeholder.
44
- state.value !== "" && _jsx("input", { type: "hidden", name: name, value: state.value, disabled: disabled }))] }));
54
+ useEffect(() => (hydrated && !kit ? whenIdle(KIT.preload) : undefined), [hydrated, kit]);
55
+ // The native select is uncontrolled (see below): a value moved by the
56
+ // parent, or put back by a reset, is written into it here.
57
+ const written = useRef(state.value);
58
+ useLayoutEffect(() => {
59
+ if (written.current === state.value)
60
+ return;
61
+ written.current = state.value;
62
+ if (native.current && native.current.value !== state.value)
63
+ native.current.value = state.value;
64
+ }, [state.value]);
65
+ if (scripted)
66
+ return _jsx(KIT.Part, { field: props, box: box, value: state.value, choose: state.choose, open: state.open, setOpen: state.setOpen, onInvalid: state.onInvalid, aria: aria });
67
+ const live = hydrated
68
+ ? {
69
+ onChange: (e) => {
70
+ const picked = e.currentTarget.value;
71
+ state.choose(picked);
72
+ // Under a `value` the parent moves it, as on a controlled input.
73
+ if (props.value !== undefined)
74
+ e.currentTarget.value = props.value;
75
+ },
76
+ onPointerEnter: (e) => {
77
+ if (e.pointerType === "mouse")
78
+ KIT.preload();
79
+ },
80
+ onFocus: () => {
81
+ setFocused(true);
82
+ KIT.preload();
83
+ },
84
+ onBlur: () => setFocused(false),
85
+ }
86
+ : {};
87
+ return (_jsx(NativeSelect, { ref: native, name: name, id: id, size: size, ...hint, required: required, disabled: disabled,
88
+ // Uncontrolled even under `value`: before the script nothing could
89
+ // follow a change, and a pick made then must survive into the swap.
90
+ defaultValue: state.value, wrapperClassName: cn(BOX, className), className: classNames?.trigger, ...aria, ...live, children: options.map(o => (_jsx(NativeSelectOption, { value: o.value, children: o.label }, o.value))) }));
45
91
  }
@@ -0,0 +1,24 @@
1
+ import type { InvalidEvent, RefObject } from "react";
2
+ import type { FormSelectProps } from "./FormSelect.js";
3
+ /** What the scripted state is handed by `FormSelect`, which keeps the value across the swap. */
4
+ export interface FormSelectKitProps {
5
+ field: FormSelectProps;
6
+ box: RefObject<HTMLSpanElement | null>;
7
+ value: string;
8
+ choose: (value: string) => void;
9
+ open: boolean;
10
+ setOpen: (open: boolean) => void;
11
+ onInvalid: (e: InvalidEvent<HTMLInputElement>) => void;
12
+ aria: {
13
+ "aria-describedby": string | undefined;
14
+ "aria-invalid": true | undefined;
15
+ };
16
+ }
17
+ /**
18
+ * `FormSelect` once scripted: the kit's `Select`, the list drawn in the
19
+ * palette, with the value in an input of the same `name`. A chunk of its own
20
+ * — the list's placing, dismissal and keys weigh more than the rest of the
21
+ * field — fetched once the page is idle, or as soon as the visitor reaches
22
+ * for the field.
23
+ */
24
+ export declare function FormSelectKit({ field, box, value, choose, open, setOpen, onInvalid, aria }: FormSelectKitProps): import("react").JSX.Element;
@@ -0,0 +1,28 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { cn, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@evinvest/uikit";
4
+ const BOX = "relative inline-flex w-full";
5
+ // The trigger's geometry matched to `NativeSelect`'s at each size: full width,
6
+ // the same text size (16 px on phones, which keeps iOS from zooming).
7
+ // The chevron at full strength, as the native select draws it: the kit's
8
+ // trigger dims its own arrow.
9
+ const TRIGGER = "w-full min-w-0 text-ink [&>svg]:opacity-100";
10
+ const TRIGGER_TEXT = { sm: "text-base md:text-sm", md: "text-base md:text-sm", lg: "" };
11
+ /**
12
+ * `FormSelect` once scripted: the kit's `Select`, the list drawn in the
13
+ * palette, with the value in an input of the same `name`. A chunk of its own
14
+ * — the list's placing, dismissal and keys weigh more than the rest of the
15
+ * field — fetched once the page is idle, or as soon as the visitor reaches
16
+ * for the field.
17
+ */
18
+ export function FormSelectKit({ field, box, value, choose, open, setOpen, onInvalid, aria }) {
19
+ const { name, options, placeholder, required, disabled, size = "md", id, className, classNames } = field;
20
+ const hint = placeholder !== undefined ? { placeholder } : {};
21
+ return (_jsxs("span", { ref: box, "data-slot": "form-select", className: cn(BOX, className), children: [_jsxs(Select, { value: value, onValueChange: choose, open: open, onOpenChange: 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: 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 ? (
22
+ // Validated, so not `type="hidden"` (which the browser never checks);
23
+ // under the trigger, out of the tab order, the accessibility tree and
24
+ // the browser's autofill.
25
+ _jsx("input", { name: name, value: value, required: true, disabled: disabled, tabIndex: -1, "aria-hidden": "true", autoComplete: "off", onChange: () => { }, onInvalid: onInvalid, className: "pointer-events-none absolute inset-0 opacity-0" })) : (
26
+ // Nothing chosen posts nothing, as a native select on its disabled placeholder.
27
+ value !== "" && _jsx("input", { type: "hidden", name: name, value: value, disabled: disabled }))] }));
28
+ }
@@ -11,7 +11,7 @@ import { flowTextOf } from "../core/lead-capture-text.js";
11
11
  import { bookingOf } from "../core/booking/model.js";
12
12
  import { servedLocalities, storefrontOf } from "../core/place/types.js";
13
13
  import { ESTIMATE_UNKNOWN, estimateField, flowOf } from "../core/pricing/flow.js";
14
- import { labelOf } from "../core/pricing/validate.js";
14
+ import { labelOf } from "../core/pricing/label.js";
15
15
  import { focusNext } from "./focus-next.js";
16
16
  import { LeadBooking } from "./LeadBooking.js";
17
17
  import { LeadCapturePriced } from "./LeadCapturePriced.js";
@@ -5,7 +5,7 @@ import { useEffect, useMemo, useState } from "react";
5
5
  import { formatCents } from "../core/lead-capture-format.js";
6
6
  import { ESTIMATE_UNKNOWN, estimateField } from "../core/pricing/flow.js";
7
7
  import { priceOf } from "../core/pricing/price.js";
8
- import { labelOf } from "../core/pricing/validate.js";
8
+ import { labelOf } from "../core/pricing/label.js";
9
9
  import { TileGroup } from "./LeadCaptureTiles.js";
10
10
  /**
11
11
  * The screens an estimate's questions take in `steps`, in order: one each,
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { cn, Popover, PopoverContent, PopoverTrigger } from "@evinvest/uikit";
3
3
  import { useSyncExternalStore } from "react";
4
4
  import { fillText, formatCents } from "../core/lead-capture-format.js";
5
- import { labelOf } from "../core/pricing/validate.js";
5
+ import { labelOf } from "../core/pricing/label.js";
6
6
  function lineLabel(line, model, locale, text) {
7
7
  switch (line.kind) {
8
8
  case "base":
@@ -14,6 +14,9 @@ export declare function useFormSelectValue(opts: {
14
14
  controlled: string | undefined;
15
15
  native: RefObject<HTMLSelectElement | null>;
16
16
  box: RefObject<HTMLElement | null>;
17
+ /** The page's script runs: the native select, if still drawn, is the field. */
18
+ live: boolean;
19
+ /** The kit's `Select` is drawn. */
17
20
  scripted: boolean;
18
21
  onValueChange: ((value: string) => void) | undefined;
19
22
  }): {
@@ -12,7 +12,7 @@ const DEV = typeof process !== "undefined" && process.env.NODE_ENV !== "producti
12
12
  * so a switch back to uncontrolled keeps the last value, as an `<input>` does.
13
13
  */
14
14
  export function useFormSelectValue(opts) {
15
- const { initial, controlled, native, box, scripted, onValueChange } = opts;
15
+ const { initial, controlled, native, box, live, scripted, onValueChange } = opts;
16
16
  const [own, setOwn] = useState(controlled ?? initial);
17
17
  const [invalid, setInvalid] = useState(false);
18
18
  const [open, setOpen] = useState(false);
@@ -25,8 +25,8 @@ export function useFormSelectValue(opts) {
25
25
  const latest = useRef({ initial, isControlled, value, onValueChange });
26
26
  latest.current = { initial, isControlled, value, onValueChange };
27
27
  // A choice made in the native select before the script arrived survives the
28
- // swap: read in the hydration commit, before the scripted render. Once —
29
- // later renders never hold the native select. Under a `value` it is the
28
+ // swap: read in the hydration commit. Once — from then on the native
29
+ // select, while it stays, reports its choices itself. Under a `value` it is the
30
30
  // parent's to take, so it is reported instead of kept — and only a real
31
31
  // pick: a `value` no option carries leaves the browser on its own default,
32
32
  // which is no choice of the visitor's.
@@ -46,8 +46,8 @@ export function useFormSelectValue(opts) {
46
46
  // from a script's `checkValidity()` by the submit that starts it.
47
47
  const submitting = useRef(false);
48
48
  useEffect(() => {
49
- const form = box.current?.closest("form");
50
- if (!scripted || !form)
49
+ const form = (scripted ? box.current : native.current)?.closest("form");
50
+ if (!live || !form)
51
51
  return;
52
52
  const reset = () => {
53
53
  if (!latest.current.isControlled)
@@ -71,7 +71,7 @@ export function useFormSelectValue(opts) {
71
71
  form.removeEventListener("reset", reset);
72
72
  document.removeEventListener("click", submit, true);
73
73
  };
74
- }, [box, scripted]);
74
+ }, [box, native, live, scripted]);
75
75
  const choose = (next) => {
76
76
  if (!isControlled)
77
77
  setOwn(next);
@@ -23,6 +23,7 @@ export { Faq, type FaqPart, type FaqProps } from "./Faq.js";
23
23
  export { FormSelect, type FormSelectOption, type FormSelectPart, type FormSelectProps } from "./FormSelect.js";
24
24
  export { LangSwitch, withLang, type LangSwitchProps } from "./LangSwitch.js";
25
25
  export { LeadCapture, type LeadCapturePart, type LeadCaptureProps } from "./LeadCapture.js";
26
+ export { loadLazyParts } from "./lazy-part.js";
26
27
  export { LeadBooking, type LeadBookingProps } from "./LeadBooking.js";
27
28
  export type { BookingPart } from "./LeadBookingManual.js";
28
29
  export { BOOKING_ADAPTERS, BOOKING_EMBEDS, bookingAdapters, calComAdapter, calComEmbedAdapter, calComEmbedOrigin, googleCalendarAdapter, linkAdapter, manualAdapter, type BookedSlot, type BookingAdapter, type BookingAdapters, type BookingContext, } from "./booking-adapters.js";
@@ -22,6 +22,7 @@ export { Faq } from "./Faq.js";
22
22
  export { FormSelect } from "./FormSelect.js";
23
23
  export { LangSwitch, withLang } from "./LangSwitch.js";
24
24
  export { LeadCapture } from "./LeadCapture.js";
25
+ export { loadLazyParts } from "./lazy-part.js";
25
26
  export { LeadBooking } from "./LeadBooking.js";
26
27
  export { BOOKING_ADAPTERS, BOOKING_EMBEDS, bookingAdapters, calComAdapter, calComEmbedAdapter, calComEmbedOrigin, googleCalendarAdapter, linkAdapter, manualAdapter, } from "./booking-adapters.js";
27
28
  export { BOOKING_ACTION } from "./use-booking.js";
@@ -0,0 +1,33 @@
1
+ import { type ComponentType } from "react";
2
+ /** A piece of a client island in a chunk of its own, and the way to ask for that chunk early. */
3
+ export interface LazyPart<P extends object> {
4
+ /** Drawn where the piece goes; see {@link lazyPart}. */
5
+ Part: ComponentType<P>;
6
+ /** Asks for the chunk — once a page; later calls share the first. */
7
+ preload: () => void;
8
+ /** Whether the chunk is here, for `useSyncExternalStore` with {@link LazyPart.subscribe}. */
9
+ isLoaded: () => boolean;
10
+ subscribe: (onLoad: () => void) => () => void;
11
+ }
12
+ /**
13
+ * Resolves once every lazily drawn part of the kit is loaded — today
14
+ * `FormSelect`'s scripted list — for a test that renders a form and reads it
15
+ * at once (`beforeAll(loadLazyParts)`). A page never needs it: the server
16
+ * loads them as their modules load, and a browser fetches them when idle.
17
+ */
18
+ export declare function loadLazyParts(): Promise<void>;
19
+ /**
20
+ * A component a page pays for only when it draws it — the shape of the
21
+ * `messenger` variants (`LeadCaptureMessenger`), here for `FormSelect`'s
22
+ * scripted state.
23
+ *
24
+ * On the server every part is loaded as its module is, so a render finds it
25
+ * and writes it into the page whole: no boundary of its own, which React
26
+ * would reveal late. Hydrating, a part whose chunk is still on its way holds
27
+ * the island's hydration — the server's markup stays as it is, no fallback
28
+ * drawn — and `preload` from the island's render starts every chunk it will
29
+ * need at once, so none waits behind another. A part first drawn after
30
+ * hydration (a later step, the success) waits behind a boundary of its own,
31
+ * drawing nothing until its chunk is here.
32
+ */
33
+ export declare function lazyPart<P extends object>(load: () => Promise<ComponentType<P>>): LazyPart<P>;
@@ -0,0 +1,66 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { lazy, Suspense, useState, useSyncExternalStore } from "react";
4
+ const noop = () => () => undefined;
5
+ /** Every part's loader, for {@link loadLazyParts}. */
6
+ const LOADERS = [];
7
+ /**
8
+ * Resolves once every lazily drawn part of the kit is loaded — today
9
+ * `FormSelect`'s scripted list — for a test that renders a form and reads it
10
+ * at once (`beforeAll(loadLazyParts)`). A page never needs it: the server
11
+ * loads them as their modules load, and a browser fetches them when idle.
12
+ */
13
+ export function loadLazyParts() {
14
+ return Promise.all(LOADERS.map(load => load())).then(() => undefined);
15
+ }
16
+ /**
17
+ * A component a page pays for only when it draws it — the shape of the
18
+ * `messenger` variants (`LeadCaptureMessenger`), here for `FormSelect`'s
19
+ * scripted state.
20
+ *
21
+ * On the server every part is loaded as its module is, so a render finds it
22
+ * and writes it into the page whole: no boundary of its own, which React
23
+ * would reveal late. Hydrating, a part whose chunk is still on its way holds
24
+ * the island's hydration — the server's markup stays as it is, no fallback
25
+ * drawn — and `preload` from the island's render starts every chunk it will
26
+ * need at once, so none waits behind another. A part first drawn after
27
+ * hydration (a later step, the success) waits behind a boundary of its own,
28
+ * drawing nothing until its chunk is here.
29
+ */
30
+ export function lazyPart(load) {
31
+ let loaded = null;
32
+ let loading = null;
33
+ const listeners = new Set();
34
+ const fetchPart = () => {
35
+ loading ??= load().then(part => {
36
+ loaded = part;
37
+ for (const listener of listeners)
38
+ listener();
39
+ return part;
40
+ }, (error) => {
41
+ // A chunk that failed may be asked for again: the next draw retries.
42
+ loading = null;
43
+ throw error;
44
+ });
45
+ return loading;
46
+ };
47
+ const preload = () => void fetchPart().catch(() => undefined);
48
+ LOADERS.push(fetchPart);
49
+ if (typeof window === "undefined")
50
+ preload();
51
+ const Lazy = lazy(() => fetchPart().then(part => ({ default: part })));
52
+ function Part(props) {
53
+ const hydrating = useSyncExternalStore(noop, () => false, () => true);
54
+ // Chosen on the first render and kept: a structure swapped under the part would draw it anew.
55
+ const [Loaded] = useState(() => loaded);
56
+ const [wrapped] = useState(() => !hydrating);
57
+ if (Loaded)
58
+ return _jsx(Loaded, { ...props });
59
+ return wrapped ? (_jsx(Suspense, { fallback: null, children: _jsx(Lazy, { ...props }) })) : (_jsx(Lazy, { ...props }));
60
+ }
61
+ const subscribe = (onLoad) => {
62
+ listeners.add(onLoad);
63
+ return () => void listeners.delete(onLoad);
64
+ };
65
+ return { Part, preload, isLoaded: () => loaded !== null, subscribe };
66
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evinvest/kitstart",
3
- "version": "0.18.0",
3
+ "version": "0.19.1",
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": {
@@ -113,7 +113,7 @@
113
113
  "@types/react-dom": "^19.3.0",
114
114
  "eslint": "^9.39.5",
115
115
  "jsdom": "^25.0.1",
116
- "next": "^16.3.6",
116
+ "next": "^16.4.0",
117
117
  "react": "^19.3.0",
118
118
  "react-dom": "^19.3.0",
119
119
  "server-only": "^0.0.1",
@@ -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.18.0";
6
+ ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.19.1";
7
7
  ev.inputs.v_flakes.follows = "v_flakes";
8
8
  };
9
9
 
@@ -19,11 +19,11 @@
19
19
  },
20
20
  "dependencies": {
21
21
  "@evinvest/analytics": "^0.4.0",
22
- "@evinvest/i18n": "^0.9.0",
23
- "@evinvest/kitstart": "^0.18.0",
22
+ "@evinvest/i18n": "^0.10.0",
23
+ "@evinvest/kitstart": "^0.19.0",
24
24
  "@evinvest/marketing": "^0.3.0",
25
- "@evinvest/uikit": "^0.27.0",
26
- "next": "16.3.6",
25
+ "@evinvest/uikit": "^0.29.0",
26
+ "next": "16.4.0",
27
27
  "react": "19.3.0",
28
28
  "react-dom": "19.3.0",
29
29
  "server-only": "^0.0.1"
@@ -35,7 +35,7 @@
35
35
  "@types/react": "^19.3.0",
36
36
  "@types/react-dom": "^19.3.0",
37
37
  "eslint": "^9.39.5",
38
- "eslint-config-next": "16.3.6",
38
+ "eslint-config-next": "16.4.0",
39
39
  "steiger": "^0.6.0",
40
40
  "tailwindcss": "^4.3.3",
41
41
  "typescript": "5.9.3",