feat(shared-ui): add submenu and divider support to PillDropdown

- Add nested submenu support to PillDropdownItem
- Add divider option for visual separation
- Move language switcher into user dropdown menu
- Add chevron indicator for expandable items
- Style submenu items with indentation

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-30 00:51:37 +01:00
parent 9a29a8e1ed
commit 2ce19feb37
3 changed files with 168 additions and 46 deletions

View file

@ -18,6 +18,7 @@
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);
@ -45,6 +46,7 @@
}
function close() {
openSubmenuId = null;
if (onToggle) {
onToggle(false);
} else {
@ -52,8 +54,25 @@
}
}
function toggleSubmenu(itemId: string) {
openSubmenuId = openSubmenuId === itemId ? null : itemId;
}
function handleItemClick(item: PillDropdownItem) {
item.onClick();
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();
}
@ -62,6 +81,7 @@
'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:
@ -135,41 +155,81 @@
{/if}
{#each items.filter((i) => !i.disabled) as item, i (item.id)}
<button
onclick={() => handleItemClick(item)}
class="pill glass-pill fan-pill"
class:danger-pill={item.danger}
class:active-pill={item.active}
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 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}
<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>
{/if}
</button>
{/if}
{/each}
</div>
{/if}
@ -352,4 +412,61 @@
.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.25rem;
padding-left: 1rem;
margin-top: -0.25rem;
margin-bottom: 0.25rem;
}
.submenu-item {
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
animation: fanIn 0.15s ease-out forwards;
opacity: 0;
}
.submenu-item .pill-label {
flex: 1;
}
</style>

View file

@ -332,16 +332,7 @@
{/if}
{/each}
<!-- Language Switcher -->
{#if showLanguageSwitcher && languageItems.length > 0}
<PillDropdown
items={languageItems}
direction="down"
label={currentLanguageLabel}
/>
{/if}
<!-- Theme Variant Selector -->
<!-- Theme Variant Selector -->
{#if showThemeVariants && themeVariantItems.length > 0}
<PillDropdown
items={themeVariantItems}
@ -463,6 +454,16 @@
},
active: currentPath === settingsHref,
},
...(showLanguageSwitcher && languageItems.length > 0
? [
{ id: 'language-divider', label: '', divider: true },
...languageItems.map((item) => ({
...item,
id: `lang-${item.id}`,
})),
]
: []),
{ id: 'logout-divider', label: '', divider: true },
{
id: 'logout',
label: 'Logout',

View file

@ -32,13 +32,17 @@ export interface PillDropdownItem {
/** Image URL for icon (data URL or regular URL) */
imageUrl?: string;
/** Click handler */
onClick: () => void;
onClick?: () => void;
/** Whether item is disabled */
disabled?: boolean;
/** Whether item should be styled as danger/destructive */
danger?: boolean;
/** Whether this item is currently active/selected */
active?: boolean;
/** Whether this item is a divider */
divider?: boolean;
/** Nested submenu items */
submenu?: PillDropdownItem[];
}
export interface PillAppItem {