feat(stage-*): apply ripple grid to other pages (#751)
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { IconItem } from '@proj-airi/stage-ui/components'
|
||||
import { IconItem, RippleGrid } from '@proj-airi/stage-ui/components'
|
||||
import { useRippleGridState } from '@proj-airi/stage-ui/composables/use-ripple-grid-state'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { lastClickedIndex, setLastClickedIndex } = useRippleGridState()
|
||||
|
||||
const settings = computed(() => [
|
||||
{
|
||||
@@ -31,21 +33,22 @@ const settings = computed(() => [
|
||||
<div flex="~ col gap-4" font-normal>
|
||||
<div />
|
||||
<div flex="~ col gap-4">
|
||||
<IconItem
|
||||
v-for="(setting, index) in settings"
|
||||
:key="setting.to"
|
||||
v-motion
|
||||
:initial="{ opacity: 0, y: 10 }"
|
||||
:enter="{ opacity: 1, y: 0 }"
|
||||
:duration="250"
|
||||
:style="{
|
||||
transitionDelay: `${index * 50}ms`, // delay between each item, unocss doesn't support dynamic generation of classes now
|
||||
}"
|
||||
:title="setting.title"
|
||||
:description="setting.description"
|
||||
:icon="setting.icon"
|
||||
:to="setting.to"
|
||||
/>
|
||||
<RippleGrid
|
||||
:items="settings"
|
||||
:get-key="item => item.to"
|
||||
:columns="1"
|
||||
:origin-index="lastClickedIndex"
|
||||
@item-click="({ globalIndex }) => setLastClickedIndex(globalIndex)"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<IconItem
|
||||
:title="item.title"
|
||||
:description="item.description"
|
||||
:icon="item.icon"
|
||||
:to="item.to"
|
||||
/>
|
||||
</template>
|
||||
</RippleGrid>
|
||||
</div>
|
||||
<div
|
||||
v-motion
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { IconItem } from '@proj-airi/stage-ui/components'
|
||||
import { IconItem, RippleGrid } from '@proj-airi/stage-ui/components'
|
||||
import { useRippleGridState } from '@proj-airi/stage-ui/composables/use-ripple-grid-state'
|
||||
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -8,6 +9,7 @@ import { useRouter } from 'vue-router'
|
||||
const router = useRouter()
|
||||
const resolveAnimation = ref<() => void>()
|
||||
const { t } = useI18n()
|
||||
const { lastClickedIndex, setLastClickedIndex } = useRippleGridState()
|
||||
|
||||
const settingsStore = useSettings()
|
||||
|
||||
@@ -72,23 +74,23 @@ const settings = computed(() => [
|
||||
|
||||
<template>
|
||||
<div flex="~ col gap-4" font-normal>
|
||||
<div />
|
||||
<div flex="~ col gap-4" pb-12>
|
||||
<IconItem
|
||||
v-for="(setting, index) in settings"
|
||||
:key="setting.to"
|
||||
v-motion
|
||||
:initial="{ opacity: 0, y: 10 }"
|
||||
:enter="{ opacity: 1, y: 0 }"
|
||||
:duration="250"
|
||||
:style="{
|
||||
transitionDelay: `${index * 50}ms`, // delay between each item, unocss doesn't support dynamic generation of classes now
|
||||
}"
|
||||
:title="setting.title"
|
||||
:description="setting.description"
|
||||
:icon="setting.icon"
|
||||
:to="setting.to"
|
||||
/>
|
||||
<div pb-12>
|
||||
<RippleGrid
|
||||
:items="settings"
|
||||
:get-key="item => item.to"
|
||||
:columns="1"
|
||||
:origin-index="lastClickedIndex"
|
||||
@item-click="({ globalIndex }) => setLastClickedIndex(globalIndex)"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<IconItem
|
||||
:title="item.title"
|
||||
:description="item.description"
|
||||
:icon="item.icon"
|
||||
:to="item.to"
|
||||
/>
|
||||
</template>
|
||||
</RippleGrid>
|
||||
</div>
|
||||
<div
|
||||
v-motion
|
||||
|
||||
@@ -1,28 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { IconStatusItem } from '@proj-airi/stage-ui/components'
|
||||
import { IconStatusItem, RippleGrid } from '@proj-airi/stage-ui/components'
|
||||
import { useModulesList } from '@proj-airi/stage-ui/composables/use-modules-list'
|
||||
import { useRippleGridState } from '@proj-airi/stage-ui/composables/use-ripple-grid-state'
|
||||
|
||||
const { modulesList } = useModulesList()
|
||||
const { lastClickedIndex, setLastClickedIndex } = useRippleGridState()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div grid="~ cols-1 sm:cols-2 gap-4">
|
||||
<IconStatusItem
|
||||
v-for="(module, index) of modulesList"
|
||||
:key="module.id"
|
||||
v-motion
|
||||
:initial="{ opacity: 0, y: 10 }"
|
||||
:enter="{ opacity: 1, y: 0 }"
|
||||
:duration="250 + index * 10"
|
||||
:delay="index * 50"
|
||||
:title="module.name"
|
||||
:description="module.description"
|
||||
:icon="module.icon"
|
||||
:icon-color="module.iconColor"
|
||||
:icon-image="module.iconImage"
|
||||
:to="module.to"
|
||||
:configured="module.configured"
|
||||
/>
|
||||
<div>
|
||||
<RippleGrid
|
||||
:items="modulesList"
|
||||
:columns="{ default: 1, sm: 2 }"
|
||||
:origin-index="lastClickedIndex"
|
||||
@item-click="({ globalIndex }) => setLastClickedIndex(globalIndex)"
|
||||
>
|
||||
<template #item="{ item: module }">
|
||||
<IconStatusItem
|
||||
:title="module.name"
|
||||
:description="module.description"
|
||||
:icon="module.icon"
|
||||
:icon-color="module.iconColor"
|
||||
:icon-image="module.iconImage"
|
||||
:to="module.to"
|
||||
:configured="module.configured"
|
||||
/>
|
||||
</template>
|
||||
</RippleGrid>
|
||||
</div>
|
||||
<div
|
||||
v-motion
|
||||
|
||||
@@ -1,41 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { ProviderMetadata } from '@proj-airi/stage-ui/stores/providers'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { IconStatusItem } from '@proj-airi/stage-ui/components'
|
||||
import { IconStatusItem, RippleGrid } from '@proj-airi/stage-ui/components'
|
||||
import { useRippleGridState } from '@proj-airi/stage-ui/composables/use-ripple-grid-state'
|
||||
import { useScrollToHash } from '@proj-airi/stage-ui/composables/useScrollToHash'
|
||||
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
|
||||
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
import { onBeforeRouteLeave, useRoute } from 'vue-router'
|
||||
|
||||
import { useProvidersPageStore } from './store'
|
||||
|
||||
interface ProviderBlock {
|
||||
id: string
|
||||
icon: string
|
||||
title: string
|
||||
description: string
|
||||
providersRef: Ref<ProviderMetadata[]>
|
||||
}
|
||||
|
||||
interface ProviderRenderData extends ProviderMetadata {
|
||||
renderIndex: number
|
||||
}
|
||||
|
||||
interface ComputedProviderBlock {
|
||||
id: string
|
||||
icon: string
|
||||
title: string
|
||||
description: string
|
||||
providers: ProviderRenderData[]
|
||||
}
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const providersStore = useProvidersStore()
|
||||
const providersPageStore = useProvidersPageStore()
|
||||
const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||
const { lastClickedIndex, setLastClickedIndex } = useRippleGridState()
|
||||
|
||||
const {
|
||||
allChatProvidersMetadata,
|
||||
@@ -43,9 +17,7 @@ const {
|
||||
allAudioTranscriptionProvidersMetadata,
|
||||
} = storeToRefs(providersStore)
|
||||
|
||||
const { lastClickedProviderIndex } = storeToRefs(providersPageStore)
|
||||
|
||||
const providerBlocksConfig: ProviderBlock[] = [
|
||||
const providerBlocksConfig = [
|
||||
{
|
||||
id: 'chat',
|
||||
icon: 'i-solar:chat-square-like-bold-duotone',
|
||||
@@ -69,7 +41,7 @@ const providerBlocksConfig: ProviderBlock[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const providerBlocks = computed<ComputedProviderBlock[]>(() => {
|
||||
const providerBlocks = computed(() => {
|
||||
let globalIndex = 0
|
||||
return providerBlocksConfig.map(block => ({
|
||||
id: block.id,
|
||||
@@ -83,35 +55,6 @@ const providerBlocks = computed<ComputedProviderBlock[]>(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
const cols = computed(() => {
|
||||
if (breakpoints.greaterOrEqual('xl').value) {
|
||||
return 3
|
||||
}
|
||||
if (breakpoints.greaterOrEqual('sm').value) {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
})
|
||||
|
||||
function getAnimationDelay(renderIndex: number) {
|
||||
const numCols = cols.value
|
||||
|
||||
const currentRow = Math.floor(renderIndex / numCols)
|
||||
const currentCol = renderIndex % numCols
|
||||
|
||||
const clickedRow = Math.floor(lastClickedProviderIndex.value / numCols)
|
||||
const clickedCol = lastClickedProviderIndex.value % numCols
|
||||
|
||||
// manhattan distance
|
||||
const distance = Math.abs(currentRow - clickedRow) + Math.abs(currentCol - clickedCol)
|
||||
|
||||
return distance * 80
|
||||
}
|
||||
|
||||
function handleProviderClick(renderIndex: number) {
|
||||
providersPageStore.setLastClickedProviderIndex(renderIndex)
|
||||
}
|
||||
|
||||
useScrollToHash(() => route.hash, {
|
||||
auto: true, // automatically react to route hash
|
||||
offset: 16, // header + margin spacing
|
||||
@@ -119,12 +62,6 @@ useScrollToHash(() => route.hash, {
|
||||
maxRetries: 15, // retry if target element isn't ready
|
||||
retryDelay: 150, // wait between retries
|
||||
})
|
||||
|
||||
onBeforeRouteLeave((to, from) => {
|
||||
if (!to.path.startsWith('/settings/providers/')) {
|
||||
providersPageStore.resetLastClickedProviderIndex()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -145,47 +82,41 @@ onBeforeRouteLeave((to, from) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-for="(block, blockIndex) in providerBlocks" :key="block.id">
|
||||
<div flex="~ row items-center gap-2" :class="{ 'my-5': blockIndex > 0 }">
|
||||
<div :id="block.id" :class="block.icon" text="neutral-500 dark:neutral-400 4xl" />
|
||||
<div>
|
||||
<RippleGrid
|
||||
:sections="providerBlocks"
|
||||
:get-items="block => block.providers"
|
||||
:columns="{ default: 1, sm: 2, xl: 3 }"
|
||||
:origin-index="lastClickedIndex"
|
||||
@item-click="({ globalIndex }) => setLastClickedIndex(globalIndex)"
|
||||
>
|
||||
<template #header="{ section: block }">
|
||||
<div flex="~ row items-center gap-2">
|
||||
<div :id="block.id" :class="block.icon" text="neutral-500 dark:neutral-400 4xl" />
|
||||
<div>
|
||||
<span text="neutral-300 dark:neutral-500 sm sm:base">{{ block.description }}</span>
|
||||
</div>
|
||||
<div flex text-nowrap text="2xl sm:3xl" font-normal>
|
||||
<div>
|
||||
{{ block.title }}
|
||||
<span text="neutral-300 dark:neutral-500 sm sm:base">{{ block.description }}</span>
|
||||
</div>
|
||||
<div flex text-nowrap text="2xl sm:3xl" font-normal>
|
||||
<div>
|
||||
{{ block.title }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div grid="~ cols-1 sm:cols-2 xl:cols-3 gap-4">
|
||||
<div
|
||||
v-for="provider of block.providers"
|
||||
:key="provider.id"
|
||||
v-motion
|
||||
:initial="{ opacity: 0, y: 10 }"
|
||||
:enter="{ opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 250,
|
||||
delay: getAnimationDelay(provider.renderIndex),
|
||||
} }"
|
||||
</template>
|
||||
|
||||
<template #item="{ item: provider }">
|
||||
<IconStatusItem
|
||||
:title="provider.localizedName || 'Unknown'"
|
||||
:description="provider.localizedDescription"
|
||||
:icon="provider.icon"
|
||||
:icon-color="provider.iconColor"
|
||||
:icon-image="provider.iconImage"
|
||||
:to="`/settings/providers/${provider.category}/${provider.id}`"
|
||||
@click="handleProviderClick(provider.renderIndex)"
|
||||
>
|
||||
<IconStatusItem
|
||||
:title="provider.localizedName || 'Unknown'"
|
||||
:description="provider.localizedDescription"
|
||||
:icon="provider.icon"
|
||||
:icon-color="provider.iconColor"
|
||||
:icon-image="provider.iconImage"
|
||||
:to="`/settings/providers/${provider.category}/${provider.id}`"
|
||||
:configured="provider.configured"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
:configured="provider.configured"
|
||||
/>
|
||||
</template>
|
||||
</RippleGrid>
|
||||
</div>
|
||||
<div
|
||||
v-motion
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useProvidersPageStore = defineStore('providersPage', {
|
||||
state: () => ({
|
||||
lastClickedProviderIndex: 0,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
setLastClickedProviderIndex(index: number) {
|
||||
this.lastClickedProviderIndex = index
|
||||
},
|
||||
|
||||
resetLastClickedProviderIndex() {
|
||||
this.lastClickedProviderIndex = 0
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from './data-pane'
|
||||
export * from './gadgets'
|
||||
export * from './graphics'
|
||||
export { default as RippleGrid } from './layout/ripple-grid/RippleGrid.vue'
|
||||
export * from './layouts'
|
||||
export * from './markdown'
|
||||
export * from './menu'
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import RippleGrid from './RippleGrid.vue'
|
||||
|
||||
const sections = ref([
|
||||
{
|
||||
title: 'Section 1',
|
||||
items: Array.from({ length: 51 }, (_, i) => ({ id: `s1-${i}`, label: `Item ${i + 1}` })),
|
||||
},
|
||||
{
|
||||
title: 'Section 2',
|
||||
items: Array.from({ length: 11 }, (_, i) => ({ id: `s2-${i}`, label: `Item ${i + 1}` })),
|
||||
},
|
||||
])
|
||||
|
||||
const flatItems = ref(Array.from({ length: 10 }, (_, i) => ({ id: `flat-${i}`, label: `Flat Item ${i + 1}` })))
|
||||
|
||||
const lastClickedIndex = ref(0)
|
||||
const renderKey = ref(0)
|
||||
|
||||
function handleItemClick({ globalIndex }: { globalIndex: number }) {
|
||||
lastClickedIndex.value = globalIndex
|
||||
renderKey.value++
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="RippleGrid"
|
||||
group="layout"
|
||||
:layout="{ type: 'grid', width: '100%' }"
|
||||
>
|
||||
<Variant
|
||||
id="sections"
|
||||
title="With Sections"
|
||||
>
|
||||
<div p-4>
|
||||
<RippleGrid
|
||||
:key="renderKey"
|
||||
:sections="sections"
|
||||
:get-items="s => s.items"
|
||||
:get-key="i => i.id"
|
||||
:columns="{ default: 6, md: 8, xl: 10 }"
|
||||
:origin-index="lastClickedIndex"
|
||||
@item-click="handleItemClick"
|
||||
>
|
||||
<template #header="{ section }">
|
||||
<h2 mb-2 text-xl font-bold>
|
||||
{{ section.title }}
|
||||
</h2>
|
||||
</template>
|
||||
<template #item="{ item, active }">
|
||||
<div
|
||||
|
||||
h-24 flex items-center justify-center border-2 rounded-lg transition-colors duration-200
|
||||
:class="active ? 'bg-primary-500 text-white border-primary-600' : 'bg-neutral-100 dark:bg-neutral-800 border-transparent hover:bg-neutral-200 dark:hover:bg-neutral-700'"
|
||||
>
|
||||
{{ item.label }}
|
||||
</div>
|
||||
</template>
|
||||
</RippleGrid>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant
|
||||
id="flat"
|
||||
title="No Sections (Flat List)"
|
||||
>
|
||||
<div p-4>
|
||||
<RippleGrid
|
||||
:key="renderKey"
|
||||
:items="flatItems"
|
||||
:columns="1"
|
||||
:origin-index="lastClickedIndex"
|
||||
@item-click="handleItemClick"
|
||||
>
|
||||
<template #item="{ item, active }">
|
||||
<div
|
||||
|
||||
h-24 flex items-center justify-center border-2 rounded-lg transition-colors duration-200
|
||||
:class="active ? 'bg-emerald-500 text-white border-emerald-600' : 'bg-neutral-100 dark:bg-neutral-800 border-transparent hover:bg-neutral-200 dark:hover:bg-neutral-700'"
|
||||
>
|
||||
{{ item.label }}
|
||||
</div>
|
||||
</template>
|
||||
</RippleGrid>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts" generic="TSection, TItem">
|
||||
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
|
||||
import { computed, toRef } from 'vue'
|
||||
|
||||
import { useGridRipple } from './useGridRipple'
|
||||
|
||||
interface VirtualSection {
|
||||
_isVirtual: true
|
||||
items: TItem[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
items?: TItem[]
|
||||
sections?: TSection[]
|
||||
|
||||
getItems?: (section: TSection) => TItem[]
|
||||
getKey?: (item: TItem) => string | number
|
||||
|
||||
columns?: number | Record<string, number>
|
||||
|
||||
originIndex?: number
|
||||
animationInitial?: Record<string, unknown>
|
||||
animationEnter?: Record<string, unknown>
|
||||
animationDuration?: number
|
||||
delayPerUnit?: number
|
||||
}>(), {
|
||||
columns: () => ({ default: 1, sm: 2, xl: 3 }),
|
||||
originIndex: 0,
|
||||
animationInitial: () => ({ opacity: 0, y: 10 }),
|
||||
animationEnter: () => ({ opacity: 1, y: 0 }),
|
||||
animationDuration: 250,
|
||||
delayPerUnit: 80,
|
||||
getItems: (section: any) => section.items || [],
|
||||
getKey: (item: any) => item.id ?? item.key,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
itemClick: [payload: { item: TItem, globalIndex: number }]
|
||||
}>()
|
||||
|
||||
const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||
const COLUMN_ORDER = ['2xl', 'xl', 'lg', 'md', 'sm'] as const
|
||||
|
||||
const isFlat = computed(() => !!props.items && !props.sections)
|
||||
|
||||
const normalizedSections = computed(() => {
|
||||
if (isFlat.value && props.items) {
|
||||
return [{ _isVirtual: true, items: props.items }] as unknown as TSection[]
|
||||
}
|
||||
return props.sections || []
|
||||
})
|
||||
const currentCols = computed(() => {
|
||||
if (typeof props.columns === 'number')
|
||||
return props.columns
|
||||
|
||||
for (const key of COLUMN_ORDER) {
|
||||
if ((props.columns as any)[key] && breakpoints.greaterOrEqual(key).value) {
|
||||
return (props.columns as any)[key]
|
||||
}
|
||||
}
|
||||
return props.columns.default || 1
|
||||
})
|
||||
|
||||
const sectionMeta = computed(() => {
|
||||
let globalCounter = 0
|
||||
return normalizedSections.value.map((section) => {
|
||||
const items = isFlat.value ? (section as unknown as VirtualSection).items : props.getItems(section)
|
||||
const startIndex = globalCounter
|
||||
globalCounter += items.length
|
||||
return { items, startIndex, count: items.length }
|
||||
})
|
||||
})
|
||||
|
||||
const sectionItemCounts = computed(() => sectionMeta.value.map(m => m.count))
|
||||
|
||||
const { getDelay } = useGridRipple({
|
||||
cols: currentCols,
|
||||
originIndex: toRef(props, 'originIndex'),
|
||||
sectionItemCounts,
|
||||
delayPerUnit: props.delayPerUnit,
|
||||
})
|
||||
|
||||
function handleItemClick(item: TItem, globalIndex: number) {
|
||||
emit('itemClick', { item, globalIndex })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-5">
|
||||
<template v-for="(section, sIndex) in normalizedSections" :key="sIndex">
|
||||
<div v-if="$slots.header && !isFlat" :class="{ 'my-5': sIndex > 0 }">
|
||||
<slot name="header" :section="section" :index="sIndex" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid gap-4"
|
||||
:style="{
|
||||
gridTemplateColumns: `repeat(${currentCols}, minmax(0, 1fr))`,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-for="(item, iIndex) in sectionMeta[sIndex].items"
|
||||
:key="props.getKey(item)"
|
||||
v-motion
|
||||
:initial="animationInitial"
|
||||
:enter="{
|
||||
...animationEnter,
|
||||
transition: {
|
||||
duration: animationDuration,
|
||||
delay: getDelay(sectionMeta[sIndex].startIndex + iIndex),
|
||||
},
|
||||
}"
|
||||
@click="handleItemClick(item, sectionMeta[sIndex].startIndex + iIndex)"
|
||||
>
|
||||
<slot
|
||||
name="item"
|
||||
:item="item"
|
||||
:index="sectionMeta[sIndex].startIndex + iIndex"
|
||||
:active="originIndex === sectionMeta[sIndex].startIndex + iIndex"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
import { computed, toValue } from 'vue'
|
||||
|
||||
export interface UseGridRippleOptions {
|
||||
cols: MaybeRefOrGetter<number>
|
||||
originIndex: MaybeRefOrGetter<number>
|
||||
sectionItemCounts: MaybeRefOrGetter<number[]>
|
||||
delayPerUnit?: number
|
||||
}
|
||||
|
||||
export function useGridRipple(options: UseGridRippleOptions) {
|
||||
const { cols, originIndex, sectionItemCounts, delayPerUnit = 80 } = options
|
||||
|
||||
// to property propagate the ripple, we need to know the layout of the grid and map each item onto a coordinate
|
||||
const sectionLayout = computed(() => {
|
||||
const layout: { startLinearIndex: number, startRow: number, itemCount: number }[] = []
|
||||
let currentLinearIndex = 0
|
||||
let currentRow = 0
|
||||
const numCols = toValue(cols)
|
||||
const counts = toValue(sectionItemCounts)
|
||||
|
||||
for (const count of counts) {
|
||||
const rows = Math.ceil(count / numCols)
|
||||
|
||||
layout.push({
|
||||
startLinearIndex: currentLinearIndex,
|
||||
startRow: currentRow,
|
||||
itemCount: count,
|
||||
})
|
||||
|
||||
currentLinearIndex += count
|
||||
currentRow += rows
|
||||
}
|
||||
return layout
|
||||
})
|
||||
|
||||
// precompute the coordinates
|
||||
const coordinateMap = computed(() => {
|
||||
const map = new Map<number, { row: number, col: number }>()
|
||||
const numCols = toValue(cols)
|
||||
|
||||
for (const meta of sectionLayout.value) {
|
||||
for (let i = 0; i < meta.itemCount; i++) {
|
||||
const linearIndex = meta.startLinearIndex + i
|
||||
const localRow = Math.floor(i / numCols)
|
||||
const col = i % numCols
|
||||
const row = meta.startRow + localRow
|
||||
map.set(linearIndex, { row, col })
|
||||
}
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
function getCoordinates(linearIndex: number) {
|
||||
return coordinateMap.value.get(linearIndex) || { row: 0, col: 0 }
|
||||
}
|
||||
|
||||
const originCoordinates = computed(() => getCoordinates(toValue(originIndex) || 0))
|
||||
|
||||
function getDelay(index: number) {
|
||||
const target = getCoordinates(index)
|
||||
const origin = originCoordinates.value
|
||||
const distance = Math.abs(target.row - origin.row) + Math.abs(target.col - origin.col)
|
||||
return distance * delayPerUnit
|
||||
}
|
||||
|
||||
return {
|
||||
getDelay,
|
||||
}
|
||||
}
|
||||
@@ -22,9 +22,9 @@ const subtitle = ref(props.subtitle)
|
||||
const finalizedDisableBackButton = ref(props.disableBackButton)
|
||||
|
||||
const { apply } = useMotion(pageHeaderRef, {
|
||||
initial: { opacity: 0, x: 10, transition: { duration: 250 } },
|
||||
initial: { opacity: 0, x: 10, transition: { duration: 50 } },
|
||||
enter: { opacity: 1, x: 0, transition: { duration: 250 } },
|
||||
leave: { opacity: 0, x: -5, transition: { duration: 100 } },
|
||||
leave: { opacity: 0, x: -5, transition: { duration: 25 } },
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
import { onBeforeRouteLeave, useRoute } from 'vue-router'
|
||||
|
||||
// inlined store since this shouldn't be used anywhere else
|
||||
const useRippleGridStore = defineStore('rippleGrid', {
|
||||
state: () => ({
|
||||
clickedIndices: new Map<string, number>(),
|
||||
}),
|
||||
|
||||
actions: {
|
||||
setClickedIndex(key: string, index: number) {
|
||||
this.clickedIndices.set(key, index)
|
||||
},
|
||||
|
||||
getClickedIndex(key: string) {
|
||||
return this.clickedIndices.get(key) ?? 0
|
||||
},
|
||||
|
||||
resetClickedIndex(key: string) {
|
||||
this.clickedIndices.delete(key)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export function useRippleGridState(key?: string) {
|
||||
const route = useRoute()
|
||||
const store = useRippleGridStore()
|
||||
|
||||
const stateKey = key || route.fullPath
|
||||
|
||||
const lastClickedIndex = computed(() => store.getClickedIndex(stateKey))
|
||||
|
||||
function setLastClickedIndex(index: number) {
|
||||
store.setClickedIndex(stateKey, index)
|
||||
}
|
||||
|
||||
onBeforeRouteLeave((to) => {
|
||||
if (!to.path.startsWith(stateKey)) {
|
||||
store.resetClickedIndex(stateKey)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
lastClickedIndex,
|
||||
setLastClickedIndex,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user