Org chart explorer
A hierarchy you can actually interrogate. A nine-person company rendered
with md-organization-chart — a real ARIA
tree with roving tabindex and arrow-key navigation — fronted by an
md-search bar that filters people by name or role and
tints the matching nodes. Selecting anyone (click, or Enter on a focused node)
opens a modal md-side-sheet profile: initials
avatar with a direct-report count badge, contact chips, and the full reporting
line up to the CEO drawn as breadcrumbs.
Live preview — search 'engineer', or click any card to open a profile
Meridian Robotics
People directory — 9 people across Engineering, Design and Finance
No people loaded.
Reporting line
Contact
Show code for each technology
<!-- index.html <head> — the icon font the components draw from -->
<link rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap">
<!-- index.html — register the AWC UI elements once -->
<script type="module">
import '@awc-ui/core/define';
</script>
<md-search id="people-search" layout="docked" trigger="bar" full-width
debounce="200" placeholder="Search people and roles"
results-label="{count} people found"
no-results-label="No one matches that search">
<div slot="results" id="people-results"></div>
</md-search>
<md-organization-chart id="org" selection-mode="single"
label="Meridian Robotics reporting structure">
<div slot="empty">No people loaded.</div>
</md-organization-chart>
<md-side-sheet id="profile" variant="modal" headline="Profile">
<span style="position: relative; display: inline-block;">
<md-avatar id="profile-avatar" size="large"></md-avatar>
<md-badge id="report-count"></md-badge>
</span>
<md-breadcrumbs id="reporting-line" label="Reporting line" separator="›"></md-breadcrumbs>
<md-chip id="email-chip" variant="assist" icon="mail"></md-chip>
<md-chip id="phone-chip" variant="assist" icon="call"></md-chip>
</md-side-sheet>
<script type="module">
const chart = document.querySelector('md-organization-chart');
// The component scrolls internally via its exported viewport part when the
// tree outgrows the container. Start centered on the root node so the
// first paint shows the CEO, not a cropped corner.
setTimeout(() => {
const viewport = chart.shadowRoot && chart.shadowRoot.querySelector('[part~="viewport"]');
if (viewport) {
viewport.scrollLeft = Math.max(0, (viewport.scrollWidth - viewport.clientWidth) / 2);
}
}, 400);
const search = document.querySelector('md-search');
const resultsHost = document.querySelector('[data-results]');
const sheet = document.querySelector('[data-profile]');
const avatarEl = document.querySelector('[data-p-avatar]');
const badgeEl = document.querySelector('[data-p-badge]');
const nameEl = document.querySelector('[data-p-name]');
const titleEl = document.querySelector('[data-p-title]');
const reportsEl = document.querySelector('[data-p-reports]');
const crumbsEl = document.querySelector('[data-p-crumbs]');
const emailChip = document.querySelector('[data-p-email]');
const phoneChip = document.querySelector('[data-p-phone]');
// Flat records; the tree and the breadcrumb trail are both derived
// from the manager field, so there is a single source of truth.
const people = [
{ id: 'ceo', name: 'Mara Voss', title: 'Chief Executive Officer', manager: null,
email: 'mara.voss@meridian.dev', phone: '+1 415 555 0138' },
{ id: 'vp-eng', name: 'Daniel Okafor', title: 'VP of Engineering', manager: 'ceo',
email: 'daniel.okafor@meridian.dev', phone: '+1 415 555 0142' },
{ id: 'em-platform', name: 'Priya Raman', title: 'Engineering Manager, Platform', manager: 'vp-eng',
email: 'priya.raman@meridian.dev', phone: '+1 415 555 0177' },
{ id: 'be-1', name: 'Jonas Keller', title: 'Senior Backend Engineer', manager: 'em-platform',
email: 'jonas.keller@meridian.dev', phone: '+49 30 555 0104' },
{ id: 'fe-1', name: 'Alice Zhang', title: 'Frontend Engineer', manager: 'em-platform',
email: 'alice.zhang@meridian.dev', phone: '+1 415 555 0191' },
{ id: 'sre', name: 'Tom Bradshaw', title: 'Site Reliability Lead', manager: 'vp-eng',
email: 'tom.bradshaw@meridian.dev', phone: '+44 20 555 0126' },
{ id: 'design', name: 'Elena Petrescu', title: 'Head of Design', manager: 'ceo',
email: 'elena.petrescu@meridian.dev', phone: '+40 21 555 0163' },
{ id: 'pd-1', name: 'Marcus Hale', title: 'Product Designer', manager: 'design',
email: 'marcus.hale@meridian.dev', phone: '+1 415 555 0158' },
{ id: 'finance', name: 'Sofia Lindqvist', title: 'Finance Lead', manager: 'ceo',
email: 'sofia.lindqvist@meridian.dev', phone: '+46 8 555 0119' },
];
const byId = new Map(people.map(function (p) { return [p.id, p]; }));
function initialsOf(name) {
const parts = name.split(' ');
const last = parts.length > 1 ? parts[parts.length - 1].charAt(0) : '';
return (parts[0].charAt(0) + last).toUpperCase();
}
function directReports(id) {
return people.filter(function (p) { return p.manager === id; });
}
// Reassigning nodes resets fold state, but this tree renders fully
// expanded anyway, so a rebuild per search is fine here.
function buildTree(highlight) {
const nodes = new Map();
people.forEach(function (p) {
nodes.set(p.id, {
id: p.id, name: p.name, title: p.title, children: [],
accent: highlight.has(p.id) ? 'var(--md-sys-color-tertiary)' : undefined,
});
});
const roots = [];
people.forEach(function (p) {
if (p.manager && nodes.has(p.manager)) {
nodes.get(p.manager).children.push(nodes.get(p.id));
} else {
roots.push(nodes.get(p.id));
}
});
return roots;
}
chart.nodes = buildTree(new Set());
function openProfile(id) {
const person = byId.get(id);
if (!person) return;
// The sheet repeats the name as text, so the avatar stays decorative:
// initials only, no name/label (see the md-avatar manual).
avatarEl.initials = initialsOf(person.name);
nameEl.textContent = person.name;
titleEl.textContent = person.title;
const count = directReports(id).length;
reportsEl.textContent = count === 0 ? 'No direct reports'
: count === 1 ? '1 direct report' : count + ' direct reports';
badgeEl.value = String(count);
badgeEl.style.display = count === 0 ? 'none' : '';
emailChip.label = person.email;
phoneChip.label = person.phone;
crumbsEl.innerHTML = '';
const chain = [];
let cursor = person;
while (cursor) {
chain.unshift(cursor);
cursor = cursor.manager ? byId.get(cursor.manager) : null;
}
chain.forEach(function (p) {
const crumb = document.createElement('md-breadcrumb-item');
crumb.textContent = p.name;
crumbsEl.appendChild(crumb);
});
sheet.show();
}
chart.addEventListener('mdSelectionChange', function (e) {
if (e.detail.selectedIds.length === 0) { sheet.close(); return; }
openProfile(e.detail.selectedIds[0]);
});
// Clearing the selection on close lets the same card be re-selected.
sheet.addEventListener('mdClose', function () { chart.selectedIds = []; });
let matches = [];
function pick(id) {
search.close();
chart.selectedIds = [id];
openProfile(id);
}
function renderResults(query) {
resultsHost.innerHTML = '';
const q = query.toLowerCase();
if (!q) { matches = []; chart.nodes = buildTree(new Set()); return; }
matches = people.filter(function (p) {
return p.name.toLowerCase().indexOf(q) !== -1
|| p.title.toLowerCase().indexOf(q) !== -1;
});
chart.nodes = buildTree(new Set(matches.map(function (p) { return p.id; })));
matches.forEach(function (p) {
const row = document.createElement('button');
row.type = 'button';
// data-search-result makes the row count toward the live-region
// announcement and reachable with ArrowDown/ArrowUp from the input.
row.setAttribute('data-search-result', '');
row.style.cssText = 'display: flex; flex-direction: column; align-items: flex-start;'
+ 'gap: 2px; padding: 10px 16px; border: none; background: transparent;'
+ 'cursor: pointer; text-align: start; border-radius: 12px; inline-size: 100%;';
const nm = document.createElement('span');
nm.style.cssText = 'font: var(--md-sys-typescale-body-large); color: var(--md-sys-color-on-surface);';
nm.textContent = p.name;
const tl = document.createElement('span');
tl.style.cssText = 'font: var(--md-sys-typescale-body-small); color: var(--md-sys-color-on-surface-variant);';
tl.textContent = p.title;
row.appendChild(nm);
row.appendChild(tl);
row.addEventListener('click', function () { pick(p.id); });
resultsHost.appendChild(row);
});
}
// One query source: mdSearch is debounced and de-duplicated on the
// trimmed query. Enter opens the top match.
search.addEventListener('mdSearch', function (e) { renderResults(e.detail.value); });
search.addEventListener('mdSubmit', function () {
if (matches.length > 0) pick(matches[0].id);
});
</script>import { useMemo, useRef, useState } from 'react';
import {
MdAvatar, MdBadge, MdBreadcrumbItem, MdBreadcrumbs, MdChip,
MdOrganizationChart, MdSearch, MdSideSheet,
} from '@awc-ui/react';
// Flat records; the tree and the breadcrumb trail are both derived
// from the manager field, so there is a single source of truth.
const PEOPLE = [
{ id: 'ceo', name: 'Mara Voss', title: 'Chief Executive Officer', manager: null,
email: 'mara.voss@meridian.dev', phone: '+1 415 555 0138' },
{ id: 'vp-eng', name: 'Daniel Okafor', title: 'VP of Engineering', manager: 'ceo',
email: 'daniel.okafor@meridian.dev', phone: '+1 415 555 0142' },
{ id: 'em-platform', name: 'Priya Raman', title: 'Engineering Manager, Platform',
manager: 'vp-eng', email: 'priya.raman@meridian.dev', phone: '+1 415 555 0177' },
{ id: 'fe-1', name: 'Alice Zhang', title: 'Frontend Engineer', manager: 'em-platform',
email: 'alice.zhang@meridian.dev', phone: '+1 415 555 0191' },
{ id: 'design', name: 'Elena Petrescu', title: 'Head of Design', manager: 'ceo',
email: 'elena.petrescu@meridian.dev', phone: '+40 21 555 0163' },
];
const initialsOf = (name) => {
const parts = name.split(' ');
const last = parts.length > 1 ? parts[parts.length - 1].charAt(0) : '';
return (parts[0].charAt(0) + last).toUpperCase();
};
const buildTree = (highlight) => {
const byId = new Map(PEOPLE.map((p) => [p.id, {
id: p.id, name: p.name, title: p.title, children: [],
accent: highlight.has(p.id) ? 'var(--md-sys-color-tertiary)' : undefined,
}]));
const roots = [];
for (const p of PEOPLE) {
if (p.manager) byId.get(p.manager).children.push(byId.get(p.id));
else roots.push(byId.get(p.id));
}
return roots;
};
export default function OrgChartExplorer() {
const searchRef = useRef(null);
const [query, setQuery] = useState('');
const [selectedId, setSelectedId] = useState(null);
const matches = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return [];
return PEOPLE.filter((p) =>
p.name.toLowerCase().includes(q) || p.title.toLowerCase().includes(q));
}, [query]);
const nodes = useMemo(
() => buildTree(new Set(matches.map((p) => p.id))), [matches]);
const person = PEOPLE.find((p) => p.id === selectedId) ?? null;
const reportCount = person
? PEOPLE.filter((p) => p.manager === person.id).length : 0;
const chain = [];
for (let c = person; c; c = PEOPLE.find((p) => p.id === c.manager) ?? null) {
chain.unshift(c);
}
const pick = (id) => {
searchRef.current?.close();
setSelectedId(id);
};
return (
<div style={{ display: 'grid', gap: 20 }}>
<MdSearch ref={searchRef} layout="docked" trigger="bar" fullWidth
debounce={200} placeholder="Search people and roles"
resultsLabel="{count} people found"
noResultsLabel="No one matches that search"
onMdSearch={(e) => setQuery(e.detail.value)}
onMdSubmit={() => { if (matches.length > 0) pick(matches[0].id); }}>
<div slot="results" style={{ display: 'grid', gap: 2, padding: 8 }}>
{matches.map((p) => (
<button key={p.id} type="button" data-search-result=""
onClick={() => pick(p.id)}>
{p.name} — {p.title}
</button>
))}
</div>
</MdSearch>
<MdOrganizationChart selectionMode="single" nodes={nodes}
selectedIds={person ? [person.id] : []}
label="Meridian Robotics reporting structure"
onMdSelectionChange={(e) => setSelectedId(e.detail.selectedIds[0] ?? null)}>
<div slot="empty">No people loaded.</div>
</MdOrganizationChart>
{/* Clearing the selection on close lets the same card be re-selected. */}
<MdSideSheet variant="modal" headline="Profile" open={person !== null}
onMdClose={() => setSelectedId(null)}>
{person && (
<div style={{ display: 'grid', gap: 20 }}>
<span style={{ position: 'relative', display: 'inline-block' }}>
<MdAvatar size="large" initials={initialsOf(person.name)} />
{reportCount > 0 && <MdBadge value={String(reportCount)} />}
</span>
<div>
<div>{person.name}</div>
<div>{person.title}</div>
<div>{reportCount === 0 ? 'No direct reports'
: reportCount === 1 ? '1 direct report'
: reportCount + ' direct reports'}</div>
</div>
<MdBreadcrumbs label="Reporting line" separator="›">
{chain.map((p) => (
<MdBreadcrumbItem key={p.id}>{p.name}</MdBreadcrumbItem>
))}
</MdBreadcrumbs>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<MdChip variant="assist" icon="mail" label={person.email} />
<MdChip variant="assist" icon="call" label={person.phone} />
</div>
</div>
)}
</MdSideSheet>
</div>
);
}// org-chart-explorer.component.ts
import { Component, computed, signal } from '@angular/core';
import {
MdAvatar, MdBadge, MdBreadcrumbItem, MdBreadcrumbs, MdChip,
MdOrganizationChart, MdSearch, MdSideSheet,
} from '@awc-ui/angular';
interface Person {
id: string; name: string; title: string;
manager: string | null; email: string; phone: string;
}
// Flat records; tree and breadcrumbs both derive from the manager field.
const PEOPLE: Person[] = [
{ id: 'ceo', name: 'Mara Voss', title: 'Chief Executive Officer', manager: null,
email: 'mara.voss@meridian.dev', phone: '+1 415 555 0138' },
{ id: 'vp-eng', name: 'Daniel Okafor', title: 'VP of Engineering', manager: 'ceo',
email: 'daniel.okafor@meridian.dev', phone: '+1 415 555 0142' },
{ id: 'em-platform', name: 'Priya Raman', title: 'Engineering Manager, Platform',
manager: 'vp-eng', email: 'priya.raman@meridian.dev', phone: '+1 415 555 0177' },
{ id: 'fe-1', name: 'Alice Zhang', title: 'Frontend Engineer', manager: 'em-platform',
email: 'alice.zhang@meridian.dev', phone: '+1 415 555 0191' },
{ id: 'design', name: 'Elena Petrescu', title: 'Head of Design', manager: 'ceo',
email: 'elena.petrescu@meridian.dev', phone: '+40 21 555 0163' },
];
@Component({
selector: 'app-org-chart-explorer',
standalone: true,
imports: [
MdAvatar, MdBadge, MdBreadcrumbItem, MdBreadcrumbs, MdChip,
MdOrganizationChart, MdSearch, MdSideSheet,
],
templateUrl: './org-chart-explorer.component.html',
})
export class OrgChartExplorerComponent {
query = signal('');
selectedId = signal<string | null>(null);
matches = computed(() => {
const q = this.query().trim().toLowerCase();
if (!q) return [];
return PEOPLE.filter((p) =>
p.name.toLowerCase().includes(q) || p.title.toLowerCase().includes(q));
});
nodes = computed(() => {
const hits = new Set(this.matches().map((p) => p.id));
const byId = new Map<string, any>(PEOPLE.map((p) => [p.id, {
id: p.id, name: p.name, title: p.title, children: [],
accent: hits.has(p.id) ? 'var(--md-sys-color-tertiary)' : undefined,
}]));
const roots: any[] = [];
for (const p of PEOPLE) {
if (p.manager) byId.get(p.manager).children.push(byId.get(p.id));
else roots.push(byId.get(p.id));
}
return roots;
});
person = computed(() => PEOPLE.find((p) => p.id === this.selectedId()) ?? null);
selectedIds = computed(() => { const p = this.person(); return p ? [p.id] : []; });
reportCount = computed(() => {
const me = this.person();
return me ? PEOPLE.filter((p) => p.manager === me.id).length : 0;
});
chain = computed(() => {
const trail: Person[] = [];
let cursor = this.person();
while (cursor) {
trail.unshift(cursor);
const mgr: string | null = cursor.manager;
cursor = mgr ? PEOPLE.find((p) => p.id === mgr) ?? null : null;
}
return trail;
});
initialsOf(name: string): string {
const parts = name.split(' ');
const last = parts.length > 1 ? parts[parts.length - 1].charAt(0) : '';
return (parts[0].charAt(0) + last).toUpperCase();
}
reportsLabel(): string {
const n = this.reportCount();
return n === 0 ? 'No direct reports'
: n === 1 ? '1 direct report' : n + ' direct reports';
}
onQuery(e: CustomEvent<{ value: string }>): void {
this.query.set(e.detail.value);
}
onSelection(e: CustomEvent<{ selectedIds: string[] }>): void {
this.selectedId.set(e.detail.selectedIds[0] ?? null);
}
// md-search's close() is proxied onto the Angular wrapper, so the
// template reference variable can call it directly.
pick(id: string, search: MdSearch): void {
search.close();
this.selectedId.set(id);
}
pickTop(search: MdSearch): void {
const m = this.matches();
if (m.length > 0) this.pick(m[0].id, search);
}
}
<!-- org-chart-explorer.component.html -->
<md-search #search layout="docked" trigger="bar" [fullWidth]="true"
[debounce]="200" placeholder="Search people and roles"
resultsLabel="{count} people found"
noResultsLabel="No one matches that search"
(mdSearch)="onQuery($event)" (mdSubmit)="pickTop(search)">
<div slot="results">
@for (p of matches(); track p.id) {
<button type="button" data-search-result (click)="pick(p.id, search)">
{{ p.name }} — {{ p.title }}
</button>
}
</div>
</md-search>
<md-organization-chart selectionMode="single" [nodes]="nodes()"
[selectedIds]="selectedIds()"
label="Meridian Robotics reporting structure"
(mdSelectionChange)="onSelection($event)">
<div slot="empty">No people loaded.</div>
</md-organization-chart>
<!-- Clearing the selection on close lets the same card be re-selected. -->
<md-side-sheet variant="modal" headline="Profile"
[open]="person() !== null" (mdClose)="selectedId.set(null)">
@if (person(); as p) {
<span style="position: relative; display: inline-block;">
<md-avatar size="large" [initials]="initialsOf(p.name)"></md-avatar>
@if (reportCount() > 0) {
<md-badge [value]="'' + reportCount()"></md-badge>
}
</span>
<div>{{ p.name }}</div>
<div>{{ p.title }}</div>
<div>{{ reportsLabel() }}</div>
<md-breadcrumbs label="Reporting line" separator="›">
@for (c of chain(); track c.id) {
<md-breadcrumb-item>{{ c.name }}</md-breadcrumb-item>
}
</md-breadcrumbs>
<md-chip variant="assist" icon="mail" [label]="p.email"></md-chip>
<md-chip variant="assist" icon="call" [label]="p.phone"></md-chip>
}
</md-side-sheet><script setup lang="ts">
import { computed, ref } from 'vue';
import {
MdAvatar, MdBadge, MdBreadcrumbItem, MdBreadcrumbs, MdChip,
MdOrganizationChart, MdSearch, MdSideSheet,
} from '@awc-ui/vue';
// Flat records; tree and breadcrumbs both derive from the manager field.
const PEOPLE = [
{ id: 'ceo', name: 'Mara Voss', title: 'Chief Executive Officer', manager: null,
email: 'mara.voss@meridian.dev', phone: '+1 415 555 0138' },
{ id: 'vp-eng', name: 'Daniel Okafor', title: 'VP of Engineering', manager: 'ceo',
email: 'daniel.okafor@meridian.dev', phone: '+1 415 555 0142' },
{ id: 'em-platform', name: 'Priya Raman', title: 'Engineering Manager, Platform',
manager: 'vp-eng', email: 'priya.raman@meridian.dev', phone: '+1 415 555 0177' },
{ id: 'fe-1', name: 'Alice Zhang', title: 'Frontend Engineer', manager: 'em-platform',
email: 'alice.zhang@meridian.dev', phone: '+1 415 555 0191' },
{ id: 'design', name: 'Elena Petrescu', title: 'Head of Design', manager: 'ceo',
email: 'elena.petrescu@meridian.dev', phone: '+40 21 555 0163' },
];
const query = ref('');
const selectedId = ref<string | null>(null);
// The wrapper's root node IS the md-search element, so its close()
// method is reachable through the component ref's $el.
const searchRef = ref<any>(null);
const matches = computed(() => {
const q = query.value.trim().toLowerCase();
if (!q) return [];
return PEOPLE.filter((p) =>
p.name.toLowerCase().includes(q) || p.title.toLowerCase().includes(q));
});
const nodes = computed(() => {
const hits = new Set(matches.value.map((p) => p.id));
const byId = new Map<string, any>(PEOPLE.map((p) => [p.id, {
id: p.id, name: p.name, title: p.title, children: [],
accent: hits.has(p.id) ? 'var(--md-sys-color-tertiary)' : undefined,
}]));
const roots: any[] = [];
for (const p of PEOPLE) {
if (p.manager) byId.get(p.manager).children.push(byId.get(p.id));
else roots.push(byId.get(p.id));
}
return roots;
});
const person = computed(() => PEOPLE.find((p) => p.id === selectedId.value) ?? null);
const selectedIds = computed(() => (person.value ? [person.value.id] : []));
const reportCount = computed(() => {
const me = person.value;
return me ? PEOPLE.filter((p) => p.manager === me.id).length : 0;
});
const chain = computed(() => {
const trail: Array<(typeof PEOPLE)[number]> = [];
let cursor = person.value;
while (cursor) {
trail.unshift(cursor);
const mgr: string | null = cursor.manager;
cursor = mgr ? PEOPLE.find((p) => p.id === mgr) ?? null : null;
}
return trail;
});
const reportsLabel = computed(() => {
const n = reportCount.value;
return n === 0 ? 'No direct reports'
: n === 1 ? '1 direct report' : n + ' direct reports';
});
function initialsOf(name: string): string {
const parts = name.split(' ');
const last = parts.length > 1 ? parts[parts.length - 1].charAt(0) : '';
return (parts[0].charAt(0) + last).toUpperCase();
}
function pick(id: string): void {
if (searchRef.value) searchRef.value.$el.close();
selectedId.value = id;
}
function onQuery(e: CustomEvent<{ value: string }>) { query.value = e.detail.value; }
function onSubmit() { if (matches.value.length > 0) pick(matches.value[0].id); }
function onSelection(e: CustomEvent<{ selectedIds: string[] }>) {
selectedId.value = e.detail.selectedIds[0] ?? null;
}
</script>
<template>
<MdSearch ref="searchRef" layout="docked" trigger="bar" :fullWidth="true"
:debounce="200" placeholder="Search people and roles"
resultsLabel="{count} people found"
noResultsLabel="No one matches that search"
@mdSearch="onQuery" @mdSubmit="onSubmit">
<div slot="results">
<button v-for="p in matches" :key="p.id" type="button" data-search-result
@click="pick(p.id)">
{{ p.name }} — {{ p.title }}
</button>
</div>
</MdSearch>
<MdOrganizationChart selectionMode="single" :nodes="nodes"
:selectedIds="selectedIds" label="Meridian Robotics reporting structure"
@mdSelectionChange="onSelection">
<div slot="empty">No people loaded.</div>
</MdOrganizationChart>
<!-- Clearing the selection on close lets the same card be re-selected. -->
<MdSideSheet variant="modal" headline="Profile" :open="person !== null"
@mdClose="selectedId = null">
<template v-if="person">
<span style="position: relative; display: inline-block;">
<MdAvatar size="large" :initials="initialsOf(person.name)" />
<MdBadge v-if="reportCount > 0" :value="'' + reportCount" />
</span>
<div>{{ person.name }}</div>
<div>{{ person.title }}</div>
<div>{{ reportsLabel }}</div>
<MdBreadcrumbs label="Reporting line" separator="›">
<MdBreadcrumbItem v-for="c in chain" :key="c.id">{{ c.name }}</MdBreadcrumbItem>
</MdBreadcrumbs>
<MdChip variant="assist" icon="mail" :label="person.email" />
<MdChip variant="assist" icon="call" :label="person.phone" />
</template>
</MdSideSheet>
</template><script>
import { onMount } from 'svelte';
// Registers every md-* element once on the client. In SvelteKit run this
// browser-side only: if (browser) defineCustomElements(window);
import { defineCustomElements } from '@awc-ui/svelte';
defineCustomElements(window);
// Flat records; tree and breadcrumbs both derive from the manager field.
const PEOPLE = [
{ id: 'ceo', name: 'Mara Voss', title: 'Chief Executive Officer', manager: null,
email: 'mara.voss@meridian.dev', phone: '+1 415 555 0138' },
{ id: 'vp-eng', name: 'Daniel Okafor', title: 'VP of Engineering', manager: 'ceo',
email: 'daniel.okafor@meridian.dev', phone: '+1 415 555 0142' },
{ id: 'em-platform', name: 'Priya Raman', title: 'Engineering Manager, Platform',
manager: 'vp-eng', email: 'priya.raman@meridian.dev', phone: '+1 415 555 0177' },
{ id: 'fe-1', name: 'Alice Zhang', title: 'Frontend Engineer', manager: 'em-platform',
email: 'alice.zhang@meridian.dev', phone: '+1 415 555 0191' },
{ id: 'design', name: 'Elena Petrescu', title: 'Head of Design', manager: 'ceo',
email: 'elena.petrescu@meridian.dev', phone: '+40 21 555 0163' },
];
let query = '';
let selectedId = null;
let searchEl;
let chartEl;
let chartReady = false;
onMount(async () => {
await customElements.whenDefined('md-organization-chart');
chartReady = true;
});
function initialsOf(name) {
const parts = name.split(' ');
const last = parts.length > 1 ? parts[parts.length - 1].charAt(0) : '';
return (parts[0].charAt(0) + last).toUpperCase();
}
function buildTree(highlight) {
const byId = new Map(PEOPLE.map((p) => [p.id, {
id: p.id, name: p.name, title: p.title, children: [],
accent: highlight.has(p.id) ? 'var(--md-sys-color-tertiary)' : undefined,
}]));
const roots = [];
for (const p of PEOPLE) {
if (p.manager) byId.get(p.manager).children.push(byId.get(p.id));
else roots.push(byId.get(p.id));
}
return roots;
}
function trailOf(p) {
const trail = [];
let cursor = p;
while (cursor) {
trail.unshift(cursor);
cursor = cursor.manager ? PEOPLE.find((x) => x.id === cursor.manager) : null;
}
return trail;
}
function pick(id) {
if (searchEl) searchEl.close();
selectedId = id;
}
$: q = query.trim().toLowerCase();
$: matches = q ? PEOPLE.filter((p) =>
p.name.toLowerCase().includes(q) || p.title.toLowerCase().includes(q)) : [];
$: person = PEOPLE.find((p) => p.id === selectedId) || null;
$: reportCount = person
? PEOPLE.filter((p) => p.manager === person.id).length : 0;
$: chain = trailOf(person);
$: reportsLabel = reportCount === 0 ? 'No direct reports'
: reportCount === 1 ? '1 direct report' : reportCount + ' direct reports';
// nodes and selectedIds are array props no attribute can carry — assign
// them once the element has upgraded, and again whenever they change.
$: if (chartReady && chartEl) {
chartEl.nodes = buildTree(new Set(matches.map((p) => p.id)));
chartEl.selectedIds = selectedId ? [selectedId] : [];
}
</script>
<md-search bind:this={searchEl} layout="docked" trigger="bar" full-width
debounce="200" placeholder="Search people and roles"
results-label={'{count} people found'}
no-results-label="No one matches that search"
on:mdSearch={(e) => (query = e.detail.value)}
on:mdSubmit={() => matches.length > 0 && pick(matches[0].id)}>
<div slot="results">
{#each matches as p (p.id)}
<button type="button" data-search-result on:click={() => pick(p.id)}>
{p.name} — {p.title}
</button>
{/each}
</div>
</md-search>
<md-organization-chart bind:this={chartEl} selection-mode="single"
label="Meridian Robotics reporting structure"
on:mdSelectionChange={(e) => (selectedId = e.detail.selectedIds[0] || null)}>
<div slot="empty">No people loaded.</div>
</md-organization-chart>
<!-- Clearing the selection on close lets the same card be re-selected. -->
<md-side-sheet variant="modal" headline="Profile" open={person !== null}
on:mdClose={() => (selectedId = null)}>
{#if person}
<span style="position: relative; display: inline-block;">
<md-avatar size="large" initials={initialsOf(person.name)}></md-avatar>
{#if reportCount > 0}
<md-badge value={String(reportCount)}></md-badge>
{/if}
</span>
<div>{person.name}</div>
<div>{person.title}</div>
<div>{reportsLabel}</div>
<md-breadcrumbs label="Reporting line" separator="›">
{#each chain as c (c.id)}
<md-breadcrumb-item>{c.name}</md-breadcrumb-item>
{/each}
</md-breadcrumbs>
<md-chip variant="assist" icon="mail" label={person.email}></md-chip>
<md-chip variant="assist" icon="call" label={person.phone}></md-chip>
{/if}
</md-side-sheet>How it’s built
Section titled “How it’s built”| Component | Role in this screen |
|---|---|
md-organization-chart | The directory itself. nodes is derived from flat records with a manager field; selection-mode="single" makes every card selectable and mdSelectionChange drives the profile sheet. Search matches get a per-node accent tint. |
md-search | Docked bar above the tree. mdSearch (debounced 200 ms) filters the people list, re-renders result rows into the results slot, and re-tints the chart. results-label announces the match count. |
md-side-sheet | The profile panel, variant="modal": scrim, focus trap, Escape to dismiss, and focus restored to the chart on close. show() / close() keep open the single source of truth. |
md-avatar | Initials with a name-hashed container colour, so each person keeps the same tint every time the sheet opens. Left nameless on purpose — the name is adjacent text, so the avatar stays decorative. |
md-badge | Direct-report count pinned to the avatar’s corner; the wrapper span provides the position: relative anchor. The count is mirrored as visible text below the name. |
md-chip | Email and phone as assist chips with mail / call glyphs — contextual actions attached to the profile. |
md-breadcrumbs | The reporting line, CEO first. Crumbs are rebuilt by walking the manager chain; the last one is auto-promoted to current. |
The details that make it production-grade
Section titled “The details that make it production-grade”- The chart is a real
role="tree". Roving tabindex, Arrow Up/Down through visible nodes, Enter/Space to select, Home/End to jump — so the whole select-a-person flow works without a pointer, andaria-selectedtracks the open profile. - One query source, announced. The fetch-equivalent work hangs off
mdSearchalone (debounced, de-duplicated on the trimmed query), and each result row carriesdata-search-result, so ArrowDown steps from the input into the rows and the polite live region reports “3 people found”. - The modal sheet manages focus for you. It traps Tab while open, closes on Escape and scrim click, and restores focus to the previously focused element — the org-chart node you selected — on close.
- The badge is not the only carrier of the count.
md-badgeexposesrole="status", but the sheet also prints “3 direct reports” as plain text, following the M3 rule that the host content reflects what the badge says. - RTL comes free. The sheet’s
sideis logical (end), the chart mirrors its layout and swaps the horizontal arrow keys, and the breadcrumb trail reverses with the reading direction.
Variations
Section titled “Variations”- Deep orgs read better sideways: set
orientation="horizontal"on the chart and start lower branches folded withexpanded: falseper node. - Compare people instead of inspecting one:
selection-mode="multiple"and a chip per selected person —mdSelectionChangehands you the fullselectedIdsarray on every click. - Wide screens can keep the profile open: switch the sheet to
variant="standard"inside a flex row so the tree and the profile sit side by side with no scrim. - Companies bigger than a screen: load reports lazily in
mdNodeToggle(seed each branch with a placeholder child) and cap the tree with the viewport’s built-in horizontal panning.
Related
Section titled “Related”- Recipe: Two-factor verification — the same demo pattern applied to an auth flow.
- Component manuals:
md-organization-chart— node data format, selection and toggle events, keyboard map — andmd-side-sheet— the standard vs. modal accessibility contract.