diff --git a/src/components/Chevron.astro b/src/components/Chevron.astro index 00922aa4af..9c7baa2329 100644 --- a/src/components/Chevron.astro +++ b/src/components/Chevron.astro @@ -1,17 +1,13 @@ --- +import { CHEVRON_PATHS, type ChevronDirection } from '@components/chevron-paths'; + interface Props { - direction: 'left' | 'right' | 'down'; + direction: ChevronDirection; size?: number; class?: string; } const { direction, size = 20, class: className } = Astro.props; - -const paths: Record = { - left: 'm15 6-6 6 6 6', - right: 'm9 6 6 6-6 6', - down: 'm6 9 6 6 6-6', -}; --- = { aria-hidden="true" >
- -
- - - {formatPageSummary(currentPage, totalPages)} - - -
-
-
- - Items per page: - - - -
- - - - - diff --git a/src/components/IotHub/SearchFilterBar.astro b/src/components/IotHub/SearchFilterBar.astro index 41beb676f8..0007635272 100644 --- a/src/components/IotHub/SearchFilterBar.astro +++ b/src/components/IotHub/SearchFilterBar.astro @@ -1,7 +1,7 @@ --- import { IOT_HUB_STRINGS, SEARCH_PAGE_SIZE } from '@models/iot-hub'; import IotHubSort from './IotHubSort.astro'; -import PaginationLink from './PaginationLink.astro'; +import PaginationBar from '@components/Pagination/PaginationBar.astro'; const T = IOT_HUB_STRINGS.searchPage; @@ -88,11 +88,12 @@ const dynamicHeading = !!headingPrefix && searchText.length > 0;

{totalResults} {resultsWord}

{totalPages > 1 && ( - )} @@ -233,7 +234,7 @@ const dynamicHeading = !!headingPrefix && searchText.length > 0; color: var(--color-text); } - .search-bar__row2 :global(.iot-hub-pagination) { + .search-bar__row2 :global(.tb-pagination-bar) { flex: 1 1 0; } diff --git a/src/components/IotHub/iot-hub-dynamic-search.ts b/src/components/IotHub/iot-hub-dynamic-search.ts index 11c339b041..687a96d021 100644 --- a/src/components/IotHub/iot-hub-dynamic-search.ts +++ b/src/components/IotHub/iot-hub-dynamic-search.ts @@ -1,6 +1,7 @@ import { IOT_HUB_API_URL, IOT_HUB_CATEGORIES, + IOT_HUB_STRINGS, SEARCH_PAGE_SIZE, DEFAULT_IOT_HUB_SORT_ID, getCardVariant, @@ -11,10 +12,17 @@ import { } from '@models/iot-hub'; import { bindListingCard } from './iot-hub-listing-card-bind'; import { getKnownSlugs } from './iot-hub-known-slugs'; -import { - updatePaginationDynamic, - updateResultsCount, -} from './iot-hub-pagination-update'; +import { updatePagination } from '@components/Pagination/pagination-client'; +import { setPerPageValue } from '@components/Pagination/per-page-client'; + +// Host-visible "N results" line next to the pagination. +function updateResultsCount(countEl: HTMLElement, totalResults: number): void { + const word = + totalResults === 1 + ? IOT_HUB_STRINGS.searchPage.resultSingular + : IOT_HUB_STRINGS.searchPage.resultPlural; + countEl.textContent = `${totalResults} ${word}`; +} // Shared dynamic-search pipeline used by the search page, the creator // page, the category pages, and any future surface that lists @@ -50,7 +58,7 @@ import { // URL state: q / sort / page / pageSize are mirrored in the query string // via history.replaceState on every fetch. On load, any non-default value // triggers an immediate fetch and is reflected in the matching UI control -// (SearchFilterBar input, IotHubSort selection, PaginationLink per-page). +// (SearchFilterBar input, IotHubSort selection, Pagination per-page). const DEBOUNCE_MS = 300; const ITEM_TYPE_BY_TYPE = new Map(IOT_HUB_CATEGORIES.map((c) => [c.itemType, c])); @@ -131,7 +139,11 @@ export function setupDynamicSearch(): void { const input = root.querySelector('[data-search-input]'); const resultsContainer = root.querySelector('[data-search-results]'); const itemsWrap = root.querySelector('[data-search-items]'); - const paginationNav = root.querySelector('[data-iot-hub-pagination]'); + const paginationNav = root.querySelector('[data-tb-pagination]'); + // The bar wraps the nav + the items-per-page selector. Error/no-data states + // hide the whole bar; a single page of results hides only the nav (via + // updatePagination's hideOnSinglePage) so the per-page control stays usable. + const paginationBar = root.querySelector('[data-tb-pagination-bar]'); const countEl = root.querySelector('[data-search-count]'); const noResults = root.querySelector('[data-iot-hub-no-results]'); const fetchError = root.querySelector('[data-iot-hub-fetch-error]'); @@ -181,9 +193,11 @@ export function setupDynamicSearch(): void { if (show) { resultsContainer!.replaceChildren(); noResults!.hidden = true; - if (paginationNav) paginationNav.hidden = true; - } else if (paginationNav) { - paginationNav.hidden = false; + // Hide the whole bar (nav + per-page) on error; the success path + // re-shows it and updatePagination re-applies the single-page rule. + if (paginationBar) paginationBar.hidden = true; + } else if (paginationBar) { + paginationBar.hidden = false; } } @@ -227,19 +241,7 @@ export function setupDynamicSearch(): void { function applyPageSizeToUi(size: number): void { const perPageRoot = root!.querySelector('[data-per-page-root]'); - if (!perPageRoot) return; - const target = perPageRoot.querySelector( - `[data-per-page-option][data-per-page-value="${size}"]` - ); - if (!target) return; - perPageRoot.querySelectorAll('[data-per-page-option]').forEach((opt) => { - const isSelected = opt === target; - opt.classList.toggle('iot-hub-pagination__per-page-option--selected', isSelected); - opt.setAttribute('aria-pressed', String(isSelected)); - }); - const labelEl = perPageRoot.querySelector('[data-per-page-label]'); - if (labelEl) labelEl.textContent = String(size); - perPageRoot.dataset.perPage = String(size); + if (perPageRoot) setPerPageValue(perPageRoot, size); } // --- DOM builders ------------------------------------------------------ @@ -367,7 +369,11 @@ export function setupDynamicSearch(): void { showFetchError(false); renderResults(items); if (paginationNav) { - updatePaginationDynamic(paginationNav, { currentPage, totalPages }); + // Hide the page-number nav when a filter narrows results to a + // single page, matching the other surfaces. Safe here because the + // per-page selector lives in the bar (sibling of the nav), so it + // stays visible — letting the user lower the page size again. + updatePagination(paginationNav, { currentPage, totalPages, hideOnSinglePage: true }); } updateResultsCount(countEl!, body.totalElements ?? 0); } catch (err) { @@ -469,14 +475,14 @@ export function setupDynamicSearch(): void { void refetch({ resetPage: true }); }) as EventListener); - root.addEventListener('iot-hub-page-size:change', ((e: CustomEvent) => { + root.addEventListener('tb-pagination:page-size-change', ((e: CustomEvent) => { const next = Number.parseInt(e.detail?.perPage ?? '0', 10); if (!Number.isFinite(next) || next <= 0) return; pageSize = next; void refetch({ resetPage: true }); }) as EventListener); - root.addEventListener('iot-hub-page:change', ((e: CustomEvent) => { + root.addEventListener('tb-pagination:page-change', ((e: CustomEvent) => { const next = Number.parseInt(e.detail?.page ?? '0', 10); if (!Number.isFinite(next) || next <= 0) return; currentPage = next; diff --git a/src/components/IotHub/iot-hub-pagination-update.ts b/src/components/IotHub/iot-hub-pagination-update.ts deleted file mode 100644 index 9ab78cfc24..0000000000 --- a/src/components/IotHub/iot-hub-pagination-update.ts +++ /dev/null @@ -1,168 +0,0 @@ -// Runtime updater for PaginationLink. When the host (dynamic search page, -// future dynamic category pages) flips a pagination nav into dynamic mode, -// it calls `updatePaginationDynamic(nav, { currentPage, totalPages })` to -// rebuild the page-number list with event-based buttons. Clicks dispatch -// `iot-hub-page:change` { page } bubbling events on the nav root — the -// host listens and refetches. - -import { IOT_HUB_STRINGS, formatPageSummary } from '@models/iot-hub'; - -interface PaginationState { - currentPage: number; - totalPages: number; -} - -// Same page-list algorithm as the static buildPages() in PaginationLink.astro -// so the dynamic UI keeps the same ellipsis ranges users are used to. -function buildPages(current: number, total: number): Array { - if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1); - const result: Array = [1]; - if (current > 3) result.push('ellipsis'); - for (let p = Math.max(2, current - 1); p <= Math.min(total - 1, current + 1); p++) { - result.push(p); - } - if (current < total - 2) result.push('ellipsis'); - result.push(total); - return result; -} - -const CHEVRON_PATHS = { - left: 'm15 6-6 6 6 6', - right: 'm9 6 6 6-6 6', -}; - -function chevronSvgHtml(direction: 'left' | 'right'): string { - return ``; -} - -// + )} + + ) + ) + } +
  • +
  • + +
    +
    +
    + + +{/* Global by necessity: runtime-built buttons (pagination-client.ts) can't + carry Astro's scoped attributes, so static and dynamic markup share these + class-based styles. The `tb-pagination` prefix is the leakage guard. */} + diff --git a/src/components/Pagination/PaginationBar.astro b/src/components/Pagination/PaginationBar.astro new file mode 100644 index 0000000000..149adc1194 --- /dev/null +++ b/src/components/Pagination/PaginationBar.astro @@ -0,0 +1,97 @@ +--- +// Pagination row for surfaces that pair the page-number nav with an +// items-per-page dropdown (IoT Hub). The selector is a SIBLING of the nav, +// not slotted inside it, so hiding the nav on a single page (or on a fetch +// error) leaves the per-page control reachable — without it, a user who +// raised the page size enough to collapse results to one page would lose the +// only control that could lower it again. This wrapper is the positioned +// ancestor for the selector's popup and owns the centered/right layout that +// used to live on the nav. +import Pagination from './Pagination.astro'; +import PerPageSelector from './PerPageSelector.astro'; + +interface Props { + currentPage: number; + totalPages: number; + basePath: string; + perPage: number; + /** Stable per-instance string (e.g. basePath) for unique selector ids. */ + idBase: string; + /** + * Layout variant: + * - 'centered' (default) — page numbers centred, items-per-page + * absolutely pinned to the right edge. + * - 'right' — everything right-aligned in a single flex row, items-per- + * page sits inline after the page numbers. + */ + variant?: 'centered' | 'right'; + class?: string; +} + +const { + currentPage, + totalPages, + basePath, + perPage, + idBase, + variant = 'centered', + class: className = '', +} = Astro.props; +--- + +
    + + +
    + + diff --git a/src/components/Pagination/PaginationChevron.astro b/src/components/Pagination/PaginationChevron.astro new file mode 100644 index 0000000000..c5de07a376 --- /dev/null +++ b/src/components/Pagination/PaginationChevron.astro @@ -0,0 +1,60 @@ +--- +// A single prev/next chevron, rendered three ways so the four near-identical +// copies in Pagination.astro (prev/next × numbers/compact rows) stay in sync: +// - link mode, enabled → real (crawlable, rel=prev/next) +// - interactive mode → event-emitting + ) : ( + + ) +} diff --git a/src/components/Pagination/PerPageSelector.astro b/src/components/Pagination/PerPageSelector.astro new file mode 100644 index 0000000000..6cb60a92bb --- /dev/null +++ b/src/components/Pagination/PerPageSelector.astro @@ -0,0 +1,228 @@ +--- +import { Icon } from 'astro-icon/components'; +import Chevron from '@components/Chevron.astro'; +import { PAGINATION_STRINGS } from './pagination-shared'; + +interface Props { + /** Current items-per-page value; options are [n, 2n, 3n]. */ + perPage: number; + /** Stable per-instance string (e.g. the page's basePath) for unique ids. */ + idBase: string; +} + +const { perPage, idBase } = Astro.props; + +const perPageOptions = [perPage, perPage * 2, perPage * 3]; +// Deterministic instance-unique label id (reproducible SSR output). +const slug = idBase + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +const labelId = `tb-pagination-pp-${slug || 'nav'}`; +--- + +
    + + {PAGINATION_STRINGS.perPageLabel} + + + +
    + +{/* Global for consistency with the parent's .tb-pagination__* contract. */} + + + diff --git a/src/components/Pagination/pagination-client.ts b/src/components/Pagination/pagination-client.ts new file mode 100644 index 0000000000..dfbee2e97d --- /dev/null +++ b/src/components/Pagination/pagination-client.ts @@ -0,0 +1,160 @@ +// Runtime updater for Pagination.astro. Client-driven surfaces (filtered +// grids, dynamic search) call `updatePagination(nav, state)` after every +// filter/page change to rebuild the page-number list with event-emitting +// buttons. Clicks dispatch a bubbling `tb-pagination:page-change` { page } +// CustomEvent on the nav root — the host listens, updates its state, and +// calls updatePagination again. The per-page dropdown (when enabled on the +// component) dispatches `tb-pagination:page-size-change` { perPage } the +// same way. + +import { buildPages, CHEVRON_PATHS, formatPageSummary, PAGINATION_STRINGS } from './pagination-shared'; + +export interface PaginationState { + currentPage: number; + totalPages: number; + /** Hide the whole nav when there is only one page. Default false. */ + hideOnSinglePage?: boolean; +} + +function chevronSvgHtml(direction: 'left' | 'right'): string { + return ``; +} + +// - -
    - -
    - - - - - - - diff --git a/src/components/Partners/PartnerLibrary.astro b/src/components/Partners/PartnerLibrary.astro index c762227298..76e07fa9e5 100644 --- a/src/components/Partners/PartnerLibrary.astro +++ b/src/components/Partners/PartnerLibrary.astro @@ -3,7 +3,7 @@ import { Icon } from 'astro-icon/components'; import PartnerCard from './PartnerCard.astro'; import PartnerFilterSidebar from './PartnerFilterSidebar.astro'; import SearchBar from './SearchBar.astro'; -import Pagination from './Pagination.astro'; +import Pagination from '@components/Pagination/Pagination.astro'; import type { HardwarePartner } from '~/data/partners/types'; interface Props { @@ -79,11 +79,13 @@ const filterGroups = [ No partners match your filters. - +