@marktiderman/genesis-forms-web 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
@@ -24,7 +24,7 @@ import { Form } from "@marktiderman/genesis-forms-web";
24
24
  progress bar and Enter to continue (Ctrl/Cmd+Enter in long text; Enter on a focused radio or
25
25
  checkbox too). After a single pick (rating, NPS, yes/no, one choice) it moves on by itself
26
26
  (`autoAdvance={false}` turns it off): only a click, tap or Space counts as a pick, arrow keys just
27
- move through the options, and it never moves on from the last question, so Submit is always a
27
+ move through the options, and it never moves on from the last question, so Send is always a
28
28
  deliberate press. `classic` shows every visible question; a failed submit focuses an error summary
29
29
  above the fields that links to each invalid one.
30
30
  - **Presentation:** `presentation="embedded"` (the default) is a block inside your own page.
@@ -40,7 +40,7 @@ import { Form } from "@marktiderman/genesis-forms-web";
40
40
  button at the top-left. Each new question fades and slides in (CSS `@starting-style`, none with
41
41
  `prefers-reduced-motion`). The ending is centred and large. In classic layout, page mode is one
42
42
  scrolling page, Google Forms style: a header card with the title and description, each question
43
- in its own card (red-edged while invalid), Submit at the end.
43
+ in its own card (red-edged while invalid), Send at the end.
44
44
  `brand={{ name: "Acme", logo }}` shows an organisation's or business's logo (an image URL or an
45
45
  element; its initials without one) and name small at the top of every step and on the ending.
46
46
  Custom slots and renderers can match with `useFormPresentation()`, which returns
@@ -57,6 +57,18 @@ import { Form } from "@marktiderman/genesis-forms-web";
57
57
  question text), a failed Next is announced once through a live region, and the ending heading
58
58
  takes focus. Choice rows are full-width labels at least 44px tall; motion respects
59
59
  `prefers-reduced-motion`. Ids are unique per form, so two forms can share a page.
60
+ - **Send word and required note:** the send button says `submitLabel` (default **Send**) in every
61
+ layout. When any question is required, the title shows "\* Required" (`requiredLabel`, default
62
+ "Required"; `null` hides it).
63
+ - **Theme:** `theme={{ accent: "#f97316", headerImage: url, headerImageAlt }}` sets the form's own
64
+ primary colour (buttons, selected options, stars, progress, focus rings) and a banner over the
65
+ title. The accent is adjusted only as far as contrast needs: at least 3:1 against the page in light
66
+ and dark, and text on it at least 4.5:1. It takes `#rgb`, `#rrggbb` or `rgb()`; anything else is
67
+ ignored. `themeColors(accent)` returns the colours it would use.
68
+ - **Question types:** besides text, email, phone, rating, NPS, choices and yes/no, there are
69
+ `dropdown` (a native select), `number` (min, max, step; the decimal keypad on phones), `date` (the
70
+ browser's date picker) and `consent` (one checkbox). A rating can show end labels (`lowLabel`,
71
+ `highLabel`) and `display: "stars"`; NPS takes the same labels.
60
72
  - **Submitting:** the ending shows once `onSubmit` resolves. If it rejects, the completed response
61
73
  stays on screen read-only (a completed response is final), Back is hidden, and "Try again"
62
74
  (`retryLabel`) resends the same answers.
@@ -96,6 +108,10 @@ choice, yes/no, and the structured types:
96
108
 
97
109
  `useMediaTime()` and `useSourceText()` read them in a custom renderer.
98
110
 
111
+ A **`calculated`** field shows the value the engine worked out from other answers, read-only, in an
112
+ `<output>` named by the question that announces changes politely. It follows the field's `format`
113
+ and `decimals`, and shows a dash until there is a value.
114
+
99
115
  ## License
100
116
 
101
117
  MIT
package/dist/index.d.ts CHANGED
@@ -21,10 +21,56 @@ interface FormPresentationValue {
21
21
  */
22
22
  declare function useFormPresentation(): FormPresentationValue;
23
23
 
24
+ /**
25
+ * A form's own look: an accent colour and a header image. The accent becomes
26
+ * the Genesis `--primary` (and `--ring`) inside the form, so every primary
27
+ * surface follows it: buttons, selected options, stars, progress, focus rings.
28
+ */
29
+ interface FormTheme {
30
+ /**
31
+ * The accent colour, as `#rgb`, `#rrggbb` or `rgb(r, g, b)`, such as a
32
+ * brand's colour. It is adjusted only as far as needed for contrast: at
33
+ * least 3:1 against the page (light and dark), with text on it at least
34
+ * 4.5:1 (white or near-black, whichever reads better). Anything else is ignored.
35
+ */
36
+ accent?: string | undefined;
37
+ /** A banner image URL shown over the form's title. */
38
+ headerImage?: string | undefined;
39
+ /** The banner's text alternative. Default "" (decorative). */
40
+ headerImageAlt?: string | undefined;
41
+ }
42
+ /** Shadcn HSL components (`"221 83% 53%"`), as Genesis tokens hold them. */
43
+ interface ThemeColors {
44
+ primary: string;
45
+ primaryForeground: string;
46
+ }
47
+ /**
48
+ * The accent as Genesis primary colours for a light and a dark page, each
49
+ * adjusted only as far as contrast needs. Null when `accent` is not a colour
50
+ * this understands.
51
+ */
52
+ declare function themeColors(accent: string): {
53
+ light: ThemeColors;
54
+ dark: ThemeColors;
55
+ } | null;
56
+ /** The CSS that applies `theme` to the elements matching `selector`, or "" when it changes no colour. */
57
+ declare function themeCss(theme: FormTheme | undefined, selector: string): string;
58
+
24
59
  type SlotOverride<P> = ComponentType<P> | null;
25
60
  interface HeaderSlotProps {
26
61
  title: string;
27
62
  description?: string;
63
+ /** `theme.headerImage`: a banner over the title. */
64
+ image?: {
65
+ src: string;
66
+ alt: string;
67
+ } | undefined;
68
+ /**
69
+ * The note that marks required questions ("Required", drawn as "* Required"),
70
+ * set when the form has at least one required question. Empty when it has none
71
+ * or `requiredLabel` is null.
72
+ */
73
+ requiredNote?: string | undefined;
28
74
  className?: string;
29
75
  }
30
76
  interface BrandSlotProps {
@@ -112,6 +158,13 @@ interface FormProps {
112
158
  presentation?: FormPresentation | undefined;
113
159
  /** An organisation's or business's name and logo, shown small at the top of every step and on the ending. */
114
160
  brand?: FormBrand | undefined;
161
+ /**
162
+ * Colours and a header image: `accent` replaces the primary colour (buttons,
163
+ * selected options, progress, focus ring), adjusted where needed so it keeps
164
+ * at least 3:1 contrast with the page and its text 4.5:1; `headerImage` is a
165
+ * banner over the title.
166
+ */
167
+ theme?: FormTheme | undefined;
115
168
  registry?: RendererRegistry | undefined;
116
169
  /**
117
170
  * Called once the form completes. The ending shows only after it resolves.
@@ -130,8 +183,15 @@ interface FormProps {
130
183
  * Never moves on from the last question. Default true.
131
184
  */
132
185
  autoAdvance?: boolean | undefined;
186
+ /** The send button's word, the same in every layout. Default "Send". */
133
187
  submitLabel?: string | undefined;
188
+ /** The button that moves to the next question in focus layout. Default "OK". */
134
189
  nextLabel?: string | undefined;
190
+ /**
191
+ * The note shown with the title when any question is required, after an
192
+ * asterisk ("* Required"). `null` hides it. Default "Required".
193
+ */
194
+ requiredLabel?: string | null | undefined;
135
195
  /** The submit button's label after `onSubmit` failed. Default "Try again". */
136
196
  retryLabel?: string | undefined;
137
197
  slots?: {
@@ -198,4 +258,4 @@ declare const SourceTextContext: react.Context<string | null>;
198
258
  /** The enclosing `SourceTextContext` value, or null. */
199
259
  declare function useSourceText(): string | null;
200
260
 
201
- export { type BrandSlotProps, type EndingSlotProps, type ErrorSummaryItem, type ErrorSummarySlotProps, FieldChrome, type FieldChromeProps, Form, type FormBrand, type FormPresentation, type FormPresentationValue, type FormProps, type FormSlotMap, type HeaderSlotProps, type MediaTime, MediaTimeContext, type NavigationSlotProps, type ProgressSlotProps, SourceTextContext, builtInRenderers, defaultRegistry, describedBy, useFormPresentation, useMediaTime, useSourceText };
261
+ export { type BrandSlotProps, type EndingSlotProps, type ErrorSummaryItem, type ErrorSummarySlotProps, FieldChrome, type FieldChromeProps, Form, type FormBrand, type FormPresentation, type FormPresentationValue, type FormProps, type FormSlotMap, type FormTheme, type HeaderSlotProps, type MediaTime, MediaTimeContext, type NavigationSlotProps, type ProgressSlotProps, SourceTextContext, type ThemeColors, builtInRenderers, defaultRegistry, describedBy, themeColors, themeCss, useFormPresentation, useMediaTime, useSourceText };
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { createContext, useRef, useState, useId, useEffect, useContext, useLayoutEffect } from 'react';
2
+ import { createContext, useState, useEffect, useRef, useId, useContext, useLayoutEffect } from 'react';
3
3
  import { Button } from '@marktiderman/genesis-ui/button';
4
4
  import { Progress } from '@marktiderman/genesis-ui/progress';
5
5
  import { cn } from '@marktiderman/genesis-ui/utils';
@@ -9,7 +9,7 @@ import { Input } from '@marktiderman/genesis-ui/input';
9
9
  import { Label } from '@marktiderman/genesis-ui/label';
10
10
  import { RadioGroup, RadioGroupItem } from '@marktiderman/genesis-ui/radio-group';
11
11
  import { Textarea } from '@marktiderman/genesis-ui/textarea';
12
- import { choiceOptions, listLimits, tableColumns, tableLimits, parseTimestamp, formatTimestamp, getFieldType } from '@marktiderman/genesis-forms';
12
+ import { ratingDisplay, choiceOptions, numberLimits, dateLimits, listLimits, tableColumns, tableLimits, parseTimestamp, formatTimestamp, formatCalculated, getFieldType, scaleLabels } from '@marktiderman/genesis-forms';
13
13
  import { FormDescription, FormError, FormLabel } from '@marktiderman/genesis-ui/form-field';
14
14
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
15
15
  import { Stack } from '@marktiderman/genesis-ui/layout';
@@ -120,6 +120,29 @@ function describedBy(props) {
120
120
  ].filter(Boolean);
121
121
  return ids.length ? ids.join(" ") : void 0;
122
122
  }
123
+ function CalculatedField(props) {
124
+ const { binding, inputId } = props;
125
+ const { presentation, layout } = useFormPresentation();
126
+ const pageFocus = presentation === "page" && layout === "focus";
127
+ const text = formatCalculated(binding.value, binding.field);
128
+ return /* @__PURE__ */ jsx(FieldChrome, { props, children: /* @__PURE__ */ jsx(
129
+ "output",
130
+ {
131
+ id: inputId,
132
+ "aria-labelledby": `${inputId}-label`,
133
+ "aria-describedby": describedBy(props),
134
+ "aria-live": "polite",
135
+ "data-gf-calculated": "",
136
+ className: cn(
137
+ "block rounded-md border border-dashed bg-muted/40 px-3 py-2 font-medium tabular-nums",
138
+ pageFocus ? "text-2xl sm:text-3xl" : "text-base",
139
+ !text && "text-muted-foreground"
140
+ ),
141
+ children: text || "\u2014"
142
+ }
143
+ ) });
144
+ }
145
+ var calculatedRenderers = [{ type: "calculated", Component: CalculatedField }];
123
146
  var MediaTimeContext = createContext(null);
124
147
  function useMediaTime() {
125
148
  return useContext(MediaTimeContext);
@@ -894,32 +917,88 @@ function scaleValues(type, max) {
894
917
  if (type === "nps") return Array.from({ length: 11 }, (_, i) => i);
895
918
  return Array.from({ length: max }, (_, i) => i + 1);
896
919
  }
920
+ function endLabels(props) {
921
+ const own = scaleLabels(props.binding.field);
922
+ if (props.binding.field.type !== "nps") return own;
923
+ return { low: own.low ?? "Not likely", high: own.high ?? "Very likely" };
924
+ }
925
+ function EndLabels({ inputId, low, high }) {
926
+ if (!low && !high) return null;
927
+ return /* @__PURE__ */ jsxs("div", { className: "flex justify-between gap-4 text-sm text-muted-foreground", "data-gf-scale-labels": "", children: [
928
+ /* @__PURE__ */ jsx("span", { id: `${inputId}-low`, children: low ?? "" }),
929
+ /* @__PURE__ */ jsx("span", { id: `${inputId}-high`, className: "text-right", children: high ?? "" })
930
+ ] });
931
+ }
897
932
  function ScaleField(props) {
898
933
  const { binding, inputId } = props;
899
934
  const max = typeof binding.field.props?.max === "number" ? binding.field.props.max : 5;
900
935
  const values = scaleValues(binding.field.type, max);
901
- const nps = binding.field.type === "nps";
902
- const options = values.map((n, i) => ({
903
- value: String(n),
904
- label: String(n),
905
- describedBy: nps && i === 0 ? `${inputId}-low` : nps && i === values.length - 1 ? `${inputId}-high` : void 0
906
- }));
936
+ const { low, high } = endLabels(props);
937
+ const stars = binding.field.type === "rating" && ratingDisplay(binding.field) === "stars";
938
+ const describe = (i) => low && i === 0 ? `${inputId}-low` : high && i === values.length - 1 ? `${inputId}-high` : void 0;
939
+ const value = typeof binding.value === "number" ? String(binding.value) : "";
940
+ const onValueChange = (v) => binding.setValue(Number(v));
941
+ if (stars) {
942
+ return /* @__PURE__ */ jsxs(FieldChrome, { props, children: [
943
+ /* @__PURE__ */ jsx(StarRadioGroup, { props, values, value, onValueChange, describe }),
944
+ /* @__PURE__ */ jsx(EndLabels, { inputId, low, high })
945
+ ] });
946
+ }
947
+ const options = values.map((n, i) => ({ value: String(n), label: String(n), describedBy: describe(i) }));
907
948
  return /* @__PURE__ */ jsxs(FieldChrome, { props, children: [
908
- /* @__PURE__ */ jsx(
909
- ButtonRadioGroup,
910
- {
911
- props,
912
- options,
913
- value: typeof binding.value === "number" ? String(binding.value) : "",
914
- onValueChange: (v) => binding.setValue(Number(v))
915
- }
916
- ),
917
- nps ? /* @__PURE__ */ jsxs("div", { className: "flex justify-between text-sm text-muted-foreground", children: [
918
- /* @__PURE__ */ jsx("span", { id: `${inputId}-low`, children: "Not likely" }),
919
- /* @__PURE__ */ jsx("span", { id: `${inputId}-high`, children: "Very likely" })
920
- ] }) : null
949
+ /* @__PURE__ */ jsx(ButtonRadioGroup, { props, options, value, onValueChange }),
950
+ /* @__PURE__ */ jsx(EndLabels, { inputId, low, high })
921
951
  ] });
922
952
  }
953
+ function StarIcon({ filled }) {
954
+ return /* @__PURE__ */ jsx(
955
+ "svg",
956
+ {
957
+ viewBox: "0 0 24 24",
958
+ "aria-hidden": "true",
959
+ className: cn("size-8 transition-colors motion-reduce:transition-none", filled ? "fill-primary text-primary" : "fill-transparent text-muted-foreground"),
960
+ stroke: "currentColor",
961
+ strokeWidth: "1.75",
962
+ strokeLinejoin: "round",
963
+ children: /* @__PURE__ */ jsx("path", { d: "M12 2.5l2.9 6.1 6.6.8-4.9 4.6 1.3 6.6L12 17.3l-5.9 3.3 1.3-6.6-4.9-4.6 6.6-.8z" })
964
+ }
965
+ );
966
+ }
967
+ function StarRadioGroup({
968
+ props,
969
+ values,
970
+ value,
971
+ onValueChange,
972
+ describe
973
+ }) {
974
+ const pick = useDeliberatePick(props.onAdvance);
975
+ const chosen = value === "" ? 0 : Number(value);
976
+ const max = values[values.length - 1] ?? 5;
977
+ return /* @__PURE__ */ jsx(
978
+ RadioGroup,
979
+ {
980
+ ...groupAria(props),
981
+ ...pick.rootProps,
982
+ value,
983
+ onValueChange,
984
+ className: "flex flex-wrap justify-start gap-1",
985
+ "data-gf-stars": "",
986
+ children: values.map((n, i) => /* @__PURE__ */ jsxs("div", { className: "relative", children: [
987
+ /* @__PURE__ */ jsx(
988
+ RadioGroupItem,
989
+ {
990
+ value: String(n),
991
+ "aria-label": `${n} of ${max}`,
992
+ "aria-describedby": describe(i),
993
+ onClick: pick.onItemClick,
994
+ className: "peer size-11 aspect-auto rounded-md border-0 bg-transparent shadow-none hover:bg-accent data-[state=checked]:bg-transparent [&_svg]:hidden"
995
+ }
996
+ ),
997
+ /* @__PURE__ */ jsx("span", { "aria-hidden": "true", className: "pointer-events-none absolute inset-0 flex items-center justify-center peer-disabled:opacity-50", children: /* @__PURE__ */ jsx(StarIcon, { filled: n <= chosen }) })
998
+ ] }, n))
999
+ }
1000
+ );
1001
+ }
923
1002
  function YesNoField(props) {
924
1003
  const { binding } = props;
925
1004
  const current = binding.value === true ? "yes" : binding.value === false ? "no" : "";
@@ -1049,6 +1128,115 @@ function MultiChoiceField(props) {
1049
1128
  ] }, o.value))
1050
1129
  ] }) });
1051
1130
  }
1131
+ function useLineClass() {
1132
+ const { presentation, layout } = useFormPresentation();
1133
+ if (presentation === "page" && layout === "focus") {
1134
+ return "h-auto rounded-none border-0 border-b-2 border-input bg-transparent px-0 py-2 text-xl shadow-none md:text-2xl sm:text-2xl focus-visible:border-primary focus-visible:ring-0 focus-visible:ring-offset-0 aria-invalid:border-destructive";
1135
+ }
1136
+ return presentation === "page" ? "h-11 md:text-base" : void 0;
1137
+ }
1138
+ function controlAria(props) {
1139
+ return {
1140
+ id: props.inputId,
1141
+ "data-gf-focus": "",
1142
+ "aria-invalid": props.binding.error ? true : void 0,
1143
+ "aria-required": props.binding.field.required || void 0,
1144
+ "aria-describedby": describedBy(props)
1145
+ };
1146
+ }
1147
+ function DropdownField(props) {
1148
+ const { binding } = props;
1149
+ const options = choiceOptions(binding.field);
1150
+ const line = useLineClass();
1151
+ const value = typeof binding.value === "string" ? binding.value : "";
1152
+ return /* @__PURE__ */ jsx(FieldChrome, { props, children: /* @__PURE__ */ jsxs(
1153
+ "select",
1154
+ {
1155
+ ...controlAria(props),
1156
+ value,
1157
+ onChange: (e) => binding.setValue(e.target.value === "" ? void 0 : e.target.value),
1158
+ className: cn(
1159
+ "flex h-10 w-full min-w-0 rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm aria-invalid:border-destructive",
1160
+ value === "" && "text-muted-foreground",
1161
+ line
1162
+ ),
1163
+ children: [
1164
+ /* @__PURE__ */ jsx("option", { value: "", children: binding.field.placeholder ?? "Choose\u2026" }),
1165
+ options.map((o) => /* @__PURE__ */ jsx("option", { value: o.value, children: o.label ?? o.value }, o.value))
1166
+ ]
1167
+ }
1168
+ ) });
1169
+ }
1170
+ function NumberField(props) {
1171
+ const { binding } = props;
1172
+ const { min, max, step } = numberLimits(binding.field);
1173
+ const line = useLineClass();
1174
+ const shown = typeof binding.value === "number" ? String(binding.value) : typeof binding.value === "string" ? binding.value : "";
1175
+ const [text, setText] = useState(shown);
1176
+ useEffect(() => {
1177
+ setText((typed) => typed.trim() !== "" && Number(typed.trim().replace(",", ".")) === binding.value ? typed : shown);
1178
+ }, [shown, binding.value]);
1179
+ return /* @__PURE__ */ jsxs(FieldChrome, { props, children: [
1180
+ /* @__PURE__ */ jsx(
1181
+ Input,
1182
+ {
1183
+ ...controlAria(props),
1184
+ type: "text",
1185
+ inputMode: min !== void 0 && min >= 0 && (step === void 0 || Number.isInteger(step)) ? "numeric" : "decimal",
1186
+ autoComplete: "off",
1187
+ value: text,
1188
+ placeholder: binding.field.placeholder,
1189
+ className: line,
1190
+ onChange: (e) => {
1191
+ const raw = e.target.value;
1192
+ setText(raw);
1193
+ const t = raw.trim().replace(",", ".");
1194
+ if (t === "") binding.setValue(void 0);
1195
+ else {
1196
+ const n = Number(t);
1197
+ binding.setValue(Number.isFinite(n) && /^[-+]?(\d+\.?\d*|\.\d+)(e[-+]?\d+)?$/i.test(t) ? n : raw);
1198
+ }
1199
+ }
1200
+ }
1201
+ ),
1202
+ min !== void 0 || max !== void 0 ? /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", "data-gf-number-range": "", children: min !== void 0 && max !== void 0 ? `From ${min} to ${max}` : min !== void 0 ? `${min} or more` : `${max} or less` }) : null
1203
+ ] });
1204
+ }
1205
+ function DateField(props) {
1206
+ const { binding } = props;
1207
+ const { min, max } = dateLimits(binding.field);
1208
+ const line = useLineClass();
1209
+ return /* @__PURE__ */ jsx(FieldChrome, { props, children: /* @__PURE__ */ jsx(
1210
+ Input,
1211
+ {
1212
+ ...controlAria(props),
1213
+ type: "date",
1214
+ min,
1215
+ max,
1216
+ value: typeof binding.value === "string" ? binding.value : "",
1217
+ className: cn("w-full sm:w-auto sm:min-w-48", line),
1218
+ onChange: (e) => binding.setValue(e.target.value === "" ? void 0 : e.target.value)
1219
+ }
1220
+ ) });
1221
+ }
1222
+ function ConsentField(props) {
1223
+ const { binding, inputId } = props;
1224
+ const text = typeof binding.field.props?.text === "string" && binding.field.props.text.trim() ? binding.field.props.text : "I agree";
1225
+ const look = useChoiceLook(inputId, 1);
1226
+ return /* @__PURE__ */ jsx(FieldChrome, { props, children: /* @__PURE__ */ jsxs(Label, { htmlFor: `${inputId}-0`, className: look.row, children: [
1227
+ /* @__PURE__ */ jsx(
1228
+ Checkbox,
1229
+ {
1230
+ id: `${inputId}-0`,
1231
+ checked: binding.value === true,
1232
+ "aria-invalid": binding.error ? true : void 0,
1233
+ "aria-required": binding.field.required || void 0,
1234
+ onCheckedChange: (c) => binding.setValue(c === true ? true : void 0)
1235
+ }
1236
+ ),
1237
+ /* @__PURE__ */ jsx("span", { children: props.engine.text(text) })
1238
+ ] }) });
1239
+ }
1052
1240
  var builtInRenderers = [
1053
1241
  { type: "short-text", Component: (p) => /* @__PURE__ */ jsx(TextField, { ...p }) },
1054
1242
  { type: "long-text", Component: (p) => /* @__PURE__ */ jsx(TextField, { ...p, multiline: true }) },
@@ -1061,21 +1249,127 @@ var builtInRenderers = [
1061
1249
  { type: "single-choice", Component: SingleChoiceField },
1062
1250
  { type: "multi-choice", Component: MultiChoiceField },
1063
1251
  { type: "yes-no", Component: YesNoField },
1064
- ...structuredRenderers
1252
+ { type: "dropdown", Component: DropdownField },
1253
+ { type: "number", Component: NumberField },
1254
+ { type: "date", Component: DateField },
1255
+ { type: "consent", Component: ConsentField },
1256
+ ...structuredRenderers,
1257
+ ...calculatedRenderers
1065
1258
  ];
1066
1259
  var defaultRegistry = createRendererRegistry(builtInRenderers);
1260
+
1261
+ // src/theme.ts
1262
+ function parseColor(value) {
1263
+ const v = value.trim().toLowerCase();
1264
+ let m = /^#([0-9a-f]{3})$/.exec(v);
1265
+ if (m) return [...m[1]].map((c) => parseInt(c + c, 16));
1266
+ m = /^#([0-9a-f]{6})$/.exec(v);
1267
+ if (m) return [0, 2, 4].map((i) => parseInt(m[1].slice(i, i + 2), 16));
1268
+ m = /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/.exec(v);
1269
+ if (m) {
1270
+ const rgb = [m[1], m[2], m[3]].map(Number);
1271
+ return rgb.every((c) => c <= 255) ? rgb : null;
1272
+ }
1273
+ return null;
1274
+ }
1275
+ function rgbToHsl([r, g, b]) {
1276
+ const [rn, gn, bn] = [r / 255, g / 255, b / 255];
1277
+ const max = Math.max(rn, gn, bn);
1278
+ const min = Math.min(rn, gn, bn);
1279
+ const l = (max + min) / 2;
1280
+ if (max === min) return [0, 0, l * 100];
1281
+ const d = max - min;
1282
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
1283
+ const h = max === rn ? (gn - bn) / d + (gn < bn ? 6 : 0) : max === gn ? (bn - rn) / d + 2 : (rn - gn) / d + 4;
1284
+ return [h * 60, s * 100, l * 100];
1285
+ }
1286
+ function hslToRgb(h, s, l) {
1287
+ const sn = s / 100;
1288
+ const ln = l / 100;
1289
+ const k = (n) => (n + h / 30) % 12;
1290
+ const a = sn * Math.min(ln, 1 - ln);
1291
+ const f = (n) => ln - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
1292
+ return [f(0), f(8), f(4)].map((x) => Math.round(x * 255));
1293
+ }
1294
+ function luminance([r, g, b]) {
1295
+ const c = (x) => {
1296
+ const n = x / 255;
1297
+ return n <= 0.03928 ? n / 12.92 : ((n + 0.055) / 1.055) ** 2.4;
1298
+ };
1299
+ return 0.2126 * c(r) + 0.7152 * c(g) + 0.0722 * c(b);
1300
+ }
1301
+ function contrast(a, b) {
1302
+ const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p);
1303
+ return (x + 0.05) / (y + 0.05);
1304
+ }
1305
+ var WHITE = [255, 255, 255];
1306
+ var NEAR_BLACK = hslToRgb(240, 10, 3.9);
1307
+ var WHITE_HSL = "0 0% 100%";
1308
+ var NEAR_BLACK_HSL = "240 10% 3.9%";
1309
+ var fmt = (h, s, l) => `${Math.round(h)} ${Math.round(s)}% ${Math.round(l * 10) / 10}%`;
1310
+ function fit(h, s, l, page, darken) {
1311
+ let light = l;
1312
+ for (let i = 0; i < 100 && contrast(hslToRgb(h, s, light), page) < 3; i++) light = Math.max(0, Math.min(100, light + (darken ? -1 : 1)));
1313
+ const best = () => {
1314
+ const rgb = hslToRgb(h, s, light);
1315
+ const white = contrast(rgb, WHITE);
1316
+ const dark = contrast(rgb, NEAR_BLACK);
1317
+ return white >= dark ? { ratio: white, fg: WHITE_HSL } : { ratio: dark, fg: NEAR_BLACK_HSL };
1318
+ };
1319
+ let pick = best();
1320
+ for (let i = 0; i < 100 && pick.ratio < 4.5; i++) {
1321
+ light = Math.min(100, light + 1);
1322
+ pick = { ratio: contrast(hslToRgb(h, s, light), NEAR_BLACK), fg: NEAR_BLACK_HSL };
1323
+ }
1324
+ return { primary: fmt(h, s, light), primaryForeground: pick.fg };
1325
+ }
1326
+ function themeColors(accent) {
1327
+ const rgb = parseColor(accent);
1328
+ if (!rgb) return null;
1329
+ const [h, s, l] = rgbToHsl(rgb);
1330
+ return { light: fit(h, s, l, WHITE, true), dark: fit(h, s, l, NEAR_BLACK, false) };
1331
+ }
1332
+ function themeCss(theme, selector) {
1333
+ const colors = theme?.accent ? themeColors(theme.accent) : null;
1334
+ if (!colors) return "";
1335
+ const block = (c) => `--primary: ${c.primary}; --primary-foreground: ${c.primaryForeground}; --ring: ${c.primary};`;
1336
+ return `${selector} { ${block(colors.light)} }
1337
+ .dark ${selector}, ${selector}.dark { ${block(colors.dark)} }`;
1338
+ }
1067
1339
  function renderSlot(Default, override, props, extra) {
1068
1340
  if (override === null) return null;
1069
1341
  const Component = override ?? Default;
1070
1342
  return /* @__PURE__ */ jsx(Component, { ...props, ...extra });
1071
1343
  }
1072
1344
  var focusRing = "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ring-offset-background";
1073
- function DefaultHeader({ title, description, className }) {
1345
+ function RequiredNote({ text, className }) {
1346
+ return /* @__PURE__ */ jsxs("p", { className: cn("text-sm text-muted-foreground", className), "data-gf-required-note": "", children: [
1347
+ /* @__PURE__ */ jsxs("span", { className: "text-destructive", "aria-hidden": "true", children: [
1348
+ "*",
1349
+ " "
1350
+ ] }),
1351
+ text
1352
+ ] });
1353
+ }
1354
+ function HeaderImage({ image, className }) {
1355
+ return /* @__PURE__ */ jsx(
1356
+ "img",
1357
+ {
1358
+ src: image.src,
1359
+ alt: image.alt,
1360
+ "data-gf-header-image": "",
1361
+ className: cn("block h-32 w-full object-cover sm:h-44", className)
1362
+ }
1363
+ );
1364
+ }
1365
+ function DefaultHeader({ title, description, image, requiredNote, className }) {
1074
1366
  const { presentation, layout } = useFormPresentation();
1075
1367
  if (presentation === "page" && layout === "focus") {
1076
1368
  return /* @__PURE__ */ jsxs("header", { className: cn("space-y-1", className), children: [
1369
+ image ? /* @__PURE__ */ jsx(HeaderImage, { image, className: "mb-4 rounded-lg" }) : null,
1077
1370
  /* @__PURE__ */ jsx("h1", { className: "text-sm font-medium text-muted-foreground", children: title }),
1078
- description ? /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: description }) : null
1371
+ description ? /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: description }) : null,
1372
+ requiredNote ? /* @__PURE__ */ jsx(RequiredNote, { text: requiredNote }) : null
1079
1373
  ] });
1080
1374
  }
1081
1375
  if (presentation === "page") {
@@ -1083,19 +1377,26 @@ function DefaultHeader({ title, description, className }) {
1083
1377
  "header",
1084
1378
  {
1085
1379
  className: cn(
1086
- "rounded-xl border border-t-8 border-t-primary bg-card px-5 py-6 text-card-foreground sm:px-8 sm:py-8",
1380
+ "overflow-hidden rounded-xl border bg-card text-card-foreground",
1381
+ !image && "border-t-8 border-t-primary",
1087
1382
  className
1088
1383
  ),
1089
1384
  children: [
1090
- /* @__PURE__ */ jsx("h1", { className: "text-3xl font-semibold tracking-tight text-balance sm:text-4xl", children: title }),
1091
- description ? /* @__PURE__ */ jsx("p", { className: "mt-3 text-base text-muted-foreground text-pretty", children: description }) : null
1385
+ image ? /* @__PURE__ */ jsx(HeaderImage, { image }) : null,
1386
+ /* @__PURE__ */ jsxs("div", { className: cn("px-5 py-6 sm:px-8 sm:py-8", image && "border-t-8 border-t-primary"), children: [
1387
+ /* @__PURE__ */ jsx("h1", { className: "text-3xl font-semibold tracking-tight text-balance sm:text-4xl", children: title }),
1388
+ description ? /* @__PURE__ */ jsx("p", { className: "mt-3 text-base text-muted-foreground text-pretty", children: description }) : null,
1389
+ requiredNote ? /* @__PURE__ */ jsx(RequiredNote, { text: requiredNote, className: "mt-4" }) : null
1390
+ ] })
1092
1391
  ]
1093
1392
  }
1094
1393
  );
1095
1394
  }
1096
1395
  return /* @__PURE__ */ jsxs("header", { className: cn("space-y-1", className), children: [
1396
+ image ? /* @__PURE__ */ jsx(HeaderImage, { image, className: "mb-4 rounded-lg" }) : null,
1097
1397
  /* @__PURE__ */ jsx("h1", { className: "text-2xl font-semibold tracking-tight", children: title }),
1098
- description ? /* @__PURE__ */ jsx("p", { className: "text-muted-foreground", children: description }) : null
1398
+ description ? /* @__PURE__ */ jsx("p", { className: "text-muted-foreground", children: description }) : null,
1399
+ requiredNote ? /* @__PURE__ */ jsx(RequiredNote, { text: requiredNote, className: "pt-1" }) : null
1099
1400
  ] });
1100
1401
  }
1101
1402
  function initials(name) {
@@ -1328,10 +1629,12 @@ function FormBody({
1328
1629
  layout: layoutProp,
1329
1630
  presentation = "embedded",
1330
1631
  brand,
1632
+ theme,
1331
1633
  onSubmit,
1332
1634
  onChange,
1333
1635
  autoAdvance = true,
1334
- submitLabel = "Submit",
1636
+ submitLabel = "Send",
1637
+ requiredLabel = "Required",
1335
1638
  nextLabel = "OK",
1336
1639
  retryLabel = "Try again",
1337
1640
  slots,
@@ -1468,6 +1771,10 @@ function FormBody({
1468
1771
  };
1469
1772
  const liveRegion = /* @__PURE__ */ jsx("div", { role: "status", "aria-live": "polite", "aria-atomic": "true", className: "sr-only", "data-gf-live": "", children: announcement });
1470
1773
  const presentationValue = { presentation, layout };
1774
+ const themeId = `gf-theme-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
1775
+ const themeStyle = themeCss(theme, `[data-gf-theme="${themeId}"]`);
1776
+ const themed = themeStyle ? { "data-gf-theme": themeId } : {};
1777
+ const styleNode = themeStyle ? /* @__PURE__ */ jsx("style", { children: themeStyle }) : null;
1471
1778
  const brandNode = brand ? renderSlot(DefaultBrand, slots?.brand, { name: brand.name, logo: brand.logo }, slotProps?.brand) : null;
1472
1779
  if (showEnding) {
1473
1780
  const end = def.endings?.find((x) => x.id === ending) ?? def.endings?.[0];
@@ -1482,10 +1789,12 @@ function FormBody({
1482
1789
  );
1483
1790
  return /* @__PURE__ */ jsxs(FormPresentationProvider, { value: presentationValue, children: [
1484
1791
  liveRegion,
1792
+ styleNode,
1485
1793
  page ? /* @__PURE__ */ jsxs(
1486
1794
  "div",
1487
1795
  {
1488
1796
  ref: endingRef,
1797
+ ...themed,
1489
1798
  "data-presentation": "page",
1490
1799
  "data-testid": testID,
1491
1800
  className: cn("flex min-h-dvh w-full flex-col bg-background text-foreground", className),
@@ -1494,7 +1803,7 @@ function FormBody({
1494
1803
  /* @__PURE__ */ jsx("div", { className: "flex flex-1 items-center justify-center px-4 pb-24 pt-8 sm:px-6", children: /* @__PURE__ */ jsx("div", { className: "w-full max-w-xl", children: endingNode }) })
1495
1804
  ]
1496
1805
  }
1497
- ) : /* @__PURE__ */ jsxs("div", { ref: endingRef, className: cn("mx-auto w-full max-w-xl space-y-6", className), "data-testid": testID, children: [
1806
+ ) : /* @__PURE__ */ jsxs("div", { ref: endingRef, ...themed, className: cn("mx-auto w-full max-w-xl space-y-6", className), "data-testid": testID, children: [
1498
1807
  brandNode,
1499
1808
  endingNode
1500
1809
  ] })
@@ -1543,10 +1852,16 @@ function FormBody({
1543
1852
  }));
1544
1853
  const currentField = pageFocus && current ? engine.field(current) : void 0;
1545
1854
  const keyHint = currentField ? currentField.type === "long-text" ? `${modKey} + Enter` : "Enter \u21B5" : void 0;
1855
+ const hasRequired = def.blocks.some((b) => b.kind === "field" && b.required === true);
1546
1856
  const header = renderSlot(
1547
1857
  DefaultHeader,
1548
1858
  slots?.header,
1549
- { title: def.title, description },
1859
+ {
1860
+ title: def.title,
1861
+ description,
1862
+ image: theme?.headerImage ? { src: theme.headerImage, alt: theme.headerImageAlt ?? "" } : void 0,
1863
+ requiredNote: hasRequired && requiredLabel ? requiredLabel : void 0
1864
+ },
1550
1865
  slotProps?.header
1551
1866
  );
1552
1867
  const errorSummary = errorItems.length > 0 && layout === "classic" ? renderSlot(
@@ -1602,6 +1917,7 @@ function FormBody({
1602
1917
  slotProps?.navigation
1603
1918
  );
1604
1919
  const formProps = {
1920
+ ...themed,
1605
1921
  ref: formRef,
1606
1922
  noValidate: true,
1607
1923
  onSubmit: handleSubmit,
@@ -1612,6 +1928,7 @@ function FormBody({
1612
1928
  };
1613
1929
  return /* @__PURE__ */ jsxs(FormPresentationProvider, { value: presentationValue, children: [
1614
1930
  liveRegion,
1931
+ styleNode,
1615
1932
  pageFocus ? /* @__PURE__ */ jsxs(
1616
1933
  "form",
1617
1934
  {
@@ -1655,6 +1972,6 @@ function FormBody({
1655
1972
  }
1656
1973
  var classicSection = "rounded-xl border bg-card px-5 py-6 text-card-foreground sm:px-8 has-[[aria-invalid=true]]:border-destructive";
1657
1974
 
1658
- export { FieldChrome, Form, MediaTimeContext, SourceTextContext, builtInRenderers, defaultRegistry, describedBy, useFormPresentation, useMediaTime, useSourceText };
1975
+ export { FieldChrome, Form, MediaTimeContext, SourceTextContext, builtInRenderers, defaultRegistry, describedBy, themeColors, themeCss, useFormPresentation, useMediaTime, useSourceText };
1659
1976
  //# sourceMappingURL=index.js.map
1660
1977
  //# sourceMappingURL=index.js.map