fontdue-js 3.2.6 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +25 -16
  2. package/dist/__generated__/CharacterViewerIDQuery.graphql.d.ts +1 -1
  3. package/dist/__generated__/CharacterViewerIDQuery.graphql.js +33 -3
  4. package/dist/__generated__/CharacterViewerSlugQuery.graphql.d.ts +1 -1
  5. package/dist/__generated__/CharacterViewerSlugQuery.graphql.js +33 -3
  6. package/dist/__generated__/CharacterViewerStyleRefetchQuery.graphql.d.ts +1 -1
  7. package/dist/__generated__/CharacterViewerStyleRefetchQuery.graphql.js +34 -4
  8. package/dist/__generated__/CharacterViewer_style.graphql.d.ts +2 -2
  9. package/dist/__generated__/CharacterViewer_style.graphql.js +6 -2
  10. package/dist/__generated__/FeatureTesterStandaloneQuery.graphql.d.ts +1 -1
  11. package/dist/__generated__/FeatureTesterStandaloneQuery.graphql.js +15 -20
  12. package/dist/__generated__/FeatureTestersIdQuery.graphql.d.ts +1 -1
  13. package/dist/__generated__/FeatureTestersIdQuery.graphql.js +15 -20
  14. package/dist/__generated__/FeatureTestersSlugQuery.graphql.d.ts +1 -1
  15. package/dist/__generated__/FeatureTestersSlugQuery.graphql.js +15 -20
  16. package/dist/__generated__/TypeTesterStandaloneChangedStylesQuery.graphql.d.ts +1 -1
  17. package/dist/__generated__/TypeTesterStandaloneChangedStylesQuery.graphql.js +14 -20
  18. package/dist/__generated__/TypeTesterStandaloneQuery.graphql.d.ts +1 -1
  19. package/dist/__generated__/TypeTesterStandaloneQuery.graphql.js +14 -20
  20. package/dist/__generated__/TypeTestersChangedStylesQuery.graphql.d.ts +1 -1
  21. package/dist/__generated__/TypeTestersChangedStylesQuery.graphql.js +14 -20
  22. package/dist/__generated__/TypeTestersIDQuery.graphql.d.ts +1 -1
  23. package/dist/__generated__/TypeTestersIDQuery.graphql.js +17 -23
  24. package/dist/__generated__/TypeTestersRefetchQuery.graphql.d.ts +1 -1
  25. package/dist/__generated__/TypeTestersRefetchQuery.graphql.js +17 -23
  26. package/dist/__generated__/TypeTestersSlugQuery.graphql.d.ts +1 -1
  27. package/dist/__generated__/TypeTestersSlugQuery.graphql.js +17 -23
  28. package/dist/__generated__/useFeaturesData_fontStyle.graphql.d.ts +4 -4
  29. package/dist/__generated__/useFeaturesData_fontStyle.graphql.js +6 -6
  30. package/dist/__tests__/consentActivation.test.js +85 -0
  31. package/dist/__tests__/trackingRouteChanges.test.js +110 -0
  32. package/dist/components/CharacterViewer/index.js +17 -4
  33. package/dist/components/ConfigContext.d.ts +2 -2
  34. package/dist/components/ConfigContext.js +9 -2
  35. package/dist/components/ConsentBanner/consent.d.ts +9 -0
  36. package/dist/components/ConsentBanner/consent.js +26 -3
  37. package/dist/components/ConsentBanner/index.js +6 -3
  38. package/dist/components/Tracking/index.js +10 -18
  39. package/dist/components/Tracking/routeChanges.d.ts +19 -0
  40. package/dist/components/Tracking/routeChanges.js +62 -0
  41. package/dist/components/TypeTester/TypeTesterContext.d.ts +1 -0
  42. package/dist/components/TypeTester/TypeTesterContext.js +1 -1
  43. package/dist/components/TypeTester/TypeTesterFeatures.d.ts +1 -1
  44. package/dist/components/TypeTester/TypeTesterFeatures.js +200 -22
  45. package/dist/components/TypeTester/TypeTesterFeaturesButton.js +4 -1
  46. package/dist/components/TypeTester/TypeTesterState.js +20 -8
  47. package/dist/components/TypeTester/index.d.ts +1 -1
  48. package/dist/components/TypeTester/useFeaturesData.d.ts +9 -9
  49. package/dist/components/TypeTester/useFeaturesData.js +13 -23
  50. package/dist/fontdue.css +62 -0
  51. package/dist/relay/environment.js +1 -1
  52. package/package.json +2 -1
@@ -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
  }
@@ -4,6 +4,7 @@ import { useContext, useEffect } from 'react';
4
4
  import ConfigContext from '../ConfigContext.js';
5
5
  import { useFontdueUrl } from '../UrlContext.js';
6
6
  import { hasConsent, onConsent, setClientAnonymousId, setEntryAttribution } from '../ConsentBanner/consent.js';
7
+ import { trackRouteChanges } from './routeChanges.js';
7
8
  function getCampaignDataFromUrl() {
8
9
  const urlParams = new URLSearchParams(window.location.search);
9
10
  const campaign = {};
@@ -155,26 +156,17 @@ const Tracking = () => {
155
156
  }
156
157
 
157
158
  // Track client-side route changes
158
- const originalPushState = history.pushState.bind(history);
159
- const originalReplaceState = history.replaceState.bind(history);
160
- const handleRouteChange = () => {
161
- // Defer slightly to let document.title update
162
- setTimeout(() => sendPageView(url), 0);
163
- };
164
- history.pushState = function () {
165
- originalPushState(...arguments);
166
- handleRouteChange();
167
- };
168
- history.replaceState = function () {
169
- originalReplaceState(...arguments);
170
- handleRouteChange();
171
- };
172
- window.addEventListener('popstate', handleRouteChange);
159
+ const untrackRouteChanges = trackRouteChanges({
160
+ history,
161
+ window,
162
+ currentUrl: () => window.location.href,
163
+ onNavigate: () => {
164
+ sendPageView(url);
165
+ }
166
+ });
173
167
  return () => {
174
168
  var _consentCleanup;
175
- history.pushState = originalPushState;
176
- history.replaceState = originalReplaceState;
177
- window.removeEventListener('popstate', handleRouteChange);
169
+ untrackRouteChanges();
178
170
  (_consentCleanup = consentCleanup) === null || _consentCleanup === void 0 ? void 0 : _consentCleanup();
179
171
  };
180
172
  }, []);
@@ -0,0 +1,19 @@
1
+ type HistoryLike = Pick<History, 'pushState' | 'replaceState'>;
2
+ type WindowLike = Pick<Window, 'addEventListener' | 'removeEventListener'>;
3
+ export type RouteChangeOptions = {
4
+ history: HistoryLike;
5
+ window: WindowLike;
6
+ /** The URL as it stands right now, e.g. `() => window.location.href`. */
7
+ currentUrl: () => string;
8
+ /** Called once per navigation to a URL different from the previous one. */
9
+ onNavigate: () => void;
10
+ /** Overridable so tests can run the check synchronously. */
11
+ defer?: (callback: () => void) => void;
12
+ };
13
+ /**
14
+ * Wrap `pushState`/`replaceState` and call `onNavigate` when the URL changes.
15
+ *
16
+ * Returns a cleanup function that restores the original History methods.
17
+ */
18
+ export declare function trackRouteChanges({ history, window: windowLike, currentUrl, onNavigate, defer, }: RouteChangeOptions): () => void;
19
+ export {};
@@ -0,0 +1,62 @@
1
+ // Client-side route-change detection for the Tracking component.
2
+ //
3
+ // There is no navigation event a page can listen to for SPA routing, so the
4
+ // only way to notice one is to wrap the History API and watch for the URL
5
+ // changing underneath us. Kept in its own module — and taking its history,
6
+ // window and clock as arguments — so the wrapping and the de-duplication can
7
+ // be tested without a DOM.
8
+
9
+ /**
10
+ * Wrap `pushState`/`replaceState` and call `onNavigate` when the URL changes.
11
+ *
12
+ * Returns a cleanup function that restores the original History methods.
13
+ */
14
+ export function trackRouteChanges(_ref) {
15
+ let {
16
+ history,
17
+ window: windowLike,
18
+ currentUrl,
19
+ onNavigate,
20
+ defer = callback => {
21
+ setTimeout(callback, 0);
22
+ }
23
+ } = _ref;
24
+ // The caller has already reported the URL we start on, so it is the first
25
+ // thing we compare against.
26
+ let lastUrl = currentUrl();
27
+ const handleRouteChange = () => {
28
+ // Defer slightly to let document.title update
29
+ defer(() => {
30
+ const url = currentUrl();
31
+
32
+ // A history entry gets rewritten in place for all sorts of reasons that
33
+ // are not navigation: saving scroll position, stashing UI state, marking
34
+ // how the entry was reached. Phoenix LiveView, for one, calls
35
+ // `history.replaceState(state, "", window.location.href)` 100ms after
36
+ // every scroll stop to remember the scroll offset — which billed a page
37
+ // view per scroll pause on the site of anyone running LiveView. Only a
38
+ // URL that actually differs from the last one we reported can be a new
39
+ // page view. Note this compares against the *previous* URL rather than
40
+ // every URL seen, so A → B → A is three page views, as it should be.
41
+ if (url === lastUrl) return;
42
+ lastUrl = url;
43
+ onNavigate();
44
+ });
45
+ };
46
+ const originalPushState = history.pushState.bind(history);
47
+ const originalReplaceState = history.replaceState.bind(history);
48
+ history.pushState = function () {
49
+ originalPushState(...arguments);
50
+ handleRouteChange();
51
+ };
52
+ history.replaceState = function () {
53
+ originalReplaceState(...arguments);
54
+ handleRouteChange();
55
+ };
56
+ windowLike.addEventListener('popstate', handleRouteChange);
57
+ return () => {
58
+ history.pushState = originalPushState;
59
+ history.replaceState = originalReplaceState;
60
+ windowLike.removeEventListener('popstate', handleRouteChange);
61
+ };
62
+ }
@@ -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,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;
@@ -1,18 +1,18 @@
1
1
  import { useFeaturesData_fontStyle$key } from '__generated__/useFeaturesData_fontStyle.graphql.js';
2
2
  interface useFeatures_props {
3
- fontStyle: useFeaturesData_fontStyle$key;
3
+ fontStyle: useFeaturesData_fontStyle$key | null | undefined;
4
4
  }
5
+ export type FeatureNames = {
6
+ [feature: string]: string;
7
+ };
5
8
  declare const useFeaturesData: ({ fontStyle }: useFeatures_props) => {
6
- featureNames: {
7
- [feature: string]: string;
8
- };
9
9
  fontFeatures: {
10
- readonly stylisticSetNames: ReadonlyArray<{
11
- readonly featureName: string;
12
- readonly humanName: string;
10
+ readonly featureNames: ReadonlyArray<{
11
+ readonly name: string;
12
+ readonly tag: string;
13
13
  }>;
14
14
  readonly supportedFeatures: ReadonlyArray<string>;
15
- };
16
- " $fragmentType": "useFeaturesData_fontStyle";
15
+ } | null;
16
+ featureNames: FeatureNames;
17
17
  };
18
18
  export default useFeaturesData;