Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion website/src/components/CodeBlock.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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"
><ControlIcon client:idle icon="copy" size={14} /><span data-copy-label>{t(locale).common.copy}</span></button
><ControlIcon client:idle icon="copy" copied="checkmark" size={14} /><span data-copy-label>{t(locale).common.copy}</span></button
>
</div>
155 changes: 122 additions & 33 deletions website/src/components/ControlIcon.tsx
Original file line number Diff line number Diff line change
@@ -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 `<details>` 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<HTMLElement | null>(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 `<details>`. */
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);
Expand All @@ -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 }) => (
<UseAnimations
animation={{ animationData: ICONS[icon], interaction: 'click-toggle' }}
active={active}
size={size}
className={className}
render={(_eventProps, animationProps) => <span {...animationProps} />}
/>
);

/** Plays in as soon as it's on screen. */
function Arriving({ icon, ...look }: Look & { icon: IconName }) {
const [wrapper, setWrapper] = useState<HTMLElement | null>(null);
const visible = useOnScreen(wrapper);
return (
<span ref={setWrapper} className="contents">
<Toggle icon={icon} active={visible} {...look} />
</span>
);
}

/**
* 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 `<details>` 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' ? (
<Arriving icon={icon} {...look} />
) : (
<Triggered icon={icon} on={on} copied={copied} {...look} />
);
}

function Triggered({
icon,
on,
copied,
...look
}: Look & { icon: IconName; on: 'hover' | 'open'; copied?: IconName }) {
const [wrapper, setWrapper] = useState<HTMLElement | null>(null);
const active = useTrigger(wrapper, on);
const showCopied = useCopied(wrapper, copied !== undefined);
return (
<UseAnimations
animation={{ animationData: ICONS[icon], interaction: 'click-toggle' }}
active={active}
size={size}
className={className}
render={(_eventProps, animationProps) => (
<span ref={setSpan} className="contents">
<span {...animationProps} />
</span>
<span ref={setWrapper} className="contents">
{copied && showCopied ? (
<Arriving icon={copied} {...look} />
) : (
<Toggle icon={icon} active={active} {...look} />
)}
/>
</span>
);
}
4 changes: 3 additions & 1 deletion website/src/components/Footer.astro
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -28,8 +29,9 @@ const languages = BUILT.map((l) => ({ locale: l, href: alternates?.[l.hreflang]
<ul class="mt-4 space-y-2.5">
{column.links.map((link) => (
<li>
<a href={link.href} class="text-sm text-muted transition-colors hover:text-fg">
<a href={link.href} class="inline-flex items-center gap-1 text-sm text-muted transition-colors hover:text-fg">
{link.label}
{link.href.startsWith('https:') && <ControlIcon client:idle icon="externalLink" size={12} className="text-subtle" />}
</a>
</li>
))}
Expand Down
69 changes: 69 additions & 0 deletions website/src/components/HoverIcon.tsx
Original file line number Diff line number Diff line change
@@ -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<Interaction, Interaction> = {
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<EventProps | null>(null);
const [wrapper, setWrapper] = useState<HTMLElement | null>(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 (
<UseAnimations
animation={{ animationData, interaction: ON_HOVER[interaction] }}
size="100%"
render={(eventProps, animationProps) => {
handlers.current = eventProps;
return (
<span ref={setWrapper} className="contents">
<span {...animationProps} />
</span>
);
}}
/>
);
}
28 changes: 13 additions & 15 deletions website/src/components/IconDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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"
>
<ControlIcon icon="copy" copied="checkmark" size={14} />
<span data-copy-label>{label}</span>
</button>
</div>
Expand Down Expand Up @@ -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"
>
<svg
viewBox="0 0 24 24"
className="size-5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M6 6l12 12M18 6L6 18" />
</svg>
{/* A plus that turns into an X each time the dialog opens. */}
<ControlIcon icon="plusToX" on="screen" size={24} />
</button>
</div>
<div
Expand Down Expand Up @@ -289,8 +283,9 @@ export default function IconDialog({
data-track="download"
data-track-icon={icon.id}
data-track-format="zip"
className="rounded-full bg-fg px-4 py-2 text-sm font-medium text-bg hover:opacity-85"
className="inline-flex items-center gap-1.5 rounded-full bg-fg px-4 py-2 text-sm font-medium text-bg hover:opacity-85"
>
<ControlIcon icon="download" size={14} />
ZIP
</a>
<a
Expand All @@ -299,8 +294,9 @@ export default function IconDialog({
data-track="download"
data-track-icon={icon.id}
data-track-format="json"
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"
>
<ControlIcon icon="download" size={14} />
Lottie JSON
</a>
{icon.svg.map((state) => (
Expand All @@ -312,18 +308,20 @@ 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"
>
<ControlIcon icon="download" size={14} />
{icon.svg.length > 1 ? `${state}.svg` : 'SVG'}
</a>
))}
<a
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}
<ControlIcon icon="arrowRight" size={14} />
</a>
</div>
</div>
Expand Down
3 changes: 2 additions & 1 deletion website/src/components/IconGrid.astro
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
import { EN, type Locale } from '../i18n';
import IconStill from './IconStill.astro';
import type { Icon } from '../lib/icons';

interface Props {
Expand All @@ -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"
>
<span data-useanimations="" class="block size-10 text-fg" set:html={icon.preview} />
<IconStill icon={icon} class="size-10 text-fg" />
{lang ? <span lang={lang}>{icon.title}</span> : icon.title}
</a>
</li>
Expand Down
Loading
Loading