Resource groups
Give resources a group and they are listed together under a header you can fold. It is for the tens or hundreds of rows that need some structure: rooms by floor, staff by team, vehicles by depot.
Grouping
Section titled “Grouping”const rooms: Resource[] = [ { id: "atlas", name: "Atlas", group: "Floor 1", events: [] }, { id: "borealis", name: "Borealis", group: "Floor 1", events: [] }, { id: "cirrus", name: "Cirrus", group: "Floor 2", events: [] }, { id: "lobby", name: "Lobby screen", events: [] }, // no group: stays a plain row];
<ResourceScheduler resources={rooms} />- A group’s header sits where its first member is, and all its members follow it in their original order, even if they were spread out in your list. Resources without a
groupkeep their place. - The group name is its identity: resources with the same string are in the same group, and it is the text on the header. (A path nests it, below.)
- Headers are not resources. They have no events, and
renderResourceHeader,onEventClickand the other handlers never receive one. - Without any
group, nothing changes.
Nested groups
Section titled “Nested groups”Give group a path to nest it, outermost first. The demo above is built this way: two floors inside “Main building”, and a one-level “Annex”.
const rooms: Resource[] = [ { id: "atlas", name: "Atlas", group: ["Main building", "Floor 1"], events: [] }, { id: "cirrus", name: "Cirrus", group: ["Main building", "Floor 2"], events: [] }, { id: "hall", name: "Hall", group: ["Main building"], events: [] }, // directly in the building { id: "echo", name: "Echo", group: "Annex", events: [] },];Each level gets a header, indented one step further than its parent, and a header’s number counts every resource below it, nested ones included. Folding a parent folds everything inside it. Order works as before at every level: a group sits where its first member is, and a resource that belongs to the parent itself stays where it is among the groups.
A nested group is named by its path with “ / “ between the names, in collapsedGroups, defaultCollapsedGroups and onCollapsedGroupsChange: ["Main building / Floor 1"] folds just that floor. A plain one-name group keeps its plain name, so existing lists still work. Two floors called “Floor 1” in different buildings are different groups, because their paths differ.
Folding
Section titled “Folding”Click a header, or Tab to it and press Enter or Space. A folded group keeps its header, with the number of resources in it, and its rows are not drawn. Header buttons say whether they are open (aria-expanded) and read as “Floor 1, 2 resources”.
// Some groups start folded; the scheduler remembers the rest.<ResourceScheduler resources={rooms} defaultCollapsedGroups={["Annex"]} />
// Or keep the list yourself, for "collapse all" buttons or to save it per user.const [collapsed, setCollapsed] = useState<string[]>([]);<ResourceScheduler resources={rooms} collapsedGroups={collapsed} onCollapsedGroupsChange={setCollapsed}/>With collapsedGroups set, the list is yours: the scheduler calls onCollapsedGroupsChange with the new list and shows what you pass back.
What it does around the headers
Section titled “What it does around the headers”- Keyboard. The arrow keys in the grid move between resources and step over the headers; the headers are reached with Tab, since they are buttons.
- Drag and drop. Events can be dragged between groups like between any rows. The rules (
eventOverlap,isValidDrop,blockUnavailable) see the visible resources only: a folded group is not a place to drop. - Utilization. With
showUtilizationeach resource has its bar, and each header shows the total of everything below it as a small bar and a percentage: nested groups are included, and so are folded ones, so a collapsed team still shows how busy it is. Screen readers hear it in the header’s label, such as “Floor 1, 2 resources, 6 of 16 hours booked”. The same sums are available asgroupUtilization. - Large lists. Headers are rows like the others, so row virtualization works across them.
- Languages. The header’s spoken text is
labels.group(name, count).
Reordering
Section titled “Reordering”Pass onGroupReorder, onResourceReorder or both, and the scheduler puts a grip on each group header and each resource that the user can drag, or move with the keyboard. Without them nothing is added. The scheduler never owns your data, so a drop is a request: it calls your callback, and you apply it to the resources array you keep with two helpers that return the new array.
import { ResourceScheduler, moveResource, reorderGroup } from "resource-scheduler";
const [rooms, setRooms] = useState(initialRooms);
<ResourceScheduler resources={rooms} onGroupReorder={(key, before) => setRooms(reorderGroup(rooms, key, before))} onResourceReorder={(id, before, group) => setRooms(moveResource(rooms, id, before, group))}/>onGroupReorder(groupKey, beforeGroupKey): the group (its name, or its path joined with “ / “) and the sibling group it goes before, ornullfor the end. A group moves among the groups beside it, the ones with the same parent, and takes its resources and its nested groups with it, folded or not.onResourceReorder(resourceId, beforeResourceId, group): the resource, the one it goes before (nullfor the end of the group), and the key of the group it lands in (nullfor none). The group differs from its own when it was dropped on another group.reorderGroup(resources, groupKey, beforeGroupKey)andmoveResource(resources, resourceId, beforeResourceId, group?)return the new array, written in the order it is shown. A resource that changes group is copied with itsgroupset to the new path; every other resource is the same object. They returnresourcesitself when nothing would change, so asetStatewith the result does not re-render, and the callbacks are never called for a drop that changes nothing. Leavegroupout ofmoveResourceto keep the resource’s own group. Both are pure, so they also run on a server.
Because the order of your array is the order on screen, that is also what you save: persist the array, or the ids in it.
With the pointer
Section titled “With the pointer”Press a grip, drag, and drop: a line shows where it will land. Drop a resource on a group header to add it at the end of that group, and on a plain row to join the group of that row (or none). Escape cancels. The column scrolls when the pointer nears the top or bottom edge, and row virtualization does not get in the way: the position comes from the row heights, not from what is drawn, so rows that are not on screen can be dropped on. Touch works the same way, from the grip.
With the keyboard
Section titled “With the keyboard”Tab to a group header (they are buttons), or to the resources: they are one tab stop, and the arrow keys move between them. Then Alt + ↑ or Alt + ↓ moves it one place. A group swaps with the group beside it. A resource swaps with its neighbour in the group, and at the edge of the group it joins the next group at that end. When your list has changed, the scheduler says where it went through the live region (“Cy moved to Floor 1, position 2 of 3.”) and keeps the focus on it; at the first or last place it says it can’t go further. The keys are described to screen readers, and the words are labels.dragToReorder, labels.reorderHelp and labels.announce.reordered, which you can translate.
Good to know
Section titled “Good to know”- A folded group moves as one unit, members and all.
- It works with
showUtilization,showColumnUtilization,components.ResourceHeader(the grip sits beside your header),timeZone(the callbacks carry ids and keys, so there is nothing to convert) anddir="rtl"(the grip moves to the right, the drop line spans the column, the keys are the same). - The ungrouped rows of the first level keep their place when a group moves: a group lands just before the group you drop it on, or after the last one.
- A resource leaves its groups by being dropped beside an ungrouped row, or with Alt + arrow past the edge of its group when the neighbour is ungrouped. With no ungrouped rows there is nowhere to leave to.
- The announcement is made when your list actually changes. If you apply a move later than a second after the drop (after a server round trip), it is not spoken.
- Touch is checked with synthetic touch input in Chrome, not yet on physical devices.