docbrown/app/javascript/listings/listings.jsx
Suzanne Aitchison 5bed8f56d4
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>
2021-02-24 16:01:10 +00:00

310 lines
8.5 KiB
JavaScript

import { h, Component } from 'preact';
import { debounceAction } from '../utilities/debounceAction';
import { fetchSearch } from '../utilities/search';
import { Modal } from './components/Modal';
import { AllListings } from './components/AllListings';
import { ListingFilters } from './components/ListingFilters';
import {
LISTING_PAGE_SIZE,
updateListings,
getQueryParams,
resizeAllMasonryItems,
getLocation,
} from './utils';
export class Listings extends Component {
state = {
listings: [],
query: '',
tags: [],
category: '',
allCategories: [],
initialFetch: true,
currentUserId: null,
openedListing: null,
message: '',
slug: null,
page: 0,
showNextPageButton: false,
isModalOpen: false,
};
componentWillMount() {
const params = getQueryParams();
const container = document.getElementById('listings-index-container');
const category = container.dataset.category || '';
const allCategories = JSON.parse(container.dataset.allcategories || []);
let tags = [];
let openedListing = null;
let slug = null;
let listings = [];
let isModalOpen = false;
if (params.t) {
tags = params.t.split(',');
}
const query = params.q || '';
if (tags.length === 0 && query === '') {
listings = JSON.parse(container.dataset.listings);
}
if (container.dataset.displayedlisting) {
openedListing = JSON.parse(container.dataset.displayedlisting);
({ slug } = openedListing);
isModalOpen = true;
}
this.debouncedListingSearch = debounceAction(this.handleQuery.bind(this), {
time: 150,
config: { leading: true },
});
this.setState({
query,
tags,
category,
allCategories,
listings,
openedListing,
slug,
isModalOpen,
});
this.listingSearch(query, tags, category, slug);
this.setUser();
/*
The width of the columns also changes when the browser is resized
so we will also call this function on window resize to recalculate
each grid item's height to avoid content overflow
*/
window.addEventListener('resize', resizeAllMasonryItems);
}
componentDidUpdate() {
this.triggerMasonry();
}
addTag = (e, tag) => {
e.preventDefault();
const { query, tags, category } = this.state;
const newTags = tags;
if (newTags.indexOf(tag) === -1) {
newTags.push(tag);
}
this.setState({ tags: newTags, page: 0, isModalOpen: false });
this.listingSearch(query, newTags, category, null);
window.scroll(0, 0);
};
removeTag = (e, tag) => {
e.preventDefault();
const { query, tags, category } = this.state;
const newTags = tags;
const index = newTags.indexOf(tag);
if (newTags.indexOf(tag) > -1) {
newTags.splice(index, 1);
}
this.setState({ tags: newTags, page: 0 });
this.listingSearch(query, newTags, category, null);
};
handleKeyPressedOnSelectedTags = (e, tag) => {
if (e.key === 'Enter') {
this.removeTag(e, tag);
}
};
selectCategory = (e, cat = '') => {
e.preventDefault();
const { query, tags } = this.state;
this.setState({ category: cat, page: 0 });
this.listingSearch(query, tags, cat, null);
};
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, isModalOpen: true });
window.history.replaceState(
null,
null,
`/listings/${listing.category}/${listing.slug}`,
);
this.setLocation(null, null, listing.category, listing.slug);
};
handleDraftingMessage = (e) => {
e.preventDefault();
this.setState({ message: e.target.value });
};
handleSubmitMessage = (e) => {
e.preventDefault();
const { message, openedListing } = this.state;
if (message.replace(/\s/g, '').length === 0) {
return;
}
const formData = new FormData();
formData.append('user_id', openedListing.user_id);
formData.append('message', `**re: ${openedListing.title}** ${message}`);
formData.append('controller', 'chat_channels');
const destination = `/connect/@${openedListing.user.username}`;
const metaTag = document.querySelector("meta[name='csrf-token']");
window
.fetch('/chat_channels/create_chat', {
method: 'POST',
headers: {
'X-CSRF-Token': metaTag.getAttribute('content'),
},
body: formData,
credentials: 'same-origin',
})
.then(() => {
window.location.href = destination;
});
};
handleQuery = (e) => {
const { tags, category } = this.state;
this.setState({ query: e.target.value, page: 0 });
this.listingSearch(e.target.value, tags, category, null);
};
clearQuery = () => {
const { tags, category } = this.state;
document.getElementById('listings-search').value = '';
this.setState({ query: '', page: 0 });
this.listingSearch('', tags, category, null);
};
loadNextPage = () => {
const { query, tags, category, slug, page } = this.state;
this.setState({ page: page + 1 });
this.listingSearch(query, tags, category, slug);
};
setUser = () => {
const { currentUserId } = this.state;
setTimeout(() => {
if (window.currentUser && currentUserId === null) {
this.setState({ currentUserId: window.currentUser.id });
}
}, 1000);
};
triggerMasonry = () => {
resizeAllMasonryItems();
setTimeout(resizeAllMasonryItems, 1);
setTimeout(resizeAllMasonryItems, 3);
};
setLocation = (query, tags, category, slug) => {
const newLocation = getLocation({ query, tags, category, slug });
window.history.replaceState(null, null, newLocation);
};
/**
* Call search API for Listings
*
* @param {string} query - The search term
* @param {string} tags - The tags selected by the user
* @param {string} category - The category selected by the user
* @param {string} slug - The listing's slug
*
* @returns {Promise} A promise object with response formatted as JSON.
*/
listingSearch(query, tags, category, slug) {
const { page } = this.state;
const dataHash = {
category,
listing_search: query,
page,
per_page: LISTING_PAGE_SIZE,
tags,
tag_boolean_mode: 'all',
};
const responsePromise = fetchSearch('listings', dataHash);
return responsePromise.then((response) => {
const listings = response.result;
const fullListings = updateListings(listings);
this.setState({
listings: fullListings,
initialFetch: false,
showNextPageButton: listings.length === LISTING_PAGE_SIZE,
});
this.setLocation(query, tags, category, slug);
});
}
render() {
const {
listings,
query,
tags,
category,
allCategories,
currentUserId,
openedListing,
showNextPageButton,
initialFetch,
message,
isModalOpen,
} = this.state;
const shouldRenderModal =
isModalOpen && openedListing !== null && openedListing !== undefined;
if (initialFetch) {
this.triggerMasonry();
}
return (
<div className="crayons-layout crayons-layout--2-cols">
<ListingFilters
categories={allCategories}
category={category}
onSelectCategory={this.selectCategory}
message={message}
onKeyUp={this.debouncedListingSearch}
onClearQuery={this.clearQuery}
onRemoveTag={this.removeTag}
tags={tags}
onKeyPress={this.handleKeyPressedOnSelectedTags}
query={query}
/>
<AllListings
listings={listings}
onAddTag={this.addTag}
onChangeCategory={this.selectCategory}
currentUserId={currentUserId}
onOpenModal={this.handleOpenModal}
showNextPageButton={showNextPageButton}
loadNextPage={this.loadNextPage}
/>
{shouldRenderModal && (
<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>
);
}
}
Listings.displayName = 'Classified Listings';