import {
attrs,
css,
on,
type CSSMixinDescriptor,
type Handle,
type MixValue,
type Props,
type RemixNode,
} from 'remix/component'
import { Button, type ButtonProps } from './button.tsx'
import { PanelLeftIcon } from './icons.tsx'
import { Input, type InputProps } from './input.tsx'
import { Separator, type SeparatorProps } from './separator.tsx'
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './sheet.tsx'
import { Skeleton } from './skeleton.tsx'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
type TooltipContentProps,
} from './tooltip.tsx'
const SIDEBAR_COOKIE_NAME = 'sidebar_state'
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = '16rem'
const SIDEBAR_WIDTH_MOBILE = '18rem'
const SIDEBAR_WIDTH_ICON = '3rem'
const SIDEBAR_KEYBOARD_SHORTCUT = 'b'
const MOBILE_BREAKPOINT = 768
const ease = 'cubic-bezier(0.4, 0, 0.2, 1)'
const srOnly = css({
position: 'absolute',
width: '1px',
height: '1px',
padding: 0,
margin: '-1px',
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
borderWidth: 0,
})
// `ring-sidebar-ring outline-hidden focus-visible:ring-2`
const sidebarRing = {
outlineStyle: 'none',
'--tw-ring-color': 'var(--sidebar-ring)',
boxShadow: 'var(--tw-ring-shadow), var(--tw-shadow)',
'&:focus-visible': { '--tw-ring-shadow': '0 0 0 2px var(--tw-ring-color, currentcolor)' },
} as const
// `group-data-[collapsible=icon]:…`: the desktop `Sidebar` root carries `data-collapsible`.
const collapsedIcon = '[data-slot="sidebar"][data-collapsible="icon"] &'
/** The value shadcn's `useSidebar()` returns. Read it with `getSidebar(handle)`. */
export interface SidebarContext {
readonly state: 'expanded' | 'collapsed'
readonly open: boolean
setOpen(open: boolean): void
readonly openMobile: boolean
setOpenMobile(open: boolean): void
readonly isMobile: boolean
toggleSidebar(): void
}
/** Components that read a provider's state, re-rendered when it changes. */
const subscribers = new WeakMap<SidebarContext, Set<() => void>>()
/**
* shadcn's `useSidebar()`: returns the nearest `SidebarProvider`'s state and actions, and
* re-renders the calling component whenever that state changes. Call it in component setup.
*/
export function getSidebar(handle: Handle<object, unknown>): SidebarContext {
let sidebar = handle.context.get(SidebarProvider) as SidebarContext | undefined
if (!sidebar) throw new Error('getSidebar must be used within a SidebarProvider.')
let update = () => void handle.update()
let set = subscribers.get(sidebar)!
set.add(update)
handle.signal.addEventListener('abort', () => set.delete(update))
return sidebar
}
export interface SidebarProviderProps extends Props<'div'> {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}
const wrapperStyles = css({
display: 'flex',
minHeight: '100svh',
width: '100%',
'&:has([data-variant="inset"])': { backgroundColor: 'var(--sidebar)' },
})
export function SidebarProvider(handle: Handle<SidebarProviderProps, SidebarContext>) {
let listeners = new Set<() => void>()
let uncontrolledOpen = handle.props.defaultOpen ?? true
let openMobile = false
let isMobile = false
let renderedOpen: boolean | undefined
let notify = () => {
for (let listener of listeners) listener()
}
let currentOpen = () => handle.props.open ?? uncontrolledOpen
let setOpen = (open: boolean) => {
if (handle.props.open === undefined) uncontrolledOpen = open
handle.props.onOpenChange?.(open)
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
notify()
}
let setOpenMobile = (open: boolean) => {
openMobile = open
notify()
}
let sidebar: SidebarContext = {
get state() {
return currentOpen() ? 'expanded' : 'collapsed'
},
get open() {
return currentOpen()
},
setOpen,
get openMobile() {
return openMobile
},
setOpenMobile,
get isMobile() {
return isMobile
},
toggleSidebar() {
if (isMobile) setOpenMobile(!openMobile)
else setOpen(!currentOpen())
},
}
subscribers.set(sidebar, listeners)
handle.context.set(sidebar)
handle.queueTask(() => {
let query = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
let syncMobile = () => {
isMobile = query.matches
notify()
}
query.addEventListener('change', syncMobile, { signal: handle.signal })
syncMobile()
// Adds a keyboard shortcut to toggle the sidebar.
window.addEventListener(
'keydown',
(event) => {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
sidebar.toggleSidebar()
}
},
{ signal: handle.signal },
)
})
return () => {
let { defaultOpen, open, onOpenChange, style, mix, children, ...props } = handle.props
// A controlled `open` prop changed: let consumers re-render with it.
if (renderedOpen !== undefined && renderedOpen !== currentOpen()) handle.queueTask(notify)
renderedOpen = currentOpen()
return (
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={{
'--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
...(typeof style === 'object' ? style : undefined),
}}
{...props}
mix={[wrapperStyles, mix]}
>
{children}
</div>
</TooltipProvider>
)
}
}
export type SidebarSide = 'left' | 'right'
export type SidebarVariant = 'sidebar' | 'floating' | 'inset'
export type SidebarCollapsible = 'offcanvas' | 'icon' | 'none'
export interface SidebarProps extends Props<'div'> {
side?: SidebarSide
variant?: SidebarVariant
collapsible?: SidebarCollapsible
}
const staticSidebarStyles = css({
display: 'flex',
height: '100%',
width: 'var(--sidebar-width)',
flexDirection: 'column',
backgroundColor: 'var(--sidebar)',
color: 'var(--sidebar-foreground)',
})
const mobileSheetStyles = css({
width: 'var(--sidebar-width)',
backgroundColor: 'var(--sidebar)',
padding: 0,
color: 'var(--sidebar-foreground)',
})
const mobileInnerStyles = css({
display: 'flex',
height: '100%',
width: '100%',
flexDirection: 'column',
})
const rootStyles = css({
display: 'none',
color: 'var(--sidebar-foreground)',
'@media (min-width: 48rem)': { display: 'block' },
})
const gapBase = css({
position: 'relative',
width: 'var(--sidebar-width)',
backgroundColor: 'transparent',
transitionProperty: 'width',
transitionTimingFunction: 'linear',
transitionDuration: '200ms',
'[data-slot="sidebar"][data-collapsible="offcanvas"] &': { width: 0 },
'[data-slot="sidebar"][data-side="right"] &': { rotate: '180deg' },
})
const gapInset = css({
[collapsedIcon]: { width: 'calc(var(--sidebar-width-icon) + 1rem)' },
})
const gapPlain = css({
[collapsedIcon]: { width: 'var(--sidebar-width-icon)' },
})
const containerBase = css({
position: 'fixed',
top: 0,
bottom: 0,
zIndex: 10,
display: 'none',
height: '100svh',
width: 'var(--sidebar-width)',
transitionProperty: 'left, right, width',
transitionTimingFunction: 'linear',
transitionDuration: '200ms',
'@media (min-width: 48rem)': { display: 'flex' },
})
const containerSides: Record<SidebarSide, CSSMixinDescriptor> = {
left: css({
left: 0,
'[data-slot="sidebar"][data-collapsible="offcanvas"] &': {
left: 'calc(var(--sidebar-width) * -1)',
},
}),
right: css({
right: 0,
'[data-slot="sidebar"][data-collapsible="offcanvas"] &': {
right: 'calc(var(--sidebar-width) * -1)',
},
}),
}
// Adjust the padding for floating and inset variants.
const containerInset = css({
padding: '0.5rem',
[collapsedIcon]: { width: 'calc(var(--sidebar-width-icon) + 1rem + 2px)' },
})
const containerPlain = css({
[collapsedIcon]: { width: 'var(--sidebar-width-icon)' },
'[data-slot="sidebar"][data-side="left"] &': { borderRightWidth: '1px' },
'[data-slot="sidebar"][data-side="right"] &': { borderLeftWidth: '1px' },
})
const innerStyles = css({
display: 'flex',
height: '100%',
width: '100%',
flexDirection: 'column',
backgroundColor: 'var(--sidebar)',
'[data-slot="sidebar"][data-variant="floating"] &': {
borderRadius: 'var(--radius)',
borderWidth: '1px',
borderColor: 'var(--sidebar-border)',
boxShadow: '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
},
})
export function Sidebar(handle: Handle<SidebarProps>) {
let sidebar = getSidebar(handle)
return () => {
let {
side = 'left',
variant = 'sidebar',
collapsible = 'offcanvas',
mix,
children,
...props
} = handle.props
if (collapsible === 'none') {
return (
<div data-slot="sidebar" {...props} mix={[staticSidebarStyles, mix]}>
{children}
</div>
)
}
if (sidebar.isMobile) {
return (
<Sheet open={sidebar.openMobile} onOpenChange={sidebar.setOpenMobile}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
showCloseButton={false}
style={{ '--sidebar-width': SIDEBAR_WIDTH_MOBILE }}
side={side}
mix={mobileSheetStyles}
>
<SheetHeader mix={srOnly}>
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div mix={mobileInnerStyles}>{children}</div>
</SheetContent>
</Sheet>
)
}
let state = sidebar.state
let insetLike = variant === 'floating' || variant === 'inset'
return (
<div
data-state={state}
data-collapsible={state === 'collapsed' ? collapsible : ''}
data-variant={variant}
data-side={side}
data-slot="sidebar"
mix={rootStyles}
>
{/* This is what handles the sidebar gap on desktop */}
<div data-slot="sidebar-gap" mix={[gapBase, insetLike ? gapInset : gapPlain]} />
<div
data-slot="sidebar-container"
{...props}
mix={[
containerBase,
containerSides[side],
insetLike ? containerInset : containerPlain,
mix,
]}
>
<div data-sidebar="sidebar" data-slot="sidebar-inner" mix={innerStyles}>
{children}
</div>
</div>
</div>
)
}
}
const triggerStyles = css({ width: '1.75rem', height: '1.75rem' })
export function SidebarTrigger(handle: Handle<ButtonProps>) {
let sidebar = getSidebar(handle)
return () => {
let { mix, ...props } = handle.props
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
{...props}
mix={[triggerStyles, mix, on('click', () => sidebar.toggleSidebar())]}
>
<PanelLeftIcon />
<span mix={srOnly}>Toggle Sidebar</span>
</Button>
)
}
}
const railStyles = css({
position: 'absolute',
top: 0,
bottom: 0,
zIndex: 20,
display: 'none',
width: '1rem',
translate: '-50% 0',
transitionProperty: 'all',
transitionTimingFunction: 'linear',
transitionDuration: '150ms',
'&::after': {
content: '""',
position: 'absolute',
top: 0,
bottom: 0,
left: '50%',
width: '2px',
},
'&:hover::after': { backgroundColor: 'var(--sidebar-border)' },
'@media (min-width: 40rem)': { display: 'flex' },
'[data-slot="sidebar"][data-side="left"] &': { right: '-1rem' },
'[data-slot="sidebar"][data-side="right"] &': { left: 0 },
'[data-side="left"] &': { cursor: 'w-resize' },
'[data-side="right"] &': { cursor: 'e-resize' },
'[data-side="left"][data-state="collapsed"] &': { cursor: 'e-resize' },
'[data-side="right"][data-state="collapsed"] &': { cursor: 'w-resize' },
'[data-slot="sidebar"][data-collapsible="offcanvas"] &': { translate: '0 0' },
'[data-slot="sidebar"][data-collapsible="offcanvas"] &::after': { left: '100%' },
'[data-slot="sidebar"][data-collapsible="offcanvas"] &:hover': {
backgroundColor: 'var(--sidebar)',
},
'[data-side="left"][data-collapsible="offcanvas"] &': { right: '-0.5rem' },
'[data-side="right"][data-collapsible="offcanvas"] &': { left: '-0.5rem' },
})
export function SidebarRail(handle: Handle<Props<'button'>>) {
let sidebar = getSidebar(handle)
return () => {
let { mix, ...props } = handle.props
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
title="Toggle Sidebar"
{...props}
mix={[railStyles, mix, on('click', () => sidebar.toggleSidebar())]}
/>
)
}
}
const insetStyles = css({
position: 'relative',
display: 'flex',
width: '100%',
flex: '1 1 0%',
flexDirection: 'column',
backgroundColor: 'var(--background)',
'@media (min-width: 48rem)': {
'[data-slot="sidebar"][data-variant="inset"] ~ &': {
margin: '0.5rem',
marginLeft: 0,
borderRadius: 'calc(var(--radius) + 4px)',
boxShadow: '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
},
'[data-slot="sidebar"][data-variant="inset"][data-state="collapsed"] ~ &': {
marginLeft: '0.5rem',
},
},
})
export function SidebarInset(handle: Handle<Props<'main'>>) {
return () => {
let { mix, ...props } = handle.props
return <main data-slot="sidebar-inset" {...props} mix={[insetStyles, mix]} />
}
}
const sidebarInputStyles = css({
height: '2rem',
width: '100%',
backgroundColor: 'var(--background)',
'--tw-shadow': '0 0 #0000',
'.dark &': { backgroundColor: 'color-mix(in oklab, var(--input) 30%, transparent)' },
})
export function SidebarInput(handle: Handle<InputProps>) {
return () => {
let { mix, ...props } = handle.props
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
{...props}
mix={[sidebarInputStyles, mix]}
/>
)
}
}
const headerFooterStyles = css({
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
padding: '0.5rem',
})
export function SidebarHeader(handle: Handle<Props<'div'>>) {
return () => {
let { mix, ...props } = handle.props
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
{...props}
mix={[headerFooterStyles, mix]}
/>
)
}
}
export function SidebarFooter(handle: Handle<Props<'div'>>) {
return () => {
let { mix, ...props } = handle.props
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
{...props}
mix={[headerFooterStyles, mix]}
/>
)
}
}
// shadcn's `w-auto` loses to the separator's `data-[orientation=horizontal]:w-full` (higher
// specificity), so the rendered separator keeps its full width; only margin and color apply.
const sidebarSeparatorStyles = css({
marginInline: '0.5rem',
backgroundColor: 'var(--sidebar-border)',
})
export function SidebarSeparator(handle: Handle<SeparatorProps>) {
return () => {
let { mix, ...props } = handle.props
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
{...props}
mix={[sidebarSeparatorStyles, mix]}
/>
)
}
}
const contentStyles = css({
display: 'flex',
minHeight: 0,
flex: '1 1 0%',
flexDirection: 'column',
gap: '0.5rem',
overflow: 'auto',
[collapsedIcon]: { overflow: 'hidden' },
})
export function SidebarContent(handle: Handle<Props<'div'>>) {
return () => {
let { mix, ...props } = handle.props
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
{...props}
mix={[contentStyles, mix]}
/>
)
}
}
const groupStyles = css({
position: 'relative',
display: 'flex',
width: '100%',
minWidth: 0,
flexDirection: 'column',
padding: '0.5rem',
})
export function SidebarGroup(handle: Handle<Props<'div'>>) {
return () => {
let { mix, ...props } = handle.props
return (
<div data-slot="sidebar-group" data-sidebar="group" {...props} mix={[groupStyles, mix]} />
)
}
}
const groupLabelStyles = css({
display: 'flex',
height: '2rem',
flexShrink: 0,
alignItems: 'center',
borderRadius: 'calc(var(--radius) - 2px)',
paddingInline: '0.5rem',
fontSize: '0.75rem',
lineHeight: 'calc(1 / 0.75)',
fontWeight: 500,
color: 'color-mix(in oklab, var(--sidebar-foreground) 70%, transparent)',
...sidebarRing,
transitionProperty: 'margin, opacity',
transitionTimingFunction: 'linear',
transitionDuration: '200ms',
'& > svg': { width: '1rem', height: '1rem', flexShrink: 0 },
[collapsedIcon]: { marginTop: '-2rem', opacity: 0 },
})
/** shadcn's `<SidebarGroupLabel asChild>`: put this mix on your own element. */
export function sidebarGroupLabelVariants() {
return [groupLabelStyles, attrs({ 'data-sidebar': 'group-label' })]
}
export function SidebarGroupLabel(handle: Handle<Props<'div'>>) {
return () => {
let { mix, ...props } = handle.props
return (
<div
data-slot="sidebar-group-label"
data-sidebar="group-label"
{...props}
mix={[groupLabelStyles, mix]}
/>
)
}
}
// Increases the hit area of the button on mobile.
const mobileHitArea = {
'&::after': { content: '""', position: 'absolute', inset: '-0.5rem' },
'@media (min-width: 48rem)': { '&::after': { display: 'none' } },
} as const
const actionBase = {
position: 'absolute',
display: 'flex',
aspectRatio: '1 / 1',
width: '1.25rem',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 'calc(var(--radius) - 2px)',
padding: 0,
color: 'var(--sidebar-foreground)',
...sidebarRing,
transitionProperty: 'transform, translate, scale, rotate',
transitionTimingFunction: ease,
transitionDuration: '150ms',
'&:hover': {
backgroundColor: 'var(--sidebar-accent)',
color: 'var(--sidebar-accent-foreground)',
},
'& > svg': { width: '1rem', height: '1rem', flexShrink: 0 },
...mobileHitArea,
[collapsedIcon]: { display: 'none' },
} as const
const groupActionStyles = css({ ...actionBase, top: '0.875rem', right: '0.75rem' })
/** shadcn's `<SidebarGroupAction asChild>`: put this mix on your own element. */
export function sidebarGroupActionVariants() {
return [groupActionStyles, attrs({ 'data-sidebar': 'group-action' })]
}
export function SidebarGroupAction(handle: Handle<Props<'button'>>) {
return () => {
let { mix, ...props } = handle.props
return (
<button
data-slot="sidebar-group-action"
data-sidebar="group-action"
{...props}
mix={[groupActionStyles, mix]}
/>
)
}
}
const groupContentStyles = css({
width: '100%',
fontSize: '0.875rem',
lineHeight: 'calc(1.25 / 0.875)',
})
export function SidebarGroupContent(handle: Handle<Props<'div'>>) {
return () => {
let { mix, ...props } = handle.props
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
{...props}
mix={[groupContentStyles, mix]}
/>
)
}
}
const menuStyles = css({
display: 'flex',
width: '100%',
minWidth: 0,
flexDirection: 'column',
gap: '0.25rem',
})
export function SidebarMenu(handle: Handle<Props<'ul'>>) {
return () => {
let { mix, ...props } = handle.props
return <ul data-slot="sidebar-menu" data-sidebar="menu" {...props} mix={[menuStyles, mix]} />
}
}
const menuItemStyles = css({ position: 'relative' })
export function SidebarMenuItem(handle: Handle<Props<'li'>>) {
return () => {
let { mix, ...props } = handle.props
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
{...props}
mix={[menuItemStyles, mix]}
/>
)
}
}
export type SidebarMenuButtonVariant = 'default' | 'outline'
export type SidebarMenuButtonSize = 'default' | 'sm' | 'lg'
export interface SidebarMenuButtonVariantOptions {
variant?: SidebarMenuButtonVariant
size?: SidebarMenuButtonSize
isActive?: boolean
}
const accentHover = {
'&:hover': {
backgroundColor: 'var(--sidebar-accent)',
color: 'var(--sidebar-accent-foreground)',
},
}
// The collapsed (icon) geometry is `!important` in shadcn (`size-8! p-2!`) so it beats sizes,
// the menu-action padding and user styles; `size="lg"` collapses to `p-0!`.
const menuButtonBase = css({
display: 'flex',
width: '100%',
alignItems: 'center',
gap: '0.5rem',
overflow: 'hidden',
borderRadius: 'calc(var(--radius) - 2px)',
padding: '0.5rem',
textAlign: 'left',
fontSize: '0.875rem',
lineHeight: 'calc(1.25 / 0.875)',
...sidebarRing,
transitionProperty: 'width, height, padding',
transitionTimingFunction: ease,
transitionDuration: '150ms',
'[data-slot="sidebar-menu-item"]:has([data-sidebar="menu-action"]) &': {
paddingRight: '2rem',
},
...accentHover,
'&:active': {
backgroundColor: 'var(--sidebar-accent)',
color: 'var(--sidebar-accent-foreground)',
},
'&:disabled, &[aria-disabled="true"]': { pointerEvents: 'none', opacity: 0.5 },
'&[data-active="true"]': {
backgroundColor: 'var(--sidebar-accent)',
fontWeight: 500,
color: 'var(--sidebar-accent-foreground)',
},
'&[data-state="open"]:hover': {
backgroundColor: 'var(--sidebar-accent)',
color: 'var(--sidebar-accent-foreground)',
},
'& > span:last-child': { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
'& > svg': { width: '1rem', height: '1rem', flexShrink: 0 },
[collapsedIcon]: {
width: '2rem !important',
height: '2rem !important',
padding: '0.5rem !important',
},
'[data-slot="sidebar"][data-collapsible="icon"] &[data-size="lg"]': {
padding: '0 !important',
},
})
const menuButtonVariants: Record<SidebarMenuButtonVariant, CSSMixinDescriptor> = {
default: css(accentHover),
// The plain background would beat the base state rules, so they are repeated here.
outline: css({
backgroundColor: 'var(--background)',
'--tw-shadow': '0 0 0 1px var(--sidebar-border)',
'&:hover': {
backgroundColor: 'var(--sidebar-accent)',
color: 'var(--sidebar-accent-foreground)',
'--tw-shadow': '0 0 0 1px var(--sidebar-accent)',
},
'&:active, &[data-active="true"], &[data-state="open"]:hover': {
backgroundColor: 'var(--sidebar-accent)',
color: 'var(--sidebar-accent-foreground)',
},
}),
}
const menuButtonSizes: Record<SidebarMenuButtonSize, CSSMixinDescriptor> = {
default: css({ height: '2rem', fontSize: '0.875rem', lineHeight: 'calc(1.25 / 0.875)' }),
sm: css({ height: '1.75rem', fontSize: '0.75rem', lineHeight: 'calc(1 / 0.75)' }),
lg: css({ height: '3rem', fontSize: '0.875rem', lineHeight: 'calc(1.25 / 0.875)' }),
}
/**
* shadcn's `<SidebarMenuButton asChild>`: the menu button styles plus its `data-sidebar`,
* `data-size` and `data-active` attributes as a mix for your own element, e.g. a
* `DropdownMenuTrigger`.
*/
export function sidebarMenuButtonVariants({
variant = 'default',
size = 'default',
isActive = false,
}: SidebarMenuButtonVariantOptions = {}) {
return [
attrs({ 'data-sidebar': 'menu-button', 'data-size': size, 'data-active': String(isActive) }),
menuButtonBase,
menuButtonVariants[variant],
menuButtonSizes[size],
]
}
export interface SidebarMenuButtonProps
extends Omit<Props<'button'>, 'href' | 'mix'>,
SidebarMenuButtonVariantOptions {
mix?: MixValue<HTMLElement>
/** Renders an `<a>` instead of a `<button>` (shadcn's `asChild` with a link). */
href?: string
target?: string
rel?: string
/** Shown to the right of the button while the sidebar is collapsed to icons. */
tooltip?: string | TooltipContentProps
}
export function SidebarMenuButton(handle: Handle<SidebarMenuButtonProps>) {
let sidebar = getSidebar(handle)
return () => {
let {
variant = 'default',
size = 'default',
isActive = false,
tooltip,
href,
target,
rel,
mix,
...props
} = handle.props
let styles = [menuButtonBase, menuButtonVariants[variant], menuButtonSizes[size], mix]
let slots = {
'data-slot': 'sidebar-menu-button',
'data-sidebar': 'menu-button',
'data-size': size,
'data-active': String(isActive),
}
let button = tooltip ? (
<TooltipTrigger {...slots} href={href} target={target} rel={rel} {...props} mix={styles} />
) : href !== undefined ? (
<a {...slots} href={href} target={target} rel={rel} {...(props as Omit<Props<'a'>, 'role'>)} mix={styles} />
) : (
<button {...slots} {...props} mix={styles} />
)
if (!tooltip) return button
let content: TooltipContentProps = typeof tooltip === 'string' ? { children: tooltip } : tooltip
return (
<Tooltip>
{button}
<TooltipContent
side="right"
align="center"
hidden={sidebar.state !== 'collapsed' || sidebar.isMobile}
{...content}
/>
</Tooltip>
)
}
}
export interface SidebarMenuActionProps extends Props<'button'> {
showOnHover?: boolean
}
const menuActionStyles = css({
...actionBase,
top: '0.375rem',
right: '0.25rem',
'[data-sidebar="menu-button"]:hover ~ &': { color: 'var(--sidebar-accent-foreground)' },
'[data-sidebar="menu-button"][data-size="sm"] ~ &': { top: '0.25rem' },
'[data-sidebar="menu-button"][data-size="default"] ~ &': { top: '0.375rem' },
'[data-sidebar="menu-button"][data-size="lg"] ~ &': { top: '0.625rem' },
})
const menuActionShowOnHover = css({
'[data-sidebar="menu-button"][data-active="true"] ~ &': {
color: 'var(--sidebar-accent-foreground)',
},
'@media (min-width: 48rem)': { opacity: 0 },
'[data-slot="sidebar-menu-item"]:focus-within &, [data-slot="sidebar-menu-item"]:hover &, &[data-state="open"]':
{ opacity: 1 },
})
/** shadcn's `<SidebarMenuAction asChild>`: put this mix on your own element. */
export function sidebarMenuActionVariants({ showOnHover = false }: { showOnHover?: boolean } = {}) {
return [
attrs({ 'data-sidebar': 'menu-action' }),
menuActionStyles,
showOnHover ? menuActionShowOnHover : undefined,
]
}
export function SidebarMenuAction(handle: Handle<SidebarMenuActionProps>) {
return () => {
let { showOnHover = false, mix, ...props } = handle.props
return (
<button
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
{...props}
mix={[menuActionStyles, showOnHover ? menuActionShowOnHover : undefined, mix]}
/>
)
}
}
const menuBadgeStyles = css({
pointerEvents: 'none',
position: 'absolute',
right: '0.25rem',
display: 'flex',
height: '1.25rem',
minWidth: '1.25rem',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 'calc(var(--radius) - 2px)',
paddingInline: '0.25rem',
fontSize: '0.75rem',
lineHeight: 'calc(1 / 0.75)',
fontWeight: 500,
color: 'var(--sidebar-foreground)',
fontVariantNumeric: 'tabular-nums',
userSelect: 'none',
'[data-sidebar="menu-button"]:hover ~ &, [data-sidebar="menu-button"][data-active="true"] ~ &': {
color: 'var(--sidebar-accent-foreground)',
},
'[data-sidebar="menu-button"][data-size="sm"] ~ &': { top: '0.25rem' },
'[data-sidebar="menu-button"][data-size="default"] ~ &': { top: '0.375rem' },
'[data-sidebar="menu-button"][data-size="lg"] ~ &': { top: '0.625rem' },
[collapsedIcon]: { display: 'none' },
})
export function SidebarMenuBadge(handle: Handle<Props<'div'>>) {
return () => {
let { mix, ...props } = handle.props
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
{...props}
mix={[menuBadgeStyles, mix]}
/>
)
}
}
export interface SidebarMenuSkeletonProps extends Props<'div'> {
showIcon?: boolean
}
const menuSkeletonStyles = css({
display: 'flex',
height: '2rem',
alignItems: 'center',
gap: '0.5rem',
borderRadius: 'calc(var(--radius) - 2px)',
paddingInline: '0.5rem',
})
const skeletonIconStyles = css({
width: '1rem',
height: '1rem',
borderRadius: 'calc(var(--radius) - 2px)',
})
const skeletonTextStyles = css({
height: '1rem',
maxWidth: 'var(--skeleton-width)',
flex: '1 1 0%',
})
export function SidebarMenuSkeleton(handle: Handle<SidebarMenuSkeletonProps>) {
// Random width between 50 to 90%.
let width = `${Math.floor(Math.random() * 40) + 50}%`
return () => {
let { showIcon = false, mix, ...props } = handle.props
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
{...props}
mix={[menuSkeletonStyles, mix]}
>
{showIcon && <Skeleton data-sidebar="menu-skeleton-icon" mix={skeletonIconStyles} />}
<Skeleton
data-sidebar="menu-skeleton-text"
style={{ '--skeleton-width': width }}
mix={skeletonTextStyles}
/>
</div>
)
}
}
const menuSubStyles = css({
marginInline: '0.875rem',
display: 'flex',
minWidth: 0,
translate: '1px 0',
flexDirection: 'column',
gap: '0.25rem',
borderLeftWidth: '1px',
borderColor: 'var(--sidebar-border)',
paddingInline: '0.625rem',
paddingBlock: '0.125rem',
[collapsedIcon]: { display: 'none' },
})
export function SidebarMenuSub(handle: Handle<Props<'ul'>>) {
return () => {
let { mix, ...props } = handle.props
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
{...props}
mix={[menuSubStyles, mix]}
/>
)
}
}
export function SidebarMenuSubItem(handle: Handle<Props<'li'>>) {
return () => {
let { mix, ...props } = handle.props
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
{...props}
mix={[menuItemStyles, mix]}
/>
)
}
}
export type SidebarMenuSubButtonSize = 'sm' | 'md'
export type SidebarMenuSubButtonProps = Props<'a'> & {
size?: SidebarMenuSubButtonSize
isActive?: boolean
}
const menuSubButtonStyles = css({
display: 'flex',
height: '1.75rem',
minWidth: 0,
translate: '-1px 0',
alignItems: 'center',
gap: '0.5rem',
overflow: 'hidden',
borderRadius: 'calc(var(--radius) - 2px)',
paddingInline: '0.5rem',
color: 'var(--sidebar-foreground)',
...sidebarRing,
...accentHover,
'&:active': {
backgroundColor: 'var(--sidebar-accent)',
color: 'var(--sidebar-accent-foreground)',
},
'&:disabled, &[aria-disabled="true"]': { pointerEvents: 'none', opacity: 0.5 },
'& > span:last-child': { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
'& > svg': {
width: '1rem',
height: '1rem',
flexShrink: 0,
color: 'var(--sidebar-accent-foreground)',
},
'&[data-active="true"]': {
backgroundColor: 'var(--sidebar-accent)',
color: 'var(--sidebar-accent-foreground)',
},
'&[data-size="sm"]': { fontSize: '0.75rem', lineHeight: 'calc(1 / 0.75)' },
'&[data-size="md"]': { fontSize: '0.875rem', lineHeight: 'calc(1.25 / 0.875)' },
[collapsedIcon]: { display: 'none' },
})
/** shadcn's `<SidebarMenuSubButton asChild>`: put this mix on your own element. */
export function sidebarMenuSubButtonVariants({
size = 'md',
isActive = false,
}: { size?: SidebarMenuSubButtonSize; isActive?: boolean } = {}) {
return [
attrs({ 'data-sidebar': 'menu-sub-button', 'data-size': size, 'data-active': String(isActive) }),
menuSubButtonStyles,
]
}
export function SidebarMenuSubButton(handle: Handle<SidebarMenuSubButtonProps>) {
return () => {
let { size = 'md', isActive = false, mix, ...props } = handle.props
return (
<a
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={String(isActive)}
{...props}
mix={[menuSubButtonStyles, mix]}
/>
)
}
}
export type { RemixNode }