aweft

recipes/ui/catalogue.tsx

One file of recipes/ui, at commit 9a5bb24.

// The catalogue: one page per component `@aweftjs/ui` ships, in light and dark side by side.
//
// The page lists nothing. It reads `examples/` at build time and turns what it finds into one act
// per component, sorted by name (designs 197 and 226). Adding a component to the catalogue is
// adding a file.
//
// It routes on the stack's own stage and router, so the gallery uses the routing it ships. The
// router's base is this page's path with a `#` on the end, which makes the router's path the URL's
// hash: a reload asks the server for `catalogue.html` and nothing else, which is the only form a
// three-page dev server answers.
//
// Everything the page draws of its own is layout, named `catalogue*` and defined here. The
// examples use the library's default theme and nothing else, which is what they are here to show.
//
// Several components ask for an icon by name and the stack ships no drawings (design 144), so the
// page answers them once, at the top, from an installed set.

import { mutable } from '@aweftjs/core';
import { Icon, Icons, PopupContext, Stage, StageContext, TextField, Theme, dark, h, light } from '@aweftjs/ui';
import type { Act, Definitions } from '@aweftjs/ui';
import type { Router } from '@aweftjs/dom/router';
import standard from '@aweftjs/icons/lucide/+standard';

import type { ExampleComponent, ExampleModule } from './example.ts';

Theme.define({
	// This page's own layout choices, so each one has a name anybody can find again.
	'*': { $navWidth: '180px', $paneWidth: '480px' },

	catalogue: {
		display: 'flex',
		flexDirection: 'column',
		gap: '$space6',
		padding: '$space6',
		fontFamily: '$font',
		background: '$background',
		color: '$foreground',
	},
	catalogue_head: {
		display: 'flex',
		flexWrap: 'wrap',
		alignItems: 'center',
		justifyContent: 'space-between',
		gap: '$space4',
	},
	catalogue_title: { margin: 0 },
	catalogue_body: { display: 'flex', alignItems: 'flex-start', gap: '$space6' },

	catalogue_nav: {
		position: 'sticky',
		top: '$space6',
		alignSelf: 'flex-start',
		flex: '0 0 $navWidth',
		width: '$navWidth',
		boxSizing: 'border-box',
		display: 'flex',
		flexDirection: 'column',
		gap: '$space2',
		// Thirty components ran off the bottom of the screen and there was no way back to them. The
		// box is the viewport less the room above and below it, and the rest scrolls inside it.
		maxHeight: 'calc(100vh - $space6 * 2)',
		overflowY: 'auto',
		// A scrolling box clips both axes, and the focus halo is 3px wide, so it needs the room.
		padding: '$space',
	},
	catalogue_list: {
		listStyle: 'none',
		margin: 0,
		padding: 0,
		display: 'flex',
		flexDirection: 'column',
		gap: '$space',
	},
	catalogue_link: {
		display: 'block',
		padding: '$space $space2',
		borderRadius: '$radiusSm',
		color: '$foreground',
		textDecoration: 'none',
		fontSize: '$textSm',
		lineHeight: '$textSmLine',
		'_cssProp_hover': { background: '$muted', color: '$mutedForeground' },
	},
	catalogue_link_current: { background: '$accent', color: '$accentForeground' },

	catalogue_main: { display: 'flex', flexDirection: 'column', gap: '$space6', flexGrow: 1, minWidth: 0 },
	catalogue_page: {
		display: 'flex',
		flexDirection: 'column',
		gap: '$space3',
		// The page takes the focus when it opens, so a reader is told where it is; the ring is for a
		// control a person has to find again, and a whole page framed in it is noise.
		'_cssProp_focus-visible': { boxShadow: 'none' },
	},
	catalogue_panes: {
		display: 'flex',
		flexWrap: 'wrap',
		alignItems: 'flex-start',
		gap: '$space4',
	},
	catalogue_pane: {
		flex: '1 1 $paneWidth',
		minWidth: '$paneWidth',
		display: 'flex',
		flexDirection: 'column',
		gap: '$space4',
		padding: '$space4',
		borderRadius: '$radiusLg',
		border: '$borderWidth solid $border',
		background: '$background',
		color: '$foreground',
	},
});

// One static import per file, written by the bundler. Nothing here names an example.
const found = import.meta.glob('./examples/*.example.tsx', { eager: true });

/** Every example, in the order the nav lists them and the order a person looks for a name in. */
const examples = Object.values(found)
	.map((module) => module as ExampleModule)
	.sort((left, right) => left.name.localeCompare(right.name));

/** One pane: the example again, under the mode this pane is in. */
const Pane = (props: { name?: unknown; mode?: unknown; values?: unknown; show?: unknown }): unknown =>
	h(Theme, { value: props.values as Definitions },
		h(PopupContext, {},
			h('div', {
				theme: 'catalogue_pane',
				id: `${String(props.name)}-pane-${String(props.mode)}`,
			},
			h('p', { theme: ['text', 'xs', 'muted'] }, `${String(props.mode)} mode`),
			h(props.show as ExampleComponent, { mode: props.mode }))));

/** One act: the component's name, and its example under each of the two modes. */
const pageFor = (example: ExampleModule): Act => (): unknown => (
	<article theme="catalogue_page" id={example.name}>
		<h2 theme={['text', 'xl', 'bold']}>{example.name}</h2>
		<div theme="catalogue_panes">
			<Pane name={example.name} mode="light" values={light} show={example.Example} />
			<Pane name={example.name} mode="dark" values={dark} show={example.Example} />
		</div>
	</article>
);

const acts: Record<string, Act> = Object.fromEntries(
	examples.map((example) => [example.name, pageFor(example)]));

/** What the search field holds, and what the nav shows for it. */
const query = mutable('');
const matches = (name: string, text: string): boolean =>
	name.toLowerCase().includes(text.trim().toLowerCase());
const hits = query.map((text) => examples.filter((held) => matches(held.name, String(text))).length);

/**
 * A nav link the router takes rather than the browser.
 *
 * `router.links` leaves a link into the page showing now that differs only in its hash, and every
 * link here is one: the browser would move the address bar and fire no `popstate`, so the stage
 * would never hear about it. A click with a modifier on it still belongs to the browser, so a
 * middle click or a Ctrl click opens the page in a tab of its own.
 */
const go = (router: Router, event: unknown, name: string): void => {
	const click = event as {
		button?: number; metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean; altKey?: boolean;
		preventDefault(): void;
	};
	if (click.button !== undefined && click.button !== 0) return;
	if (click.metaKey === true || click.ctrlKey === true || click.shiftKey === true || click.altKey === true) return;
	click.preventDefault();
	router.push(`/${name}`);
};

/** The list down the left: every component, the one showing marked, and a search over the names. */
const Nav = StageContext.use((stage) => (props: { router?: unknown }): unknown => (
	<nav theme="catalogue_nav" aria-label="Components">
		<TextField
			id="catalogue-search"
			aria-label="Search components"
			placeholder="Search"
			value={query}
			leading={<Icon name="search" />}
			onKeyDown={(event: unknown) => {
				if ((event as { key?: string }).key === 'Escape') query.set('');
			}}
		/>
		<p
			theme={['text', 'xs', 'muted']}
			id="catalogue-count"
			hidden={query.map((text) => String(text).trim() === '')}
		>{hits.map((count) => `${String(count)} of ${String(examples.length)}`)}</p>

		<ul theme="catalogue_list">
			{examples.map((example) => (
				<li hidden={query.map((text) => !matches(example.name, String(text)))}>
					<a
						theme={['catalogue_link',
							stage!.current.map((name) => (name === example.name ? 'current' : null))]}
						href={`#/${example.name}`}
						aria-current={stage!.current.map((name) => (name === example.name ? 'page' : false))}
						onClick={(event: unknown) => { go(props.router as Router, event, example.name); }}
					>{example.name}</a>
				</li>
			))}
		</ul>

		<p
			theme={['text', 'xs', 'muted']}
			id="catalogue-none"
			hidden={hits.map((count) => count > 0)}
		>Nothing matches that.</p>
	</nav>
));

export const Catalogue = (props: { router?: unknown }): unknown => (
	<Icons value={standard}>
		<div theme="catalogue" id="catalogue">
			{/* The nav and the search sit beside the `Stage` rather than in a template, because a
			    template is rebuilt on every act change and a search box that empties itself when
			    you pick a result is not a search box. */}
			<StageContext router={props.router as Router} acts={acts} initial={examples[0]!.name}>
				<header theme="catalogue_head">
					<h1 theme={['text', '2xl', 'bold', 'catalogue_title']} id="catalogue-title">The catalogue</h1>
				</header>

				<div theme="catalogue_body">
					<Nav router={props.router} />
					<main theme="catalogue_main"><Stage /></main>
				</div>
			</StageContext>
		</div>
	</Icons>
);