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
package/CHANGELOG.md CHANGED
@@ -1,3 +1,12 @@
1
+ ## 3.12.0
2
+
3
+ - `CustomerLoginForm` runs an invisible reCAPTCHA check when *Enable reCAPTCHA* is on under *Settings → Integration*, giving the sign-in form the same bot protection as `NewsletterSignup` and `TestFontsForm`.
4
+ - Fixed a `TypeTester` with `shy` leaving its OpenType features panel open after the toolbar hides. The panel and the dropdown now close shortly after the toolbar does.
5
+
6
+ ## 3.11.0
7
+
8
+ - `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`.
9
+
1
10
  ## 3.10.1
2
11
 
3
12
  - 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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @generated SignedSource<<fd35a09343fd0b9af65f7c50420747b6>>
2
+ * @generated SignedSource<<e3f8ae800d45eb22248382f5c156624a>>
3
3
  * @lightSyntaxTransform
4
4
  * @nogrep
5
5
  */
@@ -8,6 +8,7 @@ export type LoginInput = {
8
8
  email?: string | null;
9
9
  name?: string | null;
10
10
  newsletterOptIn?: boolean | null;
11
+ recaptchaToken?: string | null;
11
12
  target?: string | null;
12
13
  };
13
14
  export type CustomerLoginFormLoginMutation$variables = {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @generated SignedSource<<fd35a09343fd0b9af65f7c50420747b6>>
2
+ * @generated SignedSource<<e3f8ae800d45eb22248382f5c156624a>>
3
3
  * @lightSyntaxTransform
4
4
  * @nogrep
5
5
  */
@@ -0,0 +1,21 @@
1
+ /**
2
+ * @generated SignedSource<<ddf67cfa749ae9a10bfcec2bb2d6b15b>>
3
+ * @lightSyntaxTransform
4
+ * @nogrep
5
+ */
6
+ import { ConcreteRequest } from 'relay-runtime';
7
+ export type CustomerLoginFormRecaptchaQuery$variables = Record<PropertyKey, never>;
8
+ export type CustomerLoginFormRecaptchaQuery$data = {
9
+ readonly viewer: {
10
+ readonly settings: {
11
+ readonly recaptchaEnabled: boolean;
12
+ readonly recaptchaSiteKey: string | null;
13
+ } | null;
14
+ };
15
+ };
16
+ export type CustomerLoginFormRecaptchaQuery = {
17
+ response: CustomerLoginFormRecaptchaQuery$data;
18
+ variables: CustomerLoginFormRecaptchaQuery$variables;
19
+ };
20
+ declare const node: ConcreteRequest;
21
+ export default node;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * @generated SignedSource<<ddf67cfa749ae9a10bfcec2bb2d6b15b>>
3
+ * @lightSyntaxTransform
4
+ * @nogrep
5
+ */
6
+
7
+ /* tslint:disable */
8
+ /* eslint-disable */
9
+ // @ts-nocheck
10
+
11
+ const node = function () {
12
+ var v0 = {
13
+ "alias": null,
14
+ "args": null,
15
+ "concreteType": "Settings",
16
+ "kind": "LinkedField",
17
+ "name": "settings",
18
+ "plural": false,
19
+ "selections": [{
20
+ "alias": null,
21
+ "args": null,
22
+ "kind": "ScalarField",
23
+ "name": "recaptchaEnabled",
24
+ "storageKey": null
25
+ }, {
26
+ "alias": null,
27
+ "args": null,
28
+ "kind": "ScalarField",
29
+ "name": "recaptchaSiteKey",
30
+ "storageKey": null
31
+ }],
32
+ "storageKey": null
33
+ };
34
+ return {
35
+ "fragment": {
36
+ "argumentDefinitions": [],
37
+ "kind": "Fragment",
38
+ "metadata": null,
39
+ "name": "CustomerLoginFormRecaptchaQuery",
40
+ "selections": [{
41
+ "alias": null,
42
+ "args": null,
43
+ "concreteType": "Viewer",
44
+ "kind": "LinkedField",
45
+ "name": "viewer",
46
+ "plural": false,
47
+ "selections": [v0 /*: any*/],
48
+ "storageKey": null
49
+ }],
50
+ "type": "RootQueryType",
51
+ "abstractKey": null
52
+ },
53
+ "kind": "Request",
54
+ "operation": {
55
+ "argumentDefinitions": [],
56
+ "kind": "Operation",
57
+ "name": "CustomerLoginFormRecaptchaQuery",
58
+ "selections": [{
59
+ "alias": null,
60
+ "args": null,
61
+ "concreteType": "Viewer",
62
+ "kind": "LinkedField",
63
+ "name": "viewer",
64
+ "plural": false,
65
+ "selections": [v0 /*: any*/, {
66
+ "alias": null,
67
+ "args": null,
68
+ "kind": "ScalarField",
69
+ "name": "id",
70
+ "storageKey": null
71
+ }],
72
+ "storageKey": null
73
+ }]
74
+ },
75
+ "params": {
76
+ "cacheID": "a65b8ea63db01df3cbdf602ac7721787",
77
+ "id": null,
78
+ "metadata": {},
79
+ "name": "CustomerLoginFormRecaptchaQuery",
80
+ "operationKind": "query",
81
+ "text": "query CustomerLoginFormRecaptchaQuery {\n viewer {\n settings {\n recaptchaEnabled\n recaptchaSiteKey\n }\n id\n }\n}\n"
82
+ }
83
+ };
84
+ }();
85
+ node.hash = "fe7289e19bcc08dbad62e72d67c48c97";
86
+ export default node;
@@ -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;
@@ -0,0 +1,113 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ // The customer sign-in form solves the store's invisible reCAPTCHA before it
4
+ // asks for a sign-in link, when the store has reCAPTCHA on: the form emails
5
+ // whatever address is typed, which made it a tool for email-bombing strangers.
6
+
7
+ import React, { act, forwardRef, useImperativeHandle, useRef } from 'react';
8
+ import { createRoot } from 'react-dom/client';
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
10
+ const {
11
+ relay
12
+ } = vi.hoisted(() => ({
13
+ relay: {
14
+ settings: null,
15
+ commits: []
16
+ }
17
+ }));
18
+ vi.mock('react-relay', () => ({
19
+ graphql: () => null,
20
+ useRelayEnvironment: () => ({}),
21
+ useLazyLoadQuery: () => ({
22
+ viewer: {
23
+ settings: relay.settings
24
+ }
25
+ }),
26
+ commitMutation: (_env, config) => {
27
+ relay.commits.push(config.variables);
28
+ }
29
+ }));
30
+
31
+ // Google's widget: `execute()` hands back a token a moment later, the way the
32
+ // real callback arrives after the form has re-rendered – and, like
33
+ // react-google-recaptcha, it calls whichever `onChange` it holds by then.
34
+ vi.mock('../components/Recaptcha.js', () => ({
35
+ ReCAPTCHA: /*#__PURE__*/forwardRef(function FakeRecaptcha(props, ref) {
36
+ const latest = useRef(props);
37
+ latest.current = props;
38
+ useImperativeHandle(ref, () => ({
39
+ execute: () => setTimeout(() => latest.current.onChange('solved-token'), 0),
40
+ reset: () => {}
41
+ }));
42
+ return /*#__PURE__*/React.createElement("div", {
43
+ "data-testid": "recaptcha"
44
+ });
45
+ })
46
+ }));
47
+ import CustomerLoginForm from '../components/CustomerLoginForm/index.js';
48
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
49
+ let container;
50
+ let root;
51
+ beforeEach(() => {
52
+ relay.commits = [];
53
+ container = document.createElement('div');
54
+ document.body.append(container);
55
+ root = createRoot(container);
56
+ });
57
+ afterEach(() => {
58
+ act(() => root.unmount());
59
+ container.remove();
60
+ });
61
+ const render = async () => {
62
+ await act(async () => root.render( /*#__PURE__*/React.createElement(CustomerLoginForm, null)));
63
+ };
64
+ const submit = async email => {
65
+ const input = container.querySelector('input[type="email"]');
66
+ await act(async () => {
67
+ const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
68
+ setValue.call(input, email);
69
+ input.dispatchEvent(new Event('input', {
70
+ bubbles: true
71
+ }));
72
+ });
73
+ await act(async () => {
74
+ container.querySelector('form').dispatchEvent(new Event('submit', {
75
+ bubbles: true,
76
+ cancelable: true
77
+ }));
78
+ });
79
+ // Let the widget's token callback land.
80
+ await act(async () => new Promise(resolve => setTimeout(resolve, 5)));
81
+ };
82
+ describe('CustomerLoginForm reCAPTCHA', () => {
83
+ it('asks for a link straight away on a store without reCAPTCHA', async () => {
84
+ relay.settings = {
85
+ recaptchaEnabled: false,
86
+ recaptchaSiteKey: null
87
+ };
88
+ await render();
89
+ expect(container.querySelector('[data-testid="recaptcha"]')).toBeNull();
90
+ await submit('reader@example.com');
91
+ expect(relay.commits).toEqual([{
92
+ input: {
93
+ email: 'reader@example.com',
94
+ recaptchaToken: undefined
95
+ }
96
+ }]);
97
+ });
98
+ it('solves the widget first, and sends its token with the request', async () => {
99
+ relay.settings = {
100
+ recaptchaEnabled: true,
101
+ recaptchaSiteKey: 'site-key'
102
+ };
103
+ await render();
104
+ expect(container.querySelector('[data-testid="recaptcha"]')).not.toBeNull();
105
+ await submit('reader@example.com');
106
+ expect(relay.commits).toEqual([{
107
+ input: {
108
+ email: 'reader@example.com',
109
+ recaptchaToken: 'solved-token'
110
+ }
111
+ }]);
112
+ });
113
+ });
@@ -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
  });
@@ -0,0 +1,124 @@
1
+ // @vitest-environment happy-dom
2
+ import React, { act } from 'react';
3
+ import { createRoot } from 'react-dom/client';
4
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
5
+ import useHeightAnimation from '../components/TypeTester/useHeightAnimation.js';
6
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
7
+
8
+ // happy-dom has no layout and no Web Animations, so both are stubbed: the
9
+ // panel's content is CONTENT_HEIGHT tall, an explicit px height measures as
10
+ // itself, and `midAnimation` stands in for the frame a running animation is on.
11
+ const CONTENT_HEIGHT = 120;
12
+ let midAnimation = null;
13
+ let runs = [];
14
+ function Panel(_ref) {
15
+ let {
16
+ open
17
+ } = _ref;
18
+ const {
19
+ ref,
20
+ style
21
+ } = useHeightAnimation(open, 300);
22
+ return /*#__PURE__*/React.createElement("div", {
23
+ className: "panel",
24
+ ref: ref,
25
+ style: style
26
+ });
27
+ }
28
+ describe('useHeightAnimation', () => {
29
+ let root;
30
+ let container;
31
+ const panel = () => container.querySelector('.panel');
32
+ const render = open => act(() => root.render( /*#__PURE__*/React.createElement(Panel, {
33
+ open: open
34
+ })));
35
+ const heights = run => run.keyframes.map(k => k.height);
36
+ beforeEach(() => {
37
+ runs = [];
38
+ midAnimation = null;
39
+ vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function () {
40
+ const {
41
+ height
42
+ } = this.style;
43
+ const value = midAnimation ?? (height === 'auto' || height === '' ? CONTENT_HEIGHT : parseFloat(height));
44
+ return {
45
+ height: value
46
+ };
47
+ });
48
+ Element.prototype.animate = function (keyframes) {
49
+ const run = {
50
+ keyframes,
51
+ cancel: vi.fn(),
52
+ onfinish: null
53
+ };
54
+ runs.push(run);
55
+ return run;
56
+ };
57
+ container = document.createElement('div');
58
+ document.body.appendChild(container);
59
+ root = createRoot(container);
60
+ });
61
+ afterEach(() => {
62
+ act(() => root.unmount());
63
+ container.remove();
64
+ vi.restoreAllMocks();
65
+ delete Element.prototype.animate;
66
+ });
67
+ it('starts closed without animating', async () => {
68
+ await render(false);
69
+ expect(panel().style.height).toBe('0px');
70
+ expect(panel().style.overflow).toBe('hidden');
71
+ expect(runs).toHaveLength(0);
72
+ });
73
+ it('opens from 0 to the content height, then settles to auto and visible', async () => {
74
+ await render(false);
75
+ await render(true);
76
+ expect(runs).toHaveLength(1);
77
+ expect(heights(runs[0])).toEqual(['0px', `${CONTENT_HEIGHT}px`]);
78
+ expect(panel().style.overflow).toBe('hidden');
79
+ act(() => {
80
+ var _runs$0$onfinish, _runs$;
81
+ return (_runs$0$onfinish = (_runs$ = runs[0]).onfinish) === null || _runs$0$onfinish === void 0 ? void 0 : _runs$0$onfinish.call(_runs$);
82
+ });
83
+ expect(panel().style.height).toBe('auto');
84
+ expect(panel().style.overflow).toBe('visible');
85
+ });
86
+ it('closes from the open height to 0, and stays clipped', async () => {
87
+ await render(true);
88
+ await render(false);
89
+ expect(heights(runs[0])).toEqual([`${CONTENT_HEIGHT}px`, '0px']);
90
+ act(() => {
91
+ var _runs$0$onfinish2, _runs$2;
92
+ return (_runs$0$onfinish2 = (_runs$2 = runs[0]).onfinish) === null || _runs$0$onfinish2 === void 0 ? void 0 : _runs$0$onfinish2.call(_runs$2);
93
+ });
94
+ expect(panel().style.height).toBe('0px');
95
+ expect(panel().style.overflow).toBe('hidden');
96
+ });
97
+ it('reverses an interrupted open from the height on screen', async () => {
98
+ await render(false);
99
+ await render(true);
100
+ const opening = runs[0];
101
+ midAnimation = 60;
102
+ await render(false);
103
+ expect(opening.cancel).toHaveBeenCalled();
104
+ expect(heights(runs[1])).toEqual(['60px', '0px']);
105
+
106
+ // A late finish from the cancelled open must not unclip the closed panel.
107
+ act(() => {
108
+ var _opening$onfinish;
109
+ return (_opening$onfinish = opening.onfinish) === null || _opening$onfinish === void 0 ? void 0 : _opening$onfinish.call(opening);
110
+ });
111
+ expect(panel().style.overflow).toBe('hidden');
112
+ });
113
+ it('skips the animation for reduced motion', async () => {
114
+ vi.stubGlobal('matchMedia', () => ({
115
+ matches: true
116
+ }));
117
+ await render(false);
118
+ await render(true);
119
+ expect(runs).toHaveLength(0);
120
+ expect(panel().style.height).toBe('auto');
121
+ expect(panel().style.overflow).toBe('visible');
122
+ vi.unstubAllGlobals();
123
+ });
124
+ });