@dkile/react-multiselect
Version:
React hook wrapper for multiselect-core
663 lines (652 loc) • 18.8 kB
JavaScript
// src/use-multiselect.ts
import { useRef, useState, useEffect } from "react";
// ../multiselect-core/src/scheduler.ts
var rafScheduler = {
schedule: (fn) => {
requestAnimationFrame(fn);
}
};
// ../multiselect-core/src/utils.ts
var isID = (value) => typeof value === "string" || typeof value === "number" || typeof value === "bigint";
var accessObjectValue = (accessor, item) => {
if (typeof accessor === "string") {
const pathKeys = accessor.split(".");
const value2 = pathKeys.reduce((acc, key) => {
if (acc && typeof acc === "object" && key in acc) {
return acc[key];
}
return void 0;
}, item);
return isID(value2) ? value2 : void 0;
}
const value = accessor(item);
return value;
};
// ../multiselect-core/src/item.ts
function createCoreSelectItem(data, idAccessor, metaFn, selectedIds, applyChange) {
const rawId = accessObjectValue(idAccessor, data);
if (rawId === void 0) {
throw new Error(
`id_accessor returned undefined for item: ${JSON.stringify(data)}`
);
}
const id = rawId;
const wrapper = {
id,
data,
get isSelected() {
return selectedIds.has(id);
},
meta: metaFn ? metaFn(data) : void 0,
select: () => {
if (!selectedIds.has(id)) {
applyChange({ added: [id] });
}
},
unselect: () => {
if (selectedIds.has(id)) {
applyChange({ removed: [id] });
}
},
toggle: () => {
if (selectedIds.has(id)) {
wrapper.unselect();
} else {
wrapper.select();
}
}
};
return wrapper;
}
// ../multiselect-core/src/group.ts
var GroupManager = class {
constructor(items, groupBy) {
this.items = items;
this.groupBy = groupBy;
}
getGroupTree() {
return this.groupItemsRecursive(this.items, 0, []);
}
selectGroup(node) {
for (const leaf of this.collectLeaves(node)) {
leaf.select();
}
}
unselectGroup(node) {
for (const leaf of this.collectLeaves(node)) {
leaf.unselect();
}
}
toggleGroup(node) {
const leaves = this.collectLeaves(node);
const allSelected = leaves.every((i) => i.isSelected);
if (allSelected) {
this.unselectGroup(node);
} else {
this.selectGroup(node);
}
}
groupItemsRecursive(items, level, path) {
if (level >= this.groupBy.length) return [];
const accessor = this.groupBy[level];
const map = /* @__PURE__ */ new Map();
for (const item of items) {
const rawValue = accessObjectValue(accessor, item.data);
if (rawValue === void 0) {
if (typeof accessor === "string") {
throw new Error(
`Grouping by path '${accessor}' returned undefined for item`
);
}
continue;
}
const arr = map.get(rawValue) ?? [];
arr.push(item);
map.set(rawValue, arr);
}
const nodes = [];
for (const [key, groupItems] of map.entries()) {
const currentPath = [...path, key];
const children = this.groupItemsRecursive(
groupItems,
level + 1,
currentPath
);
const node = new GroupNodeImpl(
this,
key,
level,
currentPath,
children.length > 0 ? [] : groupItems,
children.length > 0 ? children : void 0
);
nodes.push(node);
}
return nodes;
}
collectLeaves(node) {
if (node.children) {
let leaves = [];
for (const child of node.children) {
leaves = leaves.concat(this.collectLeaves(child));
}
return leaves;
}
return node.items;
}
};
var GroupNodeImpl = class {
constructor(manager, key, level, path, items, children) {
this.manager = manager;
this.key = key;
this.level = level;
this.path = path;
this.items = items;
this.children = children;
}
select() {
this.manager.selectGroup(this);
}
unselect() {
this.manager.unselectGroup(this);
}
toggle() {
this.manager.toggleGroup(this);
}
hasChildren() {
return Array.isArray(this.children) && this.children.length > 0;
}
isAllSelected() {
if (this.hasChildren()) {
return this.children.every((child) => child.isAllSelected());
}
return this.items.every((item) => item.isSelected);
}
};
// ../multiselect-core/src/filter.ts
var FilterManager = class {
constructor(items, initialFilter) {
this.items = items;
this.filter = initialFilter;
}
setFilter(filter) {
this.filter = filter;
}
getFilteredItems() {
if (!this.filter) return this.items;
const { query, predicate } = this.filter;
return this.items.filter(
(item) => predicate(item.data, query, item.meta)
);
}
isAllFilteredSelected() {
const filtered = this.getFilteredItems();
return filtered.length > 0 && filtered.every((item) => item.isSelected);
}
selectAllFiltered() {
for (const item of this.getFilteredItems()) {
item.select();
}
}
unselectAllFiltered() {
for (const item of this.getFilteredItems()) {
item.unselect();
}
}
toggleAllFiltered() {
if (this.isAllFilteredSelected()) {
this.unselectAllFiltered();
} else {
this.selectAllFiltered();
}
}
getFilter() {
return this.filter;
}
};
// ../multiselect-core/src/group-filter.ts
function collectRawLeaves(node) {
if (node.children) {
let leaves = [];
for (const child of node.children) {
leaves = leaves.concat(collectRawLeaves(child));
}
return leaves;
}
return node.items;
}
function unifyGroupNodes(nodes, filteredSet, filteredOnly) {
const result = [];
for (const node of nodes) {
const allLeaves = collectRawLeaves(node);
const filteredLeaves = allLeaves.filter((i) => filteredSet.has(i.id));
if (filteredOnly && filteredLeaves.length === 0) continue;
const childrenUnified = unifyGroupNodes(
node.children ?? [],
filteredSet,
filteredOnly
);
const unified = {
key: node.key,
level: node.level,
path: node.path,
getMeta() {
return void 0;
},
getItems({ filteredOnly: fo } = {}) {
return fo ? filteredLeaves : allLeaves;
},
getSubGroups() {
return childrenUnified;
},
getSubGroup(key) {
return childrenUnified.find((child) => child.key === key);
},
hasSubGroups() {
return childrenUnified.length > 0;
},
select({ filteredOnly: fo } = {}) {
for (const item of fo ? filteredLeaves : allLeaves) item.select();
},
unselect({ filteredOnly: fo } = {}) {
for (const item of fo ? filteredLeaves : allLeaves) item.unselect();
},
toggle({ filteredOnly: fo } = {}) {
const target = fo ? filteredLeaves : allLeaves;
const allSel = target.length > 0 && target.every((i) => i.isSelected);
if (allSel) {
for (const item of target) {
item.unselect();
}
} else {
for (const item of target) {
item.select();
}
}
},
isAllSelected({ filteredOnly: fo } = {}) {
const target = fo ? filteredLeaves : allLeaves;
return target.length > 0 && target.every((i) => i.isSelected);
},
totalCount: allLeaves.length,
filteredCount: filteredLeaves.length
};
result.push(unified);
}
return result;
}
// ../multiselect-core/src/group-utils.ts
function collectRawLeaves2(node) {
if (node.children) {
let leaves = [];
for (const child of node.children) {
leaves = leaves.concat(collectRawLeaves2(child));
}
return leaves;
}
return node.items;
}
function pruneTreeByFilter(nodes, filteredSet, filteredOnly) {
if (!filteredOnly) return nodes;
const result = [];
for (const node of nodes) {
const leaves = collectRawLeaves2(node);
const hasFiltered = leaves.some((item) => filteredSet.has(item.id));
const childrenPruned = pruneTreeByFilter(
node.children ?? [],
filteredSet,
filteredOnly
);
if (hasFiltered || childrenPruned.length > 0) {
const clone = Object.assign(
Object.create(Object.getPrototypeOf(node)),
node
);
if (childrenPruned.length > 0) {
clone.children = childrenPruned;
}
result.push(clone);
}
}
return result;
}
function pruneUnifiedBySelection(nodes, selectionSet, selectedOnly, includePartial) {
if (!selectedOnly) return nodes;
const result = [];
for (const node of nodes) {
const allItems = node.getItems({ filteredOnly: false });
const selLeaves = allItems.filter((item) => selectionSet.has(item.id));
if (selectedOnly) {
if (includePartial) {
if (selLeaves.length === 0) continue;
} else {
if (selLeaves.length !== allItems.length) continue;
}
}
const childrenPruned = pruneUnifiedBySelection(
node.getSubGroups({ filteredOnly: false }),
selectionSet,
selectedOnly,
includePartial
);
const clone = Object.assign(
Object.create(Object.getPrototypeOf(node)),
node
);
clone.getSubGroups = () => childrenPruned;
clone.hasSubGroups = () => childrenPruned.length > 0;
result.push(clone);
}
return result;
}
// ../multiselect-core/src/collapse/collapse-strategy.ts
var CollapsePositionFirst = (inds) => Math.min(...inds);
var CollapsePositionLast = (inds) => Math.max(...inds);
function collapseSelection(tree, orderMap, options = {}) {
const { filteredOnly = false, positionStrategy = CollapsePositionFirst } = options;
const out = [];
function walk(nodes) {
for (const node of nodes) {
const items = node.getItems({ filteredOnly });
const sel = items.filter((i) => i.isSelected);
const children = node.getSubGroups({ filteredOnly });
if (sel.length === items.length && sel.length > 0) {
const idxs = sel.map(
(i) => orderMap.get(i.id) ?? Number.MAX_SAFE_INTEGER
);
out.push({ type: "group", node, order: positionStrategy(idxs) });
} else {
if (children.length === 0) {
for (const item of sel) {
const ord = orderMap.get(item.id) ?? Number.MAX_SAFE_INTEGER;
out.push({ type: "item", node: item, order: ord });
}
}
walk(children);
}
}
}
walk(tree);
return out.sort((a, b) => a.order - b.order);
}
// ../multiselect-core/src/collapse/collapse-manager.ts
var CollapseManager = class {
constructor(ms) {
this.ms = ms;
this.orderMap = /* @__PURE__ */ new Map();
this.counter = 0;
ms.subscribe((_state, diff) => {
for (const id of diff.added) {
this.orderMap.set(id, this.counter++);
}
for (const id of diff.removed) {
this.orderMap.delete(id);
}
});
}
getCollapsedSelection(options = {}) {
const filteredOnly = options.filteredOnly;
const positionStrategy = options.positionStrategy ?? CollapsePositionFirst;
const tree = this.ms.getGroupTree({ filter: { filteredOnly } });
const collapsed = collapseSelection(tree, this.orderMap, {
filteredOnly,
positionStrategy
});
const treeItemIds = /* @__PURE__ */ new Set();
for (const node of tree) {
for (const item of node.getItems({ filteredOnly })) {
treeItemIds.add(item.id);
}
}
const selectedItems = this.ms.getSelectedItems({ filteredOnly });
for (const item of selectedItems) {
if (!treeItemIds.has(item.id)) {
const order = this.orderMap.get(item.id) ?? Number.MAX_SAFE_INTEGER;
collapsed.push({ type: "item", node: item, order });
}
}
return collapsed.sort((a, b) => a.order - b.order);
}
};
// ../multiselect-core/src/multiselect.ts
var MultiSelectCore = class {
constructor(options) {
this.listeners = /* @__PURE__ */ new Set();
this.pendingDiff = {
added: /* @__PURE__ */ new Set(),
removed: /* @__PURE__ */ new Set()
};
this.isFlushScheduled = false;
this.data = options.data;
const idAccessor = options.itemDef.id_accessor;
this.selectedIds = new Set(options.initialState?.selectedIds);
this.items = this.data.map(
(dataItem) => createCoreSelectItem(
dataItem,
idAccessor,
options.itemDef.meta,
this.selectedIds,
this.applyChange.bind(this)
)
);
this.itemMap = /* @__PURE__ */ new Map();
for (const item of this.items) {
this.itemMap.set(item.id, item);
}
this.groupMap = /* @__PURE__ */ new Map();
this.filterManager = new FilterManager(this.items, options.filter);
const groupBySpecs = options.groupBy ?? [];
const accessors = groupBySpecs.map(
(spec) => typeof spec === "object" ? spec.accessor : spec
);
this.groupManager = new GroupManager(this.items, accessors);
this.metaFns = groupBySpecs.map(
(spec) => typeof spec === "object" ? spec.getMeta : void 0
);
this.rawGroupTree = this.groupManager.getGroupTree();
this.scheduler = options.scheduler ?? rafScheduler;
this.collapseManager = new CollapseManager(this);
}
applyChange(change) {
if (change.added) {
for (const id of change.added) {
this.selectedIds.add(id);
this.pendingDiff.removed.delete(id);
this.pendingDiff.added.add(id);
}
}
if (change.removed) {
for (const id of change.removed) {
this.selectedIds.delete(id);
this.pendingDiff.added.delete(id);
this.pendingDiff.removed.add(id);
}
}
this.scheduleFlush();
}
emit(diff) {
const state = { selectedIds: new Set(this.selectedIds) };
for (const listener of this.listeners) {
listener(state, { added: diff.added ?? [], removed: diff.removed ?? [] });
}
}
getItems() {
return this.items;
}
getSelectedIds({
filteredOnly
} = {}) {
return this.getSelectedItems({ filteredOnly }).map((item) => item.id);
}
getSelectedItems({
filteredOnly
} = {}) {
const items = filteredOnly ? this.filterManager.getFilteredItems() : this.items;
return items.filter((item) => item.isSelected);
}
isAllSelected() {
return this.items.length > 0 && this.items.every((item) => item.isSelected);
}
selectAll() {
for (const item of this.items) {
item.select();
}
}
unselectAll() {
for (const item of this.items) {
item.unselect();
}
}
toggleAll() {
if (this.isAllSelected()) {
this.unselectAll();
} else {
this.selectAll();
}
}
subscribe(listener) {
this.listeners.add(listener);
listener(
{ selectedIds: new Set(this.selectedIds) },
{ added: [], removed: [] }
);
return () => {
this.listeners.delete(listener);
};
}
scheduleFlush() {
if (this.isFlushScheduled) return;
this.isFlushScheduled = true;
this.scheduler.schedule(() => this.flush());
}
flush() {
const diff = {
added: Array.from(this.pendingDiff.added),
removed: Array.from(this.pendingDiff.removed)
};
this.pendingDiff.added.clear();
this.pendingDiff.removed.clear();
this.isFlushScheduled = false;
this.emit(diff);
}
getFilteredItems() {
return this.filterManager.getFilteredItems();
}
isAllFilteredSelected() {
return this.filterManager.isAllFilteredSelected();
}
selectAllFiltered() {
this.filterManager.selectAllFiltered();
}
unselectAllFiltered() {
this.filterManager.unselectAllFiltered();
}
toggleAllFiltered() {
this.filterManager.toggleAllFiltered();
}
setFilter(filter) {
this.filterManager.setFilter(filter);
}
getGroupTree(options) {
const filteredOnly = options?.filter?.filteredOnly ?? false;
const selectedOnly = options?.selection?.selectedOnly ?? false;
const includePartial = options?.selection?.includePartial ?? false;
const filterQuery = this.filterManager.getFilter()?.query ?? "";
const effectiveFilteredOnly = filteredOnly && filterQuery.length > 0;
const cacheKey = `${filterQuery}|${effectiveFilteredOnly}|${selectedOnly}|${includePartial}`;
if (this.lastUnifiedTree && cacheKey === this.lastCacheKey) {
return this.lastUnifiedTree;
}
const filteredSet = new Set(
this.filterManager.getFilteredItems().map((i) => i.id)
);
const prunedFilter = pruneTreeByFilter(
this.rawGroupTree,
filteredSet,
effectiveFilteredOnly
);
const unifiedByFilter = unifyGroupNodes(
prunedFilter,
filteredSet,
effectiveFilteredOnly
);
const selectionSet = new Set(this.getSelectedIds({ filteredOnly }));
const finalTree = pruneUnifiedBySelection(
unifiedByFilter,
selectionSet,
selectedOnly,
includePartial
);
this.lastUnifiedTree = finalTree;
this.lastCacheKey = cacheKey;
this.groupMap.clear();
const walk = (nodes) => {
for (const node of nodes) {
this.groupMap.set(node.key, node);
walk(node.getSubGroups({ filteredOnly: false }));
}
};
walk(finalTree);
const attachMeta = (nodes) => {
for (const node of nodes) {
const fn = this.metaFns[node.level];
node.getMeta = () => fn ? fn(
node.key,
node.getItems({ filteredOnly: false }).map((i) => i.data),
node.level
) : void 0;
attachMeta(node.getSubGroups({ filteredOnly: false }));
}
};
attachMeta(finalTree);
return finalTree;
}
getSelectionSummary(options) {
const total = options?.filteredOnly ? this.filterManager.getFilteredItems().length : this.items.length;
const selected = this.getSelectedItems(options).length;
return { total, selected };
}
getCollapsedSelection(options) {
return this.collapseManager.getCollapsedSelection(options);
}
getGroup(key) {
this.getGroupTree();
return this.groupMap.get(key);
}
getItem(key) {
return this.itemMap.get(key);
}
getGroupMeta(groupKey) {
return this.getGroup(groupKey)?.getMeta();
}
};
function createMultiSelect(options) {
return new MultiSelectCore(options);
}
// src/use-multiselect.ts
function useMultiSelect(options) {
const coreRef = useRef(
createMultiSelect({
...options,
scheduler: rafScheduler
})
);
coreRef.current.setFilter(options.filter);
const [, setTick] = useState(0);
useEffect(() => {
const unsubscribe = coreRef.current.subscribe(() => {
setTick((t) => t + 1);
});
return unsubscribe;
}, []);
return coreRef.current;
}
export {
CollapseManager,
CollapsePositionFirst,
CollapsePositionLast,
collapseSelection,
createMultiSelect,
useMultiSelect
};
//# sourceMappingURL=index.mjs.map