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
—
Single and multiple selection patterns with keyboard navigation, disabled items, and selection behavior configuration for building interactive collections.
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";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;
}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-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