feat(contacts): add collapsible alphabet-nav with FAB toggle

- Add isAlphabetNavCollapsed state to filter store with persistence
- Implement FAB button (left of InputBar) to toggle alphabet navigation
- Add dynamic positioning for alphabet-nav based on toolbar state
- Fix ContactsToolbar view-mode-pill positioning to align with FAB
- Refactor ContactsToolbar to use ExpandableToolbar's fixed positioning

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-14 19:42:38 +01:00
parent 68626227e0
commit 4b6a4c73ae
3 changed files with 294 additions and 21 deletions

View file

@ -1,7 +1,9 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { ExpandableToolbar } from '@manacore/shared-ui';
import ContactsToolbarContent from './ContactsToolbarContent.svelte';
import { contactsFilterStore } from '$lib/stores/filter.svelte';
import { viewModeStore } from '$lib/stores/view-mode.svelte';
import type { Contact } from '$lib/api/contacts';
interface Props {
@ -19,6 +21,7 @@
}
</script>
<!-- Main Expandable Toolbar (uses its own fixed positioning) -->
<ExpandableToolbar
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
@ -28,3 +31,120 @@
>
<ContactsToolbarContent {contacts} />
</ExpandableToolbar>
<!-- View Mode Pill - positioned to the LEFT of the FAB -->
{#if !isSidebarMode}
<div class="view-mode-pill" class:expanded={!isCollapsed}>
<button
type="button"
class="view-btn"
class:active={viewModeStore.mode === 'grid'}
onclick={() => viewModeStore.setMode('grid')}
title={$_('views.grid')}
>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="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"
/>
</svg>
</button>
<button
type="button"
class="view-btn"
class:active={viewModeStore.mode === 'alphabet'}
onclick={() => viewModeStore.setMode('alphabet')}
title={$_('views.alphabet')}
>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12"
/>
</svg>
</button>
<button
type="button"
class="view-btn"
class:active={viewModeStore.mode === 'network'}
onclick={() => viewModeStore.setMode('network')}
title={$_('views.network')}
>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"
/>
</svg>
</button>
</div>
{/if}
<style>
/* View Mode Pill - positioned to the LEFT of the FAB (which is at right: calc(50% - 350px - 70px)) */
.view-mode-pill {
position: fixed;
/* Same vertical alignment as FAB: bottom offset + 9px + safe-area */
bottom: calc(70px + 9px + env(safe-area-inset-bottom, 0px));
/* Position to the left of the FAB: FAB is at right: calc(50% - 350px - 70px), FAB width is 54px, gap is 8px */
right: calc(50% - 350px - 70px + 54px + 8px);
z-index: 91;
display: flex;
align-items: center;
gap: 0.125rem;
padding: 0.375rem;
background: hsl(var(--color-surface) / 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid hsl(var(--color-border));
border-radius: 9999px;
box-shadow: 0 2px 8px hsl(var(--color-foreground) / 0.08);
transition: bottom 0.2s ease;
}
/* When toolbar is expanded, move pill up with FAB */
.view-mode-pill.expanded {
bottom: calc(70px + 70px + 9px + env(safe-area-inset-bottom, 0px));
}
/* Responsive - on smaller screens, position relative to viewport edge */
@media (max-width: 900px) {
.view-mode-pill {
right: calc(1rem + 54px + 8px); /* FAB at right: 1rem, FAB width 54px, gap 8px */
}
}
.view-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
background: transparent;
border: none;
border-radius: 9999px;
cursor: pointer;
color: hsl(var(--color-muted-foreground));
transition: all 0.15s ease;
}
.view-btn:hover {
background: hsl(var(--color-muted) / 0.5);
color: hsl(var(--color-foreground));
}
.view-btn.active {
background: color-mix(in srgb, #3b82f6 15%, transparent 85%);
color: #3b82f6;
}
.view-btn :global(svg) {
width: 1.125rem;
height: 1.125rem;
}
</style>

View file

@ -3,6 +3,8 @@
import type { Contact } from '$lib/api/contacts';
import type { SortField } from '$lib/components/SortToggle.svelte';
import { newContactModalStore } from '$lib/stores/new-contact-modal.svelte';
import { isSidebarMode } from '$lib/stores/navigation';
import { contactsFilterStore } from '$lib/stores/filter.svelte';
interface Props {
contacts: Contact[];
@ -26,6 +28,14 @@
showNewContactCard = true,
}: Props = $props();
// Derived state for toolbar positioning
let isToolbarExpanded = $derived(!contactsFilterStore.isToolbarCollapsed);
let isAlphabetNavCollapsed = $derived(contactsFilterStore.isAlphabetNavCollapsed);
function toggleAlphabetNav() {
contactsFilterStore.toggleAlphabetNav();
}
function handleCheckboxClick(e: MouseEvent, id: string) {
e.stopPropagation();
onToggleSelection?.(id);
@ -251,28 +261,67 @@
{/each}
</div>
<!-- Alphabet Quick Jump (like DateStrip) -->
<div class="alphabet-nav">
<div class="alphabet-nav-container">
{#each alphabet as letter}
<button
type="button"
class="alphabet-nav-btn"
class:active={availableLetters.includes(letter)}
class:disabled={!availableLetters.includes(letter)}
onclick={() => availableLetters.includes(letter) && scrollToLetter(letter)}
disabled={!availableLetters.includes(letter)}
>
{letter}
</button>
{/each}
{#if availableLetters.includes('#')}
<button type="button" class="alphabet-nav-btn active" onclick={() => scrollToLetter('#')}>
#
</button>
{/if}
</div>
<!-- Alphabet FAB (when collapsed) - positioned left of InputBar -->
<div
class="alphabet-fab-container"
class:sidebar-mode={$isSidebarMode}
class:toolbar-expanded={isToolbarExpanded}
>
<button
onclick={toggleAlphabetNav}
class="alphabet-fab"
class:active={!isAlphabetNavCollapsed}
title={isAlphabetNavCollapsed
? 'Alphabet-Navigation öffnen'
: 'Alphabet-Navigation schließen'}
>
<svg class="fab-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{#if isAlphabetNavCollapsed}
<!-- ABC/Alphabet icon -->
<text x="3" y="17" font-size="12" font-weight="bold" fill="currentColor" stroke="none"
>AZ</text
>
{:else}
<!-- Chevron down icon -->
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
{/if}
</svg>
</button>
</div>
<!-- Alphabet Quick Jump (like DateStrip) - hidden when collapsed -->
{#if !isAlphabetNavCollapsed}
<div
class="alphabet-nav"
class:sidebar-mode={$isSidebarMode}
class:toolbar-expanded={isToolbarExpanded}
>
<div class="alphabet-nav-container">
{#each alphabet as letter}
<button
type="button"
class="alphabet-nav-btn"
class:active={availableLetters.includes(letter)}
class:disabled={!availableLetters.includes(letter)}
onclick={() => availableLetters.includes(letter) && scrollToLetter(letter)}
disabled={!availableLetters.includes(letter)}
>
{letter}
</button>
{/each}
{#if availableLetters.includes('#')}
<button type="button" class="alphabet-nav-btn active" onclick={() => scrollToLetter('#')}>
#
</button>
{/if}
</div>
</div>
{/if}
</div>
<style>
@ -491,6 +540,21 @@
container-name: alphabetnav;
}
/* When toolbar is expanded, push Alphabet-Nav up (+70px) */
.alphabet-nav.toolbar-expanded {
bottom: calc(210px + env(safe-area-inset-bottom, 0px));
}
/* When PillNav is in sidebar mode, only InputBar at bottom */
.alphabet-nav.sidebar-mode {
bottom: calc(70px + env(safe-area-inset-bottom, 0px));
}
/* Sidebar mode + toolbar expanded */
.alphabet-nav.sidebar-mode.toolbar-expanded {
bottom: calc(140px + env(safe-area-inset-bottom, 0px));
}
.alphabet-nav-container {
display: flex;
flex-direction: row;
@ -599,4 +663,78 @@
.new-contact-card .contact-name {
font-size: 0.875rem;
}
/* Alphabet FAB - positioned left of InputBar */
.alphabet-fab-container {
position: fixed;
bottom: calc(70px + 9px + env(safe-area-inset-bottom, 0px)); /* Align with InputBar */
left: calc(50% - 350px - 70px); /* Left of InputBar (max-width 700px / 2 + gap) */
z-index: 49; /* Below InputBar (90) and ExpandableToolbar FAB (91), above alphabet-nav (48) */
pointer-events: none;
transition: bottom 0.2s ease;
}
/* Responsive positioning for FAB */
@media (max-width: 900px) {
.alphabet-fab-container {
left: 1rem;
}
}
/* When toolbar is expanded, move FAB up */
.alphabet-fab-container.toolbar-expanded {
bottom: calc(140px + 9px + env(safe-area-inset-bottom, 0px));
}
/* Sidebar mode */
.alphabet-fab-container.sidebar-mode {
bottom: calc(9px + env(safe-area-inset-bottom, 0px));
}
.alphabet-fab-container.sidebar-mode.toolbar-expanded {
bottom: calc(70px + 9px + env(safe-area-inset-bottom, 0px));
}
.alphabet-fab {
display: flex;
align-items: center;
justify-content: center;
width: 54px;
height: 54px;
cursor: pointer;
border: none;
transition: all 0.2s ease;
pointer-events: auto;
/* Glass pill styling */
background: hsl(var(--background) / 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid hsl(var(--border));
box-shadow: 0 2px 8px hsl(var(--foreground) / 0.08);
border-radius: 9999px;
}
.alphabet-fab:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px hsl(var(--foreground) / 0.15);
}
.alphabet-fab.active {
background: hsl(var(--muted));
}
.alphabet-fab.active .fab-icon {
color: hsl(var(--primary));
}
.alphabet-fab .fab-icon {
width: 1.5rem;
height: 1.5rem;
color: hsl(var(--muted-foreground));
transition: color 0.2s ease;
}
.alphabet-fab:hover .fab-icon {
color: hsl(var(--foreground));
}
</style>

View file

@ -16,6 +16,7 @@ export interface ContactsFilterState {
selectedTagId: string | null;
selectedCompany: string | null;
isToolbarCollapsed: boolean;
isAlphabetNavCollapsed: boolean;
searchQuery: string;
}
@ -26,6 +27,7 @@ const DEFAULT_STATE: ContactsFilterState = {
selectedTagId: null,
selectedCompany: null,
isToolbarCollapsed: true,
isAlphabetNavCollapsed: false,
searchQuery: '',
};
@ -80,6 +82,9 @@ export const contactsFilterStore = {
get isToolbarCollapsed() {
return state.isToolbarCollapsed;
},
get isAlphabetNavCollapsed() {
return state.isAlphabetNavCollapsed;
},
get searchQuery() {
return state.searchQuery;
},
@ -120,6 +125,16 @@ export const contactsFilterStore = {
saveState(state);
},
setAlphabetNavCollapsed(value: boolean) {
state = { ...state, isAlphabetNavCollapsed: value };
saveState(state);
},
toggleAlphabetNav() {
state = { ...state, isAlphabetNavCollapsed: !state.isAlphabetNavCollapsed };
saveState(state);
},
setSearchQuery(value: string) {
state = { ...state, searchQuery: value };
// Don't persist search query to localStorage