Context
shadcn/ui๋ฅผ ํ์ฉํด Sidebar ์ปดํฌ๋ํธ๋ฅผ ๊ตฌํํ๊ฒ ๋์๋ค. Sidebar ์ปดํฌ๋ํธ๋ฅผ ๋ค์ด๋ก๋ ๋ฐ์ ํ ์ดํด๋ณด๋ ๋ด์ฉ์ด ๋งค์ฐ ๋ง๊ณ ํ ๋์ ์ดํดํ๊ธฐ์ ํ๊ณ๊ฐ ์์๋ค. ๋ฐ๋ผ์, ๋ก์ปฌ์ ์๋ ์ฝ๋๋ฅผ ์ดํด๋ณด๋ฉด์ ๋ด์ฉ์ ์ ๋ฆฌ ํด๋ณด๊ธฐ ์ํด ์ด ๊ธ์ ์์ฑํ๊ฒ ๋์๋ค.
Agenda
โข
shadcn/ui์ Sidebar ์ปดํฌ๋ํธ์ ๋ถ๊ฐ ์์๋ฅผ ์ดํด๋ณด๊ณ ์ฝ๋๋ฅผ ๋ถ์ ๋ฐ ์ดํด
Contents
์๋ shadcn/ui์์ ์ ๊ณตํ๋ Sidebar ์ปดํฌ๋ํธ์ ๊ตฌ์กฐ์ด๋ค.
์๋์ ๋ช
๋ น์ด๋ฅผ ํตํด Sidebar ์ปดํฌ๋ํธ๋ฅผ ๋ค์ด๋ก๋ํ๋, ์ฌ๋ฌ ํ์ผ์ด ๋ ํฌ์งํ ๋ฆฌ์ ๋ค์ด๋ก๋ ๋์๋ค.
$ pnpm dlx shadcn@latest add sidebar
Shell
๋ณต์ฌ
๋ค์ด๋ก๋ ๋ ํ์ผ์ ์ฌ๋ฌ๊ฐ๋ก Sidebar ์ปดํฌ๋ํธ์์ ์ฌ์ฉ ๋๋ ์ปดํฌ๋ํธ์ ๋ถ์์ ์ผ๋ก ์ฌ์ฉํ ์ ์๋ ์ปดํฌ๋ํธ๋ค์ด์๋ค.
๊ณต์ ๋ฌธ์์์ ์๋ ค์ค ๋ฐฉ๋ฒ๋๋ก ์๋์ ํ์ผ์ ๊ตฌ์ฑํ๋ค.
โข
app-sidebar.tsx
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarHeader,
} from "@/components/ui/sidebar"
const AppSidebar = () => {
return (
<Sidebar>
<SidebarHeader />
<SidebarContent>
<SidebarGroup />
<SidebarGroup />
</SidebarContent>
<SidebarFooter />
</Sidebar>
);
};
export default AppSidebar;
JavaScript
๋ณต์ฌ
โข
layout.tsx
import { Outlet } from 'react-router-dom';
import AppSidebar from '@/components/app-sidebar';
import { SidebarProvider, SidebarTrigger } from '@/ui/components/sidebar';
const DevLayout = () => {
return (
<SidebarProvider>
<AppSidebar />
<SidebarTrigger />
<Outlet />
</SidebarProvider>
);
};
export default DevLayout;
Shell
๋ณต์ฌ
layout.tsx๋ router๋ฅผ ํตํด ํ์ ํ์ด์ง๋ฅผ ํํํ ์์ ์ด๋ผ <Outlet />์ ์ฌ์ฉํ๋ค.
์ปจํ
์ธ ๋ฅผ ํ์ํ๋ Layout์ SidebarProvider๋ก๋ถํฐ Outlet๊น์ง์ depth๋ฅผ ์ด๋ฃจ๊ณ ์๋ค. Sidebar ์ปดํฌ๋ํธ ํ์ผ์ ์ฝ 700์ค์ ๋ฌํ๋ ๋งค์ฐ ๊ธด ํ์ผ์ธ๋ฐ, SidebarProvider๊ฐ ์ฌ์ด๋๋ฐ์ ์ํ, ๋ชจ๋ฐ์ผ ๋ถ๊ธฐ, ํ ๊ธ ๋ฑ์ ๋ชจ๋ ๊ฒฐ์ ํ๋ ์ปจํธ๋กค ํ์ ์ญํ ์ ํ๊ณ ์๋ ๊ฒ ๊ฐ์ SidebarProvider๋ถํฐ ์ดํด๋ณด๊ธฐ๋ก ํ๋ค.
SidebarProvider
SidebarProvider์ ์คํ๋ฌธ ์ฒซ ์ค ๋ถํฐ ์๋์ ๋ถ๋ถ์ด ๋์ ๋๋ค.
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
JavaScript
๋ณต์ฌ
๊ฐ์ธ์ ์ผ๋ก ์น ํ๋ก์ ํธ๋ฅผ ์งํํ๋ฉด์ ๊ฐ์ฅ ๊น๋ค๋กญ๊ณ ์ด๋ ค์ด ๋ถ๋ถ์ด Mobile ๋ถ๊ธฐ ๋ฐ ๋์์ด๋ผ๊ณ ์๊ฐํ๋๋ฐ, shadcn์ useIsMobile ํ
์ ํตํด ๋ชจ๋ฐ์ผ ๋ถ๊ธฐ๋ฅผ ์ฒ๋ฆฌํ๋ค.
import { useEffect, useState } from 'react';
const MOBILE_BREAKPOINT = 768;
export const useIsMobile = () => {
const [isMobile, setIsMobile] = useState<boolean | undefined>(undefined);
useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener('change', onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener('change', onChange);
}, []);
return !!isMobile;
};
JavaScript
๋ณต์ฌ
๋ด์ฉ์ ์์ ๊ฐ๋ค. ๊ธฐ์กด ์ฝ๋๋ export function useIsMobile()๋ก ์ ์ธ๋์ด์์ง๋ง, arrow function์ ์ฌ์ฉํ๋ ๋ฐฉํฅ์ด ๋ง์์ ๋ค์ด ์์ ํ์๋ค. ์์ผ๋ก์ ์ฝ๋ ๋ํ arrow function์ ์ฌ์ฉํ๋ค.
const MOBILE_BREAKPOINT = 768;
JavaScript
๋ณต์ฌ
์ด ํ
์ 768์ด๋ผ๋ ๊ฐ์ ํตํด ๋ชจ๋ฐ์ผ ๋ถ๊ธฐ์ ์ ์ก๋๋ค. ๋๋น 768 ๋ฏธ๋ง์ ๋ชจ๋ฐ์ผ, ๋๋น 768 ์ด์์ ๋ฐ์คํฌํ์ผ๋ก ๋ชจ๋ฐ์ผํ๋ฉด์ธ์ง ์๋์ง๋ฅผ ๊ตฌ๋ถํ๋ค.
const [isMobile, setIsMobile] = useState<boolean | undefined>(undefined);
JavaScript
๋ณต์ฌ
isMobile์ ์ํ ๊ฐ์ ์ ์ฅํ๋ ๋ณ์๋ก, ์ด๊ธฐ ๊ฐ์ undefined๋ก ์ง์ ๋์ด์๋ค. ํ๋ฉด์ด ๋ ๋๋ง ๋๊ธฐ ์ ์๋ ๋ชจ๋ฐ์ผํ๋ฉด์ธ์ง ์๋์ง ํ๋จํ ์ ์๊ธฐ ๋๋ฌธ์ undefined๋ก ์ง์ ๋์ด์๋ค.
useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener('change', onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener('change', onChange);
}, []);
JavaScript
๋ณต์ฌ
useEffectํ
์ผ๋ก ํ๋ฉด์ ๋ ๋๋ง์ด ์๋ฃ๋ ํ ์คํ๋๋ค.
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
JavaScript
๋ณต์ฌ
window ์ ์ญ ๊ฐ์ฒด์ matchMedia API๋ฅผ ์ฌ์ฉํ์ฌ ํ์ฌ ํ๋ฉด์ ๋๋น๊ฐ ์ต๋ 767px ์ดํ์ ์กฐ๊ฑด์ ๋ง์กฑํ๋์ง ์ํ๋์ง ํ๋จํ๋ค. mql์ Media Query List์ ์ฝ์๋ก 768 ๋ฏธ๋ง์ ์กฐ๊ฑด์ ๊ฐ์ง matchMedia ๊ฐ์ฒด๋ฅผ ๊ฐ๋๋ค.
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener('change', onChange);
JavaScript
๋ณต์ฌ
onChange ํจ์๋ isMobile ์ํ ๊ฐ์ ํ์ฌ ๋๋น๊ฐ ๋ถ๊ธฐ์ ๋ณด๋ค ์์ผ๋ฉด true ๊ฐ์ผ๋ก, ๊ฐ๊ฑฐ๋ ํฌ๋ฉด false ๊ฐ์ผ๋ก ์ง์ ํ๋ค. ์ด onChange ํจ์๋ mql ๊ฐ์ฒด์ change ์ด๋ฒคํธ์ ๋ฑ๋ก ๋๋ค.
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
JavaScript
๋ณต์ฌ
์ด ์คํ๋ฌธ์ ํ๋ฉด์ด ์ฒ์ ๋ ๋๋ง์ด ์๋ฃ๋ ํ ์คํ๋๋ ๊ตฌ๋ฌธ์ด๋ค. ์ฒ์ isMobile ๊ฐ์ undefined ๊ฐ์ผ๋ก ๋ชจ๋ฐ์ผ์ธ์ง ์๋์ง ํ๋จํ ์ ์๋ค. ๋ฐ๋ผ์ ์ต์ด ํ๋ฉด ๋ ๋๋ง ํ ํ์ฌ ํ๋ฉด ๋๋น์ ๋ถ๊ธฐ๊ฐ์ ํฌ๊ธฐ๋ฅผ ๋น๊ตํ์ฌ ๊ฐ์ ํ ๋นํ๋ค.
return () => mql.removeEventListener('change', onChange);
JavaScript
๋ณต์ฌ
cleanup ํจ์๋ก ์ปดํฌ๋ํธ๊ฐ ์ธ๋ง์ดํธ ๋๊ฑฐ๋ useEffect๊ฐ ๋ค์ ์คํ๋๊ธฐ ์ง์ ์ ์คํ ๋๋ค. ๋ฉ๋ชจ๋ฆฌ ๋์์ ์ค๋ณต ์คํ์ ๋ง๊ธฐ ์ํด ์์ฑ ๋์๋ค.
return !!isMobile;
JavaScript
๋ณต์ฌ
์ค์ํ ๋ถ๋ถ์ผ๋ก ํ์ฌ Mobile ํ๋ฉด์ธ์ง ์๋์ง ํ๋จํ๋ ์ํ ๊ฐ์ ๋ฐํํ๋ค.
๋ค์ Sidebar ์ปดํฌ๋ํธ์ SidebarProvider๋ก ๋์์์,
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === 'function' ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open],
);
JavaScript
๋ณต์ฌ
์์ ์ฝ๋๋ Sidebar์ ์ด๋ฆผ/๋ซํ ์ํ๋ฅผ ๊ด๋ฆฌํ๋ ๋ถ๋ถ์ด๋ค. SidebarProvider๋ ์ธ๋ถ์์ Sidebar์ ์ด๋ฆผ ๋ซํ์ ์ต์ด ์ํ๋ฅผ ๊ฒฐ์ ์ง์ ์ ์๊ณ ์ปจํธ๋กค ๋ํ ๊ฐ๋ฅํ๊ฒ๋ ์ค๊ณ ๋์ด์๋ค.
โข
defaultOpen์ SidebarProvider์ ํ๋ผ๋ฏธํฐ๋ก ๊ธฐ๋ณธ ์ด๋ฆผ/๋ซํ ์ํ๋ฅผ ์ง์ ํ ์ ์๋ค.
โฆ
true๋ฉด ์ด๋ฆผ, false๋ฉด ๋ซํ ์ํ์ด๋ค.
โข
openProp์ SidebarProvider์ ํ๋ผ๋ฏธํฐ๋ก Sidebar์ ์ด๋ฆผ/๋ซํ ์ํ๋ฅผ ์ง์ ํ๋ ๊ฐ์ด๋ค. openProp์ open์ aslias์ธ๋ฐ, defaultOpen ๊ฐ์ด ์ค์ ๋์ด์์ด๋ openProp ๊ฐ์ด ํ๋ผ๋ฏธํฐ๋ก ์ฃผ์ด์ง๋ฉด openProp ๊ฐ์ ๋ฐ๋ผ๊ฐ๋ค.
โข
setOpen์ Sidebar์ ๋ด๋ถ ์ด๋ฆผ/๋ซํ ์ํ๋ฅผ ๊ด๋ฆฌํ๋ ํจ์์ด๋ค. useCallback ํ๋ผ๋ฏธํฐ๋ก boolean | (value: boolean) => boolean ๋ ํ์
์ ํ๋ผ๋ฏธํฐ๋ฅผ ํ์ฉํ๋๋ฐ ์ด๋ setOpen(true), setOpen(false), setOpen(prev => !prev) ์ ๊ฐ์ ํํ ๋ชจ๋ ์ง์ํ๊ธฐ ์ํจ์ธ ๊ฒ์ผ๋ก ์ถ์ธกํ๋ค. ์ธ๋ถ์์ ํ๋ผ๋ฏธํฐ๋ก ์ง์ ํ setOpenProp ํจ์๊ฐ ์์ผ๋ฉด ๊ทธ ํจ์๋ฅผ ์ฌ์ฉ, ์์ผ๋ฉด ๋ด๋ถ์ ์๋ setter๋ฅผ ์ฌ์ฉํ๋ค.
โข
์ ๊ธฐํ ๋ถ๋ถ์ Cookie๋ฅผ ์ฌ์ฉํ์ฌ ์ด๋ฆผ/๋ซํ ์ํ๋ฅผ ์ ์ฅํ๋ ๊ฒ์ธ๋ฐ, ์๋ง SSR์ ์ํด ์์ฑํด๋ ๊ฒ ์๋๊น ์๊ฐํ๋ค. Next.js์ ๊ฐ์ ํ๋ ์์ํฌ๋ฅผ ์ฌ์ฉํ์ฌ SSR์ ์ฌ์ฉํ์ง ์์๊ฑฐ๋ผ๋ฉด local-storage์ ์ ์ฅํด๋ ์๊ด ์์ ๊ฒ์ผ๋ก ์์ํ๋ค.
์๋๋ ๋ณ๊ฒฝํ ์ฝ๋.
...
const SIDEBAR_STORAGE_NAME = 'sidebar_state';
...
// Internal state of the sidebar.
// Use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = useState(defaultOpen);
const open = openProp ?? _open;
// Enable setOpen(true/false) and setOpen(prev => !prev) patterns.
const setOpen = useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === 'function' ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// Save sidebar state in localStorage for persistence.
localStorage.setItem(SIDEBAR_STORAGE_NAME, String(openState));
},
[setOpenProp, open],
);
JavaScript
๋ณต์ฌ
์๋์ ์ฝ๋๋ Sidebar์ ์ด๋ฆผ/๋ซํ ์ํ๋ฅผ ์กฐ์ํ๋ ์ฝ๋์ด๋ค.
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [toggleSidebar]);
JavaScript
๋ณต์ฌ
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
JavaScript
๋ณต์ฌ
toggleSidebar ํจ์๋ Mobile ํ๊ฒฝ์ด๋ฉด setOpenMobile์ ์ฌ์ฉํ์ฌ, ๋ฐ์คํฌํ ํ๊ฒฝ์ด๋ฉด setOpen์ ์ฌ์ฉํ์ฌ Sidebar์ ์ด๋ฆผ/๋ซํ ์ํ๋ฅผ ์ง์ ํ๋ค. useCallback ํจ์๋ก, ํจ์๋ฅผ ๋งค๋ฒ ์๋ก ์์ฑํ์ง ์๊ณ ์์กด์ฑ ๋ฐฐ์ด ์์ ์๋ ํญ๋ชฉ๋ค์ด ๋ณ๊ฒฝ ๋์์ ๊ฒฝ์ฐ์ ๋ค์ ํจ์๋ฅผ ๊ทธ๋ ค๋ธ๋ค.
...
const SIDEBAR_KEYBOARD_SHORTCUT = 'b';
...
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [toggleSidebar]);
JavaScript
๋ณต์ฌ
์ด hook์ ๋จ์ถํค๋ฅผ ์ฌ์ฉํ์ฌ ์ฌ์ด๋๋ฐ๋ฅผ ์ด๊ณ ๋ซ์ ์ ์๊ฒ ์ค์ ํ๋ ๋ถ๋ถ์ด๋ค. ์ปจํธ๋กค + SIDEBAR_KEYBOARD_SHORTCUT์ ๋๋ฅด๋ฉด Sidebar์ ์ด๋ฆผ/๋ซํ ์ํ๊ฐ ์ ํ๋๋ค.
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? 'expanded' : 'collapsed';
JavaScript
๋ณต์ฌ
boolean ํ์
์ธ open ๊ฐ์ ๋ฐ๋ผ์ state๊ฐ expanded ๋๋ collapsed๋ก ์ค์ ๋๋ค. HTML Element๋ฅผ ์ดํด๋ณด๋ฉด Sidebar์ data-state="expandedโ ํ์์ผ๋ก ์์ฑ์ด ์ค์ ๋์ด์๋ ๊ฒ์ ๋ณผ ์ ์๋ค. ์ด๋ฅผ ํตํด Tailwind์์ ์๋์ ๊ฐ์ด ์ฝ๊ฒ ์คํ์ผ๋ง์ ์งํํ ์ ์๋ค.
className="data-[state=collapsed]:w-14 data-[state=expanded]:w-64"
JavaScript
๋ณต์ฌ
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
);
JavaScript
๋ณต์ฌ
Context์ ๋๊ฒจ์ค ๊ณต์ฉ ์ํ ๋ฌถ์์ ๋ง๋๋ ์ฝ๋์ด๋ค. ์ด๋ฅผ ํตํด Context๋ฅผ ์ฐ๋ ์ปดํฌ๋ํธ๋ ์๋์ ๊ฐ์ด Provider์์ ๋๊ฒจ์ฃผ๋ ๊ฐ์ ์ฌ์ฉํ ์ ์๋ค.
const {
state,
open,
setOpen,
isMobile,
openMobile,
toggleSidebar,
} = useSidebar();
JavaScript
๋ณต์ฌ
์๋๋ ํ์ฌ๊น์ง ๋ณํ ๋ ์ฝ๋.
// Toggle function for sidebar.
// If mobile, toggle mobile sidebar state.
// If not mobile, toggle desktop sidebar state.
const toggleSidebar = useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpenMobile, setOpen]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
toggleSidebar();
}
};
// Add event listener for keyboard shortcut.
window.addEventListener('keydown', handleKeyDown);
// Cleanup event listener on unmount or when toggleSidebar changes.
return () => window.removeEventListener('keydown', handleKeyDown);
}, [toggleSidebar]);
// Add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? 'expanded' : 'collapsed';
const contextValue = useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
);
JavaScript
๋ณต์ฌ
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot='sidebar-wrapper'
style={
{
'--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full',
className,
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
JavaScript
๋ณต์ฌ
Sidebar ์ ์ฒด ๋ ์ด์์๊ณผ ์ ์ญ ํดํ ์ค์ , ์ปจํ
์คํธ ๊ณต๊ธ์ ํ ๋ฒ์ ๊ฐ์ธ๋ ๊ตฌ์กฐ์ด๋ค. SidebarProvider์ ๋ง์ง๋ง ๋ถ๋ถ์ด๋ค. SidebarContext.Provider๋ Sidebar ๊ด๋ จ ์ํ/ํจ์๋ฅผ ํ์ ์ปดํฌ๋ํธ๋ค์ด ์ฌ์ฉํ ์ ์๊ฒ ํ๋ค. ์ด Provider ์์ ์๋ ๋ชจ๋ children์ Sidebar์ ์ํ๋ฅผ ๊ณต์ ํ๊ฒ ๋๋ค.
TooltipProvider๋ Sidebar์ ๋ง์ฐฌ๊ฐ์ง๋ก Tooltip์ ๋์์ ์ฌ์ฉํ ์ ์๊ฒ ํ๋ค. ๊ด๋ จ ๋ฌธ์ ์ฐธ๊ณ .
<div
data-slot='sidebar-wrapper'
style={
{
'--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full',
className,
)}
{...props}
>
{children}
</div>
JavaScript
๋ณต์ฌ
์ ์ฝ๋๋ ์ฌ์ด๋๋ฐ๋ฅผ ํฌํจํ ๋ ์ด์์์ ๊ฐ์ธ๋ wrapper์ด๋ค. data-slot์ ์์์ ๋งํด์๋๊ฒ์ฒ๋ผ ์ปดํฌ๋ํธ ์๋ณ์ฉ์ด๋ค.
style={
{
'--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
JavaScript
๋ณต์ฌ
๊ฐ์ธ์ ์ผ๋ก ์ ๊ฒฝ์ฐ์๋ ๋ถ๋ถ์ ์ด ์ฝ๋์ด๋ค. ์ปจ๋ฒค์
์์ ์ธ๋ผ์ธ์คํ์ผ์ ์ฐ์ง ์๊ธฐ๋ก ํ๋๋ฐ, style ์์ฑ์ด ๋ฑ์ฅํ์ฌ --sidebar-width์ --sidebar-width-icon์ ํ ๋นํ๋ค. ๋ฐ๋ผ์ data-* ์๋ณ์๋ฅผ ํ์ฉํด mobile์ธ์ง desktopย ํ๋ฉด์ธ์ง ๊ตฌ๋ถํ์ฌ css ํ์ผ์์ ํด๋น ๊ฐ์ ๋ณ๊ฒฝํ๋ ๋ฐฉ์์ผ๋ก ์์ ํ์๋ค.
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot='sidebar-wrapper'
data-screen-type={isMobile ? 'mobile' : 'desktop'}
className={cn(
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full ',
className,
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
JavaScript
๋ณต์ฌ
/* desktop */
[data-slot="sidebar-wrapper"][data-screen-type="desktop"] {
--sidebar-width: 16rem;
--sidebar-width-icon: 3rem;
}
/* mobile */
[data-slot="sidebar-wrapper"][data-screen-type="mobile"] {
--sidebar-width: 18rem;
--sidebar-width-icon: 3rem;
}
CSS
๋ณต์ฌ
Sidebar
ํ๋ฉด์ ๋ณด์ฌ์ง๋ Sidebar ์ปดํฌ๋ํธ์ด๋ค.
function Sidebar({
side = 'left',
variant = 'sidebar',
collapsible = 'offcanvas',
className,
children,
...props
}: React.ComponentProps<'div'> & {
side?: 'left' | 'right';
variant?: 'sidebar' | 'floating' | 'inset';
collapsible?: 'offcanvas' | 'icon' | 'none';
}) {
...
}
JavaScript
๋ณต์ฌ
์์ ์ฝ๋๋ Sidebar ์ปดํฌ๋ํธ๊ฐ ๋ฐ์ ์ ์๋ ํ๋ผ๋ฏธํฐ์ ๊ทธ ํ์
์ด๋ค. side ํ๋ผ๋ฏธํฐ๋ Sidebar๊ฐ ์์นํ ๊ณณ์ ์ง์ ํ๋ฉฐ, left ๋๋ right๋ก ์ค์ ํ ์ ์๋ค. variant๋ Sidebar๊ฐ ๋ณด์ด๋ ํํ์ธ๋ฐ sidebar๋ ๋ฐฐ๊ฒฝ์์ด ์์ผ๋ฉด์ ์์ ํ ์์ญ ์, ์๋, ์๋ฉด์ด ๋ถ์ด์๋ค. floating์ผ๋ก ์ค์ ํ๋ฉด padding์ด ์๋ ๊ฒ์ฒ๋ผ ๋ํ๋๋ฉฐ inset์ ๋ฐฐ๊ฒฝ์์ด ์์ด์ง๊ณ ๊ฒฝ๊ณ ๋ํ ์ฌ๋ผ์ ธ๋ณด์ธ๋ค. collapsible ํ๋ผ๋ฏธํฐ๋ offcanvas, icon, none ์ธ ๊ฐ์ง ๊ฐ์ ๋ฐ์ ์ ์๋ค. offcanvas๋ ์ฌ์ด๋๋ฐ๋ฅผ ์ ๊ณ ํผ์น ์ ์์ผ๋ฉฐ ์ ์์ ๋ ์์ ํ ์ ํ์ ์ฌ์ด๋๋ฐ๊ฐ ๋ณด์ด์ง ์๋๋ค. icon์ผ๋ก ์ค์ ์ ์ ๊ณ ํผ์น ์ ์๊ณ ์ ์์ ๋ ์์ด์ฝ ์์ญ์ด ๋ณด์ธ๋ค. none์ผ๋ก ์ค์ ์ ์ ์ ์ ์๋ค.
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === 'none') {
return (
<div
data-slot='sidebar'
className={cn(
'bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col',
className,
)}
{...props}
>
{children}
</div>
);
}
JavaScript
๋ณต์ฌ
๋จผ์ , useSidebar ํ
์ ํตํด SidebarProvider์์ ์ ๊ณตํ๋ ๊ฐ์ ์ฌ์ฉํ ์ ์๋ค. ๊ทธ๋ฆฌ๊ณ sidebar์ collapsible ํ๋ผ๋ฏธํฐ๊ฐ none์ผ ๋, sidebar๋ ์ ์ ์ ์๋ค. ๋ฐ๋ผ์ ๋ถ๊ธฐ๋ฌธ์ ํตํด ๊ณ ์ ๋๋น์ ๊ณต๊ฐ์ ๋ง๋ค์ด ์ปจํ
์ธ ๋ฅผ ํ์ํ๋ค.
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar='sidebar'
data-slot='sidebar'
data-mobile='true'
className='bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden'
style={
{
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className='sr-only'>
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className='flex h-full w-full flex-col'>{children}</div>
</SheetContent>
</Sheet>
);
}
JavaScript
๋ณต์ฌ
Mobile์ผ ํ๋ฉด์ผ ๋ ๋ ๋๋ง ๋๋ ์ฌ์ด๋๋ฐ์ด๋ค. radix-ui์ wrapper ์ฑ๊ฒฉ์ ์ปดํฌ๋ํธ๋ฅผ ์ฌ์ฉํ์ฌ Mobile ์ฌ์ด๋๋ฐ๋ฅผ ๋ถ๊ธฐ๋ก ์ฒ๋ฆฌํด ํ๋ฉด์ ๋ ๋๋ง ํ๋ค. ํ๊ทธ ์์ style ํ๊ทธ๋ฅผ ์ฌ์ฉํ๋ ๊ฒ์ด ์ซ์ด์ ์๋์ ๊ฐ์ด ์์ ํ์๋ค.
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar='sidebar'
data-slot='sidebar'
data-mobile='true'
className='bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden'
side={side}
>
<SheetHeader className='sr-only'>
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className='flex h-full w-full flex-col'>{children}</div>
</SheetContent>
</Sheet>
);
}
JavaScript
๋ณต์ฌ
CSS ํ์ผ์ mobile ๋์์ ํ๋ ๋ ์ถ๊ฐํ๋ค. [data-slot="sidebar"][data-mobile="true"] ์ด ๋ถ๋ถ์ธ๋ฐ, Mobile์ผ ๋ Sheet์ Portal์ ์ฌ์ฉํ์ฌ ๊ตฌ์กฐ์ ์ผ๋ก ์ธ๋ถ์ ๋ ๋๋ง ๋๊ธฐ ๋๋ฌธ์ --sidebar-width๊ฐ ์ ์ฉ ๋์ง ์์์์ด๋ค.
/* desktop */
[data-slot="sidebar-wrapper"][data-screen-type="desktop"] {
--sidebar-width: 16rem;
--sidebar-width-icon: 3rem;
}
/* mobile */
[data-slot="sidebar-wrapper"][data-screen-type="mobile"],
[data-slot="sidebar"][data-mobile="true"] {
--sidebar-width: 18rem;
--sidebar-width-icon: 3rem;
}
CSS
๋ณต์ฌ
<div
className='group peer text-sidebar-foreground hidden md:block'
data-state={state}
data-collapsible={state === 'collapsed' ? collapsible : ''}
data-variant={variant}
data-side={side}
data-slot='sidebar'
>
...
</div>
JavaScript
๋ณต์ฌ
Desktop ํ๊ฒฝ์์ ๋ ๋๋ง ๋๋ ์ฌ์ด๋๋ฐ ์ปดํฌ๋ํธ์ ์์ ๋ถ๋ถ์ด๋ค. className์ ํตํด ์ฌ์ด๋๋ฐ์ ์คํ์ผ์ ์ง์ ํ๊ณ ์๋ค. group์ ์์ ์์๊ฐ group-* ๋ฐฉ์์ผ๋ก ๋ถ๋ชจ์ data-* ๊ฐ์ ์ฝ์ด ์คํ์ผ๋ง์ ํ ์ ์๊ฒ ํ๋ค. peer๋ group๊ณผ ๋น์ทํ๊ฒ ํ์ ์ปดํฌ๋ํธ๊ฐ peer-* ๋ฐฉ์์ผ๋ก ์ด div์ data-* ๊ฐ์ ์ฝ์ด ์คํ์ผ๋ง์ ํ ์ ์๊ฒ ํ๋ค. hidden md:block์ ๋ชจ๋ฐ์ผ์์๋ ์จ๊ธฐ๊ณ md ์ฌ์ด์ฆ ์ด์์ผ ๋์ ํ๋ฉด์ ํ์๋๊ฒ ํ๋ค.
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot='sidebar-gap'
className={cn(
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
)}
/>
JavaScript
๋ณต์ฌ
์ ์ฝ๋๋ Sidebar๊ฐ ์ฐจ์งํ๋ ๊ณต๊ฐ์ ์ง์ ํ๋ ์ฝ๋์ด๋ค. Sidebar๋ Gap ๋ ์ด์ด์ ์ค์ ์ปจํ
์ธ ๋ฅผ ๋ด๋ ์ปจํ
์ด๋ ๋ ์ด์ด๋ฅผ ๋ถ๋ฆฌํด์ ์ฌ์ฉํ๋ค. ๊ณผ๊ฑฐ ์ฌ์ด๋๋ฐ๋ฅผ ๊ตฌํํ์ ๋ sidebar wrapper์ ์ง์ width๋ฅผ ์ฃผ์ด sidebar๊ฐ ํผ์ณ์ง๊ณ ์ ํ ๋์ ์ ๋๋ฉ์ด์
์ ์ฒ๋ฆฌ ํ์๋๋ฐ, shadcn/ui๋ Gap ๋ ์ด์ด๋ฅผ ๋ณ๋๋ก ๋์ด ์ปจํ
์ธ ์์ญ์ ํ๋ณด ํ๋ค๋ ์ ์ด ์ ๊ธฐํ๋ค. ๋ค์์ ์๋ ฅ์ผ๋ก ๊ตฌํ ํ ๋ ์จ๋จน์ด๋ณด๊ณ ์ถ์ ์คํฌ์ด๋ค.
transition-[width]๋ width ๊ฐ์ด ๋ณ๊ฒฝ๋ ๋ ๊ทธ ๋ณํ๋ฅผ ์ ๋๋ฉ์ด์
์ผ๋ก ์ฒ๋ฆฌํ๋ผ๋ ๊ฐ์ด๋ค. group-data-[collapsible=offcanvas]:w-0์ ๋ถ๋ชจ ์์์ data-collapsible ๊ฐ์ด offcanvas๋ฉด width๋ฅผ 0์ผ๋ก ์ค์ ํ๋ค. group-data-[side=right]:rotate-180์ ๋ถ๋ชจ ์์์ data-side ๊ฐ์ด right์ผ ๋ rotate-180์ ์ ์ฉํ๋ค. variant ๊ฐ์ด floating์ด๊ฑฐ๋ inset์ผ ๋ data-collapsible์ด icon์ผ ๊ฒฝ์ฐ ์ฃผ์ด์ง ๋๋น์์ spacing์ ์ถ๊ฐํ๋ค. ๋ง์ฝ์ variant ๊ฐ์ด ์ด ์ธ์ ๊ฐ์ผ ๊ฒฝ์ฐ spacing์ ์ถ๊ฐํ์ง ์๋๋ค.
{/* Sidebar content */}
<div
data-slot='sidebar-container'
className={cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
// Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset'
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
className,
)}
{...props}
>
...
</div>
JavaScript
๋ณต์ฌ
Sidebar ๊ณต๊ฐ์ ํ๋ณดํ๊ธฐ ์ํด Gap ๋ ์ด์ด๊ฐ ์์๋ค๋ฉด ์์ ์ฝ๋๋ ์ค์ ์ฌ์ด๋๋ฐ๊ฐ ๋ ๋๋ง ๋๋ ์ปจํ
์ด๋ ๋ถ๋ถ์ด๋ค. ์ฌ์ด๋๋ฐ๊ฐ ๋ ๋๋ง ๋๋ ์์น์ variant์ ๋ฐ๋ผ ์กฐ๊ฑด๋ถ ์คํ์ผ๋ง์ด ์ ์ฉ๋์ด ์๋ค. ํน์ดํ ๋ถ๋ถ์ variant์ ์กฐ๊ฑด์ด ๋ง์กฑํ ๋ +2px๋ฅผ ํ๋ ๋ถ๋ถ์ธ๋ฐ, ์๋ง ์๋์ sidebar-inner์์ border ๋๋ฌธ์ 1px * 2 ํด์ 2px๋งํผ์ ๋๋น๋ฅผ ๋ ๋ถ์ฌํ๋ ๊ฒ ๊ฐ๋ค.
<div
data-sidebar='sidebar'
data-slot='sidebar-inner'
className={cn(
'bg-sidebar flex h-full w-full flex-col',
'group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm',
)}
>
{children}
</div>
JavaScript
๋ณต์ฌ
Sidebar ์ปจํ
์ธ ๋ฅผ ๋ ๋๋งํ๋ ์ฝ๋์ด๋ค. ๋ง์ฐฌ๊ฐ์ง๋ก variant์ ์กฐ๊ฑด์ด ๋ง์กฑํ ๊ฒฝ์ฐ ์กฐ๊ฑด๋ถ ์คํ์ผ๋ง์ ์ ์ฉํ๋ค.
SidebarTrigger
const SidebarTrigger = ({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) => {
const { toggleSidebar } = useSidebar();
return (
<Button
data-sidebar='trigger'
data-slot='sidebar-trigger'
variant='ghost'
size='icon'
className={cn('size-7', className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeftIcon />
<span className='sr-only'>Toggle Sidebar</span>
</Button>
);
};
TypeScript
๋ณต์ฌ
Sidebar๋ฅผ ์ด๊ณ ๋ซ์ ์ ์๋ Trigger ์ปดํฌ๋ํธ์ด๋ค. shadcn/ui์ Button์ ๊ฐ์ ธ์์ ํ์ฉํ๋ค.
SidebarRail
const SidebarRail = ({
className,
...props
}: React.ComponentProps<'button'>) => {
const { toggleSidebar } = useSidebar();
return (
<button
data-sidebar='rail'
data-slot='sidebar-rail'
aria-label='Toggle Sidebar'
tabIndex={-1}
onClick={toggleSidebar}
title='Toggle Sidebar'
className={cn(
'absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear hover:after:bg-sidebar-border',
'group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex',
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full',
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
className,
)}
{...props}
/>
);
};
TypeScript
๋ณต์ฌ
SliderRail์ Trigger์ฒ๋ผ ์๋ํ๋ Slider border์ ๋ถ์ด์๋ ์ปดํฌ๋ํธ์ด๋ค. ๋ง์ฐ์ค๋ฅผ ์ฌ๋ฆฌ๋ฉด ์ธ๋ก์ ์ด ํ
๋๋ฆฌ์ ์๊ธฐ๋ ์ปดํฌ๋ํธ๋ก ์๊ฐํ๋ฉด ๋๋ค. ๋ง์ฐฌ๊ฐ์ง๋ก useSidebar ํ
์ ํ์ฉํ์ฌ Sidebar์ ํผ์นจ ์ํ๋ฅผ ํ ๊ธํ๋ค. tabIndex๋ฅผ -1๋ก ์ฃผ์ด ํค๋ณด๋๋ฅผ ์ฌ์ฉํ์ฌ ์ด๋ํ๊ฑฐ๋ ํฌ์ปค์ค๋ฅผ ์ค ๋ ํฌ์ปค์ค๊ฐ ์กํ์ง ์๋๋ก ์ง์ ํ๋ค. data-slot์ด ์์ผ๋ฉด aria-label์ด ํ์ ์๋ค๊ณ ์๊ฐํ๋๋ฐ, ์ฐพ์๋ณด๋ aria-label์ ์ ๊ทผ์ฑ ์ฐจ์์์ ํ์ํ ๊ฒ์ด๊ณ data-* ํํ๋ ์ปค์คํฐ๋ง์ด์ง ์์ฑ์ด์ด์ ๋ค๋ฅธ ์ฑ๊ฒฉ์ ๋๋ค.
์ ์ผ ๋ณต์กํ ๋ถ๋ถ์ ์คํ์ผ๋ง ๋ถ๋ถ์ธ๋ฐ, ์ฌ์ด๋๋ฐ๋ฅผ ํผ์ณค์ ๋์ ์ ์์ ๋ SidebarRail์ด ๋ณด์ด๋ ์์ญ์ ๋๋น๋ฅผ ๊ณ์ฐํ๋๋ผ ์ข ์ ๋จน์๋ค. ์คํ์ผ๋ง์ด ๋ณต์กํ๊ฒ ๋์ด์์ผ๋, ํ๋ํ๋ ํ์
ํด๋๊ฐ๋ฉด ์ดํด๋ฅผ ํ ์ ์๋ค. ์ถ๊ฐ๋ก group-data-*๊ผด๊ณผ in-data-*๊ผด์ ์ ๊ทผ์๊ฐ ์๋๋ฐ, ์ฐพ์๋ณด๋ ๋์ ๊ธฐ๋ฅ์ ์ ์ฌํ์ง๋ง group๊ณผ ๋ฌ๋ฆฌ in์ ๋ถ๋ชจ ์์์ group ๊ฐ์ด ์์ด๋ group์ฒ๋ผ ์ฌ์ฉํ ์ ์๋ค. ์ฐธ๊ณ ์๋ฃ
SidebarInset
function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
return (
<main
data-slot='sidebar-inset'
className={cn(
'bg-background relative flex w-full flex-1 flex-col',
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
className,
)}
{...props}
/>
);
}
TypeScript
๋ณต์ฌ
Sidebar์ variant๊ฐ inset์ผ ๊ฒฝ์ฐ ์ฌ์ด๋๋ฐ์ ๋ฐฐ๊ฒฝ์ด ์์ด์ง๊ณ ํ๋ซํ ํํ๊ฐ ๋๋ค. SidebarInset์ ์ฌ์ด๋๋ฐ ์ฐ์ธก์ ๋ฉ์ธ ์ปจํ
์ธ ๋ฅผ ํ์ํ ๋ ์ฌ์ฉํ๋ Wrapper์ ์ญํ ์ ์ํํ๋ค. variant๊ฐ inset์ผ ๊ฒฝ์ฐ์๋ง ์ฌ์ฉํ ์ ์๋ ๊ฒ์ ์๋ ๊ฒ ๊ฐ์๋ฐ, ์คํ์ผ๋ง์ peer-data-[variant=inset] ํํ๊ฐ ๋๋ถ๋ถ์ธ ๊ฒ์ ๋ณด์ inset ์ํ์์ ์ฃผ๋ก ์ฌ์ฉํ๋ ๊ฒ์ผ๋ก ๋ณด์ธ๋ค.
SidebarInput
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot='sidebar-input'
data-sidebar='input'
className={cn('bg-background h-8 w-full shadow-none', className)}
{...props}
/>
);
}
TypeScript
๋ณต์ฌ
Sidebar์์ ์ฌ์ฉํ๋ Input ์ปดํฌ๋ํธ์ด๋ค. shadcn/ui์ Input ์ปดํฌ๋ํธ๋ฅผ ํ์ฉํ์ฌ Sidebar์์ ์ฌ์ฉํ๋ Input์ ์ฌ์คํ์ผ๋ง ํ๋ค.
SidebarHeader
function SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='sidebar-header'
data-sidebar='header'
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
);
}
TypeScript
๋ณต์ฌ
Sidebar ์ปดํฌ๋ํธ์ ์ต์๋จ์ ํ์๋๋ SidebarHeader ์ปดํฌ๋ํธ์ด๋ค. ์์ ์์๋ค์ ์ธ๋ก๋ก ์ ๋ ฌํ๊ณ ์ฌ์ด์ ๊ฐ๊ฒฉ๊ณผ ๋ด๋ถ ํจ๋ฉ์ ๋ถ์ฌํ๋ค.
SidebarFooter
const SidebarFooter = ({
className,
...props
}: React.ComponentProps<'div'>) => {
return (
<div
data-slot='sidebar-footer'
data-sidebar='footer'
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
);
};
TypeScript
๋ณต์ฌ
Sidebar ์ปดํฌ๋ํธ์ ์ตํ๋จ์ ํ์๋๋ SidebarFooter ์ปดํฌ๋ํธ์ด๋ค. ์์ ์์๋ค์ ์ธ๋ก๋ก ์ ๋ ฌํ๊ณ ์ฌ์ด์ ๊ฐ๊ฒฉ๊ณผ ๋ด๋ถ ํจ๋ฉ์ ๋ถ์ฌํ๋ค. data-* ์์ฑ ๊ฐ์ ์ ์ธํ๊ณ ๋ SidebarHeader์ ๊ตฌํ ์ฐจ์ด์ ์ด ์๋ค.
SidebarContent
function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='sidebar-content'
data-sidebar='content'
className={cn(
'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
className,
)}
{...props}
/>
);
}
TypeScript
๋ณต์ฌ
Sidebar ์ปจํ
์ธ ๊ฐ ํ์๋๋ ์์ญ์ด๋ค. flex, min-h-0, overflow-auto๋ฅผ ํตํด ์คํฌ๋กค๋ฐ ์ ์ฒด๊ฐ ์๋ ์ปจํ
์ธ ์์ญ๋ง ์คํฌ๋กค์ด ๊ฐ๋ฅํ๊ฒ ๋์ด์๋ค.
SidebarGroup
function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='sidebar-group'
data-sidebar='group'
className={cn('relative flex w-full min-w-0 flex-col p-2', className)}
{...props}
/>
);
}
TypeScript
๋ณต์ฌ
Sidebar ์ปจํ
์ธ ๋ฅผ ๊ทธ๋ฃน์ผ๋ก ํ์ํ๊ณ ์ถ์ ๋ ์์ดํ
์ ๊ฐ์ธ๋ ์์ญ์ด๋ค. relative๋ฅผ ํตํด ์์ absolute ํฌ์ง์
์์๋ฅผ ๋ฃ์ ์ ์๋ค.
SidebarGroupAction
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<'button'> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'button';
return (
<Comp
data-slot='sidebar-group-action'
data-sidebar='group-action'
className={cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
// Increases the hit area of the button on mobile.
'after:absolute after:-inset-2 md:after:hidden',
'group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
);
}
TypeScript
๋ณต์ฌ
SidebarGroup์ ์ฐ์ธก์ ๋ถ๋ ๋ฒํผ ํธ๋ฆฌ๊ฑฐ์ด๋ค. absolute, top-3.5, right-3์ ํตํด ์ฐ์๋จ์ ๋ ๋๋ง ๋๋ ๊ฒ์ ์ ์ ์๋ค.
SidebarGroupContent
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='sidebar-group-content'
data-sidebar='group-content'
className={cn('w-full text-sm', className)}
{...props}
/>
);
}
TypeScript
๋ณต์ฌ
SidebarGroup ์์์ ์ค์ ์ปจํ
์ธ ๋ฅผ ๋ด๋ ์์ญ์ ๋ด๋นํ๋ ์ปดํฌ๋ํธ์ด๋ค.
SidebarMenu & SidebarMenuItem
const SidebarMenu = ({ className, ...props }: React.ComponentProps<'ul'>) => {
return (
<ul
data-slot='sidebar-menu'
data-sidebar='menu'
className={cn('flex w-full min-w-0 flex-col gap-1', className)}
{...props}
/>
);
};
const SidebarMenuItem = ({
className,
...props
}: React.ComponentProps<'li'>) => {
return (
<li
data-slot='sidebar-menu-item'
data-sidebar='menu-item'
className={cn('group/menu-item relative', className)}
{...props}
/>
);
};
TypeScript
๋ณต์ฌ
Sidebar์ ํ์ ๋๋ ๋ฉ๋ด์ ๊ทธ ๋ฉ๋ด ์์ ๋ค์ด๊ฐ๋ ์์ดํ
์ ๋ํ ์ปดํฌ๋ํธ์ด๋ค. SidebarMenuItem์ ๊ฒฝ์ฐ group/menu-item๊ณผ relative ์์ฑ์ ํตํด ์์ ์ปดํฌ๋ํธ์์ SidebarMenuItem์ ์ด๋ฒคํธ๋ฅผ ํ์ฉํ์ฌ ์คํ์ผ๋ง์ ํ ์ ์๊ณ ๋ด๋ถ์ absolute ์์๋ฅผ ๋ฐฐ์นํ ์ ์๋ค.
SidebarMenuButton
SidebarMenuButton์ SidebarMenuItem์์ ๋ ๋๋ง ๋๋ ์ปจํ
์ธ ์๋ ๊ฐ๋ค. ์ฒ์์ Button์ด๋ผ๊ณ ํด์ ์์ ๋ฒํผ์ธ์ค ์์๋๋ฐ MenuItem์ ๋ ๋๋ง ๋๋ ์ ์ฒด๊ฐ ๋ฒํผ์ฒ๋ผ ๋์ํ๋ค.
const sidebarMenuButtonVariants = cva(
'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
{
variants: {
variant: {
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
outline:
'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]',
},
size: {
default: 'h-8 text-sm',
sm: 'h-7 text-xs',
lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
JavaScript
๋ณต์ฌ
cva๋ฅผ ํตํด ์คํ์ผ๋ง์ ํจ์ํ ํ์ฌ ์ฌ์ฉํ๋ค. ์ด๋ sidebarMenuButtonVariants({variant, size}) ํ์์ผ๋ก ๋์ํ๋ค.
const Comp = asChild ? Slot : 'button';
const { isMobile, state } = useSidebar();
const button = (
<Comp
data-slot='sidebar-menu-button'
data-sidebar='menu-button'
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
TypeScript
๋ณต์ฌ
์ด ์ปดํฌ๋ํธ๋ tooltip ์ฌ๋ถ๋ฅผ ํ๋จํ์ฌ ๋จผ์ ์กฐ๊ฑด๋ถ ๋ ๋๋ง์ ํ๋ค. tooltip์ด ์์ ๋์๋ ์๋์ ๋์ฌ tooltip ์ปดํฌ๋ํธ๋ฅผ ์์ฑํ์ง ์๊ณ ์์์ ๋ฏธ๋ฆฌ ๋ง๋ค์ด๋์๋ variants๋ฅผ ํ์ฉํ์ฌ ์คํ์ผ๋ง์ ํ๋ค.
if (typeof tooltip === 'string') {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side='right'
align='center'
hidden={state !== 'collapsed' || isMobile}
{...tooltip}
/>
</Tooltip>
);
TypeScript
๋ณต์ฌ
๋ง์ฝ <SidebarMenuButton tooltip=โTooltipโ /> ํ์์ผ๋ก tooltip์ด string์ด๋ผ๋ฉด ๊ตฌ์กฐ ํต์ผ์ ์ํด tooltip์ children์ ๋ฃ๊ณ ๊ฐ์ฒดํํ๋ค. ๊ทธ๋ฆฌ๊ณ tooltip์ด ์์ผ๋ฉด shadcn/ui์ Tooltip ์ปดํฌ๋ํธ์ ๊ทธ ํ์ ์ปดํฌ๋ํธ๋ฅผ ํ์ฉํ์ฌ ์์ MenuItem(button)๊ณผ tooltip์ ํ์ํ๋ค.
SidebarMenuAction
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<'button'> & {
asChild?: boolean;
showOnHover?: boolean;
}) {
const Comp = asChild ? Slot : 'button';
return (
<Comp
data-slot='sidebar-menu-action'
data-sidebar='menu-action'
className={cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
// Increases the hit area of the button on mobile.
'after:absolute after:-inset-2 md:after:hidden',
'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
showOnHover &&
'peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0',
className,
)}
{...props}
/>
);
}
TypeScript
๋ณต์ฌ
SidebarMenuAction์ SidebarMenuButton์ ์ฐ์ธก ๋์ ๋ถ๋ ์์ ์ก์
๋ฒํผ์ด๋ค. MenuButton๊ณผ peer๋ก ๊ฐํ๊ฒ ์ฐ๋ ๋๋ฉฐ hover์ ์์ ๋๊ธฐํ์ size์ ๋ฐ๋ฅธ ์์น ์๋ ์กฐ์ ๋ฑ์ ๊ธฐ๋ฅ์ ์ฐ๊ฒฐํ๋ค. menu-item๊ณผ์ ์ํ์๋ ์ฐ๋ ๋๋ฉฐ ๋ฉ๋ด ์์ดํ
๊ณผ ์ผ์ฒด๊ฐ ์๊ฒ ๋์ํ๋ ์ก์
๋ฒํผ์ด๋ค.
SidebarMenuBadge
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='sidebar-menu-badge'
data-sidebar='menu-badge'
className={cn(
'text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none',
'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
);
}
TypeScript
๋ณต์ฌ
SidebarMenuAction๊ณผ ๋น์ทํ๊ฒ ๋ฉ๋ด์ ์ฐ์ธก์ ๋ํ๋๋ ์ปดํฌ๋ํธ์ด๋ค. ํ์ง๋ง, ํฌ์ธํฐ ์ด๋ฒคํธ๋ฅผ ์ฌ์ฉํ ์ ์๊ณ ์ซ์๊ฐ์ ๋จ์ํ ๋ฐ์ดํฐ๋ฅผ ํ์ํ๋ ์ฉ๋๋ก ์ฌ์ฉ ๋๋ค.
SidebarMenuSkeleton
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<'div'> & {
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
data-slot='sidebar-menu-skeleton'
data-sidebar='menu-skeleton'
className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)}
{...props}
>
{showIcon && (
<Skeleton
className='size-4 rounded-md'
data-sidebar='menu-skeleton-icon'
/>
)}
<Skeleton
className='h-4 max-w-(--skeleton-width) flex-1'
data-sidebar='menu-skeleton-text'
style={
{
'--skeleton-width': width,
} as React.CSSProperties
}
/>
</div>
);
}
TypeScript
๋ณต์ฌ
์ฌ์ด๋๋ฐ์์ ๋ก๋ฉ๊ณผ ๊ฐ์ ์ปจํ
์คํธ ๋ฐ์ ์ ๋ณด์ฌ์ฃผ๋ ์ปดํฌ๋ํธ์ด๋ค. width๋ฅผ ํ์ฉํ์ฌ ์ปจํ
์ธ ์ ๊ธธ์ด๋ฅผ ๋๋ค ๊ธธ์ด๋ก ๋ณด์ฌ์ค๋ค. ์๋ํ๋ฉด ์ปจํ
์ธ ๊ฐ ๋ชจ๋ ๊ฐ์ ๊ธธ์ด๊ฐ ์๋๊ธฐ ๋๋ฌธ์ด๋ค. showIcon์ true๋ก ์ค์ ํ๋ฉด ์ข์ธก์ ๋๊ทธ๋ ์ค์ผ๋ ํค์ด ์ถ๊ฐ ๋๋ค.
SidebarMenuSub & SidebarMenuSubItem
function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) {
return (
<ul
data-slot='sidebar-menu-sub'
data-sidebar='menu-sub'
className={cn(
'border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5',
'group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
);
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<'li'>) {
return (
<li
data-slot='sidebar-menu-sub-item'
data-sidebar='menu-sub-item'
className={cn('group/menu-sub-item relative', className)}
{...props}
/>
);
}
TypeScript
๋ณต์ฌ
Sidebar Submenu๋ฅผ ํ์ํ๋ ์ปดํฌ๋ํธ์ด๋ค. SidebarMenuSub๋ ์๋ธ ๋ฉ๋ด๋ฅผ ๊ฐ์ธ๋ ul ํ๊ทธ์ด๊ณ , SidebarMenuSubItem์ SidebarMenuSub ์์ ๋ ๋๋ง ๋๋ ์์ดํ
๋ค์ด๋ค.
SidebarMenuSubButton
function SidebarMenuSubButton({
asChild = false,
size = 'md',
isActive = false,
className,
...props
}: React.ComponentProps<'a'> & {
asChild?: boolean;
size?: 'sm' | 'md';
isActive?: boolean;
}) {
const Comp = asChild ? Slot : 'a';
return (
<Comp
data-slot='sidebar-menu-sub-button'
data-sidebar='menu-sub-button'
data-size={size}
data-active={isActive}
className={cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
size === 'sm' && 'text-xs',
size === 'md' && 'text-sm',
'group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
);
}
TypeScript
๋ณต์ฌ
SidebarMenuButton๊ณผ ๋ง์ฐฌ๊ฐ์ง๋ก SidebarMenuSubItem ์์ ๋ ๋๋ง ๋๋ ์ปจํ
์ธ ์ด๋ค.asChild ํ๋ผ๋ฏธํฐ๊ฐ true์ด๋ฉด child์ ์คํ์ผ๊ณผ ๋์๋ง์ ์ ๊ณตํ ์ ์๋ค.


