mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-23 04:06:43 +02:00
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>
530 lines
14 KiB
Svelte
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>
|