@uniweb/kit 0.1.4 → 0.1.6

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 (31) hide show
  1. package/package.json +3 -2
  2. package/src/components/Asset/Asset.jsx +31 -81
  3. package/src/components/Media/Media.jsx +27 -125
  4. package/src/components/SocialIcon/index.jsx +146 -0
  5. package/src/hooks/index.js +7 -0
  6. package/src/hooks/useAccordion.js +143 -0
  7. package/src/hooks/useActiveRoute.js +97 -0
  8. package/src/hooks/useGridLayout.js +71 -0
  9. package/src/hooks/useMobileMenu.js +58 -0
  10. package/src/hooks/useRouting.js +119 -0
  11. package/src/hooks/useScrolled.js +48 -0
  12. package/src/hooks/useTheme.js +205 -0
  13. package/src/index.js +29 -10
  14. package/src/styled/Asset/Asset.jsx +161 -0
  15. package/src/styled/Asset/index.js +1 -0
  16. package/src/{components → styled}/Disclaimer/Disclaimer.jsx +1 -1
  17. package/src/styled/Media/Media.jsx +322 -0
  18. package/src/styled/Media/index.js +1 -0
  19. package/src/{components → styled}/Section/Render.jsx +4 -4
  20. package/src/{components → styled}/Section/index.js +6 -0
  21. package/src/{components → styled}/Section/renderers/Alert.jsx +1 -1
  22. package/src/{components → styled}/Section/renderers/Details.jsx +1 -1
  23. package/src/{components → styled}/Section/renderers/Table.jsx +1 -1
  24. package/src/{components → styled}/Section/renderers/index.js +1 -1
  25. package/src/styled/SidebarLayout/SidebarLayout.jsx +310 -0
  26. package/src/styled/SidebarLayout/index.js +1 -0
  27. package/src/styled/index.js +40 -0
  28. /package/src/{components → styled}/Disclaimer/index.js +0 -0
  29. /package/src/{components → styled}/Section/Section.jsx +0 -0
  30. /package/src/{components → styled}/Section/renderers/Code.jsx +0 -0
  31. /package/src/{components → styled}/Section/renderers/Divider.jsx +0 -0
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Media Component
3
+ *
4
+ * Video player supporting:
5
+ * - YouTube embeds
6
+ * - Vimeo embeds
7
+ * - Local/direct video files
8
+ * - Thumbnail facades
9
+ * - Playback tracking
10
+ *
11
+ * @module @uniweb/kit/Media
12
+ */
13
+
14
+ import React, { useState, useEffect, useRef, useCallback } from 'react'
15
+ import { cn } from '../../utils/index.js'
16
+ import { detectMediaType } from '../../utils/index.js'
17
+
18
+ /**
19
+ * Extract YouTube video ID from URL
20
+ * @param {string} url
21
+ * @returns {string|null}
22
+ */
23
+ function getYouTubeId(url) {
24
+ if (!url) return null
25
+ const match = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^&?/]+)/)
26
+ return match?.[1] || null
27
+ }
28
+
29
+ /**
30
+ * Extract Vimeo video ID from URL
31
+ * @param {string} url
32
+ * @returns {string|null}
33
+ */
34
+ function getVimeoId(url) {
35
+ if (!url) return null
36
+ const match = url.match(/vimeo\.com\/(?:video\/)?(\d+)/)
37
+ return match?.[1] || null
38
+ }
39
+
40
+ /**
41
+ * Get thumbnail URL for a video
42
+ * @param {string} src - Video URL
43
+ * @param {string} type - Media type
44
+ * @returns {string|null}
45
+ */
46
+ function getVideoThumbnail(src, type) {
47
+ if (type === 'youtube') {
48
+ const id = getYouTubeId(src)
49
+ return id ? `https://img.youtube.com/vi/${id}/maxresdefault.jpg` : null
50
+ }
51
+ // Vimeo requires API call, return null for now
52
+ return null
53
+ }
54
+
55
+ /**
56
+ * YouTube Player Component
57
+ */
58
+ function YouTubePlayer({ videoId, autoplay, muted, loop, onReady, onStateChange, className }) {
59
+ const iframeRef = useRef(null)
60
+
61
+ const params = new URLSearchParams({
62
+ enablejsapi: '1',
63
+ autoplay: autoplay ? '1' : '0',
64
+ mute: muted ? '1' : '0',
65
+ loop: loop ? '1' : '0',
66
+ playlist: loop ? videoId : '',
67
+ rel: '0',
68
+ modestbranding: '1'
69
+ })
70
+
71
+ return (
72
+ <iframe
73
+ ref={iframeRef}
74
+ src={`https://www.youtube.com/embed/${videoId}?${params}`}
75
+ className={cn('w-full h-full', className)}
76
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
77
+ allowFullScreen
78
+ title="YouTube video"
79
+ />
80
+ )
81
+ }
82
+
83
+ /**
84
+ * Vimeo Player Component
85
+ */
86
+ function VimeoPlayer({ videoId, autoplay, muted, loop, className }) {
87
+ const params = new URLSearchParams({
88
+ autoplay: autoplay ? '1' : '0',
89
+ muted: muted ? '1' : '0',
90
+ loop: loop ? '1' : '0',
91
+ dnt: '1'
92
+ })
93
+
94
+ return (
95
+ <iframe
96
+ src={`https://player.vimeo.com/video/${videoId}?${params}`}
97
+ className={cn('w-full h-full', className)}
98
+ allow="autoplay; fullscreen; picture-in-picture"
99
+ allowFullScreen
100
+ title="Vimeo video"
101
+ />
102
+ )
103
+ }
104
+
105
+ /**
106
+ * Local/Direct Video Player Component
107
+ */
108
+ function LocalVideo({ src, autoplay, muted, loop, controls, poster, onProgress, className }) {
109
+ const videoRef = useRef(null)
110
+ const [milestones, setMilestones] = useState({ 25: false, 50: false, 75: false, 95: false })
111
+
112
+ useEffect(() => {
113
+ const video = videoRef.current
114
+ if (!video || !onProgress) return
115
+
116
+ const handleTimeUpdate = () => {
117
+ const percent = (video.currentTime / video.duration) * 100
118
+
119
+ Object.entries({ 25: 25, 50: 50, 75: 75, 95: 95 }).forEach(([key, threshold]) => {
120
+ if (percent >= threshold && !milestones[key]) {
121
+ setMilestones((prev) => ({ ...prev, [key]: true }))
122
+ onProgress({ milestone: key, percent, currentTime: video.currentTime })
123
+ }
124
+ })
125
+ }
126
+
127
+ video.addEventListener('timeupdate', handleTimeUpdate)
128
+ return () => video.removeEventListener('timeupdate', handleTimeUpdate)
129
+ }, [milestones, onProgress])
130
+
131
+ return (
132
+ <video
133
+ ref={videoRef}
134
+ src={src}
135
+ autoPlay={autoplay}
136
+ muted={muted}
137
+ loop={loop}
138
+ controls={controls}
139
+ poster={poster}
140
+ playsInline
141
+ className={cn('w-full h-full object-cover', className)}
142
+ />
143
+ )
144
+ }
145
+
146
+ /**
147
+ * Play Button Overlay
148
+ */
149
+ function PlayButton({ onClick, className }) {
150
+ return (
151
+ <button
152
+ onClick={onClick}
153
+ className={cn(
154
+ 'absolute inset-0 flex items-center justify-center',
155
+ 'bg-black/30 hover:bg-black/40 transition-colors',
156
+ 'group cursor-pointer',
157
+ className
158
+ )}
159
+ aria-label="Play video"
160
+ >
161
+ <div className="w-16 h-16 rounded-full bg-white/90 group-hover:bg-white flex items-center justify-center transition-colors">
162
+ <svg className="w-8 h-8 text-gray-900 ml-1" fill="currentColor" viewBox="0 0 24 24">
163
+ <path d="M8 5v14l11-7z" />
164
+ </svg>
165
+ </div>
166
+ </button>
167
+ )
168
+ }
169
+
170
+ /**
171
+ * Media - Video player component
172
+ *
173
+ * @param {Object} props
174
+ * @param {string|Object} props.src - Video URL or media object
175
+ * @param {Object} [props.media] - Media object with src/caption
176
+ * @param {string} [props.thumbnail] - Thumbnail URL
177
+ * @param {boolean} [props.autoplay=false] - Auto-play video
178
+ * @param {boolean} [props.muted=false] - Mute video
179
+ * @param {boolean} [props.loop=false] - Loop video
180
+ * @param {boolean} [props.controls=true] - Show controls
181
+ * @param {boolean} [props.facade=false] - Show thumbnail with play button
182
+ * @param {string} [props.aspectRatio='16/9'] - Aspect ratio
183
+ * @param {string} [props.className] - Additional CSS classes
184
+ * @param {Function} [props.onProgress] - Progress callback for tracking
185
+ * @param {Object} [props.block] - Block object for event tracking
186
+ *
187
+ * @example
188
+ * // YouTube video
189
+ * <Media src="https://youtube.com/watch?v=abc123" />
190
+ *
191
+ * @example
192
+ * // With thumbnail facade
193
+ * <Media
194
+ * src="https://youtube.com/watch?v=abc123"
195
+ * thumbnail="/images/video-poster.jpg"
196
+ * facade
197
+ * />
198
+ *
199
+ * @example
200
+ * // Local video
201
+ * <Media src="/videos/intro.mp4" controls autoplay={false} />
202
+ */
203
+ export function Media({
204
+ src,
205
+ media,
206
+ thumbnail,
207
+ autoplay = false,
208
+ muted = false,
209
+ loop = false,
210
+ controls = true,
211
+ facade = false,
212
+ aspectRatio = '16/9',
213
+ className,
214
+ onProgress,
215
+ block,
216
+ ...props
217
+ }) {
218
+ const [showVideo, setShowVideo] = useState(!facade)
219
+
220
+ // Normalize source
221
+ const videoSrc = typeof src === 'string' ? src : (src?.src || media?.src || '')
222
+ const caption = media?.caption || src?.caption || ''
223
+
224
+ // Detect video type
225
+ const mediaType = detectMediaType(videoSrc)
226
+
227
+ // Get thumbnail
228
+ const thumbnailSrc = thumbnail || getVideoThumbnail(videoSrc, mediaType)
229
+
230
+ // Handle play click (for facade mode)
231
+ const handlePlay = useCallback(() => {
232
+ setShowVideo(true)
233
+ }, [])
234
+
235
+ // Handle progress tracking
236
+ const handleProgress = useCallback((data) => {
237
+ onProgress?.(data)
238
+
239
+ // Track via block if available
240
+ if (block?.trackEvent && typeof window !== 'undefined' && window.uniweb?.analytics?.initialized) {
241
+ block.trackEvent(`video_milestone_${data.milestone}`, {
242
+ milestone: `${data.milestone}%`,
243
+ src: videoSrc
244
+ })
245
+ }
246
+ }, [onProgress, block, videoSrc])
247
+
248
+ // Render facade (thumbnail with play button)
249
+ if (facade && !showVideo && thumbnailSrc) {
250
+ return (
251
+ <div
252
+ className={cn('relative overflow-hidden', className)}
253
+ style={{ aspectRatio }}
254
+ {...props}
255
+ >
256
+ <img
257
+ src={thumbnailSrc}
258
+ alt={caption || 'Video thumbnail'}
259
+ className="w-full h-full object-cover"
260
+ />
261
+ <PlayButton onClick={handlePlay} />
262
+ </div>
263
+ )
264
+ }
265
+
266
+ // Render video player
267
+ const videoContent = (() => {
268
+ switch (mediaType) {
269
+ case 'youtube': {
270
+ const videoId = getYouTubeId(videoSrc)
271
+ if (!videoId) return null
272
+ return (
273
+ <YouTubePlayer
274
+ videoId={videoId}
275
+ autoplay={autoplay || (facade && showVideo)}
276
+ muted={muted}
277
+ loop={loop}
278
+ />
279
+ )
280
+ }
281
+
282
+ case 'vimeo': {
283
+ const videoId = getVimeoId(videoSrc)
284
+ if (!videoId) return null
285
+ return (
286
+ <VimeoPlayer
287
+ videoId={videoId}
288
+ autoplay={autoplay || (facade && showVideo)}
289
+ muted={muted}
290
+ loop={loop}
291
+ />
292
+ )
293
+ }
294
+
295
+ case 'video':
296
+ default:
297
+ return (
298
+ <LocalVideo
299
+ src={videoSrc}
300
+ autoplay={autoplay || (facade && showVideo)}
301
+ muted={muted}
302
+ loop={loop}
303
+ controls={controls}
304
+ poster={thumbnailSrc}
305
+ onProgress={handleProgress}
306
+ />
307
+ )
308
+ }
309
+ })()
310
+
311
+ return (
312
+ <div
313
+ className={cn('relative overflow-hidden bg-black', className)}
314
+ style={{ aspectRatio }}
315
+ {...props}
316
+ >
317
+ {videoContent}
318
+ </div>
319
+ )
320
+ }
321
+
322
+ export default Media
@@ -0,0 +1 @@
1
+ export { Media, default } from './Media.jsx'
@@ -9,10 +9,10 @@
9
9
 
10
10
  import React from 'react'
11
11
  import { cn } from '../../utils/index.js'
12
- import { SafeHtml } from '../SafeHtml/index.js'
13
- import { Image } from '../Image/index.js'
14
- import { Media } from '../Media/index.js'
15
- import { Link } from '../Link/index.js'
12
+ import { SafeHtml } from '../../components/SafeHtml/index.js'
13
+ import { Image } from '../../components/Image/index.js'
14
+ import { Media } from '../../components/Media/index.js'
15
+ import { Link } from '../../components/Link/index.js'
16
16
  import { Code } from './renderers/Code.jsx'
17
17
  import { Alert } from './renderers/Alert.jsx'
18
18
  import { Table } from './renderers/Table.jsx'
@@ -1,3 +1,9 @@
1
+ /**
2
+ * Section Component (Tailwind-styled)
3
+ *
4
+ * Rich content section renderer with full styling.
5
+ */
6
+
1
7
  export { Section, default } from './Section.jsx'
2
8
  export { Render } from './Render.jsx'
3
9
  export * from './renderers/index.js'
@@ -8,7 +8,7 @@
8
8
 
9
9
  import React from 'react'
10
10
  import { cn } from '../../../utils/index.js'
11
- import { SafeHtml } from '../../SafeHtml/index.js'
11
+ import { SafeHtml } from '../../../components/SafeHtml/index.js'
12
12
 
13
13
  /**
14
14
  * Alert type configurations
@@ -8,7 +8,7 @@
8
8
 
9
9
  import React, { useState } from 'react'
10
10
  import { cn } from '../../../utils/index.js'
11
- import { SafeHtml } from '../../SafeHtml/index.js'
11
+ import { SafeHtml } from '../../../components/SafeHtml/index.js'
12
12
 
13
13
  /**
14
14
  * Details - Collapsible section
@@ -8,7 +8,7 @@
8
8
 
9
9
  import React from 'react'
10
10
  import { cn } from '../../../utils/index.js'
11
- import { SafeHtml } from '../../SafeHtml/index.js'
11
+ import { SafeHtml } from '../../../components/SafeHtml/index.js'
12
12
 
13
13
  /**
14
14
  * Table - Table renderer
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Section Renderers
2
+ * Section Renderers (Tailwind-styled)
3
3
  *
4
4
  * Individual content type renderers for the Section component.
5
5
  */
@@ -0,0 +1,310 @@
1
+ import React, { useEffect } from 'react'
2
+ import { cn } from '../../utils/index.js'
3
+ import { useMobileMenu } from '../../hooks/useMobileMenu.js'
4
+
5
+ /**
6
+ * SidebarLayout Component
7
+ *
8
+ * A flexible layout with optional left and/or right sidebars.
9
+ * On desktop, sidebars appear inline at their configured breakpoints.
10
+ * On mobile, the left panel is accessible via a slide-out drawer with FAB toggle.
11
+ * The right panel is hidden on mobile (common pattern: nav essential, TOC optional).
12
+ *
13
+ * The layout is "sandwiched" - header and footer span the full width,
14
+ * with the sidebars and content area between them.
15
+ *
16
+ * @example
17
+ * // In foundation's src/exports.js - use as-is
18
+ * import { SidebarLayout } from '@uniweb/kit'
19
+ *
20
+ * export default {
21
+ * Layout: SidebarLayout,
22
+ * }
23
+ *
24
+ * @example
25
+ * // With custom configuration
26
+ * import { SidebarLayout } from '@uniweb/kit'
27
+ *
28
+ * function CustomLayout(props) {
29
+ * return (
30
+ * <SidebarLayout
31
+ * {...props}
32
+ * leftBreakpoint="lg"
33
+ * rightBreakpoint="xl"
34
+ * leftWidth="w-72"
35
+ * />
36
+ * )
37
+ * }
38
+ *
39
+ * export default { Layout: CustomLayout }
40
+ */
41
+
42
+ /**
43
+ * Hamburger menu icon
44
+ */
45
+ function MenuIcon({ className }) {
46
+ return (
47
+ <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
48
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
49
+ </svg>
50
+ )
51
+ }
52
+
53
+ /**
54
+ * Close (X) icon
55
+ */
56
+ function CloseIcon({ className }) {
57
+ return (
58
+ <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
59
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
60
+ </svg>
61
+ )
62
+ }
63
+
64
+ /**
65
+ * Mobile drawer component (always slides from left)
66
+ */
67
+ function MobileDrawer({ isOpen, onClose, width, stickyHeader, children }) {
68
+ // Prevent body scroll when drawer is open
69
+ useEffect(() => {
70
+ if (isOpen) {
71
+ document.body.style.overflow = 'hidden'
72
+ return () => {
73
+ document.body.style.overflow = ''
74
+ }
75
+ }
76
+ }, [isOpen])
77
+
78
+ // Position below header if sticky, otherwise from top
79
+ const topOffset = stickyHeader ? 'top-16' : 'top-0'
80
+ const height = stickyHeader ? 'h-[calc(100vh-4rem)]' : 'h-screen'
81
+
82
+ return (
83
+ <>
84
+ {/* Backdrop */}
85
+ <div
86
+ className={cn(
87
+ 'fixed inset-0 bg-black/50 z-40 transition-opacity duration-300',
88
+ isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
89
+ )}
90
+ onClick={onClose}
91
+ aria-hidden="true"
92
+ />
93
+
94
+ {/* Drawer (always from left) */}
95
+ <div
96
+ className={cn(
97
+ 'fixed left-0 bg-white z-50 shadow-xl',
98
+ topOffset,
99
+ height,
100
+ width,
101
+ 'transform transition-transform duration-300 ease-in-out',
102
+ isOpen ? 'translate-x-0' : '-translate-x-full'
103
+ )}
104
+ role="dialog"
105
+ aria-modal="true"
106
+ aria-label="Sidebar navigation"
107
+ >
108
+ {/* Close button */}
109
+ <button
110
+ onClick={onClose}
111
+ className="absolute top-4 right-4 p-1.5 rounded-md hover:bg-gray-100 transition-colors"
112
+ aria-label="Close sidebar"
113
+ >
114
+ <CloseIcon className="w-5 h-5 text-gray-500" />
115
+ </button>
116
+
117
+ {/* Drawer content */}
118
+ <div className="h-full overflow-y-auto overscroll-contain">
119
+ {children}
120
+ </div>
121
+ </div>
122
+ </>
123
+ )
124
+ }
125
+
126
+ /**
127
+ * Floating action button for mobile menu (always bottom-left)
128
+ */
129
+ function FloatingMenuButton({ onClick }) {
130
+ return (
131
+ <button
132
+ onClick={onClick}
133
+ className={cn(
134
+ 'fixed bottom-4 left-4 z-30',
135
+ 'p-3 bg-primary text-white rounded-full shadow-lg',
136
+ 'hover:bg-primary/90 active:scale-95 transition-all',
137
+ 'focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2'
138
+ )}
139
+ aria-label="Open navigation menu"
140
+ >
141
+ <MenuIcon className="w-6 h-6" />
142
+ </button>
143
+ )
144
+ }
145
+
146
+ /**
147
+ * Get responsive classes for showing/hiding at breakpoint
148
+ */
149
+ function getBreakpointClasses(breakpoint) {
150
+ const showClass = {
151
+ sm: 'sm:block',
152
+ md: 'md:block',
153
+ lg: 'lg:block',
154
+ xl: 'xl:block',
155
+ }[breakpoint] || 'md:block'
156
+
157
+ const hideClass = {
158
+ sm: 'sm:hidden',
159
+ md: 'md:hidden',
160
+ lg: 'lg:hidden',
161
+ xl: 'xl:hidden',
162
+ }[breakpoint] || 'md:hidden'
163
+
164
+ return { showClass, hideClass }
165
+ }
166
+
167
+ /**
168
+ * SidebarLayout main component
169
+ *
170
+ * @param {Object} props
171
+ * @param {React.ReactNode} props.header - Header content (from @header sections)
172
+ * @param {React.ReactNode} props.body - Main body content (page sections)
173
+ * @param {React.ReactNode} props.footer - Footer content (from @footer sections)
174
+ * @param {React.ReactNode} props.left - Left panel content (from @left sections)
175
+ * @param {React.ReactNode} props.right - Right panel content (from @right sections)
176
+ * @param {React.ReactNode} props.leftPanel - Alias for left (backwards compatibility)
177
+ * @param {React.ReactNode} props.rightPanel - Alias for right (backwards compatibility)
178
+ * @param {string} [props.leftWidth='w-64'] - Tailwind width class for left sidebar
179
+ * @param {string} [props.rightWidth='w-64'] - Tailwind width class for right sidebar
180
+ * @param {string} [props.drawerWidth='w-72'] - Tailwind width class for mobile drawer
181
+ * @param {string} [props.leftBreakpoint='md'] - Breakpoint for showing left sidebar inline
182
+ * @param {string} [props.rightBreakpoint='xl'] - Breakpoint for showing right sidebar inline
183
+ * @param {boolean} [props.stickyHeader=true] - Whether header sticks to top
184
+ * @param {boolean} [props.stickySidebar=true] - Whether sidebars stick below header
185
+ * @param {string} [props.maxWidth='max-w-7xl'] - Max width of content area
186
+ * @param {string} [props.contentPadding='px-4 py-8 sm:px-6 lg:px-8'] - Padding for main content
187
+ * @param {string} [props.className] - Additional classes for the root element
188
+ */
189
+ export function SidebarLayout({
190
+ // Pre-rendered layout areas from runtime
191
+ header,
192
+ body,
193
+ footer,
194
+ left,
195
+ right,
196
+ leftPanel,
197
+ rightPanel,
198
+ // Configuration
199
+ leftWidth = 'w-64',
200
+ rightWidth = 'w-64',
201
+ drawerWidth = 'w-72',
202
+ leftBreakpoint = 'md',
203
+ rightBreakpoint = 'xl',
204
+ stickyHeader = true,
205
+ stickySidebar = true,
206
+ maxWidth = 'max-w-7xl',
207
+ contentPadding = 'px-4 py-8 sm:px-6 lg:px-8',
208
+ className,
209
+ }) {
210
+ const { isOpen, open, close } = useMobileMenu()
211
+
212
+ // Resolve panel content (support both naming conventions)
213
+ const leftContent = left || leftPanel
214
+ const rightContent = right || rightPanel
215
+
216
+ // Get breakpoint classes for each panel
217
+ const leftClasses = getBreakpointClasses(leftBreakpoint)
218
+ const rightClasses = getBreakpointClasses(rightBreakpoint)
219
+
220
+ // Sticky positioning
221
+ const headerClasses = stickyHeader
222
+ ? 'sticky top-0 z-30'
223
+ : ''
224
+
225
+ const sidebarClasses = stickySidebar && stickyHeader
226
+ ? 'sticky top-16 h-[calc(100vh-4rem)]'
227
+ : stickySidebar
228
+ ? 'sticky top-0 h-screen'
229
+ : ''
230
+
231
+ return (
232
+ <div className={cn('min-h-screen flex flex-col bg-white', className)}>
233
+ {/* Header */}
234
+ {header && (
235
+ <header className={cn(
236
+ 'w-full border-b border-gray-200 bg-white/95 backdrop-blur supports-[backdrop-filter]:bg-white/80',
237
+ headerClasses
238
+ )}>
239
+ {header}
240
+ </header>
241
+ )}
242
+
243
+ {/* Mobile Drawer (left panel only) */}
244
+ {leftContent && (
245
+ <div className={leftClasses.hideClass}>
246
+ <MobileDrawer
247
+ isOpen={isOpen}
248
+ onClose={close}
249
+ width={drawerWidth}
250
+ stickyHeader={stickyHeader}
251
+ >
252
+ {leftContent}
253
+ </MobileDrawer>
254
+ </div>
255
+ )}
256
+
257
+ {/* Main Content Area */}
258
+ <div className={cn('flex-1 w-full mx-auto', maxWidth)}>
259
+ <div className="flex">
260
+ {/* Left Sidebar (desktop) */}
261
+ {leftContent && (
262
+ <aside className={cn(
263
+ 'hidden flex-shrink-0 overflow-y-auto border-r border-gray-200',
264
+ leftClasses.showClass,
265
+ leftWidth,
266
+ sidebarClasses
267
+ )}>
268
+ {leftContent}
269
+ </aside>
270
+ )}
271
+
272
+ {/* Main Content */}
273
+ <main className="flex-1 min-w-0">
274
+ <div className={contentPadding}>
275
+ {body}
276
+ </div>
277
+ </main>
278
+
279
+ {/* Right Sidebar (desktop only, hidden on mobile) */}
280
+ {rightContent && (
281
+ <aside className={cn(
282
+ 'hidden flex-shrink-0 overflow-y-auto border-l border-gray-200',
283
+ rightClasses.showClass,
284
+ rightWidth,
285
+ sidebarClasses
286
+ )}>
287
+ {rightContent}
288
+ </aside>
289
+ )}
290
+ </div>
291
+ </div>
292
+
293
+ {/* Footer */}
294
+ {footer && (
295
+ <footer className="w-full border-t border-gray-200">
296
+ {footer}
297
+ </footer>
298
+ )}
299
+
300
+ {/* Mobile FAB (only if left panel exists) */}
301
+ {leftContent && (
302
+ <div className={leftClasses.hideClass}>
303
+ <FloatingMenuButton onClick={open} />
304
+ </div>
305
+ )}
306
+ </div>
307
+ )
308
+ }
309
+
310
+ export default SidebarLayout
@@ -0,0 +1 @@
1
+ export { SidebarLayout, default } from './SidebarLayout.jsx'