메인 μ½˜ν…μΈ λ‘œ κ±΄λ„ˆλ›°κΈ°
useSelection은 λͺ©λ‘ νŽ˜μ΄μ§€μ—μ„œ μ²΄ν¬λ°•μŠ€λ₯Ό ν†΅ν•œ 닀쀑 선택을 κ΄€λ¦¬ν•˜λŠ” ν›…μž…λ‹ˆλ‹€. Shift+클릭 λ²”μœ„ 선택, 전체 선택/ν•΄μ œ λ“±μ˜ κΈ°λŠ₯을 μ œκ³΅ν•©λ‹ˆλ‹€.

핡심 κΈ°λŠ₯

닀쀑 선택

κ°œλ³„/전체 선택 관리Set 기반 효율적 μƒνƒœ

λ²”μœ„ 선택

Shift+클릭 μ§€μ›μ—°μ†λœ ν•­λͺ© ν•œλ²ˆμ— 선택

μžλ™ 검증

νŽ˜μ΄μ§€ λ³€κ²½ μ‹œ μžλ™ μ •λ¦¬μ‘΄μž¬ν•˜λŠ” ν•­λͺ©λ§Œ μœ μ§€

νƒ€μž… μ•ˆμ „

μ œλ„€λ¦­ νƒ€μž… 지원any νƒ€μž… ν‚€ μ‚¬μš© κ°€λŠ₯

κΈ°λ³Έ μ‚¬μš©λ²•

Import 및 μ΄ˆκΈ°ν™”

import { useSelection } from "@sonamu-kit/react-components";

export function UserListPage() {
  const { data } = UserService.useUsers("A", listParams);
  const allIds = data?.rows.map((row) => row.id) ?? [];

  // useSelection μ΄ˆκΈ°ν™”
  const {
    selectedKeys, // μ„ νƒλœ ν‚€ λ°°μ—΄
    getSelected, // νŠΉμ • ν‚€κ°€ μ„ νƒλ˜μ—ˆλŠ”μ§€ 확인
    toggle, // κ°œλ³„ ν•­λͺ© 선택/ν•΄μ œ ν† κΈ€
    selectAll, // 전체 선택
    deselectAll, // 전체 ν•΄μ œ
    isAllSelected, // 전체 선택 μ—¬λΆ€
    handleCheckboxClick, // Shift+클릭 λ²”μœ„ 선택
  } = useSelection(allIds, []); // allIds: 전체 ν‚€, []: 초기 선택

  // ...
}
νŒŒλΌλ―Έν„° μ„€λͺ…:
  • allIds: ν˜„μž¬ νŽ˜μ΄μ§€μ˜ λͺ¨λ“  ν‚€ λ°°μ—΄ (예: [1, 2, 3, 4, 5])
  • defaultSelectedKeys: 초기 선택 μƒνƒœ (선택 사항, κΈ°λ³Έκ°’: [])

κ°œλ³„ ν•­λͺ© 선택

import { Checkbox } from "@sonamu-kit/react-components/components";

const { getSelected, toggle } = useSelection(allIds, []);

<Checkbox
  checked={getSelected(row.id)}
  onValueChange={() => toggle(row.id)}
/>
λ™μž‘:
  • getSelected(key): ν•΄λ‹Ή ν‚€κ°€ μ„ νƒλ˜μ—ˆμœΌλ©΄ true, μ•„λ‹ˆλ©΄ false
  • toggle(key): 선택 μƒνƒœλ₯Ό λ°˜λŒ€λ‘œ λ³€κ²½

전체 선택/ν•΄μ œ

const { isAllSelected, selectAll, deselectAll } = useSelection(allIds, []);

<Checkbox
  checked={isAllSelected}
  onValueChange={(checked) => {
    if (checked) {
      selectAll();
    } else {
      deselectAll();
    }
  }}
/>
λ™μž‘:
  • isAllSelected: ν˜„μž¬ νŽ˜μ΄μ§€μ˜ λͺ¨λ“  ν•­λͺ©μ΄ μ„ νƒλ˜μ—ˆλŠ”μ§€
  • selectAll(): ν˜„μž¬ νŽ˜μ΄μ§€μ˜ λͺ¨λ“  ν•­λͺ© 선택
  • deselectAll(): λͺ¨λ“  선택 ν•΄μ œ

Shift+클릭 λ²”μœ„ 선택

const { handleCheckboxClick } = useSelection(allIds, []);

{rows.map((row, index) => (
  <tr key={row.id}>
    <td onClick={(e) => handleCheckboxClick(e, index)}>
      <Checkbox
        checked={getSelected(row.id)}
        onValueChange={() => toggle(row.id)}
      />
    </td>
    {/* ... */}
  </tr>
))}
λ™μž‘:
  1. 일반 클릭: κ°œλ³„ ν•­λͺ© 선택/ν•΄μ œ
  2. Shift+클릭: λ§ˆμ§€λ§‰ 선택 μœ„μΉ˜λΆ€ν„° ν˜„μž¬ μœ„μΉ˜κΉŒμ§€ λͺ¨λ‘ 선택
Shift+클릭 λ™μž‘ 원리handleCheckboxClick은 λ§ˆμ§€λ§‰ 클릭 인덱슀λ₯Ό κΈ°μ–΅ν•˜κ³ , Shift+클릭 μ‹œ κ·Έ μ‚¬μ΄μ˜ λͺ¨λ“  ν•­λͺ©μ„ μ„ νƒν•©λ‹ˆλ‹€.
ν•­λͺ©: [A, B, C, D, E]
1. A 클릭 β†’ A 선택
2. D에 Shift+클릭 β†’ A, B, C, D λͺ¨λ‘ 선택

μ‹€μ „ 예제

μ™„μ „ν•œ ν…Œμ΄λΈ” κ΅¬ν˜„

import { useSelection } from "@sonamu-kit/react-components";
import { Checkbox, Button, Table } from "@sonamu-kit/react-components/components";
import { UserService } from "@/services/services.generated";

export function UserListPage() {
  const { data } = UserService.useUsers("A", listParams);
  const { rows } = data ?? {};
  const allIds = rows?.map(row => row.id) ?? [];

  const {
    selectedKeys,
    getSelected,
    toggle,
    selectAll,
    deselectAll,
    isAllSelected,
    handleCheckboxClick,
  } = useSelection(allIds, []);

  // 벌크 μ‚­μ œ
  const handleBulkDelete = async () => {
    if (selectedKeys.length === 0) {
      alert("μ‚­μ œν•  ν•­λͺ©μ„ μ„ νƒν•˜μ„Έμš”");
      return;
    }

    if (confirm(`${selectedKeys.length}개 ν•­λͺ©μ„ μ‚­μ œν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?`)) {
      await UserService.del(selectedKeys);
      deselectAll();  // μ‚­μ œ ν›„ 선택 ν•΄μ œ
    }
  };

  return (
    <div>
      {/* 벌크 μž‘μ—… λ²„νŠΌ */}
      {selectedKeys.length > 0 && (
        <div className="mb-4 flex gap-2">
          <span>{selectedKeys.length}개 선택됨</span>
          <Button variant="red" onClick={handleBulkDelete}>
            선택 ν•­λͺ© μ‚­μ œ
          </Button>
        </div>
      )}

      <Table>
        <thead>
          <tr>
            {/* 전체 선택 μ²΄ν¬λ°•μŠ€ */}
            <th className="w-[40px]">
              <Checkbox
                checked={isAllSelected}
                onValueChange={(checked) => {
                  if (checked) {
                    selectAll();
                  } else {
                    deselectAll();
                  }
                }}
              />
            </th>
            <th>ID</th>
            <th>이름</th>
            <th>이메일</th>
          </tr>
        </thead>
        <tbody>
          {rows?.map((row, index) => (
            <tr key={row.id}>
              {/* κ°œλ³„ 선택 μ²΄ν¬λ°•μŠ€ (Shift+클릭 지원) */}
              <td onClick={(e) => handleCheckboxClick(e, index)}>
                <Checkbox
                  checked={getSelected(row.id)}
                  onValueChange={() => toggle(row.id)}
                />
              </td>
              <td>{row.id}</td>
              <td>{row.username}</td>
              <td>{row.email}</td>
            </tr>
          ))}
        </tbody>
      </Table>
    </div>
  );
}

νŽ˜μ΄μ§€ λ³€κ²½ μ‹œ μžλ™ 검증

useSelection은 allIdsκ°€ λ³€κ²½λ˜λ©΄ μžλ™μœΌλ‘œ 선택 μƒνƒœλ₯Ό κ²€μ¦ν•©λ‹ˆλ‹€.
// 1νŽ˜μ΄μ§€: rows = [1, 2, 3]
const selection1 = useSelection([1, 2, 3], []);
selection1.toggle(1); // 1 선택
selection1.toggle(2); // 2 선택
// selectedKeys = [1, 2]

// 2νŽ˜μ΄μ§€λ‘œ 이동: rows = [4, 5, 6]
const selection2 = useSelection([4, 5, 6], []);
// selectedKeysλŠ” μžλ™μœΌλ‘œ []둜 정리됨 (1, 2κ°€ allIds에 μ—†μŒ)
검증 둜직:
// useSelection λ‚΄λΆ€ (list-helpers.ts:84-91)
useEffect(() => {
  const selectionKeys = Array.from(selection.keys());
  const validKeys = intersection(allKeys, selectionKeys);

  // 변경이 ν•„μš”ν•œ κ²½μš°μ—λ§Œ μƒνƒœ μ—…λ°μ΄νŠΈ
  if (validKeys.length !== selectionKeys.length) {
    setSelection(new Map(validKeys.map((key) => [key, true])));
  }
}, [allKeys, selection]);
μ™œ μžλ™ 검증이 ν•„μš”ν•œκ°€μš”?νŽ˜μ΄μ§€λ₯Ό λ„˜κΈ°κ±°λ‚˜ ν•„ν„°λ₯Ό λ³€κ²½ν•˜λ©΄ rowsκ°€ λ°”λ€λ‹ˆλ‹€. 이전 νŽ˜μ΄μ§€μ—μ„œ μ„ νƒν•œ ν•­λͺ©μ΄ μƒˆ νŽ˜μ΄μ§€μ— 없을 수 μžˆμœΌλ―€λ‘œ, μžλ™μœΌλ‘œ μœ νš¨ν•˜μ§€ μ•Šμ€ 선택을 μ œκ±°ν•©λ‹ˆλ‹€.

μ„ νƒλœ ν•­λͺ©μœΌλ‘œ μž‘μ—…

const { selectedKeys } = useSelection(allIds, []);

// μ„ νƒλœ ν•­λͺ©μ˜ 정보 κ°€μ Έμ˜€κΈ°
const selectedRows = rows?.filter((row) => selectedKeys.includes(row.id)) ?? [];

// 벌크 μˆ˜μ •
const handleBulkUpdate = async () => {
  await UserService.save({
    params: selectedRows.map((row) => ({
      id: row.id,
      status: "active",
    })),
  });
};

// 벌크 내보내기
const handleBulkExport = () => {
  const csv = selectedRows.map((row) => `${row.id},${row.username},${row.email}`).join("\n");

  // CSV λ‹€μš΄λ‘œλ“œ
  const blob = new Blob([csv], { type: "text/csv" });
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = "users.csv";
  a.click();
};

κ³ κΈ‰ μ‚¬μš©

초기 선택 μƒνƒœ

// URL νŒŒλΌλ―Έν„°μ—μ„œ 선택 μƒνƒœ 볡원
const searchParams = new URLSearchParams(window.location.search);
const preselectedIds = searchParams.get("selected")?.split(",").map(Number) ?? [];

const { selectedKeys } = useSelection(allIds, preselectedIds);

선택 μƒνƒœ μ €μž₯

const { selectedKeys } = useSelection(allIds, []);

// 선택 μƒνƒœλ₯Ό localStorage에 μ €μž₯
useEffect(() => {
  localStorage.setItem("selectedUserIds", JSON.stringify(selectedKeys));
}, [selectedKeys]);

// λ‹€μŒ λ°©λ¬Έ μ‹œ 볡원
const savedIds = JSON.parse(localStorage.getItem("selectedUserIds") ?? "[]");
const { selectedKeys } = useSelection(allIds, savedIds);

쑰건뢀 선택 μ œν•œ

const { toggle } = useSelection(allIds, []);

// νŠΉμ • 쑰건의 ν•­λͺ©λ§Œ 선택 κ°€λŠ₯
const handleToggle = (row: UserRow) => {
  if (row.role === "admin") {
    alert("κ΄€λ¦¬μžλŠ” 선택할 수 μ—†μŠ΅λ‹ˆλ‹€");
    return;
  }
  toggle(row.id);
};

<Checkbox
  checked={getSelected(row.id)}
  onValueChange={() => handleToggle(row)}
  disabled={row.role === "admin"}
/>

λ‚΄λΆ€ ꡬ쑰

useSelection은 Map<T, boolean> ꡬ쑰둜 선택 μƒνƒœλ₯Ό κ΄€λ¦¬ν•©λ‹ˆλ‹€.
// λ‚΄λΆ€ μƒνƒœ (λ‹¨μˆœν™”)
const [selection, setSelection] = useState<Map<number, boolean>>(
  new Map([
    [1, true], // ID 1 선택됨
    [2, false], // ID 2 선택 μ•ˆ 됨
    [3, true], // ID 3 선택됨
  ]),
);

// selectedKeys μΆ”μΆœ
const selectedKeys = Array.from(selection)
  .filter(([key, value]) => value === true)
  .map(([key]) => key);
// κ²°κ³Ό: [1, 3]
Map을 μ‚¬μš©ν•˜λŠ” 이유:
  • O(1) μ‹œκ°„ λ³΅μž‘λ„λ‘œ λΉ λ₯Έ 쑰회
  • ν‚€μ˜ νƒ€μž… μ œμ•½ μ—†μŒ (number, string, object λͺ¨λ‘ κ°€λŠ₯)
  • Set보닀 ν™•μž₯μ„± λ†’μŒ (μΆ”κ°€ 메타데이터 μ €μž₯ κ°€λŠ₯)

νƒ€μž… μ•ˆμ „μ„±

useSelection은 μ œλ„€λ¦­μœΌλ‘œ ν‚€ νƒ€μž…μ„ μ§€μ •ν•  수 μžˆμŠ΅λ‹ˆλ‹€.
// number νƒ€μž…
const selection1 = useSelection<number>([1, 2, 3], []);
selection1.toggle(1); // βœ… OK
selection1.toggle("1"); // ❌ νƒ€μž… μ—λŸ¬

// string νƒ€μž…
const selection2 = useSelection<string>(["a", "b", "c"], []);
selection2.toggle("a"); // βœ… OK
selection2.toggle(1); // ❌ νƒ€μž… μ—λŸ¬

// λ³΅μž‘ν•œ 객체 νƒ€μž…
type UserKey = { id: number; email: string };
const keys: UserKey[] = [
  { id: 1, email: "a@example.com" },
  { id: 2, email: "b@example.com" },
];
const selection3 = useSelection<UserKey>(keys, []);

μ£Όμ˜μ‚¬ν•­

1. allIdsλŠ” λ°˜λ“œμ‹œ 제곡

// ❌ 잘λͺ»λœ μ‚¬μš©
const { selectedKeys } = useSelection([], []); // 빈 λ°°μ—΄

// βœ… μ˜¬λ°”λ₯Έ μ‚¬μš©
const allIds = rows?.map((row) => row.id) ?? [];
const { selectedKeys } = useSelection(allIds, []);

2. νŽ˜μ΄μ§€ λ³€κ²½ μ‹œ 선택 μœ μ§€ν•˜κ³  μ‹Άλ‹€λ©΄

기본적으둜 νŽ˜μ΄μ§€λ₯Ό λ„˜κΈ°λ©΄ 선택이 μ΄ˆκΈ°ν™”λ©λ‹ˆλ‹€. μœ μ§€ν•˜λ €λ©΄ 별도 관리가 ν•„μš”ν•©λ‹ˆλ‹€.
// νŽ˜μ΄μ§€ 전체 선택 μƒνƒœ (useState둜 별도 관리)
const [globalSelectedIds, setGlobalSelectedIds] = useState<Set<number>>(new Set());

// ν˜„μž¬ νŽ˜μ΄μ§€ 선택과 동기화
useEffect(() => {
  const currentPageSelected = allIds.filter((id) => globalSelectedIds.has(id));
  // useSelection의 μƒνƒœμ™€ 동기화
}, [allIds, globalSelectedIds]);

3. handleCheckboxClick μ‚¬μš© μ‹œ 주의

handleCheckboxClick은 <td>λ‚˜ μ²΄ν¬λ°•μŠ€ μ»¨ν…Œμ΄λ„ˆμ— μ—°κ²°ν•΄μ•Ό ν•©λ‹ˆλ‹€.
// βœ… μ˜¬λ°”λ₯Έ μ‚¬μš©
<td onClick={(e) => handleCheckboxClick(e, index)}>
  <Checkbox ... />
</td>

// ❌ 잘λͺ»λœ μ‚¬μš© (클릭 μ΄λ²€νŠΈκ°€ μ „νŒŒλ˜μ§€ μ•ŠμŒ)
<Checkbox
  onClick={(e) => handleCheckboxClick(e, index)}
  ...
/>

κ΄€λ ¨ λ¬Έμ„œ