improve keyboard accessibility of modals 12427 10610 (#12511)

* adds focusTrap wrapper to preact Modal component

* add view specific code for focus trap in admin add nav link

* add script to return a focustrap toggle, use in add nav link modal partial

* add trap to edit nav link modal

* add handlers for sign up modal

* update modal controller for admin section, update nav link modals to use

* update other admin modals with new data values for trap

* remove unneeded erb script file

* remove unneeded target

* refactor to remove extra unneeded param

* remove duplicate code, store getFocusTrapToggle in window

* trap focus in comment and bookmark showModal instances for not logged in user

* remove need for activator id

* clean up id refs no longer needed

* remove custom code and re-use focsu-trap lib

* update storybook docs

* update default export in focusTrap

* prevent close button click triggering a modal toggle twice

* ensure if user navigates from a modal the trap is deactivated

* add jsdoc comments and add dynamic import

* ensure admin controller modal traps are cleaned up on disconnect

* update sign up modal to use crayons

* update modal controller and admin nav links modals to use preact modal

* update profile fields modals for new controller

* tweak styling of sign up and admin modals to match previous

* update listings modal to use crayons modal, adapt focus trap to work with click outside

* memoize deactivate callback to ensure modal can be presented on first page load

* add missed focustrap changes

* fix focus trap issues in onboarding flow

* refactor onboarding focus trap, remove getFocusTrapToggle

* tweaks for styling and article modal toggle

* add click outside tests to modal

* add cypress tests for the login modal

* update liquid tag tests affected by change

* refactors to address review comments

* fix issue with login modal presented twice on comment add

* change ids to selectors in admin modals

* small pr comment refactors

* add listings e2e tests

* add nav link modal tests

* fix issue with help modal

* tweak to fix ui bug from merge

* remove context from showLoginModal

* rename toggleModal

* rename state property for clarity

Co-authored-by: Nick Taylor <nick@dev.to>
This commit is contained in:
Suzanne Aitchison 2021-02-24 16:01:10 +00:00 committed by GitHub
parent 2bba2a6ee4
commit 5bed8f56d4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 694 additions and 479 deletions

View file

@ -1,4 +1,4 @@
/* global showModal */
/* global showLoginModal */
function initializeAllFollowButts() {
var followButts = document.getElementsByClassName('follow-action-button');
@ -88,7 +88,7 @@ function addModalEventListener(butt) {
assignState(butt, 'login');
butt.onclick = function (e) {
e.preventDefault();
showModal('follow-button');
showLoginModal();
return;
};
}
@ -158,7 +158,7 @@ function handleOptimisticButtRender(butt) {
if (butt.dataset.verb === 'self') {
window.location.href = '/settings';
} else if (butt.dataset.verb === 'login') {
showModal('follow-button');
showLoginModal();
} else {
// Handles actual following of tags/users
try {

View file

@ -1,4 +1,4 @@
/* global sendHapticMessage, showModal */
/* global sendHapticMessage, showLoginModal */
// Set reaction count to correct number
function setReactionCount(reactionName, newCount) {
@ -63,7 +63,7 @@ function reactToArticle(articleId, reaction) {
var userStatus = document.body.getAttribute('data-user-status');
sendHapticMessage('medium');
if (userStatus === 'logged-out') {
showModal('react-to-article');
showLoginModal();
return;
}
toggleReaction();

View file

@ -100,7 +100,7 @@ function initializeCommentsPage() {
sendHapticMessage('medium');
var userStatus = document.body.getAttribute('data-user-status');
if (userStatus === 'logged-out') {
showModal('react-to-comment');
showLoginModal();
return;
}
@ -155,7 +155,7 @@ function initializeCommentsPage() {
} else {
var userStatus = document.body.getAttribute('data-user-status');
if (userStatus == 'logged-out') {
showModal('reply-to-comment');
showLoginModal();
return;
}
var parentId = event.target.closest('.comment').dataset.commentId;
@ -302,12 +302,8 @@ function handleFocus(event) {
var area = event.target;
if (userStatus == 'logged-out') {
event.preventDefault();
showModal('reply-to-comment');
showLoginModal();
area.blur();
setTimeout(function () {
area.blur();
showModal('reply-to-comment');
}, 100);
} else {
var form = event.target.closest(".comment-form");
form.classList.add("comment-form--initiated");

View file

@ -50,7 +50,7 @@ function reactToReadingListButtonClick(event) {
sendHapticMessage('medium');
userStatus = document.body.getAttribute('data-user-status');
if (userStatus === 'logged-out') {
showModal('add-to-readinglist-from-index');
showLoginModal();
return;
}
button = properButtonFromEvent(event);

View file

@ -0,0 +1,7 @@
function showLoginModal() {
window.showModal({
title: 'Log in to continue',
contentSelector: '#global-signup-modal',
overlay: true,
});
}

View file

@ -1,16 +0,0 @@
function initSignupModal() {
if (document.getElementById('global-signup-modal')) {
document.getElementsByClassName(
'authentication-modal__close-btn',
)[0].onclick = () => {
document.getElementById('global-signup-modal').classList.add('hidden');
document.body.classList.remove('modal-open');
};
}
}
function showModal(context) {
document.getElementById('global-signup-modal').classList.remove('hidden');
document.body.classList.add('modal-open');
initSignupModal();
}

View file

@ -43,6 +43,10 @@
top: 0;
bottom: 0;
pointer-events: auto;
&.background-clickable {
pointer-events: none;
}
}
&__box {

View file

@ -91,3 +91,9 @@
display: none;
}
}
.listings-modal {
.crayons-modal__box__body {
padding: 0;
}
}

View file

@ -1,40 +1,11 @@
@import '../config/import';
.authentication-modal {
&__box {
position: relative;
margin-left: var(--su-4);
margin-right: var(--su-4);
height: auto;
width: auto;
@include generate-box(
$level: 2,
$bg: var(--base-inverted),
$border: var(--base),
$color: var(--card-color)
);
@media (min-width: $breakpoint-s) {
margin: inherit;
}
}
&__close-btn {
position: absolute;
top: var(--su-3);
right: var(--su-3);
}
&__container {
display: grid;
grid-gap: var(--su-2);
padding: var(--su-4);
@media (min-width: $breakpoint-s) {
padding: var(--su-8) var(--su-8) var(--su-6);
}
grid-gap: var(--su-4);
}
&__image-container {
width: var(--su-8);
height: var(--su-8);
@ -58,16 +29,6 @@
grid-gap: var(--su-1);
}
&__title {
color: var(--card-color);
font-size: var(--fs-2xl);
line-height: var(--lh-tight);
@media (min-width: $breakpoint-s) {
font-size: var(--fs-3xl);
}
}
&__description {
color: var(--card-color-secondary);
}
@ -83,16 +44,18 @@
width: 100%;
margin: 0 auto;
justify-self: center;
padding: 0 var(--su-8) var(--su-7);
padding: 0 var(--su-8) var(--su-4);
}
}
&__footer {
border-top: 2px solid var(--base-100);
padding: var(--su-2) var(--su-4);
padding: var(--su-2) var(--su-4) 0 var(--su-4);
margin: 0 calc(-1 * var(--modal-body-padding)) 0
calc(-1 * var(--modal-body-padding));
@media (min-width: $breakpoint-s) {
padding: var(--su-5) var(--su-8);
padding: var(--su-5) var(--su-8) 0 var(--su-8);
}
}

View file

@ -2,12 +2,37 @@ import { Controller } from 'stimulus';
// eslint-disable-next-line no-restricted-syntax
export default class ModalController extends Controller {
static classes = ['hidden'];
static targets = ['toggle'];
static values = {
rootSelector: String,
contentSelector: String,
title: String,
size: String,
};
toggleModal() {
if (this.hasToggleTarget) {
this.toggleTarget.classList.toggle(this.hiddenClass);
}
async toggleModal() {
const [{ Modal }, { render, h }] = await Promise.all([
import('@crayons/Modal'),
import('preact'),
]);
const modalRoot = document.querySelector(this.rootSelectorValue);
render(
<Modal
title={this.titleValue}
onClose={() => {
render(null, modalRoot);
}}
size={this.sizeValue}
>
<div
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: document.querySelector(this.contentSelectorValue).innerHTML,
}}
/>
</Modal>,
modalRoot,
);
}
}

View file

@ -98,6 +98,7 @@ export class ArticleForm extends Component {
siteLogo,
helpFor: null,
helpPosition: null,
isModalOpen: false,
...previousContentState,
};
}
@ -240,7 +241,7 @@ export class ArticleForm extends Component {
edited: false,
helpFor: null,
helpPosition: 0,
showModal: false,
isModalOpen: false,
});
};
@ -264,10 +265,10 @@ export class ArticleForm extends Component {
});
};
toggleModal = () => {
showModal = (isModalOpen) => {
if (this.state.edited) {
this.setState({
showModal: !this.state.showModal,
isModalOpen,
});
} else {
// If the user has not edited the body we send them home
@ -319,7 +320,7 @@ export class ArticleForm extends Component {
organizationId={organizationId}
onToggle={this.handleOrgIdChange}
siteLogo={siteLogo}
displayModal={this.toggleModal}
displayModal={() => this.showModal(true)}
/>
{previewShowing ? (
@ -351,11 +352,11 @@ export class ArticleForm extends Component {
helpPosition={helpPosition}
version={version}
/>
{this.state.showModal && (
{this.state.isModalOpen && (
<Modal
size="s"
title="You have unsaved changes"
onClose={this.toggleModal}
onClose={() => this.showModal(false)}
>
<p>
You've made changes to your post. Do you want to navigate to leave
@ -365,7 +366,7 @@ export class ArticleForm extends Component {
<Button className="mr-2" variant="danger" url="/" tagName="a">
Yes, leave the page
</Button>
<Button variant="secondary" onClick={this.toggleModal}>
<Button variant="secondary" onClick={() => this.showModal(false)}>
No, keep editing
</Button>
</div>

View file

@ -15,27 +15,14 @@ export class Help extends Component {
frontmatterHelpHTML:
document.getElementById('editor-frontmatter-help') &&
document.getElementById('editor-frontmatter-help').innerHTML,
liquidShowing: false,
markdownShowing: false,
frontmatterShowing: false,
};
}
setCommonProps = ({
liquidShowing = false,
markdownShowing = false,
frontmatterShowing = false,
}) => {
return {
liquidShowing,
markdownShowing,
frontmatterShowing,
};
};
toggleModal = (varShowing) => () => {
this.setState((prevState) => ({
...this.setCommonProps({
[varShowing]: !prevState[varShowing],
}),
}));
showModal = (sectionShowing, isOpen) => () => {
this.setState({ [sectionShowing]: isOpen });
};
renderArticleFormTitleHelp = () => {
@ -94,7 +81,10 @@ export class Help extends Component {
className="crayons-card crayons-card--secondary p-4 mb-6"
>
You are currently using the basic markdown editor that uses{' '}
<a href="#frontmatter" onClick={this.toggleModal('frontmatterShowing')}>
<a
href="#frontmatter"
onClick={this.showModal('frontmatterShowing', true)}
>
Jekyll front matter
</a>
. You can also use the <em>rich+markdown</em> editor you can find in{' '}
@ -130,7 +120,10 @@ export class Help extends Component {
<ul className="list-disc pl-6 color-base-70">
<li>
Use{' '}
<a href="#markdown" onClick={this.toggleModal('markdownShowing')}>
<a
href="#markdown"
onClick={this.showModal('markdownShowing', true)}
>
Markdown
</a>{' '}
to write and format posts.
@ -229,7 +222,7 @@ export class Help extends Component {
</li>
<li>
You can use{' '}
<a href="#liquid" onClick={this.toggleModal('liquidShowing')}>
<a href="#liquid" onClick={this.showModal('liquidShowing', true)}>
Liquid tags
</a>{' '}
to add rich content such as Tweets, YouTube videos, etc.
@ -285,21 +278,21 @@ export class Help extends Component {
{liquidShowing &&
this.renderModal(
this.toggleModal('liquidShowing'),
this.showModal('liquidShowing', false),
'🌊 Liquid Tags',
liquidHelpHTML,
)}
{markdownShowing &&
this.renderModal(
this.toggleModal('markdownShowing'),
this.showModal('markdownShowing', false),
'✍️ Markdown',
markdownHelpHTML,
)}
{frontmatterShowing &&
this.renderModal(
this.toggleModal('frontmatterShowing'),
this.showModal('frontmatterShowing', false),
'Jekyll Front Matter',
frontmatterHelpHTML,
)}

View file

@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import { useListNavigation } from '../shared/components/useListNavigation';
import { useKeyboardShortcuts } from '../shared/components/useKeyboardShortcuts';
/* global userData sendHapticMessage showModal buttonFormData renderNewSidebarCount */
/* global userData sendHapticMessage showLoginModal buttonFormData renderNewSidebarCount */
export const Feed = ({ timeFrame, renderFeed }) => {
const { reading_list_ids = [] } = userData(); // eslint-disable-line camelcase
@ -102,7 +102,7 @@ export const Feed = ({ timeFrame, renderFeed }) => {
sendHapticMessage('medium');
if (userStatus === 'logged-out') {
showModal('add-to-readinglist-from-index');
showLoginModal();
return;
}

View file

@ -1,6 +1,6 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
import { KeyboardShortcuts } from '../../shared/components/useKeyboardShortcuts';
import { FocusTrap } from '../../shared/components/focusTrap';
import { defaultChildrenPropTypes } from '../../common-prop-types';
import { Button } from '@crayons';
@ -40,44 +40,50 @@ export const Modal = ({
title,
overlay,
onClose,
closeOnClickOutside = false,
}) => {
const shortcuts = {
escape: onClose,
};
return (
<div
data-testid="modal-container"
className={`crayons-modal${getAdditionalClassNames({
size,
className,
})}`}
<FocusTrap
onDeactivate={onClose}
clickOutsideDeactivates={closeOnClickOutside}
>
<div
role="dialog"
aria-modal="true"
aria-label="modal"
className="crayons-modal__box"
data-testid="modal-container"
className={`crayons-modal${getAdditionalClassNames({
size,
className,
})}`}
>
{title && (
<div className="crayons-modal__box__header">
<h2>{title}</h2>
<Button
icon={CloseIcon}
variant="ghost"
contentType="icon"
aria-label="Close"
onClick={onClose}
/>
<KeyboardShortcuts shortcuts={shortcuts} />
</div>
<div
role="dialog"
aria-modal="true"
aria-label="modal"
className="crayons-modal__box"
>
{title && (
<div className="crayons-modal__box__header">
<h2>{title}</h2>
<Button
icon={CloseIcon}
variant="ghost"
contentType="icon"
aria-label="Close"
onClick={onClose}
/>
</div>
)}
<div className="crayons-modal__box__body">{children}</div>
</div>
{overlay && (
<div
data-testid="modal-overlay"
className={`crayons-modal__overlay ${
closeOnClickOutside ? 'background-clickable' : ''
}`}
/>
)}
<div className="crayons-modal__box__body">{children}</div>
</div>
{overlay && (
<div data-testid="modal-overlay" className="crayons-modal__overlay" />
)}
</div>
</FocusTrap>
);
};

View file

@ -1,6 +1,6 @@
import { h } from 'preact';
import { useState } from 'preact/hooks';
import { withKnobs, text, boolean, select } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import notes from './modals.md';
import { Modal, Button } from '@crayons';
import '../../storybook-utilities/designSystem.scss';
@ -11,37 +11,38 @@ export default {
parameters: { notes },
};
const commonProps = {
onClose: action('close fired'),
};
export const Default = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
export const Default = () => (
<div>
{/* TODO: add a trigger that will change component state... */}
<Button>Open modal</Button>
<Modal
size={select(
'size',
{
Small: 's',
Medium: 'm',
Default: 'default',
},
'default',
return (
<div>
<Button onClick={() => setIsModalOpen(true)}>Open modal</Button>
{isModalOpen && (
<Modal
onClose={() => setIsModalOpen(false)}
size={select(
'size',
{
Small: 's',
Medium: 'm',
Default: 'default',
},
'default',
)}
className={text('className')}
title={text('title', 'This is my Modal title')}
overlay={boolean('overlay', true)}
>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse
odio est, ultricies vel euismod ut, fringilla quis tellus. Sed at
dui mi. Fusce cursus nibh lectus, vitae lobortis orci volutpat quis.{' '}
</p>
</Modal>
)}
className={text('className')}
title={text('title', 'This is my Modal title')}
overlay={boolean('overlay', true)}
{...commonProps}
>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse
odio est, ultricies vel euismod ut, fringilla quis tellus. Sed at dui
mi. Fusce cursus nibh lectus, vitae lobortis orci volutpat quis.{' '}
</p>
</Modal>
</div>
);
</div>
);
};
Default.story = {
name: 'Modals',

View file

@ -4,3 +4,15 @@ Modals should be positioned centered in relation to entire viewport. So relation
to its trigger doesnt really matter.
Use your best judgements when choosing the right size.
### Modal accessibility
The Preact Modal component utilises the `focus-trap` library to ensure that:
- When the modal is opened, focus is transferred to the first interactive item
in that modal
- When the modal is closed, focus is transferred back to the button that
activated the modal
- While the modal is open, focus is trapped inside so that when a user presses
the Tab key, interactive items behind the modal are not focused
- When the modal is open, pressing the Escape key will close it

View file

@ -1,6 +1,8 @@
import { h } from 'preact';
import { axe } from 'jest-axe';
import { render } from '@testing-library/preact';
import '@testing-library/jest-dom';
import { render, waitFor } from '@testing-library/preact';
import userEvent from '@testing-library/user-event';
import { Modal } from '../Modal';
it('should have no a11y violations', async () => {
@ -12,6 +14,26 @@ it('should have no a11y violations', async () => {
expect(results).toHaveNoViolations();
});
it('should trap focus', async () => {
const { getByText, getByLabelText } = render(
<div>
<button>Outside modal button</button>
<Modal title="This is a modal title">
<button>Modal content button</button>
</Modal>
</div>,
);
const closeButton = getByLabelText('Close', { selector: 'button' });
await waitFor(() => expect(closeButton).toHaveFocus());
userEvent.tab();
expect(getByText('Modal content button')).toHaveFocus();
userEvent.tab();
expect(closeButton).toHaveFocus();
});
it('should close when the close button is clicked', async () => {
const onClose = jest.fn();
const { getByLabelText } = render(
@ -27,6 +49,52 @@ it('should close when the close button is clicked', async () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
it('should close when Escape is pressed', () => {
const onClose = jest.fn();
const { container } = render(
<Modal title="This is a modal title" onClose={onClose}>
This is the modal body content
</Modal>,
);
userEvent.type(container, '{esc}');
expect(onClose).toHaveBeenCalledTimes(1);
});
it("shouldn't close on outside click by default", () => {
const onClose = jest.fn();
const { getByText } = render(
<div>
<p>Outside content</p>
<Modal title="This is a modal title" onClose={onClose}>
This is the modal body content
</Modal>
</div>,
);
userEvent.click(getByText('Outside content'));
expect(onClose).not.toHaveBeenCalled();
});
it('should close on click outside, if enabled', () => {
const onClose = jest.fn();
const { getByText } = render(
<div>
<p>Outside content</p>
<Modal
title="This is a modal title"
onClose={onClose}
closeOnClickOutside
>
This is the modal body content
</Modal>
</div>,
);
userEvent.click(getByText('Outside content'));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('should render with additional class names', async () => {
const { getByTestId } = render(
<Modal

View file

@ -2,7 +2,7 @@ import { h } from 'preact';
import PropTypes from 'prop-types';
import { SingleListing } from '../singleListing/SingleListing';
import { MessageModal } from './MessageModal';
import { Button } from '@crayons';
import { Modal as CrayonsModal } from '@crayons';
export const Modal = ({
currentUserId,
@ -17,41 +17,13 @@ export const Modal = ({
}) => {
const shouldRenderMessageModal = listing && listing.contact_via_connect;
const Icon = () => (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
className="crayons-icon pointer-events-none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M12 10.586l4.95-4.95 1.414 1.414-4.95 4.95 4.95 4.95-1.414 1.414-4.95-4.95-4.95 4.95-1.414-1.414 4.95-4.95-4.95-4.95L7.05 5.636l4.95 4.95z" />
</svg>
);
// TODO: Why are we not using the crayons modal component and instead recreating it here?
return (
<div
role="dialog"
aria-modal="true"
aria-label="listing modal"
id="single-listing-container__inner"
className="single-listing-container__inner crayons-modal__box"
>
<div className="crayons-modal__box__header flex s:hidden">
<Button
type="button"
id="close-listing-modal"
tagName="button"
contentType="icon"
variant="ghost"
className="ml-auto"
icon={Icon}
onClick={onClick}
aria-label="Close listing"
/>
</div>
<div className="crayons-modal__box__body p-0">
<div className="listings-modal" data-testid="listings-modal">
<CrayonsModal
onClose={onClick}
closeOnClickOutside={true}
title="Listing"
>
<div className="p-3 m:p-6 l:p-8">
<SingleListing
onAddTag={onAddTag}
@ -72,7 +44,7 @@ export const Modal = ({
/>
</div>
)}
</div>
</CrayonsModal>
</div>
);
};

View file

@ -1,14 +1,11 @@
import { h, Component, Fragment } from 'preact';
import { h, Component } from 'preact';
import { debounceAction } from '../utilities/debounceAction';
import { fetchSearch } from '../utilities/search';
import { KeyboardShortcuts } from '../shared/components/useKeyboardShortcuts';
import { ModalBackground } from './components/ModalBackground';
import { Modal } from './components/Modal';
import { AllListings } from './components/AllListings';
import { ListingFilters } from './components/ListingFilters';
import {
LISTING_PAGE_SIZE,
MATCH_LISTING,
updateListings,
getQueryParams,
resizeAllMasonryItems,
@ -29,6 +26,7 @@ export class Listings extends Component {
slug: null,
page: 0,
showNextPageButton: false,
isModalOpen: false,
};
componentWillMount() {
@ -40,6 +38,7 @@ export class Listings extends Component {
let openedListing = null;
let slug = null;
let listings = [];
let isModalOpen = false;
if (params.t) {
tags = params.t.split(',');
@ -54,7 +53,7 @@ export class Listings extends Component {
if (container.dataset.displayedlisting) {
openedListing = JSON.parse(container.dataset.displayedlisting);
({ slug } = openedListing);
document.body.classList.add('modal-open');
isModalOpen = true;
}
this.debouncedListingSearch = debounceAction(this.handleQuery.bind(this), {
@ -70,6 +69,7 @@ export class Listings extends Component {
listings,
openedListing,
slug,
isModalOpen,
});
this.listingSearch(query, tags, category, slug);
this.setUser();
@ -88,15 +88,12 @@ export class Listings extends Component {
addTag = (e, tag) => {
e.preventDefault();
if (document.body.classList.contains('modal-open')) {
this.handleCloseModal('close-modal');
}
const { query, tags, category } = this.state;
const newTags = tags;
if (newTags.indexOf(tag) === -1) {
newTags.push(tag);
}
this.setState({ tags: newTags, page: 0 });
this.setState({ tags: newTags, page: 0, isModalOpen: false });
this.listingSearch(query, newTags, category, null);
window.scroll(0, 0);
};
@ -126,30 +123,21 @@ export class Listings extends Component {
this.listingSearch(query, tags, cat, null);
};
handleCloseModal = (e) => {
const { openedListing } = this.state;
if (
e === 'close-modal' ||
(openedListing !== null && e.key === 'Escape') ||
MATCH_LISTING.includes(e.target.id)
) {
const { query, tags, category } = this.state;
this.setState({ openedListing: null, page: 0 });
this.setLocation(query, tags, category, null);
document.body.classList.remove('modal-open');
}
handleCloseModal = () => {
const { query, tags, category } = this.state;
this.setState({ openedListing: null, page: 0, isModalOpen: false });
this.setLocation(query, tags, category, null);
};
handleOpenModal = (e, listing) => {
e.preventDefault();
this.setState({ openedListing: listing });
this.setState({ openedListing: listing, isModalOpen: true });
window.history.replaceState(
null,
null,
`/listings/${listing.category}/${listing.slug}`,
);
this.setLocation(null, null, listing.category, listing.slug);
document.body.classList.add('modal-open');
};
handleDraftingMessage = (e) => {
@ -269,10 +257,11 @@ export class Listings extends Component {
showNextPageButton,
initialFetch,
message,
isModalOpen,
} = this.state;
const shouldRenderModal =
openedListing !== null && openedListing !== undefined;
isModalOpen && openedListing !== null && openedListing !== undefined;
if (initialFetch) {
this.triggerMasonry();
@ -301,27 +290,17 @@ export class Listings extends Component {
loadNextPage={this.loadNextPage}
/>
{shouldRenderModal && (
<Fragment>
<div className="crayons-modal">
<Modal
currentUserId={currentUserId}
onAddTag={this.addTag}
onChangeDraftingMessage={this.handleDraftingMessage}
onClick={this.handleCloseModal}
onChangeCategory={this.selectCategory}
onOpenModal={this.handleOpenModal}
onSubmit={this.handleSubmitMessage}
listing={openedListing}
message={message}
/>
<ModalBackground onClick={this.handleCloseModal} />
</div>
<KeyboardShortcuts
shortcuts={{
Escape: this.handleCloseModal,
}}
/>
</Fragment>
<Modal
currentUserId={currentUserId}
onAddTag={this.addTag}
onChangeDraftingMessage={this.handleDraftingMessage}
onClick={this.handleCloseModal}
onChangeCategory={this.selectCategory}
onOpenModal={this.handleOpenModal}
onSubmit={this.handleSubmitMessage}
listing={openedListing}
message={message}
/>
)}
</div>
);

View file

@ -4,13 +4,6 @@
*/
export const LISTING_PAGE_SIZE = 75;
export const MATCH_LISTING = [
'single-listing-container__inner',
'listing-filters',
'listings-modal-background',
'close-listing-modal',
];
export function updateListings(listings) {
const fullListings = [];

View file

@ -1,6 +1,5 @@
import { h, Component } from 'preact';
import PropTypes from 'prop-types';
import { FocusTrap } from '../shared/components/focusTrap';
import { IntroSlide } from './components/IntroSlide';
import { EmailPreferencesForm } from './components/EmailPreferencesForm';
@ -66,22 +65,25 @@ export class Onboarding extends Component {
}
}
// TODO: Update main element id to enable skip link. See issue #1153.
render() {
const { currentSlide } = this.state;
const { communityConfig } = this.props;
return (
<FocusTrap>
<main
className="onboarding-body"
style={
communityConfig.communityBackground && {
backgroundImage: `url(${communityConfig.communityBackground})`,
}
}
>
<main
className="onboarding-body"
style={
communityConfig.communityBackground
? {
backgroundImage: `url(${communityConfig.communityBackground})`,
}
: null
}
>
<FocusTrap key={`onboarding-${currentSlide}`}>
{this.slides[currentSlide]}
</main>
</FocusTrap>
</FocusTrap>
</main>
);
}
}

View file

@ -1,6 +1,5 @@
import { h, Component } from 'preact';
import PropTypes from 'prop-types';
import { getContentOfToken, updateOnboarding } from '../utilities';
import { Navigation } from './Navigation';

View file

@ -1,7 +1,6 @@
import { h, Component } from 'preact';
import PropTypes from 'prop-types';
import he from 'he';
import { getContentOfToken } from '../utilities';
import { Navigation } from './Navigation';

View file

@ -5,6 +5,40 @@ import {
initializeTouchDevice,
} from '../topNavigation/utilities';
window.showModal = async ({
title,
contentSelector,
overlay = false,
size = 's',
}) => {
const [{ Modal }, { render, h }] = await Promise.all([
import('@crayons/Modal'),
import('preact'),
]);
const modalRoot = document.createElement('div');
document.body.appendChild(modalRoot);
render(
<Modal
overlay={overlay}
title={title}
onClose={() => {
render(null, modalRoot);
}}
size={size}
>
<div
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: document.querySelector(contentSelector).innerHTML,
}}
/>
</Modal>,
modalRoot,
);
};
function getPageEntries() {
return Object.entries({
'notifications-index': document.getElementById('notifications-link'),

View file

@ -1,3 +1,5 @@
/* global showLoginModal */
function loadFunctionality() {
if (!document.getElementById('notification-subscriptions-area')) {
return;
@ -33,9 +35,7 @@ function loadFunctionality() {
if (userStatus === 'logged-out') {
updateStatus = () => {
// Disabled because showModal() is globally defined in asset pipeline
// eslint-disable-next-line no-undef
showModal('notification-subscription');
showLoginModal();
};
} else {
updateStatus = (target) => {

View file

@ -1,5 +1,6 @@
/* eslint-disable no-alert */
/* eslint-disable no-restricted-globals */
/* global showLoginModal */
function toggleTemplateTypeButton(form, e) {
const { targetType } = e.target.dataset;
@ -296,10 +297,13 @@ function observeForReplyClick() {
}
function handleLoggedOut() {
// global method from app/assets/javascripts/utilities/showModal.js
document
.getElementsByClassName('response-templates-button')[0]
?.addEventListener('click', showModal); /* eslint-disable-line no-undef */
?.addEventListener(
'click',
// eslint-disable-next-line no-undef
showLoginModal,
);
}
/* eslint-enable no-alert */
/* eslint-enable no-restricted-globals */

View file

@ -1,27 +1,88 @@
import PropTypes from 'prop-types';
import { useLayoutEffect, useRef } from 'preact/hooks';
import { h, Fragment } from 'preact';
import { useLayoutEffect, useRef, useCallback } from 'preact/hooks';
import { createFocusTrap } from 'focus-trap';
import { defaultChildrenPropTypes } from '../../common-prop-types';
import { KeyboardShortcuts } from './useKeyboardShortcuts';
export const FocusTrap = ({ selector, children }) => {
/**
* Wrapper component to create a focus trap within the HTML element found by the given selector
*
* @example
* import { FocusTrap } from "shared/components/FocusTrap";
*
* const ExampleComponent = ({ onClose }) => (
* <FocusTrap selector=".component-with-focus-trap" onDeactivate={onClose}>
* <div class="component-with-focus-trap">
* <button onClick={onClose}>Close</button>
* </div>
* </FocusTrap>
* )
*
* @param {string} selector The CSS selector for the element where focus is to be trapped
* @param {Array} children Child element(s) passed via composition
* @param {Function} onDeactivate Callback function to be called when the focus trap is deactivated by navigation or Escape press
* @param {boolean} clickOutsideDeactivates If true, the focus trap will deactivate when a user clicks outside of the trap area
*/
export const FocusTrap = ({
selector,
children,
onDeactivate,
clickOutsideDeactivates = false,
}) => {
const focusTrap = useRef(null);
const deactivate = useCallback(() => onDeactivate(), [onDeactivate]);
useLayoutEffect(() => {
focusTrap.current = createFocusTrap(selector);
const currentLocationHref = document.location.href;
const routeChangeObserver = new MutationObserver((mutations) => {
const hasRouteChanged = mutations.some(
() => currentLocationHref !== document.location.href,
);
// Ensure trap deactivates if user navigates from the page
if (hasRouteChanged) {
focusTrap.current?.deactivate();
routeChangeObserver.disconnect();
}
});
focusTrap.current = createFocusTrap(selector, {
escapeDeactivates: false,
clickOutsideDeactivates,
onDeactivate: deactivate,
});
focusTrap.current.activate();
routeChangeObserver.observe(document.querySelector('body'), {
childList: true,
});
return () => {
focusTrap.current.deactivate();
routeChangeObserver.disconnect();
};
});
}, [clickOutsideDeactivates, selector, deactivate]);
return children;
const shortcuts = {
escape: onDeactivate,
};
return (
<Fragment>
{children}
<KeyboardShortcuts shortcuts={shortcuts} />
</Fragment>
);
};
FocusTrap.defaultProps = {
selector: '.crayons-modal',
onDeactivate: () => {},
};
FocusTrap.propTypes = {
selector: PropTypes.string,
children: defaultChildrenPropTypes.isRequired,
onDeactivate: PropTypes.func,
};

View file

@ -92,8 +92,8 @@ class PollTag < LiquidTagBase
var els = document.getElementsByClassName('ltag-poll')
for (i = 0; i < els.length; i += 1) {
els[i].onclick = function(e) {
if (typeof showModal !== "undefined") {
showModal('poll');
if (typeof showLoginModal !== "undefined") {
showLoginModal();
}
}
}

View file

@ -175,8 +175,8 @@ class UserSubscriptionTag < LiquidTagBase
const signInBtn = document.getElementById('sign-in-btn');
if (signInBtn) {
signInBtn.addEventListener('click', function(e) {
if (typeof showModal !== 'undefined') {
showModal('user_subscription');
if (typeof showLoginModal !== 'undefined') {
showLoginModal();
}
});
}

View file

@ -1,38 +1,18 @@
<button class="btn btn-primary ml-7" type="button" onclick="toggleNavigationLinkModal()">
Add Navigation Link
</button>
<span data-controller="modal" data-modal-root-selector-value="#add-navigation-link-modal-root" data-modal-content-selector-value="#add-navigation-link-modal" data-modal-title-value="Add Navigation Link" data-modal-size-value="l">
<button class="btn btn-primary ml-7" type="button" data-action="modal#toggleModal" id="nav-link-button">
Add Navigation Link
</button>
<div id="add-navigation-link-modal" class="hidden">
<div class="crayons-modal crayons-modal--l">
<div class="crayons-modal__box">
<header class="crayons-modal__box__header">
<h2>Add Navigation Link</h2>
<button type="button" onclick="toggleNavigationLinkModal()" class="crayons-btn crayons-btn--icon crayons-btn--ghost">
<svg width="24" height="24" viewBox="0 0 24 24" class="crayons-icon" xmlns="http://www.w3.org/2000/svg">
<path d="M12 10.586l4.95-4.95 1.414 1.414-4.95 4.95 4.95 4.95-1.414 1.414-4.95-4.95-4.95 4.95-1.414-1.414 4.95-4.95-4.95-4.95L7.05 5.636l4.95 4.95z" />
</svg>
</button>
</header>
<div class="crayons-modal__box__body">
<div class="form-group grid p-6 mb-6 gap-1">
<div class="form-group grid p-6 mb-6 gap-1">
<%= form_for(NavigationLink.new, url: admin_navigation_links_path) do |form| %>
<%= render "form", form: form %>
<%= form.submit "Add link", class: "crayons-btn" %>
<% end %>
</div>
</div>
<div id="add-navigation-link-modal" class="hidden">
<div class="form-group grid p-6 mb-6 gap-1">
<div class="form-group grid p-6 mb-6 gap-1">
<%= form_for(NavigationLink.new, url: admin_navigation_links_path) do |form| %>
<%= render "form", form: form %>
<%= form.submit "Add link", class: "crayons-btn" %>
<% end %>
</div>
</div>
<div class="crayons-modal__overlay"></div>
</div>
</div>
<script>
function toggleNavigationLinkModal() {
const confirmationModal = document.getElementById(`add-navigation-link-modal`);
if (confirmationModal) {
confirmationModal.classList.toggle("hidden");
}
}
</script>
<div id="add-navigation-link-modal-root"></div>
</span>

View file

@ -1,36 +1,16 @@
<div onclick="toggleEditNavigationLinkModal('<%= link[:id] %>')" class="color-accent-brand cursor-pointer">
Edit Link
</div>
<span data-controller="modal" data-modal-root-selector-value="#edit-navigation-link-<%= link[:id] %>-modal-root" data-modal-content-selector-value="#edit-navigation-link-<%= link[:id] %>-modal" data-modal-title-value="Edit Link" data-modal-size-value="l">
<button data-action="modal#toggleModal" class="color-accent-brand crayons-btn crayons-btn--ghost-brand">
Edit Link
</button>
<div id="edit-navigation-link-<%= link[:id] %>-modal" class="hidden">
<div class="crayons-modal crayons-modal--l">
<div class="crayons-modal__box">
<header class="crayons-modal__box__header">
<h2>Edit Link</h2>
<button type="button" onclick="toggleEditNavigationLinkModal('<%= link[:id] %>')" class="crayons-btn crayons-btn--icon crayons-btn--ghost">
<svg width="24" height="24" viewBox="0 0 24 24" class="crayons-icon" xmlns="http://www.w3.org/2000/svg">
<path d="M12 10.586l4.95-4.95 1.414 1.414-4.95 4.95 4.95 4.95-1.414 1.414-4.95-4.95-4.95 4.95-1.414-1.414 4.95-4.95-4.95-4.95L7.05 5.636l4.95 4.95z" />
</svg>
</button>
</header>
<div class="crayons-modal__box__body">
<div class="form-group grid p-6 mb-6 gap-1">
<%= form_for [:admin, link] do |form| %>
<%= render "form", form: form %>
<%= form.submit class: "btn btn-primary" %>
<% end %>
</div>
</div>
<div id="edit-navigation-link-<%= link[:id] %>-modal" class="hidden">
<div class="form-group grid p-6 mb-6 gap-1">
<%= form_for [:admin, link] do |form| %>
<%= render "form", form: form %>
<%= form.submit class: "btn btn-primary" %>
<% end %>
</div>
<div class="crayons-modal__overlay"></div>
</div>
</div>
<script>
function toggleEditNavigationLinkModal(link_id) {
const confirmationModal = document.getElementById(`edit-navigation-link-${link_id}-modal`);
if (confirmationModal) {
confirmationModal.classList.toggle("hidden");
}
}
</script>
<div id="edit-navigation-link-<%= link[:id] %>-modal-root"></div>
</span>

View file

@ -1,36 +1,23 @@
<span data-controller="modal" data-modal-hidden-class="hidden">
<span data-controller="modal" data-modal-root-selector-value="#add-group-modal-root" data-modal-content-selector-value="#add-group-modal" data-modal-title-value="Add Group" data-modal-size-value="s">
<button class="crayons-btn" type="button" data-action="modal#toggleModal">
Add group
</button>
<div id="add-group-modal" class="hidden" data-modal-target="toggle">
<div class="crayons-modal crayons-modal--s absolute">
<div class="crayons-modal__box">
<header class="crayons-modal__box__header">
<h2>Add Group</h2>
<button type="button" data-action="modal#toggleModal" class="crayons-btn crayons-btn--icon crayons-btn--ghost">
<svg width="24" height="24" viewBox="0 0 24 24" class="crayons-icon" xmlns="http://www.w3.org/2000/svg">
<path d="M12 10.586l4.95-4.95 1.414 1.414-4.95 4.95 4.95 4.95-1.414 1.414-4.95-4.95-4.95 4.95-1.414-1.414 4.95-4.95-4.95-4.95L7.05 5.636l4.95 4.95z" />
</svg>
</button>
</header>
<div class="crayons-modal__box__body">
<div class="form-group grid p-6 mb-6 gap-1">
<%= form_for [:admin, ProfileFieldGroup.new] do |form| %>
<div class="form-group">
<%= form.label :name %>
<%= form.text_field :name, class: "form-control" %>
</div>
<div class="form-group">
<%= form.label :description %>
<%= form.text_field :description, class: "form-control" %>
</div>
<%= form.submit class: "btn btn-primary" %>
<% end %>
</div>
<div id="add-group-modal" class="hidden">
<div class="form-group grid p-6 mb-6 gap-1">
<%= form_for [:admin, ProfileFieldGroup.new] do |form| %>
<div class="form-group">
<%= form.label :name %>
<%= form.text_field :name, class: "form-control" %>
</div>
</div>
<div class="crayons-modal__overlay"></div>
<div class="form-group">
<%= form.label :description %>
<%= form.text_field :description, class: "form-control" %>
</div>
<%= form.submit class: "btn btn-primary" %>
<% end %>
</div>
</div>
<div id="add-group-modal-root"></div>
</span>

View file

@ -1,29 +1,16 @@
<span data-controller="modal" class="mx-1" data-modal-hidden-class="hidden">
<span data-controller="modal" data-modal-root-selector-value="#add-<%= group_name %>-profile-field-modal-root" data-modal-content-selector-value="#add-<%= group_name %>-profile-field-modal" data-modal-title-value="Add Profile Field" data-modal-size-value="s" class="mx-1">
<button data-action="modal#toggleModal" class="crayons-btn">
Add Field
</button>
<div id="add-<%= group_name %>-profile-field-modal" class="hidden" data-modal-target="toggle">
<div class="crayons-modal crayons-modal--s">
<div class="crayons-modal__box">
<header class="crayons-modal__box__header">
<h2>Add Profile Field</h2>
<button type="button" data-action="modal#toggleModal" class="crayons-btn crayons-btn--icon crayons-btn--ghost">
<svg width="24" height="24" viewBox="0 0 24 24" class="crayons-icon" xmlns="http://www.w3.org/2000/svg">
<path d="M12 10.586l4.95-4.95 1.414 1.414-4.95 4.95 4.95 4.95-1.414 1.414-4.95-4.95-4.95 4.95-1.414-1.414 4.95-4.95-4.95-4.95L7.05 5.636l4.95 4.95z" />
</svg>
</button>
</header>
<div class="crayons-modal__box__body">
<div class="form-group grid p-6 mb-6 gap-1">
<%= form_for [:admin, ProfileField.new] do |form| %>
<%= render "profile_field_form", form: form, group: group, group_name: group_name %>
<%= form.submit class: "btn btn-primary" %>
<% end %>
</div>
</div>
</div>
<div class="crayons-modal__overlay"></div>
<div id="add-<%= group_name %>-profile-field-modal" class="hidden">
<div class="form-group grid p-6 mb-6 gap-1">
<%= form_for [:admin, ProfileField.new] do |form| %>
<%= render "profile_field_form", form: form, group: group, group_name: group_name %>
<%= form.submit class: "btn btn-primary" %>
<% end %>
</div>
</div>
<div id="add-<%= group_name %>-profile-field-modal-root"></div>
</span>

View file

@ -1,36 +1,23 @@
<span data-controller="modal" class="mx-1" data-modal-hidden-class="hidden">
<span data-controller="modal" data-modal-root-selector-value="#edit-group-<%= group_name %>-modal-root" data-modal-content-selector-value="#edit-group-<%= group_name %>-modal" data-modal-title-value="Edit Group <%= group_name %>" data-modal-size-value="s" class="mx-1">
<button data-action="modal#toggleModal" class="crayons-btn crayons-btn--secondary">
Edit group
</button>
<div id="edit-group-<%= group_name %>-modal" class="hidden" data-modal-target="toggle">
<div class="crayons-modal crayons-modal--s">
<div class="crayons-modal__box">
<header class="crayons-modal__box__header">
<h2>Edit Group <%= group_name %></h2>
<button type="button" data-action="modal#toggleModal" class="crayons-btn crayons-btn--icon crayons-btn--ghost">
<svg width="24" height="24" viewBox="0 0 24 24" class="crayons-icon" xmlns="http://www.w3.org/2000/svg">
<path d="M12 10.586l4.95-4.95 1.414 1.414-4.95 4.95 4.95 4.95-1.414 1.414-4.95-4.95-4.95 4.95-1.414-1.414 4.95-4.95-4.95-4.95L7.05 5.636l4.95 4.95z" />
</svg>
</button>
</header>
<div class="crayons-modal__box__body">
<div class="form-group grid p-6 mb-6 gap-1">
<%= form_for [:admin, group] do |form| %>
<div class="form-group">
<%= form.label :name %>
<%= form.text_field :name, class: "form-control" %>
</div>
<div class="form-group">
<%= form.label :description %>
<%= form.text_field :description, class: "form-control" %>
</div>
<%= form.submit class: "btn btn-primary" %>
<% end %>
</div>
<div id="edit-group-<%= group_name %>-modal" class="hidden">
<div class="form-group grid p-6 mb-6 gap-1">
<%= form_for [:admin, group] do |form| %>
<div class="form-group">
<%= form.label :name %>
<%= form.text_field :name, class: "form-control" %>
</div>
</div>
<div class="crayons-modal__overlay"></div>
<div class="form-group">
<%= form.label :description %>
<%= form.text_field :description, class: "form-control" %>
</div>
<%= form.submit class: "btn btn-primary" %>
<% end %>
</div>
</div>
<div id="edit-group-<%= group_name %>-modal-root"></div>
</span>

View file

@ -79,7 +79,7 @@
var sortString = buildSortString(sortBy, sortDirection);
if (e.target.classList.contains("my-posts-query-button") && !checkUserLoggedIn()) {
showModal("search-my-posts");
showLoginModal();
return;
}
var filters = e.target.dataset.filter;

View file

@ -1,46 +1,32 @@
<div id="global-signup-modal" class="authentication-modal crayons-modal crayons-modal--s fixed hidden">
<div class="authentication-modal__box crayons-modal__box">
<button type="button" class="authentication-modal__close-btn close-modal-button crayons-btn crayons-btn--icon crayons-btn--ghost">
<svg width="24" height="24" viewBox="0 0 24 24" class="crayons-icon" xmlns="http://www.w3.org/2000/svg">
<path d="M12 10.586l4.95-4.95 1.414 1.414-4.95 4.95 4.95 4.95-1.414 1.414-4.95-4.95-4.95 4.95-1.414-1.414 4.95-4.95-4.95-4.95L7.05 5.636l4.95 4.95z" />
</svg>
</button>
<div class="crayons-modal__box__body p-0">
<div class="authentication-modal__container">
<figure class="authentication-modal__image-container">
<img class="authentication-modal__image" src="<%= optimized_image_url(safe_logo_url(SiteConfig.secondary_logo_url), width: 190) %>"
alt="Secondary <%= community_name %> logo" />
</figure>
<div class="authentication-modal__content">
<h1 class="authentication-modal__title">
Log in to continue
</h1>
<p class="authentication-modal__description">
<% if SiteConfig.tagline.present? %>
<%= SiteConfig.tagline %>
<% else %>
Wed love for you to be a part of this community.
<% end %>
</p>
</div>
</div>
<div class="authentication-modal__actions">
<a href="<%= sign_up_path %>" class="crayons-btn" aria-label="Log in" data-no-instant>
Log in
</a>
<a href="<%= sign_up_path(state: "new-user") %>" class="crayons-btn crayons-btn--ghost-brand" aria-label="Create new account" data-no-instant>
Create new account
</a>
</div>
<footer class="authentication-modal__footer">
<p class="authentication-modal__footer-label">
We strive for transparency and don't collect excess data.
</p>
</footer>
</div>
<div id="global-signup-modal" class="authentication-modal hidden">
<div class="authentication-modal__container">
<figure class="authentication-modal__image-container">
<img class="authentication-modal__image" src="<%= optimized_image_url(safe_logo_url(SiteConfig.secondary_logo_url), width: 190) %>"
alt="<%= community_name %>" />
</figure>
<div class="authentication-modal__content">
<p class="authentication-modal__description">
<% if SiteConfig.tagline.present? %>
<%= SiteConfig.tagline %>
<% else %>
Wed love for you to be a part of this community.
<% end %>
</p>
</div>
<div class="authentication-modal__actions">
<a href="<%= sign_up_path %>" class="crayons-btn" aria-label="Log in" data-no-instant>
Log in
</a>
<a href="<%= sign_up_path(state: "new-user") %>" class="crayons-btn crayons-btn--ghost-brand" aria-label="Create new account" data-no-instant>
Create new account
</a>
</div>
<footer class="authentication-modal__footer">
<p class="authentication-modal__footer-label">
We strive for transparency and don't collect excess data.
</p>
</footer>
</div>
<div class="crayons-modal__overlay"></div>
</div>
<%= javascript_packs_with_chunks_tag "signupModalShortcuts", defer: true %>

View file

@ -0,0 +1,44 @@
describe('Navigation links', () => {
beforeEach(() => {
cy.testSetup();
cy.fixture('users/adminUser.json').as('user');
cy.get('@user').then((user) => {
cy.loginUser(user).then(() => {
cy.visit('/admin/navigation_links');
});
});
});
it('should open the add navigation link modal', () => {
cy.findByText('Add Navigation Link').as('addLinkButton');
cy.get('@addLinkButton').click();
cy.findByTestId('modal-container').as('addLinkModal');
cy.get('@addLinkModal').findByText('Add Navigation Link').should('exist');
cy.get('@addLinkModal')
.findAllByRole('button')
.first()
.should('have.focus');
cy.get('@addLinkModal').findByRole('button', { name: /Close/ }).click();
cy.get('@addLinkButton').should('have.focus');
});
it('should open the edit navigation link modal', () => {
cy.findByText('Edit Link').as('editLinkButton');
cy.get('@editLinkButton').click();
cy.findByTestId('modal-container').as('editLinkModal');
cy.get('@editLinkModal').findByText('Edit Link').should('exist');
cy.get('@editLinkModal')
.findAllByRole('button')
.first()
.should('have.focus');
cy.get('@editLinkModal').findByRole('button', { name: /Close/ }).click();
cy.get('@editLinkButton').should('have.focus');
});
});

View file

@ -0,0 +1,40 @@
describe('View listing', () => {
beforeEach(() => {
cy.testSetup();
cy.viewport('macbook-16');
cy.visit('/');
});
it('opens a listing from the Feed page', () => {
cy.findByText('Listing title').click();
cy.findByTestId('listings-modal').as('listingsModal');
cy.get('@listingsModal').findByText('Listing').should('exist');
cy.get('@listingsModal').findByText('Listing title').should('exist');
cy.get('@listingsModal')
.findAllByRole('button')
.first()
.should('have.focus');
cy.get('@listingsModal').findByRole('button', { name: /Close/ }).click();
cy.get('@listingsModal').should('not.exist');
});
it('opens a listing from the listings page', () => {
cy.visit('/listings');
cy.findByText('Listing title').as('listingTitle');
cy.get('@listingTitle').click();
cy.findByTestId('listings-modal').as('listingsModal');
cy.get('@listingsModal').findByText('Listing').should('exist');
cy.get('@listingsModal').findByText('Listing title').should('exist');
cy.get('@listingsModal')
.findAllByRole('button')
.first()
.should('have.focus');
cy.get('@listingsModal').findByRole('button', { name: /Close/ }).click();
cy.get('@listingTitle').should('have.focus');
});
});

View file

@ -0,0 +1,46 @@
describe('Show log in modal', () => {
beforeEach(() => {
cy.testSetup();
cy.visit('/');
});
it('should show a log in modal on Feed bookmark click', () => {
cy.findAllByRole('button', { name: /Save/ }).first().as('bookmarkButton');
cy.get('@bookmarkButton').click();
cy.findByTestId('modal-container').as('modal');
cy.get('@modal').findByText('Log in to continue').should('exist');
cy.get('@modal').findByLabelText('Log in').should('exist');
cy.get('@modal').findByLabelText('Create new account').should('exist');
cy.get('@modal').findByRole('button').first().should('have.focus');
cy.get('@modal').findByRole('button', { name: /Close/ }).click();
cy.get('@bookmarkButton').should('have.focus');
cy.findByTestId('modal-container').should('not.exist');
});
it('should show login modal for article reaction clicks', () => {
cy.findAllByText('Test article').last().click();
cy.findByRole('checkbox', { name: 'Heart' }).as('heartReaction');
cy.findByRole('checkbox', { name: 'Unicorn' }).as('unicornReaction');
cy.findByRole('checkbox', { name: 'Saved' }).as('bookmarkReaction');
['@heartReaction', '@unicornReaction', '@bookmarkReaction'].forEach(
(reaction) => {
cy.get(reaction).click();
cy.findByTestId('modal-container').as('modal');
cy.get('@modal').findByText('Log in to continue').should('exist');
cy.get('@modal').findByLabelText('Log in').should('exist');
cy.get('@modal').findByLabelText('Create new account').should('exist');
cy.get('@modal').findByRole('button').first().should('have.focus');
cy.get('@modal').findByRole('button', { name: /Close/ }).click();
cy.get(reaction).should('have.focus');
cy.findByTestId('modal-container').should('not.exist');
},
);
});
});

View file

@ -137,9 +137,9 @@ RSpec.describe UserSubscriptionTag, type: :liquid_tag do
end
it "allows a user to sign in", type: :system, js: true do
expect(page).to have_css("#global-signup-modal", visible: :hidden)
expect(page).not_to have_text("Log in to continue")
click_button("Sign In", id: "sign-in-btn")
expect(page).to have_css("#global-signup-modal", visible: :visible)
expect(page).to have_text("Log in to continue")
end
end

View file

@ -111,3 +111,72 @@ seeder.create_if_doesnt_exist(User, "email", "article-editor-v2-user@forem.com")
end
##############################################################################
seeder.create_if_doesnt_exist(Article, "title", "Test article") do
markdown = <<~MARKDOWN
---
title: Test article
published: true
cover_image: #{Faker::Company.logo}
---
#{Faker::Hipster.paragraph(sentence_count: 2)}
#{Faker::Markdown.random}
#{Faker::Hipster.paragraph(sentence_count: 2)}
MARKDOWN
Article.create(
body_markdown: markdown,
featured: true,
show_comments: true,
user_id: User.order(Arel.sql("RANDOM()")).first.id,
)
end
##############################################################################
seeder.create_if_none(ListingCategory) do
ListingCategory.create!(
slug: "cfp",
cost: 1,
name: "Conference CFP",
rules: "Currently open for proposals, with link to form.",
)
end
##############################################################################
seeder.create_if_none(Listing) do
user = User.first
Credit.add_to(user, rand(100))
Listing.create!(
user: user,
title: "Listing title",
body_markdown: Faker::Markdown.random,
location: Faker::Address.city,
organization_id: user.organizations.first&.id,
listing_category_id: ListingCategory.first&.id,
contact_via_connect: true,
published: true,
originally_published_at: Time.current,
bumped_at: Time.current,
tag_list: Tag.order(Arel.sql("RANDOM()")).first(2).pluck(:name),
)
end
##############################################################################
seeder.create_if_none(NavigationLink) do
protocol = ApplicationConfig["APP_PROTOCOL"].freeze
domain = Rails.application&.initialized? ? SiteConfig.app_domain : ApplicationConfig["APP_DOMAIN"]
base_url = "#{protocol}#{domain}".freeze
reading_icon = File.read(Rails.root.join("app/assets/images/twemoji/drawer.svg")).freeze
NavigationLink.create!(
name: "Reading List",
url: "#{base_url}/readinglist",
icon: reading_icon,
display_only_when_signed_in: true,
position: 0,
)
end