@pushwoosh/dumb-components 1.1.223 → 1.1.225

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.
@@ -42,6 +42,7 @@ export function EmailTemplatePreview(props) {
42
42
  onReady
43
43
  } = props;
44
44
  const clickMapItems = clickMap?.items;
45
+ const clickMapMode = clickMap?.mode;
45
46
  const clickMapActiveKey = clickMap?.activeKey;
46
47
  const clickMapOnSelect = clickMap?.onSelect;
47
48
  const clickMapOnPlaced = clickMap?.onPlaced;
@@ -60,6 +61,7 @@ export function EmailTemplatePreview(props) {
60
61
  const handleReady = useCallback(frameDocument => {
61
62
  const cleanups = [onReady?.(frameDocument), clickMapItems && decorateClickMap(frameDocument, {
62
63
  items: clickMapItems,
64
+ mode: clickMapMode,
63
65
  activeKey: clickMapActiveKey,
64
66
  scale,
65
67
  onSelect: clickMapOnSelect,
@@ -68,7 +70,7 @@ export function EmailTemplatePreview(props) {
68
70
  return () => {
69
71
  cleanups.forEach(cleanup => cleanup?.());
70
72
  };
71
- }, [onReady, clickMapItems, clickMapActiveKey, clickMapOnSelect, clickMapOnPlaced, scale]);
73
+ }, [onReady, clickMapItems, clickMapMode, clickMapActiveKey, clickMapOnSelect, clickMapOnPlaced, scale]);
72
74
  return _jsx(IframeStyled, {
73
75
  ...LIGHT_ISLAND_PROPS,
74
76
  "$scale": scale,
@@ -1,4 +1,4 @@
1
- import { CLICK_MAP_BADGE_ATTRIBUTE, decorateClickMap, matchClickMapItems } from '../clickMap';
1
+ import { CLICK_MAP_BADGE_ATTRIBUTE, CLICK_MAP_HEAT_ATTRIBUTE, decorateClickMap, matchClickMapItems } from '../clickMap';
2
2
  const PROMO = 'http://spartak.com/matchtowin';
3
3
  const TICKETS = 'https://tickets.spartak.com/zones/18956';
4
4
  const SHOP = 'https://shop.spartak.com/';
@@ -135,4 +135,34 @@ describe('decorateClickMap', () => {
135
135
  expect(frameDocument.querySelectorAll(`[${CLICK_MAP_BADGE_ATTRIBUTE}]`)).toHaveLength(0);
136
136
  expect(frameDocument.querySelector('a strong')?.parentElement?.style.outline).toBe('');
137
137
  });
138
+ });
139
+ describe('decorateClickMap in heat mode', () => {
140
+ it('tints every matched anchor by its share of clicks and counts only the active one', () => {
141
+ const frameDocument = createDocument();
142
+ const items = [item('button', PROMO, 'Перейти на страницу акции', 1000), item('image', PROMO, '', 250), item('gone', 'https://old.example.com/', 'x', 5)];
143
+ const cleanup = decorateClickMap(frameDocument, {
144
+ items,
145
+ mode: 'heat',
146
+ activeKey: 'image'
147
+ });
148
+ const heats = [...frameDocument.querySelectorAll(`[${CLICK_MAP_HEAT_ATTRIBUTE}]`)];
149
+ const badges = [...frameDocument.querySelectorAll(`[${CLICK_MAP_BADGE_ATTRIBUTE}]`)];
150
+ const alpha = heat => Number(heat.style.backgroundColor.match(/[\d.]+\)$/)?.[0].slice(0, -1));
151
+ expect(heats.map(heat => heat.getAttribute(CLICK_MAP_HEAT_ATTRIBUTE))).toEqual(['button', 'image']);
152
+ expect(alpha(heats[0])).toBeGreaterThan(alpha(heats[1]));
153
+ expect(badges.map(badge => [badge.getAttribute(CLICK_MAP_BADGE_ATTRIBUTE), badge.textContent])).toEqual([['image', '250']]);
154
+ cleanup();
155
+ expect(frameDocument.querySelectorAll(`[${CLICK_MAP_HEAT_ATTRIBUTE}], [${CLICK_MAP_BADGE_ATTRIBUTE}]`)).toHaveLength(0);
156
+ });
157
+ it('selects a row by clicking its heat overlay', () => {
158
+ const frameDocument = createDocument();
159
+ const onSelect = jest.fn();
160
+ decorateClickMap(frameDocument, {
161
+ items: [item('text', PROMO, 'странице акции', 1)],
162
+ mode: 'heat',
163
+ onSelect
164
+ });
165
+ frameDocument.querySelector(`[${CLICK_MAP_HEAT_ATTRIBUTE}]`)?.click();
166
+ expect(onSelect).toHaveBeenCalledWith('text');
167
+ });
138
168
  });
@@ -1,4 +1,4 @@
1
- import type { EmailClickMapItem, EmailClickMapPlacement } from './types';
1
+ import type { EmailClickMapItem, EmailClickMapMode, EmailClickMapPlacement } from './types';
2
2
  /** An anchor of the rendered template together with the rows recorded on it. */
3
3
  export type EmailClickMapMatch = {
4
4
  anchor: HTMLAnchorElement;
@@ -12,6 +12,7 @@ export type EmailClickMapMatchResult = {
12
12
  /** Options of {@link decorateClickMap}. */
13
13
  export type EmailClickMapDecorateOptions = {
14
14
  items: EmailClickMapItem[];
15
+ mode?: EmailClickMapMode;
15
16
  activeKey?: string | null;
16
17
  scale?: number;
17
18
  onSelect?: (key: string) => void;
@@ -21,7 +22,9 @@ export type EmailClickMapDecorateOptions = {
21
22
  export declare const CLICK_MAP_BADGE_ATTRIBUTE = "data-pw-click-map-badge";
22
23
  /** Stable anchor id stamped into the template html on save; the click carries it as `linkId`. */
23
24
  export declare const CLICK_MAP_LINK_ID_ATTRIBUTE = "data-pw-lid";
25
+ /** Attribute every heat overlay carries, so tests and consumers can find them. */
26
+ export declare const CLICK_MAP_HEAT_ATTRIBUTE = "data-pw-click-map-heat";
24
27
  /** Lands click-tracking rows on template anchors by stamped `linkId`, else by (link, text), else every anchor of the link. */
25
28
  export declare function matchClickMapItems(document: Document, items: EmailClickMapItem[]): EmailClickMapMatchResult;
26
- /** Draws click-count badges over matched anchors, outlines `activeKey`; returns a cleanup. */
29
+ /** Draws the click map over matched anchors (counts in `badges` mode, tints in `heat`), outlines `activeKey`; returns a cleanup. */
27
30
  export declare function decorateClickMap(document: Document, options: EmailClickMapDecorateOptions): () => void;
@@ -2,6 +2,10 @@
2
2
  export const CLICK_MAP_BADGE_ATTRIBUTE = 'data-pw-click-map-badge';
3
3
  /** Stable anchor id stamped into the template html on save; the click carries it as `linkId`. */
4
4
  export const CLICK_MAP_LINK_ID_ATTRIBUTE = 'data-pw-lid';
5
+ /** Attribute every heat overlay carries, so tests and consumers can find them. */
6
+ export const CLICK_MAP_HEAT_ATTRIBUTE = 'data-pw-click-map-heat';
7
+ const HEAT_MIN_ALPHA = 0.18;
8
+ const HEAT_MAX_ALPHA = 0.7;
5
9
  const ACTIVE_COLOR = '#00b2ff';
6
10
  const IDLE_COLOR = 'rgba(36, 48, 66, 0.82)';
7
11
  const compactNumber = new Intl.NumberFormat('en', {
@@ -96,29 +100,64 @@ function createBadge(document, match, options) {
96
100
  });
97
101
  return badge;
98
102
  }
99
- /** Draws click-count badges over matched anchors, outlines `activeKey`; returns a cleanup. */
103
+ const matchClicks = match => match.items.reduce((sum, item) => sum + item.clicks, 0);
104
+ const isActiveMatch = (match, options) => options.activeKey != null && match.items.some(item => item.key === options.activeKey);
105
+ function createHeat(document, match, maxClicks, options) {
106
+ const share = maxClicks > 0 ? matchClicks(match) / maxClicks : 0;
107
+ const heat = document.createElement('span');
108
+ heat.setAttribute(CLICK_MAP_HEAT_ATTRIBUTE, match.items.map(item => item.key).join(' '));
109
+ heat.title = match.items.map(item => `${item.title || item.link}: ${item.clicks}`).join('\n');
110
+ Object.assign(heat.style, {
111
+ position: 'absolute',
112
+ zIndex: '2147483646',
113
+ borderRadius: `${4 * (options.scale ?? 1)}px`,
114
+ backgroundColor: `rgba(0, 178, 255, ${(HEAT_MIN_ALPHA + (HEAT_MAX_ALPHA - HEAT_MIN_ALPHA) * share).toFixed(2)})`,
115
+ cursor: options.onSelect ? 'pointer' : 'default'
116
+ });
117
+ heat.addEventListener('click', event => {
118
+ event.preventDefault();
119
+ event.stopPropagation();
120
+ options.onSelect?.(match.items[0].key);
121
+ });
122
+ return heat;
123
+ }
124
+ /** Draws the click map over matched anchors (counts in `badges` mode, tints in `heat`), outlines `activeKey`; returns a cleanup. */
100
125
  export function decorateClickMap(document, options) {
101
126
  const view = document.defaultView;
127
+ const heatMode = options.mode === 'heat';
102
128
  const {
103
129
  matches,
104
130
  placement
105
131
  } = matchClickMapItems(document, options.items);
106
- const badges = matches.map(match => createBadge(document, match, options));
107
- const outlined = matches.filter(match => options.activeKey != null && match.items.some(item => item.key === options.activeKey)).map(match => [match.anchor, match.anchor.style.outline]);
132
+ const maxClicks = Math.max(0, ...matches.map(matchClicks));
133
+ const heats = heatMode ? matches.map(match => createHeat(document, match, maxClicks, options)) : [];
134
+ const badged = heatMode ? matches.filter(match => isActiveMatch(match, options)) : matches;
135
+ const badges = badged.map(match => createBadge(document, match, options));
136
+ const outlined = matches.filter(match => isActiveMatch(match, options)).map(match => [match.anchor, match.anchor.style.outline]);
108
137
  const isInlineText = anchor => view?.getComputedStyle(anchor).display === 'inline' && !anchor.querySelector('img');
109
138
  const layout = () => {
110
139
  const maxRight = document.documentElement.clientWidth;
111
- matches.forEach((match, index) => {
140
+ const scrollX = view?.scrollX ?? 0;
141
+ const scrollY = view?.scrollY ?? 0;
142
+ heats.forEach((heat, index) => {
143
+ const rect = anchorRect(matches[index].anchor);
144
+ heat.style.top = `${rect.top + scrollY}px`;
145
+ heat.style.left = `${rect.left + scrollX}px`;
146
+ heat.style.width = `${rect.width}px`;
147
+ heat.style.height = `${rect.height}px`;
148
+ });
149
+ badged.forEach((match, index) => {
112
150
  const badge = badges[index];
113
151
  const rect = anchorRect(match.anchor);
114
152
  const right = Math.min(rect.right, maxRight);
115
153
  const inline = isInlineText(match.anchor);
116
154
  const left = inline ? Math.min(right, maxRight - badge.offsetWidth) : right - badge.offsetWidth;
117
155
  const top = inline ? rect.top + rect.height / 2 : rect.top;
118
- badge.style.top = `${top + (view?.scrollY ?? 0)}px`;
119
- badge.style.left = `${left + (view?.scrollX ?? 0)}px`;
156
+ badge.style.top = `${top + scrollY}px`;
157
+ badge.style.left = `${left + scrollX}px`;
120
158
  });
121
159
  };
160
+ heats.forEach(heat => document.body.appendChild(heat));
122
161
  badges.forEach(badge => document.body.appendChild(badge));
123
162
  outlined.forEach(([anchor]) => {
124
163
  anchor.style.outline = `${2 * (options.scale ?? 1)}px solid ${ACTIVE_COLOR}`;
@@ -131,6 +170,7 @@ export function decorateClickMap(document, options) {
131
170
  return () => {
132
171
  observer?.disconnect();
133
172
  view?.removeEventListener('resize', layout);
173
+ heats.forEach(heat => heat.remove());
134
174
  badges.forEach(badge => badge.remove());
135
175
  outlined.forEach(([anchor, outline]) => {
136
176
  anchor.style.outline = outline;
@@ -1,5 +1,5 @@
1
1
  export { EmailTemplatePreview } from './EmailTemplatePreview';
2
2
  export { NoEmailTemplatePreview } from './components/NoEmailTemplatePreview';
3
3
  export { replaceLocalization, replacePlaceholders } from './helpers';
4
- export { matchClickMapItems, decorateClickMap, CLICK_MAP_BADGE_ATTRIBUTE, type EmailClickMapMatch, type EmailClickMapMatchResult, type EmailClickMapDecorateOptions, } from './clickMap';
5
- export type { EmailClickMapItem, EmailClickMapPlacement, EmailClickMapProps, PreviewIframeProps, } from './types';
4
+ export { matchClickMapItems, decorateClickMap, CLICK_MAP_BADGE_ATTRIBUTE, CLICK_MAP_HEAT_ATTRIBUTE, CLICK_MAP_LINK_ID_ATTRIBUTE, type EmailClickMapMatch, type EmailClickMapMatchResult, type EmailClickMapDecorateOptions, } from './clickMap';
5
+ export type { EmailClickMapItem, EmailClickMapMode, EmailClickMapPlacement, EmailClickMapProps, PreviewIframeProps, } from './types';
@@ -1,4 +1,4 @@
1
1
  export { EmailTemplatePreview } from './EmailTemplatePreview';
2
2
  export { NoEmailTemplatePreview } from './components/NoEmailTemplatePreview';
3
3
  export { replaceLocalization, replacePlaceholders } from './helpers';
4
- export { matchClickMapItems, decorateClickMap, CLICK_MAP_BADGE_ATTRIBUTE } from './clickMap';
4
+ export { matchClickMapItems, decorateClickMap, CLICK_MAP_BADGE_ATTRIBUTE, CLICK_MAP_HEAT_ATTRIBUTE, CLICK_MAP_LINK_ID_ATTRIBUTE } from './clickMap';
@@ -19,9 +19,12 @@ export type EmailClickMapPlacement = {
19
19
  /** Keys with no matching anchor in the current template. */
20
20
  unplaced: string[];
21
21
  };
22
+ /** `badges` draws a count on every anchor; `heat` tints anchors by their share and shows the count on the active one only. */
23
+ export type EmailClickMapMode = 'badges' | 'heat';
22
24
  /** Click counts to overlay on the rendered email, see {@link EmailTemplatePreview}. */
23
25
  export type EmailClickMapProps = {
24
26
  items: EmailClickMapItem[];
27
+ mode?: EmailClickMapMode;
25
28
  /** Key whose badge and anchor are highlighted. */
26
29
  activeKey?: string | null;
27
30
  /** Called with the key of the badge the user clicked. */
@@ -1,7 +1,7 @@
1
1
  import { type ReactElement } from 'react';
2
2
  import type { SwitchProps } from './types';
3
3
  /**
4
- * Toggle switch for a boolean value, with an optional label on either side.
4
+ * Toggle switch for a boolean value, with an optional label on either side; a click anywhere flips it.
5
5
  *
6
6
  * @example
7
7
  * <Switch enabled={on} onChange={() => setOn((v) => !v)}>Notifications</Switch>
package/Switch/Switch.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback } from 'react';
2
3
  import { Container, Box, Circle, Label } from './styles';
3
4
  /**
4
- * Toggle switch for a boolean value, with an optional label on either side.
5
+ * Toggle switch for a boolean value, with an optional label on either side; a click anywhere flips it.
5
6
  *
6
7
  * @example
7
8
  * <Switch enabled={on} onChange={() => setOn((v) => !v)}>Notifications</Switch>
@@ -13,12 +14,18 @@ export function Switch({
13
14
  children,
14
15
  onChange
15
16
  }) {
17
+ const handleClick = useCallback(() => {
18
+ if (!isDisabled) {
19
+ onChange();
20
+ }
21
+ }, [isDisabled, onChange]);
16
22
  return _jsxs(Container, {
17
23
  labelPosition: labelPosition,
24
+ isDisabled: isDisabled,
25
+ onClick: handleClick,
18
26
  children: [_jsx(Box, {
19
27
  enabled: enabled,
20
28
  isDisabled: isDisabled,
21
- onClick: onChange,
22
29
  children: _jsx(Circle, {
23
30
  enabled: enabled,
24
31
  isDisabled: isDisabled
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,44 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { fireEvent, render, screen } from '@testing-library/react';
3
+ import { Switch } from '../Switch';
4
+ describe('Switch', () => {
5
+ it('flips on a click on the label, not only on the toggle', () => {
6
+ const onChange = jest.fn();
7
+ render(_jsx(Switch, {
8
+ enabled: false,
9
+ onChange: onChange,
10
+ children: "Save inbox"
11
+ }));
12
+ fireEvent.click(screen.getByText('Save inbox'));
13
+ expect(onChange).toHaveBeenCalledTimes(1);
14
+ });
15
+ it('flips on a click on the toggle', () => {
16
+ const onChange = jest.fn();
17
+ const {
18
+ container
19
+ } = render(_jsx(Switch, {
20
+ enabled: true,
21
+ onChange: onChange,
22
+ children: "Save inbox"
23
+ }));
24
+ const box = container.querySelector('[tabindex="0"]');
25
+ if (!box) {
26
+ throw new Error('toggle not rendered');
27
+ }
28
+ fireEvent.click(box);
29
+ expect(onChange).toHaveBeenCalledTimes(1);
30
+ });
31
+ it('stays silent while disabled, label and toggle alike', () => {
32
+ const onChange = jest.fn();
33
+ const {
34
+ container
35
+ } = render(_jsx(Switch, {
36
+ isDisabled: true,
37
+ onChange: onChange,
38
+ children: "Save inbox"
39
+ }));
40
+ fireEvent.click(screen.getByText('Save inbox'));
41
+ fireEvent.click(container.querySelector('[tabindex="0"]'));
42
+ expect(onChange).not.toHaveBeenCalled();
43
+ });
44
+ });
package/Switch/styles.js CHANGED
@@ -37,9 +37,11 @@ const cssActive = css(["&:active{background:", ";border:2px solid ", ";}"], Colo
37
37
  export const Container = styled.div.withConfig({
38
38
  displayName: "Container",
39
39
  componentId: "sc-csdrg2-0"
40
- })(["display:flex;align-items:center;flex-direction:", ";"], ({
40
+ })(["display:flex;align-items:center;flex-direction:", ";cursor:", ";"], ({
41
41
  labelPosition
42
- }) => labelPosition === 'left' ? 'row-reverse' : 'row');
42
+ }) => labelPosition === 'left' ? 'row-reverse' : 'row', ({
43
+ isDisabled
44
+ }) => isDisabled ? 'not-allowed' : 'pointer');
43
45
  export const Box = styled.div.attrs({
44
46
  tabIndex: '0'
45
47
  }).withConfig({
package/Switch/types.d.ts CHANGED
@@ -12,9 +12,10 @@ export interface SwitchProps {
12
12
  /** Fires when the toggle is clicked; flip `enabled` in the handler. */
13
13
  onChange: () => void;
14
14
  }
15
- /** Internal style prop carrying the label side. */
15
+ /** Internal style props carrying the label side and the disabled state. */
16
16
  export interface AlignProps {
17
17
  labelPosition?: string;
18
+ isDisabled?: boolean;
18
19
  }
19
20
  /** Internal style prop carrying the toggle state. */
20
21
  export interface ComponentProps {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pushwoosh/dumb-components",
3
- "version": "1.1.223",
3
+ "version": "1.1.225",
4
4
  "description": "React components to build Pushwoosh products",
5
5
  "main": "index.js",
6
6
  "module": "index.js",