An expandable grouped list: parent rows that reveal child rows on expand, per-group roll-ups, tone-coloured tags, and inline reorder / edit / delete. One flat gallery — it owns no data, so every action raises an event for your handler.
This component requires Modern controls to be enabled. Settings → Updates → Preview → Modern controls and themes
Config; reorder / edit / delete raise events, shown in the log.Expandable grouped list with parent rows, child rows revealed on expand, per-group roll-ups, inline reorder, edit and delete actions, expand-all, and full light/dark theming. One flat gallery, so no nested gallery and no flexible-height measurement to fight.
The component owns no data. Groups and Items are inputs, so it has nothing to write to. Every action raises an event and stops; your handler does the reorder, the edit and the delete. Wire the events or the buttons will appear to do nothing.
| Property | Type | Default |
|---|---|---|
| Groups — The parent rows. Schema: GroupKey (number, unique, never zero), GroupOrder (number), Marker, Title, Subtitle, Tag, Tone, Meta, Locked. Leave Meta blank and the component counts the children instead. | Table | 4 sample orders |
| Items — The child rows. Schema: ItemKey (number, unique), GroupKey (matches Groups), ItemOrder (number), Marker, Title, Subtitle, Tag, Tone, Meta, Locked. Same column names as Groups on purpose. | Table | 7 sample lines |
| Config — Look and behaviour. Every field is Coalesced internally, so a partial record is safe. Keys: Theme, Accent, Radius, RowHeight, ShowHeader, ShowTags, ShowMeta, ShowExpandAll, AllowReorder, AllowEdit, AllowDelete. | Record | Light, 12, 76, all switches on |
| Title — Heading above the list. Hidden with the rest of the header when Config.ShowHeader is false. | Text | "Orders" |
| Property | Type | Description |
|---|---|---|
| ExpandedGroupKey | Number | The GroupKey currently open, or blank when collapsed or when every group is open. |
| IsExpandAll | Boolean | True while every group is open. |
| SelectedItemKey | Number | The ItemKey of the last child row tapped, or blank. |
| ActionKey | Number | The GroupKey or ItemKey whose action was pressed. Read inside the four action events. |
| ActionRowType | Text | The literal text group or item, so a handler can branch without guessing. |
| GroupCount | Number | How many parent rows were passed in. |
| ItemCount | Number | How many child rows were passed in, across every group. |
| Event | Description |
|---|---|
| OnSelectGroup | A parent row was tapped, after ExpandedGroupKey has already changed. |
| OnSelectItem | A child row was tapped. SelectedItemKey is populated before firing. |
| OnMoveUp | The move-up action was pressed. Read ActionKey and ActionRowType and renumber your own data. |
| OnMoveDown | The move-down action was pressed. |
| OnEdit | The edit action was pressed. Open your own editor. |
| OnDelete | The delete action was pressed. The component shows no confirmation of its own. |
Tag is the text on the pill; Tone is the colour. Valid values are positive, warning, danger, info, or blank for neutral, matched case-insensitively. Nothing in the component knows what your statuses mean, which is why the same control renders order states, approval states and task states without configuration.
Locked true on a group or a child hides all four row actions on that row. Use it for records the user may read but not change: a shipped order, a completed phase, a period that has closed.
One group opens at a time by default. The expand-all toggle in the header opens every group; tapping any row while everything is open drops back to single-expand on the row tapped.
Zero is the internal sentinel for nothing-expanded. A group keyed zero renders permanently expanded and cannot be collapsed. SharePoint's ID column starts at 1, so using ID is safe; a zero-based sequence of your own is not.
The instance is written as Control: CanvasComponent with a ComponentName sibling, not as a control type named after the component.
A pasted instance receives blank for every Table and Record input, so the sample data in Groups and Items does not arrive with it. Wire your own tables. Config is Coalesced internally against its documented defaults, so a blank record still renders correctly.
Width does not survive a paste. The instance arrives at 600 whatever the YAML says, while Height comes through intact. Set Width by hand once and it holds.
Comfortable into the low hundreds of groups. Above that the right answer is server-side paging, not an accordion.
| Parent | Child | Meta roll-up |
|---|---|---|
| Purchase order | Order lines | Line count and order total |
| Invoice | Line items | Amount due |
| Project phase | Assignees | Worker count and estimated days |
| Checklist section | Steps | Steps complete of total |
| Team | Members | Headcount |
| Product category | Products | In-stock count |
| Delivery run | Stops | Distance and window |
| Course | Modules | Percent complete |
| Incident | Timeline entries | Age and severity |
The shape fits anywhere a list has one meaningful level of nesting and the parent carries a number worth seeing before you open it. If your data is flat, use a gallery. If it nests three levels deep, this is the wrong control.
The component owns no data — every action raises an event with ActionKey and ActionRowType, so the Patch stays in your app.
Bind Groups and Items to two collections — the component builds the parent/child tree.
cmpAccordionList.Groups: colOrders
cmpAccordionList.Items: colOrderLines
cmpAccordionList.Title: "Purchase orders"
cmpAccordionList.Width: Parent.Width
cmpAccordionList.Height: 600OnMoveUp / OnMoveDown fire with the row's key and type — you find the neighbour and swap orders.
cmpAccordionList.OnMoveUp: |
If(
cmpAccordionList.ActionRowType = "group",
With(
{ me: LookUp(colOrders, GroupKey = cmpAccordionList.ActionKey) },
With(
{ prv: Last(Sort(Filter(colOrders,
GroupOrder < me.GroupOrder), GroupOrder)) },
Patch(colOrders, prv, { GroupOrder: me.GroupOrder });
Patch(colOrders, me, { GroupOrder: prv.GroupOrder })
)
),
With(
{ me: LookUp(colOrderLines, ItemKey = cmpAccordionList.ActionKey) },
With(
{ prv: Last(Sort(Filter(colOrderLines,
GroupKey = me.GroupKey,
ItemOrder < me.ItemOrder), ItemOrder)) },
Patch(colOrderLines, prv, { ItemOrder: me.ItemOrder });
Patch(colOrderLines, me, { ItemOrder: prv.ItemOrder })
)
)
)OnDelete hands you ActionKey / ActionRowType — the host decides whether a parent delete cascades.
cmpAccordionList.OnDelete: |
If(
cmpAccordionList.ActionRowType = "group",
RemoveIf(colOrderLines, GroupKey = cmpAccordionList.ActionKey);
RemoveIf(colOrders, GroupKey = cmpAccordionList.ActionKey),
RemoveIf(colOrderLines, ItemKey = cmpAccordionList.ActionKey)
)OnEdit fires for a group or an item (ActionRowType tells which) — you open your own editor and Patch the title on Save.
cmpAccordionList.OnEdit: |
Set(gblEditKey, cmpAccordionList.ActionKey);
Set(gblEditType, cmpAccordionList.ActionRowType);
Set(gblEditTitle,
If(gblEditType = "group",
LookUp(colOrders, GroupKey = gblEditKey).Title,
LookUp(colOrderLines, ItemKey = gblEditKey).Title
)
);
Set(gblEditOpen, true) // your popup binds to gblEdit*Turn the Allow* flags off in Config for a display-only list — no action column renders.
cmpAccordionList.Config: {
Theme: "light",
ShowTags: true,
ShowMeta: true,
AllowReorder: false,
AllowEdit: false,
AllowDelete: false
}Two lists in a parent and child relationship, with a plain indexed number column as the foreign key rather than a Lookup column. Index both key columns.
Bind the component to collections, never directly to the lists. The collections are the seam between your schema and the component's, and they are where a status becomes a Tone.
ClearCollect(
colOrders,
ForAll(
Filter('Orders', Archived = false) As o,
{
GroupKey: o.OrderKey,
GroupOrder: o.OrderSeq,
Marker: Text(o.OrderSeq),
Title: o.Title,
Subtitle: o.Customer,
Tag: o.Status.Value,
Tone: Switch(
o.Status.Value,
"Shipped", "positive",
"Partial", "warning",
"On hold", "danger",
""
),
Meta: "",
Locked: o.IsLocked
}
)
)
A Choice column is a record, so read .Value. Leaving it off gives a blank Tag with no error.
Three things to keep in mind. Filter on an indexed column delegates while ForAll and Switch do not, which is why they wrap a Filter that has already narrowed the result. Patch the list and the collection in the same handler, or patch the list and reload, since patching only the collection leaves the screen right and the list wrong. And SharePoint has no cascade, so a parent delete must remove its children first or they are orphaned permanently.
Full walkthrough with column schemas, lazy child loading and delegation limits: Parent and child SharePoint lists in a Canvas accordion.
One email when something new ships. Unsubscribe anytime.
Use the toolbar to format · or type markdown directly