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 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$openTypeFeatu = config.openTypeFeatures) === null || _config$openTypeFeatu === void 0 ? void 0 : _config$openTypeFeatu.buttonLabel) ?? 'OT Features',
29
- interactionStyle: (config === null || config === void 0 ? void 0 : (_config$openTypeFeatu2 = config.openTypeFeatures) === null || _config$openTypeFeatu2 === void 0 ? void 0 : _config$openTypeFeatu2.interactionStyle) ?? 'panel',
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
- const match = document.cookie.match(new RegExp('(?:^|;\\s*)' + CONSENT_COOKIE + '=([^;]*)'));
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, activateScripts, getClientAnonymousId, getEntryAttribution } from './consent.js';
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
  }
@@ -40,6 +40,7 @@ type TypeTesterContextAction = {
40
40
  type: 'TOGGLE_MODE';
41
41
  } | {
42
42
  type: 'TOGGLE_GLOBAL_FEATURES_OPEN';
43
+ value?: boolean;
43
44
  } | {
44
45
  type: 'SET_CONTENT';
45
46
  id: string;
@@ -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({ id, fontStyle: fontStyleKey, features: showFeatures, axes, }: TypeTesterFeatures_props): React.JSX.Element;
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
- export default function TypeTesterFeatures(_ref) {
47
- var _config$openTypeFeatu, _config$openTypeFeatu2, _config$openTypeFeatu3, _config$openTypeFeatu4;
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 featureCode = typeof feature === 'string' ? feature : feature.code;
74
- const featureName = typeof feature === 'string' ? featureNames[featureCode] : feature.name;
75
- const checked = selectedFeatures.indexOf(featureCode) >= 0;
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: featureCode,
85
+ key: code,
78
86
  className: "type-tester__features__button",
79
- type: "button",
80
- onClick: () => toggleFeature(featureCode)
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$openTypeFeatu = config.openTypeFeatures) !== null && _config$openTypeFeatu !== void 0 && (_config$openTypeFeatu2 = _config$openTypeFeatu.columns) !== null && _config$openTypeFeatu2 !== void 0 && _config$openTypeFeatu2.length) {
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.filter(feature => shouldIncludeFeature(fontFeatures === null || fontFeatures === void 0 ? void 0 : fontFeatures.supportedFeatures, typeof feature === 'string' ? feature : feature.code)).map(renderFeature))));
273
+ }, supportedColumnFeatures(columnConfig.features, supportedFeatures).map(renderFeature))));
101
274
  } else {
102
- const expanded = showFeatures !== null && showFeatures !== void 0 && showFeatures.includes('*') ? (fontFeatures === null || fontFeatures === void 0 ? void 0 : fontFeatures.supportedFeatures) ?? [] : (showFeatures ?? []).filter(feature => shouldIncludeFeature(fontFeatures === null || fontFeatures === void 0 ? void 0 : fontFeatures.supportedFeatures, feature));
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$openTypeFeatu3 = config.openTypeFeatures) !== null && _config$openTypeFeatu3 !== void 0 && (_config$openTypeFeatu4 = _config$openTypeFeatu3.columns) !== null && _config$openTypeFeatu4 !== void 0 && _config$openTypeFeatu4.length ? 'set' : 'auto'
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
- if (config.openTypeFeatures.interactionStyle === 'panel') {
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 { notEmpty } from '../../utils.js';
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: value => {
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',
@@ -21,7 +21,7 @@ export interface TypeTesterConfig {
21
21
  autofitOnChange?: boolean;
22
22
  openTypeFeatures?: {
23
23
  buttonLabel?: string;
24
- interactionStyle?: 'panel' | 'select';
24
+ interactionStyle?: 'panel' | 'select' | 'dropdown';
25
25
  columns?: {
26
26
  features: ({
27
27
  code: string;
@@ -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
- aspect-ratio: 1;
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.3.0";
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.0",
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",