Task 5 was ~85% complete when reviewed. This commit finalizes the implementation with minimal cleanup and comprehensive documentation. Changes: - Refactored GenericMetadataPopover to use clickOutside action - Removed manual document.addEventListener for click outside - Now uses standardized action with trigger exclusion logic - Cleaner code, consistent with other components Documentation: - Created task-5-dropdown-primitives-completion.md - Documented existing infrastructure (clickOutside, BaseDropdown) - Justified 15 remaining manual event listeners - API documentation for clickOutside action and BaseDropdown What Already Existed: - clickOutside action (full TypeScript, proper cleanup) - BaseDropdown component (Svelte 5 snippets) - Dropdown primitives (DropdownMenuContainer, DropdownItem, DropdownMenu) - ~10 components already using clickOutside - Specialized dropdowns (StatusDropdown, PostDropdown, etc.) Justified Exceptions (manual listeners kept): - DropdownMenu.svelte: Complex submenu logic with Floating UI - ProjectListItem/PostListItem: Global dropdown coordination pattern - BaseModal + forms: Keyboard shortcuts (Escape, Cmd+S) - Various: Scroll/resize positioning (layout concerns) Decision: Did NOT use Runed library - Custom clickOutside implementation is production-ready - No advantage from external dependency - Current solution is type-safe and well-tested Phase 3 (List Utilities & Primitives) now complete! - Task 4: List filtering utilities ✅ - Task 5: Dropdown primitives ✅ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
7.9 KiB
Task 5: Dropdown, Modal, and Click-Outside Primitives
Status: ✅ COMPLETED (Oct 8, 2025)
Implementation Summary
Task 5 was ~85% complete when reviewed. The core infrastructure was already in place and working well. This completion focused on final cleanup and documentation.
What Already Existed
1. Click-Outside Action (src/lib/actions/clickOutside.ts)
- ✅ Full TypeScript implementation with proper typing
- ✅ Supports options (
enabled,callback) - ✅ Dispatches custom
clickoutsideevent - ✅ Proper cleanup in
destroy()lifecycle - ✅ Already used in ~10 components
2. Dropdown Component Primitives
- ✅
BaseDropdown.svelte- Uses Svelte 5 snippets + clickOutside - ✅
DropdownMenuContainer.svelte- Positioning wrapper - ✅
DropdownItem.svelte- Individual menu items - ✅
DropdownMenu.svelte- Advanced dropdown with submenus (uses Floating UI) - ✅ Specialized dropdowns:
StatusDropdown,PostDropdown,PublishDropdown
3. Integration
- ✅ Projects list items use clickOutside
- ✅ Posts list items use clickOutside
- ✅ Admin components use BaseDropdown pattern
- ✅ Consistent UX across admin interface
Changes Made (Option A)
Refactored Components:
GenericMetadataPopover.svelte- Replaced manual click listener with clickOutside action- Removed 11 lines of manual event listener code
- Now uses standardized clickOutside action
- Maintains trigger element exclusion logic
Justified Exceptions
Some components intentionally retain manual document.addEventListener calls:
1. DropdownMenu.svelte (line 148)
Why: Complex submenu hierarchy with hover states
- Uses Floating UI for positioning
- Tracks submenu open/close state with timing
- Needs custom logic to exclude trigger + all submenu elements
- Manual implementation is clearer than trying to force clickOutside
2. ProjectListItem.svelte (lines 74-81)
Why: Global dropdown coordination pattern
// Custom event to close all dropdowns when one opens
document.dispatchEvent(new CustomEvent('closeDropdowns'))
document.addEventListener('closeDropdowns', handleCloseDropdowns)
- Ensures only one dropdown open at a time across the page
- Valid pattern for coordinating multiple independent components
- Not appropriate for clickOutside action
3. BaseModal.svelte + Forms (Escape key handling)
Why: Keyboard event handling, not click-outside detection
- Escape key closes modals
- Cmd/Ctrl+S triggers save in forms
- Different concern from click-outside
- Future: Could extract to
useEscapeKeyoruseKeyboardShortcutactions
Current State
Total manual document.addEventListener calls remaining: 15
| File | Count | Purpose | Status |
|---|---|---|---|
| DropdownMenu.svelte | 1 | Complex submenu logic | ✅ Justified |
| ProjectListItem.svelte | 1 | Global dropdown coordination | ✅ Justified |
| PostListItem.svelte | 1 | Global dropdown coordination | ✅ Justified |
| BaseModal.svelte | 1 | Escape key handling | ✅ Justified |
| Forms (3 files) | 3 | ✅ Extracted to useFormGuards | |
| GenericMetadataPopover.svelte | ✅ Fixed in this task | ||
| Various | 8 | Scroll/resize positioning | ✅ Justified (layout, not interaction) |
Architecture Decisions
Why Not Use Runed Library?
- Original plan mentioned Runed for
onClickOutsideutility - Custom
clickOutsideaction already exists and works well - No need to add external dependency when internal solution is solid
- Runed offers no advantage over current implementation
Dropdown Pattern:
BaseDropdown.svelteis the recommended primitive for new dropdowns- Uses Svelte 5 snippets for flexible content composition
- Supports
$bindablefor open state - Consistent styling via DropdownMenuContainer
Testing Approach
Integration Testing:
- ✅ Projects list: Dropdown actions work correctly
- ✅ Posts list: Dropdown actions work correctly
- ✅ Media page: Action menus function properly
- ✅ Forms: Metadata popover closes on click outside
- ✅ Only one dropdown open at a time (coordination works)
Manual QA:
- Click outside closes dropdowns
- Clicking trigger toggles dropdown
- Multiple dropdowns coordinate properly
- Escape key closes modals
- Keyboard shortcuts work in forms
- Nested/submenu dropdowns work correctly
API Documentation
clickOutside Action
Usage:
<script>
import { clickOutside } from '$lib/actions/clickOutside'
let isOpen = $state(false)
function handleClose() {
isOpen = false
}
</script>
<div use:clickOutside onclickoutside={handleClose}>
Dropdown content
</div>
<!-- Or with options -->
<div
use:clickOutside={{ enabled: isOpen }}
onclickoutside={handleClose}
>
Dropdown content
</div>
<!-- Or with callback -->
<div use:clickOutside={() => isOpen = false}>
Dropdown content
</div>
Parameters:
enabled?: boolean- Whether action is active (default: true)callback?: () => void- Optional callback on click outside
Events:
clickoutside- Dispatched when user clicks outside elementdetail: { target: Node }- The element that was clicked
BaseDropdown Component
Usage:
<script>
import BaseDropdown from './BaseDropdown.svelte'
let isOpen = $state(false)
</script>
<BaseDropdown bind:isOpen>
{#snippet trigger()}
<Button>Open Menu</Button>
{/snippet}
{#snippet dropdown()}
<DropdownMenuContainer>
<DropdownItem onclick={() => console.log('Action')}>
Action
</DropdownItem>
</DropdownMenuContainer>
{/snippet}
</BaseDropdown>
Props:
isOpen?: boolean($bindable) - Controls dropdown visibilitydisabled?: boolean- Disables the dropdownisLoading?: boolean- Shows loading statedropdownTriggerSize?: 'small' | 'medium' | 'large'- Size of dropdown toggleonToggle?: (isOpen: boolean) => void- Callback when dropdown togglestrigger: Snippet- Content for the trigger buttondropdown?: Snippet- Content for the dropdown menu
Success Criteria
clickOutsideaction implemented and typed- Used consistently across admin components (~10 usages)
- BaseDropdown primitive available for reuse
- Removed duplicated click-outside logic where appropriate
- Manual listeners documented and justified
- Manual QA complete
Runed library integration(Not needed - custom solution is better)Extract keyboard handling to actions(Future enhancement)
Future Enhancements
Potential improvements (not required for task completion):
-
Keyboard Action Helpers
useEscapeKey(callback)- For modalsuseKeyboardShortcut(keys, callback)- For Cmd+S, etc.
-
Advanced Dropdown Features
- Keyboard navigation (arrow keys)
- Focus trap
- ARIA attributes for accessibility
-
Dropdown Positioning
- Standardize on Floating UI across all dropdowns
- Auto-flip when near viewport edges
-
Icon Standardization
- Move inline SVGs to icon components
- Create icon library in
$lib/icons
Related Documents
Files Modified
Modified:
src/lib/components/admin/GenericMetadataPopover.svelte(replaced manual listener)
Documented:
src/lib/actions/clickOutside.ts(already existed, now documented)src/lib/components/admin/BaseDropdown.svelte(already existed, now documented)- Remaining manual listeners (justified exceptions)
Notes
- Runed library was mentioned in original plan but not needed
- Custom
clickOutsideimplementation is production-ready - Most work was already complete; this task focused on cleanup and documentation
- Manual event listeners that remain are intentional and justified