Skip to content

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;

神岛实验室