Skip to content

Commit 1efb554

Browse files
Subscribe component: Better destroy(), add init() (#1882)
1 parent f965011 commit 1efb554

File tree

10 files changed

+297
-73
lines changed

10 files changed

+297
-73
lines changed

.changeset/gold-shirts-yell.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@cloudfour/patterns': major
3+
---
4+
5+
Enhances the Subscribe component's ability to programmatically control its UI via
6+
`destroy()`/`init()` methods
7+
8+
- `initSubscribe()` was renamed to `createSubscribe()`
9+
- `init()` must now be explicitly called to initialize
10+
- `destroy()` hides CTA buttons UI, shows digests sign up form
11+
12+
```js
13+
// Initialize
14+
const subscribe = createSubscribe(document.querySelector('.js-subscribe'));
15+
subscribe.init();
16+
17+
// Remove all event listeners, show subscription form
18+
subscribe.destroy();
19+
```

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@
148148
"release": "npm run build && changeset publish"
149149
},
150150
"jest": {
151+
"testTimeout": 15000,
151152
"setupFilesAfterEnv": [
152153
"./jest.setup.js"
153154
],

src/components/subscribe/demo/demo.twig

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
{# This demo is used by tests, do not delete #}
2+
13
{% embed '@cloudfour/components/subscribe/subscribe.twig' with {
24
form_id: 'test',
35
weekly_digests_heading: 'Get Weekly Digests',
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{# This demo is used by tests, do not delete #}
2+
3+
{% embed '@cloudfour/objects/rhythm/rhythm.twig' only %}
4+
{% block content %}
5+
{% include '@cloudfour/components/subscribe/subscribe.twig' with {
6+
form_id: 'test',
7+
weekly_digests_heading: 'Get Weekly Digests',
8+
subscribe_heading: 'Never miss an article!',
9+
} only %}
10+
11+
<div>
12+
<a href="#">Testing link</a>
13+
</div>
14+
{% endblock %}
15+
{% endembed %}
16+
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{# This demo is used by storybook, do not delete #}
2+
3+
{% embed '@cloudfour/objects/rhythm/rhythm.twig' %}
4+
{% block content %}
5+
<button class="js-destroy-button">Destroy Subscribe component</button>
6+
<button class="js-init-button">Initialize Subscribe component</button>
7+
8+
{% include '@cloudfour/components/subscribe/subscribe.twig' %}
9+
{% endblock %}
10+
{% endembed %}
11+

src/components/subscribe/subscribe.scss

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@
3434

3535
///
3636
/// 1. Ensures the form background color matches the current theme
37+
/// 2. In the "destroyed" state, the CTA buttons are hidden so we
38+
/// don't need the form to be absolutely positioned.
3739
///
3840

3941
.c-subscribe__form {
@@ -43,7 +45,10 @@
4345
inline-size: 100%;
4446
inset-block-start: 0;
4547
inset-inline-start: 0;
46-
position: absolute;
48+
49+
.c-subscribe__controls-ui:not([hidden]) ~ & {
50+
position: absolute; // 2
51+
}
4752

4853
///
4954
/// 1. Allows for the visually hidden form to be keyboard accessible, will be

src/components/subscribe/subscribe.stories.mdx

Lines changed: 48 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,27 @@
11
import { ArgsTable, Canvas, Meta, Story } from '@storybook/addon-docs';
22
import { useEffect } from '@storybook/client-api';
33
import { makeTwigInclude } from '../../make-twig-include.js';
4-
import template from './subscribe.twig';
5-
import { initSubscribe } from './subscribe.ts';
4+
import template from './demo/storybook-demo.twig';
5+
import { createSubscribe } from './subscribe.ts';
66
// Helper function to initialize toggling button JS
77
const templateStory = (args) => {
88
useEffect(() => {
9-
const templateEls = [...document.querySelectorAll('.js-subscribe')].map(
10-
(templateEl) => initSubscribe(templateEl)
9+
// Initialize the component
10+
const subscribeComponent = createSubscribe(
11+
document.querySelector('.js-subscribe')
1112
);
13+
subscribeComponent.init();
14+
// Set up the demo destroy button
15+
const destroyBtn = document.querySelector('.js-destroy-button');
16+
destroyBtn.addEventListener('click', subscribeComponent.destroy);
17+
// Set up the demo init button
18+
const initBtn = document.querySelector('.js-init-button');
19+
initBtn.addEventListener('click', subscribeComponent.init);
1220
return () => {
13-
for (const templateEl of templateEls) templateEl.destroy();
21+
// Make sure to cleanup
22+
destroyBtn.removeEventListener('click', subscribeComponent.destroy);
23+
initBtn.removeEventListener('click', subscribeComponent.init);
24+
subscribeComponent.destroy();
1425
};
1526
});
1627
return template(args);
@@ -172,6 +183,14 @@ The Subscribe component provides the UI to subscribe to notifications and/or ema
172183

173184
This component embeds the [Button Swap component](/?path=/docs/components-button-swap--default-story) for the "Get Notifications" button.
174185

186+
## Destroy/Initialize
187+
188+
The Subscribe component allows you to programmatically disable/enable the JavaScript functionality via its `destroy()` and `init()` methods. When "destroyed," the following will occur:
189+
190+
- The "Get notifications"/"Get Weekly Digests" UI will be removed
191+
- The weekly digests form will be shown
192+
- All Subscribe component JavaScript will be removed
193+
175194
<Canvas>
176195
<Story
177196
name="Default"
@@ -213,7 +232,7 @@ The Subscribe component UX can be progressively enhanced using JavaScript. Enhan
213232
### Syntax
214233

215234
```js
216-
initSubscribe(subscribeEl);
235+
const subscribe = createSubscribe(subscribeEl);
217236
```
218237

219238
### Parameters
@@ -224,42 +243,55 @@ The Subscribe component `.js-subscribe` root element.
224243

225244
### Return value
226245

227-
An object with a `destroy` function that removes all event listeners added by this component.
246+
An object with two functions to programmatically control the Subscribe component's
247+
state:
228248

229249
```js
230250
{
251+
init: () => void
231252
destroy: () => void
232253
}
233254
```
234255

256+
| Method | Description |
257+
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
258+
| `subscribe.init()` | Initializes the Subscribe component to its default UI state, including all JavaScript functionality. |
259+
| `subscribe.destroy()` | Removes the "Get notifications"/"Get Weekly Digests" CTA UI leaving only the weekly digests sign up form. All JavaScript functionality is disabled. |
260+
235261
### Examples
236262

237263
#### Single Subscribe component on the page
238264

239265
```js
240266
// Initialize
241-
const subscribeEl = initSubscribe(document.querySelector('.js-subscribe'));
267+
const subscribe = createSubscribe(document.querySelector('.js-subscribe'));
268+
subscribe.init();
242269

243-
// Remove all event listeners
244-
subscribeEl.destroy();
270+
// Remove all event listeners, show subscription form
271+
subscribe.destroy();
245272
```
246273

247274
#### Multiple Subscribe components on the page
248275

249276
```js
250277
// Initialize
251-
const subscribeEls = [...document.querySelectorAll('.js-subscribe')].map(
252-
(subscribeEl) => initSubscribe(subscribeEl)
278+
const subscribeComponents = [...document.querySelectorAll('.js-subscribe')].map(
279+
(subscribeEl) => {
280+
const subscribe = createSubscribe(subscribeEl);
281+
subscribe.init();
282+
return subscribe;
283+
}
253284
);
254285

255-
// Remove all event listeners
256-
for (const subscribeEl of subscribeEls) {
257-
subscribeEl.destroy();
286+
// Remove all event listeners, show subscription form
287+
for (const subscribe of subscribeComponents) {
288+
subscribe.destroy();
258289
}
259290
```
260291

261292
### Note
262293

263294
You will need to initialize the "Get Notifications" button as well, see the
264295
[Button Swap component](/?path=/docs/components-button-swap--default-story#javascript)
265-
for initialization docs.
296+
for initialization docs. Alternatively, you can write custom JavaScript to swap
297+
the "Get Notifications" buttons if you prefer.

src/components/subscribe/subscribe.test.ts

Lines changed: 104 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ const componentMarkup = (args = {}) =>
1313
});
1414
// Helper to load the demo Twig template file
1515
const demoMarkup = loadTwigTemplate(path.join(__dirname, './demo/demo.twig'));
16+
const demoDestroyInitMarkup = loadTwigTemplate(
17+
path.join(__dirname, './demo/destroy-init.twig')
18+
);
1619

1720
/**
1821
* Helper function that checks the `clientHeight` and `clientWidth` of
@@ -50,13 +53,14 @@ const expectElementNotToBeVisuallyHidden = async (
5053
// Helper to initialize the component JS
5154
const initJS = (utils: PleasantestUtils) =>
5255
utils.runJS(`
53-
import { initSubscribe } from './subscribe'
54-
export default () => initSubscribe(
56+
import { createSubscribe } from './subscribe';
57+
const subscribe = createSubscribe(
5558
document.querySelector('.js-subscribe')
56-
)
59+
);
60+
subscribe.init();
5761
`);
5862

59-
describe('Subscription', () => {
63+
describe('Subscription component', () => {
6064
test(
6165
'should use semantic markup',
6266
withBrowser(async ({ utils, page }) => {
@@ -266,4 +270,100 @@ describe('Subscription', () => {
266270
});
267271
})
268272
);
273+
274+
test(
275+
'should destroy and initialize',
276+
withBrowser(async ({ utils, screen, waitFor, page }) => {
277+
await loadGlobalCSS(utils);
278+
await utils.loadCSS('./subscribe.scss');
279+
await utils.injectHTML(await demoDestroyInitMarkup());
280+
await utils.runJS(`
281+
import { createSubscribe } from './subscribe';
282+
// We attach it to the window object as a workaround to have access to
283+
// the subscribeComponent later in this test.
284+
window.subscribeComponent = createSubscribe(
285+
document.querySelector('.js-subscribe')
286+
);
287+
// Set it to the "destroyed" state
288+
window.subscribeComponent.destroy();
289+
`);
290+
291+
// The form should be active/visible when `destroy()` is called
292+
const form = await screen.getByRole('form', {
293+
name: 'Get Weekly Digests',
294+
});
295+
await expectElementNotToBeVisuallyHidden(form);
296+
297+
// Tab all the way to the "testing" link
298+
await page.keyboard.press('Tab'); // Email input
299+
await page.keyboard.press('Tab'); // Subscribe button
300+
await page.keyboard.press('Tab'); // "Testing" link
301+
302+
// The "testing" link should be in focus
303+
const testingLink = await screen.getByRole('link', {
304+
name: 'Testing link',
305+
});
306+
await expect(testingLink).toHaveFocus();
307+
308+
// After a timeout, the form should not visually hide
309+
await new Promise((resolve) => {
310+
setTimeout(resolve, 2000);
311+
});
312+
await expectElementNotToBeVisuallyHidden(form);
313+
314+
// Initialize the Subscribe component
315+
await utils.runJS(`
316+
window.subscribeComponent.init();
317+
`);
318+
319+
// The form should be visually hidden after `init()` is called
320+
await expectElementToBeVisuallyHidden(form);
321+
322+
// Navigate back into the form
323+
await page.keyboard.down('Shift'); // Navigate backwards
324+
await page.keyboard.press('Tab'); // Form Subscribe submit button
325+
await page.keyboard.up('Shift'); // Release Shift key
326+
327+
// The form should be visible when you move the focus back into the form
328+
await expectElementNotToBeVisuallyHidden(form);
329+
330+
// Navigate away from the form
331+
await page.keyboard.press('Tab'); // "Testing" link
332+
333+
// Immediately, the form should stay visible
334+
await expectElementNotToBeVisuallyHidden(form);
335+
336+
// After a timeout, the form eventually visually hides
337+
await waitFor(
338+
async () => {
339+
await expectElementToBeVisuallyHidden(form);
340+
},
341+
{
342+
timeout: 2000,
343+
interval: 1000,
344+
}
345+
);
346+
347+
// Cover a race condition where the timeout and destroy get called quickly
348+
// one after the other causing an unexpected UI state when the Subscribe
349+
// component timeout isn't cleared.
350+
// Set the focus in the form first (on the submit button)
351+
const formSubmitBtn = await screen.getByRole('button', {
352+
name: 'Subscribe',
353+
});
354+
formSubmitBtn.focus();
355+
// Then blur the focus
356+
await formSubmitBtn.evaluate((btn) => btn.blur());
357+
// And immediately run the `destroy()`
358+
await utils.runJS(`
359+
window.subscribeComponent.destroy();
360+
`);
361+
// Wait out the Subscribe component timeout
362+
await new Promise((resolve) => {
363+
setTimeout(resolve, 2000);
364+
});
365+
// The form should be visible
366+
await expectElementNotToBeVisuallyHidden(form);
367+
})
368+
);
269369
});

0 commit comments

Comments
 (0)