diff --git a/CLAUDE.md b/CLAUDE.md index e610284..64a150f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -384,24 +384,33 @@ When completing the final sub-issue of an epic, close the epic in the same pull - Built with vanilla CSS, no built-in `css` or `sx` props - Customize through props and theme configuration, NOT custom CSS -2. **Z-Index Management** +2. **🚨 CRITICAL: No Fixed Positioning 🚨** + - **NEVER use `position: fixed`** in any CSS or inline styles + - Liebe runs as a custom panel (web component) inside Home Assistant's UI + - Fixed positioning would position elements relative to Home Assistant's viewport, not Liebe's container + - This breaks the UI because elements would overlap Home Assistant's own sidebar/menu + - **ALWAYS use `position: absolute`** with a positioned parent container instead + - All overlays, modals, drawers, and floating elements must use absolute positioning + +3. **Z-Index Management** - **AVOID custom z-index values** - only use `auto`, `0`, or `-1` - Radix components that need stacking (modals, dropdowns) render in portals - Portalled components automatically manage stacking order without z-index conflicts - If you must set z-index (which you shouldn't), ensure it doesn't interfere with portal stacking -3. **Recommended Styling Approach** (in order of preference) +4. **Recommended Styling Approach** (in order of preference) 1. Use existing component props and theme configuration 2. Adjust the underlying token system (CSS variables) 3. Create custom components using Radix Primitives + Radix Colors 4. As a last resort, apply minimal style overrides -4. **What NOT to Do** +5. **What NOT to Do** - Don't extensively override component styles with custom CSS - Don't use arbitrary z-index values (like 99999 or 100000) + - Don't use `position: fixed` - always use `absolute` with a positioned container - Don't fight the design system - work with it -5. **Example: Fixing Dropdown Issues** +6. **Example: Fixing Dropdown Issues** Instead of: ```tsx @@ -417,7 +426,7 @@ When completing the final sub-issue of an epic, close the epic in the same pull // Content automatically renders in portal with proper stacking ``` -6. **Custom Components** +7. **Custom Components** When creating custom components, use: - Theme tokens for consistency - Radix Primitives for behavior diff --git a/screenshots/connection-status-popover-2025-01-06-fixed.png b/screenshots/connection-status-popover-2025-01-06-fixed.png deleted file mode 100644 index 901cb99..0000000 Binary files a/screenshots/connection-status-popover-2025-01-06-fixed.png and /dev/null differ diff --git a/screenshots/connection-status-popover-2025-01-06-properly-styled.png b/screenshots/connection-status-popover-2025-01-06-properly-styled.png deleted file mode 100644 index 901cb99..0000000 Binary files a/screenshots/connection-status-popover-2025-01-06-properly-styled.png and /dev/null differ diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index ead7f91..f4259b5 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { Box, Flex, Card, Text, Button } from '@radix-ui/themes' import { ScreenConfigDialog } from './ScreenConfigDialog' import { GridView } from './GridView' @@ -17,6 +17,7 @@ export function Dashboard() { const [addItemOpen, setAddItemOpen] = useState(false) const [addItemScreenId, setAddItemScreenId] = useState(null) const [editScreen, setEditScreen] = useState(undefined) + const mainContentRef = useRef(null) // Enable entity connection useEntityConnection() @@ -82,9 +83,11 @@ export function Dashboard() { {/* Content Area */} {currentScreen ? ( @@ -153,6 +156,7 @@ export function Dashboard() { } }} screenId={addItemScreenId} + portalContainer={mainContentRef.current} /> ) diff --git a/src/components/EntityBrowser.tsx b/src/components/EntityBrowser.tsx index 76e05c6..bfbffd5 100644 --- a/src/components/EntityBrowser.tsx +++ b/src/components/EntityBrowser.tsx @@ -1,7 +1,6 @@ import { useCallback } from 'react' -import { Tabs, Box, Flex, Button, Card, Text } from '@radix-ui/themes' -import { Cross2Icon } from '@radix-ui/react-icons' -import { FullscreenModal } from './ui' +import { Tabs, Box, Flex, Card, Text } from '@radix-ui/themes' +import { Drawer } from './ui' import { EntitiesBrowserTab } from './EntitiesBrowserTab' import { CardsBrowserTab } from './CardsBrowserTab' @@ -9,26 +8,38 @@ interface EntityBrowserProps { open: boolean onOpenChange: (open: boolean) => void screenId: string | null + portalContainer?: HTMLElement | null } -export function EntityBrowser({ open, onOpenChange, screenId }: EntityBrowserProps) { +export function EntityBrowser({ + open, + onOpenChange, + screenId, + portalContainer, +}: EntityBrowserProps) { const handleClose = useCallback(() => { onOpenChange(false) }, [onOpenChange]) return ( - + {/* Header */} @@ -46,15 +57,6 @@ export function EntityBrowser({ open, onOpenChange, screenId }: EntityBrowserPro Select items to add to your dashboard - {/* Content */} @@ -82,6 +84,6 @@ export function EntityBrowser({ open, onOpenChange, screenId }: EntityBrowserPro - + ) } diff --git a/src/components/__tests__/EntityBrowser.test.tsx b/src/components/__tests__/EntityBrowser.test.tsx index 4b02636..e7fb25c 100644 --- a/src/components/__tests__/EntityBrowser.test.tsx +++ b/src/components/__tests__/EntityBrowser.test.tsx @@ -274,20 +274,13 @@ describe('EntityBrowser', () => { expect(screen.getByText('No entities found')).toBeInTheDocument() }) - it('should handle cancel action', async () => { + it('should handle escape key to close', async () => { const user = userEvent.setup() render() - // The close button is the one with the Cross2Icon - it's a button without text - const buttons = screen.getAllByRole('button') - const closeButton = buttons.find((button) => { - // Find the button that contains the Cross2Icon (has no text content) - return button.querySelector('svg') && !button.textContent?.trim() - }) - - expect(closeButton).toBeTruthy() - await user.click(closeButton!) + // Press ESC key to close the drawer + await user.keyboard('{Escape}') expect(mockOnOpenChange).toHaveBeenCalledWith(false) }) diff --git a/src/components/ui/Drawer.tsx b/src/components/ui/Drawer.tsx new file mode 100644 index 0000000..1fe8109 --- /dev/null +++ b/src/components/ui/Drawer.tsx @@ -0,0 +1,160 @@ +import { forwardRef, type ReactNode, useContext } from 'react' +import * as Dialog from '@radix-ui/react-dialog' +import { Theme, ThemeContext } from '@radix-ui/themes' +import { Cross2Icon } from '@radix-ui/react-icons' +import './drawer.css' + +type DrawerDirection = 'left' | 'right' | 'top' | 'bottom' + +interface DrawerProps { + /** + * Controls whether the drawer is open + */ + open: boolean + /** + * Callback when the drawer open state changes + */ + onOpenChange: (open: boolean) => void + /** + * Content to display in the drawer + */ + children: ReactNode + /** + * Direction from which the drawer slides in + * @default 'right' + */ + direction?: DrawerDirection + /** + * Whether to include the Theme wrapper. Default true for styled content. + * @default true + */ + includeTheme?: boolean + /** + * Whether clicking backdrop closes drawer. Default true. + * @default true + */ + closeOnBackdropClick?: boolean + /** + * Whether ESC key closes drawer. Default true. + * @default true + */ + closeOnEsc?: boolean + /** + * Custom width for left/right drawers or height for top/bottom drawers + */ + size?: string + /** + * Whether to show close button + * @default true + */ + showCloseButton?: boolean + /** + * Title for the drawer (for accessibility) + */ + title?: string + /** + * Description for the drawer (for accessibility) + */ + description?: string + /** + * Container element to portal the drawer into. If not provided, portals to document.body. + */ + portalContainer?: HTMLElement | null + /** + * Whether to use partial overlay (absolute positioning within container) + * instead of full-screen overlay (fixed positioning) + * @default false + */ + partialOverlay?: boolean +} + +/** + * A drawer component built on Radix UI Dialog with CSS animations. + * Provides slide-in functionality from any edge of the viewport. + * + * Features: + * - Built on Radix Dialog for proper accessibility and focus management + * - CSS-based animations for test compatibility + * - Supports all four directions + * - Portal rendering to escape shadow DOM + * - ESC key and backdrop click support + */ +export const Drawer = forwardRef( + ( + { + open, + onOpenChange, + children, + direction = 'right', + includeTheme = true, + closeOnBackdropClick = true, + closeOnEsc = true, + size, + showCloseButton = true, + title, + description, + portalContainer, + partialOverlay = false, + }, + ref + ) => { + // Get current theme context to inherit appearance in portal + // Use useContext directly to avoid throwing when not in a Theme + const themeContext = useContext(ThemeContext) + + const portalContent = ( + <> + onOpenChange(false) : undefined} + /> + e.preventDefault()} + onPointerDownOutside={closeOnBackdropClick ? undefined : (e) => e.preventDefault()} + onInteractOutside={closeOnBackdropClick ? undefined : (e) => e.preventDefault()} + > + {/* Always render title and description for accessibility, but hide them visually */} + {title || 'Dialog'} + + {description || 'Dialog content'} + + + {showCloseButton && ( + + + + )} + +
{children}
+
+ + ) + + return ( + + + {includeTheme ? ( + {portalContent} + ) : ( + portalContent + )} + + + ) + } +) + +Drawer.displayName = 'Drawer' diff --git a/src/components/ui/__tests__/Drawer.test.tsx b/src/components/ui/__tests__/Drawer.test.tsx new file mode 100644 index 0000000..b4cbfcd --- /dev/null +++ b/src/components/ui/__tests__/Drawer.test.tsx @@ -0,0 +1,180 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Theme } from '@radix-ui/themes' +import { Drawer } from '../Drawer' + +describe('Drawer', () => { + const mockOnOpenChange = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render content when open', () => { + render( + +
Drawer Content
+
+ ) + + expect(screen.getByText('Drawer Content')).toBeInTheDocument() + }) + + it('should not render content when closed', () => { + render( + +
Drawer Content
+
+ ) + + expect(screen.queryByText('Drawer Content')).not.toBeInTheDocument() + }) + + it('should close on ESC key press', async () => { + const user = userEvent.setup() + + render( + +
Drawer Content
+
+ ) + + await user.keyboard('{Escape}') + + expect(mockOnOpenChange).toHaveBeenCalledWith(false) + }) + + it('should not close on ESC when closeOnEsc is false', async () => { + const user = userEvent.setup() + + render( + +
Drawer Content
+
+ ) + + await user.keyboard('{Escape}') + + expect(mockOnOpenChange).not.toHaveBeenCalled() + }) + + it('should show close button by default', () => { + render( + +
Drawer Content
+
+ ) + + expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument() + }) + + it('should hide close button when showCloseButton is false', () => { + render( + +
Drawer Content
+
+ ) + + expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument() + }) + + it('should close when close button is clicked', async () => { + const user = userEvent.setup() + + render( + +
Drawer Content
+
+ ) + + await user.click(screen.getByRole('button', { name: 'Close' })) + + expect(mockOnOpenChange).toHaveBeenCalledWith(false) + }) + + it('should inherit dark theme appearance from parent Theme', () => { + render( + + +
Drawer Content
+
+
+ ) + + // The drawer content should be inside a Theme with dark appearance + const drawerContent = screen.getByTestId('drawer-content') + // Find the Theme wrapper in the portal - it should have the dark appearance class + const themeWrapper = drawerContent.closest('[data-is-root-theme]') + expect(themeWrapper).toHaveAttribute('data-is-root-theme', 'false') + // The drawer's Theme should have class indicating dark mode + expect(themeWrapper).toHaveClass('dark') + }) + + it('should inherit light theme appearance from parent Theme', () => { + render( + + +
Drawer Content
+
+
+ ) + + const drawerContent = screen.getByTestId('drawer-content') + const themeWrapper = drawerContent.closest('[data-is-root-theme]') + expect(themeWrapper).toHaveAttribute('data-is-root-theme', 'false') + expect(themeWrapper).toHaveClass('light') + }) + + it('should work without parent Theme (fallback)', () => { + // This should not throw - it should render with default theme + render( + +
Drawer Content
+
+ ) + + expect(screen.getByText('Drawer Content')).toBeInTheDocument() + }) + + it('should apply custom size', () => { + render( + +
Drawer Content
+
+ ) + + const drawerContent = screen.getByTestId('drawer-content') + const dialogContent = drawerContent.closest('.drawer-content') + expect(dialogContent).toHaveStyle({ width: '400px' }) + }) + + it('should apply direction class', () => { + render( + +
Drawer Content
+
+ ) + + const drawerContent = screen.getByTestId('drawer-content') + const dialogContent = drawerContent.closest('.drawer-content') + expect(dialogContent).toHaveClass('drawer-left') + }) + + it('should render accessibility title and description', () => { + render( + +
Drawer Content
+
+ ) + + // Title and description are visually hidden but present for screen readers + expect(screen.getByText('Test Title')).toBeInTheDocument() + expect(screen.getByText('Test Description')).toBeInTheDocument() + }) +}) diff --git a/src/components/ui/drawer.css b/src/components/ui/drawer.css new file mode 100644 index 0000000..e485962 --- /dev/null +++ b/src/components/ui/drawer.css @@ -0,0 +1,211 @@ +/* Drawer overlay - uses absolute positioning for Home Assistant panel compatibility */ +.drawer-overlay { + position: absolute; + inset: 0; + background-color: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + animation: overlayShow 200ms cubic-bezier(0.16, 1, 0.3, 1); +} + +/* Partial overlay - only covers container, not full screen */ +.drawer-overlay-partial { + position: absolute; + inset: 0; + background-color: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(2px); + animation: overlayShow 200ms cubic-bezier(0.16, 1, 0.3, 1); +} + +/* Base drawer content styles - uses absolute positioning for Home Assistant panel compatibility */ +.drawer-content { + position: absolute; + background-color: var(--color-panel-solid); + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* Focus outline styling */ +.drawer-content:focus { + outline: none; +} + +/* Drawer body - scrollable content area */ +.drawer-body { + flex: 1; + overflow: auto; + -webkit-overflow-scrolling: touch; +} + +/* Close button */ +.drawer-close { + position: absolute; + top: 16px; + right: 16px; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + cursor: pointer; + border-radius: var(--radius-2); + color: var(--gray-11); + transition: + background-color 200ms, + color 200ms; +} + +.drawer-close:hover { + background-color: var(--gray-a3); + color: var(--gray-12); +} + +.drawer-close:focus { + outline: 2px solid var(--accent-8); + outline-offset: 2px; +} + +/* Accessibility titles */ +.drawer-title { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} + +.drawer-description { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} + +/* Direction-specific styles */ + +/* Right drawer */ +.drawer-right { + top: 0; + right: 0; + bottom: 0; + width: 80vw; + max-width: 600px; + animation: slideInFromRight 300ms cubic-bezier(0.16, 1, 0.3, 1); +} + +/* Left drawer - 60% width on desktop, full width on mobile */ +.drawer-left { + top: 0; + left: 0; + bottom: 0; + width: 60%; + max-width: none; + border-radius: 0; + border-left: none; + animation: slideInFromLeft 300ms cubic-bezier(0.16, 1, 0.3, 1); +} + +/* Top drawer */ +.drawer-top { + top: 0; + left: 0; + right: 0; + height: 80vh; + max-height: 600px; + animation: slideInFromTop 300ms cubic-bezier(0.16, 1, 0.3, 1); +} + +/* Bottom drawer */ +.drawer-bottom { + bottom: 0; + left: 0; + right: 0; + height: 80vh; + max-height: 600px; + animation: slideInFromBottom 300ms cubic-bezier(0.16, 1, 0.3, 1); +} + +/* Animations */ +@keyframes overlayShow { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes slideInFromRight { + from { + transform: translateX(100%); + } + to { + transform: translateX(0); + } +} + +@keyframes slideInFromLeft { + from { + transform: translateX(-100%); + } + to { + transform: translateX(0); + } +} + +@keyframes slideInFromTop { + from { + transform: translateY(-100%); + } + to { + transform: translateY(0); + } +} + +@keyframes slideInFromBottom { + from { + transform: translateY(100%); + } + to { + transform: translateY(0); + } +} + +/* Mobile adjustments */ +@media (max-width: 640px) { + .drawer-right { + width: 90vw; + max-width: 100vw; + } + + /* Left drawer goes full width on mobile */ + .drawer-left { + width: 100%; + } + + .drawer-top, + .drawer-bottom { + height: 90vh; + max-height: none; + } +} + +/* Reduced motion support */ +@media (prefers-reduced-motion: reduce) { + .drawer-overlay, + .drawer-content { + animation-duration: 0.01ms !important; + } +} diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index 98effe4..994c728 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -5,6 +5,7 @@ export * from '@radix-ui/themes' export { Modal } from './Modal' export { AlertModal } from './AlertModal' export { FullscreenModal } from './FullscreenModal' +export { Drawer } from './Drawer' // Export skeleton component export { SkeletonCard } from './SkeletonCard' diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 3859325..ab26bfa 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -135,3 +135,12 @@ const rootRouteChildren: RootRouteChildren = { export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/vite.config.ts b/vite.config.ts index 83b7878..ffbd0c2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -133,6 +133,7 @@ function panelPlugin() { export default defineConfig({ server: { port: 3000, + allowedHosts: true, cors: { origin: '*', credentials: false,