Last active
October 28, 2024 12:14
-
-
Save kotsutsumi/3677758aa6b4092b693fed887e7984cd to your computer and use it in GitHub Desktop.
Scrollable tabs with drag-and-drop reordering using ParkUI
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
'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 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment