Skip to content
Open

wip #4690

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
5 changes: 5 additions & 0 deletions .changeset/dark-hotels-drop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'nextra': minor
---

feat(tsdoc): add support for `@inline` tag on function parameters
6 changes: 6 additions & 0 deletions docs/app/docs/built-ins/[name]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ const API_REFERENCE: (
"Omit<ComboboxInputProps, 'className' | 'onChange' | 'onFocus' | 'onBlur' | 'value' | 'placeholder'>"
},
{ type: 'separator', title: 'Content Components', name: '_2' },
{
name: 'Accordion',
packageName: 'nextra/components',
groupKeys: 'Omit<ComponentProps<"details">, "open" | "className">',
isFlattened: false
},
{
name: 'Bleed',
packageName: 'nextra/components',
Expand Down
144 changes: 85 additions & 59 deletions packages/nextra/src/client/components/tabs/index.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,14 @@ import type {
TabPanelProps
} from '@headlessui/react'
import cn from 'clsx'
import type { FC, ReactElement, ReactNode } from 'react'
import { Fragment, useEffect, useRef, useState } from 'react'
import type { FC, ReactNode } from 'react'
import { Children, Fragment, useEffect, useState } from 'react'
import { useHash } from '../../hooks/use-hash.js'

type TabItem = string | ReactElement
// eslint-disable-next-line sonarjs/redundant-type-aliases
type TabItem = string

type TabObjectItem = {
interface TabObjectItem {
label: TabItem
disabled: boolean
}
Expand All @@ -31,7 +32,10 @@ function isTabObjectItem(item: unknown): item is TabObjectItem {

export const Tabs: FC<
{
items: (TabItem | TabObjectItem)[]
/**
* @deprecated Use `Tabs.Tab#label` and `Tabs.Tab#disabled` props instead.
*/
items?: (TabItem | TabObjectItem)[]
children: ReactNode
/** LocalStorage key for persisting the selected tab. */
storageKey?: string
Expand All @@ -41,48 +45,57 @@ export const Tabs: FC<
tabClassName?: HeadlessTabProps['className']
} & Pick<TabGroupProps, 'defaultIndex' | 'selectedIndex' | 'onChange'>
> = ({
items,
children,
storageKey,
defaultIndex = 0,
selectedIndex: _selectedIndex,
onChange,
className,
tabClassName
tabClassName,
...props
}) => {
const [selectedIndex, setSelectedIndex] = useState(defaultIndex)
const hash = useHash()
const tabPanelsRef = useRef<HTMLDivElement>(null!)

useEffect(() => {
if (_selectedIndex !== undefined) {
setSelectedIndex(_selectedIndex)
}
}, [_selectedIndex])

const hasLabelPropInTab = Children.toArray(children).some(
child => (child as any).props.label
)
const items: TabObjectItem[] = hasLabelPropInTab
? (children as any).map((child: any) => child.props as TabObjectItem)
: // eslint-disable-next-line @typescript-eslint/no-deprecated
props.items!.map(item => {
if (!isTabObjectItem(item)) {
return { id: item }
}
return {
id: item.label,
disabled: item.disabled
}
})

useEffect(() => {
if (!hash) return
const tabPanel = tabPanelsRef.current.querySelector(
`[role=tabpanel]:has([id="${hash}"])`
)
if (!tabPanel) return

for (const [index, el] of Object.entries(tabPanelsRef.current.children)) {
if (el === tabPanel) {
setSelectedIndex(Number(index))
// Clear hash first, otherwise page isn't scrolled
location.hash = ''
// Execute on next tick after `selectedIndex` update
requestAnimationFrame(() => {
location.hash = `#${hash}`
})
}
}
}, [hash])
const index = items.findIndex(item => item.label === hash)
if (index === -1) return
setSelectedIndex(index)

// Clear hash first, otherwise the page isn't scrolled
location.hash = ''
// Execute on next tick after `selectedIndex` update
requestAnimationFrame(() => {
location.hash = `#${hash}`
})
}, [hash]) // eslint-disable-line react-hooks/exhaustive-deps -- check only hash

useEffect(() => {
if (!storageKey) {
// Do not listen storage events if there is no storage key
// Do not listen to storage events if there is no storage key
return
}

Expand Down Expand Up @@ -134,49 +147,62 @@ export const Tabs: FC<
)
}
>
{items.map((item, index) => (
<HeadlessTab
key={index}
disabled={isTabObjectItem(item) && item.disabled}
className={args => {
const { selected, disabled, hover, focus } = args
return cn(
focus && 'x:nextra-focus x:ring-inset',
'x:whitespace-nowrap x:cursor-pointer',
'x:rounded-t x:p-2 x:font-medium x:leading-5 x:transition-colors',
'x:-mb-0.5 x:select-none x:border-b-2',
selected
? 'x:border-current x:outline-none'
: hover
? 'x:border-gray-200 x:dark:border-neutral-800'
: 'x:border-transparent',
selected
? 'x:text-primary-600'
: disabled
? 'x:text-gray-400 x:dark:text-neutral-600 x:pointer-events-none'
{items.map((item, index) => {
return (
<HeadlessTab
onClick={() => {
history.replaceState(null, '', `#${item.label}`)
}}
key={index}
disabled={item.disabled}
className={args => {
const { selected, disabled, hover, focus } = args
return cn(
focus && 'x:nextra-focus x:ring-inset',
'x:whitespace-nowrap x:cursor-pointer',
'x:rounded-t x:p-2 x:font-medium x:leading-5 x:transition-colors',
'x:-mb-0.5 x:select-none x:border-b-2',
selected
? 'x:border-current x:outline-none'
: hover
? 'x:text-black x:dark:text-white'
: 'x:text-gray-600 x:dark:text-gray-200',
typeof tabClassName === 'function'
? tabClassName(args)
: tabClassName
)
}}
>
{isTabObjectItem(item) ? item.label : item}
</HeadlessTab>
))}
? 'x:border-gray-200 x:dark:border-neutral-800'
: 'x:border-transparent',
selected
? 'x:text-primary-600'
: disabled
? 'x:text-gray-400 x:dark:text-neutral-600 x:pointer-events-none'
: hover
? 'x:text-black x:dark:text-white'
: 'x:text-gray-600 x:dark:text-gray-200',
typeof tabClassName === 'function'
? tabClassName(args)
: tabClassName
)
}}
>
<h3
// Subtitle for pagefind search
id={item.label}
className="x:size-0 x:invisible"
>
{item.label}
</h3>
{item.label}
</HeadlessTab>
)
})}
</TabList>
<TabPanels ref={tabPanelsRef}>{children}</TabPanels>
<TabPanels>{children}</TabPanels>
</TabGroup>
)
}

export const Tab: FC<TabPanelProps> = ({
export const Tab: FC<TabPanelProps & { label: string }> = ({
children,
// For SEO display all the Panel in the DOM and set `display: none;` for those that are not selected
unmount = false,
className,
label: _label,
...props
}) => {
return (
Expand Down
32 changes: 16 additions & 16 deletions packages/nextra/src/client/components/tabs/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,25 @@
import type { ComponentProps } from 'react'
import { Tabs as _Tabs, Tab } from './index.client.js'

// Workaround to fix
// Error: Cannot access Tab.propTypes on the server. You cannot dot into a client module from a
// server component. You can only pass the imported name through.
/**
* A built-in component for creating tabbed content, helping organize related information in a
* compact, interactive layout.
*
* @example
* <Tabs items={['pnpm', 'npm', 'yarn']}>
* <Tabs.Tab>**pnpm**: Fast, disk space efficient package manager.</Tabs.Tab>
* <Tabs.Tab>**npm** is a package manager for the JavaScript programming language.</Tabs.Tab>
* <Tabs.Tab>**Yarn** is a software packaging system.</Tabs.Tab>
* <Tabs>
* <Tabs.Tab label="pnpm">**pnpm**: Fast, disk space efficient package manager.</Tabs.Tab>
* <Tabs.Tab label="npm">**npm** is a package manager for the JavaScript programming language.</Tabs.Tab>
* <Tabs.Tab label="yarn">**Yarn** is a software packaging system.</Tabs.Tab>
* </Tabs>
*
* @usage
* ```mdx
* import { Tabs } from 'nextra/components'
*
* <Tabs items={['pnpm', 'npm', 'yarn']}>
* <Tabs.Tab>**pnpm**: Fast, disk space efficient package manager.</Tabs.Tab>
* <Tabs.Tab>**npm** is a package manager for the JavaScript programming language.</Tabs.Tab>
* <Tabs.Tab>**Yarn** is a software packaging system.</Tabs.Tab>
* <Tabs>
* <Tabs.Tab label="pnpm">**pnpm**: Fast, disk space efficient package manager.</Tabs.Tab>
* <Tabs.Tab label="npm">**npm** is a package manager for the JavaScript programming language.</Tabs.Tab>
* <Tabs.Tab label="yarn">**Yarn** is a software packaging system.</Tabs.Tab>
* </Tabs>
* ```
*
Expand All @@ -35,20 +32,23 @@ import { Tabs as _Tabs, Tab } from './index.client.js'
* ```mdx /defaultIndex="1"/
* import { Tabs } from 'nextra/components'
*
* <Tabs items={['pnpm', 'npm', 'yarn']} defaultIndex="1">
* <Tabs defaultIndex="1">
* ...
* </Tabs>
* ```
*
* And you will have `npm` as the default tab:
*
* <Tabs items={['pnpm', 'npm', 'yarn']} defaultIndex="1">
* <Tabs.Tab>**pnpm**: Fast, disk space efficient package manager.</Tabs.Tab>
* <Tabs.Tab>**npm** is a package manager for the JavaScript programming language.</Tabs.Tab>
* <Tabs.Tab>**Yarn** is a software packaging system.</Tabs.Tab>
* <Tabs defaultIndex="1">
* <Tabs.Tab label="pnpm">**pnpm**: Fast, disk space efficient package manager.</Tabs.Tab>
* <Tabs.Tab label="npm">**npm** is a package manager for the JavaScript programming language.</Tabs.Tab>
* <Tabs.Tab label="yarn">**Yarn** is a software packaging system.</Tabs.Tab>
* </Tabs>
*/
export const Tabs = Object.assign(
// Workaround to fix
// Error: Cannot access Tab.propTypes on the server. You cannot dot into a client module from a
// server component. You can only pass the imported name through.
(props: ComponentProps<typeof _Tabs>) => <_Tabs {...props} />,
{ Tab }
)
64 changes: 61 additions & 3 deletions packages/nextra/src/client/mdx-components/details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,72 @@ import { Children, cloneElement, useEffect, useRef, useState } from 'react'
import { Collapse } from '../components/collapse.js'
import { useHash } from '../hooks/index.js'

export const Details: FC<ComponentProps<'details'>> = ({
type DetailsProps = ComponentProps<'details'>

export interface AccordionProps extends DetailsProps {
/** Default open state. */
open?: DetailsProps['open']

/** CSS class name. */
className?: DetailsProps['className']
}

/**
* A vertically stacked, interactive heading component that reveals or hides content when toggled.
*
* > [!NOTE]
* >
* > This Accordion uses native HTML `<details>` and `<summary>` elements, which are
* > collapsible by default and fully compatible with platforms like GitHub.
*
* @usage
* ```mdx filename="page.mdx"
* <details>
* <summary>Section 1</summary>
* Content for section 1.
* <details>
* <summary>Section 2</summary>
* Content for section 2.
* </details>
* </details>
* ```
*
* ```jsx filename="page.jsx"
* import { Accordion, AccordionTrigger } from 'nextra/components'
*
* export function Demo() {
* return (
* <Accordion>
* <AccordionTrigger>Section 1</AccordionTrigger>
* Content for section 1.
*
* <Accordion>
* <AccordionTrigger>Section 2</AccordionTrigger>
* Content for section 2.
* </Accordion>
* </Accordion>
* )
* }
* ```
*
* @example
* <details>
* <summary>Summary</summary>
* Details
* <details>
* <summary>Summary 2</summary>
* Details 2
* </details>
* </details>
*/
export const Accordion: FC<AccordionProps> = ({
children,
open,
className,
...props
}) => {
const [isOpen, setIsOpen] = useState(!!open)
// To animate the close animation we have to delay the DOM node state here.
// To animate the close animation, we have to delay the DOM node state here.
const [delayedOpenState, setDelayedOpenState] = useState(isOpen)
const animationRef = useRef(0)

Expand Down Expand Up @@ -109,7 +167,7 @@ function findSummary(
return
}
// @ts-expect-error -- fixme
if (child.type !== Details && child.props.children) {
if (child.type !== Accordion && child.props.children) {
// @ts-expect-error -- fixme
;[summary, child] = findSummary(child.props.children, setIsOpen)
}
Expand Down
4 changes: 2 additions & 2 deletions packages/nextra/src/client/mdx-components/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export { Pre } from './pre/index.js'
export { Anchor } from './anchor.js'
export { Code } from './code.js'
export { Details } from './details.js'
export { Accordion, Accordion as Details } from './details.js'
export { Image } from './image.js'
export { Summary } from './summary.js'
export { AccordionTrigger, AccordionTrigger as Summary } from './summary.js'
export { Table } from './table.js'
2 changes: 1 addition & 1 deletion packages/nextra/src/client/mdx-components/summary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import cn from 'clsx'
import type { FC, HTMLAttributes } from 'react'
import { ArrowRightIcon, LinkIcon } from '../icons/index.js'

export const Summary: FC<HTMLAttributes<HTMLElement>> = ({
export const AccordionTrigger: FC<HTMLAttributes<HTMLElement>> = ({
children,
className,
id,
Expand Down
Loading
Loading