diff --git a/website/src/components/CodeBlock.astro b/website/src/components/CodeBlock.astro index 304d9c6..b3a6e32 100644 --- a/website/src/components/CodeBlock.astro +++ b/website/src/components/CodeBlock.astro @@ -27,6 +27,6 @@ const html = await highlight(code, lang); data-track="copy" {...trackData} class="absolute top-2 right-2 inline-flex items-center gap-1 rounded-lg border border-line bg-surface px-2 py-1 text-xs text-muted opacity-0 transition group-hover/code:opacity-100 focus:opacity-100 data-[copied]:text-accent" - >{t(locale).common.copy}{t(locale).common.copy} diff --git a/website/src/components/ControlIcon.tsx b/website/src/components/ControlIcon.tsx index 6c2f526..72faead 100644 --- a/website/src/components/ControlIcon.tsx +++ b/website/src/components/ControlIcon.tsx @@ -1,58 +1,58 @@ import { useEffect, useState } from 'react'; import UseAnimations from 'react-useanimations'; +import alertTriangle from '../../../icons/alertTriangle.json'; +import arrowLeft from '../../../icons/arrowLeft.json'; import arrowRight from '../../../icons/arrowRight.json'; +import checkmark from '../../../icons/checkmark.json'; import chevronDown from '../../../icons/chevronDown.json'; +import chevronRight from '../../../icons/chevronRight.json'; import copy from '../../../icons/copy.json'; import cpu from '../../../icons/cpu.json'; +import download from '../../../icons/download.json'; import droplet from '../../../icons/droplet.json'; +import externalLink from '../../../icons/externalLink.json'; +import grid from '../../../icons/grid.json'; +import home from '../../../icons/home.json'; import packageIcon from '../../../icons/package.json'; import plusToX from '../../../icons/plusToX.json'; import server from '../../../icons/server.json'; import users from '../../../icons/users.json'; const ICONS = { + alertTriangle, + arrowLeft, arrowRight, + checkmark, chevronDown, + chevronRight, copy, cpu, + download, droplet, + externalLink, + grid, + home, package: packageIcon, plusToX, server, users, }; -/** - * An icon inside Astro markup, played by the element around it, which isn't React's. So the icon - * is a toggle driven from that element's events: - * - `hover`: forward while its closest link, button, label or `[data-icon-trigger]` is hovered or - * keyboard-focused, back on leave; - * - `open`: at its end state while its closest `
` is open (e.g. plus to X). - */ -export default function ControlIcon({ - icon, - on = 'hover', - size = 16, - className, -}: { - icon: keyof typeof ICONS; - on?: 'hover' | 'open'; - size?: number; - className?: string; -}) { - const [span, setSpan] = useState(null); - const [active, setActive] = useState(false); +type IconName = keyof typeof ICONS; +/** Whether the element around `wrapper` hovers, has keyboard focus, or (`open`) is an open `
`. */ +function useTrigger(wrapper: HTMLElement | null, on: 'hover' | 'open') { + const [active, setActive] = useState(false); useEffect(() => { if (on === 'open') { - const details = span?.closest('details'); + const details = wrapper?.closest('details'); if (!details) return undefined; const sync = () => setActive(details.open); sync(); details.addEventListener('toggle', sync); return () => details.removeEventListener('toggle', sync); } - const target = span?.closest('a, button, label, [data-icon-trigger]'); + const target = wrapper?.closest('a, button, label, [data-icon-trigger]'); if (!target) return undefined; // Touch devices fire pointer events on tap; only a mouse hovers. const enter = (event: PointerEvent) => event.pointerType === 'mouse' && setActive(true); @@ -70,19 +70,108 @@ export default function ControlIcon({ return () => { for (const [type, listener] of events) target.removeEventListener(type, listener); }; - }, [span, on]); + }, [wrapper, on]); + return active; +} + +/** Whether the icon in `wrapper` is on screen; not while a closed dialog or a hidden banner holds it. */ +function useOnScreen(wrapper: HTMLElement | null) { + const [visible, setVisible] = useState(false); + useEffect(() => { + const icon = wrapper?.firstElementChild; + if (!icon) return undefined; + const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting)); + observer.observe(icon); + return () => observer.disconnect(); + }, [wrapper]); + return visible; +} + +/** Whether the copy button around `wrapper` reports a copy (scripts/copy.ts sets `data-copied`). */ +function useCopied(wrapper: HTMLElement | null, enabled: boolean) { + const [copied, setCopied] = useState(false); + useEffect(() => { + const button = enabled && wrapper?.closest('[data-copy], [data-copy-from]'); + if (!button) return undefined; + const observer = new MutationObserver(() => setCopied(button.hasAttribute('data-copied'))); + observer.observe(button, { attributeFilter: ['data-copied'] }); + return () => observer.disconnect(); + }, [wrapper, enabled]); + return copied; +} + +type Look = { size: number; className?: string }; +/** The icon as a toggle: forward to its end state while `active`, back when not. */ +const Toggle = ({ icon, active, size, className }: Look & { icon: IconName; active: boolean }) => ( + } + /> +); + +/** Plays in as soon as it's on screen. */ +function Arriving({ icon, ...look }: Look & { icon: IconName }) { + const [wrapper, setWrapper] = useState(null); + const visible = useOnScreen(wrapper); + return ( + + + + ); +} + +/** + * An icon inside Astro markup, played by the element around it, which isn't React's. So the icon + * is a toggle driven from that element's events: + * - `hover`: forward while its closest link, button, label or `[data-icon-trigger]` is hovered or + * keyboard-focused, back on leave; + * - `open`: at its end state while its closest `
` is open (e.g. plus to X); + * - `screen`: at its end state while it's on screen, so it plays in on load or when the dialog or + * banner around it opens. + * + * With `copied`, a copy button around it shows that icon, played in, while it reports a copy. + */ +export default function ControlIcon({ + icon, + on = 'hover', + copied, + size = 16, + className, +}: { + icon: IconName; + on?: 'hover' | 'open' | 'screen'; + copied?: IconName; + size?: number; + className?: string; +}) { + const look = { size, className }; + return on === 'screen' ? ( + + ) : ( + + ); +} + +function Triggered({ + icon, + on, + copied, + ...look +}: Look & { icon: IconName; on: 'hover' | 'open'; copied?: IconName }) { + const [wrapper, setWrapper] = useState(null); + const active = useTrigger(wrapper, on); + const showCopied = useCopied(wrapper, copied !== undefined); return ( - ( - - - + + {copied && showCopied ? ( + + ) : ( + )} - /> + ); } diff --git a/website/src/components/Footer.astro b/website/src/components/Footer.astro index bf36c5e..ec62f18 100644 --- a/website/src/components/Footer.astro +++ b/website/src/components/Footer.astro @@ -1,4 +1,5 @@ --- +import ControlIcon from './ControlIcon'; import Logo from './Logo.astro'; import { BUILT, EN, type Locale, localize, t } from '../i18n'; import { footer } from '../lib/site'; @@ -28,8 +29,9 @@ const languages = BUILT.map((l) => ({ locale: l, href: alternates?.[l.hreflang]
    {column.links.map((link) => (
  • - + {link.label} + {link.href.startsWith('https:') && }
  • ))} diff --git a/website/src/components/HoverIcon.tsx b/website/src/components/HoverIcon.tsx new file mode 100644 index 0000000..22ff140 --- /dev/null +++ b/website/src/components/HoverIcon.tsx @@ -0,0 +1,69 @@ +import { useEffect, useRef, useState } from 'react'; +import UseAnimations, { type EventProps, type Interaction } from 'react-useanimations'; + +// Inside a link a click navigates, so every icon plays on hover, in its closest hover form. +const ON_HOVER: Record = { + hover: 'hover', + 'hover-loop': 'hover-loop', + 'hover-replay': 'hover-replay', + loop: 'hover-loop', + 'click-replay': 'hover-replay', + 'click-toggle': 'hover', +}; + +/** + * An icon that plays while `trigger`, a link in Astro markup (not React's), is hovered or has + * keyboard focus. It mounts on the first hover (scripts/hover-icons.ts), so it starts right away + * when that hover is still going on. + */ +export default function HoverIcon({ + animationData, + interaction, + trigger, +}: { + animationData: object; + interaction: Interaction; + trigger: HTMLElement; +}) { + const handlers = useRef(null); + const [wrapper, setWrapper] = useState(null); + + useEffect(() => { + if (!wrapper) return undefined; + // The component's own hover and focus handlers, fed from the trigger's native events. + const call = (name: 'onMouseEnter' | 'onMouseLeave' | 'onFocus' | 'onBlur') => + handlers.current?.[name]({ defaultPrevented: false, currentTarget: trigger } as never); + // Touch devices fire pointer events on tap; only a mouse hovers. + const enter = (event: PointerEvent) => event.pointerType === 'mouse' && call('onMouseEnter'); + const leave = (event: PointerEvent) => event.pointerType === 'mouse' && call('onMouseLeave'); + const focus = () => call('onFocus'); + const blur = () => call('onBlur'); + const events = [ + ['pointerenter', enter], + ['pointerleave', leave], + ['focusin', focus], + ['focusout', blur], + ] as [string, EventListener][]; + for (const [type, listener] of events) trigger.addEventListener(type, listener); + if (trigger.matches(':hover')) call('onMouseEnter'); + else if (trigger.matches(':focus-visible')) call('onFocus'); + return () => { + for (const [type, listener] of events) trigger.removeEventListener(type, listener); + }; + }, [wrapper, trigger]); + + return ( + { + handlers.current = eventProps; + return ( + + + + ); + }} + /> + ); +} diff --git a/website/src/components/IconDialog.tsx b/website/src/components/IconDialog.tsx index 0d04f38..778dd1a 100644 --- a/website/src/components/IconDialog.tsx +++ b/website/src/components/IconDialog.tsx @@ -3,6 +3,7 @@ import UseAnimations, { type Interaction } from 'react-useanimations'; import { islands } from '../i18n/messages/en/islands'; import { loadIconData } from '../lib/icon-data'; import { tabFromKey } from '../lib/tabs'; +import ControlIcon from './ControlIcon'; import { openIcon } from './IconCard'; export type DialogIcon = { @@ -59,8 +60,9 @@ function Code({ data-track="copy" data-track-icon={icon} data-track-code={code} - className="absolute top-2 right-2 rounded-lg border border-line bg-surface px-2 py-1 text-xs text-muted opacity-0 transition group-hover/code:opacity-100 focus:opacity-100 data-[copied]:text-accent" + className="absolute top-2 right-2 inline-flex items-center gap-1 rounded-lg border border-line bg-surface px-2 py-1 text-xs text-muted opacity-0 transition group-hover/code:opacity-100 focus:opacity-100 data-[copied]:text-accent" > + {label} @@ -200,16 +202,8 @@ export default function IconDialog({ aria-label={s.close} className="rounded-full p-2 text-muted hover:bg-surface-2 hover:text-fg" > - - - + {/* A plus that turns into an X each time the dialog opens. */} +
    + ZIP + Lottie JSON {icon.svg.map((state) => ( @@ -312,8 +308,9 @@ export default function IconDialog({ data-track-icon={icon.id} data-track-format="svg" data-track-file={`${state}.svg`} - className="rounded-full border border-line-strong px-4 py-2 text-sm hover:bg-surface-2" + className="inline-flex items-center gap-1.5 rounded-full border border-line-strong px-4 py-2 text-sm hover:bg-surface-2" > + {icon.svg.length > 1 ? `${state}.svg` : 'SVG'} ))} @@ -321,9 +318,10 @@ export default function IconDialog({ href={`/icons/${icon.id}`} data-track="icon-page" data-track-icon={icon.id} - className="ml-auto self-center text-sm text-muted underline-offset-4 hover:text-fg hover:underline" + className="ml-auto inline-flex items-center gap-1 self-center text-sm text-muted underline-offset-4 hover:text-fg hover:underline" > {s.iconPage} +
    diff --git a/website/src/components/IconGrid.astro b/website/src/components/IconGrid.astro index 024c9f0..968f865 100644 --- a/website/src/components/IconGrid.astro +++ b/website/src/components/IconGrid.astro @@ -1,5 +1,6 @@ --- import { EN, type Locale } from '../i18n'; +import IconStill from './IconStill.astro'; import type { Icon } from '../lib/icons'; interface Props { @@ -24,7 +25,7 @@ const lang = locale === EN ? undefined : 'en'; hreflang={lang} class="flex aspect-[1/1.08] flex-col items-center justify-center gap-3 rounded-2xl border border-line bg-surface px-2 text-center text-[13px] text-muted transition hover:border-line-strong hover:text-fg" > - + {lang ? {icon.title} : icon.title} diff --git a/website/src/components/IconStill.astro b/website/src/components/IconStill.astro new file mode 100644 index 0000000..49be5af --- /dev/null +++ b/website/src/components/IconStill.astro @@ -0,0 +1,25 @@ +--- +import type { Icon } from '../lib/icons'; + +interface Props { + icon: Icon; + class?: string; +} + +// An icon's still (lib/icons.ts) inside a link; it plays once the link is hovered or focused +// (scripts/hover-icons.ts). +const { icon, class: className } = Astro.props; +--- + + + + diff --git a/website/src/components/InstallCommand.astro b/website/src/components/InstallCommand.astro index dbb9cbe..dce1bce 100644 --- a/website/src/components/InstallCommand.astro +++ b/website/src/components/InstallCommand.astro @@ -25,7 +25,7 @@ const { command = 'npm i react-useanimations', class: className, locale = EN } = {command} - + {t(locale).common.copy} diff --git a/website/src/components/LanguageBanner.astro b/website/src/components/LanguageBanner.astro index 4aaa990..0bb29d0 100644 --- a/website/src/components/LanguageBanner.astro +++ b/website/src/components/LanguageBanner.astro @@ -3,6 +3,7 @@ // redirects (Google asks sites not to, and it would hide the other versions from crawlers and // people); it only suggests, in that language, and remembers a dismissal. import { BUILT, type Locale, t } from '../i18n'; +import ControlIcon from './ControlIcon'; interface Props { locale: Locale; @@ -25,8 +26,9 @@ const offers = BUILT.filter((l) => l !== locale && alternates[l.hreflang]).map(( >

    - diff --git a/website/src/views/HomePage.astro b/website/src/views/HomePage.astro index ab775d4..86d2c2c 100644 --- a/website/src/views/HomePage.astro +++ b/website/src/views/HomePage.astro @@ -639,7 +639,8 @@ const jsonLd = { <> {' '} - {item.link.label} → + {item.link.label} + )} diff --git a/website/src/views/IconIndexPage.astro b/website/src/views/IconIndexPage.astro index 5bb63dc..810396d 100644 --- a/website/src/views/IconIndexPage.astro +++ b/website/src/views/IconIndexPage.astro @@ -1,5 +1,6 @@ --- // The index of every icon page (/icons) in any language, grouped like the home page; plain links, no scripts. +import ControlIcon from '../components/ControlIcon'; import Breadcrumbs from '../components/Breadcrumbs.astro'; import IconGrid from '../components/IconGrid.astro'; import { alternatesOf, type Locale, localize, t } from '../i18n'; @@ -80,8 +81,9 @@ const jsonLd = {

    {m.taxonomy.categories[meta.name].name}

    - + {p.categoryLink(group.length, m.taxonomy.categories[meta.name].name)} + diff --git a/website/src/views/InteractionIndexPage.astro b/website/src/views/InteractionIndexPage.astro index b7417fd..c66f719 100644 --- a/website/src/views/InteractionIndexPage.astro +++ b/website/src/views/InteractionIndexPage.astro @@ -1,6 +1,7 @@ --- // The list of interactions (/interactions) in any language; src/pages routes render it. import Breadcrumbs from '../components/Breadcrumbs.astro'; +import IconStill from '../components/IconStill.astro'; import { alternatesOf, type Locale, localize, t } from '../i18n'; import Layout from '../layouts/Layout.astro'; import { breadcrumbNode, interactionPath, interactions, localDescription, localTitle } from '../lib/hubs'; @@ -70,7 +71,7 @@ const jsonLd = { >