import { Fragment, h } from 'preact'; import { forwardRef } from 'preact/compat'; import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { useStateRef } from 'preact-use'; import { IntlProvider, Localizer, Text, useText } from 'preact-i18n'; import { StyleSheet } from 'aphrodite'; import { _, _1, _2, it } from 'one-liner.macro'; import moize from 'moize'; import { identity, invertComparison, noop } from 'duo-toolbox/utils/functions'; import { getFixedElementPositioningParent, scrollElementIntoParentView } from 'duo-toolbox/utils/ui'; import { STRING_MATCH_MODE_GLOBAL, STRING_MATCH_MODE_WORDS, STRING_MATCH_TYPE_ANYWHERE, STRING_MATCH_TYPE_END, STRING_MATCH_TYPE_EXACT, STRING_MATCH_TYPE_NONE, STRING_MATCH_TYPE_START, } from '../constants'; import { boundIndicesOf, getWordAt } from '../strings'; import * as Solution from '../solutions'; import { BASE, CONTEXT_CHALLENGE, CONTEXT_FORUM, useLocalStorage, useLocalStorageList, useStyles, } from './index'; import Dropdown from './Dropdown'; import FilterInput from './FilterInput'; import Pagination from './Pagination'; const SORT_TYPE_SIMILARITY = 'similarity'; const SORT_TYPE_ALPHABETICAL = 'alphabetical'; const SORT_TYPES = { [SORT_TYPE_SIMILARITY]: { labelId: 'similarity_sort', defaultLabel: 'Similarity sort', actionLabelId: 'sort_by_similarity', defaultActionLabel: 'Sort by similarity', }, [SORT_TYPE_ALPHABETICAL]: { labelId: 'alphabetical_sort', defaultLabel: 'Alphabetical sort', actionLabelId: 'sort_alphabetically', defaultActionLabel: 'Sort alphabetically', }, }; /** * @param {boolean} isScoreAvailable Whether similarity scores are available on solutions. * @returns {string[]} The available sort types. */ const getAvailableSortTypes = moize(isScoreAvailable => { let sortTypes = Object.keys(SORT_TYPES); if (!isScoreAvailable) { sortTypes = sortTypes.filter(SORT_TYPE_SIMILARITY !== it); } return sortTypes; }); const SORT_DIRECTION_ASC = 'asc'; const SORT_DIRECTION_DESC = 'desc'; const SORT_DIRECTIONS = { [SORT_DIRECTION_ASC]: { label: '↑', actionLabelId: 'sort_ascending', defaultActionLabel: 'Sort in ascending order', }, [SORT_DIRECTION_DESC]: { label: '↓', actionLabelId: 'sort_descending', defaultActionLabel: 'Sort in descending order', }, }; const PAGE_SIZE_ALL = 'all'; const DEFAULT_PAGE_SIZE = 20; const PAGE_SIZES = [ 10, 20, 50, 200, PAGE_SIZE_ALL ]; /** * @type {Function} * @param {number|string} sizeA A page size. * @param {number|string} sizeB Another page size. * @returns {boolean} Whether the two page sizes are equivalent. */ const isEqualPageSizes = String(_) === String(_); const ListSortLinks = ({ context, availableSortTypes, sortType, nextSortType, sortDirection, nextSortDirection, onSortTypeToggle, onSortDirectionToggle, }) => { const getElementClassNames = useStyles(CLASS_NAMES, STYLE_SHEETS, [ context ]); const { sortTypeLabel, nextSortTypeTitle, nextSortDirectionTitle, } = useText({ sortTypeLabel: ( <Text id={SORT_TYPES[sortType].labelId}> {SORT_TYPES[sortType].defaultLabel} </Text> ), nextSortTypeTitle: ( <Text id={SORT_TYPES[nextSortType].actionLabelId}> {SORT_TYPES[nextSortType].defaultActionLabel} </Text> ), nextSortDirectionTitle: ( <Text id={SORT_DIRECTIONS[nextSortDirection].actionLabelId}> {SORT_DIRECTIONS[nextSortDirection].defaultActionLabel} </Text> ), }); return ( <div className={getElementClassNames(TITLE_LINK_WRAPPER)}> <Localizer> {(1 === availableSortTypes.length) ? ( // Single sort type <span className={getElementClassNames([ SORT_TYPE_LABEL, SINGLE_SORT_TYPE_LABEL ])}> {sortTypeLabel} </span> ) : ( // Multiple sort types <a title={nextSortTypeTitle} onClick={onSortTypeToggle} className={getElementClassNames(SORT_LINK)} > <span className={getElementClassNames(SORT_TYPE_LABEL)}> {sortTypeLabel} </span> </a> )} <a title={nextSortDirectionTitle} onClick={onSortDirectionToggle} className={getElementClassNames(SORT_LINK)} > <span className={getElementClassNames(SORT_DIRECTION_LABEL)}> {SORT_DIRECTIONS[sortDirection].label} </span> </a> </Localizer> </div> ); }; const WORD_ACTION_INCLUDE = 'include'; const WORD_ACTION_EXCLUDE = 'exclude'; const SelectedWordActions = ({ context, bbox, word, matchType = STRING_MATCH_TYPE_EXACT, onAddFilter = noop, }) => { const [ isMenuDisplayed, setIsMenuDisplayed ] = useState(true); const onCloseMenu = () => setIsMenuDisplayed(false); const onSelect = action => { onCloseMenu(); onAddFilter({ word, matchType, isExcluded: WORD_ACTION_EXCLUDE === action, }) }; const getElementClassNames = useStyles(CLASS_NAMES, STYLE_SHEETS, [ context ]); const actions = [ { action: WORD_ACTION_INCLUDE, icon: 'check', labelId: 'view_list_with_word', defaultLabel: `View solutions with "${word}"`, labelFields: { word }, }, { action: WORD_ACTION_EXCLUDE, icon: 'times', labelId: 'view_list_without_word', defaultLabel: `View solutions without "${word}"`, labelFields: { word }, }, ]; if (!isMenuDisplayed) { return; } return (<div style={bbox} className={getElementClassNames(SELECTED_WORD_ACTIONS)}> <Dropdown context={context} options={actions} getOptionKey={({ action }) => action} onSelect={onSelect} onClose={onCloseMenu} /> </div> ); }; const ListPagination = ({ context, solutionCount, page, pageSize, onPageChange, onPageSizeChange, }) => { const getElementClassNames = useStyles(CLASS_NAMES, STYLE_SHEETS, [ context ]); const getSizeLabel = size => (PAGE_SIZE_ALL !== size) ? `${size}` : <Text id="all">all</Text>; const renderSizeLink = useCallback(size => ( isEqualPageSizes(size, pageSize) ? ( // Same page size <span className={getElementClassNames(CURRENT_PAGE_SIZE)}> {getSizeLabel(size)} </span> ) : ( // Different page size <a onClick={() => onPageSizeChange(size)} className={getElementClassNames(PAGE_SIZE_LINK)}> {getSizeLabel(size)} </a> ) ), [ pageSize, onPageSizeChange, getElementClassNames ]); const renderSizeOption = useCallback(size => ( <option value={size} selected={isEqualPageSizes(size, pageSize)} className={getElementClassNames(PAGE_SIZE_OPTION)} > {getSizeLabel(size)} </option> ), [ pageSize, getElementClassNames ]); const [ firstIndex, lastIndex ] = (PAGE_SIZE_ALL === pageSize) ? [ 1, solutionCount ] : [ (page - 1) * pageSize + 1, Math.min(solutionCount, page * pageSize) ]; return ( <div className={getElementClassNames(PAGINATION_WRAPPER)}> {(PAGE_SIZE_ALL !== pageSize) && ( <Pagination activePage={page} itemCountPerPage={pageSize} totalItemCount={solutionCount} onPageChange={onPageChange} context={context} /> )} <div className={getElementClassNames(PAGINATION_FOOTER)}> <div className={getElementClassNames(PAGINATION_STATE)}> {firstIndex} - {lastIndex} / {solutionCount} </div> <div className={getElementClassNames(PAGINATION_SIZE_WRAPPER)}> <Text id="per_page">per page:</Text> {PAGE_SIZES.map(renderSizeLink)} <div className={getElementClassNames(PAGE_SIZE_SELECT_WRAPPER)}> <select onChange={event => onPageSizeChange(event.target.value)} className={getElementClassNames(PAGE_SIZE_SELECT)} > {PAGE_SIZES.map(renderSizeOption)} </select> </div> </div> </div> </div> ) }; /** * @type {Function} * @param {number} matchType A match type. * @param {number} matches A set of match results. * @returns {boolean} Whether the given results include a match of the given type. */ const testMatches = (_1 & _2) === _1; /** * @param {string} string A string. * @param {string} substring The substring to search in the given string. * @returns {number} A set of match results corresponding to the positions of the substring in the string. */ const matchSubstring = (string, substring) => { let match = STRING_MATCH_TYPE_NONE; const [ first, last ] = boundIndicesOf(string, substring); if (first >= 0) { if (first === 0) { if (last + substring.length === string.length) { match = STRING_MATCH_TYPE_EXACT; } else { match = STRING_MATCH_TYPE_START; } } else if (last + substring.length === string.length) { match = STRING_MATCH_TYPE_END; } else if (first + last >= 0) { match = STRING_MATCH_TYPE_ANYWHERE; } } return match; }; /** * @typedef {Object} MatchResult The result of a match between a solution against a filter. * @property {boolean} isMatched Whether the solution matched the filter. * @property {number} matches A set of match results corresponding to the positions of the filter in the solution. * @property {boolean} isPartial Whether the results may still be refined. * @property {*} state If the results may be refined, a state indicating where to pick up next. */ /** * @param {import('../solutions.js').Solution} solution A solution. * @param {import('./FilterInput.js').WordFilter} filter A filter. * @param {number} matches A set of previous match results. * @param {number} index The index of the first word of the solution to match against the filter. * @returns {MatchResult} The result of the match between the given solution and filter. */ const matchSolutionOnWords = (solution, filter, matches, index = 0) => { const words = solution.matchingData.words; let isMatched; let isPartial; do { matches |= matchSubstring(words[index], filter.word); index = (STRING_MATCH_TYPE_EXACT === matches) ? words.length : index + 1; isPartial = (index < words.length); isMatched = testMatches(filter.matchType, matches); } while (!isMatched && isPartial); return { isMatched, matches, isPartial, state: index, }; }; /** * @param {import('../solutions.js').Solution} solution A solution. * @param {import('./FilterInput.js').WordFilter} filter A filter. * @returns {MatchResult} The result of the match between the given solution and filter. */ const matchSolutionOnSummary = (solution, filter) => { const matches = matchSubstring(solution.matchingData.summary, filter.word); return { isMatched: testMatches(filter.matchType, matches), matches, isPartial: false, } }; /** * @param {Function} matchSolution The callback usable to match a solution against a filter. * @param {import('../solutions.js').Solution[]} solutions A list of solutions. * @param {import('./FilterInput.js').WordFilter[]} filters A list of filters. * @param {Object} filterCache A cache for the results of filters. * @returns {import('../solutions.js').Solution[]} A sub-list of the solutions that matched the given filters. */ const filterSolutions = (matchSolution, solutions, filters, filterCache) => { for (const filter of filters) { if (!filterCache[filter.word]) { filterCache[filter.word] = {}; } } return solutions.filter(solution => { const id = solution.matchingData.id; for (const filter of filters) { const word = filter.word; let cache; let isMatched; if (filterCache[word][id]) { cache = filterCache[word][id]; isMatched = testMatches(filter.matchType, cache.matches); } else { cache = { matches: STRING_MATCH_TYPE_NONE, isPartial: true }; isMatched = false; } if (!isMatched && cache.isPartial) { ({ isMatched, ...cache } = matchSolution(solution, filter, cache.matches, cache.state)); filterCache[word][id] = cache; } if (isMatched === filter.isExcluded) { return false; } } return true; }); }; /** * Filters a list of solutions based on the words they contain. * * @type {Function} * @param {import('../solutions.js').Solution[]} solutions A list of solutions. * @param {import('./FilterInput.js').WordFilter[]} filters A list of filters. * @param {Object} filterCache A cache for the results of filters. * @returns {import('../solutions.js').Solution[]} A sub-list of the solutions that matched the given filters. */ const filterSolutionsUsingWords = filterSolutions(matchSolutionOnWords, _, _, _); /** * Filters a list of solutions based on their summaries. * * @type {Function} * @param {import('../solutions.js').Solution[]} solutions A list of solutions. * @param {import('./FilterInput.js').WordFilter[]} filters A list of filters. * @param {Object} filterCache A cache for the results of filters. * @returns {import('../solutions.js').Solution[]} A sub-list of the solutions that matched the given filters. */ const filterSolutionsUsingSummaries = filterSolutions(matchSolutionOnSummary, _, _, _); const SolutionList = forwardRef( ( { context = CONTEXT_CHALLENGE, solutions = [], matchingData = {}, onPageChange = noop, scrollOffsetGetter = (() => 0), }, listRef ) => { const isScoreAvailable = useMemo(() => { return solutions.some('score' in it); }, [ solutions ]); const sortTypes = getAvailableSortTypes(isScoreAvailable); const { state: sortType, nextState: nextSortType, next: setNextSortType, } = useLocalStorageList( 'sort-type', sortTypes, sortTypes[0] ); const { state: sortDirection, nextState: nextSortDirection, next: setNextSortDirection, } = useLocalStorageList( 'sort-direction', Object.keys(SORT_DIRECTIONS), SORT_DIRECTION_DESC ); const isFilterWordBased = !!matchingData.words; // Sort the solutions. const sortedSolutions = useMemo(() => ( solutions.slice() .sort( SORT_TYPE_SIMILARITY === sortType ? (SORT_DIRECTION_ASC === sortDirection ? invertComparison : identity)(Solution.compareByScore) : (SORT_DIRECTION_ASC === sortDirection ? identity : invertComparison)(Solution.compareByReference) ) ), [ solutions, sortType, sortDirection ]); // Filter the solutions. const filterCache = useRef({}).current; const [ filters, filtersRef, setFilters ] = useStateRef([]); const filteredSolutions = useMemo( () => ( isFilterWordBased ? filterSolutionsUsingWords : filterSolutionsUsingSummaries )(sortedSolutions, filters, filterCache), [ sortedSolutions, filters, filterCache, isFilterWordBased ] ); // Paginate and render the current solutions. const [ rawPage, setRawPage ] = useState(1); const shouldTriggerPageChange = useRef(false); const [ pageSize, setRawPageSize ] = useLocalStorage('page_size', DEFAULT_PAGE_SIZE); const page = (PAGE_SIZE_ALL === pageSize) ? 1 : Math.min(rawPage, Math.ceil(filteredSolutions.length / pageSize)); const setPage = useCallback(page => { setRawPage(page); shouldTriggerPageChange.current = true; }, [ setRawPage ]); const setPageSize = useCallback(size => { setRawPageSize(size); if (PAGE_SIZE_ALL === size) { setRawPage(1); } else { // Update the current page to keep the same solution at the top of the list. const sizeValue = Number(size); if (PAGE_SIZES.indexOf(sizeValue) === -1) { return; } const oldSize = (PAGE_SIZE_ALL === pageSize) ? filteredSolutions.length : Math.min(pageSize, filteredSolutions.length); setRawPage(Math.ceil(((page - 1) * oldSize + 1) / sizeValue)); } shouldTriggerPageChange.current = true; }, [ page, pageSize, filteredSolutions.length, setRawPageSize ]); const getElementClassNames = useStyles(CLASS_NAMES, STYLE_SHEETS, [ context ]); const solutionItems = useMemo(() => { const renderSolutionItem = solution => ( <li className={getElementClassNames(SOLUTION)}> {Solution.getReaderFriendlySummary(solution)} </li> ); const pageSolutions = (PAGE_SIZE_ALL === pageSize) ? filteredSolutions : filteredSolutions.slice((page - 1) * pageSize, page * pageSize); return pageSolutions.map(renderSolutionItem); }, [ page, pageSize, filteredSolutions, getElementClassNames ]); // Triggers the "page change" callback asynchronously, // to make sure it is run only when the changes have been applied to the UI. useEffect(() => { if (shouldTriggerPageChange.current) { setTimeout(onPageChange()); shouldTriggerPageChange.current = false; } }, [ solutionItems, onPageChange, shouldTriggerPageChange ]); const filterWrapperRef = useRef(); // Scrolls the filter input into view when it is focused. const onFilterFocus = useCallback(() => { filterWrapperRef.current && scrollElementIntoParentView(filterWrapperRef.current, scrollOffsetGetter(), 'smooth'); }, [ scrollOffsetGetter, filterWrapperRef ]); // Focuses the solution list when the filter input loses focus, to ensure that the list is scrollable again. const onFilterBlur = useCallback(() => listRef.current?.closest('[tabindex]')?.focus(), [ listRef ]); // Detects selected words, and proposes new filter options when relevant. const [ selectedWord, setSelectedWord ] = useState(null); useEffect(() => { // Detect when the left button is released to only propose suggestions when a selection has been committed. const onMouseUp = event => { if (listRef.current && (event.button === 0)) { const selection = document.getSelection(); if ( selection.anchorNode && (selection.anchorNode === selection.focusNode) && listRef.current.contains(selection.anchorNode) && (selection.anchorNode.parentNode.nodeName === 'LI') ) { // We are only interested in single-word selections. const words = Solution.getStringMatchableWords( selection.toString().trim(), matchingData.locale, matchingData.matchingOptions ); if (1 === words.length) { const selectedText = !isFilterWordBased ? selection.toString() : getWordAt( selection.anchorNode.wholeText, Math.floor((selection.anchorOffset + selection.focusOffset) / 2) ); const [ word = '' ] = Solution.getStringMatchableWords( selectedText, matchingData.locale, matchingData.matchingOptions ); if ( (!isFilterWordBased || (word.length > 1)) && !(filtersRef.current || []).some(it.word === word) ) { const bbox = selection.getRangeAt(0).getBoundingClientRect(); const offsetParent = getFixedElementPositioningParent(listRef.current); if (offsetParent) { const parentBbox = offsetParent.getBoundingClientRect(); bbox.x -= parentBbox.x; bbox.y -= parentBbox.y; } setSelectedWord({ word, bbox: { left: `${Math.floor(bbox.x)}px`, top: `${Math.floor(bbox.y)}px`, width: `${Math.ceil(bbox.width)}px`, height: `${Math.ceil(bbox.height)}px`, }, }); return; } } } } // Delay hiding the actions dropdown to let "click" events be triggered normally. setTimeout(() => setSelectedWord(null)); }; // Detect change events to ensure that suggestions are hidden when the selection is canceled. const onSelectionChange = () => { const selection = document.getSelection(); if (!selection || ('None' === selection.type)) { setSelectedWord(null); } }; document.addEventListener('mouseup', onMouseUp); document.addEventListener('selectionchange', onSelectionChange); return () => { document.removeEventListener('mouseup', onMouseUp); document.removeEventListener('selectionchange', onSelectionChange); } }); if (0 === solutions.length) { return null; } return ( <IntlProvider scope="solution_list"> <div> <h3 ref={filterWrapperRef} className={getElementClassNames(TITLE)}> <span className={getElementClassNames(TITLE_TEXT)}> <Text id="filter">Filter:</Text> </span> <FilterInput context={context} matchMode={isFilterWordBased ? STRING_MATCH_MODE_WORDS : STRING_MATCH_MODE_GLOBAL} matchingData={matchingData} minQueryLength={isFilterWordBased ? 2 : 1} filters={filters} onChange={setFilters} onFocus={onFilterFocus} onBlur={onFilterBlur} /> </h3> <div ref={listRef}> <h3 className={getElementClassNames(TITLE)}> <span className={getElementClassNames(TITLE_TEXT)}> <Text id="correct_solutions">Correct solutions:</Text> </span> <ListSortLinks context={context} availableSortTypes={sortTypes} sortType={sortType} nextSortType={nextSortType} sortDirection={sortDirection} nextSortDirection={nextSortDirection} onSortTypeToggle={() => setNextSortType()} onSortDirectionToggle={() => setNextSortDirection()} /> </h3> {(0 === filteredSolutions.length) ? ( <div className={getElementClassNames(EMPTY_LIST)}> <Text id="no_matching_solution">There is no matching solution.</Text> </div> ) : ( <Fragment> <ul>{solutionItems}</ul> {selectedWord && ( <SelectedWordActions {...selectedWord} context={context} matchType={isFilterWordBased ? STRING_MATCH_TYPE_EXACT : STRING_MATCH_TYPE_ANYWHERE} onAddFilter={setFilters([ ...filters, _ ])} /> )} <ListPagination context={context} solutionCount={filteredSolutions.length} page={page} pageSize={pageSize} onPageChange={setPage} onPageSizeChange={setPageSize} /> </Fragment> )} </div> </div> </IntlProvider> ); } ); export default SolutionList; const TITLE = 'title'; const TITLE_TEXT = 'title_text'; const TITLE_LINK_WRAPPER = 'title_link_wrapper'; const SORT_LINK = 'sort_link'; const SORT_TYPE_LABEL = 'sort_type_label'; const SORT_DIRECTION_LABEL = 'sort_direction_label'; const SINGLE_SORT_TYPE_LABEL = 'single_sort_type_label'; const EMPTY_LIST = 'empty_list'; const SOLUTION = 'solution'; const SELECTED_WORD_ACTIONS = 'selected_word_actions'; const PAGINATION_WRAPPER = 'pagination'; const PAGINATION_FOOTER = 'pagination_footer'; const PAGINATION_STATE = 'pagination_state'; const PAGINATION_SIZE_WRAPPER = 'pagination_size_wrapper'; const CURRENT_PAGE_SIZE = 'current_page_size'; const PAGE_SIZE_LINK = 'page_size_link'; const PAGE_SIZE_SELECT_WRAPPER = 'page_size_select_wrapper'; const PAGE_SIZE_SELECT = 'page_size_select'; const PAGE_SIZE_OPTION = 'page_size_option'; const CLASS_NAMES = { [CONTEXT_CHALLENGE]: { // Found in the "app" stylesheet. Adds the main link color. [SORT_LINK]: [ '_2__FI' ], // Found in the "app" stylesheet. Adds the page background color. [PAGINATION_WRAPPER]: [ '_3lUbm' ], [PAGE_SIZE_LINK]: [ '_2__FI' ], [PAGE_SIZE_SELECT_WRAPPER]: [ '_2__FI' ], [PAGE_SIZE_SELECT]: [ '_2__FI' ], }, [CONTEXT_FORUM]: { // Copied from the (heading) wrapper of the "Translation:" subtitle and the translation. [TITLE_TEXT]: [ '_2qRu2' ], // Copied from the "Reply" links. Only the class name which adds the color is used here. [SINGLE_SORT_TYPE_LABEL]: [ 'uFNEM' ], [SOLUTION]: [ '_2qRu2' ], // Found in the "ltr" stylesheet. Adds the main link color (unwanted styles are reset below). [PAGE_SIZE_SELECT_WRAPPER]: [ '_1bO3u' ], }, }; const STYLE_SHEETS = { [BASE]: StyleSheet.create({ [TITLE]: { alignItems: 'center', display: 'flex', flexWrap: 'wrap', justifyContent: 'space-between', }, [TITLE_TEXT]: { marginRight: '1em', '@media (max-width: 699px)': { marginBottom: '0.5em', }, }, [TITLE_LINK_WRAPPER]: { '@media (any-pointer: coarse)': { lineHeight: '2em', }, '@media (max-width: 699px)': { marginBottom: '0.5em', }, }, [SORT_LINK]: { cursor: 'pointer', marginRight: '0.5em', userSelect: 'none', whiteSpace: 'nowrap', '@media (any-pointer: coarse)': { display: 'inline-block', padding: '0.125em 1em', position: 'relative', ':active': { transform: 'translate3d(0, 2px, 0)', ':before': { borderWidth: '2px', }, }, ':before': { borderColor: 'currentColor', borderRadius: '12px', borderStyle: 'solid', borderWidth: '2px 2px 4px', bottom: 0, content: '""', display: 'block', left: 0, position: 'absolute', right: 0, top: 0, }, }, }, [SORT_TYPE_LABEL]: { userSelect: 'none', }, [SORT_DIRECTION_LABEL]: { fontSize: '1.2em', fontWeight: '900', }, [SINGLE_SORT_TYPE_LABEL]: { fontWeight: 'normal', marginRight: '0.5em', }, [SOLUTION]: { padding: '0.4em 0.5em 0.3em', ':nth-child(odd)': { background: 'rgba(0, 0, 0, 0.125)', }, }, [SELECTED_WORD_ACTIONS]: { position: 'fixed', }, [PAGINATION_WRAPPER]: { userSelect: 'none', }, [PAGINATION_FOOTER]: { alignItems: 'center', display: 'flex', flexWrap: 'wrap', justifyContent: 'center', marginTop: '1em', }, [PAGINATION_STATE]: { margin: '0 0.65em 0.5em', }, [PAGINATION_SIZE_WRAPPER]: { alignItems: 'center', display: 'flex', fontSize: '0.85em', margin: '0 0.65em 0.5em', }, [CURRENT_PAGE_SIZE]: { margin: '0 0.25em', '@media (any-pointer: coarse)': { display: 'none', } }, [PAGE_SIZE_LINK]: { cursor: 'pointer', margin: '0 0.25em', '@media (any-pointer: coarse)': { display: 'none', } }, [PAGE_SIZE_SELECT_WRAPPER]: { display: 'none', marginLeft: '0.5em', padding: '0', position: 'relative', // Fixes the display of the border with Darklingo++. transform: 'translate3d(0, 0, 0)', ':active': { transform: 'translate3d(0, 2px, 0)', ':before': { borderWidth: '2px', }, }, ':before': { borderColor: 'currentColor', borderRadius: '12px', borderStyle: 'solid', borderWidth: '2px 2px 4px', bottom: 0, content: '""', display: 'block', left: 0, position: 'absolute', right: 0, top: 0, zIndex: -1, }, '@media (any-pointer: coarse)': { display: 'block', }, }, [PAGE_SIZE_SELECT]: { appearance: 'none', background: 'none', border: 0, fontWeight: 'bold', padding: '0.75em', textAlign: 'center', textAlignLast: 'center', }, [PAGE_SIZE_OPTION]: { background: 'initial', color: 'initial', }, }), [CONTEXT_CHALLENGE]: StyleSheet.create({ [SORT_LINK]: { fontSize: '0.75em', }, [SINGLE_SORT_TYPE_LABEL]: { fontSize: '0.75em', }, [PAGINATION_WRAPPER]: { bottom: '0', paddingTop: '0.1em', position: 'sticky', }, }), [CONTEXT_FORUM]: StyleSheet.create({ [TITLE_LINK_WRAPPER]: { '@media (max-width: 699px)': { marginBottom: '0.5em', }, }, [TITLE_TEXT]: { padding: 0, textTransform: 'none', }, [EMPTY_LIST]: { paddingBottom: '1em', }, [SORT_TYPE_LABEL]: { marginRight: '0.5em', textTransform: 'none', }, [PAGE_SIZE_SELECT]: { color: 'inherit', }, }), };