@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 +18 -2
- package/dist/index.d.ts +61 -1
- package/dist/index.js +349 -32
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
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
|
|
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),
|
|
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,
|
|
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
|
|
902
|
-
const
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
1091
|
-
|
|
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 = "
|
|
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
|
-
{
|
|
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
|