Support dropdown without link (#2596)

This commit is contained in:
Greg Bergé
2024-12-04 12:05:17 +01:00
committed by GitHub
parent 75e29b69cc
commit 75a6fabe61
4 changed files with 170 additions and 90 deletions
@@ -82,11 +82,12 @@ export function Dropdown<E extends HTMLElement>(props: {
/**
* Animated chevron to display in the dropdown button.
*/
export function DropdownChevron(props: {}) {
export function DropdownChevron() {
return (
<Icon
icon="chevron-down"
className={tcls(
'shrink-0',
'opacity-6',
'size-3',
'ms-1',
@@ -1,15 +1,20 @@
import {
CustomizationContentLink,
CustomizationHeaderLink,
CustomizationSettings,
CustomizationHeaderPreset,
SiteCustomizationSettings,
CustomizationHeaderLink,
} from '@gitbook/api';
import assertNever from 'assert-never';
import { ContentRefContext, resolveContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
// @TODO replace by api.CustomizationHeaderItem when available
type CustomizationHeaderItem = Omit<CustomizationHeaderLink, 'to'> & {
to: CustomizationHeaderLink['to'] | null;
};
import {
Dropdown,
DropdownButtonProps,
@@ -21,93 +26,40 @@ import { Button, Link } from '../primitives';
export async function HeaderLink(props: {
context: ContentRefContext;
link: CustomizationHeaderLink;
link: CustomizationHeaderItem;
customization: CustomizationSettings | SiteCustomizationSettings;
}) {
const { context, link, customization } = props;
const target = await resolveContentRef(link.to, context);
if (!target) {
return null;
}
const target = link.to ? await resolveContentRef(link.to, context) : null;
const headerPreset = customization.header.preset;
const renderLink = (linkProps: DropdownButtonProps<HTMLAnchorElement>) => {
const linkStyle = link.style ?? 'link';
switch (linkStyle) {
case 'button-secondary':
case 'button-primary': {
const variant = (() => {
switch (linkStyle) {
case 'button-secondary':
return 'secondary';
case 'button-primary':
return 'primary';
default:
assertNever(linkStyle);
}
})();
return (
<Button
href={target.href}
variant={variant}
size="medium"
className={tcls(
{
'button-primary':
headerPreset === CustomizationHeaderPreset.Custom ||
headerPreset === CustomizationHeaderPreset.Bold
? tcls(
'bg-header-link-500 hover:bg-text-header-link-300 text-header-button-text',
'dark:bg-header-link-500 dark:hover:bg-text-header-link-300 dark:text-header-button-text',
)
: null,
'button-secondary': tcls(
'bg:transparent hover:bg-transparent',
'dark:bg-transparent dark:hover:bg-transparent',
'ring-header-link-500 hover:ring-header-link-300 text-header-link-500',
'dark:ring-header-link-500 dark:hover:ring-header-link-300 dark:text-header-link-500',
),
}[linkStyle],
)}
>
{link.title}
</Button>
);
}
case 'link': {
return (
<Link
{...linkProps}
href={target.href}
className={tcls(
'overflow-hidden',
'text-sm lg:text-base',
'flex flex-row items-center',
'whitespace-nowrap',
'hover:text-header-link-400 dark:hover:text-light',
headerPreset === CustomizationHeaderPreset.Default
? ['text-dark/8', 'dark:text-light/8']
: ['text-header-link-500 hover:text-header-link-400'],
)}
>
<span className={tcls('truncate')}>{link.title}</span>
{link.links && link.links.length > 0 ? <DropdownChevron /> : null}
</Link>
);
}
default:
assertNever(linkStyle);
}
};
const linkStyle = link.style ?? 'link';
if (link.links && link.links.length > 0) {
return (
<Dropdown button={renderLink}>
<Dropdown
button={(buttonProps) => {
if (!target) {
return (
<HeaderItemDropdown
{...buttonProps}
headerPreset={headerPreset}
title={link.title}
/>
);
}
return (
<HeaderLinkNavItem
{...buttonProps}
linkStyle={linkStyle}
headerPreset={headerPreset}
title={link.title}
isDropdown
href={target?.href}
/>
);
}}
>
<DropdownMenu>
{link.links.map((subLink, index) => (
<SubHeaderLink key={index} {...props} link={subLink} />
@@ -117,7 +69,128 @@ export async function HeaderLink(props: {
);
}
return renderLink({});
if (!target) {
return null;
}
return (
<HeaderLinkNavItem
linkStyle={linkStyle}
headerPreset={headerPreset}
title={link.title}
isDropdown={false}
href={target.href}
/>
);
}
export type HeaderLinkNavItemProps = {
linkStyle: NonNullable<CustomizationHeaderLink['style']>;
headerPreset: CustomizationHeaderPreset;
title: string;
href: string;
isDropdown: boolean;
} & DropdownButtonProps<HTMLElement>;
function HeaderLinkNavItem(props: HeaderLinkNavItemProps) {
switch (props.linkStyle) {
case 'button-secondary':
case 'button-primary':
return <HeaderItemButton {...props} linkStyle={props.linkStyle} />;
case 'link':
return <HeaderItemLink {...props} />;
default:
assertNever(props.linkStyle);
}
}
function HeaderItemButton(
props: Omit<HeaderLinkNavItemProps, 'linkStyle'> & {
linkStyle: 'button-secondary' | 'button-primary';
},
) {
const { linkStyle, headerPreset, title, href, ...rest } = props;
const variant = (() => {
switch (linkStyle) {
case 'button-secondary':
return 'secondary';
case 'button-primary':
return 'primary';
default:
assertNever(linkStyle);
}
})();
return (
<Button
href={href}
variant={variant}
size="medium"
className={tcls(
{
'button-primary':
headerPreset === CustomizationHeaderPreset.Custom ||
headerPreset === CustomizationHeaderPreset.Bold
? tcls(
'bg-header-link-500 hover:bg-text-header-link-300 text-header-button-text',
'dark:bg-header-link-500 dark:hover:bg-text-header-link-300 dark:text-header-button-text',
)
: null,
'button-secondary': tcls(
'bg:transparent hover:bg-transparent',
'dark:bg-transparent dark:hover:bg-transparent',
'ring-header-link-500 hover:ring-header-link-300 text-header-link-500',
'dark:ring-header-link-500 dark:hover:ring-header-link-300 dark:text-header-link-500',
),
}[linkStyle],
)}
{...rest}
>
{title}
</Button>
);
}
function getHeaderLinkClassName(props: { headerPreset: CustomizationHeaderPreset }) {
return tcls(
'overflow-hidden',
'text-sm lg:text-base',
'flex flex-row items-center',
'whitespace-nowrap',
'hover:text-header-link-400 dark:hover:text-light',
'truncate',
props.headerPreset === CustomizationHeaderPreset.Default
? ['text-dark/8', 'dark:text-light/8']
: ['text-header-link-500 hover:text-header-link-400'],
);
}
function HeaderItemLink(props: HeaderLinkNavItemProps) {
const { headerPreset, title, isDropdown, href, ...rest } = props;
return (
<Link href={href} className={getHeaderLinkClassName({ headerPreset })} {...rest}>
{title}
{isDropdown ? <DropdownChevron /> : null}
</Link>
);
}
function HeaderItemDropdown(
props: {
headerPreset: CustomizationHeaderPreset;
title: string;
} & DropdownButtonProps<HTMLElement>,
) {
const { headerPreset, title, ...rest } = props;
return (
<span
className={tcls(getHeaderLinkClassName({ headerPreset }), 'cursor-default')}
{...rest}
>
{title}
<DropdownChevron />
</span>
);
}
async function SubHeaderLink(props: {
@@ -13,12 +13,17 @@ import { tcls } from '@/lib/tailwind';
import { Dropdown, DropdownMenu, DropdownMenuItem } from './Dropdown';
import styles from './headerLinks.module.css';
// @TODO replace by api.CustomizationHeaderItem when available
type CustomizationHeaderItem = Omit<CustomizationHeaderLink, 'to'> & {
to: CustomizationHeaderLink['to'] | null;
};
/**
* Dropdown menu for header links hidden at small screen size.
*/
export function HeaderLinkMore(props: {
label: React.ReactNode;
links: CustomizationHeaderLink[];
links: CustomizationHeaderItem[];
context: ContentRefContext;
customization: CustomizationSettings | SiteCustomizationSettings;
}) {
@@ -55,10 +60,10 @@ export function HeaderLinkMore(props: {
);
}
async function MoreMenuLink(props: { context: ContentRefContext; link: CustomizationHeaderLink }) {
async function MoreMenuLink(props: { context: ContentRefContext; link: CustomizationHeaderItem }) {
const { context, link } = props;
const target = await resolveContentRef(link.to, context);
const target = link.to ? await resolveContentRef(link.to, context) : null;
if (!target) {
return null;
@@ -1,17 +1,17 @@
'use client';
import type { ComponentPropsWithoutRef, HTMLAttributes } from 'react';
import { tcls, ClassValue } from '@/lib/tailwind';
import { Link } from './Link';
type ButtonProps = {
href?: string;
onClick?: () => void;
children: React.ReactNode;
variant?: 'primary' | 'secondary';
size?: 'default' | 'medium' | 'small';
className?: ClassValue;
};
} & HTMLAttributes<HTMLElement>;
export function Button({
href,
@@ -20,6 +20,7 @@ export function Button({
variant = 'primary',
size = 'default',
className,
...rest
}: ButtonProps) {
const variantClasses =
variant === 'primary'
@@ -69,14 +70,14 @@ export function Button({
if (href) {
return (
<Link href={href} className={domClassName}>
<Link href={href} className={domClassName} {...rest}>
{children}
</Link>
);
}
return (
<button type="button" onClick={onClick} className={domClassName}>
<button type="button" className={domClassName} {...rest}>
{children}
</button>
);