Skip to content

Commit e1ced29

Browse files
committed
Add mobile filter UI with bottom sheet (followup to galaxyproject#1060)
MobileFilterSheet component shows a filter button on mobile that opens a bottom sheet with the category list. Uses same store and URL patterns as the desktop sidebar.
1 parent 293da30 commit e1ced29

File tree

2 files changed

+173
-0
lines changed

2 files changed

+173
-0
lines changed
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
<script setup lang="ts">
2+
import { computed, onMounted, ref } from "vue";
3+
import { useStore } from "@nanostores/vue";
4+
import {
5+
DialogRoot,
6+
DialogTrigger,
7+
DialogPortal,
8+
DialogOverlay,
9+
DialogContent,
10+
DialogTitle,
11+
DialogClose,
12+
} from "radix-vue";
13+
import { Filter, X } from "lucide-vue-next";
14+
import { selectedFilters, toggleFilter, collectionToSlug, setFilterFromUrl } from "../stores/workflowStore";
15+
import type { SearchIndexEntry } from "../models/workflow";
16+
17+
const props = defineProps<{
18+
workflows: SearchIndexEntry[];
19+
}>();
20+
21+
const storeSelected = useStore(selectedFilters);
22+
const isOpen = ref(false);
23+
24+
// Use local ref to avoid hydration mismatch
25+
const selected = ref<string[]>([]);
26+
const isHydrated = ref(false);
27+
28+
// Compute collections from props
29+
const collections = computed(() => {
30+
const collectionSet = new Set<string>();
31+
for (const w of props.workflows) {
32+
for (const c of w.collections) {
33+
collectionSet.add(c);
34+
}
35+
}
36+
return Array.from(collectionSet).sort();
37+
});
38+
39+
// Count workflows per collection
40+
const collectionCounts = computed(() => {
41+
const counts: Record<string, number> = {};
42+
for (const w of props.workflows) {
43+
for (const c of w.collections) {
44+
counts[c] = (counts[c] || 0) + 1;
45+
}
46+
}
47+
return counts;
48+
});
49+
50+
// Sort collections by workflow count (descending)
51+
const sortedCollections = computed(() => {
52+
return [...collections.value].sort((a, b) => {
53+
return (collectionCounts.value[b] || 0) - (collectionCounts.value[a] || 0);
54+
});
55+
});
56+
57+
// Button label shows active filter or default text
58+
const buttonLabel = computed(() => {
59+
if (selected.value.length > 0) {
60+
return selected.value[0];
61+
}
62+
return "Filter by category";
63+
});
64+
65+
onMounted(() => {
66+
setFilterFromUrl();
67+
selected.value = storeSelected.value;
68+
isHydrated.value = true;
69+
});
70+
71+
// Keep local ref in sync with store
72+
selectedFilters.subscribe((value) => {
73+
if (isHydrated.value) {
74+
selected.value = value;
75+
}
76+
});
77+
78+
const handleFilterClick = (filter: string) => {
79+
const currentPath = window.location.pathname;
80+
81+
if (currentPath === "/") {
82+
const wasSelected = selected.value.includes(filter);
83+
toggleFilter(filter);
84+
85+
const params = new URLSearchParams(window.location.search);
86+
if (wasSelected) {
87+
params.delete("filter");
88+
} else {
89+
params.set("filter", filter);
90+
}
91+
92+
const newUrl = params.toString() ? `?${params.toString()}` : "/";
93+
window.history.pushState({}, "", newUrl);
94+
} else {
95+
window.location.href = `/collection/${collectionToSlug(filter)}`;
96+
}
97+
98+
// Close the sheet after selection
99+
isOpen.value = false;
100+
};
101+
</script>
102+
103+
<template>
104+
<div class="block md:hidden mb-4">
105+
<DialogRoot v-model:open="isOpen">
106+
<DialogTrigger as-child>
107+
<button
108+
class="flex items-center gap-2 px-4 py-2 rounded-lg border text-sm font-medium transition-colors"
109+
:class="{
110+
'bg-hokey-pokey-500/10 text-hokey-pokey-600 border-hokey-pokey-500': selected.length > 0,
111+
'bg-white text-chicago-600 border-chicago-300 hover:bg-chicago-50': selected.length === 0,
112+
}">
113+
<Filter class="w-4 h-4" />
114+
<span>{{ buttonLabel }}</span>
115+
</button>
116+
</DialogTrigger>
117+
118+
<DialogPortal>
119+
<DialogOverlay
120+
class="fixed inset-0 bg-black/50 z-40 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
121+
<DialogContent
122+
class="fixed bottom-0 left-0 right-0 z-50 bg-white rounded-t-2xl shadow-xl max-h-[70vh] overflow-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom">
123+
<!-- Handle bar -->
124+
<div class="flex justify-center pt-3 pb-1">
125+
<div class="w-10 h-1 bg-chicago-300 rounded-full"></div>
126+
</div>
127+
128+
<!-- Header -->
129+
<div class="flex items-center justify-between px-4 py-2 border-b border-chicago-200">
130+
<DialogTitle class="text-lg font-semibold text-chicago-800">Categories</DialogTitle>
131+
<DialogClose as-child>
132+
<button
133+
class="p-2 rounded-full hover:bg-chicago-100 text-chicago-500 transition-colors"
134+
aria-label="Close">
135+
<X class="w-5 h-5" />
136+
</button>
137+
</DialogClose>
138+
</div>
139+
140+
<!-- Category list -->
141+
<div class="overflow-y-auto max-h-[calc(70vh-100px)] p-4">
142+
<nav class="flex flex-col gap-1">
143+
<button
144+
v-for="filter in sortedCollections"
145+
:key="filter"
146+
class="group flex items-center justify-between px-3 py-3 rounded-lg text-sm text-left transition-all duration-150 border-l-4 w-full"
147+
:class="{
148+
'bg-hokey-pokey-500/10 text-hokey-pokey-600 border-hokey-pokey-500':
149+
selected.includes(filter),
150+
'text-chicago-600 hover:bg-chicago-100 hover:text-chicago-800 border-transparent':
151+
!selected.includes(filter),
152+
}"
153+
@click="handleFilterClick(filter)">
154+
<span>{{ filter }}</span>
155+
<span
156+
class="text-xs tabular-nums transition-colors ml-2 shrink-0"
157+
:class="{
158+
'text-hokey-pokey-500': selected.includes(filter),
159+
'text-chicago-400 group-hover:text-chicago-500': !selected.includes(filter),
160+
}">
161+
{{ collectionCounts[filter] || 0 }}
162+
</span>
163+
</button>
164+
</nav>
165+
</div>
166+
</DialogContent>
167+
</DialogPortal>
168+
</DialogRoot>
169+
</div>
170+
</template>

website/src/pages/index.astro

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import IWCHeader from "../components/IWCHeader.astro";
44
import HeroSection from "../components/HeroSection.vue";
55
import WorkflowGrid from "../components/WorkflowGrid.vue";
66
import FilterSidebar from "../components/FilterSidebar.vue";
7+
import MobileFilterSheet from "../components/MobileFilterSheet.vue";
78
import searchIndexData from "../../public/data/search-index.json";
89
910
// Define popular workflow TRS IDs
@@ -36,6 +37,8 @@ const POPULAR_WORKFLOW_TRS_IDS = [
3637
<FilterSidebar workflows={searchIndexData} client:load />
3738

3839
<div class="flex-1 min-w-0">
40+
<MobileFilterSheet workflows={searchIndexData} client:load />
41+
3942
<div id="category-description" class="w-full my-4 p-4 bg-white rounded-lg shadow-md" style="display:none;">
4043
<h2 id="category-title" class="text-xl font-semibold mb-4"></h2>
4144
<div id="category-content" class="prose !max-w-none"></div>

0 commit comments

Comments
 (0)