Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Binary file not shown.
Binary file not shown.
6 changes: 5 additions & 1 deletion src/components/Dashboard.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -17,6 +17,7 @@ export function Dashboard() {
const [addItemOpen, setAddItemOpen] = useState(false)
const [addItemScreenId, setAddItemScreenId] = useState<string | null>(null)
const [editScreen, setEditScreen] = useState<ScreenConfig | undefined>(undefined)
const mainContentRef = useRef<HTMLDivElement>(null)

// Enable entity connection
useEntityConnection()
Expand Down Expand Up @@ -82,9 +83,11 @@ export function Dashboard() {

{/* Content Area */}
<Box
ref={mainContentRef}
style={{
flex: 1,
overflow: 'auto',
position: 'relative',
}}
>
{currentScreen ? (
Expand Down Expand Up @@ -153,6 +156,7 @@ export function Dashboard() {
}
}}
screenId={addItemScreenId}
portalContainer={mainContentRef.current}
/>
</Box>
)
Expand Down
40 changes: 21 additions & 19 deletions src/components/EntityBrowser.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,45 @@
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'

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 (
<FullscreenModal open={open} onClose={handleClose}>
<Drawer
open={open}
onOpenChange={onOpenChange}
direction="left"
showCloseButton={false}
portalContainer={portalContainer}
partialOverlay={true}
>
<Card
size="3"
style={{
width: '80vw',
maxWidth: '1200px',
maxHeight: '90vh',
height: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative',
backgroundColor: 'var(--color-panel-solid)',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
borderRadius: 0,
boxShadow: 'none',
}}
>
{/* Header */}
Expand All @@ -46,15 +57,6 @@ export function EntityBrowser({ open, onOpenChange, screenId }: EntityBrowserPro
Select items to add to your dashboard
</Text>
</Box>
<Button
size="2"
variant="ghost"
color="gray"
onClick={handleClose}
style={{ marginLeft: 'auto' }}
>
<Cross2Icon width="16" height="16" />
</Button>
</Flex>

{/* Content */}
Expand Down Expand Up @@ -82,6 +84,6 @@ export function EntityBrowser({ open, onOpenChange, screenId }: EntityBrowserPro
</Tabs.Root>
</Box>
</Card>
</FullscreenModal>
</Drawer>
)
}
13 changes: 3 additions & 10 deletions src/components/__tests__/EntityBrowser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<EntityBrowser open={true} onOpenChange={mockOnOpenChange} screenId={mockScreenId} />)

// 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)
})
Expand Down
160 changes: 160 additions & 0 deletions src/components/ui/Drawer.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement, DrawerProps>(
(
{
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 = (
<>
<Dialog.Overlay
className={partialOverlay ? 'drawer-overlay-partial' : 'drawer-overlay'}
onClick={closeOnBackdropClick ? () => onOpenChange(false) : undefined}
/>
<Dialog.Content
ref={ref}
className={`drawer-content drawer-${direction}`}
style={
size
? {
...(direction === 'left' || direction === 'right'
? { width: size }
: { height: size }),
}
: undefined
}
onEscapeKeyDown={closeOnEsc ? undefined : (e) => 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 */}
<Dialog.Title className="drawer-title">{title || 'Dialog'}</Dialog.Title>
<Dialog.Description className="drawer-description">
{description || 'Dialog content'}
</Dialog.Description>

{showCloseButton && (
<Dialog.Close asChild>
<button className="drawer-close" aria-label="Close">
<Cross2Icon />
</button>
</Dialog.Close>
)}

<div className="drawer-body">{children}</div>
</Dialog.Content>
</>
)

return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal container={portalContainer ?? undefined}>
{includeTheme ? (
<Theme appearance={themeContext?.appearance}>{portalContent}</Theme>
) : (
portalContent
)}
</Dialog.Portal>
</Dialog.Root>
)
}
)

Drawer.displayName = 'Drawer'
Loading
Loading