CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl/npm-react-types--shared

Shared TypeScript type definitions for React Spectrum components and hooks, providing common interfaces for DOM interactions, styling, accessibility, internationalization, and component behavior across the React Spectrum ecosystem

Pending
Overview
Eval results
Files

selection.mddocs/

Selection Management

Single and multiple selection patterns with keyboard navigation, disabled items, and selection behavior configuration for building interactive collections.

Capabilities

Selection Types

Core types for managing selection state and behavior.

/** Selection mode options */
type SelectionMode = "none" | "single" | "multiple";

/** Selection behavior options */
type SelectionBehavior = "toggle" | "replace";

/** Selection can be all items or a specific set of keys */
type Selection = "all" | Set<Key>;

/** Focus strategy for keyboard navigation */
type FocusStrategy = "first" | "last";

/** Disabled behavior options */
type DisabledBehavior = "selection" | "all";

Single Selection

Interface for collections that support selecting a single item.

/**
 * Properties for single selection collections
 */
interface SingleSelection {
  /** Whether the collection allows empty selection */
  disallowEmptySelection?: boolean;
  /** The currently selected key in the collection (controlled) */
  selectedKey?: Key | null;
  /** The initial selected key in the collection (uncontrolled) */
  defaultSelectedKey?: Key;
  /** Handler that is called when the selection changes */
  onSelectionChange?: (key: Key | null) => void;
}

Multiple Selection

Interface for collections that support selecting multiple items.

/**
 * Properties for multiple selection collections
 */
interface MultipleSelection {
  /** The type of selection that is allowed in the collection */
  selectionMode?: SelectionMode;
  /** Whether the collection allows empty selection */
  disallowEmptySelection?: boolean;
  /** The currently selected keys in the collection (controlled) */
  selectedKeys?: "all" | Iterable<Key>;
  /** The initial selected keys in the collection (uncontrolled) */
  defaultSelectedKeys?: "all" | Iterable<Key>;
  /** Handler that is called when the selection changes */
  onSelectionChange?: (keys: Selection) => void;
  /** The currently disabled keys in the collection (controlled) */
  disabledKeys?: Iterable<Key>;
}

Spectrum Selection Properties

Spectrum-specific selection styling options.

/**
 * Spectrum-specific selection properties
 */
interface SpectrumSelectionProps {
  /** How selection should be displayed */
  selectionStyle?: "checkbox" | "highlight";
}

Usage Examples:

import { 
  SingleSelection, 
  MultipleSelection, 
  Selection, 
  SelectionMode,
  Key 
} from "@react-types/shared";

// Single selection list
interface SingleSelectListProps extends SingleSelection {
  items: Array<{ id: Key; name: string }>;
  children?: React.ReactNode;
}

function SingleSelectList({ 
  items, 
  selectedKey, 
  defaultSelectedKey, 
  onSelectionChange,
  disallowEmptySelection = false,
  children 
}: SingleSelectListProps) {
  const [internalSelectedKey, setInternalSelectedKey] = useState<Key | null>(
    defaultSelectedKey || null
  );

  const currentSelectedKey = selectedKey !== undefined ? selectedKey : internalSelectedKey;

  const handleSelectionChange = (key: Key | null) => {
    // Check if empty selection is allowed
    if (key === null && disallowEmptySelection && currentSelectedKey !== null) {
      return; // Don't allow deselection
    }

    if (selectedKey === undefined) {
      setInternalSelectedKey(key);
    }
    onSelectionChange?.(key);
  };

  const handleItemClick = (key: Key) => {
    // Toggle selection - if clicking on already selected item, deselect it
    const newKey = currentSelectedKey === key ? null : key;
    handleSelectionChange(newKey);
  };

  return (
    <div role="listbox" aria-label="Single select list">
      {items.map(item => (
        <div
          key={item.id}
          role="option"
          aria-selected={currentSelectedKey === item.id}
          onClick={() => handleItemClick(item.id)}
          style={{
            padding: "8px",
            backgroundColor: currentSelectedKey === item.id ? "#e0e0e0" : "transparent",
            cursor: "pointer",
            border: "1px solid transparent",
            borderColor: currentSelectedKey === item.id ? "#666" : "transparent"
          }}
        >
          {item.name}
        </div>
      ))}
      {children}
    </div>
  );
}

// Multiple selection list
interface MultiSelectListProps extends MultipleSelection {
  items: Array<{ id: Key; name: string; isDisabled?: boolean }>;
  children?: React.ReactNode;
}

function MultiSelectList({ 
  items, 
  selectionMode = "multiple",
  selectedKeys, 
  defaultSelectedKeys, 
  onSelectionChange,
  disallowEmptySelection = false,
  disabledKeys,
  children 
}: MultiSelectListProps) {
  const [internalSelectedKeys, setInternalSelectedKeys] = useState<Set<Key>>(
    new Set(
      defaultSelectedKeys === "all" 
        ? items.map(item => item.id)
        : Array.from(defaultSelectedKeys || [])
    )
  );

  const disabledSet = new Set(disabledKeys || []);
  
  const getCurrentSelection = (): Set<Key> => {
    if (selectedKeys !== undefined) {
      return selectedKeys === "all" 
        ? new Set(items.map(item => item.id))
        : new Set(selectedKeys);
    }
    return internalSelectedKeys;
  };

  const currentSelection = getCurrentSelection();

  const handleSelectionChange = (newSelection: Selection) => {
    if (selectedKeys === undefined) {
      setInternalSelectedKeys(
        newSelection === "all" 
          ? new Set(items.map(item => item.id))
          : newSelection
      );
    }
    onSelectionChange?.(newSelection);
  };

  const handleItemClick = (key: Key, event: React.MouseEvent) => {
    if (disabledSet.has(key)) return;
    if (selectionMode === "none") return;

    const newSelection = new Set(currentSelection);
    
    if (selectionMode === "single") {
      // Single selection mode
      newSelection.clear();
      newSelection.add(key);
    } else {
      // Multiple selection mode
      if (event.ctrlKey || event.metaKey) {
        // Toggle individual item
        if (newSelection.has(key)) {
          newSelection.delete(key);
        } else {
          newSelection.add(key);
        }
      } else if (event.shiftKey && currentSelection.size > 0) {
        // Range selection - select from last selected to current
        const itemIds = items.map(item => item.id);
        const lastSelectedIndex = itemIds.findIndex(id => currentSelection.has(id));
        const currentIndex = itemIds.findIndex(id => id === key);
        
        if (lastSelectedIndex !== -1 && currentIndex !== -1) {
          const start = Math.min(lastSelectedIndex, currentIndex);
          const end = Math.max(lastSelectedIndex, currentIndex);
          
          for (let i = start; i <= end; i++) {
            if (!disabledSet.has(itemIds[i])) {
              newSelection.add(itemIds[i]);
            }
          }
        }
      } else {
        // Replace selection
        newSelection.clear();
        newSelection.add(key);
      }
    }

    // Check if empty selection is allowed
    if (newSelection.size === 0 && disallowEmptySelection) {
      return;
    }

    handleSelectionChange(newSelection);
  };

  const handleSelectAll = () => {
    const allKeys = items
      .filter(item => !disabledSet.has(item.id))
      .map(item => item.id);
    handleSelectionChange(new Set(allKeys));
  };

  const handleClearSelection = () => {
    if (disallowEmptySelection) return;
    handleSelectionChange(new Set());
  };

  const isAllSelected = items.every(item => 
    disabledSet.has(item.id) || currentSelection.has(item.id)
  );

  return (
    <div>
      {selectionMode === "multiple" && (
        <div style={{ padding: "8px", borderBottom: "1px solid #ccc" }}>
          <button onClick={handleSelectAll} disabled={isAllSelected}>
            Select All
          </button>
          <button 
            onClick={handleClearSelection} 
            disabled={currentSelection.size === 0 || disallowEmptySelection}
            style={{ marginLeft: "8px" }}
          >
            Clear Selection
          </button>
          <span style={{ marginLeft: "16px", fontSize: "14px" }}>
            {currentSelection.size} of {items.length} selected
          </span>
        </div>
      )}
      
      <div role="listbox" aria-multiselectable={selectionMode === "multiple"}>
        {items.map(item => {
          const isSelected = currentSelection.has(item.id);
          const isDisabled = disabledSet.has(item.id);
          
          return (
            <div
              key={item.id}
              role="option"
              aria-selected={isSelected}
              aria-disabled={isDisabled}
              onClick={(e) => handleItemClick(item.id, e)}
              style={{
                padding: "8px",
                backgroundColor: isSelected ? "#e0e0e0" : "transparent",
                cursor: isDisabled ? "not-allowed" : "pointer",
                opacity: isDisabled ? 0.5 : 1,
                border: "1px solid transparent",
                borderColor: isSelected ? "#666" : "transparent",
                display: "flex",
                alignItems: "center"
              }}
            >
              {selectionMode === "multiple" && (
                <input
                  type="checkbox"
                  checked={isSelected}
                  disabled={isDisabled}
                  onChange={() => {}} // Handled by div click
                  style={{ marginRight: "8px" }}
                  tabIndex={-1}
                />
              )}
              {item.name}
            </div>
          );
        })}
      </div>
      {children}
    </div>
  );
}

// Selection utilities
class SelectionManager {
  static isSelectionEmpty(selection: Selection): boolean {
    return selection !== "all" && selection.size === 0;
  }

  static isKeySelected(selection: Selection, key: Key): boolean {
    return selection === "all" || selection.has(key);
  }

  static toggleSelection(
    currentSelection: Selection, 
    key: Key, 
    allKeys: Key[]
  ): Selection {
    if (currentSelection === "all") {
      const newSelection = new Set(allKeys);
      newSelection.delete(key);
      return newSelection;
    } else {
      const newSelection = new Set(currentSelection);
      if (newSelection.has(key)) {
        newSelection.delete(key);
      } else {
        newSelection.add(key);
      }
      return newSelection;
    }
  }

  static selectRange(
    currentSelection: Selection,
    fromKey: Key,
    toKey: Key,
    allKeys: Key[]
  ): Selection {
    const fromIndex = allKeys.indexOf(fromKey);
    const toIndex = allKeys.indexOf(toKey);
    
    if (fromIndex === -1 || toIndex === -1) {
      return currentSelection;
    }

    const start = Math.min(fromIndex, toIndex);
    const end = Math.max(fromIndex, toIndex);
    
    const newSelection = currentSelection === "all" 
      ? new Set(allKeys) 
      : new Set(currentSelection);
    
    for (let i = start; i <= end; i++) {
      newSelection.add(allKeys[i]);
    }
    
    return newSelection;
  }

  static getSelectionCount(selection: Selection, totalCount: number): number {
    return selection === "all" ? totalCount : selection.size;
  }
}

// Example usage in a data grid
interface DataGridProps {
  data: Array<{ id: Key; [key: string]: any }>;
  columns: Array<{ key: string; title: string }>;
  selectionMode?: SelectionMode;
  onSelectionChange?: (selection: Selection) => void;
}

function DataGrid({ 
  data, 
  columns, 
  selectionMode = "multiple", 
  onSelectionChange 
}: DataGridProps) {
  const [selection, setSelection] = useState<Selection>(new Set());

  const handleSelectionChange = (newSelection: Selection) => {
    setSelection(newSelection);
    onSelectionChange?.(newSelection);
  };

  const handleRowClick = (key: Key, event: React.MouseEvent) => {
    if (selectionMode === "none") return;

    let newSelection: Selection;
    
    if (selectionMode === "single") {
      newSelection = new Set([key]);
    } else {
      newSelection = SelectionManager.toggleSelection(
        selection, 
        key, 
        data.map(row => row.id)
      );
    }
    
    handleSelectionChange(newSelection);
  };

  return (
    <table>
      <thead>
        <tr>
          {selectionMode !== "none" && (
            <th>
              {selectionMode === "multiple" && (
                <input
                  type="checkbox"
                  checked={selection === "all" || selection.size === data.length}
                  onChange={(e) => {
                    const newSelection = e.target.checked 
                      ? new Set(data.map(row => row.id))
                      : new Set();
                    handleSelectionChange(newSelection);
                  }}
                />
              )}
            </th>
          )}
          {columns.map(column => (
            <th key={column.key}>{column.title}</th>
          ))}
        </tr>
      </thead>
      <tbody>
        {data.map(row => {
          const isSelected = SelectionManager.isKeySelected(selection, row.id);
          
          return (
            <tr
              key={row.id}
              onClick={(e) => handleRowClick(row.id, e)}
              style={{
                backgroundColor: isSelected ? "#f0f0f0" : "transparent",
                cursor: selectionMode !== "none" ? "pointer" : "default"
              }}
            >
              {selectionMode !== "none" && (
                <td>
                  <input
                    type={selectionMode === "single" ? "radio" : "checkbox"}
                    checked={isSelected}
                    onChange={() => {}} // Handled by row click
                    tabIndex={-1}
                  />
                </td>
              )}
              {columns.map(column => (
                <td key={column.key}>{row[column.key]}</td>
              ))}
            </tr>
          );
        })}
      </tbody>
    </table>
  );
}

// Example usage
function SelectionExamples() {
  const items = [
    { id: "1", name: "Item 1" },
    { id: "2", name: "Item 2" },
    { id: "3", name: "Item 3" },
    { id: "4", name: "Item 4" }
  ];

  return (
    <div>
      <h3>Single Selection</h3>
      <SingleSelectList
        items={items}
        onSelectionChange={(key) => console.log("Selected:", key)}
      />

      <h3>Multiple Selection</h3>
      <MultiSelectList
        items={items}
        selectionMode="multiple"
        disabledKeys={["3"]}
        onSelectionChange={(keys) => console.log("Selected:", keys)}
      />

      <h3>Data Grid with Selection</h3>
      <DataGrid
        data={[
          { id: "1", name: "Alice", age: 30 },
          { id: "2", name: "Bob", age: 25 },
          { id: "3", name: "Carol", age: 35 }
        ]}
        columns={[
          { key: "name", title: "Name" },
          { key: "age", title: "Age" }
        ]}
        selectionMode="multiple"
        onSelectionChange={(selection) => {
          console.log("Grid selection:", selection);
        }}
      />
    </div>
  );
}

Install with Tessl CLI

npx tessl i tessl/npm-react-types--shared

docs

collections.md

design-tokens.md

dom-aria.md

drag-drop.md

events.md

index.md

input-handling.md

labelable.md

refs.md

selection.md

styling.md

tile.json