Skip to content

Instantly share code, notes, and snippets.

@kotsutsumi
Last active October 28, 2024 12:14
Show Gist options
  • Save kotsutsumi/3677758aa6b4092b693fed887e7984cd to your computer and use it in GitHub Desktop.
Save kotsutsumi/3677758aa6b4092b693fed887e7984cd to your computer and use it in GitHub Desktop.
Scrollable tabs with drag-and-drop reordering using ParkUI
'use client'
/**
* Tabs Component
*
* Scrollable tabs with drag-and-drop reordering and close button.
*
* npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/modifiers @dnd-kit/utilities --force
*/
import { ComponentProps, useEffect, useRef, useState } from 'react'
import { log } from 'console'
import { VscClose, VscEllipsis, VscPinned } from 'react-icons/vsc'
import { css, cx } from 'styled-system/css'
import { DndContext, closestCenter, useSensor, useSensors, MouseSensor, TouchSensor, KeyboardSensor, Modifier } from '@dnd-kit/core'
import { restrictToHorizontalAxis, restrictToVerticalAxis } from '@dnd-kit/modifiers'
import { arrayMove, SortableContext, useSortable } from '@dnd-kit/sortable'
import { CSS, useUniqueId } from '@dnd-kit/utilities'
import { Tabs as TabsPark } from '@/components/ui/tabs'
// Types
export type ListItem = { id: string; pinned?: boolean; visible?: boolean }
// Tabs.closeTab
function closeTab(id: string, items: ListItem[]) {
const idx = items.findIndex((m) => m.id === id)
const newId = items.length > 1 ? items[idx - 1]?.id : null
return [items.filter((item) => item.id !== id), newId]
}
// Tabs.closeTabOthers
function closeTabOthers(id: string, items: ListItem[]): ListItem[] {
return items.filter((m) => m.pinned == true || m.id === id)
}
// Tabs.closeTabRights
function closeTabRights(id: string, items: ListItem[]): ListItem[] {
const idx = items.findIndex((m) => m.id === id)
return items.filter((m, i) => m.pinned == true || i <= idx)
}
// Tabs.closeTabAll
function closeTabAll(items: ListItem[]): ListItem[] {
return items.filter((m, i) => m.pinned == true)
}
// Tabs.getTab
function getTab(id: string, items: ListItem[]): ListItem | null {
const idx = items.findIndex((m) => m.id === id)
return items[idx]
}
// Tabs.pinTab
function pinTab(id: string, items: ListItem[]): ListItem[] {
const ret = items.map((m) => {
if (m.id === id) {
m.pinned = true
}
return m
})
const reorderedTabs: ListItem[] = []
let targetTab: ListItem | null = null
for (const item of ret) {
if (item.id === id) {
targetTab = item
continue
}
if (item.pinned) {
reorderedTabs.push(item)
}
}
reorderedTabs.push(targetTab!)
for (const item of ret) {
if (!item.pinned) {
reorderedTabs.push(item)
}
}
return reorderedTabs
}
// Tabs.unpinTab
export function unpinTab(id: string, items: ListItem[]): ListItem[] {
return items.map((m) => {
if (m.id === id) {
m.pinned = false
}
return m
})
}
// Tabs.List
function List({
orientation = 'horizontal',
scrollable = true,
overflowMenu,
onOverflow,
items,
setItems,
children,
...props
}: ComponentProps<typeof TabsPark.List> & {
orientation?: 'vertical' | 'horizontal'
scrollable?: boolean
overflowMenu?: React.ReactNode
onOverflow?: (isOverflow: boolean, items: ListItem[]) => void
items: ListItem[]
setItems: (items: ListItem[]) => void
}) {
const ref = useRef<HTMLDivElement>(null)
const [overflow, setOverflow] = useState(false)
useEffect(() => {
if (overflowMenu && ref.current) {
const outsideRect = ref.current.getBoundingClientRect()
let isOverflow = false
let overflowItems: ListItem[] = []
ref.current.querySelectorAll('button').forEach((button) => {
const innerRect = button.getBoundingClientRect()
const isInView = 0 < innerRect.top && innerRect.bottom < outsideRect.bottom
if (!isInView) {
button.style.display = 'none'
isOverflow = true
overflowItems.push(items.find((item) => item.id === button.getAttribute('data-value'))!)
}
})
setOverflow(isOverflow)
onOverflow?.(isOverflow, overflowItems)
}
}, [items])
return (
<TabsPark.List
ref={ref}
className={css(
{
display: 'flex'
},
scrollable
? orientation === 'horizontal'
? {
overflowX: 'scroll',
overflowY: 'hidden',
scrollbarWidth: 'auto !important',
'&::-webkit-scrollbar': {
height: '6px',
backgroundColor: 'transparent',
display: 'block !important'
},
'&::-webkit-scrollbar-thumb': {
height: '6px',
backgroundColor: 'transparent',
borderRadius: '6px',
_hover: {
backgroundColor: 'gray.400/30',
cursor: 'pointer'
}
}
}
: {
overflowX: 'hidden',
overflowY: 'scroll',
scrollbarWidth: 'auto !important',
'&::-webkit-scrollbar': {
width: '6px',
backgroundColor: 'transparent',
display: 'block !important'
},
'&::-webkit-scrollbar-thumb': {
width: '6px',
backgroundColor: 'transparent',
borderRadius: '6px',
_hover: {
backgroundColor: 'gray.400/30',
cursor: 'pointer'
}
}
}
: {
overflow: 'hidden'
}
)}
{...props}
>
<DndContext
id={useUniqueId('TabsExt.List')}
sensors={useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 5 } }),
useSensor(TouchSensor, {
activationConstraint: {
delay: 250,
tolerance: 5
}
}),
useSensor(KeyboardSensor)
)}
collisionDetection={closestCenter}
modifiers={
orientation === 'vertical' ? [restrictToVerticalAxis] : orientation === 'horizontal' ? [restrictToHorizontalAxis] : []
}
onDragEnd={(event) => {
const { active, over } = event
if (over == null || active.id === over.id) {
return
}
const oldIndex = items.findIndex((item) => item.id === active.id)
const newIndex = items.findIndex((item) => item.id === over.id)
const newItems = arrayMove(items, oldIndex, newIndex)
newItems[newIndex].pinned = false
setItems(newItems)
}}
>
<SortableContext items={items}>
{children}
{overflow && overflowMenu}
</SortableContext>
</DndContext>
</TabsPark.List>
)
}
List.displayName = 'Tabs.ListExt'
// ---
// Tabs.CloseButton
function CloseButton({ pinned, onClose, onUnpin }: { pinned?: boolean; onClose?: () => void; onUnpin?: () => void }) {
return (
<>
{pinned ? (
<>
<div
className={cx(
css({
display: 'flex',
justifyItems: 'center',
alignItems: 'center'
}),
'pin-button'
)}
onClick={onUnpin}
>
<VscPinned />
</div>
</>
) : (
<>
<div
className={cx(
css({
display: 'flex',
justifyItems: 'center',
alignItems: 'center'
}),
'close-button'
)}
onClick={onClose}
>
<VscClose />
</div>
</>
)}
</>
)
}
CloseButton.displayName = 'Tabs.CloseButtonExt'
// ---
// Tabs.Trigger
function Trigger({
id,
children,
...props
}: ComponentProps<typeof TabsPark.Trigger> & { items?: ListItem[]; setItems?: (items: ListItem[]) => void }) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: id! })
return (
<TabsPark.Trigger
{...props}
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(
transform
? {
...transform,
scaleX: 1,
scaleY: 1
}
: null
),
transition
}}
className={css({
'& .close-button': {
visibility: 'hidden',
_hover: {
visibility: 'visible',
bg: 'gray.400/30',
borderRadius: '4px'
}
},
'& .pin-button': {
visibility: 'visible',
_hover: {
visibility: 'visible',
bg: 'gray.400/30',
borderRadius: '4px'
}
}
})}
{...attributes}
{...listeners}
aria-describedby={`aria-describedby-${id}`}
>
{children}
</TabsPark.Trigger>
)
}
Trigger.displayName = 'Tabs.TriggerExt'
// ---
export const Tabs = {
Root: TabsPark.Root,
Content: TabsPark.Content,
Indicator: TabsPark.Indicator,
CloseButton: CloseButton,
List: List,
Trigger: Trigger,
closeTab: closeTab,
closeTabOthers: closeTabOthers,
closeTabRights: closeTabRights,
closeTabAll: closeTabAll,
pinTab: pinTab,
unpinTab: unpinTab,
getTab: getTab
}
// EOF
@kotsutsumi
Copy link
Author

kotsutsumi commented Oct 26, 2024

Usage:

/**
 * TabsExample
 *
 * @returns React.DOMElement
 */
'use client'
//
import { useState } from 'react'
import { SelectionDetails } from 'node_modules/@ark-ui/react/dist/components/menu/menu'
import { FcLinux } from 'react-icons/fc'
import { VscEllipsis } from 'react-icons/vsc'
import { css } from 'styled-system/css'
import { HStack } from 'styled-system/jsx'

import ContextMenu from '@/components/ui/ext/context-menu'
import { ListItem, Tabs } from '@/components/ui/ext/tabs'
import { Menu } from '@/components/ui/menu'

type Item = { text: string } & ListItem

export default function TabsExample() {
    const [items, setItems] = useState<Item[]>([
        { id: 'MX Linux', text: 'MX Linux' },
        { id: 'Mint', text: 'Mint' },
        { id: 'EndeavourOS', text: 'EndeavourOS' },
        { id: 'Debian', text: 'Debian' },
        { id: 'Manjaro', text: 'Manjaro' },
        { id: 'Ubuntu', text: 'Ubuntu' },
        { id: 'Pop!_OS', text: 'Pop!_OS' },
        { id: 'Fedora', text: 'Fedora' },
        { id: 'CachyOS', text: 'CachyOS' },
        { id: 'openSUSE', text: 'openSUSE' },
        { id: 'Zorin', text: 'Zorin' },
        { id: 'KDE neon', text: 'KDE neon' },
        { id: 'Nobara', text: 'Nobara' },
        { id: 'antiX', text: 'antiX' },
        { id: 'elementary', text: 'elementary' },
        { id: 'TUXEDO', text: 'TUXEDO' },
        { id: 'NixOS', text: 'NixOS' },
        { id: 'Garuda', text: 'Garuda' },
        { id: 'Vanilla', text: 'Vanilla' },
        { id: 'SparkyLinux', text: 'SparkyLinux' },
        { id: 'Kali', text: 'Kali' },
        { id: 'FreeBSD', text: 'FreeBSD' },
        { id: 'Lite', text: 'Lite' },
        { id: 'AlmaLinux', text: 'AlmaLinux' },
        { id: 'deepin', text: 'deepin' },
        { id: 'Solus', text: 'Solus' },
        { id: 'Kubuntu', text: 'Kubuntu' },
        { id: 'Alpine', text: 'Alpine' },
        { id: 'EasyOS', text: 'EasyOS' },
        { id: 'Tails', text: 'Tails' }
    ])

    const [activeTab, setActiveTab] = useState(items[0]?.id)
    const [activeTab2, setActiveTab2] = useState(items[0]?.id)
    const [showContextMenu, setShowContextMenu] = useState(false)
    const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
    const [contextMenuTargetId, setContextMenuTargetId] = useState<string>(activeTab)
    const [overflowItems, setOverflowItems] = useState<Item[]>([])

    const handleContextMenu = (e: React.MouseEvent) => {
        // Prevent the default context menu
        e.preventDefault()

        // Get targetId
        setContextMenuTargetId(e.currentTarget.getAttribute('data-value')!)

        // Set ContextMenu position
        setContextMenuPosition({ x: e.clientX, y: e.clientY })

        // Show ContextMenu
        setShowContextMenu(true)
    }

    return (
        <div>
            <h2
                className={css({
                    fontWeight: 'bold',
                    marginTop: '10px',
                    marginBottom: '10px'
                })}
            >
                Default
            </h2>
            <Tabs.Root defaultValue="MX Linux" lazyMount unmountOnExit>
                <Tabs.List items={items} setItems={(newItems: ListItem[]) => setItems(newItems as Item[])}>
                    {items.map((item) => (
                        <Tabs.Trigger key={item.id} id={item.id} value={item.id}>
                            <div
                                className={css({
                                    display: 'flex'
                                })}
                            >
                                <FcLinux
                                    className={css({
                                        marginRight: '10px',
                                        marginTop: '2px',
                                        w: '16px'
                                    })}
                                />
                                {item.text}
                            </div>
                        </Tabs.Trigger>
                    ))}
                    <Tabs.Indicator />
                </Tabs.List>
                {items.map(({ id, text }) => (
                    <Tabs.Content key={`content-${id}`} value={id}>
                        {text} content.
                    </Tabs.Content>
                ))}
            </Tabs.Root>

            {/* Closable and Pin Trigger */}
            <h2
                className={css({
                    fontWeight: 'bold',
                    marginTop: '50px',
                    marginBottom: '10px'
                })}
            >
                Closable and Pin Trigger
            </h2>
            <Tabs.Root value={activeTab} onValueChange={(e) => setActiveTab(e.value)} lazyMount unmountOnExit>
                <Tabs.List items={items} setItems={(newItems: ListItem[]) => setItems(newItems as Item[])}>
                    {items.map((item) => (
                        <Tabs.Trigger key={item.id} id={item.id} value={item.id} onContextMenu={handleContextMenu}>
                            <div
                                className={css({
                                    display: 'flex',
                                    gap: '10px',
                                    justifyItems: 'center'
                                })}
                            >
                                {/* Icon */}
                                <FcLinux
                                    className={css({
                                        marginRight: '10px',
                                        marginTop: '2px',
                                        w: '16px'
                                    })}
                                />

                                {/* Text */}
                                {item.text}

                                {/* Close Button */}
                                <Tabs.CloseButton
                                    pinned={item.pinned}
                                    onClose={() => {
                                        // get newItems and newId
                                        const [newItems, newId] = Tabs.closeTab(item.id, items)

                                        // setItems
                                        setItems(newItems as Item[])

                                        // setActiveTab
                                        setActiveTab(newId as string)
                                    }}
                                    onUnpin={() => {
                                        Tabs.unpinTab(item.id, items)
                                    }}
                                />
                            </div>
                        </Tabs.Trigger>
                    ))}

                    {/* Indicator */}
                    <Tabs.Indicator />

                    {/* ContextMenu */}
                    {showContextMenu && (
                        <ContextMenu
                            pos={contextMenuPosition}
                            onClose={() => {
                                // Hide ContextMenu
                                setShowContextMenu(false)
                            }}
                        >
                            <Menu.Root
                                open
                                onSelect={(details: SelectionDetails) => {
                                    if (details.value == 'close') {
                                        // Close
                                        const [newItems, newId] = Tabs.closeTab(contextMenuTargetId, items)

                                        // setItems
                                        setItems(newItems as Item[])

                                        // setActiveTab
                                        setActiveTab(newId as string)
                                    } else if (details.value == 'close-others') {
                                        // Close Tab Others

                                        // setItems
                                        setItems(Tabs.closeTabOthers(contextMenuTargetId, items) as Item[])

                                        // setActiveTab
                                        setActiveTab(contextMenuTargetId as string)
                                    } else if (details.value == 'close-rights') {
                                        // Close Tab Rights

                                        // setItems
                                        setItems(Tabs.closeTabRights(contextMenuTargetId, items) as Item[])
                                    } else if (details.value == 'close-all') {
                                        // Close All

                                        // setItems
                                        setItems(Tabs.closeTabAll(items) as Item[])
                                    } else if (details.value == 'pin') {
                                        if (Tabs.getTab(contextMenuTargetId, items)?.pinned == true) {
                                            // Unpin Tab
                                            setItems(Tabs.unpinTab(contextMenuTargetId, items) as Item[])
                                        } else {
                                            // Pin Tab
                                            setItems(Tabs.pinTab(contextMenuTargetId, items) as Item[])
                                        }
                                    }
                                }}
                            >
                                <Menu.Content>
                                    {/* Close */}
                                    <Menu.Item value="close">
                                        <HStack gap="6" justify="space-between" flex="1">
                                            <HStack gap="2">Close</HStack>
                                        </HStack>
                                    </Menu.Item>

                                    {/* Close Other Tabs */}
                                    <Menu.Item value="close-others">
                                        <HStack gap="6" justify="space-between" flex="1">
                                            <HStack gap="2">Close Others</HStack>
                                        </HStack>
                                    </Menu.Item>

                                    {/* Close Right Tabs */}
                                    <Menu.Item value="close-rights">
                                        <HStack gap="6" justify="space-between" flex="1">
                                            <HStack gap="2">Close Rights</HStack>
                                        </HStack>
                                    </Menu.Item>

                                    {/* Close All Tabs */}
                                    <Menu.Item value="close-all">
                                        <HStack gap="6" justify="space-between" flex="1">
                                            <HStack gap="2">Close All</HStack>
                                        </HStack>
                                    </Menu.Item>

                                    <Menu.Separator />

                                    {/* Pin Tab */}
                                    <Menu.Item value="pin">
                                        <HStack gap="6" justify="space-between" flex="1">
                                            <HStack gap="2">
                                                {Tabs.getTab(contextMenuTargetId, items)?.pinned == true ? 'Unpin' : 'Pin'}
                                            </HStack>
                                        </HStack>
                                    </Menu.Item>
                                </Menu.Content>
                            </Menu.Root>
                        </ContextMenu>
                    )}
                </Tabs.List>
                {items.map(({ id, text }) => (
                    <Tabs.Content key={`content-${id}`} value={id}>
                        {text} content.
                    </Tabs.Content>
                ))}
            </Tabs.Root>

            {/* Vertical */}
            <h2
                className={css({
                    fontWeight: 'bold',
                    marginTop: '50px',
                    marginBottom: '10px'
                })}
            >
                Vertical
            </h2>
            <Tabs.Root
                orientation="vertical"
                defaultValue="MX Linux"
                lazyMount
                unmountOnExit
                className={css({
                    height: '300px'
                })}
            >
                <Tabs.List orientation="vertical" items={items} setItems={(newItems: ListItem[]) => setItems(newItems as Item[])}>
                    {items.map((item) => (
                        <Tabs.Trigger key={item.id} id={item.id} value={item.id}>
                            <div
                                className={css({
                                    display: 'flex'
                                })}
                            >
                                <FcLinux
                                    className={css({
                                        marginRight: '10px',
                                        marginTop: '2px',
                                        w: '16px'
                                    })}
                                />
                                {item.text}
                            </div>
                        </Tabs.Trigger>
                    ))}
                    <Tabs.Indicator />
                </Tabs.List>
                {items.map(({ id, text }) => (
                    <Tabs.Content key={`content-${id}`} value={id}>
                        {text} content.
                    </Tabs.Content>
                ))}
            </Tabs.Root>

            {/* Overflow Menu */}
            <h2
                className={css({
                    fontWeight: 'bold',
                    marginTop: '50px',
                    marginBottom: '10px'
                })}
            >
                Overflow Menu
            </h2>
            <Tabs.Root
                orientation="vertical"
                value={activeTab2}
                lazyMount
                unmountOnExit
                className={css({
                    height: '300px'
                })}
            >
                <Tabs.List
                    orientation="vertical"
                    scrollable={false}
                    items={items}
                    setItems={(newItems: ListItem[]) => setItems(newItems as Item[])}
                    overflowMenu={
                        <Menu.Root
                            onSelect={(details) => {
                                setActiveTab2(details.value)

                                // TODO: 選ばれたタブを表示リストの末尾に配置する
                            }}
                        >
                            <Menu.Trigger asChild>
                                <div
                                    className={css({
                                        display: 'flex',
                                        justifyContent: 'center',
                                        alignContent: 'center',
                                        cursor: 'pointer'
                                    })}
                                >
                                    <VscEllipsis />
                                </div>
                            </Menu.Trigger>
                            <Menu.Positioner>
                                <Menu.Content>
                                    {overflowItems.map((item) => {
                                        return (
                                            <Menu.Item key={item.id} value={item.id}>
                                                <HStack gap="6" justify="space-between" flex="1">
                                                    <HStack gap="2">{item.text}</HStack>
                                                </HStack>
                                            </Menu.Item>
                                        )
                                    })}
                                </Menu.Content>
                            </Menu.Positioner>
                        </Menu.Root>
                    }
                    onOverflow={(isOverflow, items) => {
                        setOverflowItems(items as Item[])
                    }}
                >
                    {items.map((item) => (
                        <Tabs.Trigger key={item.id} id={item.id} value={item.id}>
                            <div
                                className={css({
                                    display: 'flex'
                                })}
                            >
                                <FcLinux
                                    className={css({
                                        marginRight: '10px',
                                        marginTop: '2px',
                                        w: '16px'
                                    })}
                                />
                                {item.text}
                            </div>
                        </Tabs.Trigger>
                    ))}
                    <Tabs.Indicator />
                </Tabs.List>
                {items.map(({ id, text }) => (
                    <Tabs.Content key={`content-${id}`} value={id}>
                        {text} content.
                    </Tabs.Content>
                ))}
            </Tabs.Root>
        </div>
    )
}

// EOF

@kotsutsumi
Copy link
Author

Closable and Pin Trigger using with following component(ContextMenu)
https://gist.github.com/kotsutsumi/ea326401b23620b6dd5c0cc945eb9600

@kotsutsumi
Copy link
Author

スクリーンショット 2024-10-28 18 30 58

@kotsutsumi
Copy link
Author

File Structure:
スクリーンショット 2024-10-28 18 45 54

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment