managarten/packages/shared-ui/src/navigation/PillDropdown.svelte
Till-JS 02c82c7547 feat(a11y): add accessibility settings and theme improvements
Add comprehensive accessibility support across shared packages:
- A11y store with contrast, colorblind mode, and reduce motion settings
- A11yQuickToggles and A11ySettings UI components
- PillNavigation and PillDropdown components in shared-ui
- Calendar app updates to integrate new theme/a11y features

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 22:56:09 +01:00

530 lines
14 KiB
Svelte

<script lang="ts">
import type { Snippet } from 'svelte';
import type { PillDropdownItem } from './types';
interface Props {
items: PillDropdownItem[];
direction?: 'up' | 'down';
label: string;
icon?: 'globe' | 'language' | 'chevronDown' | 'check' | string;
isOpen?: boolean;
onToggle?: (open: boolean) => void;
/** Optional header content (e.g., mode selector) */
header?: Snippet;
/** Optional footer content (e.g., a11y toggles) */
footer?: Snippet;
}
let {
items,
direction = 'down',
label,
icon,
isOpen = false,
onToggle,
header,
footer,
}: Props = $props();
let internalOpen = $state(false);
let triggerButton: HTMLButtonElement;
let dropdownPosition = $state({ top: 0, left: 0 });
let openSubmenuId = $state<string | null>(null);
const open = $derived(onToggle ? isOpen : internalOpen);
function toggle() {
if (triggerButton) {
const rect = triggerButton.getBoundingClientRect();
if (direction === 'down') {
dropdownPosition = {
top: rect.bottom + 8,
left: rect.left,
};
} else {
dropdownPosition = {
top: rect.top - 8,
left: rect.left,
};
}
}
if (onToggle) {
onToggle(!isOpen);
} else {
internalOpen = !internalOpen;
}
}
function close() {
openSubmenuId = null;
if (onToggle) {
onToggle(false);
} else {
internalOpen = false;
}
}
function toggleSubmenu(itemId: string) {
openSubmenuId = openSubmenuId === itemId ? null : itemId;
}
function handleItemClick(item: PillDropdownItem) {
if (item.submenu && item.submenu.length > 0) {
toggleSubmenu(item.id);
return;
}
if (item.onClick) {
item.onClick();
}
close();
}
function handleSubmenuItemClick(item: PillDropdownItem) {
if (item.onClick) {
item.onClick();
}
close();
}
const iconPaths: Record<string, string> = {
language:
'M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129',
check: 'M5 13l4 4L19 7',
chevronDown: 'M19 9l-7 7-7-7',
chevronRight: 'M9 5l7 7-7 7',
globe:
'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
palette:
'M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01',
// Theme icons (Phosphor-style)
sparkle:
'M12 2L13.09 8.26L18 6L15.74 10.91L22 12L15.74 13.09L18 18L13.09 15.74L12 22L10.91 15.74L6 18L8.26 13.09L2 12L8.26 10.91L6 6L10.91 8.26L12 2Z',
leaf: 'M6.5 21.5C3.5 18.5 3.5 12.5 6.5 8.5C9.5 4.5 15 3 20 3C20 8 18.5 13.5 14.5 16.5C10.5 19.5 4.5 19.5 4.5 19.5M6.5 21.5L4.5 19.5M6.5 21.5C6.5 21.5 12 18 14.5 16.5',
hexagon: 'M12 2L21.5 7.5V16.5L12 22L2.5 16.5V7.5L12 2Z',
waves:
'M2 12C2 12 5 8 9 8C13 8 15 12 15 12C15 12 17 16 21 16M2 17C2 17 5 13 9 13C13 13 15 17 15 17C15 17 17 21 21 21M2 7C2 7 5 3 9 3C13 3 15 7 15 7C15 7 17 11 21 11',
// User menu icons
user: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z',
settings:
'M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93s.844.083 1.16-.175l.713-.57a1.125 1.125 0 011.578.112l.773.773a1.125 1.125 0 01.112 1.578l-.57.713c-.258.316-.29.756-.175 1.16s.506.71.93.78l.894.15c.542.09.94.56.94 1.109v1.094c0 .55-.398 1.02-.94 1.11l-.894.149c-.424.07-.764.384-.93.78s-.083.844.175 1.16l.57.713a1.125 1.125 0 01-.112 1.578l-.773.773a1.125 1.125 0 01-1.578.112l-.713-.57c-.316-.258-.756-.29-1.16-.175s-.71.506-.78.93l-.15.894c-.09.542-.56.94-1.109.94h-1.094c-.55 0-1.02-.398-1.11-.94l-.149-.894c-.07-.424-.384-.764-.78-.93s-.844-.083-1.16.175l-.713.57a1.125 1.125 0 01-1.578-.112l-.773-.773a1.125 1.125 0 01-.112-1.578l.57-.713c.258-.316.29-.756.175-1.16s-.506-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.764-.384.93-.78s.083-.844-.175-1.16l-.57-.713a1.125 1.125 0 01.112-1.578l.773-.773a1.125 1.125 0 011.578-.112l.713.57c.316.258.756.29 1.16.175s.71-.506.78-.93l.15-.894zM15 12a3 3 0 11-6 0 3 3 0 016 0z',
logout:
'M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1',
// App icons
grid: 'M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z',
// Mana icon (water drop)
mana: 'M12.3 1c.03.05 7.3 9.67 7.3 13.7 0 4.03-3.27 7.3-7.3 7.3S5 18.73 5 14.7C5 10.66 12.3 1 12.3 1zm0 6.4c-.02.03-3.65 4.83-3.65 6.84 0 2.02 1.64 3.65 3.65 3.65s3.65-1.64 3.65-3.65c0-2.01-3.62-6.81-3.65-6.84z',
};
function getIcon(iconName: string) {
return iconPaths[iconName] || iconName;
}
</script>
<div class="pill-dropdown">
<!-- Trigger Button -->
<button bind:this={triggerButton} onclick={toggle} class="pill glass-pill trigger-button">
{#if icon}
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={getIcon(icon)} />
</svg>
{/if}
<span class="pill-label">{label}</span>
<svg
class="chevron-icon"
class:rotated={open}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={getIcon('chevronDown')}
/>
</svg>
</button>
{#if open}
<!-- Backdrop -->
<button class="menu-backdrop" onclick={close} onkeydown={(e) => e.key === 'Escape' && close()}
></button>
<!-- Dropdown items -->
<div
class="fan-container"
class:fan-up={direction === 'up'}
class:fan-down={direction === 'down'}
style="top: {dropdownPosition.top}px; left: {dropdownPosition.left}px;"
>
<!-- Optional header (e.g., mode selector) -->
{#if header}
<div class="dropdown-header">
{@render header()}
</div>
{/if}
{#each items.filter((i) => !i.disabled) as item, i (item.id)}
{#if item.divider}
<div
class="dropdown-divider"
style="animation-delay: {(header ? i + 1 : i) * 15}ms"
></div>
{:else}
<button
onclick={() => handleItemClick(item)}
class="pill glass-pill fan-pill"
class:danger-pill={item.danger}
class:active-pill={item.active}
class:has-submenu={item.submenu && item.submenu.length > 0}
class:submenu-open={openSubmenuId === item.id}
style="animation-delay: {(header ? i + 1 : i) * 15}ms"
>
{#if item.imageUrl}
<img src={item.imageUrl} alt="" class="pill-image-icon" />
{:else if item.icon === 'mana'}
<svg class="pill-icon" viewBox="0 0 24 24" fill="currentColor">
<path d={getIcon('mana')} />
</svg>
{:else if item.icon}
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={getIcon(item.icon)}
/>
</svg>
{/if}
<span class="pill-label">{item.label}</span>
{#if item.active}
<svg class="check-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={getIcon('check')}
/>
</svg>
{:else if item.submenu && item.submenu.length > 0}
<svg
class="chevron-submenu"
class:rotated={openSubmenuId === item.id}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={getIcon('chevronDown')}
/>
</svg>
{/if}
</button>
<!-- Submenu items -->
{#if item.submenu && item.submenu.length > 0 && openSubmenuId === item.id}
<div class="submenu-container">
{#each item.submenu.filter((si) => !si.disabled) as subitem, si (subitem.id)}
<button
onclick={() => handleSubmenuItemClick(subitem)}
class="pill glass-pill fan-pill submenu-item"
class:active-pill={subitem.active}
style="animation-delay: {si * 15}ms"
>
<span class="pill-label">{subitem.label}</span>
{#if subitem.active}
<svg class="check-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={getIcon('check')}
/>
</svg>
{/if}
</button>
{/each}
</div>
{/if}
{/if}
{/each}
<!-- Optional footer (e.g., a11y toggles) -->
{#if footer}
<div class="dropdown-footer">
{@render footer()}
</div>
{/if}
</div>
{/if}
</div>
<style>
.pill-dropdown {
position: relative;
z-index: 1;
}
.pill-dropdown:has(.fan-container) {
z-index: 10000;
}
.trigger-button {
position: relative;
z-index: 10;
}
.chevron-icon {
width: 0.75rem;
height: 0.75rem;
transition: transform 0.2s;
margin-left: 0.25rem;
}
.chevron-icon.rotated {
transform: rotate(180deg);
}
.fan-container {
position: fixed;
display: flex;
flex-direction: column;
gap: 0.5rem;
z-index: 9999;
}
.fan-up {
flex-direction: column-reverse;
transform: translateY(-100%);
}
.fan-down {
flex-direction: column;
}
.fan-pill {
animation: fanIn 0.15s ease-out forwards;
opacity: 0;
transform: translateY(10px);
}
.fan-up .fan-pill {
transform: translateY(-10px);
}
@keyframes fanIn {
to {
opacity: 1;
transform: translateY(0);
}
}
.pill {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 500;
white-space: nowrap;
text-decoration: none;
transition: all 0.2s;
border: none;
cursor: pointer;
}
.glass-pill,
:global(.fan-container .glass-pill) {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
color: #374151;
}
:global(.dark) .glass-pill,
:global(.dark .fan-container .glass-pill) {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.15);
color: #f3f4f6;
}
.glass-pill:hover {
background: rgba(255, 255, 255, 0.95);
border-color: rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
:global(.dark) .glass-pill:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.25);
}
.active-pill {
background: var(--color-primary-100, rgba(248, 214, 43, 0.2));
border-color: var(--color-primary-200, rgba(248, 214, 43, 0.3));
}
:global(.dark) .active-pill {
background: var(--color-primary-900, rgba(248, 214, 43, 0.15));
border-color: var(--color-primary-800, rgba(248, 214, 43, 0.25));
}
.danger-pill {
color: #dc2626;
}
:global(.dark) .danger-pill {
color: #ef4444;
}
.danger-pill:hover {
background: rgba(220, 38, 38, 0.15);
border-color: rgba(220, 38, 38, 0.3);
}
.pill-icon {
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
.pill-image-icon {
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
border-radius: 0.25rem;
object-fit: cover;
}
.check-icon {
width: 0.875rem;
height: 0.875rem;
margin-left: 0.25rem;
color: var(--color-primary-500, #f8d62b);
}
.pill-label {
display: inline;
}
.menu-backdrop {
position: fixed;
inset: 0;
z-index: 9998;
background: transparent;
border: none;
cursor: default;
}
/* Header for custom content (e.g., mode selector) */
.dropdown-header {
animation: fanIn 0.15s ease-out forwards;
opacity: 0;
transform: translateY(10px);
position: relative;
z-index: 1;
}
.fan-up .dropdown-header {
transform: translateY(-10px);
}
/* Divider in dropdown */
.dropdown-divider {
height: 1px;
background: rgba(0, 0, 0, 0.1);
margin: 0.25rem 0.5rem;
animation: fanIn 0.15s ease-out forwards;
opacity: 0;
}
:global(.dark) .dropdown-divider {
background: rgba(255, 255, 255, 0.15);
}
/* Submenu styles */
.chevron-submenu {
width: 0.75rem;
height: 0.75rem;
margin-left: auto;
transition: transform 0.2s;
}
.chevron-submenu.rotated {
transform: rotate(180deg);
}
.has-submenu {
justify-content: flex-start;
}
.submenu-open {
background: rgba(0, 0, 0, 0.05);
}
:global(.dark) .submenu-open {
background: rgba(255, 255, 255, 0.1);
}
.submenu-container {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0;
margin-bottom: 0;
}
.submenu-item {
padding: 0.5rem 0.875rem;
font-size: 0.875rem;
animation: fanIn 0.15s ease-out forwards;
opacity: 0;
justify-content: flex-start;
}
.submenu-item .pill-label {
flex: 1;
text-align: left;
}
/* Footer for custom content (e.g., a11y toggles) */
.dropdown-footer {
animation: fanIn 0.15s ease-out forwards;
opacity: 0;
transform: translateY(10px);
position: relative;
z-index: 1;
margin-top: 0.25rem;
padding-top: 0.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.1);
}
:global(.dark) .dropdown-footer {
border-top-color: rgba(255, 255, 255, 0.15);
}
.fan-up .dropdown-footer {
transform: translateY(-10px);
margin-top: 0;
margin-bottom: 0.25rem;
padding-top: 0;
padding-bottom: 0.5rem;
border-top: none;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
:global(.dark) .fan-up .dropdown-footer {
border-bottom-color: rgba(255, 255, 255, 0.15);
}
</style>