-
-
Notifications
You must be signed in to change notification settings - Fork 36
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
Changes from all commits
e8a1d56
b1cf208
a8768e6
35d8113
1761f5a
ff39756
4a2d7cb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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> |
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> |
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 | ||
} | ||
|
||
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The 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];
} |
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> |
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using emojis as icons in { icon: '/path/to/tomato.svg', label: 'Tomato' } This approach allows for better control over the presentation and accessibility features like |
||
|
||
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The if (allIngredients.every(ingredient => existing.has(ingredient.label))) {
return defaultIngredient; // or handle the case appropriately
} |
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> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
removeItem
function usesindexOf
to find the index of the item andsplice
to remove it. This approach has a time complexity of O(n) for bothindexOf
andsplice
, 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: