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.