perf(a11y): add keyboard navigation support to SiteHeader more action… (#464)
This commit is contained in:
parent
aebbafa254
commit
dce0f60e7d
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
Loading…
Reference in New Issue