Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 21 additions & 7 deletions src/components/Layout/LayoutHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useContext, useRef } from 'react';
import { useContext, useRef, useState, useMemo } from 'react';
import { useRouter } from 'next/router';
import { Button, Flex, View, VisuallyHidden } from '@aws-amplify/ui-react';
import classNames from 'classnames';
Expand All @@ -19,6 +19,8 @@ import { PageLastUpdated } from '../PageLastUpdated';
import Feedback from '../Feedback';
import RepoActions from '../Menu/RepoActions';
import { usePathWithoutHash } from '@/utils/usePathWithoutHash';
import { SearchFilters } from '@/components/Search';
import type { GenFilter, PlatformFilter } from '@/components/Search';

export const LayoutHeader = ({
currentPlatform,
Expand All @@ -39,6 +41,14 @@ export const LayoutHeader = ({
const router = useRouter();
const asPathWithNoHash = usePathWithoutHash();

const [platformFilter, setPlatformFilter] = useState<PlatformFilter>('all');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why using 'all' by default?

could we not utilize currentPlatform and fallback to all?

Suggested change
const [platformFilter, setPlatformFilter] = useState<PlatformFilter>('all');
const [platformFilter, setPlatformFilter] = useState<PlatformFilter>(currentPlatform ?? 'all');

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We intentionally default to "All" so search covers the full docs by default — users often search for concepts that span platforms (e.g. "auth", "data modeling"). The platform sidebar already filters navigation to the current platform, so having search default to "All" gives a broader discovery experience. If the user wants platform-specific results, they can narrow via the dropdown.

const [genFilter, setGenFilter] = useState<GenFilter>('gen2');

const searchParams = useMemo(() => ({
...(platformFilter !== 'all' && { optionalFacetFilters: [`platform:${platformFilter}`] }),
...(genFilter !== 'both' && { facetFilters: [`gen:${genFilter}`] })
}), [platformFilter, genFilter]);

const handleMenuToggle = () => {
if (!menuOpen) {
toggleMenuOpen(true);
Expand Down Expand Up @@ -90,13 +100,17 @@ export const LayoutHeader = ({
appId={process.env.ALGOLIA_APP_ID || ALGOLIA_APP_ID}
indexName={process.env.ALGOLIA_INDEX_NAME || ALGOLIA_INDEX_NAME}
apiKey={process.env.ALGOLIA_API_KEY || ALGOLIA_API_KEY}
searchParameters={{
facetFilters: [
`platform:${currentPlatform}`,
`gen:${isGen1 ? 'gen1' : 'gen2'}`
]
}}
searchParameters={searchParams}
transformItems={transformItems}
getMissingResultsUrl={({ query }) =>
`https://github.com/aws-amplify/docs/issues/new?title=${encodeURIComponent(`[search] Missing results for: ${query}`)}&labels=v2`
}
/>
<SearchFilters
platformFilter={platformFilter}
genFilter={genFilter}
onPlatformChange={setPlatformFilter}
onGenChange={setGenFilter}
/>
</View>
</View>
Expand Down
98 changes: 98 additions & 0 deletions src/components/Search/SearchFilters.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { useState, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { PLATFORMS, PLATFORM_DISPLAY_NAMES } from '@/data/platforms';
import type { Platform } from '@/data/platforms';

export type GenFilter = 'gen1' | 'gen2' | 'both';
export type PlatformFilter = Platform | 'all';

interface SearchFiltersProps {
platformFilter: PlatformFilter;
genFilter: GenFilter;
onPlatformChange: (platform: PlatformFilter) => void;
onGenChange: (gen: GenFilter) => void;
}

const GEN_DISPLAY: Record<GenFilter, string> = {
gen2: 'Gen 2',
gen1: 'Gen 1',
both: 'Both'
};

export function SearchFilters({
platformFilter,
genFilter,
onPlatformChange,
onGenChange
}: SearchFiltersProps) {
const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(null);

const cleanupContainer = useCallback((container: HTMLElement | null) => {
container?.remove();
}, []);

useEffect(() => {
const observer = new MutationObserver(() => {
const modal = document.querySelector('.DocSearch-Modal');
const searchBar = modal?.querySelector('.DocSearch-SearchBar');

if (searchBar && !modal?.querySelector('.search-filters-container')) {
const container = document.createElement('div');
container.className = 'search-filters-container';
searchBar.insertAdjacentElement('afterend', container);
setPortalContainer(container);
} else if (!modal) {
setPortalContainer((prev) => {
cleanupContainer(prev);
return null;
});
}
});
observer.observe(document.body, { childList: true, subtree: false });
return () => {
observer.disconnect();
setPortalContainer((prev) => {
cleanupContainer(prev);
return null;
});
};
}, [cleanupContainer]);

if (!portalContainer) return null;

return createPortal(
<div className="search-filters" role="toolbar" aria-label="Search filters">
<div className="search-filters__group">
<span className="search-filters__label">Platform:</span>
<select
className="search-filters__select"
value={platformFilter}
onChange={(e) => onPlatformChange(e.target.value as PlatformFilter)}
aria-label="Platform filter"
>
<option value="all">All</option>
{PLATFORMS.map((p) => (
<option key={p} value={p}>
{PLATFORM_DISPLAY_NAMES[p]}
</option>
))}
</select>
</div>
<div className="search-filters__separator" />
<div className="search-filters__group">
<span className="search-filters__label">Version:</span>
<select
className="search-filters__select"
value={genFilter}
onChange={(e) => onGenChange(e.target.value as GenFilter)}
aria-label="Generation filter"
>
<option value="gen2">{GEN_DISPLAY.gen2}</option>
<option value="gen1">{GEN_DISPLAY.gen1}</option>
<option value="both">{GEN_DISPLAY.both}</option>
</select>
</div>
</div>,
portalContainer
);
}
101 changes: 101 additions & 0 deletions src/components/Search/__tests__/SearchFilters.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import * as React from 'react';
import { render, waitFor, act, fireEvent } from '@testing-library/react';
import { SearchFilters } from '../SearchFilters';
import type { GenFilter, PlatformFilter } from '../SearchFilters';

describe('SearchFilters', () => {
let mockModal: HTMLElement;
let onPlatformChange: jest.Mock;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should test these interactions as well, onPlatformChange and onGenChange are unused.

let onGenChange: jest.Mock;

beforeEach(() => {
onPlatformChange = jest.fn();
onGenChange = jest.fn();
});

afterEach(() => {
if (mockModal) mockModal.remove();
});

function renderFilters(
platformFilter: PlatformFilter = 'react',
genFilter: GenFilter = 'gen2'
) {
const result = render(
<SearchFilters
platformFilter={platformFilter}
genFilter={genFilter}
onPlatformChange={onPlatformChange}
onGenChange={onGenChange}
/>
);
// Simulate DocSearch modal DOM structure
mockModal = document.createElement('div');
mockModal.className = 'DocSearch-Modal';
const searchBar = document.createElement('div');
searchBar.className = 'DocSearch-SearchBar';
const form = document.createElement('form');
form.className = 'DocSearch-Form';
searchBar.appendChild(form);
mockModal.appendChild(searchBar);
document.body.appendChild(mockModal);
return result;
}

async function waitForFilters() {
await waitFor(() => {
expect(mockModal.querySelector('.search-filters')).toBeInTheDocument();
});
}

it('should render both select dropdowns inside the modal', async () => {
renderFilters();
await waitForFilters();
const selects = mockModal.querySelectorAll('.search-filters__select');
expect(selects.length).toBe(2);
});

it('should include "All" option in platform select', async () => {
renderFilters();
await waitForFilters();
const platformSelect = mockModal.querySelectorAll('.search-filters__select')[0] as HTMLSelectElement;
const options = Array.from(platformSelect.options).map((o) => o.value);
expect(options).toContain('all');
expect(options).toContain('react');
});

it('should include Gen options in version select', async () => {
renderFilters();
await waitForFilters();
const genSelect = mockModal.querySelectorAll('.search-filters__select')[1] as HTMLSelectElement;
const options = Array.from(genSelect.options).map((o) => o.value);
expect(options).toEqual(['gen2', 'gen1', 'both']);
});

it('should reflect current platform value', async () => {
renderFilters('swift', 'gen2');
await waitForFilters();
const platformSelect = mockModal.querySelectorAll('.search-filters__select')[0] as HTMLSelectElement;
expect(platformSelect.value).toBe('swift');
});

it('should call onPlatformChange when platform is changed', async () => {
renderFilters('react', 'gen2');
await waitForFilters();
const platformSelect = mockModal.querySelectorAll('.search-filters__select')[0] as HTMLSelectElement;
await act(async () => {
fireEvent.change(platformSelect, { target: { value: 'angular' } });
});
expect(onPlatformChange).toHaveBeenCalledWith('angular');
});

it('should call onGenChange when version is changed', async () => {
renderFilters('react', 'gen2');
await waitForFilters();
const genSelect = mockModal.querySelectorAll('.search-filters__select')[1] as HTMLSelectElement;
await act(async () => {
fireEvent.change(genSelect, { target: { value: 'gen1' } });
});
expect(onGenChange).toHaveBeenCalledWith('gen1');
});
});
2 changes: 2 additions & 0 deletions src/components/Search/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { SearchFilters } from './SearchFilters';
export type { GenFilter, PlatformFilter } from './SearchFilters';
41 changes: 41 additions & 0 deletions src/styles/algolia.scss
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,44 @@
box-shadow: inset 0 0 0 2px var(--amplify-colors-border-focus);
}
}

// Search filters — compact row below search input inside the modal
.search-filters {
display: flex;
align-items: center;
gap: var(--amplify-space-xs);
padding: 6px var(--amplify-space-small);
border-bottom: 1px solid var(--amplify-colors-border-tertiary);
font-size: 11px;

&__group {
display: flex;
align-items: center;
gap: 4px;
}

&__label {
color: var(--amplify-colors-font-tertiary);
}

&__separator {
width: 1px;
height: 16px;
background: var(--amplify-colors-border-tertiary);
}

&__select {
font-size: 11px;
padding: 2px 4px;
border: 1px solid var(--amplify-colors-border-secondary);
border-radius: var(--amplify-radii-small);
background: var(--amplify-colors-background-primary);
color: var(--amplify-colors-font-primary);
cursor: pointer;

&:focus-visible {
outline: 2px solid var(--amplify-colors-border-focus);
outline-offset: 1px;
}
}
}
Loading