feat: 사이드바와 기관 목록 접기/펼치기 및 반응형 자동 접힘 추가
@e5d4fc57e9c4780fe0c9825e3fa8085a017f6d28
--- frontend/src/App.tsx
+++ frontend/src/App.tsx
... | ... | @@ -6,6 +6,7 @@ |
| 6 | 6 |
import OrgOverview from './components/OrgOverview' |
| 7 | 7 |
import SeedUpload from './components/SeedUpload' |
| 8 | 8 |
import Sidebar from './components/Sidebar' |
| 9 |
+import { useCollapsiblePanel } from './hooks/useCollapsiblePanel'
|
|
| 9 | 10 |
|
| 10 | 11 |
// 화면 2개: 기관관리(시안 레이아웃의 기관 상세)와 채널생성(명부 시드 + 담당자 입력 + 채널 생성). |
| 11 | 12 |
type Page = 'orgs' | 'channels' |
... | ... | @@ -30,6 +31,9 @@ |
| 30 | 31 |
const [auth, setAuth] = useState<AuthState>('checking')
|
| 31 | 32 |
const [authErrorMessage, setAuthErrorMessage] = useState<string | null>(null) |
| 32 | 33 |
const [page, setPage] = useState<Page>('orgs')
|
| 34 |
+ // 창이 좁아지면 자동으로 접힌다. 사용자의 접기/펼치기 선택은 저장된다. |
|
| 35 |
+ const [menuCollapsed, toggleMenu] = useCollapsiblePanel('itnhub.menu', '(max-width: 1023px)')
|
|
| 36 |
+ const [listCollapsed, toggleList] = useCollapsiblePanel('itnhub.orglist', '(max-width: 1279px)')
|
|
| 33 | 37 |
|
| 34 | 38 |
const reload = useCallback(async () => {
|
| 35 | 39 |
try {
|
... | ... | @@ -90,6 +94,8 @@ |
| 90 | 94 |
<div className="flex h-screen"> |
| 91 | 95 |
<Sidebar |
| 92 | 96 |
activeKey={page}
|
| 97 |
+ collapsed={menuCollapsed}
|
|
| 98 |
+ onToggle={toggleMenu}
|
|
| 93 | 99 |
onSelect={(key) => {
|
| 94 | 100 |
if (key === 'orgs' || key === 'channels') {
|
| 95 | 101 |
setPage(key) |
... | ... | @@ -113,9 +119,21 @@ |
| 113 | 119 |
</header> |
| 114 | 120 |
|
| 115 | 121 |
<div className="flex min-h-0 flex-1"> |
| 116 |
- <div className="flex w-80 shrink-0 flex-col border-r border-gray-200"> |
|
| 117 |
- <OrgList orgs={orgs} selectedId={selectedId} onSelect={setSelectedId} />
|
|
| 118 |
- {page === 'channels' && <SeedUpload onUploaded={() => void reload()} />}
|
|
| 122 |
+ <div |
|
| 123 |
+ className={`flex shrink-0 flex-col border-r border-gray-200 transition-all duration-200 ${
|
|
| 124 |
+ listCollapsed ? 'w-14' : 'w-80' |
|
| 125 |
+ }`} |
|
| 126 |
+ > |
|
| 127 |
+ <OrgList |
|
| 128 |
+ orgs={orgs}
|
|
| 129 |
+ selectedId={selectedId}
|
|
| 130 |
+ onSelect={setSelectedId}
|
|
| 131 |
+ collapsed={listCollapsed}
|
|
| 132 |
+ onToggle={toggleList}
|
|
| 133 |
+ /> |
|
| 134 |
+ {page === 'channels' && !listCollapsed && (
|
|
| 135 |
+ <SeedUpload onUploaded={() => void reload()} />
|
|
| 136 |
+ )} |
|
| 119 | 137 |
</div> |
| 120 | 138 |
|
| 121 | 139 |
<main className="min-w-0 flex-1 overflow-y-auto bg-gray-50/50"> |
--- frontend/src/components/OrgList.test.tsx
+++ frontend/src/components/OrgList.test.tsx
... | ... | @@ -46,4 +46,35 @@ |
| 46 | 46 |
expect(screen.queryByText('국제방송교류재단')).toBeNull()
|
| 47 | 47 |
expect(screen.getByText('경찰청_치안정책연구소')).toBeTruthy()
|
| 48 | 48 |
}) |
| 49 |
+ |
|
| 50 |
+ it('접힌 상태에서는 연번만 보이고 이름은 툴팁으로 남는다', () => {
|
|
| 51 |
+ render( |
|
| 52 |
+ <OrgList orgs={orgs} selectedId={null} onSelect={() => {}} collapsed onToggle={() => {}} />,
|
|
| 53 |
+ ) |
|
| 54 |
+ |
|
| 55 |
+ expect(screen.getByText('001')).toBeTruthy()
|
|
| 56 |
+ expect(screen.queryByText('국제방송교류재단')).toBeNull()
|
|
| 57 |
+ expect(screen.queryByPlaceholderText('기관명 검색')).toBeNull()
|
|
| 58 |
+ |
|
| 59 |
+ const item = screen.getByRole('button', { name: '001 국제방송교류재단' })
|
|
| 60 |
+ expect(item).toHaveAttribute('title', '001 국제방송교류재단 (운영 중)')
|
|
| 61 |
+ }) |
|
| 62 |
+ |
|
| 63 |
+ it('접힌 상태에서도 기관을 선택할 수 있다', () => {
|
|
| 64 |
+ const onSelect = vi.fn() |
|
| 65 |
+ render(<OrgList orgs={orgs} selectedId={null} onSelect={onSelect} collapsed />)
|
|
| 66 |
+ |
|
| 67 |
+ fireEvent.click(screen.getByRole('button', { name: '001 국제방송교류재단' }))
|
|
| 68 |
+ |
|
| 69 |
+ expect(onSelect).toHaveBeenCalledWith(1) |
|
| 70 |
+ }) |
|
| 71 |
+ |
|
| 72 |
+ it('접기 버튼을 누르면 onToggle이 호출된다', () => {
|
|
| 73 |
+ const onToggle = vi.fn() |
|
| 74 |
+ render(<OrgList orgs={orgs} selectedId={null} onSelect={() => {}} onToggle={onToggle} />)
|
|
| 75 |
+ |
|
| 76 |
+ fireEvent.click(screen.getByRole('button', { name: '기관 목록 접기' }))
|
|
| 77 |
+ |
|
| 78 |
+ expect(onToggle).toHaveBeenCalled() |
|
| 79 |
+ }) |
|
| 49 | 80 |
}) |
--- frontend/src/components/OrgList.tsx
+++ frontend/src/components/OrgList.tsx
... | ... | @@ -1,28 +1,95 @@ |
| 1 | 1 |
import { useState } from 'react'
|
| 2 | 2 |
import type { Org } from '../api/client'
|
| 3 |
-import StatusBadge from './StatusBadge' |
|
| 3 |
+import StatusBadge, { STATUS_DOT, STATUS_LABELS } from './StatusBadge'
|
|
| 4 | 4 |
|
| 5 | 5 |
interface Props {
|
| 6 | 6 |
orgs: Org[] |
| 7 | 7 |
selectedId: number | null |
| 8 | 8 |
onSelect: (id: number) => void |
| 9 |
+ collapsed?: boolean |
|
| 10 |
+ onToggle?: () => void |
|
| 9 | 11 |
} |
| 10 | 12 |
|
| 11 |
-export default function OrgList({ orgs, selectedId, onSelect }: Props) {
|
|
| 13 |
+function Chevron({ left }: { left: boolean }) {
|
|
| 14 |
+ return ( |
|
| 15 |
+ <svg |
|
| 16 |
+ className="h-4 w-4" |
|
| 17 |
+ viewBox="0 0 24 24" |
|
| 18 |
+ fill="none" |
|
| 19 |
+ stroke="currentColor" |
|
| 20 |
+ strokeWidth="2" |
|
| 21 |
+ strokeLinecap="round" |
|
| 22 |
+ strokeLinejoin="round" |
|
| 23 |
+ > |
|
| 24 |
+ {left ? <path d="M15 18l-6-6 6-6" /> : <path d="M9 18l6-6-6-6" />}
|
|
| 25 |
+ </svg> |
|
| 26 |
+ ) |
|
| 27 |
+} |
|
| 28 |
+ |
|
| 29 |
+export default function OrgList({ orgs, selectedId, onSelect, collapsed = false, onToggle }: Props) {
|
|
| 12 | 30 |
const [keyword, setKeyword] = useState('')
|
| 13 | 31 |
|
| 14 | 32 |
const visible = orgs.filter((org) => org.orgName.includes(keyword)) |
| 15 | 33 |
|
| 34 |
+ if (collapsed) {
|
|
| 35 |
+ // 접힌 상태: 연번 + 상태 점만. 이름은 툴팁으로 확인한다. |
|
| 36 |
+ return ( |
|
| 37 |
+ <div className="flex h-full flex-col overflow-hidden"> |
|
| 38 |
+ <div className="flex justify-center border-b border-gray-200 p-2"> |
|
| 39 |
+ <button |
|
| 40 |
+ type="button" |
|
| 41 |
+ onClick={onToggle}
|
|
| 42 |
+ aria-label="기관 목록 펼치기" |
|
| 43 |
+ title="기관 목록 펼치기" |
|
| 44 |
+ className="rounded-md p-1.5 text-gray-500 hover:bg-gray-100" |
|
| 45 |
+ > |
|
| 46 |
+ <Chevron left={false} />
|
|
| 47 |
+ </button> |
|
| 48 |
+ </div> |
|
| 49 |
+ |
|
| 50 |
+ <ul className="flex-1 overflow-y-auto"> |
|
| 51 |
+ {orgs.map((org) => (
|
|
| 52 |
+ <li key={org.id}>
|
|
| 53 |
+ <button |
|
| 54 |
+ type="button" |
|
| 55 |
+ onClick={() => onSelect(org.id)}
|
|
| 56 |
+ title={`${org.orgNo} ${org.orgName} (${STATUS_LABELS[org.status]})`}
|
|
| 57 |
+ aria-label={`${org.orgNo} ${org.orgName}`}
|
|
| 58 |
+ className={`flex w-full flex-col items-center gap-1 py-2 hover:bg-gray-50 ${
|
|
| 59 |
+ selectedId === org.id ? 'bg-blue-50' : '' |
|
| 60 |
+ }`} |
|
| 61 |
+ > |
|
| 62 |
+ <span className="text-[11px] font-medium text-gray-600">{org.orgNo}</span>
|
|
| 63 |
+ <span className={`h-1.5 w-1.5 rounded-full ${STATUS_DOT[org.status]}`} />
|
|
| 64 |
+ </button> |
|
| 65 |
+ </li> |
|
| 66 |
+ ))} |
|
| 67 |
+ </ul> |
|
| 68 |
+ </div> |
|
| 69 |
+ ) |
|
| 70 |
+ } |
|
| 71 |
+ |
|
| 16 | 72 |
return ( |
| 17 | 73 |
<div className="flex h-full flex-col overflow-hidden"> |
| 18 |
- <div className="border-b border-gray-200 p-3"> |
|
| 74 |
+ <div className="flex items-center gap-2 border-b border-gray-200 p-3"> |
|
| 19 | 75 |
<input |
| 20 |
- className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm" |
|
| 76 |
+ className="w-full min-w-0 rounded-md border border-gray-300 px-3 py-2 text-sm" |
|
| 21 | 77 |
placeholder="기관명 검색" |
| 22 | 78 |
aria-label="기관명 검색" |
| 23 | 79 |
value={keyword}
|
| 24 | 80 |
onChange={(e) => setKeyword(e.target.value)}
|
| 25 | 81 |
/> |
| 82 |
+ {onToggle && (
|
|
| 83 |
+ <button |
|
| 84 |
+ type="button" |
|
| 85 |
+ onClick={onToggle}
|
|
| 86 |
+ aria-label="기관 목록 접기" |
|
| 87 |
+ title="기관 목록 접기" |
|
| 88 |
+ className="shrink-0 rounded-md p-1.5 text-gray-500 hover:bg-gray-100" |
|
| 89 |
+ > |
|
| 90 |
+ <Chevron left /> |
|
| 91 |
+ </button> |
|
| 92 |
+ )} |
|
| 26 | 93 |
</div> |
| 27 | 94 |
|
| 28 | 95 |
<ul className="flex-1 overflow-y-auto"> |
--- frontend/src/components/Sidebar.test.tsx
+++ frontend/src/components/Sidebar.test.tsx
... | ... | @@ -54,4 +54,24 @@ |
| 54 | 54 |
expect(screen.getByText('ITN-HUB')).toBeTruthy()
|
| 55 | 55 |
expect(screen.getByText('사업관리시스템')).toBeTruthy()
|
| 56 | 56 |
}) |
| 57 |
+ |
|
| 58 |
+ it('접힌 상태에서는 라벨이 사라지고 툴팁으로 남는다', () => {
|
|
| 59 |
+ render(<Sidebar collapsed onToggle={() => {}} />)
|
|
| 60 |
+ |
|
| 61 |
+ expect(screen.queryByText('기관관리')).toBeNull()
|
|
| 62 |
+ expect(screen.queryByText('ITN-HUB')).toBeNull()
|
|
| 63 |
+ expect(screen.getByText('IH')).toBeTruthy()
|
|
| 64 |
+ |
|
| 65 |
+ expect(screen.getByRole('link', { name: '기관관리' })).toHaveAttribute('title', '기관관리')
|
|
| 66 |
+ expect(screen.getByTitle('Dashboard (준비 중)')).toBeTruthy()
|
|
| 67 |
+ }) |
|
| 68 |
+ |
|
| 69 |
+ it('접기 버튼을 누르면 onToggle이 호출된다', () => {
|
|
| 70 |
+ const onToggle = vi.fn() |
|
| 71 |
+ render(<Sidebar onToggle={onToggle} />)
|
|
| 72 |
+ |
|
| 73 |
+ fireEvent.click(screen.getByRole('button', { name: '메뉴 접기' }))
|
|
| 74 |
+ |
|
| 75 |
+ expect(onToggle).toHaveBeenCalled() |
|
| 76 |
+ }) |
|
| 57 | 77 |
}) |
--- frontend/src/components/Sidebar.tsx
+++ frontend/src/components/Sidebar.tsx
... | ... | @@ -113,17 +113,36 @@ |
| 113 | 113 |
interface SidebarProps {
|
| 114 | 114 |
activeKey?: string |
| 115 | 115 |
onSelect?: (key: string) => void |
| 116 |
+ collapsed?: boolean |
|
| 117 |
+ onToggle?: () => void |
|
| 116 | 118 |
} |
| 117 | 119 |
|
| 118 |
-export default function Sidebar({ activeKey = 'orgs', onSelect }: SidebarProps) {
|
|
| 120 |
+export default function Sidebar({
|
|
| 121 |
+ activeKey = 'orgs', |
|
| 122 |
+ onSelect, |
|
| 123 |
+ collapsed = false, |
|
| 124 |
+ onToggle, |
|
| 125 |
+}: SidebarProps) {
|
|
| 119 | 126 |
return ( |
| 120 |
- <aside className="flex w-56 shrink-0 flex-col border-r border-gray-200 bg-white"> |
|
| 121 |
- <div className="px-5 pb-4 pt-5"> |
|
| 122 |
- <p className="text-lg font-bold tracking-tight">ITN-HUB</p> |
|
| 123 |
- <p className="text-xs text-gray-500">사업관리시스템</p> |
|
| 124 |
- </div> |
|
| 127 |
+ <aside |
|
| 128 |
+ className={`flex shrink-0 flex-col border-r border-gray-200 bg-white transition-all duration-200 ${
|
|
| 129 |
+ collapsed ? 'w-14' : 'w-56' |
|
| 130 |
+ }`} |
|
| 131 |
+ > |
|
| 132 |
+ {collapsed ? (
|
|
| 133 |
+ <div className="flex justify-center pb-4 pt-5" title="ITN-HUB 사업관리시스템"> |
|
| 134 |
+ <span className="flex h-8 w-8 items-center justify-center rounded-md bg-gray-900 text-xs font-bold text-white"> |
|
| 135 |
+ IH |
|
| 136 |
+ </span> |
|
| 137 |
+ </div> |
|
| 138 |
+ ) : ( |
|
| 139 |
+ <div className="px-5 pb-4 pt-5"> |
|
| 140 |
+ <p className="text-lg font-bold tracking-tight">ITN-HUB</p> |
|
| 141 |
+ <p className="text-xs text-gray-500">사업관리시스템</p> |
|
| 142 |
+ </div> |
|
| 143 |
+ )} |
|
| 125 | 144 |
|
| 126 |
- <nav aria-label="주 메뉴"> |
|
| 145 |
+ <nav aria-label="주 메뉴" className="flex-1"> |
|
| 127 | 146 |
<ul className="space-y-0.5 px-2"> |
| 128 | 147 |
{MENU.map((item) =>
|
| 129 | 148 |
item.enabled ? ( |
... | ... | @@ -134,32 +153,68 @@ |
| 134 | 153 |
e.preventDefault() |
| 135 | 154 |
onSelect?.(item.key) |
| 136 | 155 |
}} |
| 156 |
+ title={collapsed ? item.label : undefined}
|
|
| 137 | 157 |
aria-current={item.key === activeKey ? 'page' : undefined}
|
| 138 | 158 |
className={`flex items-center gap-3 rounded-md px-3 py-2 text-sm ${
|
| 159 |
+ collapsed ? 'justify-center px-0' : '' |
|
| 160 |
+ } ${
|
|
| 139 | 161 |
item.key === activeKey |
| 140 | 162 |
? 'bg-gray-100 font-medium text-gray-900' |
| 141 | 163 |
: 'text-gray-700 hover:bg-gray-50' |
| 142 | 164 |
}`} |
| 143 | 165 |
> |
| 144 | 166 |
{item.icon}
|
| 145 |
- {item.label}
|
|
| 167 |
+ {!collapsed && item.label}
|
|
| 146 | 168 |
</a> |
| 147 | 169 |
</li> |
| 148 | 170 |
) : ( |
| 149 | 171 |
<li key={item.key}>
|
| 150 | 172 |
<span |
| 151 |
- title="준비 중" |
|
| 173 |
+ title={collapsed ? `${item.label} (준비 중)` : '준비 중'}
|
|
| 152 | 174 |
aria-disabled="true" |
| 153 |
- className="flex cursor-not-allowed items-center gap-3 rounded-md px-3 py-2 text-sm text-gray-400" |
|
| 175 |
+ className={`flex cursor-not-allowed items-center gap-3 rounded-md px-3 py-2 text-sm text-gray-400 ${
|
|
| 176 |
+ collapsed ? 'justify-center px-0' : '' |
|
| 177 |
+ }`} |
|
| 154 | 178 |
> |
| 155 | 179 |
{item.icon}
|
| 156 |
- {item.label}
|
|
| 180 |
+ {!collapsed && item.label}
|
|
| 157 | 181 |
</span> |
| 158 | 182 |
</li> |
| 159 | 183 |
), |
| 160 | 184 |
)} |
| 161 | 185 |
</ul> |
| 162 | 186 |
</nav> |
| 187 |
+ |
|
| 188 |
+ {onToggle && (
|
|
| 189 |
+ <div className={`border-t border-gray-100 p-2 ${collapsed ? 'flex justify-center' : ''}`}>
|
|
| 190 |
+ <button |
|
| 191 |
+ type="button" |
|
| 192 |
+ onClick={onToggle}
|
|
| 193 |
+ aria-label={collapsed ? '메뉴 펼치기' : '메뉴 접기'}
|
|
| 194 |
+ title={collapsed ? '메뉴 펼치기' : '메뉴 접기'}
|
|
| 195 |
+ className={`flex items-center gap-2 rounded-md p-2 text-sm text-gray-500 hover:bg-gray-100 ${
|
|
| 196 |
+ collapsed ? '' : 'w-full' |
|
| 197 |
+ }`} |
|
| 198 |
+ > |
|
| 199 |
+ <svg |
|
| 200 |
+ className="h-4 w-4" |
|
| 201 |
+ viewBox="0 0 24 24" |
|
| 202 |
+ fill="none" |
|
| 203 |
+ stroke="currentColor" |
|
| 204 |
+ strokeWidth="2" |
|
| 205 |
+ strokeLinecap="round" |
|
| 206 |
+ strokeLinejoin="round" |
|
| 207 |
+ > |
|
| 208 |
+ {collapsed ? (
|
|
| 209 |
+ <path d="M9 18l6-6-6-6" /> |
|
| 210 |
+ ) : ( |
|
| 211 |
+ <path d="M15 18l-6-6 6-6" /> |
|
| 212 |
+ )} |
|
| 213 |
+ </svg> |
|
| 214 |
+ {!collapsed && '메뉴 접기'}
|
|
| 215 |
+ </button> |
|
| 216 |
+ </div> |
|
| 217 |
+ )} |
|
| 163 | 218 |
</aside> |
| 164 | 219 |
) |
| 165 | 220 |
} |
--- frontend/src/components/StatusBadge.tsx
+++ frontend/src/components/StatusBadge.tsx
... | ... | @@ -1,12 +1,22 @@ |
| 1 | 1 |
import type { OrgStatus } from '../api/client'
|
| 2 | 2 |
|
| 3 |
-const LABELS: Record<OrgStatus, string> = {
|
|
| 3 |
+export const STATUS_LABELS: Record<OrgStatus, string> = {
|
|
| 4 | 4 |
INFO_PENDING: '정보 대기', |
| 5 | 5 |
READY: '생성 가능', |
| 6 | 6 |
PARTIAL: '부분 생성', |
| 7 | 7 |
ACTIVE: '운영 중', |
| 8 | 8 |
} |
| 9 | 9 |
|
| 10 |
+/** 접힌 목록 등 좁은 자리에서 뱃지 대신 쓰는 상태 점 색 */ |
|
| 11 |
+export const STATUS_DOT: Record<OrgStatus, string> = {
|
|
| 12 |
+ INFO_PENDING: 'bg-gray-300', |
|
| 13 |
+ READY: 'bg-blue-500', |
|
| 14 |
+ PARTIAL: 'bg-amber-500', |
|
| 15 |
+ ACTIVE: 'bg-emerald-500', |
|
| 16 |
+} |
|
| 17 |
+ |
|
| 18 |
+const LABELS = STATUS_LABELS |
|
| 19 |
+ |
|
| 10 | 20 |
const STYLES: Record<OrgStatus, string> = {
|
| 11 | 21 |
INFO_PENDING: 'bg-gray-100 text-gray-600', |
| 12 | 22 |
READY: 'bg-blue-50 text-blue-700', |
+++ frontend/src/hooks/useCollapsiblePanel.ts
... | ... | @@ -0,0 +1,44 @@ |
| 1 | +import { useEffect, useState } from 'react' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * 접을 수 있는 패널의 상태. | |
| 5 | + * - 사용자가 접거나 편 선택은 localStorage에 남아 새로고침 후에도 유지된다 | |
| 6 | + * - 화면이 좁아져 미디어쿼리가 일치하면 자동으로 접힌다 (반응형) | |
| 7 | + * - 다시 넓어지면 사용자가 저장해 둔 선호로 복귀한다 | |
| 8 | + */ | |
| 9 | +export function useCollapsiblePanel(storageKey: string, autoCollapseQuery: string) { | |
| 10 | + const [collapsed, setCollapsed] = useState<boolean>(() => { | |
| 11 | + if (typeof window === 'undefined') { | |
| 12 | + return false | |
| 13 | + } | |
| 14 | + if (window.localStorage.getItem(storageKey) === '1') { | |
| 15 | + return true | |
| 16 | + } | |
| 17 | + return ( | |
| 18 | + typeof window.matchMedia === 'function' && window.matchMedia(autoCollapseQuery).matches | |
| 19 | + ) | |
| 20 | + }) | |
| 21 | + | |
| 22 | + useEffect(() => { | |
| 23 | + // jsdom(테스트 환경)에는 matchMedia가 없다 | |
| 24 | + if (typeof window.matchMedia !== 'function') { | |
| 25 | + return | |
| 26 | + } | |
| 27 | + const mq = window.matchMedia(autoCollapseQuery) | |
| 28 | + const onChange = (e: MediaQueryListEvent) => { | |
| 29 | + setCollapsed(e.matches ? true : window.localStorage.getItem(storageKey) === '1') | |
| 30 | + } | |
| 31 | + mq.addEventListener('change', onChange) | |
| 32 | + return () => mq.removeEventListener('change', onChange) | |
| 33 | + }, [storageKey, autoCollapseQuery]) | |
| 34 | + | |
| 35 | + const toggle = () => { | |
| 36 | + setCollapsed((prev) => { | |
| 37 | + const next = !prev | |
| 38 | + window.localStorage.setItem(storageKey, next ? '1' : '0') | |
| 39 | + return next | |
| 40 | + }) | |
| 41 | + } | |
| 42 | + | |
| 43 | + return [collapsed, toggle] as const | |
| 44 | +} |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?