mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 13:28:57 +00:00
feat(web): view-mode selector renders as BottomSheet on mobile (TASK-636) (#166)
Scope note: the task described selectors for view-mode, sort-by, and
group-by, but only view-mode has a visible selector today (a 3-icon
segmented toggle). Sort and group-by are not user-selectable from the
collection page — they're derived from collection settings. Scoping
to the one visible selector that needed help; adding sort/group
selectors is a separate feature.
- On mobile (<640px), the segmented view-mode toggle is replaced by a
labeled chip ("View: Board ▾") that opens a BottomSheet titled
"Choose view" with each option labeled + iconed. Icon-only segmented
buttons are hard to decode on touch; labeled options are clearer.
- On desktop, the segmented 3-icon toggle is unchanged.
- Sheet mounted only when open (gate-on-open pattern).
- Breakpoint-change handler closes the sheet if the viewport leaves
mobile so it doesn't reopen on rotation back.
- saveViewMode + updateUrlFilters semantics preserved (localStorage +
URL sync unchanged).
Parent: PLAN-631.
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
import TableView from '$lib/components/collections/TableView.svelte';
|
||||
import FilterBar from '$lib/components/collections/FilterBar.svelte';
|
||||
import QuickActionsMenu from '$lib/components/common/QuickActionsMenu.svelte';
|
||||
import BottomSheet from '$lib/components/common/BottomSheet.svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { sseService } from '$lib/services/sse.svelte';
|
||||
import { syncService } from '$lib/services/sync.svelte';
|
||||
@@ -392,6 +393,40 @@
|
||||
let filtersOpen = $state(false);
|
||||
let hasActiveFilters = $derived(searchQuery.trim() !== '' || Object.keys(activeFilters).length > 0);
|
||||
|
||||
// ── Viewport detection ───────────────────────────────────────────────
|
||||
// On mobile the 3-icon view toggle (list/board/table) is swapped for a
|
||||
// chip trigger that opens a BottomSheet with labeled options — the raw
|
||||
// icon glyphs are ambiguous on touch and a labeled sheet is clearer.
|
||||
// Desktop keeps the segmented toggle unchanged.
|
||||
let isMobile = $state(false);
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const mq = window.matchMedia('(max-width: 639.98px)');
|
||||
isMobile = mq.matches;
|
||||
const onChange = (e: MediaQueryListEvent) => {
|
||||
isMobile = e.matches;
|
||||
// If the viewport crosses above the mobile breakpoint while the
|
||||
// sheet is open (e.g. rotation), close it so a return to mobile
|
||||
// doesn't immediately re-mount the open sheet.
|
||||
if (!e.matches) {
|
||||
viewSheetOpen = false;
|
||||
}
|
||||
};
|
||||
mq.addEventListener('change', onChange);
|
||||
return () => mq.removeEventListener('change', onChange);
|
||||
});
|
||||
|
||||
let viewSheetOpen = $state(false);
|
||||
let viewModeLabel = $derived(
|
||||
viewMode === 'list' ? 'List' : viewMode === 'board' ? 'Board' : 'Table'
|
||||
);
|
||||
|
||||
function selectViewMode(mode: ViewMode) {
|
||||
saveViewMode(mode);
|
||||
updateUrlFilters();
|
||||
viewSheetOpen = false;
|
||||
}
|
||||
|
||||
function singularName(): string {
|
||||
if (!collection) return 'item';
|
||||
const name = collection.name;
|
||||
@@ -803,29 +838,88 @@
|
||||
</h1>
|
||||
|
||||
<div class="header-actions">
|
||||
<div class="view-toggle">
|
||||
{#if isMobile}
|
||||
<!--
|
||||
Mobile: labeled chip + BottomSheet picker. Icon-only
|
||||
segmented buttons are hard to decode on touch, and the
|
||||
sheet gives each option a clear label.
|
||||
-->
|
||||
<button
|
||||
class="toggle-btn"
|
||||
class:active={viewMode === 'list'}
|
||||
onclick={() => { saveViewMode('list'); updateUrlFilters(); }}
|
||||
aria-label="List view"
|
||||
title="List view"
|
||||
>☰</button>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
class:active={viewMode === 'board'}
|
||||
onclick={() => { saveViewMode('board'); updateUrlFilters(); }}
|
||||
aria-label="Board view"
|
||||
title="Board view"
|
||||
>▦</button>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
class:active={viewMode === 'table'}
|
||||
onclick={() => { saveViewMode('table'); updateUrlFilters(); }}
|
||||
aria-label="Table view"
|
||||
title="Table view"
|
||||
>☷</button>
|
||||
</div>
|
||||
class="view-chip"
|
||||
type="button"
|
||||
onclick={() => (viewSheetOpen = true)}
|
||||
aria-label="Change view"
|
||||
>
|
||||
<span class="view-chip-label">View: {viewModeLabel}</span>
|
||||
<span class="view-chip-caret" aria-hidden="true">▾</span>
|
||||
</button>
|
||||
{#if viewSheetOpen}
|
||||
<!--
|
||||
Gate the sheet on `viewSheetOpen` (gate-on-open
|
||||
pattern from TASK-633) so BottomSheet's global
|
||||
keydown listener isn't mounted when idle.
|
||||
-->
|
||||
<BottomSheet
|
||||
open={viewSheetOpen}
|
||||
onclose={() => (viewSheetOpen = false)}
|
||||
title="Choose view"
|
||||
>
|
||||
<div class="view-sheet-body">
|
||||
<button
|
||||
class="view-sheet-option"
|
||||
class:active={viewMode === 'list'}
|
||||
type="button"
|
||||
onclick={() => selectViewMode('list')}
|
||||
>
|
||||
<span class="view-sheet-icon">☰</span>
|
||||
<span>List</span>
|
||||
</button>
|
||||
<button
|
||||
class="view-sheet-option"
|
||||
class:active={viewMode === 'board'}
|
||||
type="button"
|
||||
onclick={() => selectViewMode('board')}
|
||||
>
|
||||
<span class="view-sheet-icon">▦</span>
|
||||
<span>Board</span>
|
||||
</button>
|
||||
<button
|
||||
class="view-sheet-option"
|
||||
class:active={viewMode === 'table'}
|
||||
type="button"
|
||||
onclick={() => selectViewMode('table')}
|
||||
>
|
||||
<span class="view-sheet-icon">☷</span>
|
||||
<span>Table</span>
|
||||
</button>
|
||||
</div>
|
||||
</BottomSheet>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="view-toggle">
|
||||
<button
|
||||
class="toggle-btn"
|
||||
class:active={viewMode === 'list'}
|
||||
onclick={() => { saveViewMode('list'); updateUrlFilters(); }}
|
||||
aria-label="List view"
|
||||
title="List view"
|
||||
>☰</button>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
class:active={viewMode === 'board'}
|
||||
onclick={() => { saveViewMode('board'); updateUrlFilters(); }}
|
||||
aria-label="Board view"
|
||||
title="Board view"
|
||||
>▦</button>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
class:active={viewMode === 'table'}
|
||||
onclick={() => { saveViewMode('table'); updateUrlFilters(); }}
|
||||
aria-label="Table view"
|
||||
title="Table view"
|
||||
>☷</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="filter-toggle-btn"
|
||||
@@ -1228,6 +1322,70 @@
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* Mobile view chip — replaces the segmented .view-toggle under 640px.
|
||||
The chip shows a labeled summary ("View: Board ▾") that opens a
|
||||
BottomSheet of labeled choices. */
|
||||
.view-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
font-size: 0.85em;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.view-chip:hover {
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.view-chip-caret {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.view-sheet-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 var(--space-2) var(--space-3);
|
||||
}
|
||||
|
||||
.view-sheet-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: var(--space-3);
|
||||
color: var(--text-primary);
|
||||
font-size: 1em;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.view-sheet-option:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.view-sheet-option.active {
|
||||
background: var(--bg-tertiary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.view-sheet-icon {
|
||||
font-size: 1.1em;
|
||||
width: 1.5em;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Filter toggle */
|
||||
.filter-toggle-btn {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user