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

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