feat(nx-dev): improve tab UX

This commit is contained in:
Juri 2024-07-26 17:50:12 +02:00 committed by Juri Strumpflohner
parent 46dcee6dd2
commit ff51fcd2cd
4 changed files with 80 additions and 42 deletions

View File

@ -4,6 +4,7 @@ import {
ClipboardDocumentIcon, ClipboardDocumentIcon,
SparklesIcon, SparklesIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import cx from 'classnames';
import { JSX, ReactNode, useEffect, useState } from 'react'; import { JSX, ReactNode, useEffect, useState } from 'react';
// @ts-ignore // @ts-ignore
import { CopyToClipboard } from 'react-copy-to-clipboard'; import { CopyToClipboard } from 'react-copy-to-clipboard';
@ -31,6 +32,7 @@ function CodeWrapper(options: {
title: string; title: string;
path: string; path: string;
language: string; language: string;
isWithinTab?: boolean;
children: string; // intentionally typed as such children: string; // intentionally typed as such
}): ({ children }: { children: ReactNode }) => JSX.Element { }): ({ children }: { children: ReactNode }) => JSX.Element {
return ({ children }: { children: ReactNode }) => return ({ children }: { children: ReactNode }) =>
@ -49,7 +51,11 @@ function CodeWrapper(options: {
title={options.title} title={options.title}
/> />
) : ( ) : (
<CodeOutput content={children} fileName={options.fileName} /> <CodeOutput
content={children}
fileName={options.fileName}
isWithinTab={options.isWithinTab}
/>
); );
} }
@ -92,6 +98,7 @@ export interface FenceProps {
skipRescope?: boolean; skipRescope?: boolean;
selectedLineGroup?: string; selectedLineGroup?: string;
onLineGroupSelectionChange?: (selection: string) => void; onLineGroupSelectionChange?: (selection: string) => void;
isWithinTab?: boolean;
} }
export function Fence({ export function Fence({
@ -107,6 +114,7 @@ export function Fence({
selectedLineGroup, selectedLineGroup,
skipRescope, skipRescope,
onLineGroupSelectionChange, onLineGroupSelectionChange,
isWithinTab,
}: FenceProps) { }: FenceProps) {
if (highlightLines) { if (highlightLines) {
highlightLines = processHighlightLines(highlightLines); highlightLines = processHighlightLines(highlightLines);
@ -168,7 +176,12 @@ export function Fence({
} }
return ( return (
<div className="code-block group relative w-full"> <div
className={cx(
'code-block group relative',
isWithinTab ? '-ml-4 -mr-4 w-[calc(100%+2rem)]' : 'w-auto'
)}
>
<div> <div>
<div className="absolute right-0 top-0 z-10 flex"> <div className="absolute right-0 top-0 z-10 flex">
{enableCopy && enableCopy === true && ( {enableCopy && enableCopy === true && (
@ -182,7 +195,7 @@ export function Fence({
type="button" type="button"
className={ className={
'not-prose flex border border-slate-200 bg-slate-50/50 p-2 opacity-0 transition-opacity group-hover:opacity-100 dark:border-slate-700 dark:bg-slate-800/60' + 'not-prose flex border border-slate-200 bg-slate-50/50 p-2 opacity-0 transition-opacity group-hover:opacity-100 dark:border-slate-700 dark:bg-slate-800/60' +
(highlightOptions && highlightOptions[0] ((highlightOptions && highlightOptions[0]) || isWithinTab
? '' ? ''
: ' rounded-tr-lg') : ' rounded-tr-lg')
} }
@ -197,7 +210,7 @@ export function Fence({
)} )}
{highlightOptions && highlightOptions[0] && ( {highlightOptions && highlightOptions[0] && (
<Selector <Selector
className="rounded-tr-lg" className={cx(isWithinTab ? '' : 'rounded-tr-lg')}
items={highlightOptions} items={highlightOptions}
selected={selectedOption} selected={selectedOption}
onChange={highlightChange} onChange={highlightChange}
@ -219,6 +232,7 @@ export function Fence({
path, path,
language, language,
children, children,
isWithinTab,
})} })}
/> />
</div> </div>

View File

@ -1,14 +1,22 @@
import { JSX, ReactNode } from 'react'; import { JSX, ReactNode } from 'react';
import cx from 'classnames';
export function CodeOutput({ export function CodeOutput({
content, content,
fileName, fileName,
isWithinTab,
}: { }: {
content: ReactNode; content: ReactNode;
fileName: string; fileName: string;
isWithinTab?: boolean;
}): JSX.Element { }): JSX.Element {
return ( return (
<div className="hljs not-prose w-full overflow-x-auto rounded-lg border border-slate-200 bg-slate-50/50 font-mono text-sm dark:border-slate-700 dark:bg-slate-800/60"> <div
className={cx(
'hljs not-prose w-full overflow-x-auto border-slate-200 bg-slate-50/50 font-mono text-sm dark:border-slate-700 dark:bg-slate-800/60',
isWithinTab ? 'border-b border-t' : 'rounded-lg border'
)}
>
{!!fileName && ( {!!fileName && (
<div className="flex border-b border-slate-200 bg-slate-50 px-4 py-2 italic text-slate-400 dark:border-slate-700 dark:bg-slate-800/80 dark:text-slate-500"> <div className="flex border-b border-slate-200 bg-slate-50 px-4 py-2 italic text-slate-400 dark:border-slate-700 dark:bg-slate-800/80 dark:text-slate-500">
{fileName} {fileName}

View File

@ -34,7 +34,7 @@ export function FenceWrapper(props: FenceProps) {
}; };
return ( return (
<div className="my-8 w-full"> <div className="w-full">
<Fence {...modifiedProps} /> <Fence {...modifiedProps} />
</div> </div>
); );

View File

@ -1,12 +1,12 @@
'use client'; 'use client';
// TODO@ben: refactor to use HeadlessUI tabs
import cx from 'classnames'; import cx from 'classnames';
import { import React, {
createContext, createContext,
ReactNode, ReactNode,
useContext, useContext,
useEffect, useEffect,
useState, useState,
cloneElement,
} from 'react'; } from 'react';
export const TabContext = createContext(''); export const TabContext = createContext('');
@ -20,7 +20,8 @@ export function Tabs({
labels: string[]; labels: string[];
children: ReactNode; children: ReactNode;
}) { }) {
const [currentTab, setCurrentTab] = useState(labels[0]); const [currentTab, setCurrentTab] = useState<string>(labels[0]);
useEffect(() => { useEffect(() => {
const handleTabSelectedEvent = () => { const handleTabSelectedEvent = () => {
const selectedTab = localStorage.getItem(SELECTED_TAB_KEY); const selectedTab = localStorage.getItem(SELECTED_TAB_KEY);
@ -28,43 +29,49 @@ export function Tabs({
setCurrentTab(selectedTab); setCurrentTab(selectedTab);
} }
}; };
handleTabSelectedEvent(); handleTabSelectedEvent();
window.addEventListener(TAB_SELECTED_EVENT, handleTabSelectedEvent); window.addEventListener(TAB_SELECTED_EVENT, handleTabSelectedEvent);
return () => return () =>
window.removeEventListener(TAB_SELECTED_EVENT, handleTabSelectedEvent); window.removeEventListener(TAB_SELECTED_EVENT, handleTabSelectedEvent);
}, [labels]); }, [labels]);
const handleTabClick = (label: string) => {
localStorage.setItem(SELECTED_TAB_KEY, label);
window.dispatchEvent(new Event(TAB_SELECTED_EVENT));
setCurrentTab(label);
};
return ( return (
<TabContext.Provider value={currentTab}> <TabContext.Provider value={currentTab}>
<section> <nav className="not-prose -mb-px flex space-x-8" aria-label="Tabs">
<div className="not-prose "> {labels.map((label, index) => (
<div className="border-b border-slate-200 dark:border-slate-800"> <button
<nav className="-mb-px flex space-x-8" aria-label="Tabs"> key={label}
{labels.map((label: string) => ( role="tab"
<button aria-selected={label === currentTab}
key={label} onClick={() => handleTabClick(label)}
role="tab" className={cx(
aria-selected={label === currentTab} 'whitespace-nowrap border-b-2 p-2 text-sm font-medium',
onClick={() => { label === currentTab
localStorage.setItem(SELECTED_TAB_KEY, label); ? 'border-blue-500 text-slate-800 dark:border-sky-500 dark:text-slate-300'
window.dispatchEvent(new Event(TAB_SELECTED_EVENT)); : 'border-transparent text-slate-500 hover:border-blue-500 hover:text-slate-800 dark:text-slate-400 dark:hover:border-sky-500 dark:hover:text-slate-300'
setCurrentTab(label); )}
}} >
className={cx( {label}
'whitespace-nowrap border-b-2 border-transparent p-2 text-sm font-medium', </button>
label === currentTab ))}
? 'border-blue-500 text-slate-800 dark:border-sky-500 dark:text-slate-300' </nav>
: 'text-slate-500 hover:border-blue-500 hover:text-slate-800 dark:text-slate-400 dark:hover:border-sky-500 dark:hover:text-slate-300' <div
)} className={cx(
> 'border border-slate-200 pb-2 pl-4 pr-4 pt-2 dark:border-slate-700',
{label} currentTab === labels[0]
</button> ? 'rounded-b-md rounded-tr-md'
))} : 'rounded-b-md rounded-t-md'
</nav> )}
</div> >
</div>
{children} {children}
</section> </div>
</TabContext.Provider> </TabContext.Provider>
); );
} }
@ -77,14 +84,23 @@ export function Tab({
children: ReactNode; children: ReactNode;
}) { }) {
const currentTab = useContext(TabContext); const currentTab = useContext(TabContext);
const isActive = label === currentTab;
if (label !== currentTab) { const passPropsToChildren = (children: ReactNode) => {
return null; return React.Children.map(children, (child) => {
} if (React.isValidElement(child) && typeof child.type !== 'string') {
return cloneElement(child, { isWithinTab: true });
}
return child;
});
};
return ( return (
<div className="prose prose-slate dark:prose-invert mt-4 max-w-none"> <div
{children} className="prose prose-slate dark:prose-invert mt-2 max-w-none"
hidden={!isActive}
>
{isActive && passPropsToChildren(children)}
</div> </div>
); );
} }