From 9b345898309ee5034a44a77188c5fa1165e6394a Mon Sep 17 00:00:00 2001 From: Paul Hebert Date: Thu, 22 Jul 2021 10:56:35 -0700 Subject: [PATCH] Integrate reply Comment Form into Comment pattern (#1423) The primary focus of this PR is integrating the Comment Form for replies into the Comment pattern. While I was working on this I made some changes to the Comment Form component and updated the code examples for all the Comment stories. --- .changeset/grumpy-dancers-jump.md | 5 + .../comment-form/comment-form.stories.mdx | 36 ++- src/components/comment-form/comment-form.twig | 31 +- src/components/comment/comment.scss | 14 + src/components/comment/comment.stories.mdx | 270 ++++++++++++++++-- src/components/comment/comment.test.ts | 70 +++++ src/components/comment/comment.ts | 52 ++++ src/components/comment/comment.twig | 50 +++- src/components/input/elastic-textarea.test.ts | 4 +- src/components/input/input.twig | 3 +- src/components/sky-nav/sky-nav.test.ts | 6 +- test-utils.ts | 5 + tsconfig.json | 2 +- 13 files changed, 495 insertions(+), 53 deletions(-) create mode 100644 .changeset/grumpy-dancers-jump.md create mode 100644 src/components/comment/comment.test.ts create mode 100644 src/components/comment/comment.ts diff --git a/.changeset/grumpy-dancers-jump.md b/.changeset/grumpy-dancers-jump.md new file mode 100644 index 000000000..4428f400c --- /dev/null +++ b/.changeset/grumpy-dancers-jump.md @@ -0,0 +1,5 @@ +--- +'@cloudfour/patterns': minor +--- + +Add reply forms to comments diff --git a/src/components/comment-form/comment-form.stories.mdx b/src/components/comment-form/comment-form.stories.mdx index fe435fd24..eea18cec1 100644 --- a/src/components/comment-form/comment-form.stories.mdx +++ b/src/components/comment-form/comment-form.stories.mdx @@ -24,7 +24,7 @@ const tyler = { # Comment Form -The form used to add a comment to an article. +The form used to add a comment to an article. Including the `heading_id` and `help_text_id` properties will provide assistive technologies additional context. -There is also a reply version of this component (`is_reply: true`), used to reply to an existing comment: +There is also a reply version of this component (`is_reply: true`), used to reply to an existing comment. (You'll also want to modify `heading_tag`, `heading_id`, `heading_class`, and `heading_text`. When using the [Comment pattern](/docs/components-comment--with-reply-button) this will be done automatically.) @@ -92,6 +112,12 @@ There is also a reply version of this component (`is_reply: true`), used to repl ## Template Properties - `class`: Append a class to the root element. +- `heading_id`: A unique ID for your heading element. This will be used as the accessible name for the form. +- `heading_tag`: The tag for your heading (defaults to `h2`). +- `heading_text`: The text for your heading. +- `heading_class`: An optional class for your heading. +- `heading_id`: A unique ID for your help text. This will be used as the accessible description for the reply textarea. +- `main_label`: The label for the primary message textarea. Defaults to `message` - `logged_in_user`: [user object](https://timber.github.io/docs/reference/timber-user/#properties) of the type: ```ts @@ -107,4 +133,4 @@ There is also a reply version of this component (`is_reply: true`), used to repl ## JavaScript -The Comment Form component includes the [Elastic Textarea component](http://localhost:6006/?path=/docs/components-input--elastic-textarea). See the Elastic Textarea component for JavaScript setup details. +The Comment Form component includes the [Elastic Textarea component](/docs/components-input--elastic-textarea). See the Elastic Textarea component for JavaScript setup details. diff --git a/src/components/comment-form/comment-form.twig b/src/components/comment-form/comment-form.twig index e903f965d..8836c1b84 100644 --- a/src/components/comment-form/comment-form.twig +++ b/src/components/comment-form/comment-form.twig @@ -1,15 +1,34 @@
{% block prompt %} -

Leave a Comment

-

Please be kind, courteous and constructive. You may use simple HTML or Markdown in your comments. All fields are required.

+ {% set _heading_tag = heading_tag|default('h2') %} + <{{_heading_tag}} + {% if heading_id %}id="{{ heading_id }}"{% endif %} + {% if heading_class %}class="{{ heading_class }}"{% endif %} + > + {{ heading_text|default("Leave a Comment") }} + +

+ Please be kind, courteous and constructive. + You may use simple HTML or + Markdown + in your comments. + All fields are required. +

{% endblock %} - {% embed '@cloudfour/objects/form-group/form-group.twig' with { label: 'Message' } only %} + {% embed '@cloudfour/objects/form-group/form-group.twig' with { label: main_label|default('Message') } %} {% block control %} - {% include '@cloudfour/components/input/input.twig' with { type: 'textarea', name: 'comment', required: true, class: 'c-input--elastic js-elastic-textarea' } only %} + {% include '@cloudfour/components/input/input.twig' with { + type: 'textarea', + name: 'comment', + required: true, + class: 'c-input--elastic js-elastic-textarea', + 'aria-describedby': help_text_id + } only %} {% endblock %} {% endembed %} {% if logged_in_user and log_out_url %} @@ -34,10 +53,10 @@ {% embed '@cloudfour/objects/button-group/button-group.twig' only %} {% block content %} {% include '@cloudfour/components/button/button.twig' with { label: 'Submit Reply' } only %} - {% include '@cloudfour/components/button/button.twig' with { label: 'Cancel', class: 'c-button--secondary' } only %} + {% include '@cloudfour/components/button/button.twig' with { label: 'Cancel', class: 'c-button--secondary js-cancel-reply' } only %} {% endblock %} {% endembed %} {% else %} {% include '@cloudfour/components/button/button.twig' with { label: 'Submit Comment' } only %} {% endif %} - +
diff --git a/src/components/comment/comment.scss b/src/components/comment/comment.scss index b811e2992..72435c453 100644 --- a/src/components/comment/comment.scss +++ b/src/components/comment/comment.scss @@ -23,6 +23,7 @@ /// Display a vertical line if the comment contains a reply thread to connect /// those comments visually with their parent. +.c-comment.is-replying::after, .c-comment--thread::after { border-radius: size.$border-radius-full; content: ''; @@ -42,6 +43,15 @@ } } +/// Comment reply forms are hidden until someone starts a reply. +.c-comment__reply-form { + display: none; +} + +.c-comment.is-replying .c-comment__reply-form { + display: block; +} + /// Named object for consistency with the Media component. .c-comment__object { grid-area: object; @@ -124,6 +134,10 @@ // Visually centers the child avatars with the indentation of the parent // comment. Without this, the replies feel nested too far to the right. margin-left: math.div(size.$square-avatar-small, -2); +} + +.c-comment__replies, +.c-comment__reply-form { // Since the replies are likely wrapped in a Rhythm object intended to inherit // the parent rhythm, we can use that custom property to inherit that same // spacing between the meta and replies. diff --git a/src/components/comment/comment.stories.mdx b/src/components/comment/comment.stories.mdx index 6d9c599f4..bd686c3bb 100644 --- a/src/components/comment/comment.stories.mdx +++ b/src/components/comment/comment.stories.mdx @@ -1,8 +1,42 @@ import { Story, Canvas, Meta } from '@storybook/addon-docs/blocks'; import { makeComment } from './demo/data.ts'; import template from './comment.twig'; +import { initCommentReplyForm } from './comment.ts'; +import { createElasticTextArea } from '../input/elastic-textarea.ts'; +import { useEffect } from '@storybook/client-api'; +import { makeTwigInclude } from '../../make-twig-include'; +const tyler = { + name: 'Tyler Sticka', + link: 'https://cloudfour.com/is/tyler', +}; +const initCommentReplyForms = () => { + const textAreaEl = document.querySelector('.js-elastic-textarea'); + const commentReplyFormEl = document.querySelector( + '.js-comment-with-reply-form' + ); + if (textAreaEl && commentReplyFormEl) { + const textareaInstance = createElasticTextArea(textAreaEl); + const commentReplyFormInstance = initCommentReplyForm(commentReplyFormEl); + return () => { + textareaInstance.destroy(); + commentReplyFormInstance.destroy(); + }; + } +}; - + # Comment @@ -12,7 +46,6 @@ Displays a single comment in a comment thread, optionally with replies. Multiple This component is still a work in progress. The following features are still in development: -- Integrating the comment reply form. - Finalizing this pattern's blocks and properties for theme integration. ## Single @@ -28,7 +61,28 @@ At minimum, a single comment consists of: This information may be passed to the component as a `comment` object matching the structure of a [Timber comment](https://timber.github.io/docs/reference/timber-comment/). - {template({ comment: makeComment() })} + + {(args) => { + useEffect(() => initCommentReplyForms()); + return template({ + comment: makeComment(), + allow_replies: args.allowReplies, + logged_in_user: args.isLoggedIn ? tyler : null, + log_out_url: '/logout', + }); + }} + ## Role badges @@ -36,14 +90,56 @@ This information may be passed to the component as a `comment` object matching t It is helpful for context within a discussion to know when a commentor is the original post author or a Cloud Four team member. The mechanics of this feature are still in development, but these stories show how these roles should appear using [the Badge component](/docs/components-badge--basic). - - {template({ comment: makeComment(), demo_post_author: true })} + + {(args) => { + useEffect(() => initCommentReplyForms()); + return template({ + comment: makeComment(), + allow_replies: args.allowReplies, + demo_post_author: true, + logged_in_user: args.isLoggedIn ? tyler : null, + log_out_url: '/logout', + }); + }} - - {template({ comment: makeComment(), demo_cloud_four_member: true })} + + {(args) => { + useEffect(() => initCommentReplyForms()); + return template({ + comment: makeComment(), + allow_replies: args.allowReplies, + demo_cloud_four_member: true, + logged_in_user: args.isLoggedIn ? tyler : null, + log_out_url: '/logout', + }); + }} @@ -52,8 +148,31 @@ It is helpful for context within a discussion to know when a commentor is the or If `comment.approved` is not `true`, an [Alert](/docs/components-alert--basic) will indicate that the comment is not yet approved. - - {template({ comment: makeComment({ approved: false }) })} + + {(args) => { + useEffect(() => initCommentReplyForms()); + return template({ + comment: makeComment({ + approved: false, + }), + allow_replies: args.allowReplies, + logged_in_user: args.isLoggedIn ? tyler : null, + log_out_url: '/logout', + }); + }} @@ -62,27 +181,72 @@ If `comment.approved` is not `true`, an [Alert](/docs/components-alert--basic) w Additionally, a `source` object may be passed to the template consisting of a `url` and `name`. This is helpful if integrating [webmentions](https://indieweb.org/Webmention) into comment threads. - - {template({ - comment: makeComment(), - source: { - url: 'https://twitter.com/smashingmag/status/1371521325236416516', - name: 'twitter.com', + + {(args) => { + useEffect(() => initCommentReplyForms()); + return template({ + comment: makeComment(), + source: { + url: 'https://twitter.com/smashingmag/status/1371521325236416516', + name: 'twitter.com', + }, + allow_replies: args.allowReplies, + logged_in_user: args.isLoggedIn ? tyler : null, + log_out_url: '/logout', + }); + }} ## With reply button -This feature is still a work in progress. +You can set `allow_replies` to `true` in order to add a reply button that toggles a [Comment Form](/docs/components-comment-form--reply). + +If the user is logged in, you should pass in `logged_in_user` and `log_out_url` properties that match the [template properties of Comment Forms](/docs/components-comment-form--reply#template-properties). + +`allow_replies` should only be set to true for first-level comments, as we do not allow multiple levels of threading in comment chains. - - {template({ - comment: makeComment(), - demo_control: true, - })} + + {(args) => { + useEffect(() => initCommentReplyForms()); + return template({ + comment: makeComment(), + allow_replies: args.allowReplies, + logged_in_user: args.isLoggedIn ? tyler : null, + log_out_url: '/logout', + }); + }} @@ -96,7 +260,65 @@ While it is theoretically possible to infinitely nest `children`, it's recommend - The heading levels for comment threads increment up to a maximum of six (since HTML only provides six heading levels). This means that levels beyond that will be harder to traverse for user agents navigating via the document outline. - - {template({ comment: makeComment({ replies: 2 }) })} + + {(args) => { + useEffect(() => initCommentReplyForms()); + return template({ + comment: makeComment({ replies: 2 }), + allow_replies: args.allowReplies, + logged_in_user: args.isLoggedIn ? tyler : null, + log_out_url: '/logout', + }); + }} + +## Template Properties + +Note: These template properties are not finalized, and may change in the future. + +- `comment`: an object matching the structure of a [Timber comment](https://timber.github.io/docs/reference/timber-comment/) +- `allow_replies`: A boolean property that controls whether to show a reply button and form +- `logged_in_user`: [user object](https://timber.github.io/docs/reference/timber-user/#properties) of the type: + ```ts + { + name: string | () => string; + link: string | () => string; + } + ``` +- `log_out_url`: URL used for log out link. +- `source`: An optional object containing a `url` and `name` for the comment source. + +## JavaScript Instructions + +You can run `initCommentReplyForm` to initialize a comment's reply form. Comments with reply forms will have the `js-comment-with-reply-form` class which you can use to query and initialize them. + +(This is not necessary for pages with comments disabled, like older posts.) + +```js +import { initCommentReplyForm } from 'path/to/comment.js'; + +const comment = document.querySelector('.js-comment-with-reply-form'); +const replyformInstance = initCommentReplyForm(comment); +``` + +It also returns an object containing a `destroy()` method, if you need to remove the event listener. + +```js +comment.destroy(); +``` + +The Comment reply form also includes the [Elastic Textarea component](/docs/components-input--elastic-textarea). See the Elastic Textarea component for JavaScript setup details. diff --git a/src/components/comment/comment.test.ts b/src/components/comment/comment.test.ts new file mode 100644 index 000000000..5651e6cf6 --- /dev/null +++ b/src/components/comment/comment.test.ts @@ -0,0 +1,70 @@ +import path from 'path'; +import type { PleasantestUtils } from 'pleasantest'; +import { withBrowser } from 'pleasantest'; +import { loadTwigTemplate, loadGlobalCSS } from '../../../test-utils'; + +const commentMarkup = loadTwigTemplate(path.join(__dirname, 'comment.twig')); +const initCommentsJs = (utils: PleasantestUtils) => + utils.runJS(` + import { initCommentReplyForm } from './comment'; + + initCommentReplyForm(document.querySelector('.js-comment-with-reply-form')); + `); + +test( + 'reply form can be opened and closed', + withBrowser(async ({ utils, screen, user }) => { + await utils.injectHTML( + await commentMarkup({ + comment: { + ID: 1, + link: '#comment-1', + date: new Date(), + avatar: '', + author: { + name: 'Test', + }, + comment_content: 'Test', + approved: true, + }, + allow_replies: true, + }) + ); + + await loadGlobalCSS(utils); + + await initCommentsJs(utils); + + const form = await screen.getByRole('form', { hidden: true }); + const replyButton = await screen.getByRole('button', { + name: /reply/i, + }); + + // Initial state: reply form is hidden + await expect(form).not.toBeVisible(); + + await user.click(replyButton); + + // Updated state: reply form is no longer hidden + await expect(form).toBeVisible(); + // Reply button is hidden + await expect(replyButton).not.toBeVisible(); + // The first input or textarea should be focused. + // (In practice this will always be a textarea, but if the form is + // changed to lead with an input in the future, and that input is selected, + // this test should still pass.) + const inputs = await screen.getAllByRole(/textbox|input/); + await expect(inputs[0]).toHaveFocus(); + + // Click the cancel button to get back to our initial state + const cancelButton = await screen.getByRole('button', { + name: /cancel/i, + }); + await user.click(cancelButton); + + // Back to our initial state, though now the reply button is focused. + await expect(form).not.toBeVisible(); + await expect(replyButton).toBeVisible(); + await expect(replyButton).toHaveFocus(); + }) +); diff --git a/src/components/comment/comment.ts b/src/components/comment/comment.ts new file mode 100644 index 000000000..825ec4c3c --- /dev/null +++ b/src/components/comment/comment.ts @@ -0,0 +1,52 @@ +export const initCommentReplyForm = (comment: HTMLElement) => { + const replyButton = comment.querySelector( + '.js-comment-reply-button' + ); + const replyForm = comment.querySelector( + '.js-comment-reply-form' + ); + // The cancel button is inside the Comment Form, so we need to reach inside + // that component to grab the button. This feels a little awkward since + // we're directly manipulating a child component, but this is a lot simpler + // than other potential solutions. Other options included: + // - Having the Comment Form emit a `cancel` event + // - Having the Comment Form have a block that you could pass a button into + const cancelButton = + comment.querySelector('.js-cancel-reply'); + + // If we're missing form elements we shouldn't proceed + if (!replyButton || !replyForm || !cancelButton) return { destroy: () => {} }; + + const show = () => { + comment.classList.add('is-replying'); + replyButton.setAttribute('hidden', ''); + const firstInput = replyForm.querySelector< + HTMLTextAreaElement | HTMLInputElement + >('textarea, input'); + + // Without this timeout, VoiceOver does not properly focus the first input + // (though it works outside of VoiceOver). + // With this timeout, iOS doesn't show the keyboard, since iOS will only + // show the keyboard in direct response to a user action. + // By doing it with and without a timeout we can make both work. + firstInput?.focus(); + setTimeout(() => firstInput?.focus(), 0); + }; + + const hide = () => { + comment.classList.remove('is-replying'); + replyButton.removeAttribute('hidden'); + // Similar to above, we use a timeout to force the focus in VoiceOver. + setTimeout(() => replyButton.focus(), 0); + }; + + replyButton.addEventListener('click', show); + cancelButton.addEventListener('click', hide); + + const destroy = () => { + replyButton.removeEventListener('click', show); + cancelButton.removeEventListener('click', hide); + }; + + return { destroy }; +}; diff --git a/src/components/comment/comment.twig b/src/components/comment/comment.twig index 16620f4c7..a8f6dd51f 100644 --- a/src/components/comment/comment.twig +++ b/src/components/comment/comment.twig @@ -1,6 +1,18 @@ {% set _heading_depth = min(heading_depth|default(3), 6) %} -
+{% set _comment_modifiers = '' %} +{% if comment.children is not empty %} + {% set _comment_modifiers = _comment_modifiers ~ ' c-comment--thread' %} +{% endif %} + +{% if allow_replies %} + {% set _comment_modifiers = _comment_modifiers ~ ' js-comment-with-reply-form' %} +{% endif %} + +
{{comment.author.name}} @@ -82,25 +94,41 @@ via {{source.name}} {% endif %} - {# - TODO: Replace `demo_control` with a more meaningful block or properties - once we have a better idea of how we want to implement the reply - functionality. For now, this property exists to allow us to test the - presentation of the control. - #} - {% if demo_control %} + + {% if allow_replies %}
+ {# + Since this button shows a form I considered adding `aria-expanded`. + I decided against this because the button only shows the form, + it doesn't toggle it. A separate button does that. + #} {% include '@cloudfour/components/button/button.twig' with { type: 'button', - class: 'c-button--tertiary', + class: 'c-button--tertiary js-comment-reply-button', content_start_icon: 'speech-balloon', label: 'Reply' } only %}
{% endif %} + {% set _section_heading_depth = min(_heading_depth + 1, 6) %} + + {% if allow_replies %} +
+ {% include '@cloudfour/components/comment-form/comment-form.twig' with { + is_reply: true, + logged_in_user: logged_in_user, + log_out_url: log_out_url, + heading_id: "reply-to-comment-#{comment.ID}", + help_text_id: "reply-to-comment-#{comment.ID}-help-text", + heading_tag: "h#{_section_heading_depth}", + heading_text: "Reply to #{comment.author.name}", + heading_class: 'u-hidden-visually', + main_label: 'Reply', + } only %} +
+ {% endif %} {% if comment.children is not empty %} - {% set _section_heading_depth = min(_heading_depth + 1, 6) %} {% set _child_heading_depth = min(_section_heading_depth + 1, 6) %} Replies to {{comment.author.name}} @@ -112,7 +140,7 @@ {% for child in comment.children %} {% include '@cloudfour/components/comment/comment.twig' with { comment: child, - heading_depth: _child_heading_depth + heading_depth: _child_heading_depth, } only %} {% endfor %} {% endblock %} diff --git a/src/components/input/elastic-textarea.test.ts b/src/components/input/elastic-textarea.test.ts index 77a44f028..01b11a83b 100644 --- a/src/components/input/elastic-textarea.test.ts +++ b/src/components/input/elastic-textarea.test.ts @@ -1,7 +1,7 @@ import path from 'path'; import type { ElementHandle, PleasantestUtils } from 'pleasantest'; import { withBrowser } from 'pleasantest'; -import { loadTwigTemplate } from '../../../test-utils'; +import { loadTwigTemplate, loadGlobalCSS } from '../../../test-utils'; const textInputHTML = loadTwigTemplate(path.join(__dirname, './input.twig')); const initTextareaJS = (utils: PleasantestUtils, textarea: ElementHandle) => @@ -22,7 +22,7 @@ test( type: 'textarea', }) ); - await utils.loadCSS('../../../dist/standalone.css'); + await loadGlobalCSS(utils); const textarea = (await screen.getByRole( 'textbox' )) as ElementHandle; diff --git a/src/components/input/input.twig b/src/components/input/input.twig index b4a9a22bc..624cac0af 100644 --- a/src/components/input/input.twig +++ b/src/components/input/input.twig @@ -14,7 +14,8 @@ 'min', 'max', 'rows', - 'step' + 'step', + 'aria-describedby' ] %} {% set attribute_value = attribute(_context, attribute_name) %} {% if attribute_value %}{{ attribute_name }}="{{ attribute_value }}"{% endif %} diff --git a/src/components/sky-nav/sky-nav.test.ts b/src/components/sky-nav/sky-nav.test.ts index aad462627..35ac982cf 100644 --- a/src/components/sky-nav/sky-nav.test.ts +++ b/src/components/sky-nav/sky-nav.test.ts @@ -1,7 +1,7 @@ import path from 'path'; import type { ElementHandle, PleasantestUtils } from 'pleasantest'; import { devices, withBrowser } from 'pleasantest'; -import { loadTwigTemplate } from '../../../test-utils'; +import { loadTwigTemplate, loadGlobalCSS } from '../../../test-utils'; import menu from './demo/menu.json'; const iPhone = devices['iPhone 6']; @@ -19,7 +19,7 @@ test( 'can be opened on small screens', withBrowser({ device: iPhone }, async ({ utils, screen, user }) => { await utils.injectHTML(await skyNavMarkup({ includeMainDemo: true, menu })); - await utils.loadCSS('../../../dist/standalone.css'); + await loadGlobalCSS(utils); const navButton = await screen.getByRole('button', { name: /toggle main menu/i, }); @@ -41,7 +41,7 @@ test( 'is expanded on large screens', withBrowser(async ({ utils, screen }) => { await utils.injectHTML(await skyNavMarkup({ includeMainDemo: true, menu })); - await utils.loadCSS('../../../dist/standalone.css'); + await loadGlobalCSS(utils); // Hidden: true allows searching hidden elements (button is hidden on large screens) const navButton = await screen.getByRole('button', { hidden: true }); await initSkyNavJS(utils, navButton); diff --git a/test-utils.ts b/test-utils.ts index e2381ed73..1d50aa86b 100644 --- a/test-utils.ts +++ b/test-utils.ts @@ -1,5 +1,6 @@ import path from 'path'; import { TwingEnvironment, TwingLoaderFilesystem } from 'twing'; +import type { PleasantestUtils } from 'pleasantest'; const loader = new TwingLoaderFilesystem(process.cwd()); loader.addPath(path.join(process.cwd(), 'src'), 'cloudfour'); @@ -17,3 +18,7 @@ export const loadTwigTemplate = (templatePath: string) => { // eslint-disable-next-line @cloudfour/typescript-eslint/await-thenable return async (data: any) => (await templatePromise).render(data); }; + +export const loadGlobalCSS = async (utils: PleasantestUtils) => { + await utils.loadCSS('../../../dist/standalone.css'); +}; diff --git a/tsconfig.json b/tsconfig.json index b1f27b1ea..bc2efd9b8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es5", + "target": "esnext", "module": "esnext", "strict": true, "noUnusedLocals": true,