@scripso-homepad/ui 0.3.9 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-C7GHBVMM.js +614 -0
- package/dist/chunk-C7GHBVMM.js.map +1 -0
- package/dist/index.js +29 -635
- package/dist/index.js.map +1 -1
- package/dist/web/index.cjs +1189 -0
- package/dist/web/index.cjs.map +1 -0
- package/dist/web/index.d.cts +156 -0
- package/dist/web/index.d.ts +156 -0
- package/dist/web/index.js +739 -0
- package/dist/web/index.js.map +1 -0
- package/package.json +26 -2
- package/src/web/hooks/useMediaQuery.ts +23 -0
- package/src/web/hooks/useOnClickOutside.ts +30 -0
- package/src/web/icons/BellIcon.tsx +27 -0
- package/src/web/icons/BuildingIcon.tsx +55 -0
- package/src/web/index.ts +37 -0
- package/src/web/layout/AppHeader.stories.tsx +85 -0
- package/src/web/layout/AppHeader.tsx +115 -0
- package/src/web/layout/BuildingSelect.stories.tsx +60 -0
- package/src/web/layout/BuildingSelect.tsx +208 -0
- package/src/web/layout/DashboardLayout.stories.tsx +87 -0
- package/src/web/layout/DashboardLayout.tsx +37 -0
- package/src/web/layout/Sidebar.stories.tsx +80 -0
- package/src/web/layout/Sidebar.tsx +244 -0
- package/src/web/layout/SidebarMobileHeader.stories.tsx +47 -0
- package/src/web/layout/SidebarMobileHeader.tsx +48 -0
- package/src/web/layout/SidebarNavItem.tsx +60 -0
- package/src/web/layout/SidebarUserCard.tsx +67 -0
- package/src/web/layout/story-fixtures.tsx +93 -0
- package/src/web/layout/story-helpers.tsx +5 -0
- package/src/web/layout/types.ts +48 -0
- package/src/web/utils/cn.ts +6 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { ChevronsUpDown } from 'lucide-react';
|
|
2
|
+
import { useCallback, useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
import { BuildingIcon } from '../icons/BuildingIcon';
|
|
5
|
+
import { useOnClickOutside } from '../hooks/useOnClickOutside';
|
|
6
|
+
import { cn } from '../utils/cn';
|
|
7
|
+
import type { AdminBuildingOption, AdminHeaderLabels } from './types';
|
|
8
|
+
|
|
9
|
+
export type BuildingSelectProps = {
|
|
10
|
+
options: AdminBuildingOption[];
|
|
11
|
+
value: string;
|
|
12
|
+
labels: Pick<AdminHeaderLabels, 'selectBuilding'>;
|
|
13
|
+
onChange?: (buildingId: string) => void;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
className?: string;
|
|
16
|
+
menuClassName?: string;
|
|
17
|
+
buildingIcon?: ReactNode;
|
|
18
|
+
chevronIcon?: ReactNode;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function BuildingSelect({
|
|
22
|
+
options,
|
|
23
|
+
value,
|
|
24
|
+
labels,
|
|
25
|
+
onChange,
|
|
26
|
+
disabled = false,
|
|
27
|
+
className,
|
|
28
|
+
menuClassName,
|
|
29
|
+
buildingIcon,
|
|
30
|
+
chevronIcon,
|
|
31
|
+
}: BuildingSelectProps) {
|
|
32
|
+
const [open, setOpen] = useState(false);
|
|
33
|
+
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
|
34
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
35
|
+
const listboxId = useId();
|
|
36
|
+
|
|
37
|
+
const selectedOption = options.find((option) => option.value === value);
|
|
38
|
+
const displayLabel = selectedOption?.label ?? labels.selectBuilding;
|
|
39
|
+
const selectableOptions = options.filter((option) => !option.disabled);
|
|
40
|
+
|
|
41
|
+
const closeMenu = useCallback(() => {
|
|
42
|
+
setOpen(false);
|
|
43
|
+
setHighlightedIndex(-1);
|
|
44
|
+
}, []);
|
|
45
|
+
|
|
46
|
+
const openMenu = useCallback(() => {
|
|
47
|
+
if (disabled || selectableOptions.length === 0) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const selectedIndex = selectableOptions.findIndex((option) => option.value === value);
|
|
52
|
+
setHighlightedIndex(selectedIndex >= 0 ? selectedIndex : 0);
|
|
53
|
+
setOpen(true);
|
|
54
|
+
}, [disabled, selectableOptions, value]);
|
|
55
|
+
|
|
56
|
+
const selectOption = useCallback(
|
|
57
|
+
(option: AdminBuildingOption) => {
|
|
58
|
+
if (option.disabled) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
onChange?.(option.value);
|
|
63
|
+
closeMenu();
|
|
64
|
+
},
|
|
65
|
+
[closeMenu, onChange],
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
useOnClickOutside(rootRef, closeMenu, open);
|
|
69
|
+
|
|
70
|
+
const handleTriggerKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
|
71
|
+
if (disabled) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
switch (event.key) {
|
|
76
|
+
case 'ArrowDown':
|
|
77
|
+
case 'Enter':
|
|
78
|
+
case ' ':
|
|
79
|
+
event.preventDefault();
|
|
80
|
+
openMenu();
|
|
81
|
+
break;
|
|
82
|
+
case 'Escape':
|
|
83
|
+
closeMenu();
|
|
84
|
+
break;
|
|
85
|
+
default:
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const handleListKeyDown = (event: KeyboardEvent<HTMLUListElement>) => {
|
|
91
|
+
if (!open || selectableOptions.length === 0) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
switch (event.key) {
|
|
96
|
+
case 'ArrowDown':
|
|
97
|
+
event.preventDefault();
|
|
98
|
+
setHighlightedIndex((current) => (current + 1) % selectableOptions.length);
|
|
99
|
+
break;
|
|
100
|
+
case 'ArrowUp':
|
|
101
|
+
event.preventDefault();
|
|
102
|
+
setHighlightedIndex(
|
|
103
|
+
(current) => (current - 1 + selectableOptions.length) % selectableOptions.length,
|
|
104
|
+
);
|
|
105
|
+
break;
|
|
106
|
+
case 'Enter':
|
|
107
|
+
case ' ':
|
|
108
|
+
event.preventDefault();
|
|
109
|
+
if (highlightedIndex >= 0) {
|
|
110
|
+
selectOption(selectableOptions[highlightedIndex]);
|
|
111
|
+
}
|
|
112
|
+
break;
|
|
113
|
+
case 'Escape':
|
|
114
|
+
event.preventDefault();
|
|
115
|
+
closeMenu();
|
|
116
|
+
break;
|
|
117
|
+
case 'Tab':
|
|
118
|
+
closeMenu();
|
|
119
|
+
break;
|
|
120
|
+
default:
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<div ref={rootRef} className={cn('relative w-57.5', className)}>
|
|
127
|
+
<button
|
|
128
|
+
type="button"
|
|
129
|
+
disabled={disabled}
|
|
130
|
+
aria-haspopup="listbox"
|
|
131
|
+
aria-expanded={open}
|
|
132
|
+
aria-controls={listboxId}
|
|
133
|
+
aria-label={labels.selectBuilding}
|
|
134
|
+
onClick={() => (open ? closeMenu() : openMenu())}
|
|
135
|
+
onKeyDown={handleTriggerKeyDown}
|
|
136
|
+
className={cn(
|
|
137
|
+
'flex h-11 w-full items-center gap-3 rounded-xl border border-storm-gray-50 bg-white p-3 text-left transition-colors',
|
|
138
|
+
!disabled && 'cursor-pointer hover:bg-storm-gray-50/60',
|
|
139
|
+
open && 'bg-storm-gray-50/60',
|
|
140
|
+
disabled && 'cursor-not-allowed opacity-60',
|
|
141
|
+
)}
|
|
142
|
+
>
|
|
143
|
+
{buildingIcon ?? <BuildingIcon className="shrink-0 text-navy" />}
|
|
144
|
+
<span className="min-w-0 flex-1 truncate text-sm font-medium text-slate-blue">
|
|
145
|
+
{displayLabel}
|
|
146
|
+
</span>
|
|
147
|
+
{chevronIcon ?? (
|
|
148
|
+
<ChevronsUpDown
|
|
149
|
+
size={16}
|
|
150
|
+
strokeWidth={1.75}
|
|
151
|
+
className={cn(
|
|
152
|
+
'shrink-0 text-storm-gray-100 transition-transform duration-200',
|
|
153
|
+
open && 'rotate-180',
|
|
154
|
+
)}
|
|
155
|
+
aria-hidden
|
|
156
|
+
/>
|
|
157
|
+
)}
|
|
158
|
+
</button>
|
|
159
|
+
|
|
160
|
+
{open ? (
|
|
161
|
+
<ul
|
|
162
|
+
id={listboxId}
|
|
163
|
+
role="listbox"
|
|
164
|
+
aria-label={labels.selectBuilding}
|
|
165
|
+
tabIndex={-1}
|
|
166
|
+
onKeyDown={handleListKeyDown}
|
|
167
|
+
className={cn(
|
|
168
|
+
'absolute top-[calc(100%+4px)] left-0 z-50 flex w-full flex-col gap-1 overflow-hidden rounded-xl border border-storm-gray-50 bg-white p-2 shadow-[0_8px_24px_rgba(21,26,30,0.08)]',
|
|
169
|
+
menuClassName,
|
|
170
|
+
)}
|
|
171
|
+
>
|
|
172
|
+
{options.map((option) => {
|
|
173
|
+
const selectableIndex = selectableOptions.findIndex(
|
|
174
|
+
(selectable) => selectable.value === option.value,
|
|
175
|
+
);
|
|
176
|
+
const isSelected = option.value === value;
|
|
177
|
+
const isHighlighted = selectableIndex === highlightedIndex;
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<li key={option.value} role="presentation">
|
|
181
|
+
<button
|
|
182
|
+
type="button"
|
|
183
|
+
role="option"
|
|
184
|
+
aria-selected={isSelected}
|
|
185
|
+
disabled={option.disabled}
|
|
186
|
+
onMouseEnter={() => {
|
|
187
|
+
if (!option.disabled && selectableIndex >= 0) {
|
|
188
|
+
setHighlightedIndex(selectableIndex);
|
|
189
|
+
}
|
|
190
|
+
}}
|
|
191
|
+
onClick={() => selectOption(option)}
|
|
192
|
+
className={cn(
|
|
193
|
+
'flex w-full rounded-lg px-3 py-2.5 text-left text-sm font-medium text-slate-blue transition-colors',
|
|
194
|
+
!option.disabled && 'cursor-pointer hover:bg-storm-gray-50',
|
|
195
|
+
(isSelected || isHighlighted) && !option.disabled && 'bg-storm-gray-50',
|
|
196
|
+
option.disabled && 'cursor-not-allowed opacity-50',
|
|
197
|
+
)}
|
|
198
|
+
>
|
|
199
|
+
<span className="truncate">{option.label}</span>
|
|
200
|
+
</button>
|
|
201
|
+
</li>
|
|
202
|
+
);
|
|
203
|
+
})}
|
|
204
|
+
</ul>
|
|
205
|
+
) : null}
|
|
206
|
+
</div>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { fn } from '@storybook/test';
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import { WebLayoutCanvas } from './story-helpers';
|
|
6
|
+
import { DashboardLayout } from './DashboardLayout';
|
|
7
|
+
import {
|
|
8
|
+
storyBranding,
|
|
9
|
+
storyBuildingOptions,
|
|
10
|
+
storyFooterNavItems,
|
|
11
|
+
storyLabels,
|
|
12
|
+
storyLogoIconDarkSrc,
|
|
13
|
+
storyNavItems,
|
|
14
|
+
storyUser,
|
|
15
|
+
} from './story-fixtures';
|
|
16
|
+
|
|
17
|
+
const meta = {
|
|
18
|
+
title: 'Web Layout/DashboardLayout',
|
|
19
|
+
parameters: {
|
|
20
|
+
layout: 'fullscreen',
|
|
21
|
+
webLayout: true,
|
|
22
|
+
},
|
|
23
|
+
} satisfies Meta;
|
|
24
|
+
|
|
25
|
+
export default meta;
|
|
26
|
+
type Story = StoryObj<typeof meta>;
|
|
27
|
+
|
|
28
|
+
function DashboardLayoutDemo() {
|
|
29
|
+
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
|
30
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
31
|
+
const [selectedBuildingId, setSelectedBuildingId] = useState(storyBuildingOptions[0].value);
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<WebLayoutCanvas>
|
|
35
|
+
<DashboardLayout
|
|
36
|
+
sidebar={{
|
|
37
|
+
navItems: storyNavItems,
|
|
38
|
+
footerNavItems: storyFooterNavItems,
|
|
39
|
+
branding: storyBranding,
|
|
40
|
+
labels: storyLabels.sidebar,
|
|
41
|
+
sidebarStorageKey: 'homepad.storybook.sidebar.open',
|
|
42
|
+
user: storyUser,
|
|
43
|
+
mobileOpen: mobileMenuOpen,
|
|
44
|
+
onMobileClose: () => setMobileMenuOpen(false),
|
|
45
|
+
onLogout: fn(),
|
|
46
|
+
onNavItemClick: () => setMobileMenuOpen(false),
|
|
47
|
+
}}
|
|
48
|
+
mobileHeader={{
|
|
49
|
+
branding: {
|
|
50
|
+
logoIconSrc: storyLogoIconDarkSrc,
|
|
51
|
+
logoTitle: storyBranding.logoTitle,
|
|
52
|
+
logoTagline: storyBranding.logoTagline,
|
|
53
|
+
},
|
|
54
|
+
openMenuLabel: storyLabels.sidebar.openMenu,
|
|
55
|
+
onOpenMenu: () => setMobileMenuOpen(true),
|
|
56
|
+
menuOpen: mobileMenuOpen,
|
|
57
|
+
}}
|
|
58
|
+
header={{
|
|
59
|
+
title: 'Dashboard',
|
|
60
|
+
labels: storyLabels.header,
|
|
61
|
+
buildingOptions: storyBuildingOptions,
|
|
62
|
+
selectedBuildingId,
|
|
63
|
+
onBuildingChange: setSelectedBuildingId,
|
|
64
|
+
className: 'hidden md:flex',
|
|
65
|
+
searchValue: searchQuery,
|
|
66
|
+
onSearchChange: setSearchQuery,
|
|
67
|
+
onNotificationsClick: fn(),
|
|
68
|
+
}}
|
|
69
|
+
>
|
|
70
|
+
<div className="rounded-xl border border-storm-gray-50 bg-white p-6">
|
|
71
|
+
<p className="text-sm text-storm-gray-500">Page content goes here.</p>
|
|
72
|
+
</div>
|
|
73
|
+
</DashboardLayout>
|
|
74
|
+
</WebLayoutCanvas>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const Default: Story = {
|
|
79
|
+
render: () => <DashboardLayoutDemo />,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const Mobile: Story = {
|
|
83
|
+
render: () => <DashboardLayoutDemo />,
|
|
84
|
+
parameters: {
|
|
85
|
+
viewport: { defaultViewport: 'mobile1' },
|
|
86
|
+
},
|
|
87
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import { AppHeader } from './AppHeader';
|
|
4
|
+
import type { AppHeaderProps } from './AppHeader';
|
|
5
|
+
import { Sidebar } from './Sidebar';
|
|
6
|
+
import type { SidebarProps } from './Sidebar';
|
|
7
|
+
import { SidebarMobileHeader } from './SidebarMobileHeader';
|
|
8
|
+
import type { SidebarMobileHeaderProps } from './SidebarMobileHeader';
|
|
9
|
+
|
|
10
|
+
export type DashboardLayoutProps = {
|
|
11
|
+
sidebar: SidebarProps;
|
|
12
|
+
header: AppHeaderProps;
|
|
13
|
+
mobileHeader: SidebarMobileHeaderProps;
|
|
14
|
+
children: ReactNode;
|
|
15
|
+
className?: string;
|
|
16
|
+
mainClassName?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function DashboardLayout({
|
|
20
|
+
sidebar,
|
|
21
|
+
header,
|
|
22
|
+
mobileHeader,
|
|
23
|
+
children,
|
|
24
|
+
className,
|
|
25
|
+
mainClassName,
|
|
26
|
+
}: DashboardLayoutProps) {
|
|
27
|
+
return (
|
|
28
|
+
<div className={className ?? 'flex min-h-screen bg-storm-gray-0'}>
|
|
29
|
+
<Sidebar {...sidebar} />
|
|
30
|
+
<div className="flex min-w-0 flex-1 flex-col">
|
|
31
|
+
<SidebarMobileHeader {...mobileHeader} />
|
|
32
|
+
<AppHeader {...header} />
|
|
33
|
+
<main className={mainClassName ?? 'flex-1 overflow-auto p-4 md:p-6'}>{children}</main>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { fn } from '@storybook/test';
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import { WebLayoutCanvas } from './story-helpers';
|
|
6
|
+
import { Sidebar, type SidebarProps } from './Sidebar';
|
|
7
|
+
import {
|
|
8
|
+
storyBranding,
|
|
9
|
+
storyFooterNavItems,
|
|
10
|
+
storyLabels,
|
|
11
|
+
storyNavItems,
|
|
12
|
+
storyUser,
|
|
13
|
+
} from './story-fixtures';
|
|
14
|
+
|
|
15
|
+
const meta = {
|
|
16
|
+
title: 'Web Layout/Sidebar',
|
|
17
|
+
component: Sidebar,
|
|
18
|
+
parameters: {
|
|
19
|
+
layout: 'fullscreen',
|
|
20
|
+
webLayout: true,
|
|
21
|
+
},
|
|
22
|
+
args: {
|
|
23
|
+
navItems: storyNavItems,
|
|
24
|
+
footerNavItems: storyFooterNavItems,
|
|
25
|
+
branding: storyBranding,
|
|
26
|
+
labels: storyLabels.sidebar,
|
|
27
|
+
sidebarStorageKey: 'homepad.storybook.sidebar.open',
|
|
28
|
+
user: storyUser,
|
|
29
|
+
mobileOpen: true,
|
|
30
|
+
onLogout: fn(),
|
|
31
|
+
onMobileClose: fn(),
|
|
32
|
+
onNavItemClick: fn(),
|
|
33
|
+
},
|
|
34
|
+
} satisfies Meta<typeof Sidebar>;
|
|
35
|
+
|
|
36
|
+
export default meta;
|
|
37
|
+
type Story = StoryObj<typeof meta>;
|
|
38
|
+
|
|
39
|
+
export const Expanded: Story = {
|
|
40
|
+
render: (args) => (
|
|
41
|
+
<WebLayoutCanvas>
|
|
42
|
+
<div className="flex min-h-screen bg-storm-gray-0">
|
|
43
|
+
<Sidebar {...args} mobileOpen={false} initialCollapsed={false} />
|
|
44
|
+
</div>
|
|
45
|
+
</WebLayoutCanvas>
|
|
46
|
+
),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const Collapsed: Story = {
|
|
50
|
+
render: (args) => (
|
|
51
|
+
<WebLayoutCanvas>
|
|
52
|
+
<div className="flex min-h-screen bg-storm-gray-0">
|
|
53
|
+
<Sidebar {...args} mobileOpen={false} initialCollapsed />
|
|
54
|
+
</div>
|
|
55
|
+
</WebLayoutCanvas>
|
|
56
|
+
),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function MobileDrawerStory(args: SidebarProps) {
|
|
60
|
+
const [mobileOpen, setMobileOpen] = useState(true);
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<WebLayoutCanvas>
|
|
64
|
+
<div className="relative min-h-screen bg-storm-gray-0">
|
|
65
|
+
<Sidebar
|
|
66
|
+
{...args}
|
|
67
|
+
mobileOpen={mobileOpen}
|
|
68
|
+
onMobileClose={() => setMobileOpen(false)}
|
|
69
|
+
/>
|
|
70
|
+
</div>
|
|
71
|
+
</WebLayoutCanvas>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const MobileDrawer: Story = {
|
|
76
|
+
render: (args) => <MobileDrawerStory {...args} />,
|
|
77
|
+
parameters: {
|
|
78
|
+
viewport: { defaultViewport: 'mobile1' },
|
|
79
|
+
},
|
|
80
|
+
};
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { PanelLeftClose, PanelLeftOpen, X } from 'lucide-react';
|
|
2
|
+
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
|
3
|
+
import { useLocation } from 'react-router-dom';
|
|
4
|
+
|
|
5
|
+
import { useMediaQuery } from '../hooks/useMediaQuery';
|
|
6
|
+
import { cn } from '../utils/cn';
|
|
7
|
+
import { SidebarNavItem } from './SidebarNavItem';
|
|
8
|
+
import { SidebarUserCard } from './SidebarUserCard';
|
|
9
|
+
import type { AdminNavItem, AdminSidebarBranding, AdminSidebarLabels, AdminSidebarUser } from './types';
|
|
10
|
+
|
|
11
|
+
export type SidebarProps = {
|
|
12
|
+
navItems: AdminNavItem[];
|
|
13
|
+
footerNavItems: AdminNavItem[];
|
|
14
|
+
branding: AdminSidebarBranding;
|
|
15
|
+
labels: AdminSidebarLabels;
|
|
16
|
+
sidebarStorageKey: string;
|
|
17
|
+
user?: AdminSidebarUser;
|
|
18
|
+
onLogout?: () => void;
|
|
19
|
+
className?: string;
|
|
20
|
+
mobileOpen: boolean;
|
|
21
|
+
onMobileClose?: () => void;
|
|
22
|
+
onNavItemClick?: (item: AdminNavItem) => void;
|
|
23
|
+
collapseIcon?: ReactNode;
|
|
24
|
+
expandIcon?: ReactNode;
|
|
25
|
+
closeIcon?: ReactNode;
|
|
26
|
+
initialCollapsed?: boolean;
|
|
27
|
+
collapsed?: boolean;
|
|
28
|
+
onCollapsedChange?: (collapsed: boolean) => void;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function readInitialCollapsedState(storageKey: string, initialCollapsed?: boolean): boolean {
|
|
32
|
+
if (initialCollapsed !== undefined) {
|
|
33
|
+
return initialCollapsed;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (typeof window === 'undefined') {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const stored = window.localStorage.getItem(storageKey);
|
|
41
|
+
if (stored === null) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return stored !== 'true';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function Sidebar({
|
|
49
|
+
navItems,
|
|
50
|
+
footerNavItems,
|
|
51
|
+
branding,
|
|
52
|
+
labels,
|
|
53
|
+
sidebarStorageKey,
|
|
54
|
+
user,
|
|
55
|
+
onLogout,
|
|
56
|
+
className,
|
|
57
|
+
mobileOpen,
|
|
58
|
+
onMobileClose,
|
|
59
|
+
onNavItemClick,
|
|
60
|
+
collapseIcon,
|
|
61
|
+
expandIcon,
|
|
62
|
+
closeIcon,
|
|
63
|
+
initialCollapsed,
|
|
64
|
+
collapsed,
|
|
65
|
+
onCollapsedChange,
|
|
66
|
+
}: SidebarProps) {
|
|
67
|
+
const location = useLocation();
|
|
68
|
+
const isDesktop = useMediaQuery('(min-width: 768px)');
|
|
69
|
+
const [internalCollapsed, setInternalCollapsed] = useState(() =>
|
|
70
|
+
readInitialCollapsedState(sidebarStorageKey, initialCollapsed),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const isCollapsed = collapsed ?? internalCollapsed;
|
|
74
|
+
const isExpanded = isDesktop ? !isCollapsed : true;
|
|
75
|
+
|
|
76
|
+
const setCollapsed = useCallback(
|
|
77
|
+
(next: boolean) => {
|
|
78
|
+
if (collapsed === undefined) {
|
|
79
|
+
setInternalCollapsed(next);
|
|
80
|
+
window.localStorage.setItem(sidebarStorageKey, String(!next));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
onCollapsedChange?.(next);
|
|
84
|
+
},
|
|
85
|
+
[collapsed, onCollapsedChange, sidebarStorageKey],
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const toggleCollapsed = useCallback(() => {
|
|
89
|
+
setCollapsed(!isCollapsed);
|
|
90
|
+
}, [isCollapsed, setCollapsed]);
|
|
91
|
+
|
|
92
|
+
const handleHeaderAction = useCallback(() => {
|
|
93
|
+
if (isDesktop) {
|
|
94
|
+
toggleCollapsed();
|
|
95
|
+
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
onMobileClose?.();
|
|
100
|
+
}, [isDesktop, onMobileClose, toggleCollapsed]);
|
|
101
|
+
|
|
102
|
+
const handleNavItemNavigate = useCallback(
|
|
103
|
+
(item: AdminNavItem) => {
|
|
104
|
+
onNavItemClick?.(item);
|
|
105
|
+
|
|
106
|
+
if (!isDesktop) {
|
|
107
|
+
onMobileClose?.();
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
[isDesktop, onMobileClose, onNavItemClick],
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const pathnameRef = useRef(location.pathname);
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (pathnameRef.current === location.pathname) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
pathnameRef.current = location.pathname;
|
|
121
|
+
|
|
122
|
+
if (!isDesktop) {
|
|
123
|
+
onMobileClose?.();
|
|
124
|
+
}
|
|
125
|
+
}, [location.pathname, isDesktop, onMobileClose]);
|
|
126
|
+
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
if (!mobileOpen || isDesktop) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const previousOverflow = document.body.style.overflow;
|
|
133
|
+
document.body.style.overflow = 'hidden';
|
|
134
|
+
|
|
135
|
+
return () => {
|
|
136
|
+
document.body.style.overflow = previousOverflow;
|
|
137
|
+
};
|
|
138
|
+
}, [mobileOpen, isDesktop]);
|
|
139
|
+
|
|
140
|
+
const visibleNavItems = navItems.filter((item) => !item.hidden);
|
|
141
|
+
const visibleFooterNavItems = footerNavItems.filter((item) => !item.hidden);
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<>
|
|
145
|
+
<button
|
|
146
|
+
type="button"
|
|
147
|
+
aria-label={labels.closeMenu}
|
|
148
|
+
onClick={onMobileClose}
|
|
149
|
+
className={cn(
|
|
150
|
+
'fixed inset-0 z-40 bg-navy-800/60 backdrop-blur-[1px] transition-opacity duration-300 ease-in-out md:hidden',
|
|
151
|
+
mobileOpen ? 'opacity-100' : 'pointer-events-none opacity-0',
|
|
152
|
+
)}
|
|
153
|
+
/>
|
|
154
|
+
|
|
155
|
+
<aside
|
|
156
|
+
className={cn(
|
|
157
|
+
'fixed inset-y-0 left-0 z-50 flex h-screen w-[min(300px,85vw)] flex-col gap-8 overflow-hidden bg-navy p-4 shadow-xl',
|
|
158
|
+
'transition-[transform,width,padding,gap] duration-300 ease-in-out will-change-[transform,width]',
|
|
159
|
+
mobileOpen ? 'translate-x-0' : '-translate-x-full',
|
|
160
|
+
'md:relative md:z-auto md:w-[300px] md:translate-x-0 md:shadow-none',
|
|
161
|
+
isExpanded ? 'md:w-[300px]' : 'md:w-[76px] md:items-center md:gap-2 md:px-3 md:py-4',
|
|
162
|
+
className,
|
|
163
|
+
)}
|
|
164
|
+
aria-hidden={!isDesktop && !mobileOpen}
|
|
165
|
+
>
|
|
166
|
+
<div
|
|
167
|
+
className={cn(
|
|
168
|
+
'flex w-full items-center transition-[justify-content] duration-300 ease-in-out',
|
|
169
|
+
isExpanded ? 'justify-between' : 'justify-center',
|
|
170
|
+
)}
|
|
171
|
+
>
|
|
172
|
+
<div
|
|
173
|
+
className={cn(
|
|
174
|
+
'flex min-w-0 items-center gap-1.5 overflow-hidden transition-[max-width,opacity] duration-300 ease-in-out',
|
|
175
|
+
isExpanded ? 'max-w-[240px] opacity-100' : 'max-w-0 opacity-0',
|
|
176
|
+
)}
|
|
177
|
+
>
|
|
178
|
+
<img src={branding.logoIconSrc} alt="" className="h-5 w-[29px] shrink-0" />
|
|
179
|
+
<div className="flex min-w-0 flex-col leading-none">
|
|
180
|
+
<span className="text-[21px] font-bold tracking-tight whitespace-nowrap text-white">
|
|
181
|
+
{branding.logoTitle}
|
|
182
|
+
</span>
|
|
183
|
+
<span className="text-[6.5px] font-normal tracking-[-0.26px] text-navy-300 uppercase">
|
|
184
|
+
{branding.logoTagline}
|
|
185
|
+
</span>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
<button
|
|
189
|
+
type="button"
|
|
190
|
+
onClick={handleHeaderAction}
|
|
191
|
+
className="shrink-0 cursor-pointer text-white transition-[opacity,transform] duration-200 hover:opacity-80 active:scale-95"
|
|
192
|
+
aria-label={
|
|
193
|
+
isDesktop
|
|
194
|
+
? isExpanded
|
|
195
|
+
? labels.collapse
|
|
196
|
+
: labels.expand
|
|
197
|
+
: labels.closeMenu
|
|
198
|
+
}
|
|
199
|
+
>
|
|
200
|
+
{isDesktop ? (
|
|
201
|
+
isExpanded ? (
|
|
202
|
+
collapseIcon ?? <PanelLeftClose size={20} strokeWidth={1.75} />
|
|
203
|
+
) : (
|
|
204
|
+
expandIcon ?? <PanelLeftOpen size={20} strokeWidth={1.75} />
|
|
205
|
+
)
|
|
206
|
+
) : (
|
|
207
|
+
closeIcon ?? <X size={20} strokeWidth={1.75} />
|
|
208
|
+
)}
|
|
209
|
+
</button>
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
<nav className="flex flex-1 flex-col gap-2 overflow-y-auto overflow-x-hidden">
|
|
213
|
+
{visibleNavItems.map((item) => (
|
|
214
|
+
<SidebarNavItem
|
|
215
|
+
key={item.to}
|
|
216
|
+
item={item}
|
|
217
|
+
isOpen={isExpanded}
|
|
218
|
+
onNavigate={handleNavItemNavigate}
|
|
219
|
+
/>
|
|
220
|
+
))}
|
|
221
|
+
</nav>
|
|
222
|
+
|
|
223
|
+
<div className="flex w-full flex-col gap-4">
|
|
224
|
+
{visibleFooterNavItems.map((item) => (
|
|
225
|
+
<SidebarNavItem
|
|
226
|
+
key={item.to}
|
|
227
|
+
item={item}
|
|
228
|
+
isOpen={isExpanded}
|
|
229
|
+
onNavigate={handleNavItemNavigate}
|
|
230
|
+
/>
|
|
231
|
+
))}
|
|
232
|
+
{user ? (
|
|
233
|
+
<SidebarUserCard
|
|
234
|
+
user={user}
|
|
235
|
+
isOpen={isExpanded}
|
|
236
|
+
logoutLabel={labels.logout}
|
|
237
|
+
onLogout={onLogout}
|
|
238
|
+
/>
|
|
239
|
+
) : null}
|
|
240
|
+
</div>
|
|
241
|
+
</aside>
|
|
242
|
+
</>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { fn } from '@storybook/test';
|
|
3
|
+
|
|
4
|
+
import { WebLayoutCanvas } from './story-helpers';
|
|
5
|
+
import { SidebarMobileHeader } from './SidebarMobileHeader';
|
|
6
|
+
import { storyBranding, storyLabels, storyLogoIconDarkSrc } from './story-fixtures';
|
|
7
|
+
|
|
8
|
+
const meta = {
|
|
9
|
+
title: 'Web Layout/SidebarMobileHeader',
|
|
10
|
+
component: SidebarMobileHeader,
|
|
11
|
+
parameters: {
|
|
12
|
+
layout: 'fullscreen',
|
|
13
|
+
webLayout: true,
|
|
14
|
+
},
|
|
15
|
+
args: {
|
|
16
|
+
branding: {
|
|
17
|
+
logoIconSrc: storyLogoIconDarkSrc,
|
|
18
|
+
logoTitle: storyBranding.logoTitle,
|
|
19
|
+
logoTagline: storyBranding.logoTagline,
|
|
20
|
+
},
|
|
21
|
+
openMenuLabel: storyLabels.sidebar.openMenu,
|
|
22
|
+
onOpenMenu: fn(),
|
|
23
|
+
menuOpen: false,
|
|
24
|
+
},
|
|
25
|
+
} satisfies Meta<typeof SidebarMobileHeader>;
|
|
26
|
+
|
|
27
|
+
export default meta;
|
|
28
|
+
type Story = StoryObj<typeof meta>;
|
|
29
|
+
|
|
30
|
+
export const Default: Story = {
|
|
31
|
+
render: (args) => (
|
|
32
|
+
<WebLayoutCanvas>
|
|
33
|
+
<SidebarMobileHeader {...args} />
|
|
34
|
+
</WebLayoutCanvas>
|
|
35
|
+
),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const MenuOpen: Story = {
|
|
39
|
+
args: {
|
|
40
|
+
menuOpen: true,
|
|
41
|
+
},
|
|
42
|
+
render: (args) => (
|
|
43
|
+
<WebLayoutCanvas>
|
|
44
|
+
<SidebarMobileHeader {...args} />
|
|
45
|
+
</WebLayoutCanvas>
|
|
46
|
+
),
|
|
47
|
+
};
|