fontdue-js 3.10.0 → 3.11.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/CHANGELOG.md CHANGED
@@ -1,12 +1,20 @@
1
+ ## 3.11.0
2
+
3
+ - `TestFontsForm` can group its family checkboxes by superfamily. With *Family list* set to *Grouped by superfamily* under *Settings → Test fonts* – or `testFonts: { familyGrouping: "superfamily" }` in the config, or the `familyGrouping` prop (`family-grouping` on `<fontdue-test-fonts-form>`) – each superfamily gets a checkbox that ticks all of its families, listed beneath it in `.test-fonts__family-group__families`.
4
+
5
+ ## 3.10.1
6
+
7
+ - Fixed the `TypeTester`, `FeatureTesters`, `CharacterViewer` and `StoreModal` never rendering a font in some Safari versions when its family or style name contains punctuation, such as an apostrophe, or starts with a digit.
8
+
1
9
  ## 3.10.0
2
10
 
3
11
  - The `StoreModal`’s browse page leaves out an unlisted family, and lists an unlisted superfamily’s families in its place, each opening the superfamily’s page. Unlisted font packs are left out of the browse page too.
4
12
  - Checkboxes are 20px by default, down from 40px on the `TestFontsForm`, `NewsletterSignup` and license agreement checkboxes. Set `--checkbox-size: 40px` and `--checkbox-margin: 20px` to keep the larger box.
5
- - Fixed a type tester with a `truncate` line cap cutting through a line, or showing one line too few, in Safari before 26.1. The checkbox size and this fix ship as stylesheet rules, so take `dist/fontdue.css` from this version alongside the JS.
13
+ - Fixed a type tester with a `truncate` line cap cutting through a line, or showing one line too few, in Safari before 26.1.
6
14
 
7
15
  ## 3.9.0
8
16
 
9
- - `TestFontsForm` can let the visitor choose which families to download. With *Customer’s choice* set under *Settings → Test fonts* – or `testFonts: { familySelection: "choice" }` in the config, or the `familySelection` prop (`family-selection` on `<fontdue-test-fonts-form>`) – the form lists the families with a checkbox each plus *Select all*, and the download holds only the ticked ones. Ships new stylesheet rules, so take `dist/fontdue.css` from this version alongside the JS.
17
+ - `TestFontsForm` can let the visitor choose which families to download. With *Customer’s choice* set under *Settings → Test fonts* – or `testFonts: { familySelection: "choice" }` in the config, or the `familySelection` prop (`family-selection` on `<fontdue-test-fonts-form>`) – the form lists the families with a checkbox each plus *Select all*, and the download holds only the ticked ones.
10
18
  - `TestFontsForm` takes `collectionSlug` or `collectionId` (`collection-slug` / `collection-id`, comma-separated for several) to offer just those families, with no checkboxes – for a form on a family’s own page. When preloading, pass the same value to `loadTestFontsFormQuery({ collectionSlug })`; the old options-only call still works.
11
19
 
12
20
  ## 3.8.0
@@ -29,7 +37,7 @@
29
37
  - A product route takes a collection's slug as well as its ID, so `?store=product/example-sans` works alongside the base64 form. `fontdue-store-route="product/<slug>"` takes a slug now too – previously a slug there either did nothing (if it contained a hyphen) or loaded a broken product page.
30
38
  - `TypeTester` and `TypeTesters` take a `truncate` prop, and `<fontdue-type-tester>` and `<fontdue-type-testers>` take a `truncate` attribute, so each tester can cap its paragraph at its own number of lines instead of every tester on a site sharing one value. A per-tester value overrides the `truncate` UI config.
31
39
  - Fixed a type tester leaving a blank gap where its variable-axis sliders would go, on a static style or when none of the requested axes are in the font. The toolbar, features panel and dropdown placements were all affected.
32
- - A numeric `truncate` cap now stays in place while a visitor edits the text, instead of the paragraph jumping to full height on click and back on blur. `truncate: true` is unchanged and still opens to the full paragraph when clicked; pass `{ lines: 20, expandOnFocus: true }` if you want a line count to behave the old way. On the custom elements that object is a second word in the attribute – `truncate="20 expandable"`, or `truncate="20 fixed"` to spell out the default. Ships new stylesheet rules, so take `dist/fontdue.css` from this version alongside the JS.
40
+ - A numeric `truncate` cap now stays in place while a visitor edits the text, instead of the paragraph jumping to full height on click and back on blur. `truncate: true` is unchanged and still opens to the full paragraph when clicked; pass `{ lines: 20, expandOnFocus: true }` if you want a line count to behave the old way. On the custom elements that object is a second word in the attribute – `truncate="20 expandable"`, or `truncate="20 fixed"` to spell out the default.
33
41
  - Fixed a type tester opening at a size outside the range its size slider spans. A seeded size below `typeTester.size.min` (or above `max`) rendered at that size with the slider handle drawn off the end of its track; it is clamped into range now.
34
42
  - `NewsletterSignup` and `TestFontsForm` now name which form a submission came from, so Fontdue records each as its own event rather than as a checkout contact step.
35
43
  - Fixed the Store Modal’s Checkout button doing nothing on a store using Stripe Tax, and the Cart’s Next button on the billing and licensee steps. Stripe.js now throws “Unable to select session” when asked to validate a form that has no payment in it yet; the address fields are validated on their own in that case, with the same inline errors.
@@ -47,7 +55,7 @@
47
55
  ## 3.4.1
48
56
 
49
57
  - Fixed the standalone type tester dropping an axis slider when its `variable-settings` starting value is negative. `variable-settings="slnt -11"` was rejected as unparseable, so no slant slider appeared and nothing in the markup explained why. Any axis with a negative range was affected. Extra spaces between an axis and its value are tolerated now too.
50
- - Fixed the `FeatureTesters` switch knob rendering stretched in Safari 18 and earlier. The fix is in `dist/fontdue.css`.
58
+ - Fixed the `FeatureTesters` switch knob rendering stretched in Safari 18 and earlier.
51
59
 
52
60
  ## 3.4.0
53
61
 
@@ -74,7 +82,7 @@
74
82
 
75
83
  ## 3.2.3
76
84
 
77
- - Fixed `TypeTester` text drifting out of alignment. Centred, right-aligned and RTL testers sat off-centre once the font was small enough for the text to flow into columns, and centred testers were slightly off at every size. Ships new stylesheet rules, so take `fontdue-js/dist/fontdue.css` from this version alongside the JS.
85
+ - Fixed `TypeTester` text drifting out of alignment. Centred, right-aligned and RTL testers sat off-centre once the font was small enough for the text to flow into columns, and centred testers were slightly off at every size.
78
86
  - The collapsed `FontdueAdminToolbar` pill now carries the Fontdue wordmark — Fontdue, Fontdue · Previewing hidden fonts, Fontdue · Preview expired. If you style the toolbar yourself, `__mark` has left `__button` and the expired suffix is a new `__expired-suffix` span.
79
87
 
80
88
  ## 3.2.2
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @generated SignedSource<<47f718aeae16846330759690c693dce6>>
2
+ * @generated SignedSource<<0a0e4ebec2aeeb79d7f6e04437e7b7cc>>
3
3
  * @lightSyntaxTransform
4
4
  * @nogrep
5
5
  */
@@ -12,6 +12,7 @@ export type TestFontsForm_Query$data = {
12
12
  readonly viewer: {
13
13
  readonly fontdueConfig: {
14
14
  readonly testFonts: {
15
+ readonly familyGrouping: string;
15
16
  readonly familySelection: string;
16
17
  };
17
18
  } | null;
@@ -26,6 +27,10 @@ export type TestFontsForm_Query$data = {
26
27
  readonly families: ReadonlyArray<{
27
28
  readonly id: string;
28
29
  readonly name: string;
30
+ readonly parent: {
31
+ readonly id: string;
32
+ readonly name: string;
33
+ } | null;
29
34
  }>;
30
35
  } | null;
31
36
  };
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @generated SignedSource<<47f718aeae16846330759690c693dce6>>
2
+ * @generated SignedSource<<0a0e4ebec2aeeb79d7f6e04437e7b7cc>>
3
3
  * @lightSyntaxTransform
4
4
  * @nogrep
5
5
  */
@@ -77,6 +77,12 @@ const node = function () {
77
77
  "kind": "ScalarField",
78
78
  "name": "familySelection",
79
79
  "storageKey": null
80
+ }, {
81
+ "alias": null,
82
+ "args": null,
83
+ "kind": "ScalarField",
84
+ "name": "familyGrouping",
85
+ "storageKey": null
80
86
  }],
81
87
  "storageKey": null
82
88
  }],
@@ -90,6 +96,13 @@ const node = function () {
90
96
  "storageKey": null
91
97
  },
92
98
  v5 = {
99
+ "alias": null,
100
+ "args": null,
101
+ "kind": "ScalarField",
102
+ "name": "name",
103
+ "storageKey": null
104
+ },
105
+ v6 = {
93
106
  "alias": null,
94
107
  "args": [{
95
108
  "kind": "Variable",
@@ -117,11 +130,14 @@ const node = function () {
117
130
  "kind": "LinkedField",
118
131
  "name": "families",
119
132
  "plural": true,
120
- "selections": [v4 /*: any*/, {
133
+ "selections": [v4 /*: any*/, v5 /*: any*/, {
121
134
  "alias": null,
122
135
  "args": null,
123
- "kind": "ScalarField",
124
- "name": "name",
136
+ "concreteType": "FontCollection",
137
+ "kind": "LinkedField",
138
+ "name": "parent",
139
+ "plural": false,
140
+ "selections": [v4 /*: any*/, v5 /*: any*/],
125
141
  "storageKey": null
126
142
  }],
127
143
  "storageKey": null
@@ -141,7 +157,7 @@ const node = function () {
141
157
  "kind": "LinkedField",
142
158
  "name": "viewer",
143
159
  "plural": false,
144
- "selections": [v2 /*: any*/, v3 /*: any*/, v5 /*: any*/],
160
+ "selections": [v2 /*: any*/, v3 /*: any*/, v6 /*: any*/],
145
161
  "storageKey": null
146
162
  }],
147
163
  "type": "RootQueryType",
@@ -159,19 +175,19 @@ const node = function () {
159
175
  "kind": "LinkedField",
160
176
  "name": "viewer",
161
177
  "plural": false,
162
- "selections": [v2 /*: any*/, v3 /*: any*/, v5 /*: any*/, v4 /*: any*/],
178
+ "selections": [v2 /*: any*/, v3 /*: any*/, v6 /*: any*/, v4 /*: any*/],
163
179
  "storageKey": null
164
180
  }]
165
181
  },
166
182
  "params": {
167
- "cacheID": "42a58842afe113b89a0bc969738246a8",
183
+ "cacheID": "f0d8dba45eec4868101fe71af251133a",
168
184
  "id": null,
169
185
  "metadata": {},
170
186
  "name": "TestFontsForm_Query",
171
187
  "operationKind": "query",
172
- "text": "query TestFontsForm_Query(\n $collectionIds: [ID!]\n $collectionSlugs: [String!]\n) {\n viewer {\n settings {\n eulaAgreementLabel(format: HTML)\n newsletterOptInLabel(format: HTML)\n recaptchaEnabled\n recaptchaSiteKey\n }\n fontdueConfig {\n testFonts {\n familySelection\n }\n }\n testFonts(collectionIds: $collectionIds, collectionSlugs: $collectionSlugs) {\n archiveUrl\n families {\n id\n name\n }\n }\n id\n }\n}\n"
188
+ "text": "query TestFontsForm_Query(\n $collectionIds: [ID!]\n $collectionSlugs: [String!]\n) {\n viewer {\n settings {\n eulaAgreementLabel(format: HTML)\n newsletterOptInLabel(format: HTML)\n recaptchaEnabled\n recaptchaSiteKey\n }\n fontdueConfig {\n testFonts {\n familySelection\n familyGrouping\n }\n }\n testFonts(collectionIds: $collectionIds, collectionSlugs: $collectionSlugs) {\n archiveUrl\n families {\n id\n name\n parent {\n id\n name\n }\n }\n }\n id\n }\n}\n"
173
189
  }
174
190
  };
175
191
  }();
176
- node.hash = "da75e312a602ac0ee2dfb79271299c77";
192
+ node.hash = "9ef2c42a7043469fec07a31649623766";
177
193
  export default node;
@@ -1,5 +1,37 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { selectFontSource } from "../fontLoader.js";
2
+ import { selectFontSource, fontStyleFamily } from "../fontLoader.js";
3
+ describe('fontStyleFamily', () => {
4
+ it('joins family and style unchanged when both are plain identifiers', () => {
5
+ expect(fontStyleFamily('Vain', 'Regular')).toBe('Vain Regular');
6
+ expect(fontStyleFamily('Sweet Lily', 'Bold Italic')).toBe('Sweet Lily Bold Italic');
7
+ });
8
+ it("escapes an apostrophe, which some Safari builds reject in a FontFace family", () => {
9
+ expect(fontStyleFamily("Hell's Gate", 'fairypools')).toBe('Hell_27s Gate fairypools');
10
+ });
11
+ it('escapes other ASCII punctuation instead of dropping it, so names stay distinct', () => {
12
+ expect(fontStyleFamily('A&B', 'Regular')).toBe('A_26B Regular');
13
+ expect(fontStyleFamily('A "B"', 'Regular')).toBe('A _22B_22 Regular');
14
+ expect(fontStyleFamily("Hell's Gate", 'Regular')).not.toBe(fontStyleFamily('Hells Gate', 'Regular'));
15
+ });
16
+ it('prefixes words CSS cannot parse unquoted: leading digits and reserved words', () => {
17
+ expect(fontStyleFamily('0712', 'Regular')).toBe('_0712 Regular');
18
+ expect(fontStyleFamily('Grotesk', '-5 Light')).toBe('Grotesk _-5 Light');
19
+ expect(fontStyleFamily('Initial', 'Bold')).toBe('_Initial Bold');
20
+ expect(fontStyleFamily('Default', 'Regular')).toBe('_Default Regular');
21
+ });
22
+ it('keeps non-ASCII letters, hyphens and underscores, and collapses whitespace', () => {
23
+ expect(fontStyleFamily('Généreuse Sans', 'Extra-Bold_2')).toBe('Généreuse Sans Extra-Bold_2');
24
+ expect(fontStyleFamily(' 游明朝 ', ' W3 ')).toBe('游明朝 W3');
25
+ });
26
+ it('only yields CSS identifiers', () => {
27
+ const names = [fontStyleFamily("Hell's Gate", 'fairypools'), fontStyleFamily('0712', '1'), fontStyleFamily('(Test) / 50%', 'Regular!'), fontStyleFamily('--x', 'y')];
28
+ for (const name of names) {
29
+ for (const word of name.split(' ')) {
30
+ expect(word).toMatch(/^(?:[A-Za-z_\u0080-￿]|-[A-Za-z_\u0080-￿])[\w\u0080-￿-]*$/);
31
+ }
32
+ }
33
+ });
34
+ });
3
35
  describe('selectFontSource', () => {
4
36
  const woff2 = function () {
5
37
  let extra = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
@@ -51,6 +51,7 @@ const families = [{
51
51
  }];
52
52
  const viewer = function (familySelection) {
53
53
  let list = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : families;
54
+ let familyGrouping = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 'flat';
54
55
  return {
55
56
  viewer: {
56
57
  settings: {
@@ -61,7 +62,8 @@ const viewer = function (familySelection) {
61
62
  },
62
63
  fontdueConfig: {
63
64
  testFonts: {
64
- familySelection
65
+ familySelection,
66
+ familyGrouping
65
67
  }
66
68
  },
67
69
  testFonts: {
@@ -147,4 +149,75 @@ describe('TestFontsForm family choice', () => {
147
149
  expect(container.querySelector('form')).toBeNull();
148
150
  expect(container.textContent).toContain('Test fonts aren’t available for this family.');
149
151
  });
152
+ });
153
+ describe('TestFontsForm family grouping', () => {
154
+ const sans = {
155
+ id: 'U3VwZXI6MQ==',
156
+ name: 'Example'
157
+ };
158
+ const grouped = [{
159
+ id: 'RmFtaWx5OjE=',
160
+ name: 'Example Sans',
161
+ parent: sans
162
+ }, {
163
+ id: 'RmFtaWx5OjI=',
164
+ name: 'Solo',
165
+ parent: null
166
+ }, {
167
+ id: 'RmFtaWx5OjM=',
168
+ name: 'Example Serif',
169
+ parent: sans
170
+ },
171
+ // The only family offered from its superfamily: listed on its own.
172
+ {
173
+ id: 'RmFtaWx5OjQ=',
174
+ name: 'Lonely',
175
+ parent: {
176
+ id: 'U3VwZXI6Mg==',
177
+ name: 'Lonely Super'
178
+ }
179
+ }];
180
+ const superfamilyBoxes = () => [...container.querySelectorAll('.test-fonts__superfamily input')];
181
+ it('lists the families flat by default', () => {
182
+ queryRef.data = viewer('choice', grouped);
183
+ render();
184
+ expect(superfamilyBoxes()).toHaveLength(0);
185
+ expect(familyCheckboxes().map(box => box.value)).toEqual(grouped.map(f => f.id));
186
+ });
187
+ it('groups families under their superfamily, which ticks them all', () => {
188
+ queryRef.data = viewer('choice', grouped, 'superfamily');
189
+ render();
190
+ expect(superfamilyBoxes()).toHaveLength(1);
191
+ const group = container.querySelector('.test-fonts__family-group');
192
+ expect(group.textContent).toContain('Example');
193
+ expect([...group.querySelectorAll('.test-fonts__family input')].map(b => b.value)).toEqual(['RmFtaWx5OjE=', 'RmFtaWx5OjM=']);
194
+ // The group sits where its first family fell; the rest keep their order.
195
+ expect(familyCheckboxes().map(box => box.value)).toEqual(['RmFtaWx5OjE=', 'RmFtaWx5OjM=', 'RmFtaWx5OjI=', 'RmFtaWx5OjQ=']);
196
+ expect(container.textContent).not.toContain('Lonely Super');
197
+ act(() => superfamilyBoxes()[0].click());
198
+ expect(hiddenIds()).toEqual(['RmFtaWx5OjE=', 'RmFtaWx5OjM=']);
199
+ expect(superfamilyBoxes()[0].checked).toBe(true);
200
+
201
+ // Unticking one family leaves the superfamily unticked.
202
+ act(() => familyCheckboxes()[1].click());
203
+ expect(hiddenIds()).toEqual(['RmFtaWx5OjE=']);
204
+ expect(superfamilyBoxes()[0].checked).toBe(false);
205
+ act(() => superfamilyBoxes()[0].click());
206
+ act(() => superfamilyBoxes()[0].click());
207
+ expect(hiddenIds()).toEqual([]);
208
+ });
209
+ it('lets the prop and code config override the dashboard grouping', () => {
210
+ queryRef.data = viewer('choice', grouped, 'superfamily');
211
+ render({}, {
212
+ testFonts: {
213
+ familyGrouping: 'flat'
214
+ }
215
+ });
216
+ expect(superfamilyBoxes()).toHaveLength(0);
217
+ queryRef.data = viewer('choice', grouped);
218
+ render({
219
+ familyGrouping: 'superfamily'
220
+ });
221
+ expect(superfamilyBoxes()).toHaveLength(1);
222
+ });
150
223
  });
@@ -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',
@@ -7,6 +7,7 @@ import { graphql, useFragment } from 'react-relay';
7
7
  import FontStyle from '../FontStyle/index.js';
8
8
  import useFeaturesData from '../TypeTester/useFeaturesData.js';
9
9
  import useFeatureTesterAutofit from '../../hooks/useFeatureTesterAutofit.js';
10
+ import { fontStyleFamily } from '../../fontLoader.js';
10
11
  import { sensitiveStringsForFeature, computeSensitivityFlags, segmentByFlags } from './highlight.js';
11
12
  const fragment = (_FeatureTesterCard_fontStyle.hash && _FeatureTesterCard_fontStyle.hash !== "687d8afca93d615ed0794f963702d082" && console.error("The definition of 'FeatureTesterCard_fontStyle' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _FeatureTesterCard_fontStyle);
12
13
  export default function FeatureTesterCard(_ref) {
@@ -88,7 +89,7 @@ export default function FeatureTesterCard(_ref) {
88
89
  } = useFeatureTesterAutofit({
89
90
  enabled: autofit,
90
91
  text: content,
91
- fontFamily: `${data.cssFamily} ${data.name}`,
92
+ fontFamily: fontStyleFamily(data.cssFamily, data.name),
92
93
  feature,
93
94
  fontSize: fontSize ?? 72,
94
95
  letterSpacing: letterSpacing ?? 0,
@@ -6,6 +6,7 @@ import { useLazyLoadQuery, graphql } from 'react-relay';
6
6
  import StoreModalCheckoutContext from './StoreModalCheckoutContext.js';
7
7
  import CheckoutSteps from '../Cart/CheckoutSteps.js';
8
8
  import useFontStyle from '../useFontStyle.js';
9
+ import { fontStyleFamily } from '../../fontLoader.js';
9
10
  import StoreModalLoadingScreen from '../elements/StoreModalLoadingScreen/index.js';
10
11
  import ConfigContext from '../ConfigContext.js';
11
12
  const getPageTitle = name => {
@@ -89,8 +90,8 @@ const StoreModalContainer = _ref => {
89
90
  };
90
91
  const [checkoutStep, setCheckoutStep] = useState(CheckoutSteps.Customer);
91
92
  const [checkoutError, setCheckoutError] = useState(null);
92
- const regularName = uiFontStyle ? `${uiFontStyle.cssFamily} ${uiFontStyle.name}` : null;
93
- const boldName = uiFontStyleBold ? `${uiFontStyleBold.cssFamily} ${uiFontStyleBold.name}` : null;
93
+ const regularName = uiFontStyle ? fontStyleFamily(uiFontStyle.cssFamily, uiFontStyle.name) : null;
94
+ const boldName = uiFontStyleBold ? fontStyleFamily(uiFontStyleBold.cssFamily, uiFontStyleBold.name) : null;
94
95
  return /*#__PURE__*/React.createElement(StoreModalCheckoutContext.Provider, {
95
96
  value: {
96
97
  checkoutStep,
@@ -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, {
@@ -1,7 +1,7 @@
1
1
  import _useFontStyle_fontStyle from "../__generated__/useFontStyle_fontStyle.graphql.js";
2
2
  import { useState, useRef, useEffect } from 'react';
3
3
  import { graphql, useFragment } from 'react-relay';
4
- import { loadFont, isFontLoaded } from '../fontLoader.js';
4
+ import { loadFont, isFontLoaded, fontStyleFamily } from '../fontLoader.js';
5
5
  import { ensureMetricFallback, metricFallbackFamily } from '../metricFallback.js';
6
6
  const FALLBACK_FAMILY = 'Fallback';
7
7
  const useFontStyle = _ref => {
@@ -9,7 +9,7 @@ const useFontStyle = _ref => {
9
9
  fontStyle: fontStyleKey
10
10
  } = _ref;
11
11
  const fontStyle = useFragment((_useFontStyle_fontStyle.hash && _useFontStyle_fontStyle.hash !== "171c546f40909692656c5e143a98d8a3" && console.error("The definition of 'useFontStyle_fontStyle' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _useFontStyle_fontStyle), fontStyleKey ?? null);
12
- const fontFamily = fontStyle ? `${fontStyle.cssFamily} ${fontStyle.name}` : null;
12
+ const fontFamily = fontStyle ? fontStyleFamily(fontStyle.cssFamily, fontStyle.name) : null;
13
13
  const sources = fontStyle === null || fontStyle === void 0 ? void 0 : fontStyle.webfontSources;
14
14
  const verticalMetrics = fontStyle === null || fontStyle === void 0 ? void 0 : fontStyle.verticalMetrics;
15
15
 
@@ -9,6 +9,32 @@ interface VerticalMetrics {
9
9
  readonly descender: number;
10
10
  readonly lineGap: number | null;
11
11
  }
12
+ /**
13
+ * The family name a font style's FontFace is registered under, and the name
14
+ * CSS must use to render it: `"<cssFamily> <styleName>"`, made safe to parse
15
+ * as an unquoted CSS font-family.
16
+ *
17
+ * Safari 26.0.x (macOS Tahoe / iOS 26 before their point updates) parses the
18
+ * FontFace family argument as CSS, so a name like "Hell's Gate fairypools"
19
+ * (the apostrophe opens an unterminated string) or "0712 Regular" (an
20
+ * identifier can't start with a digit) makes `load()` reject with
21
+ * "SyntaxError: The string did not match the expected pattern." and the style
22
+ * never renders. Chrome and other Safari builds (17.3, 18.4, 26.4+, 27.0 were
23
+ * checked) take the argument literally. A name that is valid either way
24
+ * works everywhere:
25
+ *
26
+ * - an ASCII character other than a letter, digit, `-` or `_` is escaped as
27
+ * `_<hex code>` (`'` → `_27`), so distinct names stay distinct;
28
+ * - a word starting with a digit, `-<digit>` or `--`, or matching a reserved
29
+ * word, gets a leading `_`.
30
+ *
31
+ * Non-ASCII characters are valid identifier characters and are kept.
32
+ *
33
+ * @example
34
+ * fontStyleFamily("Hell's Gate", 'fairypools') // => 'Hell_27s Gate fairypools'
35
+ * fontStyleFamily('0712', 'Regular') // => '_0712 Regular'
36
+ */
37
+ export declare function fontStyleFamily(cssFamily: string | null | undefined, styleName: string): string;
12
38
  export declare function isFontLoaded(fontFamily: string): boolean;
13
39
  export declare function loadFont(fontFamily: string, sources: ReadonlyArray<FontSource | null>, verticalMetrics?: VerticalMetrics | null): Promise<FontFace | null>;
14
40
  /**
@@ -1,3 +1,40 @@
1
+ // Words CSS refuses as an unquoted font-family identifier (the CSS-wide
2
+ // keywords plus `default`), compared case-insensitively.
3
+ const RESERVED_FAMILY_WORDS = new Set(['inherit', 'initial', 'unset', 'revert', 'revert-layer', 'default']);
4
+
5
+ /**
6
+ * The family name a font style's FontFace is registered under, and the name
7
+ * CSS must use to render it: `"<cssFamily> <styleName>"`, made safe to parse
8
+ * as an unquoted CSS font-family.
9
+ *
10
+ * Safari 26.0.x (macOS Tahoe / iOS 26 before their point updates) parses the
11
+ * FontFace family argument as CSS, so a name like "Hell's Gate fairypools"
12
+ * (the apostrophe opens an unterminated string) or "0712 Regular" (an
13
+ * identifier can't start with a digit) makes `load()` reject with
14
+ * "SyntaxError: The string did not match the expected pattern." and the style
15
+ * never renders. Chrome and other Safari builds (17.3, 18.4, 26.4+, 27.0 were
16
+ * checked) take the argument literally. A name that is valid either way
17
+ * works everywhere:
18
+ *
19
+ * - an ASCII character other than a letter, digit, `-` or `_` is escaped as
20
+ * `_<hex code>` (`'` → `_27`), so distinct names stay distinct;
21
+ * - a word starting with a digit, `-<digit>` or `--`, or matching a reserved
22
+ * word, gets a leading `_`.
23
+ *
24
+ * Non-ASCII characters are valid identifier characters and are kept.
25
+ *
26
+ * @example
27
+ * fontStyleFamily("Hell's Gate", 'fairypools') // => 'Hell_27s Gate fairypools'
28
+ * fontStyleFamily('0712', 'Regular') // => '_0712 Regular'
29
+ */
30
+ export function fontStyleFamily(cssFamily, styleName) {
31
+ return `${cssFamily ?? ''} ${styleName}`.split(/\s+/).filter(word => word.length > 0).map(cssIdentifierWord).join(' ');
32
+ }
33
+ function cssIdentifierWord(word) {
34
+ const escaped = word.replace(/[\x00-\x7f]/g, char => /[A-Za-z0-9_-]/.test(char) ? char : `_${char.charCodeAt(0).toString(16)}`);
35
+ const unparseable = /^(?:[0-9]|-[0-9]|--|-$)/.test(escaped);
36
+ return unparseable || RESERVED_FAMILY_WORDS.has(escaped.toLowerCase()) ? `_${escaped}` : escaped;
37
+ }
1
38
  const cache = new Map();
2
39
  const loaded = new Set();
3
40
  export function isFontLoaded(fontFamily) {
package/dist/fontdue.css CHANGED
@@ -185,6 +185,9 @@
185
185
  .test-fonts__families .test-fonts__checkbox-field {
186
186
  margin-bottom: 10px;
187
187
  }
188
+ .test-fonts__family-group__families {
189
+ padding-left: calc(var(--checkbox-size, 20px) + var(--checkbox-margin, 10px));
190
+ }
188
191
  .test-fonts__agreement[data-highlighted=true] {
189
192
  color: red;
190
193
  }
package/dist/version.js CHANGED
@@ -8,4 +8,4 @@
8
8
  // (Fontage.ClientVersions) to spot sites that need to upgrade before a
9
9
  // feature that depends on a newer client can work for them.
10
10
  export const CLIENT_VERSION_HEADER = 'fontdue-client-version';
11
- export const version = "3.10.0";
11
+ export const version = "3.11.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fontdue-js",
3
- "version": "3.10.0",
3
+ "version": "3.11.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "build": "npm run relay && run-p build-js build-css build-ts",