Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add reorder component #65

Merged
merged 7 commits into from
Feb 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/components/demo/reorder-layout/AddIcon.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="10"
height="10"
viewBox="0 0 20 20"
:style="{ transform: 'rotate(45deg)', stroke: 'black' }"
>
<path
d="M 3 3 L 17 17"
fill="transparent"
stroke-width="3"
stroke-linecap="round"
/>
<path
d="M 17 3 L 3 17"
fill="transparent"
stroke-width="3"
stroke-linecap="round"
/>
</svg>
</template>
49 changes: 49 additions & 0 deletions docs/components/demo/reorder-layout/Tab.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<script setup lang="ts">
import type { Ingredient } from './ingredients'
import { Cross2Icon } from '@radix-icons/vue'
import { ReorderItem, motion } from 'motion-v'

const { item, isSelected } = defineProps<{
item: Ingredient
isSelected: boolean
}>()

defineEmits<{
(e: 'click'): void
(e: 'remove'): void
}>()
</script>

<template>
<ReorderItem
:id="item.label"
:value="item"
:initial="{ opacity: 0, y: 30 }"
:animate="{
opacity: 1,
backgroundColor: isSelected ? '#f3f3f3' : '#fff',
y: 0,
transition: { duration: 0.15 },
}"
:exit="{ opacity: 0, y: 20, transition: { duration: 0.3 } }"
:while-drag="{ backgroundColor: '#e3e3e3' }"
:class="{ selected: isSelected }"
@pointerdown="$emit('click')"
>
<motion.span layout="position">
{{ item.icon }} {{ item.label }}
</motion.span>
<motion.div
layout
class="close"
>
<motion.button
:initial="false"
:animate="{ backgroundColor: isSelected ? '#e3e3e3' : '#fff' }"
@pointerdown.stop="$emit('remove')"
>
<Cross2Icon class="w-4 h-4" />
</motion.button>
</motion.div>
</ReorderItem>
</template>
18 changes: 18 additions & 0 deletions docs/components/demo/reorder-layout/array-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export function removeItem<T>([...arr]: T[], item: T) {
const index = arr.indexOf(item)
index > -1 && arr.splice(index, 1)
return arr
Comment on lines +1 to +4

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The removeItem function uses indexOf to find the index of the item and splice to remove it. This approach has a time complexity of O(n) for both indexOf and splice, leading to potentially inefficient performance for large arrays. Consider using a more efficient method for removing items, such as filtering the array, which can also achieve O(n) but with a single pass:

export function removeItem<T>(arr: T[], item: T) {
  return arr.filter(x => x !== item);
}

}

export function closestItem<T>(arr: T[], item: T) {
const index = arr.indexOf(item)
if (index === -1) {
return arr[0]
}
else if (index === arr.length - 1) {
return arr[arr.length - 2]
}
else {
return arr[index + 1]
}
}
Comment on lines +7 to +18

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The closestItem function does not handle cases where the array has fewer than two elements, which could lead to accessing an undefined index and potentially cause runtime errors. It's important to add checks to ensure the array's length is sufficient before accessing elements by index:

export function closestItem<T>(arr: T[], item: T) {
  if (arr.length < 2) return null; // or handle as appropriate
  const index = arr.indexOf(item);
  if (index === -1) return arr[0];
  else if (index === arr.length - 1) return arr[arr.length - 2];
  else return arr[index + 1];
}

213 changes: 213 additions & 0 deletions docs/components/demo/reorder-layout/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
<script setup lang="ts">
import { ref } from 'vue'
import Tab from './Tab.vue'
import AddIcon from './AddIcon.vue'
import {
type Ingredient,
allIngredients,
getNextIngredient,
initialTabs,
} from './ingredients'
import { closestItem, removeItem } from './array-utils'
import { AnimatePresence, ReorderGroup, motion } from 'motion-v'

const tabs = ref(initialTabs)
const selectedTab = ref(tabs.value[0])

function remove(item: Ingredient) {
if (item === selectedTab.value) {
selectedTab.value = closestItem(tabs.value, item)
}
tabs.value = [...removeItem(tabs.value, item)]
}

function add() {
const nextItem = getNextIngredient(tabs.value)
if (nextItem) {
tabs.value = [...tabs.value, nextItem]
selectedTab.value = nextItem
}
}
</script>

<template>
<div class="mx-auto w-[480px] h-[360px] rounded-lg bg-white overflow-hidden shadow-[0_1px_1px_hsl(0deg_0%_0%_/_0.075),0_2px_2px_hsl(0deg_0%_0%_/_0.075),0_4px_4px_hsl(0deg_0%_0%_/_0.075),0_8px_8px_hsl(0deg_0%_0%_/_0.075),0_16px_16px_hsl(0deg_0%_0%_/_0.075)] flex flex-col">
<LayoutGroup>
<nav>
<ReorderGroup
v-model:values="tabs"
tag="ul"
axis="x"
class="tabs"
>
<AnimatePresence
multiple
:initial="false"
>
<Tab
v-for="item in tabs"
:key="item.label"
:item="item"
:data-size="tabs.length"
:is-selected="selectedTab === item"
@click="selectedTab = item"
@remove="remove(item)"
/>
</AnimatePresence>
</ReorderGroup>
<motion.button
class="add-item flex-shrink-0 flex items-center justify-center"
:disabled="tabs.length === allIngredients.length"
:initial="{ scale: 1 }"
:press="{ scale: 0.9 }"
@click="add"
>
<AddIcon />
</motion.button>
</nav>
</LayoutGroup>
<main>
<AnimatePresence
mode="wait"
:initial="false"
>
<motion.div
:key="selectedTab ? selectedTab.label : 'empty'"
:initial="{ opacity: 1, y: 20 }"
:animate="{ opacity: 1, y: 0 }"
:exit="{ opacity: 0, y: -20 }"
:transition="{ duration: 0.15 }"
>
{{ selectedTab ? selectedTab.icon : '😋' }}
</motion.div>
</AnimatePresence>
</main>
</div>
</template>

<style scoped>
nav {
background: #fdfdfd;
padding: 5px 5px 0;
border-radius: 10px;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
border-bottom: 1px solid #eeeeee;
height: 44px;
display: flex;
max-width: 100%;
overflow: hidden;
}

.tabs {
display: flex;
justify-content: flex-start;
align-items: flex-end;
flex-wrap: nowrap;
padding-right: 10px;
flex:1;
overflow: hidden;
}

main {
display: flex;
justify-content: center;
align-items: center;
font-size: 128px;
flex-grow: 1;
user-select: none;
}

:deep(ul),
:deep(li) {
list-style: none;
padding: 0;
margin: 0;
font-family: "Poppins", sans-serif;
font-weight: 500;
font-size: 14px;
}

:deep(li) {
border-radius: 5px;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
width: 100%;
padding: 10px 15px;
position: relative;
background: white;
cursor: pointer;
height: 44px;
display: flex;
justify-content: space-between;
align-items: center;
flex: 1;
min-width: 0;
overflow: hidden;
position: relative;
user-select: none;
}

:deep(li span) {
flex-shrink: 1;
flex-grow: 1;
line-height: 18px;
white-space: nowrap;
display: block;
min-width: 0;
padding-right: 30px;
mask-image: linear-gradient(to left, transparent 20px, #fff 40px);
-webkit-mask-image: linear-gradient(to left, transparent 20px, #fff 40px);
}

:deep(li .close) {
position: absolute;
top: 0;
bottom: 0;
right: 10px;
display: flex;
align-items: center;
justify-content: flex-end;
flex-shrink: 0;
}

:deep(li button) {
width: 20px;
height: 20px;
border: 0;
background: #fff;
border-radius: 3px;
display: flex;
justify-content: center;
align-items: center;
stroke: #000;
margin-left: 10px;
cursor: pointer;
flex-shrink: 0;
}

.background {
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 300px;
background: #fff;
}

.add-item {
width: 30px;
height: 30px;
background: #eee;
border-radius: 50%;
border: 0;
cursor: pointer;
align-self: center;
}

.add-item:disabled {
opacity: 0.4;
cursor: default;
pointer-events: none;
}
</style>
24 changes: 24 additions & 0 deletions docs/components/demo/reorder-layout/ingredients.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export interface Ingredient {
icon: string
label: string
}

export const allIngredients = [
{ icon: '🍅', label: 'Tomato' },
{ icon: '🥬', label: 'Lettuce' },
{ icon: '🧀', label: 'Cheese' },
{ icon: '🥕', label: 'Carrot' },
{ icon: '🍌', label: 'Banana' },
{ icon: '🫐', label: 'Blueberries' },
{ icon: '🥂', label: 'Champers?' },
]
Comment on lines +6 to +14

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using emojis as icons in allIngredients might limit accessibility and internationalization. Consider using image paths or SVGs instead, which can be more flexible and accessible. For example:

{ icon: '/path/to/tomato.svg', label: 'Tomato' }

This approach allows for better control over the presentation and accessibility features like alt text.


const [tomato, lettuce, cheese] = allIngredients
export const initialTabs = [tomato, lettuce, cheese]

export function getNextIngredient(
ingredients: Ingredient[],
): Ingredient | undefined {
const existing = new Set(ingredients.map(ingredient => ingredient.label))
return allIngredients.find(ingredient => !existing.has(ingredient.label))
}
Comment on lines +23 to +24

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The getNextIngredient function returns undefined if all ingredients are already included in the input list. This behavior should be explicitly handled or documented to avoid potential runtime errors. Consider adding a check or a default return value to handle this scenario more gracefully. For example:

if (allIngredients.every(ingredient => existing.has(ingredient.label))) {
  return defaultIngredient; // or handle the case appropriately
}

32 changes: 32 additions & 0 deletions docs/components/demo/reorder/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ReorderGroup, ReorderItem } from 'motion-v'

const initialItems = ['🍅 Tomato', '🥒 Cucumber', '🧀 Cheese', '🥬 Lettuce']
const items = ref(initialItems)

function setItems(newItems: string[]) {
items.value = newItems
}
</script>

<template>
<ReorderGroup
v-model:values="items"
axis="y"
class="relative w-[300px]"
>
<ReorderItem
v-for="item in items"
:key="item"
:value="item"
drag
class="rounded-lg select-none list-none mb-2 cursor-grab w-full py-4 px-6 bg-purple-500 justify-between flex flex-shrink-0"
>
{{ item }}
</ReorderItem>
</ReorderGroup>
</template>

<style scoped>
</style>
Loading