CollaborationContext switcher
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callContext switcher
The organisation, project and environment you are in, each part a searchable switcher. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add context-switcherReact
npx designme add context-switcher --reactThe CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.
About
What it is for and what it does.
Context switcher is the breadcrumb at the top of a developer dashboard that says where you are: an organisation, a project and an environment. Each part shows its name, an optional badge like a plan ("Pro") or an environment ("Production"), and a ⇅ mark, and opens its own switcher with a search, the list with the current one ticked, and Create new at the bottom. Picking an organisation brings its projects, and picking a project brings the environment it was on.
- When people work across several organisations, projects or environments and need to see, on every page, which one they are in.
- When switching between them should take a couple of keys, without leaving the page.
- When the environment matters, like Production next to Staging, and a badge should make that hard to miss.
- When there are too many items for tabs, so a search in each list helps.
- Each part is a button with the item's name, its badge and ⇅. The organisation also has a tile with its initial. When a pick changes a part, its name and badge crossfade.
- A part's switcher opens under it: a search, a line saying what the list holds ("Projects in Fernhill Labs"), the items with their badges and the current one ticked, and Create new… at the bottom, which reads Create “…” once something is typed. Typing filters the list; with nothing left, it says so.
- The search looks like every field: a quiet grey edge at rest that turns a step stronger while you type in it, on the list's own surface (white in light mode), with no focus ring. The row under the pointer or the arrow keys keeps that surface with a grey edge rather than taking a grey fill, a step stronger on the current one.
- Picking an organisation brings its projects and picking a project brings its environments. In the HTML version each level remembers what was last picked in it; in React, the parts after a pick keep their item if their new list has it, and otherwise take the item marked
isDefault, or the first. - As the bar runs short, each part takes an even share of it and never more than it needs, so the names shorten alike with an ellipsis, and a name cut short shows in full in a tooltip. When the component is 560px wide or less the organisation's tile goes, and at 440px or less, phone width, only the last part shows, and a … button opens a menu with the others, each opening its own switcher.
- In HTML, each change goes out as a
context-changeevent with the whole path, and Create new first sends a cancelablecontext-createevent with the part and what was typed: left alone, it adds one by that name (an organisation with a first project) and picks it. In React,onChangegets the path and the item, andonCreateadds the Create new row.
- The bar is a
navnamed "Context" holding an ordered list; the slashes between the parts are hidden from screen readers. Each part's button hasaria-haspopup="listbox",aria-expandedand a name with its badge, like "Environment: eu-live, Production". - The search is a combobox with
aria-autocomplete="list"that keeps the focus whilearia-activedescendantfollows the highlight through the listbox. Rows are options witharia-selected; Create new is named "Create new project" until something is typed. - Enter, Space or the down arrow on a part opens its switcher with the focus in the search and the current item highlighted; the up arrow opens it on the last row. The arrows move and wrap, Home and End go to the ends, typing filters, Enter picks, Escape closes it and gives the focus back to the part (or to … on a phone), and Tab closes it and moves on. On the bar, the left and right arrows move between the parts; in the search, with nothing typed, they open the part before or after.
- A polite live region says what changed, like "Switched to Invoice reader (Beta), environment staging (Staging)". Every badge's text is at least 4.5:1 and APCA Lc 60 on the bar and in the list, hovered rows included, in both styles and themes.
Usage
Import it and drive it from your own state.
Import
import { ContextSwitcher } from '@/components/designme/context-switcher/ContextSwitcher';Example
The demo above as a React component, 72 lines you can copy.
'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useRef, useState } from 'react';
import { ContextSwitcher, type ContextSwitcherItem, type ContextSwitcherSegment, type ContextSwitcherValue } from './ContextSwitcher';
import { useStory } from '../lib/useScript';
/* ---------- Context switcher: open the project's switcher, search for "inv" and pick Invoice reader; the environment
follows it from eu-live in Production to staging ---------- */
type Project = ContextSwitcherItem & { envs: ContextSwitcherItem[] };
type Org = ContextSwitcherItem & { projects: Project[] };
const env = (id: string, kind: 'Production' | 'Staging' | 'Development', isDefault = false): ContextSwitcherItem =>
({ id, name: id, badge: { label: kind, tone: kind === 'Production' ? 'warning' : kind === 'Staging' ? 'blue' : 'neutral' }, isDefault });
const envs = (on: string) => [env('live', 'Production', on === 'live'), env('staging', 'Staging', on === 'staging'), env('sandbox', 'Development', on === 'sandbox')];
const ORGS: Org[] = [
{ id: 'fernhill', name: 'Fernhill Labs', tile: 'blue', badge: { label: 'Pro', tone: 'accent' }, projects: [
{ id: 'triage', name: 'Support triage agent', envs: [env('eu-live', 'Production', true), env('us-live', 'Production'), env('staging', 'Staging'), env('sandbox', 'Development')] },
{ id: 'invoice', name: 'Invoice reader', badge: { label: 'Beta', tone: 'teal' }, envs: envs('staging') },
{ id: 'research', name: 'Sales research agent', envs: envs('live') },
{ id: 'onboard', name: 'Onboarding copilot', badge: 'Paused', envs: envs('sandbox') },
{ id: 'notes', name: 'Release notes writer', envs: envs('live') }
] },
{ id: 'quillwork', name: 'Quillwork', tile: 'orange', badge: 'Free', projects: [
{ id: 'coach', name: 'Essay coach', envs: envs('live') },
{ id: 'cite', name: 'Citation checker', badge: { label: 'Beta', tone: 'teal' }, envs: envs('staging') }
] },
{ id: 'otterline', name: 'Otterline Health', tile: 'green', badge: { label: 'Enterprise', tone: 'violet' }, projects: [
{ id: 'intake', name: 'Intake assistant', envs: envs('live') },
{ id: 'claims', name: 'Claims summariser', envs: envs('live') },
{ id: 'handover', name: 'Shift handover notes', badge: { label: 'Beta', tone: 'teal' }, envs: envs('sandbox') }
] }
];
export default function ContextSwitcherDemo() {
const ref = useRef<HTMLDivElement>(null);
const [orgs, setOrgs] = useState(ORGS);
const [value, setValue] = useState<ContextSwitcherValue>({ org: 'fernhill', project: 'triage', env: 'eu-live' });
const [open, setOpen] = useState<string | null>(null);
const [query, setQuery] = useState('');
const org = (v: ContextSwitcherValue) => orgs.find(o => o.id === v.org) ?? orgs[0];
const project = (v: ContextSwitcherValue) => org(v).projects.find(p => p.id === v.project) ?? org(v).projects[0];
// each part's list comes from the parts before it, so picking an organisation brings its projects
const segments: ContextSwitcherSegment[] = [
{ key: 'org', label: 'Organisation', items: orgs, heading: 'Your organisations' },
{ key: 'project', label: 'Project', items: v => org(v).projects, heading: v => `Projects in ${org(v).name}` },
{ key: 'env', label: 'Environment', items: v => project(v).envs, heading: v => `Environments of ${project(v).name}` }
];
// Create new adds one by the name typed (an organisation comes with a first project) and picks it
const create = (key: string, name: string) => {
const id = `new-${Date.now()}`;
const next = orgs.map(o => ({ ...o, projects: o.projects.map(p => ({ ...p, envs: [...p.envs] })) }));
const o = next.find(x => x.id === value.org) ?? next[0], p = o.projects.find(x => x.id === value.project) ?? o.projects[0];
if (key === 'org') next.push({ id, name: name || 'New organisation', tile: 'violet', badge: 'Free', projects: [{ id: id + '-p', name: 'First project', envs: envs('sandbox') }] });
else if (key === 'project') o.projects.push({ id, name: name || 'New project', envs: envs('sandbox') });
else p.envs.push(env(name || 'new-environment', 'Development'));
setOrgs(next); setValue({ ...value, [key]: key === 'env' ? name || 'new-environment' : id });
};
// the story stops at the visitor's first click or key, so it never types into their search or picks over their choice
useStory(ref, async ({ wait, press }) => {
await wait(1500); setOpen('project');
await wait(1000);
let typed = '';
for (const ch of 'inv') { typed += ch; setQuery(typed); await wait(200); }
await wait(1000); press('.dm-csw-o.dm-hl');
});
return (
<div ref={ref} style={{ width: '100%' }}>
<ContextSwitcher segments={segments} value={value} onChange={setValue} onCreate={create}
open={open} onOpenChange={setOpen} query={query} onQueryChange={setQuery} />
</div>
);
}
Props
Everything ContextSwitcher takes.
| Prop | Type | What it does |
|---|---|---|
segmentsrequired | ContextSwitcherSegment[] | The parts, outermost first: an organisation, its projects, a project's environments |
value | ContextSwitcherValue | What is picked in each part. A part left out, or holding an id its list doesn't have, takes its default item |
defaultValue | ContextSwitcherValue | |
onChange | (value: ContextSwitcherValue, change: { key: string; item: ContextSwitcherItem }) => void | Called with the whole path after a pick. The parts after the one picked keep their item if their new list has it, and take its default (or first) item otherwise |
onCreate | (key: string, query: string) => void | Adds Create new… at the bottom of each list (Create “…” once something is typed), and is called with the part's key and what was typed. Add the item to your data and pick it in `value` |
open | string | null | The key of the part whose switcher is open, or null. Leave it out to let the bar open and close them |
defaultOpen | string | null | |
onOpenChange | (key: string | null) => void | |
query | string | What is typed in the open switcher's search. Opening a switcher clears it |
defaultQuery | string | |
onQueryChange | (query: string) => void | |
label | string | The bar's name for screen readers. Defaults to "Context" |
className | string |
Code
The full source, as the CLI installs it.
ContextSwitcher.tsxReact · 336 lines
'use client';
import { useEffect, useId, useRef, useState, type CSSProperties, type KeyboardEvent, type MouseEvent } from 'react';
import { cx } from '../lib/cx';
import { useControllable, useIsoLayoutEffect, useReducedMotion } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { Swap, Tip } from '../lib/motion';
import '../designme.css';
import './context-switcher.css';
/** A badge's colour, the same tones as Badge: neutral grey, the accent's tint, the three states, or a tag colour */
export type ContextSwitcherTone = 'neutral' | 'accent' | 'success' | 'warning' | 'danger' | 'blue' | 'violet' | 'green' | 'orange' | 'red' | 'yellow' | 'pink' | 'teal';
/** The tint of an item's tile */
export type ContextSwitcherTint = 'blue' | 'violet' | 'green' | 'orange' | 'red' | 'yellow' | 'pink' | 'teal';
/** One organisation, project or environment */
export interface ContextSwitcherItem {
id: string;
name: string;
/** A small badge after the name, like a plan ("Pro") or an environment ("Production"). A string is a neutral badge */
badge?: string | { label: string; tone?: ContextSwitcherTone };
/** A tile with the name's first letter on this tint, before the name, as the organisations have on designme.dev */
tile?: ContextSwitcherTint;
/** Where its list lands when the part before it changes and the item held is no longer in the list. The first item otherwise */
isDefault?: boolean;
}
/** The id picked in each part, by the part's key, like { org: 'fernhill', project: 'invoice', env: 'staging' } */
export type ContextSwitcherValue = Record<string, string>;
/** One part of the path */
export interface ContextSwitcherSegment {
key: string;
/** What the part is, like "Organisation". Read out with the name, and shown in the … menu on a phone */
label: string;
/** Its items, or a function that works them out from the parts before it, so picking an organisation brings its projects */
items: ContextSwitcherItem[] | ((value: ContextSwitcherValue) => ContextSwitcherItem[]);
/** The line over the list, like "Projects in Fernhill Labs" */
heading?: string | ((value: ContextSwitcherValue) => string);
/** The search's placeholder and name. Defaults to "Find a" and the noun */
placeholder?: string;
/** One of its items, for "Create new project". Defaults to the label in lower case */
noun?: string;
/** Several, for the list's name and "No projects match". Defaults to the noun with an s */
plural?: string;
}
export interface ContextSwitcherProps {
/** The parts, outermost first: an organisation, its projects, a project's environments */
segments: ContextSwitcherSegment[];
/** What is picked in each part. A part left out, or holding an id its list doesn't have, takes its default item */
value?: ContextSwitcherValue;
defaultValue?: ContextSwitcherValue;
/** Called with the whole path after a pick. The parts after the one picked keep their item if their new list has it, and
take its default (or first) item otherwise */
onChange?: (value: ContextSwitcherValue, change: { key: string; item: ContextSwitcherItem }) => void;
/** Adds Create new… at the bottom of each list (Create “…” once something is typed), and is called with the part's key and
what was typed. Add the item to your data and pick it in `value` */
onCreate?: (key: string, query: string) => void;
/** The key of the part whose switcher is open, or null. Leave it out to let the bar open and close them */
open?: string | null;
defaultOpen?: string | null;
onOpenChange?: (key: string | null) => void;
/** What is typed in the open switcher's search. Opening a switcher clears it */
query?: string;
defaultQuery?: string;
onQueryChange?: (query: string) => void;
/** The bar's name for screen readers. Defaults to "Context" */
label?: string;
className?: string;
}
const TINT: Record<ContextSwitcherTint, string> = { blue: 'pb', violet: 'pl', green: 'pm', orange: 'pp', red: 'pr', yellow: 'py', pink: 'pk', teal: 'pt' };
type Bdg = { label: string; tone: ContextSwitcherTone };
const badgeOf = (x?: ContextSwitcherItem): Bdg | undefined => !x?.badge ? undefined : typeof x.badge === 'string' ? { label: x.badge, tone: 'neutral' } : { label: x.badge.label, tone: x.badge.tone ?? 'neutral' };
const BadgeEl = ({ b }: { b: Bdg }) => <span className="dm-csw-bd" data-tone={b.tone === 'neutral' ? undefined : b.tone}>{b.label}</span>;
const Tile = ({ x }: { x: ContextSwitcherItem }) => x.tile ? <span className="dm-csw-lg" data-t={TINT[x.tile]} aria-hidden="true">{x.name[0]}</span> : null;
const listOf = (s: ContextSwitcherSegment, v: ContextSwitcherValue) => typeof s.items === 'function' ? s.items(v) : s.items;
/** The whole path: each part keeps its item if its list has it, otherwise takes the list's default or first item */
function settle(segments: ContextSwitcherSegment[], v: ContextSwitcherValue) {
const out: ContextSwitcherValue = {};
for (const s of segments) {
const items = listOf(s, out), held = v[s.key];
out[s.key] = (items.find(i => i.id === held) ?? items.find(i => i.isDefault) ?? items[0])?.id ?? '';
}
return out;
}
/** The badge in the bar crossfades when it changes, like the name beside it */
function FadeBadge({ b }: { b?: Bdg }) {
const k = b ? `${b.tone}|${b.label}` : '';
const [shown, setShown] = useState(k), [out, setOut] = useState(false);
const reduce = useReducedMotion();
useEffect(() => {
if (k === shown) { setOut(false); return; }
setOut(true);
const t = setTimeout(() => { setShown(k); setOut(false); }, reduce ? 0 : 170);
return () => clearTimeout(t);
}, [k, shown, reduce]);
const at = shown.indexOf('|');
return <span className={cx('dm-csw-bh', 'dm-sw', out && 'dm-out')}>{shown && <BadgeEl b={{ tone: shown.slice(0, at) as ContextSwitcherTone, label: shown.slice(at + 1) }} />}</span>;
}
/** Where you are in an app, as a breadcrumb: an organisation, a project and an environment, or any parts you give it. Each
part opens a switcher with a search, its list (the current one ticked, each with its badge) and Create new. Narrow, the
names shorten with an ellipsis; phone narrow, only the last part shows and a … menu holds the others. */
export function ContextSwitcher({ segments, value, defaultValue, onChange, onCreate, open, defaultOpen = null, onOpenChange, query, defaultQuery = '',
onQueryChange, label = 'Context', className }: ContextSwitcherProps) {
const [inner, setInner] = useState<ContextSwitcherValue>(defaultValue ?? {});
const cur = settle(segments, value ?? inner);
const [openKey, setOpenKey] = useControllable<string | null>(open, defaultOpen, onOpenChange);
const [q, setQ] = useControllable(query, defaultQuery, onQueryChange);
const [more, setMore] = useState(false);
const [said, setSaid] = useState('');
const id = useId(), listId = `${id}-list`;
const root = useRef<HTMLDivElement>(null), bar = useRef<HTMLElement>(null), pop = useRef<HTMLDivElement>(null), mpop = useRef<HTMLDivElement>(null);
const search = useRef<HTMLInputElement>(null), list = useRef<HTMLDivElement>(null), moreBtn = useRef<HTMLButtonElement>(null), mi = useRef<HTMLLIElement>(null);
const trigs = useRef<(HTMLButtonElement | null)[]>([]), opts = useRef<(HTMLDivElement | null)[]>([]);
// the switcher keeps showing the part it was opened for while it fades out
const lastKey = useRef(segments[0]?.key);
if (openKey) lastKey.current = openKey;
const d = Math.max(0, segments.findIndex(s => s.key === lastKey.current)), seg = segments[d];
const noun = seg?.noun ?? seg?.label.toLowerCase() ?? '', plural = seg?.plural ?? noun + 's';
const placeholder = seg?.placeholder ?? `Find ${/^[aeiou]/i.test(noun) ? 'an' : 'a'} ${noun}`;
const items = seg ? listOf(seg, cur) : [];
const v = q.trim(), lv = v.toLowerCase();
const shown = items.filter(x => !lv || x.name.toLowerCase().includes(lv));
const total = shown.length + (onCreate ? 1 : 0);
const heading = !seg?.heading ? '' : typeof seg.heading === 'function' ? seg.heading(cur) : seg.heading;
// the highlight starts on the current item, or on the first match once something is typed; it starts again whenever a
// switcher opens or the list changes
const [opens, setOpens] = useState(0), [wasOpen, setWasOpen] = useState(openKey);
if (wasOpen !== openKey) { setWasOpen(openKey); if (openKey) setOpens(opens + 1); }
const sig = `${opens}|${openKey}|${lv}`;
const [hi, setHi] = useState({ at: 0, for: '' });
const at = hi.for === sig ? Math.min(hi.at, total - 1) : lv ? 0 : Math.max(0, shown.findIndex(x => x.id === cur[seg?.key ?? '']));
const move = (by: number) => { if (total) setHi({ at: (at + by + total) % total, for: sig }); };
// phone narrow (the CSS decides), only the last part shows and the … button stands in for the others
const narrow = () => !!mi.current && getComputedStyle(mi.current).display !== 'none';
const anchor = (k: string) => { const i = segments.findIndex(s => s.key === k); return narrow() && i < segments.length - 1 ? moreBtn.current : trigs.current[i]; };
const [place, setPlace] = useState<CSSProperties>({}), [mplace, setMplace] = useState<CSSProperties>({});
const placeUnder = (el: HTMLElement | null, at: HTMLElement | null | undefined) => {
const r = root.current, b = bar.current; if (!el || !at || !r || !b) return {};
const rr = r.getBoundingClientRect(), ar = at.getBoundingClientRect(), br = b.getBoundingClientRect();
return { left: Math.max(0, Math.min(ar.left - rr.left, rr.width - el.offsetWidth)), top: Math.round(br.bottom - rr.top + 6) };
};
// once a closed one has faded it goes back to the component's corner, so a narrower screen later never scrolls sideways to it
useIsoLayoutEffect(() => {
if (openKey) { setPlace(placeUnder(pop.current, anchor(openKey))); return; }
const t = setTimeout(() => setPlace({}), 320); return () => clearTimeout(t);
}, [openKey]); // eslint-disable-line react-hooks/exhaustive-deps
useIsoLayoutEffect(() => {
if (more) { setMplace(placeUnder(mpop.current, moreBtn.current)); return; }
const t = setTimeout(() => setMplace({}), 320); return () => clearTimeout(t);
}, [more]); // eslint-disable-line react-hooks/exhaustive-deps
// where the focus goes once a switcher opens: its search, with the highlight on the last row for the up arrow
const focusTo = useRef<'search' | 'last' | null>(null), menuFocus = useRef<'first' | 'last' | null>(null);
useIsoLayoutEffect(() => {
if (!openKey || !focusTo.current) return;
if (focusTo.current === 'last') setHi({ at: total - 1, for: sig });
focusTo.current = null; search.current?.focus({ preventScroll: true });
}, [openKey, sig]); // eslint-disable-line react-hooks/exhaustive-deps
useIsoLayoutEffect(() => {
if (!more || !menuFocus.current) return;
const all = Array.from(mpop.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? []);
(menuFocus.current === 'last' ? all[all.length - 1] : all[0])?.focus({ preventScroll: true }); menuFocus.current = null;
}, [more]);
// the list scrolls to keep the highlight in view, never the page
useIsoLayoutEffect(() => {
const l = list.current, o = opts.current[at]; if (!openKey || !l) return;
if (!at) { l.scrollTop = 0; return; } if (!o) return;
const lr = l.getBoundingClientRect(), r = o.getBoundingClientRect();
if (r.top < lr.top) l.scrollTop -= lr.top - r.top; else if (r.bottom > lr.bottom) l.scrollTop += r.bottom - lr.bottom;
}, [at, openKey, sig]);
const show = (k: string, focus: 'search' | 'last' | null) => { setMore(false); setQ(''); focusTo.current = focus; if (openKey !== k) setOpenKey(k); else if (focus) search.current?.focus({ preventScroll: true }); };
// closing gives the focus back to the part's button (or the … button, on a phone) when it was in the switcher
const hide = (back: boolean) => { const k = openKey; setOpenKey(null); if (back && k) anchor(k)?.focus({ preventScroll: true }); };
const menu = (on: boolean, focus: 'first' | 'last' | null = null) => {
if (on) { if (openKey) setOpenKey(null); menuFocus.current = focus; setMore(true); return; }
const back = !!mpop.current?.contains(document.activeElement); setMore(false); if (back) moreBtn.current?.focus({ preventScroll: true });
};
// what changed, said once for screen readers: the part picked and the parts that came with it
const announce = (next: ContextSwitcherValue, from: number) => {
const parts = segments.slice(from).map((s, i) => {
const x = listOf(s, next).find(it => it.id === next[s.key]), b = badgeOf(x);
return x ? `${i ? (s.noun ?? s.label.toLowerCase()) + ' ' : ''}${x.name}${b ? ` (${b.label})` : ''}` : '';
}).filter(Boolean);
setSaid('Switched to ' + parts.join(', '));
};
const pick = (i: number) => {
if (!seg || !openKey) return;
const back = !!pop.current?.contains(document.activeElement);
if (i >= shown.length) { hide(back); onCreate?.(seg.key, v); return; }
const item = shown[i]; hide(back);
if (item.id === cur[seg.key]) return;
const next = settle(segments, { ...cur, [seg.key]: item.id });
if (value === undefined) setInner(next);
onChange?.(next, { key: seg.key, item }); announce(next, d);
};
// on the bar, the left and right arrows move between the parts you can see
const sideways = (e: KeyboardEvent<HTMLButtonElement>) => {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return false;
const all = [moreBtn.current, ...trigs.current].filter((b): b is HTMLButtonElement => !!b && b.getClientRects().length > 0);
const to = all[all.indexOf(e.currentTarget) + (e.key === 'ArrowLeft' ? -1 : 1)];
if (to) { e.preventDefault(); to.focus({ preventScroll: true }); }
return true;
};
// in the search: the arrows move and wrap, Home and End go to the ends, Enter picks, Escape closes it and gives the focus
// back. With nothing typed, the left and right arrows open the part before or after
const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
const k = e.key;
if (k === 'ArrowDown' || k === 'ArrowUp') { e.preventDefault(); move(k === 'ArrowDown' ? 1 : -1); }
else if (k === 'Home' || k === 'End') { e.preventDefault(); if (total) setHi({ at: k === 'Home' ? 0 : total - 1, for: sig }); }
else if (k === 'Enter') { e.preventDefault(); if (total) pick(at); }
else if (k === 'Escape') { e.preventDefault(); e.stopPropagation(); hide(true); }
else if ((k === 'ArrowLeft' || k === 'ArrowRight') && !q) { const n = segments[d + (k === 'ArrowLeft' ? -1 : 1)]; if (n) { e.preventDefault(); show(n.key, 'search'); } }
};
const onMenuKey = (e: KeyboardEvent<HTMLDivElement>) => {
const all = Array.from(mpop.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? []), i = all.indexOf(document.activeElement as HTMLElement);
const go = ({ ArrowDown: all[(i + 1) % all.length], ArrowUp: all[(i - 1 + all.length) % all.length], Home: all[0], End: all[all.length - 1] } as Record<string, HTMLElement | undefined>)[e.key];
if (go) { e.preventDefault(); go.focus({ preventScroll: true }); }
else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); menu(false); }
else if (e.key === 'Tab') setTimeout(() => setMore(false));
};
// a press anywhere else closes them, and so does a real resize; Tab out of a switcher closes it
useEffect(() => {
if (!openKey && !more) return;
const down = (e: PointerEvent) => {
const t = e.target as HTMLElement;
if (pop.current?.contains(t) || mpop.current?.contains(t) || (root.current?.contains(t) && t.closest('.dm-csw-tr,.dm-csw-more'))) return;
setOpenKey(null); setMore(false);
};
const resize = (e: UIEvent) => { if (e.isTrusted) { setOpenKey(null); setMore(false); } };
document.addEventListener('pointerdown', down); addEventListener('resize', resize);
return () => { document.removeEventListener('pointerdown', down); removeEventListener('resize', resize); };
}, [openKey, more, setOpenKey]);
// a name the ellipsis cuts short shows in full in a tooltip
const [cut, setCut] = useState<boolean[]>([]);
const names = segments.map(s => listOf(s, cur).find(x => x.id === cur[s.key])?.name ?? '').join('|');
useEffect(() => {
const check = () => setCut(trigs.current.map(t => { const n = t?.querySelector<HTMLElement>('.dm-csw-nm'); return !!n && n.scrollWidth > n.clientWidth + 1; }));
const t = setTimeout(check, 220); const ro = new ResizeObserver(check); if (bar.current) ro.observe(bar.current);
return () => { clearTimeout(t); ro.disconnect(); };
}, [names]);
const named = (s: ContextSwitcherSegment, x?: ContextSwitcherItem) => { const b = badgeOf(x); return `${s.label}: ${x?.name ?? ''}${b ? ', ' + b.label : ''}`; };
const crumbs = segments.flatMap((s, i) => {
const x = listOf(s, cur).find(it => it.id === cur[s.key]);
const onClick = (e: MouseEvent) => { if (openKey === s.key) hide(!!pop.current?.contains(document.activeElement)); else show(s.key, e.nativeEvent.isTrusted ? 'search' : null); };
const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => { if (sideways(e)) return; if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); show(s.key, e.key === 'ArrowUp' ? 'last' : 'search'); } };
return [
...(i ? [<li key={s.key + '-sep'} className="dm-csw-sep" aria-hidden="true"><Icon name="crumb" /></li>] : []),
<li key={s.key} className="dm-csw-seg" data-k={s.key}>
<Tip label={x?.name ?? ''} off={!cut[i] || openKey === s.key}>
<button ref={el => { trigs.current[i] = el; }} type="button" className="dm-csw-tr" aria-haspopup="listbox" aria-expanded={openKey === s.key} aria-controls={listId}
aria-label={named(s, x)} onClick={onClick} onKeyDown={onKeyDown}>
{x && <Tile x={x} />}<Swap className="dm-csw-nm">{x?.name ?? ''}</Swap><FadeBadge b={badgeOf(x)} /><Icon name="unfold" size="xs" />
</button>
</Tip>
</li>
];
});
const hidden = segments.slice(0, -1), moreLabel = hidden.map(s => s.label).join(' and ');
return (
<div data-dm="" ref={root} className={cx('dm-csw', className)}>
<nav ref={bar} className="dm-csw-bar dm-panel" aria-label={label}>
<ol className="dm-csw-path">
<li ref={mi} className="dm-csw-mi">
<Tip label={moreLabel} off={more}>
<button ref={moreBtn} type="button" className="dm-csw-more" aria-haspopup="menu" aria-expanded={more} aria-label={moreLabel}
onClick={e => menu(!more, e.nativeEvent.isTrusted ? 'first' : null)}
onKeyDown={e => { if (sideways(e)) return; if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); menu(true, e.key === 'ArrowUp' ? 'last' : 'first'); } }}>
<Icon name="more" />
</button>
</Tip>
</li>
<li className="dm-csw-sep dm-m" aria-hidden="true"><Icon name="crumb" /></li>
{crumbs}
</ol>
</nav>
<div ref={pop} className={cx('dm-pop', 'dm-csw-pop', openKey && 'dm-open')} style={place} inert={!openKey}
onBlur={e => { const t = e.relatedTarget as Node | null; if (openKey && t && !pop.current?.contains(t) && t !== anchor(openKey)) setOpenKey(null); }}>
<div className="dm-csw-sf">
<Icon name="search" size="xs" />
<input ref={search} className="dm-csw-q" role="combobox" aria-autocomplete="list" aria-expanded={!!openKey} aria-controls={listId}
aria-activedescendant={openKey && total ? `${id}-o${at}` : undefined} aria-label={placeholder} placeholder={placeholder}
spellCheck={false} autoComplete="off" value={q} onChange={e => setQ(e.target.value)} onKeyDown={onKey} />
</div>
<div ref={list} className="dm-csw-list" role="listbox" id={listId} aria-label={plural[0]?.toUpperCase() + plural.slice(1)}>
{heading && <div className="dm-ph" role="presentation">{heading}</div>}
{shown.map((x, i) => {
const b = badgeOf(x);
return (
<div key={x.id} ref={el => { opts.current[i] = el; }} id={`${id}-o${i}`} role="option" aria-selected={x.id === cur[seg.key]}
className={cx('dm-mi', 'dm-csw-o', i === at && 'dm-hl')} onMouseMove={() => { if (at !== i) setHi({ at: i, for: sig }); }}
onMouseDown={e => e.preventDefault()} onClick={() => pick(i)}>
<Tile x={x} /><span className="dm-csw-on">{x.name}</span>{b && <BadgeEl b={b} />}<span className="dm-ck"><Icon name="check" size="xs" /></span>
</div>
);
})}
{!shown.length && <div className="dm-csw-none" role="presentation">No {plural} match “{v}”</div>}
{onCreate && <>
<div className="dm-sep" role="presentation" />
<div ref={el => { opts.current[shown.length] = el; }} id={`${id}-o${shown.length}`} role="option" aria-selected={false} aria-label={v ? undefined : `Create new ${noun}`}
className={cx('dm-mi', 'dm-csw-o', 'dm-csw-new', at === shown.length && 'dm-hl')} onMouseMove={() => { if (at !== shown.length) setHi({ at: shown.length, for: sig }); }}
onMouseDown={e => e.preventDefault()} onClick={() => pick(shown.length)}>
<Icon name="plus" size="xs" /><span className="dm-csw-on">{v ? `Create “${v}”` : 'Create new…'}</span>
</div>
</>}
</div>
</div>
<div ref={mpop} className={cx('dm-pop', 'dm-csw-mpop', more && 'dm-open')} style={mplace} inert={!more} role="menu" aria-label={moreLabel} onKeyDown={onMenuKey}>
{hidden.map((s, i) => {
const x = listOf(s, cur).find(it => it.id === cur[s.key]), b = badgeOf(x);
return (
<button key={s.key} type="button" className="dm-mi" role="menuitem" tabIndex={-1} onClick={e => { setMore(false); show(s.key, e.nativeEvent.isTrusted ? 'search' : null); }}>
{x?.tile ? <Tile x={x} /> : <Icon name={i ? 'layers' : 'folder'} />}<span className="dm-t"><b>{x?.name}</b><span className="dm-d">{s.label}</span></span>{b && <BadgeEl b={b} />}
</button>
);
})}
</div>
<p className="dm-csw-live" aria-live="polite">{said}</p>
</div>
);
}
context-switcher.cssReact · 77 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Context switcher ---------- */
/* where you are in an app, as a breadcrumb: organisation, project and environment. Each part opens its own switcher, with a
search, the list (the current one ticked, each with its badge) and Create new at the bottom. When the bar runs short the
names shorten with an ellipsis; when it is phone narrow only the last part shows, and a … menu holds the others */
.dm-csw{position:relative;width:100%;max-width:640px;margin:0 auto;container:csw/inline-size}
.dm-csw-bar{display:flex;align-items:center;width:fit-content;max-width:100%;margin:0 auto;min-height:52px;padding:var(--dm-s-2);border-radius:var(--dm-r-card)}
.dm-csw-path{flex:1;min-width:0;display:flex;align-items:center;gap:var(--dm-s-1);margin:0;padding:0;list-style:none}
.dm-csw-path>li{display:flex;align-items:center;min-width:0}
/* short of room, each part takes an even share of the bar and never more than it needs, so a short name keeps its length
and the long ones shorten alike; the last part, where you are now, gets a larger share */
.dm-csw-seg{flex:1 1 0;max-width:max-content}.dm-csw-seg:last-child{flex-grow:1.5}
.dm-csw-sep{flex:none;color:var(--dm-muted)}
.dm-csw-path>.dm-csw-mi,.dm-csw-path>.dm-csw-sep.dm-m{display:none}
.dm-csw-tr{display:flex;align-items:center;gap:var(--dm-s-2);min-width:0;max-width:100%;height:36px;padding:0 var(--dm-s-2);border-radius:var(--dm-r-sm);font-size:13.5px;font-weight:500;color:var(--dm-fg);white-space:nowrap;transition:background var(--dm-t-fast)}
@media (hover:hover) and (pointer:fine){.dm-csw-tr:hover{background:var(--dm-row)}}
.dm-csw-tr>.dm-i{color:var(--dm-muted);flex:none}
.dm-csw-nm{min-width:0;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.dm-csw-bh{display:flex;flex:none}
.dm-csw-bh:empty{display:none}
/* an organisation's tile: its initial on its tint, the letter leaning toward the text colour so it reads in dark mode too */
.dm-csw-lg{--dm-tb:var(--dm-chip);--dm-tc:var(--dm-fg2);width:22px;height:22px;border-radius:var(--dm-r-xs);display:grid;place-items:center;flex:none;background:var(--dm-tb);color:color-mix(in srgb,var(--dm-tc) 80%,var(--dm-fg));font-size:11px;font-weight:500}
.dm-csw-lg[data-t="pb"]{--dm-tb:var(--dm-pb-bg);--dm-tc:var(--dm-pb)}.dm-csw-lg[data-t="pl"]{--dm-tb:var(--dm-pl-bg);--dm-tc:var(--dm-pl)}.dm-csw-lg[data-t="pm"]{--dm-tb:var(--dm-pm-bg);--dm-tc:var(--dm-pm)}.dm-csw-lg[data-t="pp"]{--dm-tb:var(--dm-pp-bg);--dm-tc:var(--dm-pp)}
.dm-csw-lg[data-t="pr"]{--dm-tb:var(--dm-pr-bg);--dm-tc:var(--dm-pr)}.dm-csw-lg[data-t="py"]{--dm-tb:var(--dm-py-bg);--dm-tc:var(--dm-py)}.dm-csw-lg[data-t="pk"]{--dm-tb:var(--dm-pk-bg);--dm-tc:var(--dm-pk)}.dm-csw-lg[data-t="pt"]{--dm-tb:var(--dm-pt-bg);--dm-tc:var(--dm-pt)}
/* the badges are Badge's small size, in the same tones */
.dm-csw-bd{--dm-cb-bg:var(--dm-chip);--dm-cb-fg:var(--dm-fg2);flex:none;display:inline-flex;align-items:center;height:20px;padding:0 var(--dm-s-2);border-radius:var(--dm-r-pill);background:var(--dm-cb-bg);color:var(--dm-cb-fg);font-size:11.5px;font-weight:500;line-height:1;white-space:nowrap}
.dm-csw-bd[data-tone="accent"]{--dm-cb-bg:var(--dm-accent-bg);--dm-cb-fg:var(--dm-accent-fg)}
.dm-csw-bd[data-tone="success"]{--dm-cb-bg:var(--dm-ok-bg);--dm-cb-fg:var(--dm-ok)}.dm-csw-bd[data-tone="warning"]{--dm-cb-bg:var(--dm-warn-bg);--dm-cb-fg:var(--dm-warn)}.dm-csw-bd[data-tone="danger"]{--dm-cb-bg:var(--dm-bad-bg);--dm-cb-fg:var(--dm-bad)}
.dm-csw-bd[data-tone="blue"]{--dm-cb-bg:var(--dm-pb-bg);--dm-cb-fg:var(--dm-pb)}.dm-csw-bd[data-tone="violet"]{--dm-cb-bg:var(--dm-pl-bg);--dm-cb-fg:var(--dm-pl)}.dm-csw-bd[data-tone="green"]{--dm-cb-bg:var(--dm-pm-bg);--dm-cb-fg:var(--dm-pm)}.dm-csw-bd[data-tone="orange"]{--dm-cb-bg:var(--dm-pp-bg);--dm-cb-fg:var(--dm-pp)}
.dm-csw-bd[data-tone="red"]{--dm-cb-bg:var(--dm-pr-bg);--dm-cb-fg:var(--dm-pr)}.dm-csw-bd[data-tone="yellow"]{--dm-cb-bg:var(--dm-py-bg);--dm-cb-fg:var(--dm-py)}.dm-csw-bd[data-tone="pink"]{--dm-cb-bg:var(--dm-pk-bg);--dm-cb-fg:var(--dm-pk)}.dm-csw-bd[data-tone="teal"]{--dm-cb-bg:var(--dm-pt-bg);--dm-cb-fg:var(--dm-pt)}
.dm-csw-more{display:grid;place-items:center;width:32px;height:32px;border-radius:var(--dm-r-sm);color:var(--dm-fg2);flex:none;transition:background var(--dm-t-fast)}
@media (hover:hover) and (pointer:fine){.dm-csw-more:hover{background:var(--dm-row)}}
/* the switcher: under the part it belongs to, never past the bar's ends */
.dm-csw-pop{width:288px;max-width:100%}
/* a switcher or the … menu fades in a beat after the bar starts gliding up to make room for it, so it never shows past the
bottom of the space it was given */
.dm-glide>.dm-csw-pop.dm-open,.dm-glide>.dm-csw-mpop.dm-open{transition-delay:.1s}
.dm-csw-sf{position:relative;display:flex;align-items:center;margin-bottom:var(--dm-s-1)}
.dm-csw-sf>.dm-i{position:absolute;left:10px;color:var(--dm-muted);pointer-events:none}
.dm-csw-q{width:100%;height:32px;border:0;outline:0;border-radius:var(--dm-r-field);background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line) 50%,transparent);transition:box-shadow var(--dm-t-fast);padding:0 var(--dm-s-2) 0 28px;font:inherit;font-size:13px;color:var(--dm-fg)}
.dm-csw-q::placeholder{color:var(--dm-muted)}
.dm-csw-q:focus{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent)}
@supports (corner-shape:squircle){.dm-csw-q{corner-shape:squircle;border-radius:var(--dm-r-field-sq)}}
.dm-csw-list{max-height:248px;overflow-y:auto;display:flex;flex-direction:column;gap:2px}
.dm-csw-o{flex:none;min-height:34px;cursor:pointer}
/* the row under the pointer or the arrow keys, the current one, and a hovered row in the … menu get a soft grey fill behind
them, with no edge and no tick */
@media (hover:hover) and (pointer:fine){.dm-csw-pop .dm-csw-o:is(:hover,.dm-hl,[aria-selected="true"]),.dm-csw-mpop .dm-mi:hover{background:var(--dm-row);box-shadow:none}}
.dm-csw-o .dm-csw-on{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.dm-csw-o .dm-csw-lg{width:20px;height:20px;font-size:10.5px}
.dm-csw-o .dm-ck{margin-left:0;flex:none}
.dm-csw-o .dm-ck{display:none}
.dm-csw-o[aria-selected="true"] .dm-csw-on{font-weight:500}
/* a hovered row's tint lifts the dark list a step, so a badge on it leans a little toward the text colour and keeps APCA 60 */
.dm-csw-o:is(.dm-hl,[aria-selected="true"]) .dm-csw-bd{color:color-mix(in srgb,var(--dm-cb-fg) 85%,var(--dm-fg))}
@media (hover:hover) and (pointer:fine){.dm-csw-o:hover .dm-csw-bd,.dm-csw-tr:hover .dm-csw-bd{color:color-mix(in srgb,var(--dm-cb-fg) 85%,var(--dm-fg))}}
/* on a highlighted row a tinted chip takes a fuller tint, and a grey one turns white with a quiet edge, so neither fades into
the row behind it */
@media (hover:hover) and (pointer:fine){.dm-csw-o:is(:hover,.dm-hl,[aria-selected="true"]) .dm-csw-bd,.dm-csw-tr:hover .dm-csw-bd,.dm-mi:is(:hover,.dm-hl) .dm-bdg{--dm-cb-bg:color-mix(in srgb,var(--dm-cb-fg) 16%,var(--dm-surface));--dm-bdg-bg:color-mix(in srgb,var(--dm-bdg-fg) 16%,var(--dm-surface))}}
@media (hover:hover) and (pointer:fine){.dm-csw-o:is(:hover,.dm-hl,[aria-selected="true"]) .dm-csw-bd:is(:not([data-tone]),[data-tone="neutral"]),.dm-csw-tr:hover .dm-csw-bd:is(:not([data-tone]),[data-tone="neutral"]),.dm-mi:is(:hover,.dm-hl) .dm-bdg:is(:not([data-tone]),[data-tone="neutral"]){--dm-cb-bg:var(--dm-surface);--dm-bdg-bg:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-rail) 50%,transparent)}}
.dm-csw-new,.dm-csw-new>.dm-i{color:var(--dm-fg2)}
.dm-csw-none{padding:var(--dm-s-2);font-size:12.5px;color:var(--dm-muted)}
.dm-csw-mpop{width:248px}
.dm-csw-mpop .dm-mi>.dm-t{flex:1;min-width:0}
.dm-csw-mpop .dm-mi>.dm-t>b{display:block;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.dm-csw-live{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
/* short of room, the organisation's tile goes first, so its name keeps more of its length */
@container csw (max-width:560px){.dm-csw-tr>.dm-csw-lg{display:none}}
/* phone narrow: the last part, and a … menu for the parts before it */
@container csw (max-width:440px){
.dm-csw-path>.dm-csw-seg:not(:last-child),.dm-csw-path>.dm-csw-sep:not(.dm-m){display:none}
.dm-csw-path>.dm-csw-mi,.dm-csw-path>.dm-csw-sep.dm-m{display:flex}
.dm-csw-nm{max-width:none}
}
:root:not([data-style="soft"]) .dm-csw-bd:not(:where([data-style="soft"],[data-style="soft"] *)){box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-cb-fg) 14%,transparent);font-size:10.5px;letter-spacing:.03em;text-transform:uppercase}
:root:not([data-style="soft"]) .dm-csw-bd[data-tone="accent"]:not(:where([data-style="soft"],[data-style="soft"] *)){box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-accent-mark) 50%,transparent)}
context-switcher.htmlHTML · 24 lines
<div class="card wide" data-c="context-switcher">
<div class="stage-in">
<div class="csw">
<nav class="csw-bar panel" aria-label="Context">
<ol class="csw-path">
<li class="csw-mi"><button class="csw-more" aria-haspopup="menu" aria-expanded="false" aria-label="Organisation and project" data-tip="Organisation and project"><svg class="i s"><use href="#i-more"/></svg></button></li>
<li class="csw-sep m" aria-hidden="true"><svg class="i s"><use href="#i-crumb"/></svg></li>
<li class="csw-seg" data-k="org"><button class="csw-tr" aria-haspopup="listbox" aria-expanded="false"><span class="csw-lg"></span><span class="csw-nm"></span><span class="csw-bh"></span><svg class="i xs"><use href="#i-unfold"/></svg></button></li>
<li class="csw-sep" aria-hidden="true"><svg class="i s"><use href="#i-crumb"/></svg></li>
<li class="csw-seg" data-k="project"><button class="csw-tr" aria-haspopup="listbox" aria-expanded="false"><span class="csw-nm"></span><span class="csw-bh"></span><svg class="i xs"><use href="#i-unfold"/></svg></button></li>
<li class="csw-sep" aria-hidden="true"><svg class="i s"><use href="#i-crumb"/></svg></li>
<li class="csw-seg" data-k="env"><button class="csw-tr" aria-haspopup="listbox" aria-expanded="false"><span class="csw-nm"></span><span class="csw-bh"></span><svg class="i xs"><use href="#i-unfold"/></svg></button></li>
</ol>
</nav>
<div class="pop csw-pop">
<div class="csw-sf"><svg class="i xs"><use href="#i-search"/></svg><input class="csw-q" role="combobox" aria-autocomplete="list" aria-expanded="false" spellcheck="false" autocomplete="off"></div>
<div class="csw-list" role="listbox"></div>
</div>
<div class="pop csw-mpop" role="menu" aria-label="Organisation and project"></div>
<p class="csw-live" aria-live="polite"></p>
</div>
</div>
</div>
context-switcher.cssHTML · 76 lines
/* ---------- Context switcher ---------- */
/* where you are in an app, as a breadcrumb: organisation, project and environment. Each part opens its own switcher, with a
search, the list (the current one ticked, each with its badge) and Create new at the bottom. When the bar runs short the
names shorten with an ellipsis; when it is phone narrow only the last part shows, and a … menu holds the others */
.csw{position:relative;width:100%;max-width:640px;margin:0 auto;container:csw/inline-size}
.csw-bar{display:flex;align-items:center;width:fit-content;max-width:100%;margin:0 auto;min-height:52px;padding:var(--s-2);border-radius:var(--r-card)}
.csw-path{flex:1;min-width:0;display:flex;align-items:center;gap:var(--s-1);margin:0;padding:0;list-style:none}
.csw-path>li{display:flex;align-items:center;min-width:0}
/* short of room, each part takes an even share of the bar and never more than it needs, so a short name keeps its length
and the long ones shorten alike; the last part, where you are now, gets a larger share */
.csw-seg{flex:1 1 0;max-width:max-content}.csw-seg:last-child{flex-grow:1.5}
.csw-sep{flex:none;color:var(--muted)}
.csw-path>.csw-mi,.csw-path>.csw-sep.m{display:none}
.csw-tr{display:flex;align-items:center;gap:var(--s-2);min-width:0;max-width:100%;height:36px;padding:0 var(--s-2);border-radius:var(--r-sm);font-size:13.5px;font-weight:500;color:var(--fg);white-space:nowrap;transition:background var(--t-fast)}
@media (hover:hover) and (pointer:fine){.csw-tr:hover{background:var(--row)}}
.csw-tr>.i{color:var(--muted);flex:none}
.csw-nm{min-width:0;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.csw-bh{display:flex;flex:none}
.csw-bh:empty{display:none}
/* an organisation's tile: its initial on its tint, the letter leaning toward the text colour so it reads in dark mode too */
.csw-lg{--tb:var(--chip);--tc:var(--fg2);width:22px;height:22px;border-radius:var(--r-xs);display:grid;place-items:center;flex:none;background:var(--tb);color:color-mix(in srgb,var(--tc) 80%,var(--fg));font-size:11px;font-weight:500}
.csw-lg[data-t="pb"]{--tb:var(--pb-bg);--tc:var(--pb)}.csw-lg[data-t="pl"]{--tb:var(--pl-bg);--tc:var(--pl)}.csw-lg[data-t="pm"]{--tb:var(--pm-bg);--tc:var(--pm)}.csw-lg[data-t="pp"]{--tb:var(--pp-bg);--tc:var(--pp)}
.csw-lg[data-t="pr"]{--tb:var(--pr-bg);--tc:var(--pr)}.csw-lg[data-t="py"]{--tb:var(--py-bg);--tc:var(--py)}.csw-lg[data-t="pk"]{--tb:var(--pk-bg);--tc:var(--pk)}.csw-lg[data-t="pt"]{--tb:var(--pt-bg);--tc:var(--pt)}
/* the badges are Badge's small size, in the same tones */
.csw-bd{--cb-bg:var(--chip);--cb-fg:var(--fg2);flex:none;display:inline-flex;align-items:center;height:20px;padding:0 var(--s-2);border-radius:var(--r-pill);background:var(--cb-bg);color:var(--cb-fg);font-size:11.5px;font-weight:500;line-height:1;white-space:nowrap}
.csw-bd[data-tone="accent"]{--cb-bg:var(--accent-bg);--cb-fg:var(--accent-fg)}
.csw-bd[data-tone="success"]{--cb-bg:var(--ok-bg);--cb-fg:var(--ok)}.csw-bd[data-tone="warning"]{--cb-bg:var(--warn-bg);--cb-fg:var(--warn)}.csw-bd[data-tone="danger"]{--cb-bg:var(--bad-bg);--cb-fg:var(--bad)}
.csw-bd[data-tone="blue"]{--cb-bg:var(--pb-bg);--cb-fg:var(--pb)}.csw-bd[data-tone="violet"]{--cb-bg:var(--pl-bg);--cb-fg:var(--pl)}.csw-bd[data-tone="green"]{--cb-bg:var(--pm-bg);--cb-fg:var(--pm)}.csw-bd[data-tone="orange"]{--cb-bg:var(--pp-bg);--cb-fg:var(--pp)}
.csw-bd[data-tone="red"]{--cb-bg:var(--pr-bg);--cb-fg:var(--pr)}.csw-bd[data-tone="yellow"]{--cb-bg:var(--py-bg);--cb-fg:var(--py)}.csw-bd[data-tone="pink"]{--cb-bg:var(--pk-bg);--cb-fg:var(--pk)}.csw-bd[data-tone="teal"]{--cb-bg:var(--pt-bg);--cb-fg:var(--pt)}
.csw-more{display:grid;place-items:center;width:32px;height:32px;border-radius:var(--r-sm);color:var(--fg2);flex:none;transition:background var(--t-fast)}
@media (hover:hover) and (pointer:fine){.csw-more:hover{background:var(--row)}}
/* the switcher: under the part it belongs to, never past the bar's ends */
.csw-pop{width:288px;max-width:100%}
/* a switcher or the … menu fades in a beat after the bar starts gliding up to make room for it, so it never shows past the
bottom of the space it was given */
.glide>.csw-pop.open,.glide>.csw-mpop.open{transition-delay:.1s}
.csw-sf{position:relative;display:flex;align-items:center;margin-bottom:var(--s-1)}
.csw-sf>.i{position:absolute;left:10px;color:var(--muted);pointer-events:none}
.csw-q{width:100%;height:32px;border:0;outline:0;border-radius:var(--r-field);background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line) 50%,transparent);transition:box-shadow var(--t-fast);padding:0 var(--s-2) 0 28px;font:inherit;font-size:13px;color:var(--fg)}
.csw-q::placeholder{color:var(--muted)}
.csw-q:focus{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line-strong) 50%,transparent)}
@supports (corner-shape:squircle){.csw-q{corner-shape:squircle;border-radius:var(--r-field-sq)}}
.csw-list{max-height:248px;overflow-y:auto;display:flex;flex-direction:column;gap:2px}
.csw-o{flex:none;min-height:34px;cursor:pointer}
/* the row under the pointer or the arrow keys, the current one, and a hovered row in the … menu get a soft grey fill behind
them, with no edge and no tick */
@media (hover:hover) and (pointer:fine){.csw-pop .csw-o:is(:hover,.hl,[aria-selected="true"]),.csw-mpop .mi:hover{background:var(--row);box-shadow:none}}
.csw-o .csw-on{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.csw-o .csw-lg{width:20px;height:20px;font-size:10.5px}
.csw-o .ck{margin-left:0;flex:none}
.csw-o .ck{display:none}
.csw-o[aria-selected="true"] .csw-on{font-weight:500}
/* a hovered row's tint lifts the dark list a step, so a badge on it leans a little toward the text colour and keeps APCA 60 */
.csw-o:is(.hl,[aria-selected="true"]) .csw-bd{color:color-mix(in srgb,var(--cb-fg) 85%,var(--fg))}
@media (hover:hover) and (pointer:fine){.csw-o:hover .csw-bd,.csw-tr:hover .csw-bd{color:color-mix(in srgb,var(--cb-fg) 85%,var(--fg))}}
/* on a highlighted row a tinted chip takes a fuller tint, and a grey one turns white with a quiet edge, so neither fades into
the row behind it */
@media (hover:hover) and (pointer:fine){.csw-o:is(:hover,.hl,[aria-selected="true"]) .csw-bd,.csw-tr:hover .csw-bd,.mi:is(:hover,.hl) .bdg{--cb-bg:color-mix(in srgb,var(--cb-fg) 16%,var(--surface));--bdg-bg:color-mix(in srgb,var(--bdg-fg) 16%,var(--surface))}}
@media (hover:hover) and (pointer:fine){.csw-o:is(:hover,.hl,[aria-selected="true"]) .csw-bd:is(:not([data-tone]),[data-tone="neutral"]),.csw-tr:hover .csw-bd:is(:not([data-tone]),[data-tone="neutral"]),.mi:is(:hover,.hl) .bdg:is(:not([data-tone]),[data-tone="neutral"]){--cb-bg:var(--surface);--bdg-bg:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--rail) 50%,transparent)}}
.csw-new,.csw-new>.i{color:var(--fg2)}
.csw-none{padding:var(--s-2);font-size:12.5px;color:var(--muted)}
.csw-mpop{width:248px}
.csw-mpop .mi>.t{flex:1;min-width:0}
.csw-mpop .mi>.t>b{display:block;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.csw-live{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
/* short of room, the organisation's tile goes first, so its name keeps more of its length */
@container csw (max-width:560px){.csw-tr>.csw-lg{display:none}}
/* phone narrow: the last part, and a … menu for the parts before it */
@container csw (max-width:440px){
.csw-path>.csw-seg:not(:last-child),.csw-path>.csw-sep:not(.m){display:none}
.csw-path>.csw-mi,.csw-path>.csw-sep.m{display:flex}
.csw-nm{max-width:none}
}
:root:not([data-style="soft"]) .csw-bd:not(:where([data-style="soft"],[data-style="soft"] *)){box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--cb-fg) 14%,transparent);font-size:10.5px;letter-spacing:.03em;text-transform:uppercase}
:root:not([data-style="soft"]) .csw-bd[data-tone="accent"]:not(:where([data-style="soft"],[data-style="soft"] *)){box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--accent-mark) 50%,transparent)}
context-switcher.jsHTML · 216 lines
DM.def('context-switcher', (r, c) => {
r.innerHTML = r.innerHTML.replace(/\{unfold\}/g, I('unfold','xs')).replace(/\{crumb\}/g, I('crumb')).replace('{more}', I('more')).replace('{search}', I('search','xs'));
// the organisations this account is in, with their plans; each one's projects, and each project's environments. Each level
// remembers what was picked in it (on), so an organisation brings back the project you were last in, and a project its
// environment
const KIND = { Production:'warning', Staging:'blue', Development:'neutral' };
const envs = (...e) => e.map(([id, k]) => ({ id, name:id, b:[k, KIND[k]] }));
const STD = () => envs(['live', 'Production'], ['staging', 'Staging'], ['sandbox', 'Development']);
const ROOT = { on:'fernhill', items:[
{ id:'fernhill', name:'Fernhill Labs', t:'pb', b:['Pro', 'accent'], on:'triage', items:[
{ id:'triage', name:'Support triage agent', on:'eu-live', items:envs(['eu-live', 'Production'], ['us-live', 'Production'], ['staging', 'Staging'], ['sandbox', 'Development']) },
{ id:'invoice', name:'Invoice reader', b:['Beta', 'teal'], on:'staging', items:STD() },
{ id:'research', name:'Sales research agent', on:'live', items:STD() },
{ id:'onboard', name:'Onboarding copilot', b:['Paused', 'neutral'], on:'sandbox', items:STD() },
{ id:'notes', name:'Release notes writer', on:'live', items:STD() }
] },
{ id:'quillwork', name:'Quillwork', t:'pp', b:['Free', 'neutral'], on:'coach', items:[
{ id:'coach', name:'Essay coach', on:'live', items:STD() },
{ id:'cite', name:'Citation checker', b:['Beta', 'teal'], on:'staging', items:STD() }
] },
{ id:'otterline', name:'Otterline Health', t:'pm', b:['Enterprise', 'violet'], on:'intake', items:[
{ id:'intake', name:'Intake assistant', on:'live', items:STD() },
{ id:'claims', name:'Claims summariser', on:'live', items:STD() },
{ id:'handover', name:'Shift handover notes', b:['Beta', 'teal'], on:'sandbox', items:STD() }
] }
] };
// the parts of the path, outermost first
const KEYS = ['org', 'project', 'env'];
const SEG = {
org:{ label:'Organisation', noun:'organisation', many:'organisations', find:'Find an organisation', head:() => 'Your organisations' },
project:{ label:'Project', noun:'project', many:'projects', find:'Find a project', head:() => `Projects in ${node(0).name}` },
env:{ label:'Environment', noun:'environment', many:'environments', find:'Find an environment', head:() => `Environments of ${node(1).name}` }
};
// the level a part picks from, and what is picked there
const parent = d => { let n = ROOT; for (let i = 0; i < d; i++) n = n.items.find(x => x.id === n.on); return n; };
const node = d => { const p = parent(d); return p.items.find(x => x.id === p.on); };
const box = $('.csw', r), bar = $('.csw-bar', r), pop = $('.csw-pop', r), q = $('.csw-q', r), list = $('.csw-list', r), mpop = $('.csw-mpop', r), more = $('.csw-more', r), live = $('.csw-live', r);
const uid = Math.random().toString(36).slice(2, 7);
list.id = 'csw-l-' + uid; q.setAttribute('aria-controls', list.id);
const trig = k => $(`.csw-seg[data-k="${k}"] .csw-tr`, r);
KEYS.forEach(k => trig(k).setAttribute('aria-controls', list.id));
const esc = t => String(t).replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"');
const badge = x => x.b ? `<span class="csw-bd"${x.b[1] && x.b[1] !== 'neutral' ? ` data-tone="${x.b[1]}"` : ''}>${esc(x.b[0])}</span>` : '';
const tile = x => `<span class="csw-lg" data-t="${x.t}" aria-hidden="true">${esc(x.name[0])}</span>`;
const named = (x, d) => `${SEG[KEYS[d]].label}: ${x.name}${x.b ? ', ' + x.b[0] : ''}`;
/* ---- the bar: each part's name and badge, crossfading when they change ---- */
function paint(first){
KEYS.forEach((k, d) => {
const x = node(d), tr = trig(k), nm = $('.csw-nm', tr), bh = $('.csw-bh', tr), lg = $('.csw-lg', tr);
if (first){ nm.textContent = x.name; bh.innerHTML = badge(x); } else { swap(nm, x.name); swap(bh, badge(x), true); }
if (lg){ lg.textContent = x.name[0]; lg.dataset.t = x.t; lg.setAttribute('aria-hidden', 'true'); }
tr.setAttribute('aria-label', named(x, d));
});
c.later(REDUCE ? 0 : 220, tips);
}
// a name the ellipsis cuts short shows in full in a tooltip
function tips(){ KEYS.forEach((k, d) => { const tr = trig(k), nm = $('.csw-nm', tr); if (nm.scrollWidth > nm.clientWidth + 1) tr.dataset.tip = node(d).name; else delete tr.dataset.tip; }); }
// phone narrow (the CSS decides), only the last part shows and the … button stands in for the others
const narrow = () => getComputedStyle($('.csw-mi', r)).display !== 'none';
const anchor = k => !narrow() || k === KEYS[KEYS.length - 1] ? trig(k) : more;
// under what opened it, inside the component's ends
function place(el, at){
const b = box.getBoundingClientRect(), a = at.getBoundingClientRect(), br = bar.getBoundingClientRect();
el.style.left = Math.max(0, Math.min(a.left - b.left, b.width - el.offsetWidth)) + 'px';
el.style.top = Math.round(br.bottom - b.top + 6) + 'px';
}
/* ---- a part's switcher: the search, the list, and Create new at the bottom ---- */
let open = null, full = 0, hi = 0, opts = [], mopen = false, made = 0;
function fill(reset){
const S = SEG[open], d = KEYS.indexOf(open), p = parent(d), v = q.value.trim(), lv = v.toLowerCase();
const m = p.items.filter(x => !lv || x.name.toLowerCase().includes(lv));
list.innerHTML = `<div class="ph" role="presentation">${esc(S.head())}</div>`
+ (m.length ? m.map(x => `<div class="mi csw-o" role="option" id="${list.id}-${esc(x.id)}" aria-selected="${x.id === p.on}" data-id="${esc(x.id)}">${d === 0 ? tile(x) : ''}<span class="csw-on">${esc(x.name)}</span>${badge(x)}<span class="ck">${I('check','xs')}</span></div>`).join('')
: `<div class="csw-none" role="presentation">No ${S.many} match “${esc(v)}”</div>`)
+ `<div class="sep" role="presentation"></div><div class="mi csw-o csw-new" role="option" id="${list.id}-new" aria-selected="false" data-new="1"${v ? '' : ` aria-label="Create new ${S.noun}"`}>${I('plus','xs')}<span class="csw-on">${v ? `Create “${esc(v)}”` : 'Create new…'}</span></div>`;
opts = $$('.csw-o', list);
// the highlight starts on the current one; once you type, on the first match
hi = reset ? Math.max(0, opts.findIndex(o => o.getAttribute('aria-selected') === 'true')) : 0;
opts.forEach((o, i) => { o.onmousemove = () => { if (hi !== i){ hi = i; move(0); } }; o.onmousedown = e => e.preventDefault(); o.onclick = () => pick(o); });
move(0);
}
function move(by){
if (!opts.length) return;
hi = (hi + by + opts.length) % opts.length;
opts.forEach((o, i) => o.classList.toggle('hl', i === hi));
q.setAttribute('aria-activedescendant', opts[hi].id);
// the list scrolls to keep it in view, never the page
if (!hi){ list.scrollTop = 0; return; }
const lr = list.getBoundingClientRect(), or = opts[hi].getBoundingClientRect();
if (or.top < lr.top) list.scrollTop -= lr.top - or.top; else if (or.bottom > lr.bottom) list.scrollTop += or.bottom - lr.bottom;
}
function show(k, { focus = false } = {}){
if (mopen) menu(false);
if (open === k){ if (focus) q.focus({ preventScroll:true }); return; }
if (open) hide(false, true);
open = k; const S = SEG[k];
q.value = ''; q.placeholder = S.find; q.setAttribute('aria-label', S.find); list.setAttribute('aria-label', S.many[0].toUpperCase() + S.many.slice(1));
trig(k).setAttribute('aria-expanded', 'true'); q.setAttribute('aria-expanded', 'true');
fill(true); hideTip(); place(pop, anchor(k)); pop.inert = false; pop.classList.add('open');
// the bar makes room for the switcher at its fullest, before any filter, so it holds still while typing filters the list
full = pop.offsetHeight; centred();
if (focus) q.focus({ preventScroll:true });
}
// closing gives the focus back to the part's button (or the … button, on a phone) when it was in the switcher
function hide(back, next){
if (!open) return; const k = open; open = null;
pop.classList.remove('open'); pop.inert = true; trig(k).setAttribute('aria-expanded', 'false'); q.setAttribute('aria-expanded', 'false'); q.removeAttribute('aria-activedescendant');
if (back) anchor(k).focus({ preventScroll:true });
// the bar waits for the list to fade before it glides back to the middle
if (!next) c.later(REDUCE ? 0 : 180, centred);
// once it has faded it goes back to the component's corner, so a narrower screen later never scrolls sideways to it
c.later(320, () => { if (!open){ pop.style.left = '0px'; pop.style.top = '0px'; } });
}
// what changed, said once for screen readers: the part picked and the parts that came with it
const say = d => { live.textContent = 'Switched to ' + KEYS.slice(d).map((k, i) => { const x = node(d + i); return `${i ? SEG[k].noun + ' ' : ''}${x.name}${x.b ? ` (${x.b[0]})` : ''}`; }).join(', '); };
// every change also goes out as a context-change event from the component, with the part changed and the whole path
const tell = k => box.dispatchEvent(new CustomEvent('context-change', { bubbles:true, detail:{ segment:k, path:Object.fromEntries(KEYS.map((kk, d) => [kk, node(d).id])) } }));
function pick(o){
if (!o || !open) return;
const k = open, d = KEYS.indexOf(k), p = parent(d), back = pop.contains(document.activeElement);
if (o.dataset.new) return create(k, q.value.trim(), back);
const was = p.on; p.on = o.dataset.id; hide(back);
if (p.on !== was){ paint(); say(d); tell(k); }
}
// Create new first sends a cancelable context-create event from the component, with the part and what was typed, so your
// code can open its own form; left alone, it adds one by that name (an organisation with a first project) and picks it
function create(k, name, back){
const S = SEG[k], d = KEYS.indexOf(k), p = parent(d);
const go = box.dispatchEvent(new CustomEvent('context-create', { bubbles:true, cancelable:true, detail:{ segment:k, name } }));
hide(back); if (!go) return;
const id = 'new-' + (++made), x = { id, name:name || `New ${S.noun}` };
if (k === 'org') Object.assign(x, { t:'pl', b:['Free', 'neutral'], on:id + '-p', items:[{ id:id + '-p', name:'First project', on:'sandbox', items:STD() }] });
else if (k === 'project') Object.assign(x, { on:'sandbox', items:STD() });
else x.b = ['Development', KIND.Development];
p.items.push(x); p.on = id; paint(); tell(k);
live.textContent = `Created ${S.noun} ${x.name}`;
}
/* ---- the … menu, on a phone: the parts before the last, each opening its own switcher ---- */
function menu(on, focus){
if (on === mopen) return; mopen = on;
if (on){
if (open) hide(false, true);
mpop.innerHTML = KEYS.slice(0, -1).map((k, d) => { const x = node(d); return `<button class="mi" role="menuitem" tabindex="-1" data-k="${k}">${d ? I('layers') : tile(x)}<span class="t"><b>${esc(x.name)}</b><span class="d">${SEG[k].label}</span></span>${badge(x)}</button>`; }).join('');
$$('.mi', mpop).forEach(m => m.onclick = e => { menu(false); show(m.dataset.k, { focus:e.isTrusted }); });
hideTip(); place(mpop, more); mpop.inert = false; mpop.classList.add('open'); more.setAttribute('aria-expanded', 'true'); centred();
if (focus){ const it = $$('.mi', mpop); (focus === 'last' ? it[it.length - 1] : it[0]).focus({ preventScroll:true }); }
} else {
const back = mpop.contains(document.activeElement);
mpop.classList.remove('open'); mpop.inert = true; more.setAttribute('aria-expanded', 'false');
if (back) more.focus({ preventScroll:true });
c.later(REDUCE ? 0 : 180, centred);
c.later(320, () => { if (!mopen){ mpop.style.left = '0px'; mpop.style.top = '0px'; } });
}
}
/* ---- the keyboard ---- */
// on the bar, the left and right arrows move between the parts you can see
const parts = () => [more, ...KEYS.map(trig)].filter(b => b.getClientRects().length);
const sideways = e => { if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return false; const all = parts(), to = all[all.indexOf(e.currentTarget) + (e.key === 'ArrowLeft' ? -1 : 1)]; if (to){ e.preventDefault(); to.focus({ preventScroll:true }); } return true; };
// a click, Enter or Space opens a part's switcher with the focus in its search; so do the down arrow (on the current one)
// and the up arrow (on the last row). A story's tap opens it without moving the focus
KEYS.forEach(k => {
const tr = trig(k);
tr.onclick = e => { if (open === k) hide(pop.contains(document.activeElement)); else show(k, { focus:e.isTrusted }); };
tr.onkeydown = e => {
if (sideways(e)) return;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp'){ e.preventDefault(); show(k, { focus:true }); if (e.key === 'ArrowUp'){ hi = opts.length - 1; move(0); } }
};
});
more.onclick = e => menu(!mopen, e.isTrusted && 'first');
more.onkeydown = e => { if (sideways(e)) return; if (e.key === 'ArrowDown' || e.key === 'ArrowUp'){ e.preventDefault(); menu(true, e.key === 'ArrowUp' ? 'last' : 'first'); } };
// in the search: the arrows move through the list and wrap, Home and End go to its ends, typing filters it, Enter picks,
// Escape closes it and gives the focus back to the part. With nothing typed, the left and right arrows move to the part
// before or after and open its switcher
q.oninput = () => fill(false);
q.onkeydown = e => {
const k = e.key;
if (k === 'ArrowDown' || k === 'ArrowUp'){ e.preventDefault(); move(k === 'ArrowDown' ? 1 : -1); }
else if (k === 'Home' || k === 'End'){ e.preventDefault(); hi = k === 'Home' ? 0 : opts.length - 1; move(0); }
else if (k === 'Enter'){ e.preventDefault(); pick(opts[hi]); }
else if (k === 'Escape'){ e.preventDefault(); e.stopPropagation(); hide(true); }
else if ((k === 'ArrowLeft' || k === 'ArrowRight') && !q.value){ const n = KEYS[KEYS.indexOf(open) + (k === 'ArrowLeft' ? -1 : 1)]; if (n){ e.preventDefault(); show(n, { focus:true }); } }
};
// in the … menu: the arrows move and wrap, Home and End jump to the ends, Escape closes it and Tab leaves it
c.on(mpop, 'keydown', e => {
const all = $$('.mi', mpop), i = all.indexOf(document.activeElement);
const go = { ArrowDown:all[(i + 1) % all.length], ArrowUp:all[(i - 1 + all.length) % all.length], Home:all[0], End:all[all.length - 1] }[e.key];
if (go){ e.preventDefault(); go.focus({ preventScroll:true }); }
else if (e.key === 'Escape'){ e.preventDefault(); e.stopPropagation(); menu(false); }
else if (e.key === 'Tab') setTimeout(() => menu(false));
});
// Tab out of the switcher closes it; so does a press anywhere else, or a real resize
c.on(pop, 'focusout', e => { if (open && e.relatedTarget && !pop.contains(e.relatedTarget) && e.relatedTarget !== trig(open)) hide(false); });
c.on(document, 'pointerdown', e => { const t = e.target; if (pop.contains(t) || mpop.contains(t) || (box.contains(t) && t.closest('.csw-tr,.csw-more'))) return; hide(false); menu(false); });
c.on(window, 'resize', e => { if (e.isTrusted){ hide(false); menu(false); } });
const ro = new ResizeObserver(tips); ro.observe(bar); c.offs.push(() => ro.disconnect());
// the bar sits centred on its own, and glides up as a switcher opens under it, so the two sit centred together
const centred = centre(c, box, () => open ? Math.max(box.offsetHeight, pop.offsetTop + full) : mopen ? Math.max(box.offsetHeight, mpop.offsetTop + mpop.offsetHeight) : box.offsetHeight);
pop.inert = true; mpop.inert = true; paint(true);
if (document.fonts) document.fonts.ready.then(() => { if (!c.dead) tips(); });
// the preview's story: open the project's switcher (through the … menu on a phone), search for "inv" and pick Invoice
// reader; the environment follows it, from eu-live in Production to staging
c.story(async s => {
await s.beat(3);
if (narrow()){ await s.tap(more); await s.beat(2); await s.tap($('.mi[data-k="project"]', mpop)); }
else await s.tap(trig('project'));
s.away(); await s.beat(2);
await s.type(q, 'inv', { n:.4, each:() => fill(false) }); await s.beat(2);
await s.tap(opts[hi]); s.away(); await s.beat(5);
});
});
Questions
What people ask about it.
- Does it load projects or switch my app?
- No. It keeps the path and tells you when it changes. Load the new project's data in
onChange(React) or oncontext-change(HTML). - Can I have two parts, or four?
- In React,
segmentstakes any number of parts, outermost first, each with its own list, heading and noun. The HTML version is written for the three shown. - How does the project list follow the organisation?
- In React, a segment's
itemscan be a function of the parts before it, likev => projectsOf(v.org). In HTML, each organisation holds its own projects. - What does Create new do?
- In React it calls
onCreate(key, query), and the row only shows when you pass it: add the item to your data and pick it invalue. In HTML, cancel thecontext-createevent to open your own form instead of adding it on the spot.
More in Collaboration
Threads, inboxes and boards for teams working alongside an agent.