feat(deck): mobile support — scrollable slides, swipe, desktop notice
Content-heavy slides were clipped on phones (fixed h-dvh + centered overflow-hidden collided with the top/bottom bars). SlideFrame now scrolls and top-aligns below md (clearing the header/controls), while desktop keeps the centered, full-bleed look unchanged. Add horizontal touch-swipe navigation (vertical scroll still works). Add a shadcn-vue Alert component and show a mobile-only "Best viewed on desktop" notice on the home slide; keyboard hint is desktop-only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9b06e69f06
commit
5cdbb76ab7
7 changed files with 94 additions and 4 deletions
16
src/components/ui/alert/Alert.vue
Normal file
16
src/components/ui/alert/Alert.vue
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { type AlertVariants, alertVariants } from '.'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
variant?: AlertVariants['variant']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div data-slot="alert" role="alert" :class="cn(alertVariants({ variant }), props.class)">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
12
src/components/ui/alert/AlertDescription.vue
Normal file
12
src/components/ui/alert/AlertDescription.vue
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div data-slot="alert-description" :class="cn('text-sm [&_p]:leading-relaxed', props.class)">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
12
src/components/ui/alert/AlertTitle.vue
Normal file
12
src/components/ui/alert/AlertTitle.vue
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h5 data-slot="alert-title" :class="cn('mb-1 font-medium leading-none tracking-tight', props.class)">
|
||||||
|
<slot />
|
||||||
|
</h5>
|
||||||
|
</template>
|
||||||
23
src/components/ui/alert/index.ts
Normal file
23
src/components/ui/alert/index.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
|
||||||
|
export { default as Alert } from './Alert.vue'
|
||||||
|
export { default as AlertTitle } from './AlertTitle.vue'
|
||||||
|
export { default as AlertDescription } from './AlertDescription.vue'
|
||||||
|
|
||||||
|
export const alertVariants = cva(
|
||||||
|
'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-background text-foreground',
|
||||||
|
destructive:
|
||||||
|
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export type AlertVariants = VariantProps<typeof alertVariants>
|
||||||
|
|
@ -49,6 +49,22 @@ const slides = [
|
||||||
const { index, direction, goto, next, prev, isFirst, isLast } = useDeck(slides.length)
|
const { index, direction, goto, next, prev, isFirst, isLast } = useDeck(slides.length)
|
||||||
const current = computed(() => slides[index.value])
|
const current = computed(() => slides[index.value])
|
||||||
const transitionName = computed(() => (direction.value === 1 ? 'slide-next' : 'slide-prev'))
|
const transitionName = computed(() => (direction.value === 1 ? 'slide-next' : 'slide-prev'))
|
||||||
|
|
||||||
|
// swipe navigation (touch) — horizontal only, so vertical scrolling still works
|
||||||
|
let touchX = 0
|
||||||
|
let touchY = 0
|
||||||
|
function onTouchStart(e: TouchEvent) {
|
||||||
|
touchX = e.changedTouches[0].clientX
|
||||||
|
touchY = e.changedTouches[0].clientY
|
||||||
|
}
|
||||||
|
function onTouchEnd(e: TouchEvent) {
|
||||||
|
const dx = e.changedTouches[0].clientX - touchX
|
||||||
|
const dy = e.changedTouches[0].clientY - touchY
|
||||||
|
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5) {
|
||||||
|
if (dx < 0) next()
|
||||||
|
else prev()
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -75,7 +91,7 @@ const transitionName = computed(() => (direction.value === 1 ? 'slide-next' : 's
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- slide stage -->
|
<!-- slide stage -->
|
||||||
<main class="h-full w-full">
|
<main class="h-full w-full" @touchstart.passive="onTouchStart" @touchend.passive="onTouchEnd">
|
||||||
<Transition :name="transitionName" mode="out-in">
|
<Transition :name="transitionName" mode="out-in">
|
||||||
<KeepAlive>
|
<KeepAlive>
|
||||||
<component :is="current.component" :key="current.id" class="h-full w-full" />
|
<component :is="current.component" :key="current.id" class="h-full w-full" />
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ defineProps<{
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section
|
<section
|
||||||
class="mx-auto flex h-full w-full max-w-6xl flex-col px-8 py-14 sm:px-14 lg:px-20"
|
class="mx-auto flex h-full w-full max-w-6xl flex-col overflow-y-auto px-5 pt-20 pb-24 md:overflow-visible md:px-14 md:py-14 lg:px-20"
|
||||||
:class="center ? 'items-center justify-center text-center' : 'justify-center'"
|
:class="[center ? 'items-center text-center' : '', 'justify-start md:justify-center']"
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
v-if="kicker"
|
v-if="kicker"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Monitor } from '@lucide/vue'
|
||||||
import SlideFrame from '../components/SlideFrame.vue'
|
import SlideFrame from '../components/SlideFrame.vue'
|
||||||
import PlaceholderBadge from '../components/PlaceholderBadge.vue'
|
import PlaceholderBadge from '../components/PlaceholderBadge.vue'
|
||||||
|
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||||
import { brand, surfaceEntities, accentText } from '../data'
|
import { brand, surfaceEntities, accentText } from '../data'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -40,7 +42,16 @@ import { brand, surfaceEntities, accentText } from '../data'
|
||||||
Raising now · {{ brand.firstNode }}
|
Raising now · {{ brand.firstNode }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<p class="mt-10 text-xs text-fg-muted">
|
<!-- mobile-only: this deck is built for desktop -->
|
||||||
|
<Alert class="mt-8 max-w-sm border-amber-400/40 bg-amber-400/10 text-left md:hidden">
|
||||||
|
<Monitor class="size-4" />
|
||||||
|
<AlertTitle>Best viewed on desktop</AlertTitle>
|
||||||
|
<AlertDescription class="text-fg-muted">
|
||||||
|
This investment deck is designed for a larger screen. Swipe to browse the highlights.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<p class="mt-10 hidden text-xs text-fg-muted md:block">
|
||||||
Use <kbd class="rounded border border-line-2 px-1.5 py-0.5">→</kbd> /
|
Use <kbd class="rounded border border-line-2 px-1.5 py-0.5">→</kbd> /
|
||||||
<kbd class="rounded border border-line-2 px-1.5 py-0.5">←</kbd> to navigate
|
<kbd class="rounded border border-line-2 px-1.5 py-0.5">←</kbd> to navigate
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue