fontdue-js 3.11.0 → 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.
- package/CHANGELOG.md +5 -0
- package/dist/__generated__/CustomerLoginFormLoginMutation.graphql.d.ts +2 -1
- package/dist/__generated__/CustomerLoginFormLoginMutation.graphql.js +1 -1
- package/dist/__generated__/CustomerLoginFormRecaptchaQuery.graphql.d.ts +21 -0
- package/dist/__generated__/CustomerLoginFormRecaptchaQuery.graphql.js +86 -0
- package/dist/__tests__/customerLoginRecaptcha.test.js +113 -0
- package/dist/__tests__/typeTesterHeightAnimation.test.js +124 -0
- package/dist/__tests__/typeTesterShyFeaturesClose.test.js +133 -0
- package/dist/components/CustomerLoginForm/index.js +106 -11
- package/dist/components/TypeTester/TypeTesterFeatures.js +27 -44
- package/dist/components/TypeTester/useCloseWhenToolbarHides.d.ts +17 -0
- package/dist/components/TypeTester/useCloseWhenToolbarHides.js +79 -0
- package/dist/components/TypeTester/useHeightAnimation.d.ts +27 -0
- package/dist/components/TypeTester/useHeightAnimation.js +85 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
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
|
+
|
|
1
6
|
## 3.11.0
|
|
2
7
|
|
|
3
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`.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @generated SignedSource<<
|
|
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 = {
|
|
@@ -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;
|
|
@@ -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
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -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
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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$
|
|
181
|
+
var _data$viewer2, _data$viewer2$setting;
|
|
87
182
|
const data = useLazyLoadQuery(query, {});
|
|
88
|
-
const submittedLabel = ((_data$
|
|
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: {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import _TypeTesterFeatures_fontStyle from "../../__generated__/TypeTesterFeatures_fontStyle.graphql.js";
|
|
2
|
-
import React, { useContext, useEffect, useMemo, useRef
|
|
2
|
+
import React, { useContext, useEffect, useMemo, useRef } from 'react';
|
|
3
3
|
import { graphql, useFragment } from 'react-relay';
|
|
4
4
|
import Bullet from '../TypeTester/TypeTesterBullet.js';
|
|
5
5
|
import useTypeTesterState from './TypeTesterState.js';
|
|
@@ -7,38 +7,8 @@ import Checkbox from '../Checkbox/index.js';
|
|
|
7
7
|
import ConfigContext from '../ConfigContext.js';
|
|
8
8
|
import useFeaturesData from './useFeaturesData.js';
|
|
9
9
|
import TypeTesterVariableAxes from './TypeTesterVariableAxes.js';
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
let duration = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 350;
|
|
13
|
-
const contentRef = useRef(null);
|
|
14
|
-
const [height, setHeight] = useState(isOpen ? 'auto' : 0);
|
|
15
|
-
const changed = useRef(false);
|
|
16
|
-
useEffect(() => {
|
|
17
|
-
var _contentRef$current;
|
|
18
|
-
const contentRect = (_contentRef$current = contentRef.current) === null || _contentRef$current === void 0 ? void 0 : _contentRef$current.getBoundingClientRect();
|
|
19
|
-
if (!contentRect) return;
|
|
20
|
-
if (changed.current === false) {
|
|
21
|
-
// don't run initially. couldn't think of a better solution
|
|
22
|
-
changed.current = true;
|
|
23
|
-
return;
|
|
24
|
-
}
|
|
25
|
-
setHeight(`${contentRect.height}px`);
|
|
26
|
-
const first = setTimeout(() => setHeight(isOpen ? `${contentRect.height}px` : 0));
|
|
27
|
-
const second = setTimeout(() => {
|
|
28
|
-
if (isOpen) {
|
|
29
|
-
setHeight('auto');
|
|
30
|
-
}
|
|
31
|
-
}, duration);
|
|
32
|
-
return () => {
|
|
33
|
-
clearTimeout(first);
|
|
34
|
-
clearTimeout(second);
|
|
35
|
-
};
|
|
36
|
-
}, [isOpen]);
|
|
37
|
-
return {
|
|
38
|
-
height,
|
|
39
|
-
contentRef
|
|
40
|
-
};
|
|
41
|
-
}
|
|
10
|
+
import useCloseWhenToolbarHides from './useCloseWhenToolbarHides.js';
|
|
11
|
+
import useHeightAnimation from './useHeightAnimation.js';
|
|
42
12
|
const shouldIncludeFeature = (supportedFeatures, feature) => {
|
|
43
13
|
if (!supportedFeatures) return false;
|
|
44
14
|
return supportedFeatures.indexOf(feature) >= 0;
|
|
@@ -74,6 +44,17 @@ function useFeaturesSurface(_ref) {
|
|
|
74
44
|
} = useFeaturesData({
|
|
75
45
|
fontStyle
|
|
76
46
|
});
|
|
47
|
+
|
|
48
|
+
// Every surface renders an element carrying this ref inside its tester, so
|
|
49
|
+
// a shy toolbar takes the open surface with it when it hides. The floating
|
|
50
|
+
// toolbar has no hover-shy tools to follow (it requires shy: 'focus' and
|
|
51
|
+
// sits outside the tester).
|
|
52
|
+
const surfaceRef = useRef(null);
|
|
53
|
+
useCloseWhenToolbarHides(surfaceRef, {
|
|
54
|
+
open: featuresOpen,
|
|
55
|
+
setOpen: setFeaturesOpen,
|
|
56
|
+
shy: config.toolsPosition === 'inline' ? config.shy : false
|
|
57
|
+
});
|
|
77
58
|
const selectionStyle = config.openTypeFeatures.selectionStyle;
|
|
78
59
|
const supportedFeatures = (fontFeatures === null || fontFeatures === void 0 ? void 0 : fontFeatures.supportedFeatures) ?? null;
|
|
79
60
|
const expandedFeatures = useMemo(() => showFeatures !== null && showFeatures !== void 0 && showFeatures.includes('*') ? supportedFeatures ?? [] : (showFeatures ?? []).filter(feature => shouldIncludeFeature(supportedFeatures, feature)), [showFeatures, supportedFeatures]);
|
|
@@ -109,7 +90,8 @@ function useFeaturesSurface(_ref) {
|
|
|
109
90
|
selectionStyle,
|
|
110
91
|
supportedFeatures,
|
|
111
92
|
expandedFeatures,
|
|
112
|
-
renderFeature
|
|
93
|
+
renderFeature,
|
|
94
|
+
surfaceRef
|
|
113
95
|
};
|
|
114
96
|
}
|
|
115
97
|
function TypeTesterFeaturesDropdown(_ref2) {
|
|
@@ -128,7 +110,8 @@ function TypeTesterFeaturesDropdown(_ref2) {
|
|
|
128
110
|
selectionStyle,
|
|
129
111
|
supportedFeatures,
|
|
130
112
|
expandedFeatures,
|
|
131
|
-
renderFeature
|
|
113
|
+
renderFeature,
|
|
114
|
+
surfaceRef
|
|
132
115
|
} = useFeaturesSurface({
|
|
133
116
|
id,
|
|
134
117
|
fontStyle: fontStyleKey,
|
|
@@ -191,6 +174,7 @@ function TypeTesterFeaturesDropdown(_ref2) {
|
|
|
191
174
|
if (!featuresOpen) {
|
|
192
175
|
return /*#__PURE__*/React.createElement("div", {
|
|
193
176
|
className: "type-tester__features-dropdown-anchor",
|
|
177
|
+
ref: surfaceRef,
|
|
194
178
|
"data-open": false,
|
|
195
179
|
onMouseDown: anchorMouseDown
|
|
196
180
|
});
|
|
@@ -221,6 +205,7 @@ function TypeTesterFeaturesDropdown(_ref2) {
|
|
|
221
205
|
// unlike the chromeless panel, an empty dropdown would be a visible box.
|
|
222
206
|
return /*#__PURE__*/React.createElement("div", {
|
|
223
207
|
className: "type-tester__features-dropdown-anchor",
|
|
208
|
+
ref: surfaceRef,
|
|
224
209
|
"data-open": featuresOpen,
|
|
225
210
|
onMouseDown: anchorMouseDown
|
|
226
211
|
}, dropdownFeatures.length > 0 || hasAxes ? /*#__PURE__*/React.createElement("div", {
|
|
@@ -251,15 +236,16 @@ function TypeTesterFeaturesPanel(_ref3) {
|
|
|
251
236
|
selectionStyle,
|
|
252
237
|
supportedFeatures,
|
|
253
238
|
expandedFeatures,
|
|
254
|
-
renderFeature
|
|
239
|
+
renderFeature,
|
|
240
|
+
surfaceRef
|
|
255
241
|
} = useFeaturesSurface({
|
|
256
242
|
id,
|
|
257
243
|
fontStyle: fontStyleKey,
|
|
258
244
|
features
|
|
259
245
|
});
|
|
260
246
|
const {
|
|
261
|
-
|
|
262
|
-
|
|
247
|
+
ref: heightRef,
|
|
248
|
+
style: heightStyle
|
|
263
249
|
} = useHeightAnimation(featuresOpen, 300);
|
|
264
250
|
let featuresColumns;
|
|
265
251
|
if ((_config$openTypeFeatu2 = config.openTypeFeatures) !== null && _config$openTypeFeatu2 !== void 0 && (_config$openTypeFeatu3 = _config$openTypeFeatu2.columns) !== null && _config$openTypeFeatu3 !== void 0 && _config$openTypeFeatu3.length) {
|
|
@@ -276,15 +262,12 @@ function TypeTesterFeaturesPanel(_ref3) {
|
|
|
276
262
|
}
|
|
277
263
|
return /*#__PURE__*/React.createElement("div", {
|
|
278
264
|
className: "type-tester__features-container",
|
|
265
|
+
ref: heightRef,
|
|
279
266
|
onMouseDown: e => e.preventDefault(),
|
|
280
267
|
"data-open": featuresOpen,
|
|
281
|
-
style:
|
|
282
|
-
height: featuresHeight,
|
|
283
|
-
overflow: featuresHeight === 'auto' ? 'visible' : 'hidden',
|
|
284
|
-
transition: `height 300ms ease-out`
|
|
285
|
-
}
|
|
268
|
+
style: heightStyle
|
|
286
269
|
}, /*#__PURE__*/React.createElement("div", {
|
|
287
|
-
ref:
|
|
270
|
+
ref: surfaceRef
|
|
288
271
|
}, axes ? /*#__PURE__*/React.createElement(TypeTesterVariableAxes, {
|
|
289
272
|
id: id,
|
|
290
273
|
axes: axes,
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { RefObject } from 'react';
|
|
2
|
+
export declare const CLOSE_DELAY = 250;
|
|
3
|
+
/**
|
|
4
|
+
* Closes an open features surface (panel or dropdown) when a shy toolbar hides,
|
|
5
|
+
* so it never stays open without the button that toggles it.
|
|
6
|
+
*
|
|
7
|
+
* `shy` hides the toolbar's tools in CSS (`_type-tester.scss`): they show while
|
|
8
|
+
* the tester is hovered (`'hover'` only) or holds focus. This mirrors that
|
|
9
|
+
* predicate, re-checked after `CLOSE_DELAY` whenever the pointer leaves, focus
|
|
10
|
+
* leaves, or a touch lands outside the tester (touch has no hover to leave).
|
|
11
|
+
* `ref` is any element the surface always renders inside its `.type-tester`.
|
|
12
|
+
*/
|
|
13
|
+
export default function useCloseWhenToolbarHides(ref: RefObject<HTMLElement | null>, { open, setOpen, shy, }: {
|
|
14
|
+
open: boolean;
|
|
15
|
+
setOpen: (value: boolean) => void;
|
|
16
|
+
shy: boolean | 'hover' | 'focus';
|
|
17
|
+
}): void;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
// How long the toolbar has to stay hidden before the features surface closes.
|
|
4
|
+
// Closing the panel animates its height and moves the page below it, so a
|
|
5
|
+
// cursor that only brushes past the tester's edge shouldn't trigger it.
|
|
6
|
+
export const CLOSE_DELAY = 250;
|
|
7
|
+
const matchesHover = element => {
|
|
8
|
+
try {
|
|
9
|
+
return element.matches(':hover');
|
|
10
|
+
} catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
const focusInside = element => {
|
|
15
|
+
const root = element.getRootNode();
|
|
16
|
+
return Boolean(root.activeElement && element.contains(root.activeElement));
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Closes an open features surface (panel or dropdown) when a shy toolbar hides,
|
|
21
|
+
* so it never stays open without the button that toggles it.
|
|
22
|
+
*
|
|
23
|
+
* `shy` hides the toolbar's tools in CSS (`_type-tester.scss`): they show while
|
|
24
|
+
* the tester is hovered (`'hover'` only) or holds focus. This mirrors that
|
|
25
|
+
* predicate, re-checked after `CLOSE_DELAY` whenever the pointer leaves, focus
|
|
26
|
+
* leaves, or a touch lands outside the tester (touch has no hover to leave).
|
|
27
|
+
* `ref` is any element the surface always renders inside its `.type-tester`.
|
|
28
|
+
*/
|
|
29
|
+
export default function useCloseWhenToolbarHides(ref, _ref) {
|
|
30
|
+
let {
|
|
31
|
+
open,
|
|
32
|
+
setOpen,
|
|
33
|
+
shy
|
|
34
|
+
} = _ref;
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
var _ref$current;
|
|
37
|
+
if (!open || !shy) return;
|
|
38
|
+
const tester = (_ref$current = ref.current) === null || _ref$current === void 0 ? void 0 : _ref$current.closest('.type-tester');
|
|
39
|
+
if (!tester) return;
|
|
40
|
+
const hoverReveals = shy === true || shy === 'hover';
|
|
41
|
+
let hovered = matchesHover(tester);
|
|
42
|
+
let timer;
|
|
43
|
+
const toolbarShown = () => hoverReveals && hovered || focusInside(tester);
|
|
44
|
+
const scheduleCheck = () => {
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
timer = setTimeout(() => {
|
|
47
|
+
if (!toolbarShown()) setOpen(false);
|
|
48
|
+
}, CLOSE_DELAY);
|
|
49
|
+
};
|
|
50
|
+
const handlePointerEnter = event => {
|
|
51
|
+
if (event.pointerType !== 'mouse') return;
|
|
52
|
+
hovered = true;
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
};
|
|
55
|
+
const handlePointerLeave = event => {
|
|
56
|
+
if (event.pointerType !== 'mouse') return;
|
|
57
|
+
hovered = false;
|
|
58
|
+
scheduleCheck();
|
|
59
|
+
};
|
|
60
|
+
const handleOutsidePointerDown = event => {
|
|
61
|
+
var _event$composedPath;
|
|
62
|
+
if (event.pointerType === 'mouse') return;
|
|
63
|
+
const target = ((_event$composedPath = event.composedPath) === null || _event$composedPath === void 0 ? void 0 : _event$composedPath.call(event)[0]) ?? null;
|
|
64
|
+
if (target && tester.contains(target)) return;
|
|
65
|
+
scheduleCheck();
|
|
66
|
+
};
|
|
67
|
+
tester.addEventListener('pointerenter', handlePointerEnter);
|
|
68
|
+
tester.addEventListener('pointerleave', handlePointerLeave);
|
|
69
|
+
tester.addEventListener('focusout', scheduleCheck);
|
|
70
|
+
document.addEventListener('pointerdown', handleOutsidePointerDown);
|
|
71
|
+
return () => {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
tester.removeEventListener('pointerenter', handlePointerEnter);
|
|
74
|
+
tester.removeEventListener('pointerleave', handlePointerLeave);
|
|
75
|
+
tester.removeEventListener('focusout', scheduleCheck);
|
|
76
|
+
document.removeEventListener('pointerdown', handleOutsidePointerDown);
|
|
77
|
+
};
|
|
78
|
+
}, [ref, open, setOpen, shy]);
|
|
79
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { CSSProperties } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Animates a disclosure's height between 0 and its content height.
|
|
4
|
+
*
|
|
5
|
+
* Driven by the Web Animations API on the element itself rather than through
|
|
6
|
+
* React state: a CSS transition needs a committed pixel height before the
|
|
7
|
+
* target one, and `auto` → `0` in a single style recalc (the old
|
|
8
|
+
* setState-then-setTimeout approach) snaps shut with no animation. Here every
|
|
9
|
+
* run starts from the height actually on screen, so a close that interrupts an
|
|
10
|
+
* open (or the reverse) reverses smoothly from where it is.
|
|
11
|
+
*
|
|
12
|
+
* Settled open, the element is `height: auto; overflow: visible`, so content
|
|
13
|
+
* that changes size while open (axis sliders, wrapped features) still fits and
|
|
14
|
+
* slider tooltips aren't clipped. Settled closed, it is `0px` + `hidden`.
|
|
15
|
+
*
|
|
16
|
+
* Spread `style` onto the element (it only carries the initial state and never
|
|
17
|
+
* changes, so React never overwrites the inline styles set here) and attach
|
|
18
|
+
* `ref`.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* const { ref, style } = useHeightAnimation(open, 300);
|
|
22
|
+
* return <div ref={ref} style={style}>{children}</div>;
|
|
23
|
+
*/
|
|
24
|
+
export default function useHeightAnimation(isOpen: boolean, duration?: number): {
|
|
25
|
+
ref: import("react").RefObject<HTMLDivElement | null>;
|
|
26
|
+
style: CSSProperties;
|
|
27
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, useState } from 'react';
|
|
2
|
+
const prefersReducedMotion = () => {
|
|
3
|
+
var _window$matchMedia, _window;
|
|
4
|
+
return typeof window !== 'undefined' && ((_window$matchMedia = (_window = window).matchMedia) === null || _window$matchMedia === void 0 ? void 0 : _window$matchMedia.call(_window, '(prefers-reduced-motion: reduce)').matches) === true;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Animates a disclosure's height between 0 and its content height.
|
|
9
|
+
*
|
|
10
|
+
* Driven by the Web Animations API on the element itself rather than through
|
|
11
|
+
* React state: a CSS transition needs a committed pixel height before the
|
|
12
|
+
* target one, and `auto` → `0` in a single style recalc (the old
|
|
13
|
+
* setState-then-setTimeout approach) snaps shut with no animation. Here every
|
|
14
|
+
* run starts from the height actually on screen, so a close that interrupts an
|
|
15
|
+
* open (or the reverse) reverses smoothly from where it is.
|
|
16
|
+
*
|
|
17
|
+
* Settled open, the element is `height: auto; overflow: visible`, so content
|
|
18
|
+
* that changes size while open (axis sliders, wrapped features) still fits and
|
|
19
|
+
* slider tooltips aren't clipped. Settled closed, it is `0px` + `hidden`.
|
|
20
|
+
*
|
|
21
|
+
* Spread `style` onto the element (it only carries the initial state and never
|
|
22
|
+
* changes, so React never overwrites the inline styles set here) and attach
|
|
23
|
+
* `ref`.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* const { ref, style } = useHeightAnimation(open, 300);
|
|
27
|
+
* return <div ref={ref} style={style}>{children}</div>;
|
|
28
|
+
*/
|
|
29
|
+
export default function useHeightAnimation(isOpen) {
|
|
30
|
+
let duration = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 300;
|
|
31
|
+
const ref = useRef(null);
|
|
32
|
+
const animation = useRef(null);
|
|
33
|
+
const mounted = useRef(false);
|
|
34
|
+
const [style] = useState(() => isOpen ? {
|
|
35
|
+
height: 'auto',
|
|
36
|
+
overflow: 'visible'
|
|
37
|
+
} : {
|
|
38
|
+
height: 0,
|
|
39
|
+
overflow: 'hidden'
|
|
40
|
+
});
|
|
41
|
+
useLayoutEffect(() => {
|
|
42
|
+
var _animation$current;
|
|
43
|
+
const el = ref.current;
|
|
44
|
+
if (!el) return;
|
|
45
|
+
if (!mounted.current) {
|
|
46
|
+
mounted.current = true;
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Measure before cancelling: a running animation's current frame is what
|
|
51
|
+
// the visitor sees, and cancelling it would jump to the settled style.
|
|
52
|
+
const from = el.getBoundingClientRect().height;
|
|
53
|
+
(_animation$current = animation.current) === null || _animation$current === void 0 ? void 0 : _animation$current.cancel();
|
|
54
|
+
animation.current = null;
|
|
55
|
+
el.style.height = 'auto';
|
|
56
|
+
const to = isOpen ? el.getBoundingClientRect().height : 0;
|
|
57
|
+
el.style.height = isOpen ? 'auto' : '0px';
|
|
58
|
+
el.style.overflow = 'hidden';
|
|
59
|
+
const settle = () => {
|
|
60
|
+
if (isOpen) el.style.overflow = 'visible';
|
|
61
|
+
};
|
|
62
|
+
if (from === to || typeof el.animate !== 'function' || prefersReducedMotion()) {
|
|
63
|
+
settle();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const run = el.animate([{
|
|
67
|
+
height: `${from}px`
|
|
68
|
+
}, {
|
|
69
|
+
height: `${to}px`
|
|
70
|
+
}], {
|
|
71
|
+
duration,
|
|
72
|
+
easing: 'ease-out'
|
|
73
|
+
});
|
|
74
|
+
animation.current = run;
|
|
75
|
+
run.onfinish = () => {
|
|
76
|
+
if (animation.current !== run) return;
|
|
77
|
+
animation.current = null;
|
|
78
|
+
settle();
|
|
79
|
+
};
|
|
80
|
+
}, [isOpen, duration]);
|
|
81
|
+
return {
|
|
82
|
+
ref,
|
|
83
|
+
style
|
|
84
|
+
};
|
|
85
|
+
}
|
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.
|
|
11
|
+
export const version = "3.12.0";
|