diff --git a/e2e/screenshots.spec.ts-snapshots/data-page.png b/e2e/screenshots.spec.ts-snapshots/data-page.png index 2d73363c2..cd74ee3be 100644 Binary files a/e2e/screenshots.spec.ts-snapshots/data-page.png and b/e2e/screenshots.spec.ts-snapshots/data-page.png differ diff --git a/src/app/controls.css b/src/app/controls.css index fe70aa6dc..d39c7c83d 100644 --- a/src/app/controls.css +++ b/src/app/controls.css @@ -5,6 +5,45 @@ flex-wrap: wrap; } +.selector .selectorButtons { + position: relative; + display: flex; + align-items: center; + flex-direction: row; +} + +.selector.buttonList .selectorOptions { + display: flex; + flex-wrap: wrap; + gap: 0.25em; +} + +.selector.filterList { + display: flex; + flex-direction: column; + width: 100%; + align-items: flex-start; + align-self: stretch; +} + +.selector.filterList .selectorOptions { + display: flex; + flex-direction: column; + align-items: flex-start; +} + +.selector.filterList .selectorButtons { + position: unset; + width: 100%; + align-items: flex-start; + flex-direction: column; +} + +.selector.filterList .filterListMoreButton { + font-size: 0.9em; + padding: 0.25em 3.5em; +} + .selector.buttonList { flex-direction: column; align-items: start; diff --git a/src/features/params/ui/Selector.tsx b/src/features/params/ui/Selector.tsx index 6f135a960..e4f986afd 100644 --- a/src/features/params/ui/Selector.tsx +++ b/src/features/params/ui/Selector.tsx @@ -26,6 +26,8 @@ type Props = { selectorStyle?: React.CSSProperties; }; +const FILTER_LIST_INITIAL_COUNT = 5; + function Selector({ display, getOptionDescription, @@ -41,14 +43,25 @@ function Selector({ }: Props) { const [expanded, setExpanded] = useState(false); const optionsRef = useClickOutside(() => { - setTimeout(() => setExpanded(false), 100); + // Only auto-collapse for dropdown modes; FilterList is a persistent filter UI + if (display !== SelectorDisplay.FilterList) { + setTimeout(() => setExpanded(false), 100); + } }); + // For FilterList: collapse to first N items unless expanded + const isFilterList = display === SelectorDisplay.FilterList; + const filterListCollapsed = + isFilterList && !expanded && options.length > FILTER_LIST_INITIAL_COUNT; + const visibleOptions = filterListCollapsed + ? options.slice(0, FILTER_LIST_INITIAL_COUNT) + : options; + return ( {selectorLabel && } -
+
{/* The dropdown menu or the button list */} ({ getOptionDescription={getOptionDescription} getOptionLabel={getOptionLabel} onClick={(option) => { - setExpanded(false); + if (!isFilterList) setExpanded(false); onChange(option); }} optionStyle={optionStyle} - options={options} + options={visibleOptions} selected={selected} /> + {/* "More / Less" toggle for FilterList when there are enough options */} + setExpanded((prev) => !prev)} + /> + {/* Standalone option to open/close the dropdown menu */} getOptionDescription={getOptionDescription} @@ -120,13 +140,19 @@ const OptionsContainer: React.FC> case SelectorDisplay.ButtonList: return (
+ {children} +
+ ); + case SelectorDisplay.FilterList: + return ( +
{children}
@@ -174,6 +200,28 @@ function Options({ )); } +type FilterListMoreButtonProps = { + totalCount: number; + isExpanded: boolean; + toggle: () => void; +}; + +const FilterListMoreButton: React.FC = ({ + totalCount, + isExpanded, + toggle, +}) => { + const { display } = useSelectorDisplay(); + if (display !== SelectorDisplay.FilterList || totalCount <= FILTER_LIST_INITIAL_COUNT) + return null; + + return ( + + ); +}; + type DropdownButtonProps = { getOptionDescription?: (value: T) => React.ReactNode; getOptionLabel?: (value: T) => React.ReactNode; diff --git a/src/features/params/ui/SelectorDisplayContext.tsx b/src/features/params/ui/SelectorDisplayContext.tsx index ba673b6d9..2dbb81d1b 100644 --- a/src/features/params/ui/SelectorDisplayContext.tsx +++ b/src/features/params/ui/SelectorDisplayContext.tsx @@ -5,6 +5,7 @@ export enum SelectorDisplay { InlineDropdown = 'inlineDropdown', // Used to be inline with text ButtonGroup = 'buttonGroup', // Unsure if we want to keep this ButtonList = 'buttonList', + FilterList = 'filterList', } export const SelectorDisplayContext = React.createContext<{ diff --git a/src/features/params/ui/SelectorLabel.tsx b/src/features/params/ui/SelectorLabel.tsx index 9fbb73d4c..99c6c3186 100644 --- a/src/features/params/ui/SelectorLabel.tsx +++ b/src/features/params/ui/SelectorLabel.tsx @@ -29,9 +29,8 @@ function getStyle(display: SelectorDisplay): React.CSSProperties { const style: React.CSSProperties = { display: 'flex', gap: '0.25em', - lineHeight: '1em', alignItems: 'center', - fontWeight: 'bold', + fontWeight: '800', // adjusted font weight for easier visibility padding: '0.5em', margin: 'auto 0', // Vertically center whiteSpace: 'nowrap', @@ -50,6 +49,11 @@ function getStyle(display: SelectorDisplay): React.CSSProperties { case SelectorDisplay.InlineDropdown: style.padding = '0 0.5em'; break; + case SelectorDisplay.FilterList: + style.padding = '0 0 0.5em 0.5em'; + style.lineHeight = '2.25em'; // more spacing for visibility + style.marginBottom = '-0.5em'; // adjusted to have selector buttons closer to their label + break; case SelectorDisplay.Dropdown: // nothing special break; diff --git a/src/features/params/ui/SelectorOption.tsx b/src/features/params/ui/SelectorOption.tsx index 33689e11a..00baf6647 100644 --- a/src/features/params/ui/SelectorOption.tsx +++ b/src/features/params/ui/SelectorOption.tsx @@ -138,6 +138,13 @@ export function getOptionStyle( style.borderRadius = '1em'; if (!isSelected) style.border = '0.125em solid var(--color-button-secondary)'; break; + case SelectorDisplay.FilterList: + style.border = 'none'; + style.borderRadius = '0.5em'; + style.padding = '0.2em 0.4em'; + style.lineHeight = '1.5em'; + style.margin = '0.1em'; + break; case SelectorDisplay.InlineDropdown: // The standalone option should match the regular page text if (position === PositionInGroup.Standalone) { diff --git a/src/features/transforms/filtering/selectors/FilterSelector.tsx b/src/features/transforms/filtering/selectors/FilterSelector.tsx index 619945035..28dd5d90c 100644 --- a/src/features/transforms/filtering/selectors/FilterSelector.tsx +++ b/src/features/transforms/filtering/selectors/FilterSelector.tsx @@ -31,17 +31,17 @@ const FilterSelector: React.FC = ({ field }) => { case Field.WritingSystem: return ; case Field.Modality: - return ; + return ; case Field.LanguageScope: - return ; + return ; case Field.TerritoryScope: - return ; + return ; case Field.ISOStatus: - return ; + return ; case Field.Name: return ; // Technically correct but not recommended usage case Field.SourceForLanguage: - return ; + return ; case Field.Population: return ; default: diff --git a/src/features/transforms/filtering/selectors/LanguageModalitySelector.tsx b/src/features/transforms/filtering/selectors/LanguageModalitySelector.tsx index 0476a557b..7f8b055b1 100644 --- a/src/features/transforms/filtering/selectors/LanguageModalitySelector.tsx +++ b/src/features/transforms/filtering/selectors/LanguageModalitySelector.tsx @@ -1,12 +1,15 @@ import React from 'react'; import Selector from '@features/params/ui/Selector'; +import { SelectorDisplay } from '@features/params/ui/SelectorDisplayContext'; import usePageParams from '@features/params/usePageParams'; import { LanguageModality } from '@entities/language/LanguageModality'; import { getModalityLabel } from '@entities/language/LanguageModalityDisplay'; -const LanguageModalitySelector: React.FC = () => { +type Props = { display?: SelectorDisplay }; + +const LanguageModalitySelector: React.FC = ({ display }) => { const { modalityFilter, updatePageParams } = usePageParams(); const selectorDescription = @@ -29,6 +32,7 @@ const LanguageModalitySelector: React.FC = () => { } selected={modalityFilter} getOptionLabel={getModalityLabel} + display={display} /> ); }; diff --git a/src/features/transforms/filtering/selectors/LanguageScopeSelector.tsx b/src/features/transforms/filtering/selectors/LanguageScopeSelector.tsx index e64da2dc1..33fe896cb 100644 --- a/src/features/transforms/filtering/selectors/LanguageScopeSelector.tsx +++ b/src/features/transforms/filtering/selectors/LanguageScopeSelector.tsx @@ -1,13 +1,16 @@ import React from 'react'; import Selector from '@features/params/ui/Selector'; +import { SelectorDisplay } from '@features/params/ui/SelectorDisplayContext'; import usePageParams from '@features/params/usePageParams'; import { LanguageScope } from '@entities/language/LanguageTypes'; import { getLanguageScopeDescription, getLanguageScopeLabel } from '@strings/LanguageScopeStrings'; -const LanguageScopeSelector: React.FC = () => { +type Props = { display?: SelectorDisplay }; + +const LanguageScopeSelector: React.FC = ({ display }) => { const { languageScopes, updatePageParams } = usePageParams(); const selectorDescription = @@ -27,6 +30,7 @@ const LanguageScopeSelector: React.FC = () => { selected={languageScopes} getOptionLabel={getLanguageScopeLabel} getOptionDescription={getLanguageScopeDescription} + display={display} /> ); }; diff --git a/src/features/transforms/filtering/selectors/TerritoryScopeSelector.tsx b/src/features/transforms/filtering/selectors/TerritoryScopeSelector.tsx index 5deb30985..04f261f99 100644 --- a/src/features/transforms/filtering/selectors/TerritoryScopeSelector.tsx +++ b/src/features/transforms/filtering/selectors/TerritoryScopeSelector.tsx @@ -1,13 +1,16 @@ import React from 'react'; import Selector from '@features/params/ui/Selector'; +import { SelectorDisplay } from '@features/params/ui/SelectorDisplayContext'; import usePageParams from '@features/params/usePageParams'; import { TerritoryScope } from '@entities/territory/TerritoryTypes'; import { getTerritoryScopeLabel } from '@strings/TerritoryScopeStrings'; -const TerritoryScopeSelector: React.FC = () => { +type Props = { display?: SelectorDisplay }; + +const TerritoryScopeSelector: React.FC = ({ display }) => { const { territoryScopes, updatePageParams } = usePageParams(); const selectorDescription = @@ -28,6 +31,7 @@ const TerritoryScopeSelector: React.FC = () => { } selected={territoryScopes} getOptionLabel={getTerritoryScopeLabel} + display={display} /> ); }; diff --git a/src/features/transforms/filtering/selectors/VitalitySelector.tsx b/src/features/transforms/filtering/selectors/VitalitySelector.tsx index e23078a51..c79057458 100644 --- a/src/features/transforms/filtering/selectors/VitalitySelector.tsx +++ b/src/features/transforms/filtering/selectors/VitalitySelector.tsx @@ -1,6 +1,7 @@ import React from 'react'; import Selector from '@features/params/ui/Selector'; +import { SelectorDisplay } from '@features/params/ui/SelectorDisplayContext'; import usePageParams from '@features/params/usePageParams'; import { @@ -18,7 +19,9 @@ import { const ETH_DISABLED = true; -export const LanguageISOStatusSelector: React.FC = () => { +type Props = { display?: SelectorDisplay }; + +export const LanguageISOStatusSelector: React.FC = ({ display }) => { const { isoStatus, updatePageParams } = usePageParams(); return ( @@ -34,6 +37,7 @@ export const LanguageISOStatusSelector: React.FC = () => { } selected={isoStatus} getOptionLabel={getLanguageISOStatusLabel} + display={display} /> ); }; diff --git a/src/features/transforms/filtering/selectors/__tests__/VitalitySelector.test.tsx b/src/features/transforms/filtering/selectors/__tests__/VitalitySelector.test.tsx index e8f76cfb5..6f979ccbd 100644 --- a/src/features/transforms/filtering/selectors/__tests__/VitalitySelector.test.tsx +++ b/src/features/transforms/filtering/selectors/__tests__/VitalitySelector.test.tsx @@ -18,8 +18,8 @@ import { vi.mock('@features/params/usePageParams', () => ({ default: vi.fn() })); vi.mock('@features/params/ui/SelectorDisplayContext', () => ({ - useSelectorDisplay: vi.fn().mockReturnValue({ display: 'buttonList' }), - SelectorDisplay: { ButtonList: 'buttonList', Dropdown: 'dropdown' }, + useSelectorDisplay: vi.fn().mockReturnValue({ display: 'filterList' }), + SelectorDisplay: { ButtonList: 'buttonList', Dropdown: 'dropdown', FilterList: 'filterList' }, SelectorDisplayProvider: ({ children }: { children: React.ReactNode }) => <>{children}, })); vi.mock('@features/layers/hovercard/useHoverCard', () => ({ @@ -59,10 +59,17 @@ describe('VitalitySelector', () => { }); describe('LanguageISOStatusSelector', () => { - it('displays all ISO vitality options', () => { + it('displays all ISO vitality options', async () => { + const user = userEvent.setup(); render(); + const expected = Object.values(LanguageISOStatus).filter((v) => typeof v === 'number'); + // If there are more than 4 options expand first so all are visible + if (expected.length > 4) { + await user.click(screen.getByText('Expand All')); + } + expected.forEach((status) => { const label = getLanguageISOStatusLabel(status); expect(screen.getByText(label)).toBeInTheDocument();