fontdue-js 3.10.1 → 3.12.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 (30) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/__generated__/CustomerLoginFormLoginMutation.graphql.d.ts +2 -1
  3. package/dist/__generated__/CustomerLoginFormLoginMutation.graphql.js +1 -1
  4. package/dist/__generated__/CustomerLoginFormRecaptchaQuery.graphql.d.ts +21 -0
  5. package/dist/__generated__/CustomerLoginFormRecaptchaQuery.graphql.js +86 -0
  6. package/dist/__generated__/TestFontsForm_Query.graphql.d.ts +6 -1
  7. package/dist/__generated__/TestFontsForm_Query.graphql.js +25 -9
  8. package/dist/__tests__/customerLoginRecaptcha.test.js +113 -0
  9. package/dist/__tests__/testFontsFamilyChoice.test.js +74 -1
  10. package/dist/__tests__/typeTesterHeightAnimation.test.js +124 -0
  11. package/dist/__tests__/typeTesterShyFeaturesClose.test.js +133 -0
  12. package/dist/components/ConfigContext.d.ts +4 -0
  13. package/dist/components/ConfigContext.js +3 -2
  14. package/dist/components/CustomerLoginForm/index.js +106 -11
  15. package/dist/components/TestFontsForm/TestFontsFormElement.d.ts +2 -1
  16. package/dist/components/TestFontsForm/TestFontsFormElement.js +3 -0
  17. package/dist/components/TestFontsForm/familyGroups.d.ts +16 -0
  18. package/dist/components/TestFontsForm/familyGroups.js +34 -0
  19. package/dist/components/TestFontsForm/index.d.ts +2 -1
  20. package/dist/components/TestFontsForm/index.js +51 -21
  21. package/dist/components/TypeTester/TypeTesterFeatures.js +27 -44
  22. package/dist/components/TypeTester/useCloseWhenToolbarHides.d.ts +17 -0
  23. package/dist/components/TypeTester/useCloseWhenToolbarHides.js +79 -0
  24. package/dist/components/TypeTester/useHeightAnimation.d.ts +27 -0
  25. package/dist/components/TypeTester/useHeightAnimation.js +85 -0
  26. package/dist/fontLoader.d.ts +8 -6
  27. package/dist/fontLoader.js +8 -6
  28. package/dist/fontdue.css +3 -0
  29. package/dist/version.js +1 -1
  30. package/package.json +1 -1
@@ -0,0 +1,133 @@
1
+ // @vitest-environment happy-dom
2
+ import React, { act, useRef, useState } from 'react';
3
+ import { createRoot } from 'react-dom/client';
4
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
5
+ import useCloseWhenToolbarHides, { CLOSE_DELAY } from '../components/TypeTester/useCloseWhenToolbarHides.js';
6
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
7
+ let open = false;
8
+
9
+ // The tester's markup reduced to what the hook reads: the `.type-tester` root,
10
+ // something focusable inside it (the editable text) and the surface's element.
11
+ function Tester(_ref) {
12
+ let {
13
+ shy
14
+ } = _ref;
15
+ const [featuresOpen, setFeaturesOpen] = useState(true);
16
+ open = featuresOpen;
17
+ const ref = useRef(null);
18
+ useCloseWhenToolbarHides(ref, {
19
+ open: featuresOpen,
20
+ setOpen: setFeaturesOpen,
21
+ shy
22
+ });
23
+ return /*#__PURE__*/React.createElement("div", {
24
+ className: "type-tester"
25
+ }, /*#__PURE__*/React.createElement("input", {
26
+ className: "text"
27
+ }), /*#__PURE__*/React.createElement("div", {
28
+ className: "type-tester__features-container",
29
+ ref: ref
30
+ }));
31
+ }
32
+ describe('features surface under a shy toolbar', () => {
33
+ let root;
34
+ let container;
35
+ let outside;
36
+ const tester = () => container.querySelector('.type-tester');
37
+ const text = () => container.querySelector('.text');
38
+ const pointer = function (target, type) {
39
+ let pointerType = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 'mouse';
40
+ return act(() => {
41
+ target.dispatchEvent(new PointerEvent(type, {
42
+ pointerType,
43
+ bubbles: true
44
+ }));
45
+ });
46
+ };
47
+ const wait = ms => act(() => vi.advanceTimersByTime(ms));
48
+ const render = shy => act(() => root.render( /*#__PURE__*/React.createElement(Tester, {
49
+ shy: shy
50
+ })));
51
+ beforeEach(() => {
52
+ vi.useFakeTimers();
53
+ container = document.createElement('div');
54
+ outside = document.createElement('button');
55
+ document.body.append(container, outside);
56
+ root = createRoot(container);
57
+ });
58
+ afterEach(() => {
59
+ act(() => root.unmount());
60
+ container.remove();
61
+ outside.remove();
62
+ vi.useRealTimers();
63
+ });
64
+ describe("shy: 'hover'", () => {
65
+ beforeEach(async () => {
66
+ await render('hover');
67
+ await pointer(tester(), 'pointerenter');
68
+ });
69
+ it('closes once the pointer has been gone for the delay', async () => {
70
+ await pointer(tester(), 'pointerleave');
71
+ await wait(CLOSE_DELAY - 1);
72
+ expect(open).toBe(true);
73
+ await wait(1);
74
+ expect(open).toBe(false);
75
+ });
76
+ it('stays open when the pointer comes back within the delay', async () => {
77
+ await pointer(tester(), 'pointerleave');
78
+ await wait(CLOSE_DELAY - 50);
79
+ await pointer(tester(), 'pointerenter');
80
+ await wait(CLOSE_DELAY);
81
+ expect(open).toBe(true);
82
+ });
83
+ it('stays open while the tester holds focus, then closes when focus leaves', async () => {
84
+ act(() => text().focus());
85
+ await pointer(tester(), 'pointerleave');
86
+ await wait(CLOSE_DELAY);
87
+ expect(open).toBe(true);
88
+ act(() => outside.focus());
89
+ await wait(CLOSE_DELAY);
90
+ expect(open).toBe(false);
91
+ });
92
+ it('stays open when focus leaves while the pointer is still over it', async () => {
93
+ act(() => text().focus());
94
+ act(() => outside.focus());
95
+ await wait(CLOSE_DELAY);
96
+ expect(open).toBe(true);
97
+ });
98
+ });
99
+ it('on a touch device (no hover), closes on a touch outside the tester', async () => {
100
+ await render('hover');
101
+ await pointer(tester(), 'pointerleave', 'touch');
102
+ await wait(CLOSE_DELAY);
103
+ expect(open).toBe(true);
104
+ await pointer(text(), 'pointerdown', 'touch');
105
+ await wait(CLOSE_DELAY);
106
+ expect(open).toBe(true);
107
+ await pointer(outside, 'pointerdown', 'touch');
108
+ await wait(CLOSE_DELAY);
109
+ expect(open).toBe(false);
110
+ });
111
+ it('treats shy: true as hover', async () => {
112
+ await render(true);
113
+ await pointer(tester(), 'pointerenter');
114
+ await pointer(tester(), 'pointerleave');
115
+ await wait(CLOSE_DELAY);
116
+ expect(open).toBe(false);
117
+ });
118
+ it("closes under shy: 'focus' when focus leaves, even while hovered", async () => {
119
+ await render('focus');
120
+ await pointer(tester(), 'pointerenter');
121
+ act(() => text().focus());
122
+ act(() => outside.focus());
123
+ await wait(CLOSE_DELAY);
124
+ expect(open).toBe(false);
125
+ });
126
+ it('leaves the surface alone without shy, where its toggle stays visible', async () => {
127
+ await render(false);
128
+ await pointer(tester(), 'pointerenter');
129
+ await pointer(tester(), 'pointerleave');
130
+ await wait(CLOSE_DELAY);
131
+ expect(open).toBe(true);
132
+ });
133
+ });
@@ -29,8 +29,10 @@ interface PreviewConfig {
29
29
  clientSide?: boolean;
30
30
  }
31
31
  export type TestFontsFamilySelection = 'all' | 'choice';
32
+ export type TestFontsFamilyGrouping = 'flat' | 'superfamily';
32
33
  interface TestFontsConfig {
33
34
  familySelection?: TestFontsFamilySelection;
35
+ familyGrouping?: TestFontsFamilyGrouping;
34
36
  }
35
37
  export interface Config {
36
38
  form?: FormConfig;
@@ -101,6 +103,7 @@ export declare const makeConfig: (config?: Config) => {
101
103
  };
102
104
  testFonts: {
103
105
  familySelection: TestFontsFamilySelection | undefined;
106
+ familyGrouping: TestFontsFamilyGrouping | undefined;
104
107
  };
105
108
  storeModal: {
106
109
  indexLayout: "styled-aa" | "styled-font-names";
@@ -182,6 +185,7 @@ declare const _default: React.Context<{
182
185
  };
183
186
  testFonts: {
184
187
  familySelection: TestFontsFamilySelection | undefined;
188
+ familyGrouping: TestFontsFamilyGrouping | undefined;
185
189
  };
186
190
  storeModal: {
187
191
  indexLayout: "styled-aa" | "styled-font-names";
@@ -88,7 +88,7 @@ export const mergeConfig = (base, override) => {
88
88
  return deepMerge(base, override);
89
89
  };
90
90
  export const makeConfig = config => {
91
- var _config$form, _config$testFonts, _config$storeModal, _config$storeModal2, _config$storeModal3, _config$storeModal4, _config$stripe, _config$tracking, _config$tracking2, _config$tracking3, _config$tracking4, _config$preview, _config$preview2, _config$preview3;
91
+ var _config$form, _config$testFonts, _config$testFonts2, _config$storeModal, _config$storeModal2, _config$storeModal3, _config$storeModal4, _config$stripe, _config$tracking, _config$tracking2, _config$tracking3, _config$tracking4, _config$preview, _config$preview2, _config$preview3;
92
92
  return {
93
93
  typeTester: makeTypeTesterConfig(config === null || config === void 0 ? void 0 : config.typeTester),
94
94
  form: {
@@ -98,7 +98,8 @@ export const makeConfig = config => {
98
98
  // Deliberately no default: the form falls back to the dashboard setting
99
99
  // it reads in its own query (the npm path never merges server config
100
100
  // into `children`'s context – see ServerConfigProvider).
101
- familySelection: config === null || config === void 0 ? void 0 : (_config$testFonts = config.testFonts) === null || _config$testFonts === void 0 ? void 0 : _config$testFonts.familySelection
101
+ familySelection: config === null || config === void 0 ? void 0 : (_config$testFonts = config.testFonts) === null || _config$testFonts === void 0 ? void 0 : _config$testFonts.familySelection,
102
+ familyGrouping: config === null || config === void 0 ? void 0 : (_config$testFonts2 = config.testFonts) === null || _config$testFonts2 === void 0 ? void 0 : _config$testFonts2.familyGrouping
102
103
  },
103
104
  storeModal: {
104
105
  indexLayout: (config === null || config === void 0 ? void 0 : (_config$storeModal = config.storeModal) === null || _config$storeModal === void 0 ? void 0 : _config$storeModal.indexLayout) ?? 'styled-aa',
@@ -1,13 +1,19 @@
1
1
  'use client';
2
2
 
3
+ import _CustomerLoginFormRecaptchaQuery from "../../__generated__/CustomerLoginFormRecaptchaQuery.graphql.js";
3
4
  import _CustomerLoginFormQuery from "../../__generated__/CustomerLoginFormQuery.graphql.js";
4
5
  import _CustomerLoginFormLoginMutation from "../../__generated__/CustomerLoginFormLoginMutation.graphql.js";
5
- import React, { Suspense, useState } from 'react';
6
+ import React, { Suspense, useCallback, useEffect, useRef, useState } from 'react';
6
7
  import { commitMutation, graphql, useLazyLoadQuery, useRelayEnvironment } from 'react-relay';
8
+ // The script-injecting reCAPTCHA wrapper (loads Google's api.js); see
9
+ // ../Recaptcha for why it isn't the package's named export.
10
+ import { ReCAPTCHA } from '../Recaptcha.js';
7
11
  import TextField from '../TextField/index.js';
12
+ import { useRecaptchaTimeout, isRecaptchaScriptLoaded, RECAPTCHA_UNAVAILABLE_MESSAGE } from '../../hooks/useRecaptchaTimeout.js';
8
13
  const loginMutation = (_CustomerLoginFormLoginMutation.hash && _CustomerLoginFormLoginMutation.hash !== "975b639fac1e0d88e0f0c9c55acd8b9c" && console.error("The definition of 'CustomerLoginFormLoginMutation' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _CustomerLoginFormLoginMutation);
9
14
  const query = (_CustomerLoginFormQuery.hash && _CustomerLoginFormQuery.hash !== "f32c7b0b485a2b2a02ddf2cbc5b87a3c" && console.error("The definition of 'CustomerLoginFormQuery' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _CustomerLoginFormQuery);
10
- const DEFAULT_SUBMITTED_LABEL = '<p>Submitted!</p><p>Please check your email inbox for a link to log in.</p><p>If you don\u2019t receive an email, please contact us to retrieve your order information.</p>';
15
+ const recaptchaQuery = (_CustomerLoginFormRecaptchaQuery.hash && _CustomerLoginFormRecaptchaQuery.hash !== "fe7289e19bcc08dbad62e72d67c48c97" && console.error("The definition of 'CustomerLoginFormRecaptchaQuery' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _CustomerLoginFormRecaptchaQuery);
16
+ const DEFAULT_SUBMITTED_LABEL = '<p>Submitted!</p><p>Please check your email inbox for a link to log in.</p><p>If you don’t receive an email, please contact us to retrieve your order information.</p>';
11
17
  const CustomerLoginForm = _ref => {
12
18
  let {
13
19
  submitLabel = 'Submit'
@@ -16,24 +22,40 @@ const CustomerLoginForm = _ref => {
16
22
  const [error, setError] = useState(null);
17
23
  const [submitting, setSubmitting] = useState(false);
18
24
  const [submitted, setSubmitted] = useState(false);
25
+ // The store's reCAPTCHA site key once known: null when it doesn't use
26
+ // reCAPTCHA. Undefined until the settings arrive, which is only after mount.
27
+ const [siteKey, setSiteKey] = useState(undefined);
28
+ const [recaptchaToken, setRecaptchaToken] = useState(null);
29
+ const [pendingSubmit, setPendingSubmit] = useState(false);
30
+ const [mounted, setMounted] = useState(false);
31
+ const recaptchaRef = useRef(null);
32
+ const recaptchaTimeout = useRecaptchaTimeout();
19
33
  const environment = useRelayEnvironment();
20
- const handleSubmit = e => {
21
- e.preventDefault();
34
+ useEffect(() => setMounted(true), []);
35
+ const resetRecaptcha = useCallback(() => {
36
+ var _recaptchaRef$current;
37
+ (_recaptchaRef$current = recaptchaRef.current) === null || _recaptchaRef$current === void 0 ? void 0 : _recaptchaRef$current.reset();
38
+ setRecaptchaToken(null);
39
+ }, []);
40
+ const submitForm = useCallback(token => {
41
+ recaptchaTimeout.clear();
42
+ setPendingSubmit(false);
22
43
  setSubmitting(true);
23
44
  commitMutation(environment, {
24
45
  mutation: loginMutation,
25
46
  variables: {
26
47
  input: {
27
- email
48
+ email,
49
+ recaptchaToken: token ?? undefined
28
50
  }
29
51
  },
30
52
  onCompleted: (_res, errors) => {
53
+ setSubmitting(false);
31
54
  if (errors && errors.length > 0) {
32
55
  setError(errors[0].message);
33
- setSubmitting(false);
56
+ resetRecaptcha();
34
57
  } else {
35
58
  setSubmitted(true);
36
- setSubmitting(false);
37
59
  setError(null);
38
60
  }
39
61
  },
@@ -43,8 +65,46 @@ const CustomerLoginForm = _ref => {
43
65
  } = _ref2;
44
66
  setError(message);
45
67
  setSubmitting(false);
68
+ resetRecaptcha();
46
69
  }
47
70
  });
71
+ }, [environment, email, recaptchaTimeout, resetRecaptcha]);
72
+ const handleRecaptchaChange = useCallback(token => {
73
+ setRecaptchaToken(token);
74
+ if (token && pendingSubmit) submitForm(token);
75
+ }, [pendingSubmit, submitForm]);
76
+ const handleRecaptchaExpired = useCallback(() => setRecaptchaToken(null), []);
77
+
78
+ // Recover when the widget can't produce a token, so the button doesn't sit
79
+ // on “Submitting…” forever if Google's script fails to load.
80
+ const abortRecaptcha = useCallback(() => {
81
+ recaptchaTimeout.clear();
82
+ setPendingSubmit(false);
83
+ resetRecaptcha();
84
+ setError(RECAPTCHA_UNAVAILABLE_MESSAGE);
85
+ }, [recaptchaTimeout, resetRecaptcha]);
86
+ const handleRecaptchaErrored = useCallback(() => {
87
+ if (pendingSubmit) abortRecaptcha();
88
+ }, [pendingSubmit, abortRecaptcha]);
89
+ const handleSubmit = e => {
90
+ e.preventDefault();
91
+ setError(null);
92
+ if (siteKey && !recaptchaToken) {
93
+ var _recaptchaRef$current2;
94
+ setPendingSubmit(true);
95
+ // Give up if no token arrives and the script never loaded; a visitor
96
+ // mid-challenge keeps `grecaptcha` defined and isn't cut off.
97
+ recaptchaTimeout.start(() => {
98
+ if (isRecaptchaScriptLoaded()) return;
99
+ abortRecaptcha();
100
+ });
101
+ (_recaptchaRef$current2 = recaptchaRef.current) === null || _recaptchaRef$current2 === void 0 ? void 0 : _recaptchaRef$current2.execute();
102
+ return;
103
+ }
104
+
105
+ // Settings not in yet (a submit within moments of the page loading) goes
106
+ // without a token; the server says so if the store needs one.
107
+ submitForm(recaptchaToken);
48
108
  };
49
109
  const disabled = !email;
50
110
  return /*#__PURE__*/React.createElement("div", {
@@ -69,23 +129,58 @@ const CustomerLoginForm = _ref => {
69
129
  onChange: value => setEmail(value),
70
130
  autocomplete: "email",
71
131
  placeholder: "Your email"
132
+ })), mounted && /*#__PURE__*/React.createElement(Suspense, {
133
+ fallback: null
134
+ }, /*#__PURE__*/React.createElement(LoginRecaptcha, {
135
+ widgetRef: recaptchaRef,
136
+ onSiteKey: setSiteKey,
137
+ onChange: handleRecaptchaChange,
138
+ onExpired: handleRecaptchaExpired,
139
+ onErrored: handleRecaptchaErrored
72
140
  })), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("button", {
73
141
  className: "submit-button",
74
142
  type: "submit",
75
- disabled: submitting || disabled
76
- }, submitting ? 'Submitting...' : submitLabel, /*#__PURE__*/React.createElement("span", {
143
+ disabled: submitting || pendingSubmit || disabled
144
+ }, submitting || pendingSubmit ? 'Submitting...' : submitLabel, /*#__PURE__*/React.createElement("span", {
77
145
  className: "submit-button__arrow"
78
146
  }, " \u2192")))));
79
147
  };
80
148
 
149
+ // The store's reCAPTCHA settings and, when it uses reCAPTCHA, the invisible
150
+ // widget. Rendered only after mount, so the lazy query stays out of the SSR
151
+ // pass for the same reason as SubmittedMessage below.
152
+ function LoginRecaptcha(_ref3) {
153
+ var _data$viewer;
154
+ let {
155
+ widgetRef,
156
+ onSiteKey,
157
+ onChange,
158
+ onExpired,
159
+ onErrored
160
+ } = _ref3;
161
+ const data = useLazyLoadQuery(recaptchaQuery, {});
162
+ const settings = (_data$viewer = data.viewer) === null || _data$viewer === void 0 ? void 0 : _data$viewer.settings;
163
+ const siteKey = settings !== null && settings !== void 0 && settings.recaptchaEnabled ? settings.recaptchaSiteKey ?? null : null;
164
+ useEffect(() => onSiteKey(siteKey), [siteKey, onSiteKey]);
165
+ if (!siteKey) return null;
166
+ return /*#__PURE__*/React.createElement(ReCAPTCHA, {
167
+ ref: widgetRef,
168
+ sitekey: siteKey,
169
+ size: "invisible",
170
+ onChange: onChange,
171
+ onExpired: onExpired,
172
+ onErrored: onErrored
173
+ });
174
+ }
175
+
81
176
  // The settings label is only shown after a submit, which can only happen in
82
177
  // the browser — fetching it here (not in the form component) keeps the lazy
83
178
  // query out of the SSR pass, where it has no session and, in multi-tenant
84
179
  // setups, possibly no resolvable GraphQL URL.
85
180
  function SubmittedMessage() {
86
- var _data$viewer, _data$viewer$settings;
181
+ var _data$viewer2, _data$viewer2$setting;
87
182
  const data = useLazyLoadQuery(query, {});
88
- const submittedLabel = ((_data$viewer = data.viewer) === null || _data$viewer === void 0 ? void 0 : (_data$viewer$settings = _data$viewer.settings) === null || _data$viewer$settings === void 0 ? void 0 : _data$viewer$settings.customerLoginSubmittedLabel) || DEFAULT_SUBMITTED_LABEL;
183
+ const submittedLabel = ((_data$viewer2 = data.viewer) === null || _data$viewer2 === void 0 ? void 0 : (_data$viewer2$setting = _data$viewer2.settings) === null || _data$viewer2$setting === void 0 ? void 0 : _data$viewer2$setting.customerLoginSubmittedLabel) || DEFAULT_SUBMITTED_LABEL;
89
184
  return /*#__PURE__*/React.createElement("div", {
90
185
  className: "login-form__submitted",
91
186
  dangerouslySetInnerHTML: {
@@ -6,6 +6,7 @@ interface TestFontsFormElement_props {
6
6
  collectionId?: string;
7
7
  collectionSlug?: string;
8
8
  familySelection?: string;
9
+ familyGrouping?: string;
9
10
  }
10
- declare const TestFontsFormElement: ({ familySelection, ...props }: TestFontsFormElement_props) => React.JSX.Element;
11
+ declare const TestFontsFormElement: ({ familySelection, familyGrouping, ...props }: TestFontsFormElement_props) => React.JSX.Element;
11
12
  export default TestFontsFormElement;
@@ -2,12 +2,15 @@ function _extends() { return _extends = Object.assign ? Object.assign.bind() : f
2
2
  import React from 'react';
3
3
  import TestFontsFormFetcher from './index.js';
4
4
  const FAMILY_SELECTIONS = ['all', 'choice'];
5
+ const FAMILY_GROUPINGS = ['flat', 'superfamily'];
5
6
  const TestFontsFormElement = _ref => {
6
7
  let {
7
8
  familySelection,
9
+ familyGrouping,
8
10
  ...props
9
11
  } = _ref;
10
12
  return /*#__PURE__*/React.createElement(TestFontsFormFetcher, _extends({}, props, {
13
+ familyGrouping: familyGrouping && FAMILY_GROUPINGS.includes(familyGrouping) ? familyGrouping : undefined,
11
14
  familySelection: familySelection && FAMILY_SELECTIONS.includes(familySelection) ? familySelection : undefined,
12
15
  newsletterCheckboxChecked: props.newsletterCheckboxChecked === 'true' ? true : false
13
16
  }));
@@ -0,0 +1,16 @@
1
+ import { TestFontsFamilyGrouping } from '../ConfigContext.js';
2
+ interface Superfamily {
3
+ readonly id: string;
4
+ readonly name: string | null | undefined;
5
+ }
6
+ export interface TestFontsFamily {
7
+ readonly id: string;
8
+ readonly name: string | null | undefined;
9
+ readonly parent?: Superfamily | null;
10
+ }
11
+ export interface FamilyGroup<Family extends TestFontsFamily> {
12
+ superfamily: Superfamily | null;
13
+ families: Family[];
14
+ }
15
+ export declare function groupFamilies<Family extends TestFontsFamily>(families: readonly Family[], grouping: TestFontsFamilyGrouping): FamilyGroup<Family>[];
16
+ export {};
@@ -0,0 +1,34 @@
1
+ // One row of the family checkboxes: a lone family (`superfamily` null), or a
2
+ // superfamily with its families nested beneath it.
3
+
4
+ // Lays the families out for the form's checkboxes, keeping their order: flat
5
+ // lists each family on its own; 'superfamily' gathers families under their
6
+ // superfamily, placed where its first family falls. A superfamily with only one
7
+ // family to offer adds nothing over that family, so the family stands alone.
8
+ export function groupFamilies(families, grouping) {
9
+ const alone = family => ({
10
+ superfamily: null,
11
+ families: [family]
12
+ });
13
+ if (grouping !== 'superfamily') return families.map(alone);
14
+ const groups = [];
15
+ const bySuperfamily = new Map();
16
+ for (const family of families) {
17
+ const superfamily = family.parent;
18
+ if (!superfamily) {
19
+ groups.push(alone(family));
20
+ continue;
21
+ }
22
+ let group = bySuperfamily.get(superfamily.id);
23
+ if (!group) {
24
+ group = {
25
+ superfamily,
26
+ families: []
27
+ };
28
+ bySuperfamily.set(superfamily.id, group);
29
+ groups.push(group);
30
+ }
31
+ group.families.push(family);
32
+ }
33
+ return groups.map(group => group.families.length > 1 ? group : alone(group.families[0]));
34
+ }
@@ -1,13 +1,14 @@
1
1
  import React from 'react';
2
2
  import { TestFontsForm_Query } from '../../__generated__/TestFontsForm_Query.graphql.js';
3
3
  import { SerializablePreloadedQuery, type LoadQueryOptions } from '../../relay/loadSerializableQuery.js';
4
- import { Config, TestFontsFamilySelection } from '../ConfigContext.js';
4
+ import { Config, TestFontsFamilyGrouping, TestFontsFamilySelection } from '../ConfigContext.js';
5
5
  import { TestFontsFormCollectionProps } from './variables.js';
6
6
  export interface TestFontsForm_props extends TestFontsFormCollectionProps {
7
7
  agreementLabel?: string;
8
8
  downloadLabel?: string;
9
9
  newsletterCheckboxChecked?: boolean;
10
10
  familySelection?: TestFontsFamilySelection;
11
+ familyGrouping?: TestFontsFamilyGrouping;
11
12
  }
12
13
  export type TestFontsFormPreloadedQuery = SerializablePreloadedQuery<TestFontsForm_Query>;
13
14
  export type LoadTestFontsFormQueryVariables = TestFontsFormCollectionProps;
@@ -18,6 +18,7 @@ import useSerializablePreloadedQuery from '../../relay/useSerializablePreloadedQ
18
18
  import { EnsureFontdueContext, LazyQueryBoundary } from '../FontdueContextProvider/index.js';
19
19
  import ConfigContext from '../ConfigContext.js';
20
20
  import { testFontsFormQueryVariables } from './variables.js';
21
+ import { groupFamilies } from './familyGroups.js';
21
22
  import { useRecaptchaTimeout, isRecaptchaScriptLoaded, RECAPTCHA_UNAVAILABLE_MESSAGE } from '../../hooks/useRecaptchaTimeout.js';
22
23
  const updateCustomerMutation = (_TestFontsFormUpdateCustomerMutation.hash && _TestFontsFormUpdateCustomerMutation.hash !== "ba56958399f0893bd667ff02c33a6975" && console.error("The definition of 'TestFontsFormUpdateCustomerMutation' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _TestFontsFormUpdateCustomerMutation);
23
24
  // The archive endpoint's family filter: `collection_ids[]` narrows the ZIP to
@@ -41,7 +42,7 @@ const TestFontsDownloading = _ref => {
41
42
  }, "here"), ".")));
42
43
  };
43
44
  const TestFontsFormComponent = _ref2 => {
44
- var _data$viewer$testFont, _data$viewer$fontdueC, _data$viewer$settings, _data$viewer$settings2, _data$viewer$testFont2;
45
+ var _data$viewer$testFont, _data$viewer$fontdueC, _data$viewer$fontdueC2, _data$viewer$settings, _data$viewer$settings2, _data$viewer$testFont2;
45
46
  let {
46
47
  data,
47
48
  agreementLabel,
@@ -49,7 +50,8 @@ const TestFontsFormComponent = _ref2 => {
49
50
  newsletterCheckboxChecked = false,
50
51
  collectionId,
51
52
  collectionSlug,
52
- familySelection: familySelectionProp
53
+ familySelection: familySelectionProp,
54
+ familyGrouping: familyGroupingProp
53
55
  } = _ref2;
54
56
  const [email, setEmail] = useState('');
55
57
  const [name, setName] = useState('');
@@ -82,11 +84,30 @@ const TestFontsFormComponent = _ref2 => {
82
84
  // This form's prop, then code config (which, on the script-tag path, already
83
85
  // has the dashboard setting merged beneath it), then the dashboard setting.
84
86
  const familySelection = familySelectionProp ?? config.testFonts.familySelection ?? ((_data$viewer$fontdueC = data.viewer.fontdueConfig) === null || _data$viewer$fontdueC === void 0 ? void 0 : _data$viewer$fontdueC.testFonts.familySelection) ?? 'all';
87
+ // Resolved the same way as the family selection.
88
+ const familyGrouping = familyGroupingProp ?? config.testFonts.familyGrouping ?? ((_data$viewer$fontdueC2 = data.viewer.fontdueConfig) === null || _data$viewer$fontdueC2 === void 0 ? void 0 : _data$viewer$fontdueC2.testFonts.familyGrouping) ?? 'flat';
85
89
  // One family leaves nothing to choose.
86
90
  const choosing = !limited && familySelection === 'choice' && families.length > 1;
87
91
  const downloadIds = limited ? families.map(family => family.id) : choosing ? families.map(family => family.id).filter(id => chosenIds.includes(id)) : [];
88
- const toggleFamily = (id, checked) => setChosenIds(ids => checked ? [...ids, id] : ids.filter(other => other !== id));
89
- const allChosen = families.every(family => chosenIds.includes(family.id));
92
+
93
+ // Ticks or unticks families – one, or a superfamily's.
94
+ const toggleFamilies = (toggled, checked) => setChosenIds(ids => [...ids.filter(id => !toggled.includes(id)), ...(checked ? toggled : [])]);
95
+ const allChosen = list => list.every(family => chosenIds.includes(family.id));
96
+ const familyGroups = groupFamilies(families, familyGrouping);
97
+ const familyField = family => {
98
+ const fieldId = `${idPrefix}-family-${families.indexOf(family)}`;
99
+ return /*#__PURE__*/React.createElement("div", {
100
+ className: "test-fonts__checkbox-field test-fonts__family",
101
+ key: family.id
102
+ }, /*#__PURE__*/React.createElement(Checkbox, {
103
+ id: fieldId,
104
+ value: family.id,
105
+ checked: chosenIds.includes(family.id),
106
+ onChange: event => toggleFamilies([family.id], event.target.checked)
107
+ }), /*#__PURE__*/React.createElement("label", {
108
+ htmlFor: fieldId
109
+ }, family.name));
110
+ };
90
111
  const recaptchaEnabled = ((_data$viewer$settings = data.viewer.settings) === null || _data$viewer$settings === void 0 ? void 0 : _data$viewer$settings.recaptchaEnabled) ?? false;
91
112
  const recaptchaSiteKey = (_data$viewer$settings2 = data.viewer.settings) === null || _data$viewer$settings2 === void 0 ? void 0 : _data$viewer$settings2.recaptchaSiteKey;
92
113
  const submitForm = useCallback(token => {
@@ -212,21 +233,30 @@ const TestFontsFormComponent = _ref2 => {
212
233
  className: "test-fonts__checkbox-field test-fonts__families__all"
213
234
  }, /*#__PURE__*/React.createElement(Checkbox, {
214
235
  id: `${idPrefix}-families-all`,
215
- checked: allChosen,
236
+ checked: allChosen(families),
216
237
  onChange: event => setChosenIds(event.target.checked ? families.map(family => family.id) : [])
217
238
  }), /*#__PURE__*/React.createElement("label", {
218
239
  htmlFor: `${idPrefix}-families-all`
219
- }, "Select all")), families.map((family, index) => /*#__PURE__*/React.createElement("div", {
220
- className: "test-fonts__checkbox-field test-fonts__family",
221
- key: family.id
222
- }, /*#__PURE__*/React.createElement(Checkbox, {
223
- id: `${idPrefix}-family-${index}`,
224
- value: family.id,
225
- checked: chosenIds.includes(family.id),
226
- onChange: event => toggleFamily(family.id, event.target.checked)
227
- }), /*#__PURE__*/React.createElement("label", {
228
- htmlFor: `${idPrefix}-family-${index}`
229
- }, family.name)))), /*#__PURE__*/React.createElement("div", {
240
+ }, "Select all")), familyGroups.map((_ref4, groupIndex) => {
241
+ let {
242
+ superfamily,
243
+ families: grouped
244
+ } = _ref4;
245
+ return superfamily ? /*#__PURE__*/React.createElement("div", {
246
+ className: "test-fonts__family-group",
247
+ key: superfamily.id
248
+ }, /*#__PURE__*/React.createElement("div", {
249
+ className: "test-fonts__checkbox-field test-fonts__superfamily"
250
+ }, /*#__PURE__*/React.createElement(Checkbox, {
251
+ id: `${idPrefix}-superfamily-${groupIndex}`,
252
+ checked: allChosen(grouped),
253
+ onChange: event => toggleFamilies(grouped.map(family => family.id), event.target.checked)
254
+ }), /*#__PURE__*/React.createElement("label", {
255
+ htmlFor: `${idPrefix}-superfamily-${groupIndex}`
256
+ }, superfamily.name)), /*#__PURE__*/React.createElement("div", {
257
+ className: "test-fonts__family-group__families"
258
+ }, grouped.map(familyField))) : familyField(grouped[0]);
259
+ })), /*#__PURE__*/React.createElement("div", {
230
260
  className: "test-fonts__section"
231
261
  }, /*#__PURE__*/React.createElement(TextField, {
232
262
  label: "Name *",
@@ -289,7 +319,7 @@ const TestFontsFormComponent = _ref2 => {
289
319
  value: id
290
320
  }))));
291
321
  };
292
- const query = (_TestFontsForm_Query.hash && _TestFontsForm_Query.hash !== "da75e312a602ac0ee2dfb79271299c77" && console.error("The definition of 'TestFontsForm_Query' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _TestFontsForm_Query);
322
+ const query = (_TestFontsForm_Query.hash && _TestFontsForm_Query.hash !== "9ef2c42a7043469fec07a31649623766" && console.error("The definition of 'TestFontsForm_Query' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _TestFontsForm_Query);
293
323
  // Pass the same `collectionId` / `collectionSlug` as the form's props.
294
324
  export async function loadTestFontsFormQuery(variables, options) {
295
325
  // Before the collection variables existed this took `options` alone.
@@ -298,11 +328,11 @@ export async function loadTestFontsFormQuery(variables, options) {
298
328
  }
299
329
  return loadSerializableQuery(TestFontsFormQueryNode, testFontsFormQueryVariables(variables ?? {}), options);
300
330
  }
301
- export function TestFontsFormPreloadedQueryRenderer(_ref4) {
331
+ export function TestFontsFormPreloadedQueryRenderer(_ref5) {
302
332
  let {
303
333
  preloadedQuery,
304
334
  ...rest
305
- } = _ref4;
335
+ } = _ref5;
306
336
  // The query node lets the hook commit the payload into the store, so
307
337
  // usePreloadedQuery resolves synchronously during SSR instead of
308
338
  // refetching (the response cache only exists in the browser).
@@ -322,11 +352,11 @@ function TestFontsFormLazyQueryRenderer(props) {
322
352
  // (server-side preload path) or fetches lazily on mount. `EnsureFontdueContext`
323
353
  // only self-wraps with `FontdueContextProvider` when no parent Fontdue
324
354
  // context is present, seeding it with `config`.
325
- export default function TestFontsForm(_ref5) {
355
+ export default function TestFontsForm(_ref6) {
326
356
  let {
327
357
  config,
328
358
  ...props
329
- } = _ref5;
359
+ } = _ref6;
330
360
  return /*#__PURE__*/React.createElement(EnsureFontdueContext, {
331
361
  config: config
332
362
  }, props.preloadedQuery ? /*#__PURE__*/React.createElement(TestFontsFormPreloadedQueryRenderer, _extends({}, props, {