fontdue-js 3.3.0 → 3.4.1
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 +10 -0
- package/dist/__tests__/consentActivation.test.js +85 -0
- package/dist/__tests__/parseVariableSettings.test.js +73 -0
- package/dist/components/ConfigContext.d.ts +2 -2
- package/dist/components/ConfigContext.js +9 -2
- package/dist/components/ConsentBanner/consent.d.ts +9 -0
- package/dist/components/ConsentBanner/consent.js +26 -3
- package/dist/components/ConsentBanner/index.js +6 -3
- package/dist/components/TypeTester/TypeTesterContext.d.ts +1 -0
- package/dist/components/TypeTester/TypeTesterContext.js +1 -1
- package/dist/components/TypeTester/TypeTesterFeatures.d.ts +1 -1
- package/dist/components/TypeTester/TypeTesterFeatures.js +200 -22
- package/dist/components/TypeTester/TypeTesterFeaturesButton.js +4 -1
- package/dist/components/TypeTester/TypeTesterStandaloneElement.js +1 -18
- package/dist/components/TypeTester/TypeTesterState.js +20 -8
- package/dist/components/TypeTester/index.d.ts +1 -1
- package/dist/components/TypeTester/parseVariableSettings.d.ts +21 -0
- package/dist/components/TypeTester/parseVariableSettings.js +41 -0
- package/dist/fontdue.css +64 -1
- package/dist/relay/environment.js +1 -1
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
## 3.4.1
|
|
2
|
+
|
|
3
|
+
- 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.
|
|
4
|
+
- Fixed the `FeatureTesters` switch knob rendering stretched in Safari 18 and earlier. The fix is in `dist/fontdue.css`.
|
|
5
|
+
|
|
6
|
+
## 3.4.0
|
|
7
|
+
|
|
8
|
+
- Added new `openTypeFeatures.interactionStyle: 'dropdown'` for type testers. The features button opens a compact single-column overlay instead of the panel.
|
|
9
|
+
- Scripts gated with `data-consent-category` now activate on every page load where the visitor has already granted that category, not only the load where they accepted.
|
|
10
|
+
|
|
1
11
|
## 3.3.0
|
|
2
12
|
|
|
3
13
|
- Fixed one page being counted as many pageviews. Sites that rewrite the URL without navigating — scroll restoration, shallow routing — counted a view per rewrite, inflating Pageviews and Top pages in your Fontdue dashboard. Only a change of URL counts now.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
// @vitest-environment-options { "url": "https://store.example.com/" }
|
|
3
|
+
|
|
4
|
+
// Consent-gated scripts (`<script type="text/plain" data-consent-category>`)
|
|
5
|
+
// must activate both at the moment consent is granted and on every later page
|
|
6
|
+
// load where consent is already stored — the accept-click only covers the page
|
|
7
|
+
// load it happens on (FD-1088: a consented visitor's Meta pixel never fired
|
|
8
|
+
// again after the banner was dismissed).
|
|
9
|
+
|
|
10
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
11
|
+
import { activateConsentedScripts, getConsentedCategories, hasConsent, setConsent } from '../components/ConsentBanner/consent.js';
|
|
12
|
+
function addGatedScript(category) {
|
|
13
|
+
let attrs = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
|
|
14
|
+
const script = document.createElement('script');
|
|
15
|
+
script.setAttribute('type', 'text/plain');
|
|
16
|
+
script.setAttribute('data-consent-category', category);
|
|
17
|
+
for (const [name, value] of Object.entries(attrs)) {
|
|
18
|
+
script.setAttribute(name, value);
|
|
19
|
+
}
|
|
20
|
+
script.textContent = `/* ${category} snippet */`;
|
|
21
|
+
document.head.appendChild(script);
|
|
22
|
+
return script;
|
|
23
|
+
}
|
|
24
|
+
function gatedScripts(category) {
|
|
25
|
+
return document.querySelectorAll(`script[type="text/plain"][data-consent-category="${category}"]`);
|
|
26
|
+
}
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
document.cookie = '_fontdue_cc=;path=/;max-age=0';
|
|
29
|
+
document.head.innerHTML = '';
|
|
30
|
+
});
|
|
31
|
+
describe('activateConsentedScripts', () => {
|
|
32
|
+
it('activates gated scripts for categories granted on a previous visit', () => {
|
|
33
|
+
document.cookie = '_fontdue_cc=necessary,analytics;path=/';
|
|
34
|
+
addGatedScript('analytics', {
|
|
35
|
+
'data-pixel': 'meta'
|
|
36
|
+
});
|
|
37
|
+
addGatedScript('marketing');
|
|
38
|
+
activateConsentedScripts();
|
|
39
|
+
|
|
40
|
+
// The analytics script is replaced by an executable copy that keeps its
|
|
41
|
+
// other attributes and content.
|
|
42
|
+
expect(gatedScripts('analytics')).toHaveLength(0);
|
|
43
|
+
const activated = document.querySelector('script[data-pixel="meta"]');
|
|
44
|
+
expect(activated).not.toBeNull();
|
|
45
|
+
expect(activated.getAttribute('type')).toBeNull();
|
|
46
|
+
expect(activated.getAttribute('data-consent-category')).toBeNull();
|
|
47
|
+
expect(activated.textContent).toBe('/* analytics snippet */');
|
|
48
|
+
|
|
49
|
+
// A category the visitor never granted stays inert.
|
|
50
|
+
expect(gatedScripts('marketing')).toHaveLength(1);
|
|
51
|
+
});
|
|
52
|
+
it('does nothing without a consent cookie', () => {
|
|
53
|
+
addGatedScript('analytics');
|
|
54
|
+
activateConsentedScripts();
|
|
55
|
+
expect(gatedScripts('analytics')).toHaveLength(1);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
describe('setConsent', () => {
|
|
59
|
+
it('activates gated scripts for the granted categories', () => {
|
|
60
|
+
addGatedScript('analytics');
|
|
61
|
+
let eventCategories;
|
|
62
|
+
window.addEventListener('fontdue:consent', event => {
|
|
63
|
+
eventCategories = event.detail.categories;
|
|
64
|
+
});
|
|
65
|
+
setConsent(['necessary', 'analytics']);
|
|
66
|
+
expect(gatedScripts('analytics')).toHaveLength(0);
|
|
67
|
+
expect(hasConsent('analytics')).toBe(true);
|
|
68
|
+
expect(eventCategories).toEqual(['necessary', 'analytics']);
|
|
69
|
+
});
|
|
70
|
+
it('leaves scripts of ungranted categories inert', () => {
|
|
71
|
+
addGatedScript('analytics');
|
|
72
|
+
setConsent(['necessary']);
|
|
73
|
+
expect(gatedScripts('analytics')).toHaveLength(1);
|
|
74
|
+
expect(hasConsent('analytics')).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
describe('getConsentedCategories', () => {
|
|
78
|
+
it('parses the cookie into categories', () => {
|
|
79
|
+
document.cookie = '_fontdue_cc=necessary,analytics;path=/';
|
|
80
|
+
expect(getConsentedCategories()).toEqual(['necessary', 'analytics']);
|
|
81
|
+
});
|
|
82
|
+
it('returns an empty list without a cookie', () => {
|
|
83
|
+
expect(getConsentedCategories()).toEqual([]);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { parseVariableSettings } from '../components/TypeTester/parseVariableSettings.js';
|
|
3
|
+
describe('parseVariableSettings', () => {
|
|
4
|
+
let warn;
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
7
|
+
});
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
warn.mockRestore();
|
|
10
|
+
});
|
|
11
|
+
it('returns null when the attribute is absent or empty', () => {
|
|
12
|
+
expect(parseVariableSettings(undefined)).toBeNull();
|
|
13
|
+
expect(parseVariableSettings(null)).toBeNull();
|
|
14
|
+
expect(parseVariableSettings('')).toBeNull();
|
|
15
|
+
});
|
|
16
|
+
it('parses integer and decimal values', () => {
|
|
17
|
+
expect(parseVariableSettings('wdth 1000, ital 0.5')).toEqual([{
|
|
18
|
+
axis: 'wdth',
|
|
19
|
+
value: 1000
|
|
20
|
+
}, {
|
|
21
|
+
axis: 'ital',
|
|
22
|
+
value: 0.5
|
|
23
|
+
}]);
|
|
24
|
+
expect(warn).not.toHaveBeenCalled();
|
|
25
|
+
});
|
|
26
|
+
it('parses negative values, so axes like slnt keep their slider', () => {
|
|
27
|
+
expect(parseVariableSettings('slnt -11')).toEqual([{
|
|
28
|
+
axis: 'slnt',
|
|
29
|
+
value: -11
|
|
30
|
+
}]);
|
|
31
|
+
expect(parseVariableSettings('slnt -11.0')).toEqual([{
|
|
32
|
+
axis: 'slnt',
|
|
33
|
+
value: -11
|
|
34
|
+
}]);
|
|
35
|
+
expect(parseVariableSettings('ital -0.5')).toEqual([{
|
|
36
|
+
axis: 'ital',
|
|
37
|
+
value: -0.5
|
|
38
|
+
}]);
|
|
39
|
+
expect(warn).not.toHaveBeenCalled();
|
|
40
|
+
});
|
|
41
|
+
it('keeps the sibling axes of a negative setting', () => {
|
|
42
|
+
// Regression for FD-1137: a single unparsed setting used to be dropped, and
|
|
43
|
+
// with it the slider for that axis, leaving adjacent testers inconsistent.
|
|
44
|
+
expect(parseVariableSettings('slnt -11.0, wdth 50.0, wght 300.0')).toEqual([{
|
|
45
|
+
axis: 'slnt',
|
|
46
|
+
value: -11
|
|
47
|
+
}, {
|
|
48
|
+
axis: 'wdth',
|
|
49
|
+
value: 50
|
|
50
|
+
}, {
|
|
51
|
+
axis: 'wght',
|
|
52
|
+
value: 300
|
|
53
|
+
}]);
|
|
54
|
+
expect(warn).not.toHaveBeenCalled();
|
|
55
|
+
});
|
|
56
|
+
it('tolerates whitespace around the separator and between axis and value', () => {
|
|
57
|
+
expect(parseVariableSettings(' wght 300 , slnt -11 ')).toEqual([{
|
|
58
|
+
axis: 'wght',
|
|
59
|
+
value: 300
|
|
60
|
+
}, {
|
|
61
|
+
axis: 'slnt',
|
|
62
|
+
value: -11
|
|
63
|
+
}]);
|
|
64
|
+
expect(warn).not.toHaveBeenCalled();
|
|
65
|
+
});
|
|
66
|
+
it('warns about and drops malformed settings, keeping the valid ones', () => {
|
|
67
|
+
expect(parseVariableSettings('wght 300, nonsense, wdth abc, slnt -')).toEqual([{
|
|
68
|
+
axis: 'wght',
|
|
69
|
+
value: 300
|
|
70
|
+
}]);
|
|
71
|
+
expect(warn).toHaveBeenCalledTimes(3);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -57,7 +57,7 @@ export declare const makeConfig: (config?: Config) => {
|
|
|
57
57
|
bulletStyle: "round" | "square";
|
|
58
58
|
openTypeFeatures: {
|
|
59
59
|
buttonLabel: string;
|
|
60
|
-
interactionStyle: "select" | "panel";
|
|
60
|
+
interactionStyle: "select" | "panel" | "dropdown";
|
|
61
61
|
columns: {
|
|
62
62
|
features: ({
|
|
63
63
|
code: string;
|
|
@@ -135,7 +135,7 @@ declare const _default: React.Context<{
|
|
|
135
135
|
bulletStyle: "round" | "square";
|
|
136
136
|
openTypeFeatures: {
|
|
137
137
|
buttonLabel: string;
|
|
138
|
-
interactionStyle: "select" | "panel";
|
|
138
|
+
interactionStyle: "select" | "panel" | "dropdown";
|
|
139
139
|
columns: {
|
|
140
140
|
features: ({
|
|
141
141
|
code: string;
|
|
@@ -9,6 +9,13 @@ const makeTypeTesterConfig = config => {
|
|
|
9
9
|
if (shy === true) shy = 'hover';
|
|
10
10
|
let toolsPosition = (config === null || config === void 0 ? void 0 : config.toolsPosition) ?? 'inline';
|
|
11
11
|
if (shy !== 'focus') toolsPosition = 'inline';
|
|
12
|
+
|
|
13
|
+
// The dropdown features surface is built for the inline toolbar; inside the
|
|
14
|
+
// bottom-fixed floating toolbar it would open downward off-viewport. Fall
|
|
15
|
+
// back to the panel there, mirroring the toolsPosition rule above, so every
|
|
16
|
+
// consumer sees a coherent combination.
|
|
17
|
+
let featuresInteractionStyle = (config === null || config === void 0 ? void 0 : (_config$openTypeFeatu = config.openTypeFeatures) === null || _config$openTypeFeatu === void 0 ? void 0 : _config$openTypeFeatu.interactionStyle) ?? 'panel';
|
|
18
|
+
if (featuresInteractionStyle === 'dropdown' && toolsPosition === 'floating') featuresInteractionStyle = 'panel';
|
|
12
19
|
return {
|
|
13
20
|
autofitOnChange: (config === null || config === void 0 ? void 0 : config.autofitOnChange) ?? false,
|
|
14
21
|
truncate: (config === null || config === void 0 ? void 0 : config.truncate) ?? false,
|
|
@@ -25,8 +32,8 @@ const makeTypeTesterConfig = config => {
|
|
|
25
32
|
groupEdit: (config === null || config === void 0 ? void 0 : config.groupEdit) ?? false,
|
|
26
33
|
bulletStyle: (config === null || config === void 0 ? void 0 : config.bulletStyle) ?? 'square',
|
|
27
34
|
openTypeFeatures: {
|
|
28
|
-
buttonLabel: (config === null || config === void 0 ? void 0 : (_config$
|
|
29
|
-
interactionStyle:
|
|
35
|
+
buttonLabel: (config === null || config === void 0 ? void 0 : (_config$openTypeFeatu2 = config.openTypeFeatures) === null || _config$openTypeFeatu2 === void 0 ? void 0 : _config$openTypeFeatu2.buttonLabel) ?? 'OT Features',
|
|
36
|
+
interactionStyle: featuresInteractionStyle,
|
|
30
37
|
columns: config === null || config === void 0 ? void 0 : (_config$openTypeFeatu3 = config.openTypeFeatures) === null || _config$openTypeFeatu3 === void 0 ? void 0 : _config$openTypeFeatu3.columns,
|
|
31
38
|
selectionStyle: (config === null || config === void 0 ? void 0 : (_config$openTypeFeatu4 = config.openTypeFeatures) === null || _config$openTypeFeatu4 === void 0 ? void 0 : _config$openTypeFeatu4.selectionStyle) ?? 'bullet'
|
|
32
39
|
},
|
|
@@ -15,10 +15,19 @@ export type EntryAttribution = {
|
|
|
15
15
|
};
|
|
16
16
|
export declare function setEntryAttribution(body: EntryAttribution): void;
|
|
17
17
|
export declare function getEntryAttribution(): EntryAttribution | undefined;
|
|
18
|
+
/** Parse the _fontdue_cc cookie into the list of granted categories. */
|
|
19
|
+
export declare function getConsentedCategories(): string[];
|
|
18
20
|
/** Parse the _fontdue_cc cookie and check if it contains the given category. */
|
|
19
21
|
export declare function hasConsent(category: string): boolean;
|
|
20
22
|
/** Set the _fontdue_cc cookie with the given categories. */
|
|
21
23
|
export declare function setConsent(categories: string[]): void;
|
|
24
|
+
/**
|
|
25
|
+
* Activate gated scripts for every category the visitor has already granted.
|
|
26
|
+
* Must run on every page load: the accept-click only activates scripts on the
|
|
27
|
+
* page load it happens on, and once consent is stored the banner no longer
|
|
28
|
+
* appears to activate anything.
|
|
29
|
+
*/
|
|
30
|
+
export declare function activateConsentedScripts(): void;
|
|
22
31
|
/**
|
|
23
32
|
* Call callback when the given consent category is granted.
|
|
24
33
|
* If already granted, calls immediately. Otherwise polls (500ms) and
|
|
@@ -29,16 +29,27 @@ export function getEntryAttribution() {
|
|
|
29
29
|
return entryAttribution;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/** Parse the _fontdue_cc cookie into the list of granted categories. */
|
|
33
|
+
export function getConsentedCategories() {
|
|
34
|
+
const match = document.cookie.match(new RegExp('(?:^|;\\s*)' + CONSENT_COOKIE + '=([^;]*)'));
|
|
35
|
+
if (!match) return [];
|
|
36
|
+
return match[1].split(',').filter(Boolean);
|
|
37
|
+
}
|
|
38
|
+
|
|
32
39
|
/** Parse the _fontdue_cc cookie and check if it contains the given category. */
|
|
33
40
|
export function hasConsent(category) {
|
|
34
|
-
|
|
35
|
-
if (!match) return false;
|
|
36
|
-
return match[1].split(',').includes(category);
|
|
41
|
+
return getConsentedCategories().includes(category);
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
/** Set the _fontdue_cc cookie with the given categories. */
|
|
40
45
|
export function setConsent(categories) {
|
|
41
46
|
document.cookie = CONSENT_COOKIE + '=' + categories.join(',') + ';path=/;max-age=' + MAX_AGE + ';SameSite=Lax;Secure';
|
|
47
|
+
|
|
48
|
+
// Granting a category activates its gated scripts, whichever UI called this
|
|
49
|
+
// — the bundled banner or a custom one built on these helpers.
|
|
50
|
+
for (const category of categories) {
|
|
51
|
+
activateScripts(category);
|
|
52
|
+
}
|
|
42
53
|
window.dispatchEvent(new CustomEvent(CONSENT_EVENT, {
|
|
43
54
|
detail: {
|
|
44
55
|
categories
|
|
@@ -46,6 +57,18 @@ export function setConsent(categories) {
|
|
|
46
57
|
}));
|
|
47
58
|
}
|
|
48
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Activate gated scripts for every category the visitor has already granted.
|
|
62
|
+
* Must run on every page load: the accept-click only activates scripts on the
|
|
63
|
+
* page load it happens on, and once consent is stored the banner no longer
|
|
64
|
+
* appears to activate anything.
|
|
65
|
+
*/
|
|
66
|
+
export function activateConsentedScripts() {
|
|
67
|
+
for (const category of getConsentedCategories()) {
|
|
68
|
+
activateScripts(category);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
49
72
|
/**
|
|
50
73
|
* Call callback when the given consent category is granted.
|
|
51
74
|
* If already granted, calls immediately. Otherwise polls (500ms) and
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React, { useContext, useEffect, useState } from 'react';
|
|
2
2
|
import ConfigContext from '../ConfigContext.js';
|
|
3
3
|
import { useFontdueUrl } from '../UrlContext.js';
|
|
4
|
-
import { setConsent,
|
|
4
|
+
import { setConsent, activateConsentedScripts, getClientAnonymousId, getEntryAttribution } from './consent.js';
|
|
5
5
|
const DEFAULT_MESSAGE = 'We use cookies to analyze site usage and improve your\u00A0experience.';
|
|
6
6
|
const ConsentBanner = () => {
|
|
7
7
|
const config = useContext(ConfigContext);
|
|
@@ -11,6 +11,10 @@ const ConsentBanner = () => {
|
|
|
11
11
|
// Check on the client in useEffect.
|
|
12
12
|
const [dismissed, setDismissed] = useState(true);
|
|
13
13
|
useEffect(() => {
|
|
14
|
+
// Scripts gated on an already-granted category stay inert until something
|
|
15
|
+
// activates them, and the accept-click only covers the page load it
|
|
16
|
+
// happens on — so activation has to re-run on every mount.
|
|
17
|
+
activateConsentedScripts();
|
|
14
18
|
if (!config.tracking.consentRequired) return;
|
|
15
19
|
if (!document.cookie.includes('_fontdue_cc=')) {
|
|
16
20
|
setDismissed(false);
|
|
@@ -47,9 +51,8 @@ const ConsentBanner = () => {
|
|
|
47
51
|
};
|
|
48
52
|
const handleAcceptAll = () => {
|
|
49
53
|
// Write the consent cookie first, so the tracking request carries it and the
|
|
50
|
-
// server is permitted to forward.
|
|
54
|
+
// server is permitted to forward. setConsent also activates gated scripts.
|
|
51
55
|
setConsent(['necessary', 'analytics']);
|
|
52
|
-
activateScripts('analytics');
|
|
53
56
|
if (config.tracking.enabled) {
|
|
54
57
|
sendConsentEvent(['necessary', 'analytics']);
|
|
55
58
|
}
|
|
@@ -65,7 +65,7 @@ const groupReducer = (state, action) => {
|
|
|
65
65
|
case 'TOGGLE_GLOBAL_FEATURES_OPEN':
|
|
66
66
|
return {
|
|
67
67
|
...state,
|
|
68
|
-
globalFeaturesOpen: !state.globalFeaturesOpen
|
|
68
|
+
globalFeaturesOpen: action.value ?? !state.globalFeaturesOpen
|
|
69
69
|
};
|
|
70
70
|
case 'SET_CONTENT':
|
|
71
71
|
return updateTester(state, action.id, {
|
|
@@ -6,5 +6,5 @@ interface TypeTesterFeatures_props {
|
|
|
6
6
|
features: ReadonlyArray<string> | null;
|
|
7
7
|
axes: ReadonlyArray<string> | null | undefined;
|
|
8
8
|
}
|
|
9
|
-
export default function TypeTesterFeatures(
|
|
9
|
+
export default function TypeTesterFeatures(props: TypeTesterFeatures_props): React.JSX.Element;
|
|
10
10
|
export {};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import _TypeTesterFeatures_fontStyle from "../../__generated__/TypeTesterFeatures_fontStyle.graphql.js";
|
|
2
|
-
import React, { useContext, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import React, { useContext, useEffect, useMemo, useRef, useState } 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';
|
|
@@ -43,13 +43,18 @@ const shouldIncludeFeature = (supportedFeatures, feature) => {
|
|
|
43
43
|
if (!supportedFeatures) return false;
|
|
44
44
|
return supportedFeatures.indexOf(feature) >= 0;
|
|
45
45
|
};
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
const featureCode = feature => typeof feature === 'string' ? feature : feature.code;
|
|
47
|
+
|
|
48
|
+
// The single support filter for a configured column, shared by the panel's
|
|
49
|
+
// per-column rendering and the dropdown's flattened list so the two layouts
|
|
50
|
+
// can't drift apart.
|
|
51
|
+
const supportedColumnFeatures = (features, supportedFeatures) => features.filter(feature => shouldIncludeFeature(supportedFeatures, featureCode(feature)));
|
|
52
|
+
// Data plumbing shared by both feature surfaces.
|
|
53
|
+
function useFeaturesSurface(_ref) {
|
|
48
54
|
let {
|
|
49
55
|
id,
|
|
50
56
|
fontStyle: fontStyleKey,
|
|
51
|
-
features: showFeatures
|
|
52
|
-
axes
|
|
57
|
+
features: showFeatures
|
|
53
58
|
} = _ref;
|
|
54
59
|
const fontStyle = useFragment((_TypeTesterFeatures_fontStyle.hash && _TypeTesterFeatures_fontStyle.hash !== "d0c693dadaa9ae68f5295f3fc69a7578" && console.error("The definition of 'TypeTesterFeatures_fontStyle' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _TypeTesterFeatures_fontStyle), fontStyleKey);
|
|
55
60
|
const {
|
|
@@ -58,26 +63,34 @@ export default function TypeTesterFeatures(_ref) {
|
|
|
58
63
|
const {
|
|
59
64
|
features: selectedFeatures,
|
|
60
65
|
toggleFeature,
|
|
61
|
-
featuresOpen
|
|
66
|
+
featuresOpen,
|
|
67
|
+
setFeaturesOpen
|
|
62
68
|
} = useTypeTesterState({
|
|
63
69
|
id
|
|
64
70
|
});
|
|
65
|
-
const selectionStyle = config.openTypeFeatures.selectionStyle;
|
|
66
71
|
const {
|
|
67
72
|
featureNames,
|
|
68
73
|
fontFeatures
|
|
69
74
|
} = useFeaturesData({
|
|
70
75
|
fontStyle
|
|
71
76
|
});
|
|
77
|
+
const selectionStyle = config.openTypeFeatures.selectionStyle;
|
|
78
|
+
const supportedFeatures = (fontFeatures === null || fontFeatures === void 0 ? void 0 : fontFeatures.supportedFeatures) ?? null;
|
|
79
|
+
const expandedFeatures = useMemo(() => showFeatures !== null && showFeatures !== void 0 && showFeatures.includes('*') ? supportedFeatures ?? [] : (showFeatures ?? []).filter(feature => shouldIncludeFeature(supportedFeatures, feature)), [showFeatures, supportedFeatures]);
|
|
72
80
|
const renderFeature = feature => {
|
|
73
|
-
const
|
|
74
|
-
const featureName = typeof feature === 'string' ? featureNames[
|
|
75
|
-
const checked = selectedFeatures.indexOf(
|
|
81
|
+
const code = featureCode(feature);
|
|
82
|
+
const featureName = typeof feature === 'string' ? featureNames[code] : feature.name;
|
|
83
|
+
const checked = selectedFeatures.indexOf(code) >= 0;
|
|
76
84
|
return /*#__PURE__*/React.createElement("button", {
|
|
77
|
-
key:
|
|
85
|
+
key: code,
|
|
78
86
|
className: "type-tester__features__button",
|
|
79
|
-
type: "button"
|
|
80
|
-
|
|
87
|
+
type: "button"
|
|
88
|
+
// The row button carries the checked state so site CSS can style the
|
|
89
|
+
// whole row. Unchecked rows render data-checked="false", so select
|
|
90
|
+
// with [data-checked='true'], not the bare [data-checked].
|
|
91
|
+
,
|
|
92
|
+
"data-checked": checked,
|
|
93
|
+
onClick: () => toggleFeature(code)
|
|
81
94
|
}, selectionStyle === 'bullet' && /*#__PURE__*/React.createElement(Bullet, {
|
|
82
95
|
checked: checked,
|
|
83
96
|
config: config
|
|
@@ -88,8 +101,168 @@ export default function TypeTesterFeatures(_ref) {
|
|
|
88
101
|
className: "type-tester__features__name"
|
|
89
102
|
}, featureName));
|
|
90
103
|
};
|
|
104
|
+
return {
|
|
105
|
+
fontStyle,
|
|
106
|
+
config,
|
|
107
|
+
featuresOpen,
|
|
108
|
+
setFeaturesOpen,
|
|
109
|
+
selectionStyle,
|
|
110
|
+
supportedFeatures,
|
|
111
|
+
expandedFeatures,
|
|
112
|
+
renderFeature
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function TypeTesterFeaturesDropdown(_ref2) {
|
|
116
|
+
var _config$openTypeFeatu;
|
|
117
|
+
let {
|
|
118
|
+
id,
|
|
119
|
+
fontStyle: fontStyleKey,
|
|
120
|
+
features,
|
|
121
|
+
axes
|
|
122
|
+
} = _ref2;
|
|
123
|
+
const {
|
|
124
|
+
fontStyle,
|
|
125
|
+
config,
|
|
126
|
+
featuresOpen,
|
|
127
|
+
setFeaturesOpen,
|
|
128
|
+
selectionStyle,
|
|
129
|
+
supportedFeatures,
|
|
130
|
+
expandedFeatures,
|
|
131
|
+
renderFeature
|
|
132
|
+
} = useFeaturesSurface({
|
|
133
|
+
id,
|
|
134
|
+
fontStyle: fontStyleKey,
|
|
135
|
+
features
|
|
136
|
+
});
|
|
137
|
+
const dropdownRef = useRef(null);
|
|
138
|
+
|
|
139
|
+
// Close on outside interaction and on Escape. Registered only while open.
|
|
140
|
+
// pointerdown rather than mousedown: iOS never synthesizes mouse events for
|
|
141
|
+
// taps on non-clickable page areas, which would leave the dropdown stuck
|
|
142
|
+
// open. composedPath keeps the inside/outside test working when a host site
|
|
143
|
+
// wraps the tester in a shadow root, where event.target retargets to the
|
|
144
|
+
// shadow host.
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
if (!featuresOpen) return;
|
|
147
|
+
const resolveTarget = event => {
|
|
148
|
+
var _event$composedPath;
|
|
149
|
+
return ((_event$composedPath = event.composedPath) === null || _event$composedPath === void 0 ? void 0 : _event$composedPath.call(event)[0]) ?? event.target;
|
|
150
|
+
};
|
|
151
|
+
const handlePointerDown = event => {
|
|
152
|
+
var _dropdown$closest;
|
|
153
|
+
const target = resolveTarget(event);
|
|
154
|
+
const dropdown = dropdownRef.current;
|
|
155
|
+
if (!target || !dropdown) return;
|
|
156
|
+
if (dropdown.contains(target)) return;
|
|
157
|
+
// The features button toggles the dropdown itself; closing here as well
|
|
158
|
+
// would make the button's click handler immediately re-open it.
|
|
159
|
+
const button = (_dropdown$closest = dropdown.closest('.type-tester')) === null || _dropdown$closest === void 0 ? void 0 : _dropdown$closest.querySelector('.type-tester__features-button');
|
|
160
|
+
if (button !== null && button !== void 0 && button.contains(target)) return;
|
|
161
|
+
setFeaturesOpen(false);
|
|
162
|
+
};
|
|
163
|
+
const handleKeyDown = event => {
|
|
164
|
+
if (event.key !== 'Escape') return;
|
|
165
|
+
// The keypress belongs to an overlay above us, or to an in-progress IME
|
|
166
|
+
// composition being cancelled inside the editable tester.
|
|
167
|
+
if (event.defaultPrevented || event.isComposing) return;
|
|
168
|
+
setFeaturesOpen(false);
|
|
169
|
+
};
|
|
170
|
+
document.addEventListener('pointerdown', handlePointerDown);
|
|
171
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
172
|
+
return () => {
|
|
173
|
+
document.removeEventListener('pointerdown', handlePointerDown);
|
|
174
|
+
document.removeEventListener('keydown', handleKeyDown);
|
|
175
|
+
};
|
|
176
|
+
}, [featuresOpen, setFeaturesOpen]);
|
|
177
|
+
const anchorMouseDown = event => {
|
|
178
|
+
var _target$classList;
|
|
179
|
+
// Keep the tester's contentEditable focused while interacting with the
|
|
180
|
+
// dropdown (same purpose as the panel container's handler below), except
|
|
181
|
+
// for a mousedown on the list's own vertical scrollbar: cancelling that
|
|
182
|
+
// aborts the scrollbar-thumb drag in browsers with classic scrollbars.
|
|
183
|
+
// A scrollbar hit lands on the scrollable element itself with offsetX
|
|
184
|
+
// beyond clientWidth (clientWidth excludes the scrollbar).
|
|
185
|
+
const target = event.target;
|
|
186
|
+
if ((_target$classList = target.classList) !== null && _target$classList !== void 0 && _target$classList.contains('type-tester__features-dropdown') && event.nativeEvent.offsetX >= target.clientWidth) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
event.preventDefault();
|
|
190
|
+
};
|
|
191
|
+
if (!featuresOpen) {
|
|
192
|
+
return /*#__PURE__*/React.createElement("div", {
|
|
193
|
+
className: "type-tester__features-dropdown-anchor",
|
|
194
|
+
"data-open": false,
|
|
195
|
+
onMouseDown: anchorMouseDown
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// A configured `columns` layout is flattened into the single scrolling
|
|
200
|
+
// column in order (a column's `range` has no meaning here), deduped by
|
|
201
|
+
// feature code: the panel may legitimately repeat a code across columns,
|
|
202
|
+
// but one flat list would hand React duplicate keys.
|
|
203
|
+
const columns = (_config$openTypeFeatu = config.openTypeFeatures) === null || _config$openTypeFeatu === void 0 ? void 0 : _config$openTypeFeatu.columns;
|
|
204
|
+
let dropdownFeatures;
|
|
205
|
+
if (columns !== null && columns !== void 0 && columns.length) {
|
|
206
|
+
const seen = new Set();
|
|
207
|
+
dropdownFeatures = columns.flatMap(columnConfig => supportedColumnFeatures(columnConfig.features, supportedFeatures)).filter(feature => {
|
|
208
|
+
const code = featureCode(feature);
|
|
209
|
+
if (seen.has(code)) return false;
|
|
210
|
+
seen.add(code);
|
|
211
|
+
return true;
|
|
212
|
+
});
|
|
213
|
+
} else {
|
|
214
|
+
dropdownFeatures = expandedFeatures;
|
|
215
|
+
}
|
|
216
|
+
const hasAxes = Boolean(axes && axes.length > 0);
|
|
217
|
+
|
|
218
|
+
// The anchor has no height of its own, so the open dropdown overlays the
|
|
219
|
+
// content below instead of pushing it down. With nothing to show (no
|
|
220
|
+
// supported features, no axes routed here) skip the overlay entirely:
|
|
221
|
+
// unlike the chromeless panel, an empty dropdown would be a visible box.
|
|
222
|
+
return /*#__PURE__*/React.createElement("div", {
|
|
223
|
+
className: "type-tester__features-dropdown-anchor",
|
|
224
|
+
"data-open": featuresOpen,
|
|
225
|
+
onMouseDown: anchorMouseDown
|
|
226
|
+
}, dropdownFeatures.length > 0 || hasAxes ? /*#__PURE__*/React.createElement("div", {
|
|
227
|
+
className: "type-tester__features-dropdown",
|
|
228
|
+
ref: dropdownRef
|
|
229
|
+
}, axes && axes.length > 0 ? /*#__PURE__*/React.createElement(TypeTesterVariableAxes, {
|
|
230
|
+
id: id,
|
|
231
|
+
axes: axes,
|
|
232
|
+
fontStyle: fontStyle
|
|
233
|
+
}) : null, /*#__PURE__*/React.createElement("div", {
|
|
234
|
+
className: "type-tester__features",
|
|
235
|
+
"data-selection-style": selectionStyle,
|
|
236
|
+
"data-columns": "dropdown"
|
|
237
|
+
}, dropdownFeatures.map(renderFeature))) : null);
|
|
238
|
+
}
|
|
239
|
+
function TypeTesterFeaturesPanel(_ref3) {
|
|
240
|
+
var _config$openTypeFeatu2, _config$openTypeFeatu3, _config$openTypeFeatu4, _config$openTypeFeatu5;
|
|
241
|
+
let {
|
|
242
|
+
id,
|
|
243
|
+
fontStyle: fontStyleKey,
|
|
244
|
+
features,
|
|
245
|
+
axes
|
|
246
|
+
} = _ref3;
|
|
247
|
+
const {
|
|
248
|
+
fontStyle,
|
|
249
|
+
config,
|
|
250
|
+
featuresOpen,
|
|
251
|
+
selectionStyle,
|
|
252
|
+
supportedFeatures,
|
|
253
|
+
expandedFeatures,
|
|
254
|
+
renderFeature
|
|
255
|
+
} = useFeaturesSurface({
|
|
256
|
+
id,
|
|
257
|
+
fontStyle: fontStyleKey,
|
|
258
|
+
features
|
|
259
|
+
});
|
|
260
|
+
const {
|
|
261
|
+
height: featuresHeight,
|
|
262
|
+
contentRef: featuresContentRef
|
|
263
|
+
} = useHeightAnimation(featuresOpen, 300);
|
|
91
264
|
let featuresColumns;
|
|
92
|
-
if ((_config$
|
|
265
|
+
if ((_config$openTypeFeatu2 = config.openTypeFeatures) !== null && _config$openTypeFeatu2 !== void 0 && (_config$openTypeFeatu3 = _config$openTypeFeatu2.columns) !== null && _config$openTypeFeatu3 !== void 0 && _config$openTypeFeatu3.length) {
|
|
93
266
|
featuresColumns = /*#__PURE__*/React.createElement(React.Fragment, null, config.openTypeFeatures.columns.map((columnConfig, i) => /*#__PURE__*/React.createElement("div", {
|
|
94
267
|
key: i,
|
|
95
268
|
"data-range": columnConfig.range,
|
|
@@ -97,15 +270,10 @@ export default function TypeTesterFeatures(_ref) {
|
|
|
97
270
|
style: {
|
|
98
271
|
'--grid-range': columnConfig.range
|
|
99
272
|
}
|
|
100
|
-
}, columnConfig.features
|
|
273
|
+
}, supportedColumnFeatures(columnConfig.features, supportedFeatures).map(renderFeature))));
|
|
101
274
|
} else {
|
|
102
|
-
|
|
103
|
-
featuresColumns = /*#__PURE__*/React.createElement(React.Fragment, null, expanded.map(renderFeature));
|
|
275
|
+
featuresColumns = /*#__PURE__*/React.createElement(React.Fragment, null, expandedFeatures.map(renderFeature));
|
|
104
276
|
}
|
|
105
|
-
const {
|
|
106
|
-
height: featuresHeight,
|
|
107
|
-
contentRef: featuresContentRef
|
|
108
|
-
} = useHeightAnimation(featuresOpen, 300);
|
|
109
277
|
return /*#__PURE__*/React.createElement("div", {
|
|
110
278
|
className: "type-tester__features-container",
|
|
111
279
|
onMouseDown: e => e.preventDefault(),
|
|
@@ -124,6 +292,16 @@ export default function TypeTesterFeatures(_ref) {
|
|
|
124
292
|
}) : null, /*#__PURE__*/React.createElement("div", {
|
|
125
293
|
className: "type-tester__features",
|
|
126
294
|
"data-selection-style": selectionStyle,
|
|
127
|
-
"data-columns": (_config$
|
|
295
|
+
"data-columns": (_config$openTypeFeatu4 = config.openTypeFeatures) !== null && _config$openTypeFeatu4 !== void 0 && (_config$openTypeFeatu5 = _config$openTypeFeatu4.columns) !== null && _config$openTypeFeatu5 !== void 0 && _config$openTypeFeatu5.length ? 'set' : 'auto'
|
|
128
296
|
}, featuresColumns)));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Each surface owns its hooks (the panel's height animation, the dropdown's
|
|
300
|
+
// document listeners), so a config change between renders remounts cleanly
|
|
301
|
+
// instead of tripping over conditional hook order.
|
|
302
|
+
export default function TypeTesterFeatures(props) {
|
|
303
|
+
const {
|
|
304
|
+
typeTester: config
|
|
305
|
+
} = useContext(ConfigContext);
|
|
306
|
+
return config.openTypeFeatures.interactionStyle === 'dropdown' ? /*#__PURE__*/React.createElement(TypeTesterFeaturesDropdown, props) : /*#__PURE__*/React.createElement(TypeTesterFeaturesPanel, props);
|
|
129
307
|
}
|
|
@@ -34,7 +34,10 @@ const TypeTesterFeaturesButton = _ref => {
|
|
|
34
34
|
fontStyle
|
|
35
35
|
});
|
|
36
36
|
const effectiveFeatures = showFeatures !== null && showFeatures !== void 0 && showFeatures.includes('*') ? (fontFeatures === null || fontFeatures === void 0 ? void 0 : fontFeatures.supportedFeatures) ?? [] : showFeatures;
|
|
37
|
-
|
|
37
|
+
|
|
38
|
+
// 'dropdown' uses the same toggle button as 'panel'; only the surface the
|
|
39
|
+
// toggle reveals differs (see TypeTesterFeatures).
|
|
40
|
+
if (config.openTypeFeatures.interactionStyle === 'panel' || config.openTypeFeatures.interactionStyle === 'dropdown') {
|
|
38
41
|
return /*#__PURE__*/React.createElement("div", {
|
|
39
42
|
className: "type-tester__features-button",
|
|
40
43
|
"data-features-open": featuresOpen,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
|
|
2
2
|
import React from 'react';
|
|
3
3
|
import TypeTesterStandalone from './TypeTesterStandalone.js';
|
|
4
|
-
import {
|
|
4
|
+
import { parseVariableSettings } from './parseVariableSettings.js';
|
|
5
5
|
const isAlignment = str => {
|
|
6
6
|
return str === 'left' || str === 'center' || str === 'right';
|
|
7
7
|
};
|
|
@@ -14,23 +14,6 @@ const parseBool = input => input === 'true' ? true : false;
|
|
|
14
14
|
// `axes="wght, opsz"` behaves the same as `axes="wght,opsz"` and a trailing
|
|
15
15
|
// comma doesn't produce a bogus empty entry.
|
|
16
16
|
const splitList = input => (input === null || input === void 0 ? void 0 : input.split(',').map(token => token.trim()).filter(Boolean)) ?? [];
|
|
17
|
-
|
|
18
|
-
// parse a string like "wdth 1000, ital 0.5" into
|
|
19
|
-
// [{ axis: "wdth", value: 1000 }, { axis: "ital", value: 0.5 }]
|
|
20
|
-
function parseVariableSettings(input) {
|
|
21
|
-
if (!input) return null;
|
|
22
|
-
return input.split(/\s*,\s*/).map(settingString => {
|
|
23
|
-
const m = settingString.match(/^([A-Za-z0-9]{4}) (\d+(?:\.\d+)?)$/);
|
|
24
|
-
if (m && m.length > 2) {
|
|
25
|
-
return {
|
|
26
|
-
axis: m[1],
|
|
27
|
-
value: parseFloat(m[2])
|
|
28
|
-
};
|
|
29
|
-
} else {
|
|
30
|
-
console.warn(`fontdue-type-tester failed to parse variable-settings value: "${settingString}", ignoring`);
|
|
31
|
-
}
|
|
32
|
-
}).filter(notEmpty);
|
|
33
|
-
}
|
|
34
17
|
export const TypeTesterStandaloneElement = _ref => {
|
|
35
18
|
let {
|
|
36
19
|
lineHeight,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useContext } from 'react';
|
|
1
|
+
import { useCallback, useContext } from 'react';
|
|
2
2
|
import { TypeTesterContext } from './TypeTesterContext.js';
|
|
3
3
|
import ConfigContext from '../ConfigContext.js';
|
|
4
4
|
const useTypeTesterState = _ref => {
|
|
@@ -12,6 +12,24 @@ const useTypeTesterState = _ref => {
|
|
|
12
12
|
dispatch
|
|
13
13
|
} = useContext(TypeTesterContext);
|
|
14
14
|
const toolsPosition = config.typeTester.toolsPosition;
|
|
15
|
+
|
|
16
|
+
// In floating mode the flag consumers read is the global one (see
|
|
17
|
+
// featuresOpen below), so writes must land there too. Stable identity so
|
|
18
|
+
// effects can list it in their deps without re-registering per render.
|
|
19
|
+
const setFeaturesOpen = useCallback(value => {
|
|
20
|
+
if (toolsPosition === 'floating') {
|
|
21
|
+
dispatch({
|
|
22
|
+
type: 'TOGGLE_GLOBAL_FEATURES_OPEN',
|
|
23
|
+
value
|
|
24
|
+
});
|
|
25
|
+
} else {
|
|
26
|
+
dispatch({
|
|
27
|
+
type: 'SET_FEATURES_OPEN',
|
|
28
|
+
value,
|
|
29
|
+
id
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}, [dispatch, id, toolsPosition]);
|
|
15
33
|
const {
|
|
16
34
|
content,
|
|
17
35
|
contentEdited,
|
|
@@ -107,13 +125,7 @@ const useTypeTesterState = _ref => {
|
|
|
107
125
|
});
|
|
108
126
|
}
|
|
109
127
|
},
|
|
110
|
-
setFeaturesOpen
|
|
111
|
-
dispatch({
|
|
112
|
-
type: 'SET_FEATURES_OPEN',
|
|
113
|
-
value,
|
|
114
|
-
id
|
|
115
|
-
});
|
|
116
|
-
},
|
|
128
|
+
setFeaturesOpen,
|
|
117
129
|
variableSettings,
|
|
118
130
|
setVariableSettings: value => dispatch({
|
|
119
131
|
type: 'SET_VARIABLE_SETTINGS',
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse the `variable-settings` attribute of `<fontdue-type-tester>` into the
|
|
3
|
+
* axis/value pairs the tester expects.
|
|
4
|
+
*
|
|
5
|
+
* Whitespace around the commas and between an axis and its value is ignored, so
|
|
6
|
+
* `"wdth 1000,ital 0.5"` and `"wdth 1000, ital 0.5"` are equivalent, and a
|
|
7
|
+
* trailing comma is tolerated. Settings that don't parse are warned about and
|
|
8
|
+
* skipped, leaving their siblings intact.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* parseVariableSettings('wdth 1000, ital 0.5')
|
|
12
|
+
* // => [{ axis: 'wdth', value: 1000 }, { axis: 'ital', value: 0.5 }]
|
|
13
|
+
*
|
|
14
|
+
* parseVariableSettings('slnt -11.0')
|
|
15
|
+
* // => [{ axis: 'slnt', value: -11 }]
|
|
16
|
+
*/
|
|
17
|
+
export declare function parseVariableSettings(input: string | null | undefined): {
|
|
18
|
+
axis: string;
|
|
19
|
+
value: number;
|
|
20
|
+
}[] | null;
|
|
21
|
+
export default parseVariableSettings;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { notEmpty } from '../../utils.js';
|
|
2
|
+
|
|
3
|
+
// Value group allows a leading minus: several registered OpenType axes are
|
|
4
|
+
// conventionally negative (`slnt` runs about -20 to 0, `ital` can too), and a
|
|
5
|
+
// custom axis can have any range. A setting that fails to parse is dropped, and
|
|
6
|
+
// TypeTesterVariableAxes only renders a slider for an axis that has a value, so
|
|
7
|
+
// rejecting a sign used to make the slider vanish with nothing in the markup to
|
|
8
|
+
// explain it.
|
|
9
|
+
const SETTING = /^([A-Za-z0-9]{4})\s+(-?\d+(?:\.\d+)?)$/;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Parse the `variable-settings` attribute of `<fontdue-type-tester>` into the
|
|
13
|
+
* axis/value pairs the tester expects.
|
|
14
|
+
*
|
|
15
|
+
* Whitespace around the commas and between an axis and its value is ignored, so
|
|
16
|
+
* `"wdth 1000,ital 0.5"` and `"wdth 1000, ital 0.5"` are equivalent, and a
|
|
17
|
+
* trailing comma is tolerated. Settings that don't parse are warned about and
|
|
18
|
+
* skipped, leaving their siblings intact.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* parseVariableSettings('wdth 1000, ital 0.5')
|
|
22
|
+
* // => [{ axis: 'wdth', value: 1000 }, { axis: 'ital', value: 0.5 }]
|
|
23
|
+
*
|
|
24
|
+
* parseVariableSettings('slnt -11.0')
|
|
25
|
+
* // => [{ axis: 'slnt', value: -11 }]
|
|
26
|
+
*/
|
|
27
|
+
export function parseVariableSettings(input) {
|
|
28
|
+
if (!input) return null;
|
|
29
|
+
return input.split(',').map(settingString => settingString.trim()).filter(Boolean).map(settingString => {
|
|
30
|
+
const m = settingString.match(SETTING);
|
|
31
|
+
if (m && m.length > 2) {
|
|
32
|
+
return {
|
|
33
|
+
axis: m[1],
|
|
34
|
+
value: parseFloat(m[2])
|
|
35
|
+
};
|
|
36
|
+
} else {
|
|
37
|
+
console.warn(`fontdue-type-tester failed to parse variable-settings value: "${settingString}", ignoring`);
|
|
38
|
+
}
|
|
39
|
+
}).filter(notEmpty);
|
|
40
|
+
}
|
|
41
|
+
export default parseVariableSettings;
|
package/dist/fontdue.css
CHANGED
|
@@ -700,6 +700,68 @@ fontdue-type-tester {
|
|
|
700
700
|
.type-tester__features[data-selection-style=checkbox] .type-tester__features__button {
|
|
701
701
|
margin-bottom: 5px;
|
|
702
702
|
}
|
|
703
|
+
.type-tester__features-dropdown-anchor {
|
|
704
|
+
position: relative;
|
|
705
|
+
z-index: 3;
|
|
706
|
+
}
|
|
707
|
+
.type-tester__features-dropdown {
|
|
708
|
+
position: absolute;
|
|
709
|
+
top: 5px;
|
|
710
|
+
right: 0;
|
|
711
|
+
box-sizing: border-box;
|
|
712
|
+
width: 320px;
|
|
713
|
+
max-width: 100%;
|
|
714
|
+
max-height: 320px;
|
|
715
|
+
overflow-y: auto;
|
|
716
|
+
overflow-x: hidden;
|
|
717
|
+
padding: 15px;
|
|
718
|
+
border: 1px solid var(--horizontal_rule_color);
|
|
719
|
+
background: var(--primary_background_color, canvas);
|
|
720
|
+
color: var(--primary_text_color);
|
|
721
|
+
}
|
|
722
|
+
.type-tester__features-dropdown .type-tester__features {
|
|
723
|
+
padding-bottom: 0;
|
|
724
|
+
}
|
|
725
|
+
.type-tester__features-dropdown .type-tester__features[data-columns=dropdown] {
|
|
726
|
+
display: flex;
|
|
727
|
+
flex-direction: column;
|
|
728
|
+
align-items: stretch;
|
|
729
|
+
}
|
|
730
|
+
.type-tester__features-dropdown .type-tester__features__button {
|
|
731
|
+
min-width: 0;
|
|
732
|
+
}
|
|
733
|
+
.type-tester__features-dropdown .type-tester__features__button > :first-child {
|
|
734
|
+
flex-shrink: 0;
|
|
735
|
+
}
|
|
736
|
+
.type-tester__features-dropdown .type-tester__features__button:not(:last-child) {
|
|
737
|
+
margin-bottom: 5px;
|
|
738
|
+
}
|
|
739
|
+
.type-tester__features-dropdown .type-tester__features__name {
|
|
740
|
+
min-width: 0;
|
|
741
|
+
overflow-wrap: anywhere;
|
|
742
|
+
}
|
|
743
|
+
.type-tester__features-dropdown .type-tester__variable-axes {
|
|
744
|
+
grid-template-columns: 1fr;
|
|
745
|
+
margin-bottom: 15px;
|
|
746
|
+
}
|
|
747
|
+
.type-tester__features-dropdown .type-tester__variable-axes__axis {
|
|
748
|
+
box-sizing: border-box;
|
|
749
|
+
border: 1px solid var(--horizontal_rule_color);
|
|
750
|
+
padding: 10px;
|
|
751
|
+
flex-wrap: wrap;
|
|
752
|
+
}
|
|
753
|
+
.type-tester__features-dropdown .type-tester__variable-axes__value {
|
|
754
|
+
margin-left: auto;
|
|
755
|
+
margin-right: 0;
|
|
756
|
+
}
|
|
757
|
+
.type-tester__features-dropdown .type-tester__variable-axes__slider {
|
|
758
|
+
flex-basis: 100%;
|
|
759
|
+
margin-top: 8px;
|
|
760
|
+
}
|
|
761
|
+
.type-tester__features-dropdown .type-tester__variable-axes__name {
|
|
762
|
+
white-space: normal;
|
|
763
|
+
overflow-wrap: anywhere;
|
|
764
|
+
}
|
|
703
765
|
.type-tester > .type-tester__variable-axes:first-child {
|
|
704
766
|
margin-top: 10px;
|
|
705
767
|
}
|
|
@@ -901,6 +963,7 @@ fontdue-feature-testers {
|
|
|
901
963
|
--_sw-knob: var(--fontdue-feature-tester-switch-knob, var(--primary_background_color, canvas));
|
|
902
964
|
--_sw-track: var(--fontdue-feature-tester-switch-track, transparent);
|
|
903
965
|
--_sw-travel: calc(var(--_sw-w) - var(--_sw-h));
|
|
966
|
+
--_sw-knob-inset-right: calc(var(--_sw-w) - var(--_sw-h) + var(--_sw-p));
|
|
904
967
|
position: relative;
|
|
905
968
|
box-sizing: border-box;
|
|
906
969
|
width: var(--_sw-w);
|
|
@@ -918,7 +981,7 @@ fontdue-feature-testers {
|
|
|
918
981
|
top: var(--_sw-p);
|
|
919
982
|
left: var(--_sw-p);
|
|
920
983
|
bottom: var(--_sw-p);
|
|
921
|
-
|
|
984
|
+
right: var(--_sw-knob-inset-right);
|
|
922
985
|
border: var(--_sw-b) solid var(--_sw-color);
|
|
923
986
|
border-radius: var(--_sw-r);
|
|
924
987
|
background: transparent;
|
|
@@ -8,7 +8,7 @@ import { NODE_ACCESS_HEADER } from '../nodeAccess.js';
|
|
|
8
8
|
// (defineVersionPlugin in .babelrc.cjs) with the literal package.json#version.
|
|
9
9
|
// Exported so UI (the admin toolbar) can surface it without re-reading the
|
|
10
10
|
// build-time global in a 'use client' module.
|
|
11
|
-
export const version = "3.
|
|
11
|
+
export const version = "3.4.1";
|
|
12
12
|
const IS_SERVER = typeof window === typeof undefined;
|
|
13
13
|
|
|
14
14
|
// Opt server fetches into Next's data cache only in production; dev stays
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fontdue-js",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"build": "npm run relay && run-p build-js build-css build-ts",
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
"@types/relay-runtime": "^19.0.0",
|
|
58
58
|
"@types/uuid": "^8.3.3",
|
|
59
59
|
"babel-plugin-relay": "^18.0.0",
|
|
60
|
+
"happy-dom": "^20.11.2",
|
|
60
61
|
"npm-run-all": "^4.1.5",
|
|
61
62
|
"prettier": "2.0.5",
|
|
62
63
|
"react": "^19.0.0",
|