@scripso-homepad/ui 0.3.8 → 0.4.0

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.
Files changed (37) hide show
  1. package/dist/chunk-C7GHBVMM.js +614 -0
  2. package/dist/chunk-C7GHBVMM.js.map +1 -0
  3. package/dist/index.cjs +33 -22
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +5 -1
  6. package/dist/index.d.ts +5 -1
  7. package/dist/index.js +29 -624
  8. package/dist/index.js.map +1 -1
  9. package/dist/web/index.cjs +1211 -0
  10. package/dist/web/index.cjs.map +1 -0
  11. package/dist/web/index.d.cts +156 -0
  12. package/dist/web/index.d.ts +156 -0
  13. package/dist/web/index.js +761 -0
  14. package/dist/web/index.js.map +1 -0
  15. package/package.json +26 -2
  16. package/src/components/Input.tsx +12 -1
  17. package/src/web/hooks/useMediaQuery.ts +23 -0
  18. package/src/web/hooks/useOnClickOutside.ts +30 -0
  19. package/src/web/icons/BellIcon.tsx +27 -0
  20. package/src/web/icons/BuildingIcon.tsx +55 -0
  21. package/src/web/index.ts +37 -0
  22. package/src/web/layout/AppHeader.stories.tsx +85 -0
  23. package/src/web/layout/AppHeader.tsx +115 -0
  24. package/src/web/layout/BuildingSelect.stories.tsx +60 -0
  25. package/src/web/layout/BuildingSelect.tsx +208 -0
  26. package/src/web/layout/DashboardLayout.stories.tsx +87 -0
  27. package/src/web/layout/DashboardLayout.tsx +37 -0
  28. package/src/web/layout/Sidebar.stories.tsx +80 -0
  29. package/src/web/layout/Sidebar.tsx +244 -0
  30. package/src/web/layout/SidebarMobileHeader.stories.tsx +47 -0
  31. package/src/web/layout/SidebarMobileHeader.tsx +48 -0
  32. package/src/web/layout/SidebarNavItem.tsx +60 -0
  33. package/src/web/layout/SidebarUserCard.tsx +79 -0
  34. package/src/web/layout/story-fixtures.tsx +93 -0
  35. package/src/web/layout/story-helpers.tsx +5 -0
  36. package/src/web/layout/types.ts +48 -0
  37. package/src/web/utils/cn.ts +6 -0
@@ -0,0 +1,60 @@
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 { BuildingSelect, type BuildingSelectProps } from './BuildingSelect';
7
+ import { storyBuildingOptions, storyLabels } from './story-fixtures';
8
+
9
+ const meta = {
10
+ title: 'Web Layout/BuildingSelect',
11
+ component: BuildingSelect,
12
+ parameters: {
13
+ layout: 'centered',
14
+ webLayout: true,
15
+ },
16
+ args: {
17
+ options: storyBuildingOptions,
18
+ value: storyBuildingOptions[0].value,
19
+ labels: storyLabels.header,
20
+ onChange: fn(),
21
+ },
22
+ } satisfies Meta<typeof BuildingSelect>;
23
+
24
+ export default meta;
25
+ type Story = StoryObj<typeof meta>;
26
+
27
+ export const Default: Story = {
28
+ render: (args) => (
29
+ <WebLayoutCanvas>
30
+ <BuildingSelect {...args} />
31
+ </WebLayoutCanvas>
32
+ ),
33
+ };
34
+
35
+ function InteractiveBuildingSelectStory(args: BuildingSelectProps) {
36
+ const [value, setValue] = useState(args.value);
37
+
38
+ return (
39
+ <WebLayoutCanvas>
40
+ <div className="pb-64 pt-2">
41
+ <BuildingSelect {...args} value={value} onChange={setValue} />
42
+ </div>
43
+ </WebLayoutCanvas>
44
+ );
45
+ }
46
+
47
+ export const Interactive: Story = {
48
+ render: (args) => <InteractiveBuildingSelectStory {...args} />,
49
+ };
50
+
51
+ export const Disabled: Story = {
52
+ args: {
53
+ disabled: true,
54
+ },
55
+ render: (args) => (
56
+ <WebLayoutCanvas>
57
+ <BuildingSelect {...args} />
58
+ </WebLayoutCanvas>
59
+ ),
60
+ };
@@ -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
+ };