mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:01:09 +02:00
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:
parent
68626227e0
commit
4b6a4c73ae
3 changed files with 294 additions and 21 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue