From 8eb2d309378593cc7e4a903e668dbb82ca80bf1a Mon Sep 17 00:00:00 2001 From: Luke Bennett Date: Sat, 1 Aug 2026 23:30:41 +1000 Subject: [PATCH] Add an Iconography overview page with a searchable icon gallery (#315) * Add an Iconography overview page with a searchable icon gallery Browsing the icon set previously meant reading the Icon component page or the icons directory. Add an overview page, parallel to Typography, whose job is finding an icon and taking its name away. The gallery renders all 27 icons in a ruled grid with a name filter, a live count, and a size toggle that scales the glyph without reflowing the grid. Each cell carries two copy targets, JSX and the bare name, revealed on hover and on keyboard focus, and shown permanently on touch devices where there is no hover. Move the spritesheet setup out of the Icon component page, which keeps its API, createIcon, and accessibility sections, and cross-link the two. * Rewrite the Iconography page and keep the copy buttons visible The page followed no established shape and skipped the topics that other design systems cover. Rewrite it around browse, use, size, colour, accessibility, setup, and what to do when the set has no matching icon. Write the prose in Simplified Technical English. Drop the bundler tour from the spritesheet section. State the requirement, that the bundler must give a URL and must not inline the file, and give one Vite line as an example. Move the spritesheet regeneration command to docs/COMPONENTS.md. It is a maintainer task, and the docs are for consumers of the package. Replace the hover-revealed copy chips with a ruled two-column footer that is always present. The chips contradicted the hairline grid and needed a separate touch code path. The footer uses one behaviour on every input method, and the glyph is never covered. * Fix renamed prop * Address review on the Iconography page and gallery Render the icon examples with ExampleBlock instead of static code fences, and add size, shared size, colour, and informative examples. Drop the paragraph that explained how to use the gallery UI, the resolved pixel values for the themeable size tokens, and the request to contact the maintainers. Derive the gallery's size union from IconProps rather than repeating it. Fold the copy feedback and its announcement into one reducer, since they always change together. Replace the hand-rolled announcement debounce with useDeferredValue, and hold a ref to the filter input itself rather than querying the field wrapper on each clear. --- .../docs/components/visuals/icon/index.mdx | 34 +-- .../content/docs/overview/iconography.mdx | 119 ++++++++ apps/docs/content/docs/overview/meta.json | 3 +- apps/docs/src/components/icon-gallery.tsx | 280 ++++++++++++++++++ apps/docs/src/examples/icon/colours.tsx | 34 +++ apps/docs/src/examples/icon/informative.tsx | 5 + apps/docs/src/examples/icon/size-context.tsx | 14 + apps/docs/src/examples/icon/sizes.tsx | 26 ++ apps/docs/src/routes/$.tsx | 2 + docs/COMPONENTS.md | 12 + 10 files changed, 496 insertions(+), 33 deletions(-) create mode 100644 apps/docs/content/docs/overview/iconography.mdx create mode 100644 apps/docs/src/components/icon-gallery.tsx create mode 100644 apps/docs/src/examples/icon/colours.tsx create mode 100644 apps/docs/src/examples/icon/informative.tsx create mode 100644 apps/docs/src/examples/icon/size-context.tsx create mode 100644 apps/docs/src/examples/icon/sizes.tsx diff --git a/apps/docs/content/docs/components/visuals/icon/index.mdx b/apps/docs/content/docs/components/visuals/icon/index.mdx index 539ed03a..08a616fb 100644 --- a/apps/docs/content/docs/components/visuals/icon/index.mdx +++ b/apps/docs/content/docs/components/visuals/icon/index.mdx @@ -6,44 +6,14 @@ description: SVG icon component backed by the generated spritesheet. `Icon` renders a symbol from the generated Luke UI spritesheet. It needs an `IconSpritesheetProvider` ancestor so it can resolve the spritesheet URL. +To find an icon, or to set up the spritesheet, see [Iconography](/overview/iconography). + -## Set up the spritesheet - -Wrap your app with `IconSpritesheetProvider`. Pass the URL of the generated spritesheet asset. - -- Source asset: `@luke-ui/react/spritesheet.svg`, exported from `./dist/spritesheet.svg` -- Runtime lookup: `#` - -```tsx - - - -``` - -Vite and Storybook should import the spritesheet as a URL. - -```ts -import spriteSheetHref from '@luke-ui/react/spritesheet.svg?url&no-inline'; - - - -; -``` - -The `no-inline` query avoids `data:` URLs, which can break `` rendering. - -When developing `@luke-ui/react`, generate the spritesheet from -`packages/@luke-ui/react/icons/*.svg`. - -```bash -pnpm --dir packages/@luke-ui/react run generate:icons -``` - ## Use an icon `Icon` renders an `` that references a symbol in the generated spritesheet with diff --git a/apps/docs/content/docs/overview/iconography.mdx b/apps/docs/content/docs/overview/iconography.mdx new file mode 100644 index 00000000..0e81158d --- /dev/null +++ b/apps/docs/content/docs/overview/iconography.mdx @@ -0,0 +1,119 @@ +--- +title: Iconography +description: Find an icon in the Luke UI set, then use it at the correct size and colour. +--- + +Luke UI includes one icon set. All the icons have the same weight, and all use a 24 by 24 grid. The +build collects the icons into one spritesheet file. The `Icon` component shows one symbol from that +file. + +## Browse + + + +## Use an icon + +Import `Icon` and give it a name from the set. The `name` prop accepts only the names in the gallery +above. The compiler rejects any other name. + + + +## Size + +Set `size` to `xsmall`, `small`, `medium`, or `large`. Each size is a theme token, not a pixel +value. A custom theme can give each size a different value. + + + +The default size is `medium`. + +To give a group of icons the same size, put an `IconSizeProvider` around them. An icon that has its +own `size` prop keeps that size. + + + +## Colour + +An icon takes the colour of the text around it. To change the colour of an icon, set the colour on +the icon or on a parent element. + + + +An icon that gives information must have a contrast ratio of 3 to 1 or more against its background. +An icon that only decorates adjacent text has no contrast requirement. + +## Accessibility + +Each icon is decorative or informative. Use the correct one. + +The icon is decorative when the text next to it gives the meaning. Do not give it a title. The +component then hides the icon from assistive technology. + + + +The icon is informative when it appears alone. Give it a `title`. The component then shows the icon +to assistive technology as an image. + + + +Do not set `aria-hidden` and `title` on the same icon. + +## Set up the spritesheet + +The `Icon` component reads its symbols from a spritesheet file. The package supplies this file at +`@luke-ui/react/spritesheet.svg`. + +Put an `IconSpritesheetProvider` around your application. Give the provider the URL of the +spritesheet file. + +```tsx +import { IconSpritesheetProvider } from '@luke-ui/react/icon'; + + + +; +``` + +Your bundler must give a URL for the spritesheet file. It must not inline the file as a `data:` URL, +because a `` reference to a `data:` URL does not resolve in all browsers. In Vite, the +`?url&no-inline` query gives this result. + +```ts +import spriteSheetHref from '@luke-ui/react/spritesheet.svg?url&no-inline'; +``` + +## When the set does not have an icon + +Use `createIcon` to make a component for a symbol that the set does not include. The new component +gets the same size behaviour and accessibility behaviour as `Icon`. + +## Continue learning + + + + The component API, and how to make a custom icon. + + + Give an icon an accessible name to make a small button. + + diff --git a/apps/docs/content/docs/overview/meta.json b/apps/docs/content/docs/overview/meta.json index 26a2110a..0454174a 100644 --- a/apps/docs/content/docs/overview/meta.json +++ b/apps/docs/content/docs/overview/meta.json @@ -10,6 +10,7 @@ "color", "color-mode", "shadow", - "typography" + "typography", + "iconography" ] } diff --git a/apps/docs/src/components/icon-gallery.tsx b/apps/docs/src/components/icon-gallery.tsx new file mode 100644 index 00000000..9f78ce84 --- /dev/null +++ b/apps/docs/src/components/icon-gallery.tsx @@ -0,0 +1,280 @@ +/** + * THESIS: An index, not a showcase. Refuses the icon-library marketing grid — big hero, + * style/weight tabs, infinite scroll — because 27 first-party icons need one job done: + * find the name, take it away. + * OWN-WORLD: The docs' existing pill-toggle and fumadocs token language, unchanged. + * Hairline-ruled grid, no floating cards, no shadows. + * STORY: Scan or filter, see the glyph at the size you'll ship it at, take the name or + * the JSX in one click. + * FIRST VIEWPORT: Filter, size pills, live count, then the grid — the set is visible + * without scrolling. + * FORM: Extension of an established surface. TanStack's ruled grid, with the two copy targets + * always present in a ruled cell footer rather than revealed on hover. + */ +import type { IconName, IconProps } from '@luke-ui/react/icon'; +import { Icon, iconNames } from '@luke-ui/react/icon'; +import { TextField } from '@luke-ui/react/text-field'; +import { cx } from '@luke-ui/react/utils'; +import { VisuallyHidden } from '@luke-ui/react/visually-hidden'; +import type { JSX, ReactNode } from 'react'; +import { useDeferredValue, useEffect, useMemo, useReducer, useRef, useState } from 'react'; +import { TextToggleButtonGroup } from './playground/icon-toggle-button-group.js'; + +type GalleryIconSize = NonNullable; + +const SIZE_OPTIONS = [ + { label: 'XS', value: 'xsmall' }, + { label: 'S', value: 'small' }, + { label: 'M', value: 'medium' }, + { label: 'L', value: 'large' }, +] as const satisfies ReadonlyArray<{ label: string; value: GalleryIconSize }>; + +/** How long a copy button shows its "Copied"/error feedback before reverting. */ +const COPY_FEEDBACK_DURATION_MS = 1500; + +type CopyKind = 'jsx' | 'name'; + +/** Which cell's copy button is mid-feedback, and whether the write succeeded. */ +interface CopyStatus { + kind: CopyKind; + name: IconName; + state: 'copied' | 'error'; +} + +/** + * Shared treatment for the two per-cell copy buttons. Always visible, on every input method, + * as part of the cell's ruled footer. The only state change is a tint on hover/focus-visible. + */ +const COPY_BUTTON_CLASS_NAME = cx( + 'flex h-7 min-w-0 items-center justify-center gap-1 whitespace-nowrap', + 'font-medium text-[11px] text-fd-muted-foreground', + 'hover:bg-fd-accent hover:text-fd-accent-foreground', + 'focus-visible:bg-fd-accent focus-visible:text-fd-accent-foreground', + 'transition-colors duration-150 motion-reduce:transition-none', +); + +/** Searchable index of the first-party icon set, sized at the token you'll ship it at. */ +export function IconGallery(): JSX.Element { + const [filter, setFilter] = useState(''); + const [previewSize, setPreviewSize] = useState('medium'); + const [copyState, dispatchCopy] = useReducer(copyReducer, { announcement: '', status: null }); + const inputRef = useRef(null); + const copyTimeoutRef = useRef(null); + + useEffect(() => { + return () => { + if (copyTimeoutRef.current != null) window.clearTimeout(copyTimeoutRef.current); + }; + }, []); + + const trimmedFilter = filter.trim().toLowerCase(); + const filteredNames = useMemo(() => { + if (trimmedFilter === '') return iconNames; + return iconNames.filter((name) => name.toLowerCase().includes(trimmedFilter)); + }, [trimmedFilter]); + + const countText = + trimmedFilter === '' + ? `${iconNames.length} icons` + : `${filteredNames.length} of ${iconNames.length}`; + + /** Deferred so a screen reader hears the settled result, not every keystroke. */ + const deferredCountText = useDeferredValue(countText); + + function handleClearFilter() { + setFilter(''); + inputRef.current?.focus(); + } + + async function handleCopy(name: IconName, kind: CopyKind) { + const copiedText = kind === 'jsx' ? `` : name; + + if (copyTimeoutRef.current != null) window.clearTimeout(copyTimeoutRef.current); + + try { + await navigator.clipboard.writeText(copiedText); + dispatchCopy({ kind, name, text: copiedText, type: 'copied' }); + } catch { + dispatchCopy({ kind, name, text: copiedText, type: 'failed' }); + } + + copyTimeoutRef.current = window.setTimeout(() => { + dispatchCopy({ type: 'reset' }); + }, COPY_FEEDBACK_DURATION_MS); + } + + return ( +
+
+
{ + inputRef.current = node?.querySelector('input') ?? null; + }} + > + } + size="small" + value={filter} + /> +
+ +

{countText}

+ + {deferredCountText} + +
+ +
+ {filteredNames.length === 0 ? ( + + ) : ( +
+ {filteredNames.map((name) => ( + + ))} +
+ )} +
+ + + {copyState.announcement} + +
+ ); +} + +interface CopyState { + announcement: string; + status: CopyStatus | null; +} + +type CopyAction = + | { kind: CopyKind; name: IconName; text: string; type: 'copied' } + | { kind: CopyKind; name: IconName; text: string; type: 'failed' } + | { type: 'reset' }; + +/** Drives the copy button feedback: sets status and announcement together, resets status only. */ +function copyReducer(state: CopyState, action: CopyAction): CopyState { + switch (action.type) { + case 'copied': + return { + announcement: `Copied ${action.text}`, + status: { kind: action.kind, name: action.name, state: 'copied' }, + }; + case 'failed': + return { + announcement: `Couldn't copy automatically. Please copy manually: ${action.text}.`, + status: { kind: action.kind, name: action.name, state: 'error' }, + }; + case 'reset': + return { ...state, status: null }; + } +} + +interface IconGalleryCellProps { + copyStatus: CopyStatus | null; + name: IconName; + onCopy: (name: IconName, kind: CopyKind) => void; + previewSize: GalleryIconSize; +} + +/** One grid cell: a fixed-height glyph area, the name below it, and a ruled footer of two copy buttons. */ +function IconGalleryCell({ copyStatus, name, onCopy, previewSize }: IconGalleryCellProps) { + return ( +
+
+
+ +
+ + {name} + +
+
+ + +
+
+ ); +} + +interface CopyButtonProps { + copyStatus: CopyStatus | null; + kind: CopyKind; + name: IconName; + onCopy: (name: IconName, kind: CopyKind) => void; +} + +/** One half of the ruled footer's copy control. The `jsx` button renders first, so it carries the divider. */ +function CopyButton({ copyStatus, kind, name, onCopy }: CopyButtonProps) { + const accessibleLabel = kind === 'jsx' ? `Copy JSX for ${name}` : `Copy name ${name}`; + + const label: ReactNode = (() => { + const copyStatusState = copyStatus?.state; + if (copyStatusState === 'copied') return 'Copied'; + if (copyStatusState === 'error') return 'Failed'; + if (kind === 'jsx') return 'JSX'; + + return 'Name'; + })(); + + return ( + + ); +} + +interface IconGalleryEmptyStateProps { + onClear: () => void; + query: string; +} + +/** Real empty state inside the grid frame: names the query, offers a way back. */ +function IconGalleryEmptyState({ onClear, query }: IconGalleryEmptyStateProps) { + return ( +
+

No icon matches "{query}"

+ +
+ ); +} diff --git a/apps/docs/src/examples/icon/colours.tsx b/apps/docs/src/examples/icon/colours.tsx new file mode 100644 index 00000000..4adc366e --- /dev/null +++ b/apps/docs/src/examples/icon/colours.tsx @@ -0,0 +1,34 @@ +import { Box } from '@luke-ui/react/box'; +import { Icon } from '@luke-ui/react/icon'; +import { Text } from '@luke-ui/react/text'; + +export default function Colours() { + return ( + + + + + + accent + + + + + + success + + + + + + warning + + + + + + danger + + + ); +} diff --git a/apps/docs/src/examples/icon/informative.tsx b/apps/docs/src/examples/icon/informative.tsx new file mode 100644 index 00000000..de859b75 --- /dev/null +++ b/apps/docs/src/examples/icon/informative.tsx @@ -0,0 +1,5 @@ +import { Icon } from '@luke-ui/react/icon'; + +export default function Informative() { + return ; +} diff --git a/apps/docs/src/examples/icon/size-context.tsx b/apps/docs/src/examples/icon/size-context.tsx new file mode 100644 index 00000000..d4e5f312 --- /dev/null +++ b/apps/docs/src/examples/icon/size-context.tsx @@ -0,0 +1,14 @@ +import { Box } from '@luke-ui/react/box'; +import { Icon } from '@luke-ui/react/icon'; +import { IconSizeProvider } from '@luke-ui/react/icon-size-context'; + +export default function SizeContext() { + return ( + + + + + + + ); +} diff --git a/apps/docs/src/examples/icon/sizes.tsx b/apps/docs/src/examples/icon/sizes.tsx new file mode 100644 index 00000000..bef3e34d --- /dev/null +++ b/apps/docs/src/examples/icon/sizes.tsx @@ -0,0 +1,26 @@ +import { Box } from '@luke-ui/react/box'; +import { Icon } from '@luke-ui/react/icon'; +import { Text } from '@luke-ui/react/text'; + +export default function Sizes() { + return ( + + + + xsmall + + + + small + + + + medium + + + + large + + + ); +} diff --git a/apps/docs/src/routes/$.tsx b/apps/docs/src/routes/$.tsx index bef65427..a8ec2b6f 100644 --- a/apps/docs/src/routes/$.tsx +++ b/apps/docs/src/routes/$.tsx @@ -11,6 +11,7 @@ import { Suspense } from 'react'; import * as z from 'zod'; import browserCollections from '../../.source/browser'; import { ExampleBlock } from '../components/example-block'; +import { IconGallery } from '../components/icon-gallery'; import { PageActions } from '../components/page-actions'; import { SourceCodeBlock } from '../components/source-code-block'; import { getComponentPageNavigation } from '../lib/component-page-navigation.js'; @@ -24,6 +25,7 @@ const mdxComponents = { ...defaultMdxComponents, AutoTypeTable, ExampleBlock, + IconGallery, SourceCodeBlock, TypeTable, }; diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index 4afa836b..aff495d8 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -60,3 +60,15 @@ example and references it with `ExampleBlock`. Replace the placeholder content w renderable use of the component. Do not move creation rules into one-off generator code. + +## Icons + +Icon SVGs live in `packages/@luke-ui/react/icons`. After adding, renaming, or removing one, +regenerate the spritesheet and the `iconNames` union: + +```bash +pnpm --dir packages/@luke-ui/react run generate:icons +``` + +The generated `iconNames` export drives the docs gallery at `/overview/iconography`, so a new icon +appears there with no further changes. -- 2.51.2