Two-Way Selector - Interactive View
This document showcases a fully functional two-way selector component that supports item selection, batch moving, select all/clear operations, and features smooth animations. This component is widely used in scenarios requiring data transfer between two lists, such as permission allocation, file organization, etc.
Feature Highlights
- Dual List Layout: Clearly displays two different sets of data.
- Multi-Select Operation: Supports both single and batch selection.
- Select All/Clear: One-click operations for all items.
- Smooth Animations: Provides fluid visual feedback during movement.
- Responsive Design: Adapts to different screen sizes.
- Visual Distinction: Uses different color schemes for different lists.
React Implementation
This code was generated by claude-3.7-sonnet.
tsx
// clientApp.tsx
import React, { hooks } from "@dao3fun/react";
const { useState, useEffect, useScreenSize } = hooks;
// Define the list item type
interface ListItem {
id: number;
name: string;
category?: string;
color?: string;
animating?: boolean;
direction?: "left" | "right";
originalIndex?: number;
}
// Example component for a transfer list
function TransferListExample() {
// Get screen size for responsiveness
const { screenWidth, screenHeight } = useScreenSize();
// Calculate component dimensions based on screen size
const containerWidth = Math.min(screenWidth - 40, 800);
const containerHeight = screenHeight;
const listWidth = (containerWidth - 120) / 2;
const listHeight = containerHeight - 80;
// Define data for the left and right lists, adding color for better visual effect
const [leftItems, setLeftItems] = useState<ListItem[]>([
{ id: 1, name: "Apple", color: "#e53935" },
{ id: 2, name: "Banana", color: "#fdd835" },
{ id: 3, name: "Strawberry", color: "#d81b60" },
{ id: 4, name: "Watermelon", color: "#43a047" },
{ id: 5, name: "Orange", color: "#fb8c00" },
{ id: 9, name: "Grape", color: "#8e24aa" },
{ id: 10, name: "Kiwi", color: "#558b2f" },
]);
const [rightItems, setRightItems] = useState<ListItem[]>([
{ id: 6, name: "Potato", color: "#a1887f" },
{ id: 7, name: "Carrot", color: "#ef6c00" },
{ id: 8, name: "Tomato", color: "#d84315" },
]);
// State for selected items
const [selectedLeft, setSelectedLeft] = useState<number[]>([]);
const [selectedRight, setSelectedRight] = useState<number[]>([]);
// State for animations
const [animatingToRight, setAnimatingToRight] = useState(false);
const [animatingToLeft, setAnimatingToLeft] = useState(false);
// State for items being animated
const [animatingItems, setAnimatingItems] = useState<ListItem[]>([]);
const [animationProgress, setAnimationProgress] = useState(0);
const [isAnimating, setIsAnimating] = useState(false);
// Recalculate layout when screen size changes
useEffect(() => {
console.log("Screen size changed, readjusting layout");
}, [screenWidth, screenHeight]);
// Handle left item selection
const handleLeftSelect = (id: number) => {
setSelectedLeft((prev) => {
if (prev.includes(id)) {
return prev.filter((itemId) => itemId !== id);
} else {
return [...prev, id];
}
});
};
// Handle right item selection
const handleRightSelect = (id: number) => {
setSelectedRight((prev) => {
if (prev.includes(id)) {
return prev.filter((itemId) => itemId !== id);
} else {
return [...prev, id];
}
});
};
// Move selected items from left to right
const moveToRight = () => {
if (selectedLeft.length === 0 || isAnimating) return;
// Add animation effect
setAnimatingToRight(true);
setIsAnimating(true);
// Find the items to move
const itemsToMove = leftItems
.filter((item) => selectedLeft.includes(item.id))
.map((item, index) => ({
...item,
animating: true,
direction: "right" as "left" | "right",
originalIndex: leftItems.findIndex((i) => i.id === item.id),
}));
setAnimatingItems(itemsToMove);
// Start the animation
let progress = 0;
const animateInterval = setInterval(() => {
progress += 0.05;
setAnimationProgress(progress);
if (progress >= 1) {
clearInterval(animateInterval);
// Animation complete, update the lists
setRightItems((prev) => [
...prev,
...itemsToMove.map((item) => ({
...item,
animating: false,
direction: undefined,
})),
]);
setLeftItems((prev) =>
prev.filter((item) => !selectedLeft.includes(item.id))
);
setSelectedLeft([]);
setAnimatingToRight(false);
setAnimatingItems([]);
setAnimationProgress(0);
setIsAnimating(false);
console.log(
"Items moved to the right list",
itemsToMove.map((item) => item.name)
);
}
}, 16);
};
// Move selected items from right to left
const moveToLeft = () => {
if (selectedRight.length === 0 || isAnimating) return;
// Add animation effect
setAnimatingToLeft(true);
setIsAnimating(true);
// Find the items to move
const itemsToMove = rightItems
.filter((item) => selectedRight.includes(item.id))
.map((item, index) => ({
...item,
animating: true,
direction: "left" as "left" | "right",
originalIndex: rightItems.findIndex((i) => i.id === item.id),
}));
setAnimatingItems(itemsToMove);
// Start the animation
let progress = 0;
const animateInterval = setInterval(() => {
progress += 0.05;
setAnimationProgress(progress);
if (progress >= 1) {
clearInterval(animateInterval);
// Animation complete, update the lists
setLeftItems((prev) => [
...prev,
...itemsToMove.map((item) => ({
...item,
animating: false,
direction: undefined,
})),
]);
setRightItems((prev) =>
prev.filter((item) => !selectedRight.includes(item.id))
);
setSelectedRight([]);
setAnimatingToLeft(false);
setAnimatingItems([]);
setAnimationProgress(0);
setIsAnimating(false);
console.log(
"Items moved to the left list",
itemsToMove.map((item) => item.name)
);
}
}, 16);
};
// Select all functionality
const selectAllLeft = () => {
setSelectedLeft(leftItems.map((item) => item.id));
};
const selectAllRight = () => {
setSelectedRight(rightItems.map((item) => item.id));
};
// Clear selections
const clearSelectionsLeft = () => {
setSelectedLeft([]);
};
const clearSelectionsRight = () => {
setSelectedRight([]);
};
return (
<box
style={{
backgroundColor: Vec3.create({ r: 245, g: 247, b: 250 }),
size: {
offset: Vec2.create({ x: containerWidth, y: containerHeight }),
},
// Center the display
position: {
offset: Vec2.create({
x: (screenWidth - containerWidth) / 2,
y: (screenHeight - containerHeight) / 2,
}),
},
}}
>
{/* Title Bar */}
<box
style={{
size: { offset: Vec2.create({ x: containerWidth, y: 60 }) },
backgroundColor: Vec3.create({ r: 63, g: 81, b: 181 }),
}}
>
<text
style={{
content: "Interactive Transfer List",
font: "https://meta-os.cdn.bcebos.com/msyhl.ttf",
fontSize: 24,
color: Vec3.create({ r: 255, g: 255, b: 255 }),
position: { offset: Vec2.create({ x: 20, y: 18 }) },
}}
/>
</box>
{/* Main Content Area */}
<box
style={{
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
size: {
offset: Vec2.create({ x: containerWidth, y: containerHeight - 60 }),
},
position: { offset: Vec2.create({ x: 0, y: 60 }) },
}}
>
{/* Left List */}
<List
title="Available"
items={leftItems.filter(
(item) =>
!animatingItems.some(
(animItem) =>
animItem.id === item.id && animItem.direction === "right"
)
)}
selectedItems={selectedLeft}
onSelect={handleLeftSelect}
onSelectAll={selectAllLeft}
onClear={clearSelectionsLeft}
width={listWidth}
height={listHeight}
colorScheme={{
header: "#42a5f5",
bg: "#e3f2fd",
item: "#bbdefb",
selected: "#90caf9",
}}
/>
{/* Action Buttons */}
<box
style={{
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
size: { offset: Vec2.create({ x: 120, y: listHeight }) },
}}
>
<Button
label=">"
onClick={moveToRight}
disabled={selectedLeft.length === 0 || isAnimating}
/>
<Button
label="<"
onClick={moveToLeft}
disabled={selectedRight.length === 0 || isAnimating}
/>
</box>
{/* Right List */}
<List
title="Assigned"
items={rightItems.filter(
(item) =>
!animatingItems.some(
(animItem) =>
animItem.id === item.id && animItem.direction === "left"
)
)}
selectedItems={selectedRight}
onSelect={handleRightSelect}
onSelectAll={selectAllRight}
onClear={clearSelectionsRight}
width={listWidth}
height={listHeight}
colorScheme={{
header: "#66bb6a",
bg: "#e8f5e9",
item: "#c8e6c9",
selected: "#a5d6a7",
}}
/>
</box>
{/* Animated Items */}
{animatingItems.map((item, index) => {
const startX =
item.direction === "right"
? (containerWidth - listWidth * 2 - 120) / 2
: (containerWidth - listWidth * 2 - 120) / 2 + listWidth + 120;
const endX =
item.direction === "right"
? (containerWidth - listWidth * 2 - 120) / 2 + listWidth + 120
: (containerWidth - listWidth * 2 - 120) / 2;
const startY = 80 + (item.originalIndex || 0) * 50;
const endY =
80 +
(item.direction === "right" ? rightItems.length : leftItems.length) *
50;
const currentX = startX + (endX - startX) * animationProgress;
const currentY = startY + (endY - startY) * animationProgress;
return (
<box
key={item.id}
style={{
size: { offset: Vec2.create({ x: listWidth, y: 40 }) },
backgroundColor: hexToVec3(item.color || "#ffffff"),
position: { offset: Vec2.create({ x: currentX, y: currentY }) },
borderRadius: 5,
opacity: 1 - animationProgress,
}}
>
<text
style={{
content: item.name,
font: "https://meta-os.cdn.bcebos.com/msyhl.ttf",
fontSize: 16,
color: Vec3.create({ r: 255, g: 255, b: 255 }),
position: { offset: Vec2.create({ x: 10, y: 12 }) },
}}
/>
</box>
);
})}
</box>
);
}
// List component
function List({
title,
items,
selectedItems,
onSelect,
onSelectAll,
onClear,
width,
height,
colorScheme,
}) {
return (
<box
style={{
size: { offset: Vec2.create({ x: width, y: height }) },
backgroundColor: hexToVec3(colorScheme.bg),
borderRadius: 10,
flexDirection: "column",
}}
>
<box
style={{
size: { offset: Vec2.create({ x: width, y: 40 }) },
backgroundColor: hexToVec3(colorScheme.header),
borderTopLeftRadius: 10,
borderTopRightRadius: 10,
}}
>
<text
style={{
content: `${title} (${selectedItems.length}/${items.length})`,
font: "https://meta-os.cdn.bcebos.com/msyhl.ttf",
fontSize: 18,
color: Vec3.create({ r: 255, g: 255, b: 255 }),
position: { offset: Vec2.create({ x: 10, y: 11 }) },
}}
/>
<box
style={{
flexDirection: "row",
position: {
offset: Vec2.create({ x: width - 110, y: 5 }),
},
}}
>
<Button label="All" onClick={onSelectAll} size="small" />
<Button label="Clear" onClick={onClear} size="small" />
</box>
</box>
<box
style={{
flexDirection: "column",
padding: 10,
size: { offset: Vec2.create({ x: width, y: height - 40 }) },
overflow: "scroll",
}}
>
{items.map((item, index) => (
<ListItem
key={item.id}
item={item}
isSelected={selectedItems.includes(item.id)}
onSelect={onSelect}
colorScheme={colorScheme}
/>
))}
</box>
</box>
);
}
// ListItem component
function ListItem({ item, isSelected, onSelect, colorScheme }) {
const [isHovered, setIsHovered] = useState(false);
return (
<box
style={{
size: { offset: Vec2.create({ x: "100%", y: 40 }) },
backgroundColor: isSelected
? hexToVec3(colorScheme.selected)
: isHovered
? hexToVec3(colorScheme.item, 0.8)
: hexToVec3(colorScheme.item),
marginBottom: 10,
borderRadius: 5,
justifyContent: "center",
}}
onPointerEnter={() => setIsHovered(true)}
onPointerLeave={() => setIsHovered(false)}
onPointerUp={() => onSelect(item.id)}
>
<text
style={{
content: item.name,
font: "https://meta-os.cdn.bcebos.com/msyhl.ttf",
fontSize: 16,
color: Vec3.create({ r: 0, g: 0, b: 0 }),
position: { offset: Vec2.create({ x: 10, y: 0 }) },
}}
/>
</box>
);
}
// Button component
function Button({ label, onClick, disabled, size = "normal" }) {
const [isHovered, setIsHovered] = useState(false);
const buttonWidth = size === "small" ? 40 : 80;
const buttonHeight = size === "small" ? 30 : 40;
return (
<box
style={{
size: { offset: Vec2.create({ x: buttonWidth, y: buttonHeight }) },
backgroundColor: disabled
? Vec3.create({ r: 200, g: 200, b: 200 })
: isHovered
? Vec3.create({ r: 100, g: 120, b: 200 })
: Vec3.create({ r: 63, g: 81, b: 181 }),
borderRadius: 5,
margin: 5,
justifyContent: "center",
alignItems: "center",
}}
onPointerEnter={() => setIsHovered(true)}
onPointerLeave={() => setIsHovered(false)}
onPointerUp={!disabled ? onClick : undefined}
>
<text
style={{
content: label,
font: "https://meta-os.cdn.bcebos.com/msyhl.ttf",
fontSize: size === "small" ? 14 : 18,
color: Vec3.create({ r: 255, g: 255, b: 255 }),
}}
/>
</box>
);
}
// Helper to convert hex color to Vec3
function hexToVec3(hex: string, alpha?: number) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return Vec3.create({ r, g, b });
}
export default TransferListExample;