perf(a11y): add keyboard navigation support to SiteHeader more action… (#464)

This commit is contained in:
Doma 2023-05-05 02:01:27 +08:00 committed by GitHub
parent aebbafa254
commit dce0f60e7d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 194 additions and 125 deletions

View File

@ -1,5 +1,5 @@
import { useTranslation } from "next-i18next"
import { useEffect, useState } from "react"
import React, { useEffect, useState } from "react"
import {
GeneralAccount,
@ -33,14 +33,17 @@ import { SITE_URL } from "~/lib/env"
import { getSiteLink } from "~/lib/helpers"
import { cn } from "~/lib/utils"
import { UniLink } from "../ui/UniLink"
type HeaderLinkType = {
icon?: React.ReactNode
label: string | JSX.Element
url?: string
onClick?: () => void
}
} & (
| {
href: string
}
| {
onClick: React.MouseEventHandler
}
)
export const ConnectButton: React.FC<{
left?: boolean
@ -113,7 +116,7 @@ export const ConnectButton: React.FC<{
? {
icon: "icon-[mingcute--home-1-line]",
label: t("My xLog") || "",
url: getSiteLink({
href: getSiteLink({
subdomain: account?.character?.handle || "",
}),
}
@ -125,12 +128,15 @@ export const ConnectButton: React.FC<{
{
icon: "icon-[mingcute--grid-line]",
label: t("Dashboard") || "",
url: `${SITE_URL}/dashboard`,
href: `${SITE_URL}/dashboard`,
},
{
icon: "icon-[mingcute--copy-2-line]",
label: t(copyLabelDisplay) || "",
onClick: copyLabel,
onClick: (e) => {
e.preventDefault()
copyLabel()
},
},
...(account?.type === "wallet"
? [
@ -242,7 +248,7 @@ export const ConnectButton: React.FC<{
placement="bottom-end"
target={
<button
className="flex items-center w-full hover:bg-hover transition-colors py-1 px-2 rounded-lg ml-1"
className="flex items-center w-full hover:bg-hover transition-colors py-1 px-2 rounded-lg ml-1 focus-visible:outline focus-visible:outline-accent focus-visible:outline-offset-1"
type="button"
aria-label="connector"
>
@ -292,28 +298,30 @@ export const ConnectButton: React.FC<{
}
dropdown={
<div
className={`text-gray-600 bg-white rounded-lg ring-1 ring-border min-w-[140px] shadow-md py-2 ${
className={`min-w-[140px] ${
size === "base" ? "text-base" : "text-sm"
} mt-1`}
}`}
>
{dropdownLinks.map((link, i) => {
return (
<UniLink
key={i}
href={link.url}
onClick={link.onClick}
className={`${
size === "base" ? "pl-5 pr-6 h-11" : "pl-4 pr-5 h-9"
} flex items-center w-full whitespace-nowrap hover:bg-hover`}
aria-label={link.label}
>
<span className="mr-2 flex justify-center">
<i className={cn(link.icon, "text-base")} />
</span>
{link.label}
</UniLink>
)
})}
{dropdownLinks.map((link, i) => (
<Menu.Item
key={i}
icon={<i className={cn(link.icon, "text-base")} />}
className={`${
size === "base" ? "pl-5 pr-6 h-11" : "pl-4 pr-5 h-9"
} whitespace-nowrap`}
{...("href" in link
? {
type: "link",
href: link.href,
}
: {
type: "button",
onClick: link.onClick,
})}
>
{link.label}
</Menu.Item>
))}
</div>
}
/>

View File

@ -13,7 +13,6 @@ import { PatronButton } from "~/components/common/PatronButton"
import { SearchInput } from "~/components/common/SearchInput"
import { BlockchainIcon } from "~/components/icons/BlockchainIcon"
import { Image } from "~/components/ui/Image"
import { Menu } from "~/components/ui/Menu"
import { Modal } from "~/components/ui/Modal"
import { Tooltip } from "~/components/ui/Tooltip"
import { useIsDark } from "~/hooks/useDarkMode"
@ -25,6 +24,7 @@ import { cn } from "~/lib/utils"
import { ConnectButton } from "../common/ConnectButton"
import { Avatar } from "../ui/Avatar"
import { Button } from "../ui/Button"
import { Menu } from "../ui/Menu"
import { UniLink } from "../ui/UniLink"
type HeaderLinkType = {
@ -255,36 +255,25 @@ export const SiteHeader: React.FC<{
</div>
<div className="ml-0 sm:ml-8 space-x-3 sm:space-x-4 flex items-center sm:static absolute -bottom-0 right-0">
<div className="xlog-site-more-menu relative inline-block align-middle">
<Menu
target={
<Button
variant="text"
aria-label="more"
className="-mx-2 text-zinc-600"
<MoreActions>
{moreMenuItems.map((item) => (
<MoreActions.Item
key={item.text}
icon={item.icon}
{...(item.onClick
? {
type: "button",
onClick: item.onClick,
}
: {
type: "link",
href: item.url,
})}
>
<i className="icon-[mingcute--more-1-line] text-2xl" />
</Button>
}
dropdown={
<div className="text-gray-600 bg-white rounded-lg ring-1 ring-border shadow-md py-2 text-sm">
{moreMenuItems.map((item) => {
return (
<UniLink
key={item.text}
href={item.url}
onClick={item.onClick}
className="h-10 flex w-full space-x-2 items-center px-3 hover:bg-hover"
>
<span className="fill-gray-500 flex items-center w-4 h-4 text-base leading-none">
{item.icon}
</span>
<span>{t(item.text)}</span>
</UniLink>
)
})}
</div>
}
/>
{t(item.text)}
</MoreActions.Item>
))}
</MoreActions>
</div>
<div className="xlog-site-more-out hidden sm:block">
<div className="-mx-2 flex">
@ -357,3 +346,22 @@ export const SiteHeader: React.FC<{
</header>
)
}
function MoreActions({ children }: React.PropsWithChildren<{}>) {
return (
<Menu
target={
<Button
variant="text"
aria-label="more"
className="-mx-2 text-zinc-600"
>
<i className="icon-[mingcute--more-1-line] text-2xl" />
</Button>
}
dropdown={<div className="text-sm">{children}</div>}
/>
)
}
MoreActions.Item = Menu.Item

View File

@ -1,70 +1,114 @@
import { useState } from "react"
import Link from "next/link"
import { Fragment } from "react"
import {
Placement,
autoUpdate,
flip,
offset,
shift,
useClick,
useDismiss,
useFloating,
useInteractions,
useRole,
useTransitionStyles,
} from "@floating-ui/react"
import { Menu as HeadlessUiMenu } from "@headlessui/react"
export const Menu: React.FC<{
import { cn } from "~/lib/utils"
export function Menu({
target,
dropdown,
placement = "bottom-start",
}: React.PropsWithChildren<{
target: JSX.Element
dropdown: JSX.Element
placement?: Placement
}> = ({ target, dropdown, placement }) => {
const [isOpen, setIsOpen] = useState(false)
const { x, y, strategy, refs, context } = useFloating({
placement: placement || "bottom-start",
open: isOpen,
onOpenChange: setIsOpen,
middleware: [offset(5), flip(), shift({ padding: 8 })],
whileElementsMounted: autoUpdate,
})
const click = useClick(context)
const { getReferenceProps, getFloatingProps } = useInteractions([
click,
useRole(context, { role: "tooltip" }),
useDismiss(context),
])
const { isMounted, styles } = useTransitionStyles(context, {
duration: 100,
})
dropdown: React.ReactNode
placement?: "bottom-start" | "bottom-end"
}>) {
return (
<>
<div
ref={refs.setReference}
{...getReferenceProps({
className: "flex items-center",
})}
<HeadlessUiMenu>
<HeadlessUiMenu.Button as={Fragment}>{target}</HeadlessUiMenu.Button>
<HeadlessUiMenu.Items
className={cn(
"absolute z-10 mt-1 w-max outline-none text-gray-600 bg-white rounded-lg ring-1 ring-border shadow-md py-2",
{
"bottom-start": "top-[100%] left-0",
"bottom-end": "top-[100%] right-0",
}[placement],
)}
>
{target}
</div>
{isMounted && (
<div
ref={refs.setFloating}
className="z-10 w-max"
style={{
position: strategy,
top: y ?? "0",
left: x ?? "0",
...styles,
}}
{...getFloatingProps()}
>
{dropdown}
</div>
)}
</>
{dropdown}
</HeadlessUiMenu.Items>
</HeadlessUiMenu>
)
}
type MenuItemProps = {
icon?: React.ReactNode
className?: string
} & (
| {
type: "link"
href: string
}
| {
type: "button"
onClick: React.MouseEventHandler
}
)
Menu.Item = function MenuItem({
icon,
children,
className: classNameProp,
...props
}: React.PropsWithChildren<MenuItemProps>) {
const childElement = (
<>
<span
className="mr-2 fill-gray-500 flex items-center w-4 h-4 text-base leading-none"
aria-hidden
>
{icon}
</span>
{children}
</>
)
return (
<HeadlessUiMenu.Item>
{({ active }) => {
const className = cn(
"w-full h-10 px-3 flex items-center flex-nowrap",
{
"bg-hover": active,
},
classNameProp,
)
// Can't use <UniLink> here because headlessui Menu.Item assigns `onClick` to its child
if (props.type === "button") {
return (
<button className={className} {...props}>
{childElement}
</button>
)
}
if (typeof props.href === "undefined") {
return <span className={className}>{childElement}</span>
}
const isExternal =
/^https?:\/\//.test(props.href) || props.href.startsWith("/feed")
if (isExternal) {
return (
<a
className={className}
target="_blank"
rel="nofollow noreferrer"
{...props}
>
{childElement}
</a>
)
}
return (
<Link className={className} {...props}>
{childElement}
</Link>
)
}}
</HeadlessUiMenu.Item>
)
}

View File

@ -1,12 +1,21 @@
import Link from "next/link"
export const UniLink: React.FC<{
export type UniLinkProps = {
href?: string
onClick?: () => void
children: React.ReactNode
className?: string
target?: string
}> = ({ href, onClick, children, className, target, ...props }) => {
}
export const UniLink: React.FC<UniLinkProps> = ({
href,
onClick,
children,
className,
target,
...props
}) => {
if (onClick) {
return (
<button className={className} onClick={onClick} {...props}>