({
- lastClickedProviderIndex: 0,
- }),
-
- actions: {
- setLastClickedProviderIndex(index: number) {
- this.lastClickedProviderIndex = index
- },
-
- resetLastClickedProviderIndex() {
- this.lastClickedProviderIndex = 0
- },
- },
-})
diff --git a/packages/stage-ui/src/components/index.ts b/packages/stage-ui/src/components/index.ts
index c7f788f05..e432108f6 100644
--- a/packages/stage-ui/src/components/index.ts
+++ b/packages/stage-ui/src/components/index.ts
@@ -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'
diff --git a/packages/stage-ui/src/components/layout/ripple-grid/RippleGrid.story.vue b/packages/stage-ui/src/components/layout/ripple-grid/RippleGrid.story.vue
new file mode 100644
index 000000000..0a84e70c8
--- /dev/null
+++ b/packages/stage-ui/src/components/layout/ripple-grid/RippleGrid.story.vue
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
+ {{ section.title }}
+
+
+
+
+ {{ item.label }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.label }}
+
+
+
+
+
+
+
diff --git a/packages/stage-ui/src/components/layout/ripple-grid/RippleGrid.vue b/packages/stage-ui/src/components/layout/ripple-grid/RippleGrid.vue
new file mode 100644
index 000000000..500f08071
--- /dev/null
+++ b/packages/stage-ui/src/components/layout/ripple-grid/RippleGrid.vue
@@ -0,0 +1,125 @@
+
+
+
+
+
diff --git a/packages/stage-ui/src/components/layout/ripple-grid/useGridRipple.ts b/packages/stage-ui/src/components/layout/ripple-grid/useGridRipple.ts
new file mode 100644
index 000000000..ad2e89735
--- /dev/null
+++ b/packages/stage-ui/src/components/layout/ripple-grid/useGridRipple.ts
@@ -0,0 +1,71 @@
+import type { MaybeRefOrGetter } from 'vue'
+
+import { computed, toValue } from 'vue'
+
+export interface UseGridRippleOptions {
+ cols: MaybeRefOrGetter
+ originIndex: MaybeRefOrGetter
+ sectionItemCounts: MaybeRefOrGetter
+ 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()
+ 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,
+ }
+}
diff --git a/packages/stage-ui/src/components/layouts/PageHeader.vue b/packages/stage-ui/src/components/layouts/PageHeader.vue
index 515ed81d8..31b0e9ca2 100644
--- a/packages/stage-ui/src/components/layouts/PageHeader.vue
+++ b/packages/stage-ui/src/components/layouts/PageHeader.vue
@@ -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 () => {
diff --git a/packages/stage-ui/src/composables/use-ripple-grid-state.ts b/packages/stage-ui/src/composables/use-ripple-grid-state.ts
new file mode 100644
index 000000000..6543e809a
--- /dev/null
+++ b/packages/stage-ui/src/composables/use-ripple-grid-state.ts
@@ -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(),
+ }),
+
+ 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,
+ }
+}