feat(home): replace horizontal carousel with vertical scroll-snap stack
CI / test (push) Has been cancelled
CI / test (push) Has been cancelled
For multi-frame setups, switch from side-swipe carousel + dot indicators to a vertical scroll-snap stack of full-size cards. Each frame gets its own page-height slide; flicking up/down moves between frames with the same snap-stop feel as the horizontal version. Removes ~30 lines of carousel scroll-tracking JS and the dot navigation. Single-frame layout unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -106,8 +106,8 @@ describe('HomeView', () => {
|
||||
})
|
||||
}
|
||||
|
||||
// HV-01: N devices renders a carousel of N large FrameCard stubs + N dots
|
||||
it('renders one FrameCard per device in a carousel when multiple devices present', async () => {
|
||||
// HV-01: N devices renders a vertical stack of N large FrameCard stubs
|
||||
it('renders one FrameCard per device in a vertical stack when multiple devices present', async () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
devicesStore.devices = [
|
||||
makeDevice({ id: 1, name: 'Frame A' }),
|
||||
@@ -119,58 +119,27 @@ describe('HomeView', () => {
|
||||
const wrapper = mountView()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.find('.home-view__carousel').exists()).toBe(true)
|
||||
expect(wrapper.find('.home-view__stack').exists()).toBe(true)
|
||||
expect(wrapper.findAll('.home-view__slide')).toHaveLength(3)
|
||||
expect(wrapper.findAll('.frame-card-stub')).toHaveLength(3)
|
||||
// All cards should be the large variant (no more compact stack)
|
||||
// All cards should be the large variant (no compact / no carousel)
|
||||
const cards = wrapper.findAllComponents({ name: 'FrameCard' })
|
||||
for (const c of cards) expect(c.props('size')).toBe('large')
|
||||
// One navigation dot per device
|
||||
expect(wrapper.findAll('.home-view__dot')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('marks the first dot active by default and updates active dot on scroll', async () => {
|
||||
it('labels each slide with the device name for accessibility', async () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
devicesStore.devices = [
|
||||
makeDevice({ id: 1, name: 'A' }),
|
||||
makeDevice({ id: 2, name: 'B' }),
|
||||
makeDevice({ id: 1, name: 'Living Room' }),
|
||||
makeDevice({ id: 2, name: 'Bedroom' }),
|
||||
]
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||
|
||||
const wrapper = mountView()
|
||||
await wrapper.vm.$nextTick()
|
||||
let dots = wrapper.findAll('.home-view__dot')
|
||||
expect(dots[0].classes()).toContain('home-view__dot--active')
|
||||
expect(dots[1].classes()).not.toContain('home-view__dot--active')
|
||||
|
||||
// Simulate the carousel having scrolled to the second slide
|
||||
const carousel = wrapper.find('.home-view__carousel').element as HTMLElement
|
||||
Object.defineProperty(carousel, 'clientWidth', { configurable: true, value: 360 })
|
||||
Object.defineProperty(carousel, 'scrollLeft', { configurable: true, value: 360 })
|
||||
await wrapper.find('.home-view__carousel').trigger('scroll')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
dots = wrapper.findAll('.home-view__dot')
|
||||
expect(dots[1].classes()).toContain('home-view__dot--active')
|
||||
expect(dots[0].classes()).not.toContain('home-view__dot--active')
|
||||
})
|
||||
|
||||
it('clicking a dot scrolls the carousel to that slide', async () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
devicesStore.devices = [makeDevice({ id: 1 }), makeDevice({ id: 2 }), makeDevice({ id: 3 })]
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||
|
||||
const wrapper = mountView()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const carousel = wrapper.find('.home-view__carousel').element as HTMLElement
|
||||
Object.defineProperty(carousel, 'clientWidth', { configurable: true, value: 360 })
|
||||
const scrollToSpy = vi.fn()
|
||||
;(carousel as any).scrollTo = scrollToSpy
|
||||
|
||||
await wrapper.findAll('.home-view__dot')[2].trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(scrollToSpy).toHaveBeenCalledWith(expect.objectContaining({ left: 720, behavior: 'smooth' }))
|
||||
const slides = wrapper.findAll('.home-view__slide')
|
||||
expect(slides[0].attributes('aria-label')).toBe('Living Room')
|
||||
expect(slides[1].attributes('aria-label')).toBe('Bedroom')
|
||||
})
|
||||
|
||||
// HV-01b: single device still renders one FrameCard (large variant branch)
|
||||
|
||||
@@ -37,51 +37,34 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Multiple devices — swipeable carousel of large cards -->
|
||||
<template v-else>
|
||||
<!-- Multiple devices — vertical scroll-snap stack of large cards -->
|
||||
<div
|
||||
v-else
|
||||
class="home-view__stack"
|
||||
role="list"
|
||||
aria-label="Frames"
|
||||
>
|
||||
<div
|
||||
ref="carouselEl"
|
||||
class="home-view__carousel"
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
aria-label="Frames"
|
||||
@scroll.passive="onCarouselScroll"
|
||||
v-for="device in devicesStore.devices"
|
||||
:key="device.id"
|
||||
class="home-view__slide"
|
||||
role="listitem"
|
||||
:aria-label="device.name"
|
||||
>
|
||||
<div
|
||||
v-for="device in devicesStore.devices"
|
||||
:key="device.id"
|
||||
class="home-view__slide"
|
||||
role="group"
|
||||
:aria-roledescription="`Slide`"
|
||||
:aria-label="device.name"
|
||||
>
|
||||
<FrameCard
|
||||
:deviceId="device.id"
|
||||
:name="device.name"
|
||||
size="large"
|
||||
:status="deviceStatus(device)"
|
||||
:orientation="device.orientation"
|
||||
:thumbnailUrl="previewUrl(device)"
|
||||
:lastSync="lastSyncLabel(device)"
|
||||
:nextSync="nextSyncLabel(device)"
|
||||
@add-photo="onAddPhoto"
|
||||
@edit="onEdit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="home-view__dots" role="tablist" aria-label="Frame selector">
|
||||
<button
|
||||
v-for="(d, i) in devicesStore.devices"
|
||||
:key="d.id"
|
||||
type="button"
|
||||
role="tab"
|
||||
:class="['home-view__dot', { 'home-view__dot--active': i === activeSlide }]"
|
||||
:aria-label="`Go to ${d.name}`"
|
||||
:aria-selected="i === activeSlide"
|
||||
@click="goToSlide(i)"
|
||||
<FrameCard
|
||||
:deviceId="device.id"
|
||||
:name="device.name"
|
||||
size="large"
|
||||
:status="deviceStatus(device)"
|
||||
:orientation="device.orientation"
|
||||
:thumbnailUrl="previewUrl(device)"
|
||||
:lastSync="lastSyncLabel(device)"
|
||||
:nextSync="nextSyncLabel(device)"
|
||||
@add-photo="onAddPhoto"
|
||||
@edit="onEdit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Frame settings sheet -->
|
||||
@@ -131,7 +114,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, nextTick } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useDevicesStore } from '@/stores/devices'
|
||||
import { useUploadStore } from '@/stores/upload'
|
||||
@@ -209,25 +192,6 @@ onMounted(() => {
|
||||
devicesStore.fetchDevices()
|
||||
})
|
||||
|
||||
// ── Multi-device carousel ─────────────────────────────────────────────────────
|
||||
|
||||
const carouselEl = ref<HTMLElement | null>(null)
|
||||
const activeSlide = ref(0)
|
||||
|
||||
function onCarouselScroll() {
|
||||
const el = carouselEl.value
|
||||
if (!el) return
|
||||
// Each slide is 100% of the carousel width — stride is clientWidth.
|
||||
activeSlide.value = Math.round(el.scrollLeft / el.clientWidth)
|
||||
}
|
||||
|
||||
async function goToSlide(i: number) {
|
||||
await nextTick()
|
||||
const el = carouselEl.value
|
||||
if (!el) return
|
||||
el.scrollTo({ left: i * el.clientWidth, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
function onAddPhoto(deviceId: number) {
|
||||
// File picker must be triggered in the user-gesture context (the click handler)
|
||||
// before navigating, otherwise browsers block it as a popup.
|
||||
@@ -403,55 +367,33 @@ async function saveSettings() {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
// Carousel: full-bleed horizontal scroll-snap. The container ignores the
|
||||
// page's side padding so cards reach edge-to-edge; each slide pads itself
|
||||
// back in so the card visually aligns with the rest of the page.
|
||||
&__carousel {
|
||||
// Vertical scroll-snap stack of full-size cards. Each slide takes a full
|
||||
// screen-port height and snaps as the user scrolls, so flipping between
|
||||
// frames feels deliberate (like swiping pages) rather than continuous
|
||||
// scrolling through a feed.
|
||||
&__stack {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-snap-type: x mandatory;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
overflow-y: auto;
|
||||
scroll-snap-type: y mandatory;
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-width: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
margin: 0 calc(-1 * var(--space-4));
|
||||
|
||||
&::-webkit-scrollbar { display: none; }
|
||||
}
|
||||
|
||||
&__slide {
|
||||
flex: 0 0 100%;
|
||||
flex: 0 0 auto;
|
||||
min-height: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0 var(--space-4);
|
||||
scroll-snap-align: start;
|
||||
scroll-snap-stop: always;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__dots {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
padding-top: var(--space-3);
|
||||
}
|
||||
|
||||
&__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: var(--color-border);
|
||||
cursor: pointer;
|
||||
transition: background var(--duration-fast), transform var(--duration-fast);
|
||||
|
||||
&--active {
|
||||
background: var(--color-primary);
|
||||
transform: scale(1.25);
|
||||
}
|
||||
scroll-snap-align: start;
|
||||
scroll-snap-stop: always;
|
||||
}
|
||||
|
||||
&__sheet-title {
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{_ as e,d as t,g as n,j as r,k as i,l as a,o,p as s,pt as c,t as l,u,v as d,z as f}from"./_plugin-vue_export-helper-DVo1OUMD.js";import{n as p,t as m}from"./BaseBottomSheet-CXcLrct9.js";var h={class:`device-picker__list`},g=[`checked`,`onChange`],_={class:`device-picker__name`},v={class:`device-picker__orientation`},y=l(d({__name:`DevicePicker`,props:{modelValue:{type:Boolean},devices:{},selected:{},uploading:{type:Boolean}},emits:[`update:modelValue`,`update:selected`,`confirm`],setup(l,{emit:d}){let y=l,b=d;function x(e){y.selected.includes(e)?b(`update:selected`,y.selected.filter(t=>t!==e)):b(`update:selected`,[...y.selected,e])}let S=a(()=>{let e=y.selected.length;return e===0?`Add to frame`:`Add to ${e} frame${e>1?`s`:``}`});return(a,d)=>(i(),t(m,{"model-value":l.modelValue,label:`Choose frames`,"onUpdate:modelValue":d[1]||=e=>a.$emit(`update:modelValue`,e)},{default:f(()=>[d[2]||=u(`h2`,{class:`device-picker__title`},`Add to frames`,-1),d[3]||=u(`p`,{class:`device-picker__sub`},`Choose which frames will show this photo.`,-1),u(`div`,h,[(i(!0),s(o,null,r(l.devices,e=>(i(),s(`label`,{key:e.id,class:`device-picker__row`},[u(`input`,{type:`checkbox`,class:`device-picker__check`,checked:l.selected.includes(e.id),onChange:t=>x(e.id)},null,40,g),u(`span`,_,c(e.name),1),u(`span`,v,c(e.orientation),1)]))),128))]),e(p,{variant:`primary`,class:`device-picker__confirm`,disabled:l.selected.length===0||l.uploading,onClick:d[0]||=e=>a.$emit(`confirm`)},{default:f(()=>[n(c(l.uploading?`Uploading…`:S.value),1)]),_:1},8,[`disabled`])]),_:1},8,[`model-value`]))}}),[[`__scopeId`,`data-v-a6466fa5`]]);export{y as t};
|
||||
import{_ as e,d as t,g as n,j as r,k as i,l as a,o,p as s,pt as c,t as l,u,v as d,z as f}from"./_plugin-vue_export-helper-DVo1OUMD.js";import{n as p,t as m}from"./BaseBottomSheet-DYbXyS7e.js";var h={class:`device-picker__list`},g=[`checked`,`onChange`],_={class:`device-picker__name`},v={class:`device-picker__orientation`},y=l(d({__name:`DevicePicker`,props:{modelValue:{type:Boolean},devices:{},selected:{},uploading:{type:Boolean}},emits:[`update:modelValue`,`update:selected`,`confirm`],setup(l,{emit:d}){let y=l,b=d;function x(e){y.selected.includes(e)?b(`update:selected`,y.selected.filter(t=>t!==e)):b(`update:selected`,[...y.selected,e])}let S=a(()=>{let e=y.selected.length;return e===0?`Add to frame`:`Add to ${e} frame${e>1?`s`:``}`});return(a,d)=>(i(),t(m,{"model-value":l.modelValue,label:`Choose frames`,"onUpdate:modelValue":d[1]||=e=>a.$emit(`update:modelValue`,e)},{default:f(()=>[d[2]||=u(`h2`,{class:`device-picker__title`},`Add to frames`,-1),d[3]||=u(`p`,{class:`device-picker__sub`},`Choose which frames will show this photo.`,-1),u(`div`,h,[(i(!0),s(o,null,r(l.devices,e=>(i(),s(`label`,{key:e.id,class:`device-picker__row`},[u(`input`,{type:`checkbox`,class:`device-picker__check`,checked:l.selected.includes(e.id),onChange:t=>x(e.id)},null,40,g),u(`span`,_,c(e.name),1),u(`span`,v,c(e.orientation),1)]))),128))]),e(p,{variant:`primary`,class:`device-picker__confirm`,disabled:l.selected.length===0||l.uploading,onClick:d[0]||=e=>a.$emit(`confirm`)},{default:f(()=>[n(c(l.uploading?`Uploading…`:S.value),1)]),_:1},8,[`disabled`])]),_:1},8,[`model-value`]))}}),[[`__scopeId`,`data-v-a6466fa5`]]);export{y as t};
|
||||
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{K as e,dt as t,f as n,ft as r,j as i,k as a,l as o,o as s,p as c,pt as l,t as u,u as d,v as f}from"./_plugin-vue_export-helper-DVo1OUMD.js";import{n as p,r as m,t as h}from"./index-aP_uBWCi.js";var g={class:`settings`},_={class:`settings__section`},v={class:`theme-grid`,role:`radiogroup`,"aria-label":`Choose theme`},y=[`aria-checked`,`aria-label`,`onClick`],b={class:`theme-swatch__label`},x={key:0,class:`theme-swatch__check`,"aria-hidden":`true`},S={class:`settings__section`},C={class:`settings__row`},w={class:`settings__row-value`},T=u(f({__name:`SettingsView`,setup(u){let f=m(),{saveTheme:T}=p(),E=o(()=>f.user?.theme??`warm-craft`);function D(e){T(e)}return(o,u)=>(a(),c(`main`,g,[u[5]||=d(`h1`,{class:`settings__title`},`Settings`,-1),d(`section`,_,[u[1]||=d(`h2`,{class:`settings__section-title`},`Theme`,-1),d(`div`,v,[(a(!0),c(s,null,i(e(h),e=>(a(),c(`button`,{key:e.id,type:`button`,role:`radio`,"aria-checked":E.value===e.id,"aria-label":e.label,class:t([`theme-swatch`,{"theme-swatch--active":E.value===e.id}]),style:r({"--swatch-bg":e.bg,"--swatch-primary":e.primary,"--swatch-text":e.text}),onClick:t=>D(e.id)},[u[0]||=d(`span`,{class:`theme-swatch__preview`,"aria-hidden":`true`},[d(`span`,{class:`theme-swatch__bar`}),d(`span`,{class:`theme-swatch__dot`})],-1),d(`span`,b,l(e.label),1),E.value===e.id?(a(),c(`span`,x,`✓`)):n(``,!0)],14,y))),128))])]),d(`section`,S,[u[3]||=d(`h2`,{class:`settings__section-title`},`Account`,-1),d(`div`,C,[u[2]||=d(`span`,{class:`settings__row-label`},`Signed in as`,-1),d(`span`,w,l(e(f).user?.email),1)]),u[4]||=d(`a`,{href:`/logout`,class:`settings__logout`},`Sign out`,-1)])]))}}),[[`__scopeId`,`data-v-76ec3881`]]);export{T as default};
|
||||
import{K as e,dt as t,f as n,ft as r,j as i,k as a,l as o,o as s,p as c,pt as l,t as u,u as d,v as f}from"./_plugin-vue_export-helper-DVo1OUMD.js";import{n as p,r as m,t as h}from"./index-Dt4UyE7n.js";var g={class:`settings`},_={class:`settings__section`},v={class:`theme-grid`,role:`radiogroup`,"aria-label":`Choose theme`},y=[`aria-checked`,`aria-label`,`onClick`],b={class:`theme-swatch__label`},x={key:0,class:`theme-swatch__check`,"aria-hidden":`true`},S={class:`settings__section`},C={class:`settings__row`},w={class:`settings__row-value`},T=u(f({__name:`SettingsView`,setup(u){let f=m(),{saveTheme:T}=p(),E=o(()=>f.user?.theme??`warm-craft`);function D(e){T(e)}return(o,u)=>(a(),c(`main`,g,[u[5]||=d(`h1`,{class:`settings__title`},`Settings`,-1),d(`section`,_,[u[1]||=d(`h2`,{class:`settings__section-title`},`Theme`,-1),d(`div`,v,[(a(!0),c(s,null,i(e(h),e=>(a(),c(`button`,{key:e.id,type:`button`,role:`radio`,"aria-checked":E.value===e.id,"aria-label":e.label,class:t([`theme-swatch`,{"theme-swatch--active":E.value===e.id}]),style:r({"--swatch-bg":e.bg,"--swatch-primary":e.primary,"--swatch-text":e.text}),onClick:t=>D(e.id)},[u[0]||=d(`span`,{class:`theme-swatch__preview`,"aria-hidden":`true`},[d(`span`,{class:`theme-swatch__bar`}),d(`span`,{class:`theme-swatch__dot`})],-1),d(`span`,b,l(e.label),1),E.value===e.id?(a(),c(`span`,x,`✓`)):n(``,!0)],14,y))),128))])]),d(`section`,S,[u[3]||=d(`h2`,{class:`settings__section-title`},`Account`,-1),d(`div`,C,[u[2]||=d(`span`,{class:`settings__row-label`},`Signed in as`,-1),d(`span`,w,l(e(f).user?.email),1)]),u[4]||=d(`a`,{href:`/logout`,class:`settings__logout`},`Sign out`,-1)])]))}}),[[`__scopeId`,`data-v-76ec3881`]]);export{T as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -14,7 +14,7 @@
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="pictureFrame" />
|
||||
<script type="module" crossorigin src="/build/assets/index-aP_uBWCi.js"></script>
|
||||
<script type="module" crossorigin src="/build/assets/index-Dt4UyE7n.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-DVo1OUMD.js">
|
||||
<link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
|
||||
</head>
|
||||
|
||||
Reference in New Issue
Block a user