@webround/core 0.1.8 → 0.2.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.
Files changed (104) hide show
  1. package/dist/base-css.d.ts +8 -0
  2. package/dist/base-css.js +19 -0
  3. package/dist/components/WrAltcha.d.ts +5 -0
  4. package/dist/components/WrAltcha.js +7 -0
  5. package/dist/components/WrButton.js +19 -0
  6. package/dist/components/WrForm.d.ts +7 -0
  7. package/dist/components/WrForm.js +15 -0
  8. package/dist/components/WrFormPrivacy.d.ts +4 -0
  9. package/dist/components/WrFormPrivacy.js +6 -0
  10. package/dist/components/WrFormSuccess.d.ts +4 -0
  11. package/dist/components/WrFormSuccess.js +6 -0
  12. package/dist/components/WrIframe.d.ts +8 -0
  13. package/dist/components/WrIframe.js +13 -0
  14. package/dist/components/WrImage.d.ts +9 -0
  15. package/dist/components/WrImage.js +13 -0
  16. package/dist/components/WrInput.d.ts +8 -0
  17. package/dist/components/WrInput.js +10 -0
  18. package/dist/components/WrLangSelector.d.ts +17 -0
  19. package/dist/components/WrLangSelector.js +27 -0
  20. package/dist/components/WrLangSync.d.ts +6 -0
  21. package/dist/components/WrLangSync.js +12 -0
  22. package/dist/components/WrLink.js +23 -0
  23. package/dist/components/WrNav.d.ts +26 -0
  24. package/dist/components/WrNav.js +15 -0
  25. package/dist/{WrSection.d.ts → components/WrSection.d.ts} +3 -3
  26. package/dist/components/WrShared.d.ts +22 -0
  27. package/dist/components/WrShared.js +18 -0
  28. package/dist/components/WrText.d.ts +11 -0
  29. package/dist/components/WrText.js +14 -0
  30. package/dist/components/WrThemeToggle.d.ts +9 -0
  31. package/dist/components/WrThemeToggle.js +43 -0
  32. package/dist/components/WrVideo.js +61 -0
  33. package/dist/context/WrAppContext.d.ts +40 -0
  34. package/dist/context/WrAppContext.js +95 -0
  35. package/dist/context/WrLayout.d.ts +23 -0
  36. package/dist/context/WrLayout.js +18 -0
  37. package/dist/context/WrPageContext.d.ts +10 -0
  38. package/dist/context/WrPageContext.js +13 -0
  39. package/dist/form-react.d.ts +82 -0
  40. package/dist/form-react.js +165 -0
  41. package/dist/form.d.ts +83 -0
  42. package/dist/form.js +175 -0
  43. package/dist/index.css +651 -0
  44. package/dist/index.d.ts +20 -8
  45. package/dist/index.js +20 -8
  46. package/dist/kinds.d.ts +109 -0
  47. package/dist/kinds.js +35 -0
  48. package/dist/lang-codes.d.ts +366 -0
  49. package/dist/lang-codes.js +166 -0
  50. package/dist/localized.d.ts +36 -0
  51. package/dist/localized.js +71 -0
  52. package/dist/not-found.d.ts +9 -0
  53. package/dist/not-found.js +13 -0
  54. package/dist/routing.d.ts +122 -0
  55. package/dist/routing.js +198 -0
  56. package/dist/seo-react.d.ts +20 -0
  57. package/dist/seo-react.js +37 -0
  58. package/dist/seo.d.ts +167 -0
  59. package/dist/seo.js +221 -0
  60. package/dist/site-env.d.ts +25 -0
  61. package/dist/site-env.js +37 -0
  62. package/dist/style.d.ts +456 -0
  63. package/dist/style.js +1081 -0
  64. package/dist/text-react.d.ts +11 -0
  65. package/dist/text-react.js +5 -0
  66. package/dist/theme.d.ts +22 -0
  67. package/dist/theme.js +45 -0
  68. package/dist/types.d.ts +37 -21
  69. package/package.json +93 -7
  70. package/dist/WrButton.d.ts.map +0 -1
  71. package/dist/WrButton.js +0 -7
  72. package/dist/WrButton.js.map +0 -1
  73. package/dist/WrImage.d.ts +0 -8
  74. package/dist/WrImage.d.ts.map +0 -1
  75. package/dist/WrImage.js +0 -7
  76. package/dist/WrImage.js.map +0 -1
  77. package/dist/WrLink.d.ts.map +0 -1
  78. package/dist/WrLink.js +0 -15
  79. package/dist/WrLink.js.map +0 -1
  80. package/dist/WrPageContext.d.ts +0 -8
  81. package/dist/WrPageContext.d.ts.map +0 -1
  82. package/dist/WrPageContext.js +0 -11
  83. package/dist/WrPageContext.js.map +0 -1
  84. package/dist/WrSection.d.ts.map +0 -1
  85. package/dist/WrSection.js.map +0 -1
  86. package/dist/WrText.d.ts +0 -11
  87. package/dist/WrText.d.ts.map +0 -1
  88. package/dist/WrText.js +0 -7
  89. package/dist/WrText.js.map +0 -1
  90. package/dist/WrVideo.d.ts.map +0 -1
  91. package/dist/WrVideo.js +0 -42
  92. package/dist/WrVideo.js.map +0 -1
  93. package/dist/bridge.d.ts +0 -2
  94. package/dist/bridge.d.ts.map +0 -1
  95. package/dist/bridge.js +0 -110
  96. package/dist/bridge.js.map +0 -1
  97. package/dist/index.d.ts.map +0 -1
  98. package/dist/index.js.map +0 -1
  99. package/dist/types.d.ts.map +0 -1
  100. package/dist/types.js.map +0 -1
  101. /package/dist/{WrButton.d.ts → components/WrButton.d.ts} +0 -0
  102. /package/dist/{WrLink.d.ts → components/WrLink.d.ts} +0 -0
  103. /package/dist/{WrSection.js → components/WrSection.js} +0 -0
  104. /package/dist/{WrVideo.d.ts → components/WrVideo.d.ts} +0 -0
@@ -0,0 +1,95 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // packages/core/src/context/WrAppContext.tsx
3
+ import { createContext, useContext, useState, useCallback, useEffect, useMemo, useSyncExternalStore, lazy, Suspense, } from 'react';
4
+ import { createBrowserRouter, RouterProvider, useLocation, useParams } from 'react-router-dom';
5
+ import { wrResolveRoutes, wrBuildRegistry, wrBuildLazyRegistry, wrBuildPageConfigMap, wrExpandRoutesForLangs, wrExtractLang, wrBuildLangPath, } from '../routing';
6
+ import { WrPageProvider } from './WrPageContext';
7
+ import { WrLayout } from './WrLayout';
8
+ import { WrSharedContext } from '../components/WrShared';
9
+ import { WrSeoHead } from '../seo-react';
10
+ import { WrNotFound } from '../not-found';
11
+ const WrAppContext = createContext(null);
12
+ export function useWrApp() {
13
+ const ctx = useContext(WrAppContext);
14
+ return {
15
+ lang: ctx?.lang ?? 'default',
16
+ setLang: ctx?.setLang ?? (() => { }),
17
+ };
18
+ }
19
+ function WrDefaultPageFallback() {
20
+ return _jsx("div", { "data-wr-loading": "true" });
21
+ }
22
+ const lazyComponentCache = new Map();
23
+ function getLazyComponent(route) {
24
+ let cached = lazyComponentCache.get(route.routePath);
25
+ if (!cached) {
26
+ cached = lazy(route.loadComponent);
27
+ lazyComponentCache.set(route.routePath, cached);
28
+ }
29
+ return cached;
30
+ }
31
+ function WrRouteElement({ route, pageConfigMap, lazyFallback: LazyFallback, PageProvider, Header, Footer, seo, langs, }) {
32
+ const params = useParams();
33
+ const { pathname } = useLocation();
34
+ const { lang } = useWrApp();
35
+ // `/:lang/…` answers for any word in that place: only the languages of the site are one
36
+ if (langs && params.lang !== undefined && !langs.available.includes(params.lang))
37
+ return _jsx(WrNotFound, { langs: langs });
38
+ const pageName = route.resolvePageName(params);
39
+ const config = pageConfigMap[pageName] ?? {};
40
+ const shared = useMemo(() => ({ pageConfigMap, ScopeProvider: PageProvider }), [pageConfigMap, PageProvider]);
41
+ const body = route.lazy
42
+ ? (() => {
43
+ const LazyComponent = getLazyComponent(route);
44
+ return (_jsx(Suspense, { fallback: _jsx(LazyFallback, {}), children: _jsx(LazyComponent, {}) }));
45
+ })()
46
+ : (() => {
47
+ const Component = route.Component;
48
+ return _jsx(Component, {});
49
+ })();
50
+ return (_jsx(PageProvider, { config: config, pageName: pageName, children: _jsxs(WrSharedContext.Provider, { value: shared, children: [_jsx(WrSeoHead, { site: seo, config: config, pathname: pathname, lang: lang, langs: langs }), _jsx(WrLayout, { Header: Header, Footer: Footer, showHeader: route.header, showFooter: route.footer, pageConfigMap: pageConfigMap, ScopeProvider: PageProvider, children: body })] }) }, pageName));
51
+ }
52
+ export function WrAppProvider({ routesFile, eagerModules, lazyModules, configModules, langs, initialLang = 'default', lazyFallback = WrDefaultPageFallback, onRouterReady, PageProvider = WrPageProvider, Header, Footer, seo, children, }) {
53
+ const router = useMemo(() => {
54
+ const registry = wrBuildRegistry(eagerModules);
55
+ const lazyRegistry = wrBuildLazyRegistry(lazyModules);
56
+ const pageConfigMap = wrBuildPageConfigMap(configModules);
57
+ const { routes, errors } = wrResolveRoutes(routesFile, registry, lazyRegistry);
58
+ if (errors.missingComponents.length > 0) {
59
+ console.error('[wr] unresolved route -> component mappings:', errors.missingComponents);
60
+ }
61
+ const expandedRoutes = wrExpandRoutesForLangs(routes, langs);
62
+ const builtRouter = createBrowserRouter([
63
+ ...expandedRoutes.map((route) => ({
64
+ path: route.pattern,
65
+ element: _jsx(WrRouteElement, { route: route, pageConfigMap: pageConfigMap, lazyFallback: lazyFallback, PageProvider: PageProvider, Header: Header, Footer: Footer, seo: seo, langs: langs })
66
+ })),
67
+ // An address that leads to no page takes the visitor to the home page
68
+ { path: '*', element: _jsx(WrNotFound, { langs: langs }) },
69
+ ]);
70
+ onRouterReady?.(builtRouter.navigate);
71
+ return builtRouter;
72
+ }, [routesFile, eagerModules, lazyModules, configModules, langs, lazyFallback, onRouterReady, PageProvider, Header, Footer, seo]);
73
+ // Fallback quando non c'è alcuna config lingue: stato locale semplice, comportamento invariato.
74
+ const [staticLang, setStaticLang] = useState(initialLang);
75
+ // Con `langs`: la lingua è sempre derivata dal pathname corrente del router. Va letta in modo
76
+ // reattivo qui perché questo componente sta SOPRA il <RouterProvider>, non sotto — niente useLocation().
77
+ const pathname = useSyncExternalStore(router.subscribe, () => router.state.location.pathname, () => router.state.location.pathname);
78
+ const lang = langs ? wrExtractLang(pathname, langs) : staticLang;
79
+ // The page says what language it is in (screen readers pick their voice by it): the language shown, `default` being the site's own
80
+ const htmlLang = langs ? (lang === 'default' ? langs.default : lang) : undefined;
81
+ useEffect(() => {
82
+ if (htmlLang)
83
+ document.documentElement.lang = htmlLang;
84
+ }, [htmlLang]);
85
+ const setLang = useCallback((target) => {
86
+ if (langs) {
87
+ router.navigate(wrBuildLangPath(target, pathname, langs));
88
+ }
89
+ else {
90
+ setStaticLang(target);
91
+ }
92
+ }, [langs, pathname, router]);
93
+ return (_jsxs(WrAppContext.Provider, { value: { lang, setLang }, children: [children, _jsx(RouterProvider, { router: router })] }));
94
+ }
95
+ //# sourceMappingURL=WrAppContext.js.map
@@ -0,0 +1,23 @@
1
+ import type { ComponentType, ReactNode } from 'react';
2
+ import type { WrPageConfig } from '../types';
3
+ export type WrPageProviderType = ComponentType<{
4
+ config: WrPageConfig;
5
+ pageName: string;
6
+ children: ReactNode;
7
+ }>;
8
+ /**
9
+ * La pagina con il suo header e il suo footer. Vive DENTRO il provider della pagina e decide al primo render (i flag arrivano già
10
+ * risolti dalla rotta): niente effetti e niente stato, quindi niente scatto di layout e l'header non compare "per un attimo".
11
+ * Il contenuto della pagina sta in un `<main>` (il punto d'arrivo per chi salta alla pagina, WCAG 2.4.1): lo mette il layout, quindi una
12
+ * pagina non deve metterne uno suo (due `<main>` sono un errore).
13
+ */
14
+ export declare function WrLayout({ Header, Footer, showHeader, showFooter, pageConfigMap, ScopeProvider, children }: {
15
+ Header?: ComponentType;
16
+ Footer?: ComponentType;
17
+ showHeader: boolean;
18
+ showFooter: boolean;
19
+ pageConfigMap: Record<string, WrPageConfig>;
20
+ ScopeProvider: WrPageProviderType;
21
+ children: ReactNode;
22
+ }): import("react").JSX.Element;
23
+ //# sourceMappingURL=WrLayout.d.ts.map
@@ -0,0 +1,18 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { WR_SHARED_SCOPES } from '../routing';
3
+ /**
4
+ * La pagina con il suo header e il suo footer. Vive DENTRO il provider della pagina e decide al primo render (i flag arrivano già
5
+ * risolti dalla rotta): niente effetti e niente stato, quindi niente scatto di layout e l'header non compare "per un attimo".
6
+ * Il contenuto della pagina sta in un `<main>` (il punto d'arrivo per chi salta alla pagina, WCAG 2.4.1): lo mette il layout, quindi una
7
+ * pagina non deve metterne uno suo (due `<main>` sono un errore).
8
+ */
9
+ export function WrLayout({ Header, Footer, showHeader, showFooter, pageConfigMap, ScopeProvider, children }) {
10
+ const header = showHeader && Header ? Header : null;
11
+ const footer = showFooter && Footer ? Footer : null;
12
+ if (!header && !footer)
13
+ return _jsx("main", { children: children });
14
+ const shared = (key, Component) => (_jsx(ScopeProvider, { config: pageConfigMap[WR_SHARED_SCOPES[key].name] ?? {}, pageName: WR_SHARED_SCOPES[key].name, children: _jsx(Component, {}) }));
15
+ // Colonna alta come lo schermo: il footer sta in fondo anche con la pagina corta o ancora in caricamento
16
+ return (_jsxs("div", { "data-wr-layout": true, style: { display: 'flex', flexDirection: 'column', minHeight: '100dvh' }, children: [header && shared('header', header), _jsx("main", { style: { flex: '1 0 auto' }, children: children }), footer && shared('footer', footer)] }));
17
+ }
18
+ //# sourceMappingURL=WrLayout.js.map
@@ -0,0 +1,10 @@
1
+ import React from 'react';
2
+ import type { WrPageConfig, WrElementConfig } from '../types';
3
+ export declare function WrPageProvider({ config, pageName, children }: {
4
+ config: WrPageConfig;
5
+ pageName: string;
6
+ children: React.ReactNode;
7
+ }): React.JSX.Element;
8
+ export declare function useWrPage(id: string): WrElementConfig | undefined;
9
+ export declare function useWrPageMeta(): string;
10
+ //# sourceMappingURL=WrPageContext.d.ts.map
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useContext } from 'react';
3
+ const WrPageContext = createContext(null);
4
+ export function WrPageProvider({ config, pageName, children }) {
5
+ return (_jsx(WrPageContext.Provider, { value: { config, pageName }, children: children }));
6
+ }
7
+ export function useWrPage(id) {
8
+ return useContext(WrPageContext)?.config[id];
9
+ }
10
+ export function useWrPageMeta() {
11
+ return useContext(WrPageContext).pageName;
12
+ }
13
+ //# sourceMappingURL=WrPageContext.js.map
@@ -0,0 +1,82 @@
1
+ import React from 'react';
2
+ import { wrFormProblems, type WrFieldRule, type WrFieldValue, type WrFormField, type WrFormMessages, type WrFormTransport } from './form.js';
3
+ export interface WrFormViewConfig {
4
+ hidden?: boolean;
5
+ [key: string]: unknown;
6
+ }
7
+ export interface WrInputViewConfig {
8
+ label?: unknown;
9
+ placeholder?: unknown;
10
+ ariaLabel?: unknown;
11
+ hidden?: boolean;
12
+ }
13
+ interface FormContext {
14
+ lang: string;
15
+ messages?: WrFormMessages;
16
+ transport: WrFormTransport;
17
+ values: Readonly<Record<string, WrFieldValue>>;
18
+ setValue(key: string, value: WrFieldValue): void;
19
+ /** What a visitor is told: only the fields they have been in (or all, after a try). */
20
+ shown: Readonly<Record<string, ReturnType<typeof wrFormProblems>[string]>>;
21
+ touch(key: string): void;
22
+ registerField(key: string, field: WrFormField): () => void;
23
+ setProof(payload: string | null): void;
24
+ registerAltcha(): () => void;
25
+ registerConsent(): () => void;
26
+ consent: boolean;
27
+ setConsent(accepted: boolean): void;
28
+ reset(): void;
29
+ disabled: boolean;
30
+ sending: boolean;
31
+ }
32
+ /** The form a part is in; null outside of one (a part then works on its own, with nothing to check). */
33
+ export declare const useWrForm: () => FormContext | null;
34
+ /** What the submit button needs: null outside a form. */
35
+ export declare function useWrSubmitGate(): {
36
+ disabled: boolean;
37
+ } | null;
38
+ export interface WrFormViewProps extends Omit<React.FormHTMLAttributes<HTMLFormElement>, 'id' | 'onSubmit'> {
39
+ id: string;
40
+ lang: string;
41
+ config?: WrFormViewConfig;
42
+ transport: WrFormTransport;
43
+ }
44
+ export declare function WrFormView({ id, lang, config, transport, children, ...props }: WrFormViewProps): React.ReactElement | null;
45
+ export interface WrInputViewProps extends WrFieldRule {
46
+ id: string;
47
+ lang: string;
48
+ config?: WrInputViewConfig;
49
+ className?: string;
50
+ /** `file`: the types the file chooser offers (the form still refuses what the backend does not take). */
51
+ accept?: string;
52
+ multiple?: boolean;
53
+ /** What it says until the page's JSON says otherwise (the first text lives in the code, as a `WrText`'s `html`). */
54
+ label?: string;
55
+ placeholder?: string;
56
+ /** Attributes for the field (the editor marks it interactive). */
57
+ rootProps?: Record<string, string>;
58
+ }
59
+ export declare function WrInputView({ id, lang, config, className, type, required, maxLength, min, max, step, accept, multiple, rootProps, label: codeLabel, placeholder: codePlaceholder }: WrInputViewProps): React.ReactElement | null;
60
+ export interface WrAltchaViewProps {
61
+ id: string;
62
+ className?: string;
63
+ rootProps?: Record<string, string>;
64
+ }
65
+ export declare function WrAltchaView({ id, className, rootProps }: WrAltchaViewProps): React.ReactElement | null;
66
+ export interface WrFormPrivacyViewProps {
67
+ id: string;
68
+ className?: string;
69
+ children?: React.ReactNode;
70
+ rootProps?: Record<string, string>;
71
+ }
72
+ /** The consent checkbox; its text is whatever is put inside (a `WrText` with the link to the privacy page), so it is translated like any text. */
73
+ export declare function WrFormPrivacyView({ id, className, children, rootProps }: WrFormPrivacyViewProps): React.ReactElement;
74
+ export interface WrFormSuccessViewProps {
75
+ id: string;
76
+ className?: string;
77
+ children?: React.ReactNode;
78
+ }
79
+ /** What the visitor reads after the form went. The base CSS shows it only then (`WR_BASE_CSS`); the editor shows it always, to be written. */
80
+ export declare function WrFormSuccessView({ id, className, children, ...props }: WrFormSuccessViewProps & Omit<React.HTMLAttributes<HTMLDivElement>, 'id'>): React.ReactElement;
81
+ export {};
82
+ //# sourceMappingURL=form-react.d.ts.map
@@ -0,0 +1,165 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // The form, once: state, validation, sending and the markup of every part. Two packages draw a form — `@webround/core` (the published site, which sends for real) and
3
+ // `@webround/editor` (the page in the editor, which sends nothing) — and each reads its own page config and talks to its own parent. What is the form itself lives here, and
4
+ // they hand it their `config`, their `lang` and their `transport`: the only thing that changes between the two is how a form is sent.
5
+ import React, { createContext, useContext, useEffect, useId, useMemo, useRef, useState } from 'react';
6
+ import { wrLocalized } from './localized.js';
7
+ import { wrFormMessage, wrFormMessagesOf, wrFormProblems, wrFormReady, } from './form.js';
8
+ const FormCtx = createContext(null);
9
+ /** The form a part is in; null outside of one (a part then works on its own, with nothing to check). */
10
+ export const useWrForm = () => useContext(FormCtx);
11
+ /** What the submit button needs: null outside a form. */
12
+ export function useWrSubmitGate() {
13
+ const form = useContext(FormCtx);
14
+ return form ? { disabled: form.disabled } : null;
15
+ }
16
+ export function WrFormView({ id, lang, config, transport, children, ...props }) {
17
+ const [fields, setFields] = useState({});
18
+ const [values, setValues] = useState({});
19
+ const [touched, setTouched] = useState({});
20
+ const [proof, setProofState] = useState(null);
21
+ const [hasAltcha, setHasAltcha] = useState(0);
22
+ const [consentAsked, setConsentAsked] = useState(0);
23
+ const [consent, setConsent] = useState(false);
24
+ const [sending, setSending] = useState(false);
25
+ const [sent, setSent] = useState(false);
26
+ const [failure, setFailure] = useState(null);
27
+ const [tried, setTried] = useState(false);
28
+ const messages = useMemo(() => wrFormMessagesOf(config), [config]);
29
+ const problems = useMemo(() => wrFormProblems(fields, values), [fields, values]);
30
+ const shown = useMemo(() => Object.fromEntries(Object.entries(problems).filter(([key]) => tried || touched[key])), [problems, touched, tried]);
31
+ const ready = wrFormReady(problems, { proof: hasAltcha > 0 && proof !== null, privacy: consentAsked > 0 ? consent : null });
32
+ // The functions never change (a part registers itself in an effect that depends on them: new ones each render would register it again, forever)
33
+ const actions = useMemo(() => ({
34
+ setValue: (key, value) => setValues(prev => ({ ...prev, [key]: value })),
35
+ touch: (key) => setTouched(prev => (prev[key] ? prev : { ...prev, [key]: true })),
36
+ registerField: (key, field) => {
37
+ setFields(prev => ({ ...prev, [key]: field }));
38
+ return () => setFields(prev => { const { [key]: _gone, ...rest } = prev; return rest; });
39
+ },
40
+ setProof: setProofState,
41
+ registerAltcha: () => { setHasAltcha(n => n + 1); return () => setHasAltcha(n => n - 1); },
42
+ registerConsent: () => { setConsentAsked(n => n + 1); return () => setConsentAsked(n => n - 1); },
43
+ setConsent,
44
+ // Back to filling the form in (the editor's form sends nothing, so it stays on its confirmation until someone asks)
45
+ reset: () => { setSent(false); setValues({}); setTouched({}); setTried(false); setConsent(false); setFailure(null); },
46
+ }), []);
47
+ const context = useMemo(() => ({ ...actions, lang, messages, transport, values, shown, consent, sending, disabled: !ready || sending }), [actions, lang, messages, transport, values, shown, consent, sending, ready]);
48
+ if (config?.hidden)
49
+ return null;
50
+ const onSubmit = async (event) => {
51
+ event.preventDefault();
52
+ setTried(true);
53
+ if (!ready || sending || proof === null)
54
+ return;
55
+ const entries = Object.entries(fields).map(([key, field]) => ({ key, label: field.label, value: values[key] ?? '' }));
56
+ setSending(true);
57
+ setFailure(null);
58
+ const result = await transport.send(entries, proof);
59
+ setSending(false);
60
+ if (result === null) {
61
+ setSent(true);
62
+ setValues({});
63
+ setTouched({});
64
+ setTried(false);
65
+ setConsent(false);
66
+ }
67
+ else
68
+ setFailure(result);
69
+ };
70
+ return (_jsx(FormCtx.Provider, { value: context, children: _jsxs("form", { noValidate: true, ...props, "data-wr-id": id, "data-wr-type": "form", "data-wr-sent": sent ? '' : undefined, "aria-busy": sending || undefined, onSubmit: onSubmit, children: [children, failure && _jsx("p", { role: "alert", className: "text-sm text-red-600", children: wrFormMessage(failure, lang, messages) })] }) }));
71
+ }
72
+ export function WrInputView({ id, lang, config, className, type, required, maxLength, min, max, step, accept, multiple, rootProps, label: codeLabel, placeholder: codePlaceholder }) {
73
+ const form = useWrForm();
74
+ const domId = useId();
75
+ const label = wrLocalized(config?.label, lang) ?? codeLabel ?? '';
76
+ const placeholder = wrLocalized(config?.placeholder, lang) ?? codePlaceholder;
77
+ const ariaLabel = wrLocalized(config?.ariaLabel, lang);
78
+ const registerField = form?.registerField;
79
+ useEffect(() => registerField?.(id, { type, required, maxLength, min, max, step, label: label || id }), [registerField, id, type, required, maxLength, min, max, step, label]);
80
+ if (config?.hidden)
81
+ return null;
82
+ const value = form?.values[id];
83
+ const problem = form?.shown[id];
84
+ const error = problem ? wrFormMessage(problem, lang, form?.messages) : null;
85
+ const shared = {
86
+ id: domId,
87
+ name: id,
88
+ required,
89
+ placeholder,
90
+ 'aria-label': label ? undefined : ariaLabel,
91
+ 'aria-invalid': error ? true : undefined,
92
+ 'aria-describedby': error ? `${domId}-error` : undefined,
93
+ // The element is the FIELD itself (what is seen and styled); the label and the error are around it. Its base look is in `WR_BASE_CSS`, under every class a person writes.
94
+ ...rootProps,
95
+ 'data-wr-id': id,
96
+ 'data-wr-type': 'input',
97
+ className,
98
+ onBlur: () => form?.touch(id),
99
+ };
100
+ let field;
101
+ if (type === 'textarea') {
102
+ field = _jsx("textarea", { ...shared, rows: 5, maxLength: maxLength, value: typeof value === 'string' ? value : '', onChange: e => form?.setValue(id, e.target.value) });
103
+ }
104
+ else if (type === 'file') {
105
+ field = _jsx("input", { ...shared, type: "file", accept: accept, multiple: multiple, onChange: e => { form?.setValue(id, Array.from(e.target.files ?? [])); form?.touch(id); } });
106
+ }
107
+ else {
108
+ field = _jsx("input", { ...shared, type: type, maxLength: maxLength, min: min, max: max, step: step, value: typeof value === 'string' ? value : '', onChange: e => form?.setValue(id, e.target.value) });
109
+ }
110
+ return (_jsxs("div", { children: [label && _jsxs("label", { htmlFor: domId, className: "mb-1.5 block text-sm font-medium text-fg", children: [label, required && _jsx("span", { "aria-hidden": "true", children: " *" })] }), field, error && _jsx("p", { id: `${domId}-error`, role: "alert", className: "mt-1 text-sm text-red-600", children: error })] }));
111
+ }
112
+ // ─── Altcha ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
113
+ // The widget follows the site's colours when it has them (`--color-*` tokens) and its own when it does not.
114
+ const ALTCHA_STYLE = {
115
+ '--altcha-color-text': 'var(--color-fg, currentColor)',
116
+ '--altcha-color-base': 'var(--color-page, Canvas)',
117
+ '--altcha-color-border': 'var(--color-line, #d4d4d8)',
118
+ '--altcha-color-border-focus': 'var(--color-accent, currentColor)',
119
+ '--altcha-border-radius': '0.75rem',
120
+ '--altcha-max-width': '20rem',
121
+ };
122
+ export function WrAltchaView({ id, className, rootProps }) {
123
+ const form = useWrForm();
124
+ const widget = useRef(null);
125
+ const [ready, setReady] = useState(false);
126
+ const registerAltcha = form?.registerAltcha;
127
+ const setProof = form?.setProof;
128
+ useEffect(() => registerAltcha?.(), [registerAltcha]);
129
+ // The widget is a heavy custom element: it is loaded when a form that has it is on the page, never with the rest of the site.
130
+ useEffect(() => {
131
+ let alive = true;
132
+ import('altcha').then(() => { if (alive)
133
+ setReady(true); }, () => undefined);
134
+ return () => { alive = false; };
135
+ }, []);
136
+ useEffect(() => {
137
+ const el = widget.current;
138
+ if (!ready || !el)
139
+ return;
140
+ const onState = (event) => {
141
+ const detail = event.detail;
142
+ setProof?.(detail?.state === 'verified' && detail.payload ? detail.payload : null);
143
+ };
144
+ el.addEventListener('statechange', onState);
145
+ return () => { el.removeEventListener('statechange', onState); setProof?.(null); };
146
+ }, [ready, setProof]);
147
+ return (_jsx("div", { ...rootProps, "data-wr-id": id, "data-wr-type": "altcha", className: className ?? 'flex justify-center', style: ALTCHA_STYLE, children: ready && React.createElement('altcha-widget', {
148
+ ref: widget,
149
+ ...(form?.transport.mock ? { test: true } : { challengeurl: form?.transport.challengeUrl }),
150
+ hidefooter: true,
151
+ }) }));
152
+ }
153
+ /** The consent checkbox; its text is whatever is put inside (a `WrText` with the link to the privacy page), so it is translated like any text. */
154
+ export function WrFormPrivacyView({ id, className, children, rootProps }) {
155
+ const form = useWrForm();
156
+ const textId = useId();
157
+ const registerConsent = form?.registerConsent;
158
+ useEffect(() => registerConsent?.(), [registerConsent]);
159
+ return (_jsxs("div", { ...rootProps, "data-wr-id": id, "data-wr-type": "form-privacy", className: className ?? 'flex items-start gap-2 text-sm text-muted', children: [_jsx("input", { type: "checkbox", className: "mt-1 size-4 shrink-0 accent-[var(--color-accent,currentColor)]", "aria-labelledby": textId, checked: form?.consent ?? false, onChange: e => form?.setConsent(e.target.checked) }), _jsx("div", { id: textId, children: children })] }));
160
+ }
161
+ /** What the visitor reads after the form went. The base CSS shows it only then (`WR_BASE_CSS`); the editor shows it always, to be written. */
162
+ export function WrFormSuccessView({ id, className, children, ...props }) {
163
+ return _jsx("div", { ...props, "data-wr-id": id, "data-wr-type": "form-success", role: "status", "aria-live": "polite", className: className, children: children });
164
+ }
165
+ //# sourceMappingURL=form-react.js.map
package/dist/form.d.ts ADDED
@@ -0,0 +1,83 @@
1
+ import { type WrLocalized } from './localized.js';
2
+ export declare const WR_INPUT_TYPES: readonly ["text", "email", "tel", "number", "textarea", "file"];
3
+ export type WrInputType = (typeof WR_INPUT_TYPES)[number];
4
+ /** What the Webround backend (Core, `POST /form/:draftId`) accepts: the same list it checks. */
5
+ export declare const WR_FORM_FILE_TYPES: readonly string[];
6
+ /** The server takes 40 MB per file; the form stops at 30, so a file that is refused is refused before it travels. */
7
+ export declare const WR_FORM_FILE_MAX_BYTES: number;
8
+ export declare const WR_FORM_FILES_MAX = 10;
9
+ /** The value of a field: its text, or the files chosen. */
10
+ export type WrFieldValue = string | File[];
11
+ /** What a field asks of its value (the props of an input that matter to the checking). */
12
+ export interface WrFieldRule {
13
+ type: WrInputType;
14
+ required?: boolean;
15
+ maxLength?: number;
16
+ min?: number;
17
+ max?: number;
18
+ step?: number;
19
+ }
20
+ export type WrFieldError = 'required' | 'email' | 'number' | 'min' | 'max' | 'step' | 'maxLength' | 'file-count' | 'file-size' | 'file-type';
21
+ /** The first reason a value is not acceptable, or null. An empty value is fine unless the field is required. */
22
+ export declare function wrValidateField(rule: WrFieldRule, value: WrFieldValue | undefined): WrFieldError | null;
23
+ type MessageTable = Record<WrFieldError | 'submit-failed' | 'rate-limited' | 'expired' | 'not-configured', string>;
24
+ export type WrFormMessage = keyof MessageTable;
25
+ /** Every message a form can say, in the order the panel lists them. */
26
+ export declare const WR_FORM_MESSAGE_CODES: WrFormMessage[];
27
+ /** The page JSON of a form holds each message as `msg-<code>` (a translated text like any other field): the messages as `wrFormMessage` reads them. */
28
+ export declare function wrFormMessagesOf(config: Record<string, unknown> | undefined): WrFormMessages;
29
+ /** The messages a form says in its own words: per message, a text per language (`{ default, es… }`, as every translated text of a page). */
30
+ export type WrFormMessages = Partial<Record<WrFormMessage, WrLocalized>>;
31
+ /**
32
+ * The text for a message in the language of the page. Order: what the form says in that language; what Webround says in it (`it-IT` reads as `it`);
33
+ * what the form says as its `default`; English. So a form that writes only the Spanish text gets Spanish, and the languages it did not write
34
+ * keep reading what they read before.
35
+ */
36
+ export declare function wrFormMessage(code: WrFormMessage, lang: string, own?: WrFormMessages): string;
37
+ /** A field as the form sends it: the `id` of the input is the key, the label is what the owner reads in the email. */
38
+ export interface WrFormEntry {
39
+ key: string;
40
+ label: string;
41
+ value: WrFieldValue;
42
+ }
43
+ /**
44
+ * The body of `POST /form/:draftId`: a multipart with `formData` (the JSON `{ key: { label, value }, …, proof }`) and the `files`.
45
+ * A file field reads, in the JSON, as the names of the files; the files themselves travel as `files`.
46
+ */
47
+ export declare function wrBuildFormBody(entries: readonly WrFormEntry[], proof: string): FormData;
48
+ /** What the backend's answer means to the visitor. */
49
+ export declare function wrFormFailure(status: number): WrFormMessage;
50
+ /** A field the form knows about: what it asks of its value, and the name the owner reads in the email. */
51
+ export interface WrFormField extends WrFieldRule {
52
+ label: string;
53
+ }
54
+ /** The fields that cannot be sent as they are, by key. */
55
+ export declare function wrFormProblems(fields: Readonly<Record<string, WrFieldRule>>, values: Readonly<Record<string, WrFieldValue>>): Record<string, WrFieldError>;
56
+ /** The three things besides the fields that a form waits for before it can be sent. */
57
+ export interface WrFormGates {
58
+ /** The Altcha proof of work has been solved (a form with no Altcha cannot be sent: the backend refuses it). */
59
+ proof: boolean;
60
+ /** The privacy consent: `null` when the form does not ask for it. */
61
+ privacy: boolean | null;
62
+ }
63
+ /** Whether the form may be sent now: no field problem, the proof, the consent. THE rule of the submit button. */
64
+ export declare function wrFormReady(problems: Readonly<Record<string, unknown>>, gates: WrFormGates): boolean;
65
+ /** Where a form is sent. `challengeUrl` is where the Altcha widget asks for its challenge. */
66
+ export interface WrFormTarget {
67
+ coreApiUrl?: string;
68
+ draftId?: string;
69
+ }
70
+ export declare const wrChallengeUrl: (target: WrFormTarget) => string | undefined;
71
+ /** How a form is sent: the real one talks to the Webround backend; the editor swaps in one that does not. `null` = it went. */
72
+ export interface WrFormTransport {
73
+ /** Altcha runs with a mock challenge, with no request to the backend. */
74
+ mock: boolean;
75
+ challengeUrl?: string;
76
+ send(entries: readonly WrFormEntry[], proof: string): Promise<WrFormMessage | null>;
77
+ }
78
+ /** `POST /form/:draftId`. Anything that goes wrong is a message for the visitor, never an exception. */
79
+ export declare function wrSubmitForm(target: WrFormTarget, entries: readonly WrFormEntry[], proof: string, fetchImpl?: typeof fetch): Promise<WrFormMessage | null>;
80
+ /** The transport of a published site. */
81
+ export declare const wrRealTransport: (target: WrFormTarget) => WrFormTransport;
82
+ export {};
83
+ //# sourceMappingURL=form.d.ts.map