@twentyfirstmedia/ui-core 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ICONS-LICENSE +175 -0
- package/dist/components/admin.d.ts.map +1 -1
- package/dist/components/admin.js +31 -21
- package/dist/components/admin.js.map +1 -1
- package/dist/components/agents.d.ts.map +1 -1
- package/dist/components/agents.js +21 -18
- package/dist/components/agents.js.map +1 -1
- package/dist/components/app-chrome.d.ts.map +1 -1
- package/dist/components/app-chrome.js +5 -5
- package/dist/components/app-chrome.js.map +1 -1
- package/dist/components/approvals.d.ts.map +1 -1
- package/dist/components/approvals.js +2 -2
- package/dist/components/approvals.js.map +1 -1
- package/dist/components/building-blocks.d.ts.map +1 -1
- package/dist/components/building-blocks.js +13 -10
- package/dist/components/building-blocks.js.map +1 -1
- package/dist/components/charts.d.ts +2 -2
- package/dist/components/charts.d.ts.map +1 -1
- package/dist/components/charts.js +14 -11
- package/dist/components/charts.js.map +1 -1
- package/dist/components/chat.d.ts +2 -2
- package/dist/components/chat.d.ts.map +1 -1
- package/dist/components/chat.js +22 -18
- package/dist/components/chat.js.map +1 -1
- package/dist/components/commerce.d.ts.map +1 -1
- package/dist/components/commerce.js +20 -16
- package/dist/components/commerce.js.map +1 -1
- package/dist/components/data-display.d.ts +0 -5
- package/dist/components/data-display.d.ts.map +1 -1
- package/dist/components/data-display.js +41 -22
- package/dist/components/data-display.js.map +1 -1
- package/dist/components/data-table.d.ts +2 -2
- package/dist/components/data-table.d.ts.map +1 -1
- package/dist/components/data-table.js +3 -3
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/developer.d.ts.map +1 -1
- package/dist/components/developer.js +7 -6
- package/dist/components/developer.js.map +1 -1
- package/dist/components/feedback.d.ts.map +1 -1
- package/dist/components/feedback.js +5 -4
- package/dist/components/feedback.js.map +1 -1
- package/dist/components/forms.d.ts.map +1 -1
- package/dist/components/forms.js +63 -21
- package/dist/components/forms.js.map +1 -1
- package/dist/components/foundation.d.ts.map +1 -1
- package/dist/components/foundation.js +13 -9
- package/dist/components/foundation.js.map +1 -1
- package/dist/components/index.d.ts +2 -1
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +2 -1
- package/dist/components/index.js.map +1 -1
- package/dist/components/inputs.d.ts.map +1 -1
- package/dist/components/inputs.js +33 -8
- package/dist/components/inputs.js.map +1 -1
- package/dist/components/mail.d.ts.map +1 -1
- package/dist/components/mail.js +5 -5
- package/dist/components/mail.js.map +1 -1
- package/dist/components/media.d.ts.map +1 -1
- package/dist/components/media.js +62 -11
- package/dist/components/media.js.map +1 -1
- package/dist/components/navigation.d.ts +3 -1
- package/dist/components/navigation.d.ts.map +1 -1
- package/dist/components/navigation.js +287 -20
- package/dist/components/navigation.js.map +1 -1
- package/dist/components/operations.d.ts.map +1 -1
- package/dist/components/operations.js +10 -10
- package/dist/components/operations.js.map +1 -1
- package/dist/components/overlays.d.ts.map +1 -1
- package/dist/components/overlays.js +2 -2
- package/dist/components/overlays.js.map +1 -1
- package/dist/components/previews.js +2 -2
- package/dist/components/previews.js.map +1 -1
- package/dist/components/primitives.js +2 -2
- package/dist/components/primitives.js.map +1 -1
- package/dist/components/qr-code.d.ts +8 -0
- package/dist/components/qr-code.d.ts.map +1 -0
- package/dist/components/qr-code.js +18 -0
- package/dist/components/qr-code.js.map +1 -0
- package/dist/components/real-estate.d.ts.map +1 -1
- package/dist/components/real-estate.js +5 -5
- package/dist/components/real-estate.js.map +1 -1
- package/dist/components/rich-text-editor.d.ts.map +1 -1
- package/dist/components/rich-text-editor.js +1 -1
- package/dist/components/rich-text-editor.js.map +1 -1
- package/dist/components/select-menu.js +1 -1
- package/dist/components/select-menu.js.map +1 -1
- package/dist/components/shell.d.ts +0 -4
- package/dist/components/shell.d.ts.map +1 -1
- package/dist/components/shell.js +100 -7
- package/dist/components/shell.js.map +1 -1
- package/dist/components/site.d.ts.map +1 -1
- package/dist/components/site.js +8 -6
- package/dist/components/site.js.map +1 -1
- package/dist/components/status.d.ts.map +1 -1
- package/dist/components/status.js +2 -2
- package/dist/components/status.js.map +1 -1
- package/dist/components/system.d.ts.map +1 -1
- package/dist/components/system.js +3 -2
- package/dist/components/system.js.map +1 -1
- package/dist/components/types.d.ts +257 -18
- package/dist/components/types.d.ts.map +1 -1
- package/dist/components/workspace.d.ts.map +1 -1
- package/dist/components/workspace.js +15 -12
- package/dist/components/workspace.js.map +1 -1
- package/dist/icon-library.d.ts +254 -3
- package/dist/icon-library.d.ts.map +1 -1
- package/dist/icon-library.js +518 -14
- package/dist/icon-library.js.map +1 -1
- package/dist/qr.d.ts +14 -0
- package/dist/qr.d.ts.map +1 -0
- package/dist/qr.js +298 -0
- package/dist/qr.js.map +1 -0
- package/dist/strings.d.ts +30 -4
- package/dist/strings.d.ts.map +1 -1
- package/dist/strings.js +15 -0
- package/dist/strings.js.map +1 -1
- package/dist/themes.css +2345 -176
- package/package.json +2 -1
- package/src/components/admin.tsx +66 -23
- package/src/components/agents.tsx +22 -20
- package/src/components/app-chrome.tsx +9 -5
- package/src/components/approvals.tsx +3 -1
- package/src/components/building-blocks.tsx +24 -13
- package/src/components/charts.tsx +17 -12
- package/src/components/chat.tsx +54 -31
- package/src/components/commerce.tsx +58 -24
- package/src/components/data-display.tsx +106 -38
- package/src/components/data-table.tsx +11 -7
- package/src/components/developer.tsx +15 -6
- package/src/components/feedback.tsx +5 -3
- package/src/components/forms.tsx +101 -23
- package/src/components/foundation.tsx +9 -5
- package/src/components/index.ts +10 -1
- package/src/components/inputs.tsx +44 -7
- package/src/components/mail.tsx +7 -3
- package/src/components/media.tsx +88 -16
- package/src/components/navigation.tsx +651 -71
- package/src/components/operations.tsx +17 -8
- package/src/components/overlays.tsx +7 -2
- package/src/components/previews.tsx +2 -2
- package/src/components/primitives.tsx +2 -2
- package/src/components/qr-code.tsx +30 -0
- package/src/components/real-estate.tsx +7 -4
- package/src/components/rich-text-editor.tsx +7 -1
- package/src/components/select-menu.tsx +1 -1
- package/src/components/shell.tsx +237 -11
- package/src/components/site.tsx +18 -4
- package/src/components/status.tsx +2 -0
- package/src/components/system.tsx +3 -2
- package/src/components/types.ts +261 -18
- package/src/components/workspace.tsx +37 -15
- package/src/icon-library.ts +523 -18
- package/src/qr.ts +300 -0
- package/src/strings.ts +56 -1
|
@@ -4,9 +4,11 @@ import { useStrings } from "../context.js";
|
|
|
4
4
|
import { NAVIGATION_STRINGS } from "../strings.js";
|
|
5
5
|
import { cx } from "../cx.js";
|
|
6
6
|
import { Icon } from "../Icon.js";
|
|
7
|
-
import { moveFocus, useControlled, useId, useOutside } from "../hooks.js";
|
|
8
|
-
import {
|
|
7
|
+
import { FOCUSABLE, moveFocus, useControlled, useId, useOutside } from "../hooks.js";
|
|
8
|
+
import { Portal } from "../portal.js";
|
|
9
|
+
import { Avatar, IconButton } from "./foundation.js";
|
|
9
10
|
import { Menu } from "./feedback.js";
|
|
11
|
+
import { Popover } from "./overlays.js";
|
|
10
12
|
import type {
|
|
11
13
|
BreadcrumbProps,
|
|
12
14
|
MenubarProps,
|
|
@@ -15,6 +17,7 @@ import type {
|
|
|
15
17
|
SidebarItem,
|
|
16
18
|
SidebarProps,
|
|
17
19
|
TabsProps,
|
|
20
|
+
UserMenuProps,
|
|
18
21
|
} from "./types.js";
|
|
19
22
|
|
|
20
23
|
/* ── Navigation ── */
|
|
@@ -107,14 +110,34 @@ export function Tabs(p: TabsProps) {
|
|
|
107
110
|
|
|
108
111
|
export function Breadcrumb(p: BreadcrumbProps) {
|
|
109
112
|
const t = useStrings(NAVIGATION_STRINGS);
|
|
110
|
-
const items = p.items || [];
|
|
113
|
+
const items = (p.items || []).map((it) => (typeof it === "string" ? { label: it } : it));
|
|
114
|
+
const Link = (p.linkAs || "a") as React.ElementType;
|
|
111
115
|
return (
|
|
112
|
-
<nav
|
|
116
|
+
<nav
|
|
117
|
+
style={p.style}
|
|
118
|
+
className={cx("tf-crumbs", p.className)}
|
|
119
|
+
aria-label={p.label || t.breadcrumb}
|
|
120
|
+
>
|
|
113
121
|
{items.map((it, i) => {
|
|
114
122
|
const last = i === items.length - 1;
|
|
123
|
+
const click = it.onClick || (p.onNavigate ? () => p.onNavigate?.(i) : undefined);
|
|
115
124
|
return (
|
|
116
125
|
<React.Fragment key={i}>
|
|
117
|
-
{last ?
|
|
126
|
+
{last ? (
|
|
127
|
+
<span className="tf-crumb" aria-current="page">
|
|
128
|
+
{it.label}
|
|
129
|
+
</span>
|
|
130
|
+
) : it.href ? (
|
|
131
|
+
<Link className="tf-crumb" href={it.href} onClick={it.onClick}>
|
|
132
|
+
{it.label}
|
|
133
|
+
</Link>
|
|
134
|
+
) : click ? (
|
|
135
|
+
<button type="button" className="tf-crumb" onClick={click}>
|
|
136
|
+
{it.label}
|
|
137
|
+
</button>
|
|
138
|
+
) : (
|
|
139
|
+
<span className="tf-crumb">{it.label}</span>
|
|
140
|
+
)}
|
|
118
141
|
{!last && <Icon name="chevronRight" size={13} />}
|
|
119
142
|
</React.Fragment>
|
|
120
143
|
);
|
|
@@ -139,21 +162,40 @@ export function Pagination(p: PaginationProps) {
|
|
|
139
162
|
if (i === 1 || i === total || Math.abs(i - cur) <= 1) pages.push(i);
|
|
140
163
|
else if (pages[pages.length - 1] !== "…") pages.push("…");
|
|
141
164
|
}
|
|
165
|
+
const Link = (p.linkAs || "a") as React.ElementType;
|
|
166
|
+
const step = (to: number, icon: "chevronLeft" | "chevronRight", label: string) => {
|
|
167
|
+
const off = to < 1 || to > total;
|
|
168
|
+
// links cannot be disabled: at the ends the arrow is a disabled button
|
|
169
|
+
return p.href && !off ? (
|
|
170
|
+
<IconButton icon={icon} label={label} size="sm" asChild>
|
|
171
|
+
<Link href={p.href(to)} onClick={() => go(to)} />
|
|
172
|
+
</IconButton>
|
|
173
|
+
) : (
|
|
174
|
+
<IconButton icon={icon} label={label} size="sm" disabled={off} onClick={() => go(to)} />
|
|
175
|
+
);
|
|
176
|
+
};
|
|
142
177
|
return (
|
|
143
|
-
<nav
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
go(cur - 1);
|
|
150
|
-
}}
|
|
151
|
-
/>
|
|
178
|
+
<nav
|
|
179
|
+
style={p.style}
|
|
180
|
+
className={cx("tf-pager", p.className)}
|
|
181
|
+
aria-label={p.label || t.pagination}
|
|
182
|
+
>
|
|
183
|
+
{step(cur - 1, "chevronLeft", t.previousPage)}
|
|
152
184
|
{pages.map((n, i) =>
|
|
153
185
|
n === "…" ? (
|
|
154
186
|
<span key={"e" + i} className="tf-page" aria-hidden>
|
|
155
187
|
…
|
|
156
188
|
</span>
|
|
189
|
+
) : p.href ? (
|
|
190
|
+
<Link
|
|
191
|
+
key={n}
|
|
192
|
+
href={p.href(n)}
|
|
193
|
+
className="tf-page"
|
|
194
|
+
aria-current={n === cur ? "page" : undefined}
|
|
195
|
+
onClick={() => go(n)}
|
|
196
|
+
>
|
|
197
|
+
{n}
|
|
198
|
+
</Link>
|
|
157
199
|
) : (
|
|
158
200
|
<button
|
|
159
201
|
key={n}
|
|
@@ -168,14 +210,7 @@ export function Pagination(p: PaginationProps) {
|
|
|
168
210
|
</button>
|
|
169
211
|
),
|
|
170
212
|
)}
|
|
171
|
-
|
|
172
|
-
icon="chevronRight"
|
|
173
|
-
label={t.nextPage}
|
|
174
|
-
size="sm"
|
|
175
|
-
onClick={() => {
|
|
176
|
-
go(cur + 1);
|
|
177
|
-
}}
|
|
178
|
-
/>
|
|
213
|
+
{step(cur + 1, "chevronRight", t.nextPage)}
|
|
179
214
|
</nav>
|
|
180
215
|
);
|
|
181
216
|
}
|
|
@@ -358,16 +393,288 @@ export function NavigationMenu(p: NavigationMenuProps) {
|
|
|
358
393
|
}
|
|
359
394
|
|
|
360
395
|
/* ── Sidebar: app navigation with groups, nested pages and a collapsed (icons) mode ── */
|
|
396
|
+
/** Whether the screen is phone-sized (the sidebar becomes a drawer there). */
|
|
397
|
+
function usePhone(): boolean {
|
|
398
|
+
const [phone, setPhone] = React.useState(false);
|
|
399
|
+
React.useEffect(() => {
|
|
400
|
+
if (typeof window === "undefined" || !window.matchMedia) return;
|
|
401
|
+
const mq = window.matchMedia("(max-width: 760px)");
|
|
402
|
+
const on = () => setPhone(mq.matches);
|
|
403
|
+
on();
|
|
404
|
+
mq.addEventListener("change", on);
|
|
405
|
+
return () => mq.removeEventListener("change", on);
|
|
406
|
+
}, []);
|
|
407
|
+
return phone;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const typing = (t: EventTarget | null) =>
|
|
411
|
+
t instanceof HTMLElement && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName));
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* A layer next to an element (tooltip, flyout, item menu), drawn in the portal so the sidebar's
|
|
415
|
+
* scrolling body does not clip it, and kept on screen.
|
|
416
|
+
*/
|
|
417
|
+
function SideLayer(p: {
|
|
418
|
+
anchor: HTMLElement | null;
|
|
419
|
+
place: "right" | "below-end";
|
|
420
|
+
center?: boolean;
|
|
421
|
+
className?: string;
|
|
422
|
+
layerRef?: React.MutableRefObject<HTMLDivElement | null>;
|
|
423
|
+
onPointerEnter?: () => void;
|
|
424
|
+
onPointerLeave?: () => void;
|
|
425
|
+
onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void;
|
|
426
|
+
role?: string;
|
|
427
|
+
label?: string;
|
|
428
|
+
hidden?: boolean;
|
|
429
|
+
/** Move the focus into the layer when it appears (opened from the keyboard) */
|
|
430
|
+
autoFocus?: boolean;
|
|
431
|
+
children: React.ReactNode;
|
|
432
|
+
}) {
|
|
433
|
+
const el = React.useRef<HTMLDivElement | null>(null);
|
|
434
|
+
const place = React.useCallback(() => {
|
|
435
|
+
const box = el.current,
|
|
436
|
+
a = p.anchor;
|
|
437
|
+
if (!box || !a) return;
|
|
438
|
+
box.style.left = "0px";
|
|
439
|
+
box.style.top = "0px";
|
|
440
|
+
// where 0,0 lands: the layer's containing block may not be the screen (a transformed parent)
|
|
441
|
+
const o = box.getBoundingClientRect(),
|
|
442
|
+
r = a.getBoundingClientRect();
|
|
443
|
+
let left = p.place === "right" ? r.right + 8 : r.right - o.width;
|
|
444
|
+
let top =
|
|
445
|
+
p.place === "right" ? (p.center ? r.top + r.height / 2 - o.height / 2 : r.top) : r.bottom + 4;
|
|
446
|
+
left = Math.max(8, Math.min(left, window.innerWidth - o.width - 8));
|
|
447
|
+
top = Math.max(8, Math.min(top, window.innerHeight - o.height - 8));
|
|
448
|
+
box.style.left = `${left - o.left}px`;
|
|
449
|
+
box.style.top = `${top - o.top}px`;
|
|
450
|
+
}, [p.anchor, p.place, p.center]);
|
|
451
|
+
const { layerRef, autoFocus } = p;
|
|
452
|
+
// stable, so it runs when the layer appears (the portal mounts a render later), not on every render
|
|
453
|
+
const attach = React.useCallback(
|
|
454
|
+
(n: HTMLDivElement | null) => {
|
|
455
|
+
el.current = n;
|
|
456
|
+
if (layerRef) layerRef.current = n;
|
|
457
|
+
if (!n) return;
|
|
458
|
+
place();
|
|
459
|
+
if (autoFocus) n.querySelector<HTMLElement>(FOCUSABLE)?.focus();
|
|
460
|
+
},
|
|
461
|
+
[place, layerRef, autoFocus],
|
|
462
|
+
);
|
|
463
|
+
React.useEffect(() => {
|
|
464
|
+
place();
|
|
465
|
+
}, [place]);
|
|
466
|
+
React.useEffect(() => {
|
|
467
|
+
window.addEventListener("scroll", place, true);
|
|
468
|
+
window.addEventListener("resize", place);
|
|
469
|
+
return () => {
|
|
470
|
+
window.removeEventListener("scroll", place, true);
|
|
471
|
+
window.removeEventListener("resize", place);
|
|
472
|
+
};
|
|
473
|
+
}, [place]);
|
|
474
|
+
return (
|
|
475
|
+
<Portal>
|
|
476
|
+
<div
|
|
477
|
+
ref={attach}
|
|
478
|
+
className={cx("tf-sidebar-layer", p.className)}
|
|
479
|
+
role={p.role}
|
|
480
|
+
aria-label={p.label}
|
|
481
|
+
aria-hidden={p.hidden || undefined}
|
|
482
|
+
onPointerEnter={p.onPointerEnter}
|
|
483
|
+
onPointerLeave={p.onPointerLeave}
|
|
484
|
+
onKeyDown={p.onKeyDown}
|
|
485
|
+
>
|
|
486
|
+
{p.children}
|
|
487
|
+
</div>
|
|
488
|
+
</Portal>
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
type Fly = { id: string; el: HTMLElement; kind: "tip" | "menu"; keyboard?: boolean };
|
|
493
|
+
|
|
361
494
|
export function Sidebar(p: SidebarProps) {
|
|
362
495
|
const t = useStrings(NAVIGATION_STRINGS);
|
|
363
|
-
const
|
|
496
|
+
const phone = usePhone();
|
|
497
|
+
const collapsedState = useControlled(p.collapsed, !!p.defaultCollapsed, p.onCollapsedChange);
|
|
498
|
+
const aside = p.as === "aside";
|
|
499
|
+
const canCollapse = p.collapsible ?? !aside;
|
|
500
|
+
const collapsed = canCollapse && collapsedState[0] && !phone;
|
|
501
|
+
const setCollapsed = collapsedState[1];
|
|
502
|
+
const drawer = useControlled(p.mobileOpen, false, p.onMobileOpenChange);
|
|
503
|
+
const open = phone && drawer[0];
|
|
364
504
|
const idOf = (it: { id?: string; label: string }) => it.id || it.label;
|
|
365
505
|
const hasCurrent = (it: SidebarItem) =>
|
|
366
506
|
idOf(it) === p.current || !!it.items?.some((c) => idOf(c) === p.current);
|
|
367
507
|
const [expanded, setExpanded] = React.useState<string[]>(() =>
|
|
368
|
-
p.groups.flatMap((g) => g.items.filter((it) => it.items && hasCurrent(it)).map(idOf)),
|
|
508
|
+
p.groups.flatMap((g) => (g.items || []).filter((it) => it.items && hasCurrent(it)).map(idOf)),
|
|
509
|
+
);
|
|
510
|
+
const groupKey = (g: SidebarProps["groups"][number], i: number) => g.id || g.label || String(i);
|
|
511
|
+
const [shut, setShut] = React.useState<string[]>(() =>
|
|
512
|
+
p.groups.flatMap((g, i) => (g.collapsible && g.defaultOpen === false ? [groupKey(g, i)] : [])),
|
|
369
513
|
);
|
|
514
|
+
const [fly, setFly] = React.useState<Fly | null>(null);
|
|
515
|
+
const [menuFor, setMenuFor] = React.useState<{ id: string; el: HTMLElement } | null>(null);
|
|
516
|
+
const flyRef = React.useRef<HTMLDivElement | null>(null);
|
|
517
|
+
const menuRef = React.useRef<HTMLDivElement | null>(null);
|
|
518
|
+
const navRef = React.useRef<HTMLElement>(null);
|
|
519
|
+
const toggleRef = React.useRef<HTMLButtonElement>(null);
|
|
520
|
+
const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
370
521
|
const uid = useId("side");
|
|
522
|
+
const Link = (p.linkAs || "a") as React.ElementType;
|
|
523
|
+
|
|
524
|
+
// remember the collapsed state
|
|
525
|
+
const stored = React.useRef(false);
|
|
526
|
+
React.useEffect(() => {
|
|
527
|
+
if (!p.storageKey) return;
|
|
528
|
+
try {
|
|
529
|
+
const v = localStorage.getItem(p.storageKey);
|
|
530
|
+
if (v === "1" || v === "0") setCollapsed(v === "1");
|
|
531
|
+
} catch {
|
|
532
|
+
/* storage may be off */
|
|
533
|
+
}
|
|
534
|
+
stored.current = true;
|
|
535
|
+
}, [p.storageKey]);
|
|
536
|
+
React.useEffect(() => {
|
|
537
|
+
if (!p.storageKey || !stored.current) return;
|
|
538
|
+
try {
|
|
539
|
+
localStorage.setItem(p.storageKey, collapsedState[0] ? "1" : "0");
|
|
540
|
+
} catch {
|
|
541
|
+
/* storage may be off */
|
|
542
|
+
}
|
|
543
|
+
}, [collapsedState[0], p.storageKey]);
|
|
544
|
+
|
|
545
|
+
// ⌘B / Ctrl+B
|
|
546
|
+
React.useEffect(() => {
|
|
547
|
+
if (!p.shortcut) return;
|
|
548
|
+
const k = (e: KeyboardEvent) => {
|
|
549
|
+
if (
|
|
550
|
+
(e.metaKey || e.ctrlKey) &&
|
|
551
|
+
!e.altKey &&
|
|
552
|
+
e.key.toLowerCase() === "b" &&
|
|
553
|
+
!typing(e.target)
|
|
554
|
+
) {
|
|
555
|
+
e.preventDefault();
|
|
556
|
+
if (phone) drawer[1](!drawer[0]);
|
|
557
|
+
else if (canCollapse) setCollapsed(!collapsedState[0]);
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
document.addEventListener("keydown", k);
|
|
561
|
+
return () => document.removeEventListener("keydown", k);
|
|
562
|
+
}, [p.shortcut, phone, drawer[0], collapsedState[0], canCollapse]);
|
|
563
|
+
|
|
564
|
+
// the drawer on phones: focus inside, Escape and Tab stay in it, focus returns to the button
|
|
565
|
+
React.useEffect(() => {
|
|
566
|
+
if (!open) return;
|
|
567
|
+
const nav = navRef.current;
|
|
568
|
+
nav?.querySelector<HTMLElement>(FOCUSABLE)?.focus();
|
|
569
|
+
const k = (e: KeyboardEvent) => {
|
|
570
|
+
if (e.key === "Escape") {
|
|
571
|
+
drawer[1](false);
|
|
572
|
+
toggleRef.current?.focus();
|
|
573
|
+
} else if (e.key === "Tab" && nav) {
|
|
574
|
+
const els = Array.from(nav.querySelectorAll<HTMLElement>(FOCUSABLE));
|
|
575
|
+
const a = els[0],
|
|
576
|
+
z = els[els.length - 1];
|
|
577
|
+
if (e.shiftKey && document.activeElement === a) {
|
|
578
|
+
e.preventDefault();
|
|
579
|
+
z?.focus();
|
|
580
|
+
} else if (!e.shiftKey && document.activeElement === z) {
|
|
581
|
+
e.preventDefault();
|
|
582
|
+
a?.focus();
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
document.addEventListener("keydown", k);
|
|
587
|
+
return () => document.removeEventListener("keydown", k);
|
|
588
|
+
}, [open]);
|
|
589
|
+
|
|
590
|
+
// flyouts close when collapsing ends, and on a click elsewhere
|
|
591
|
+
React.useEffect(() => {
|
|
592
|
+
if (!collapsed) setFly(null);
|
|
593
|
+
}, [collapsed]);
|
|
594
|
+
React.useEffect(() => {
|
|
595
|
+
if (!fly && !menuFor) return;
|
|
596
|
+
const down = (e: PointerEvent) => {
|
|
597
|
+
const n = e.target as Node;
|
|
598
|
+
if (fly && !fly.el.contains(n) && !flyRef.current?.contains(n)) setFly(null);
|
|
599
|
+
if (menuFor && !menuFor.el.contains(n) && !menuRef.current?.contains(n)) setMenuFor(null);
|
|
600
|
+
};
|
|
601
|
+
document.addEventListener("pointerdown", down);
|
|
602
|
+
return () => document.removeEventListener("pointerdown", down);
|
|
603
|
+
}, [fly, menuFor]);
|
|
604
|
+
|
|
605
|
+
const pick = (id: string) => {
|
|
606
|
+
p.onPick?.(id);
|
|
607
|
+
setFly(null);
|
|
608
|
+
if (open) drawer[1](false);
|
|
609
|
+
};
|
|
610
|
+
const later = (fn: () => void, ms: number) => {
|
|
611
|
+
clearTimeout(timer.current);
|
|
612
|
+
timer.current = setTimeout(fn, ms);
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
const inner = (
|
|
616
|
+
it: Omit<SidebarItem, "items"> & { items?: SidebarItem["items"] },
|
|
617
|
+
nested: boolean,
|
|
618
|
+
) => (
|
|
619
|
+
<>
|
|
620
|
+
{!nested && it.icon && <Icon name={it.icon} />}
|
|
621
|
+
<span className="tf-sidebar-label">{it.label}</span>
|
|
622
|
+
{it.external && (
|
|
623
|
+
<>
|
|
624
|
+
<Icon name="arrowUpRight" size={14} className="tf-sidebar-ext" />
|
|
625
|
+
<span className="tf-sidebar-sr">{t.opensInNewTab}</span>
|
|
626
|
+
</>
|
|
627
|
+
)}
|
|
628
|
+
{it.badge === "dot" ? (
|
|
629
|
+
<span className="tf-sidebar-dot">
|
|
630
|
+
<span className="tf-sidebar-sr">{t.newActivity}</span>
|
|
631
|
+
</span>
|
|
632
|
+
) : it.badge ? (
|
|
633
|
+
<span className="tf-sidebar-badge">{it.badge}</span>
|
|
634
|
+
) : null}
|
|
635
|
+
{it.count ? <span className="tf-sidebar-count">{it.count}</span> : null}
|
|
636
|
+
{it.items && <Icon name="chevronDown" size={14} className="tf-sidebar-chev" />}
|
|
637
|
+
</>
|
|
638
|
+
);
|
|
639
|
+
|
|
640
|
+
/** A filter option: a checkbox, or a radio when the group picks one */
|
|
641
|
+
const option = (it: SidebarItem, gi: number, single: boolean) => {
|
|
642
|
+
const id = idOf(it);
|
|
643
|
+
return (
|
|
644
|
+
<li key={id} className="tf-sidebar-li">
|
|
645
|
+
<label className={cx("tf-sidebar-item", "tf-sidebar-option", it.disabled && "disabled")}>
|
|
646
|
+
<input
|
|
647
|
+
type={single ? "radio" : "checkbox"}
|
|
648
|
+
name={single ? `${uid}-r${gi}` : undefined}
|
|
649
|
+
className="tf-sidebar-input"
|
|
650
|
+
checked={!!it.checked}
|
|
651
|
+
disabled={it.disabled}
|
|
652
|
+
onChange={(e) => it.onCheckedChange?.(e.currentTarget.checked)}
|
|
653
|
+
/>
|
|
654
|
+
<span className={cx("tf-sidebar-mark", single && "radio")} aria-hidden>
|
|
655
|
+
{!single && (
|
|
656
|
+
<svg
|
|
657
|
+
viewBox="0 0 24 24"
|
|
658
|
+
width={12}
|
|
659
|
+
height={12}
|
|
660
|
+
fill="none"
|
|
661
|
+
stroke="currentColor"
|
|
662
|
+
strokeWidth={3}
|
|
663
|
+
strokeLinecap="round"
|
|
664
|
+
strokeLinejoin="round"
|
|
665
|
+
>
|
|
666
|
+
<path d="M5 12.5l4.5 4.5L19 7.5" />
|
|
667
|
+
</svg>
|
|
668
|
+
)}
|
|
669
|
+
</span>
|
|
670
|
+
{it.icon && <Icon name={it.icon} />}
|
|
671
|
+
<span className="tf-sidebar-label">{it.label}</span>
|
|
672
|
+
{it.count !== undefined && <span className="tf-sidebar-count">{it.count}</span>}
|
|
673
|
+
</label>
|
|
674
|
+
</li>
|
|
675
|
+
);
|
|
676
|
+
};
|
|
677
|
+
|
|
371
678
|
const row = (
|
|
372
679
|
it: Omit<SidebarItem, "items"> & { items?: SidebarItem["items"] },
|
|
373
680
|
nested = false,
|
|
@@ -375,69 +682,342 @@ export function Sidebar(p: SidebarProps) {
|
|
|
375
682
|
const id = idOf(it);
|
|
376
683
|
const isOpen = expanded.includes(id);
|
|
377
684
|
const current = id === p.current;
|
|
378
|
-
|
|
379
|
-
|
|
685
|
+
const parentCurrent = !!it.items && hasCurrent(it as SidebarItem);
|
|
686
|
+
const cls = cx(
|
|
687
|
+
"tf-sidebar-item",
|
|
688
|
+
nested && "nested",
|
|
689
|
+
parentCurrent && "has-current",
|
|
690
|
+
fly?.id === id && "flying",
|
|
691
|
+
);
|
|
692
|
+
const hover = collapsed && !nested;
|
|
693
|
+
const showFly = (el: HTMLElement, keyboard = false) =>
|
|
694
|
+
setFly({ id, el, kind: it.items ? "menu" : "tip", keyboard });
|
|
695
|
+
const itemProps = {
|
|
696
|
+
className: cls,
|
|
697
|
+
"aria-current": current ? ("page" as const) : undefined,
|
|
698
|
+
onFocus: hover
|
|
699
|
+
? (e: React.FocusEvent<HTMLElement>) => {
|
|
700
|
+
if (!it.items) showFly(e.currentTarget);
|
|
701
|
+
}
|
|
702
|
+
: undefined,
|
|
703
|
+
onBlur: hover && !it.items ? () => setFly(null) : undefined,
|
|
704
|
+
};
|
|
705
|
+
let control: React.ReactNode;
|
|
706
|
+
if (it.items)
|
|
707
|
+
control = (
|
|
380
708
|
<button
|
|
381
709
|
type="button"
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
710
|
+
{...itemProps}
|
|
711
|
+
aria-expanded={collapsed ? fly?.id === id : isOpen}
|
|
712
|
+
aria-controls={!collapsed && isOpen ? `${uid}-${id}` : undefined}
|
|
713
|
+
aria-haspopup={collapsed ? "true" : undefined}
|
|
714
|
+
disabled={it.disabled}
|
|
715
|
+
onClick={(e) => {
|
|
716
|
+
if (collapsed) {
|
|
717
|
+
if (fly?.id === id && fly.kind === "menu") setFly(null);
|
|
718
|
+
else showFly(e.currentTarget, e.detail === 0);
|
|
719
|
+
} else setExpanded(isOpen ? expanded.filter((x) => x !== id) : expanded.concat(id));
|
|
720
|
+
}}
|
|
721
|
+
onKeyDown={(e) => {
|
|
722
|
+
if (collapsed && e.key === "ArrowRight") {
|
|
723
|
+
e.preventDefault();
|
|
724
|
+
showFly(e.currentTarget, true);
|
|
725
|
+
}
|
|
395
726
|
}}
|
|
396
727
|
>
|
|
397
|
-
{
|
|
398
|
-
<span className="tf-sidebar-label">{it.label}</span>
|
|
399
|
-
{it.count ? <span className="tf-subnav-count">{it.count}</span> : null}
|
|
400
|
-
{it.items && <Icon name="chevronDown" size={14} className="tf-sidebar-chev" />}
|
|
728
|
+
{inner(it, nested)}
|
|
401
729
|
</button>
|
|
402
|
-
|
|
730
|
+
);
|
|
731
|
+
else if (it.href && !it.disabled)
|
|
732
|
+
control = (
|
|
733
|
+
<Link
|
|
734
|
+
href={it.href}
|
|
735
|
+
{...itemProps}
|
|
736
|
+
{...(it.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
|
737
|
+
onClick={() => pick(id)}
|
|
738
|
+
>
|
|
739
|
+
{inner(it, nested)}
|
|
740
|
+
</Link>
|
|
741
|
+
);
|
|
742
|
+
else
|
|
743
|
+
control = (
|
|
744
|
+
<button type="button" {...itemProps} disabled={it.disabled} onClick={() => pick(id)}>
|
|
745
|
+
{inner(it, nested)}
|
|
746
|
+
</button>
|
|
747
|
+
);
|
|
748
|
+
return (
|
|
749
|
+
<li
|
|
750
|
+
key={id}
|
|
751
|
+
className={cx("tf-sidebar-li", it.actions && !collapsed && "with-actions")}
|
|
752
|
+
onPointerEnter={
|
|
753
|
+
hover
|
|
754
|
+
? (e) => {
|
|
755
|
+
const el = e.currentTarget.firstElementChild as HTMLElement;
|
|
756
|
+
later(() => setFly({ id, el, kind: it.items ? "menu" : "tip" }), 120);
|
|
757
|
+
}
|
|
758
|
+
: undefined
|
|
759
|
+
}
|
|
760
|
+
onPointerLeave={hover ? () => later(() => setFly(null), 160) : undefined}
|
|
761
|
+
>
|
|
762
|
+
{control}
|
|
763
|
+
{it.actions && !collapsed && (
|
|
764
|
+
<IconButton
|
|
765
|
+
icon="more"
|
|
766
|
+
label={t.moreActionsFor(it.label)}
|
|
767
|
+
variant="ghost"
|
|
768
|
+
size="sm"
|
|
769
|
+
className="tf-sidebar-more"
|
|
770
|
+
aria-haspopup="menu"
|
|
771
|
+
aria-expanded={menuFor?.id === id}
|
|
772
|
+
onClick={(e) =>
|
|
773
|
+
setMenuFor(menuFor?.id === id ? null : { id, el: e.currentTarget as HTMLElement })
|
|
774
|
+
}
|
|
775
|
+
/>
|
|
776
|
+
)}
|
|
777
|
+
{it.items && isOpen && !collapsed && (
|
|
403
778
|
<ul id={`${uid}-${id}`} className="tf-sidebar-sub">
|
|
404
779
|
{it.items.map((c) => row(c, true))}
|
|
405
780
|
</ul>
|
|
406
781
|
)}
|
|
782
|
+
{menuFor?.id === id && it.actions && (
|
|
783
|
+
<SideLayer anchor={menuFor.el} place="below-end" layerRef={menuRef}>
|
|
784
|
+
<Menu
|
|
785
|
+
items={it.actions}
|
|
786
|
+
label={t.moreActionsFor(it.label)}
|
|
787
|
+
autoFocus
|
|
788
|
+
onClose={() => {
|
|
789
|
+
const el = menuFor.el;
|
|
790
|
+
setMenuFor(null);
|
|
791
|
+
el.focus();
|
|
792
|
+
}}
|
|
793
|
+
/>
|
|
794
|
+
</SideLayer>
|
|
795
|
+
)}
|
|
407
796
|
</li>
|
|
408
797
|
);
|
|
409
798
|
};
|
|
799
|
+
|
|
800
|
+
const flyItem = fly
|
|
801
|
+
? p.groups.flatMap((g) => g.items || []).find((it) => idOf(it) === fly.id)
|
|
802
|
+
: undefined;
|
|
803
|
+
const state = { collapsed };
|
|
804
|
+
const header = typeof p.header === "function" ? p.header(state) : p.header;
|
|
805
|
+
const footer = typeof p.footer === "function" ? p.footer(state) : p.footer;
|
|
806
|
+
const variant = p.variant || "floating";
|
|
807
|
+
const Root = aside ? "aside" : "nav";
|
|
410
808
|
return (
|
|
411
|
-
|
|
412
|
-
className={cx("tf-sidebar",
|
|
413
|
-
style={p.style}
|
|
414
|
-
aria-label={p.label || t.sidebar}
|
|
415
|
-
>
|
|
416
|
-
{p.header && <div className="tf-sidebar-head">{p.header}</div>}
|
|
417
|
-
<div className="tf-sidebar-body">
|
|
418
|
-
{p.groups.map((g, gi) => (
|
|
419
|
-
<div key={g.label || gi} className="tf-sidebar-group">
|
|
420
|
-
{g.label && (
|
|
421
|
-
<div className="tf-sidebar-grouplabel" id={`${uid}-g${gi}`}>
|
|
422
|
-
{g.label}
|
|
423
|
-
</div>
|
|
424
|
-
)}
|
|
425
|
-
<ul aria-labelledby={g.label ? `${uid}-g${gi}` : undefined}>
|
|
426
|
-
{g.items.map((it) => row(it))}
|
|
427
|
-
</ul>
|
|
428
|
-
</div>
|
|
429
|
-
))}
|
|
430
|
-
</div>
|
|
431
|
-
<div className="tf-sidebar-foot">
|
|
432
|
-
{p.footer}
|
|
809
|
+
<>
|
|
810
|
+
<div className={cx("tf-sidebar-bar", variant)}>
|
|
433
811
|
<IconButton
|
|
434
|
-
|
|
435
|
-
|
|
812
|
+
ref={toggleRef}
|
|
813
|
+
icon={aside ? "filter" : "menu"}
|
|
814
|
+
label={aside ? p.label || t.filters : open ? t.closeNavigation : t.openNavigation}
|
|
436
815
|
variant="ghost"
|
|
437
|
-
|
|
438
|
-
|
|
816
|
+
aria-expanded={open}
|
|
817
|
+
aria-controls={`${uid}-nav`}
|
|
818
|
+
onClick={() => drawer[1](!drawer[0])}
|
|
439
819
|
/>
|
|
820
|
+
{(typeof p.header === "function" ? p.header({ collapsed: false }) : p.header) || null}
|
|
440
821
|
</div>
|
|
441
|
-
|
|
822
|
+
{open && <div className="tf-sidebar-scrim" onClick={() => drawer[1](false)} />}
|
|
823
|
+
<Root
|
|
824
|
+
ref={navRef}
|
|
825
|
+
id={`${uid}-nav`}
|
|
826
|
+
className={cx(
|
|
827
|
+
"tf-sidebar",
|
|
828
|
+
variant !== "floating" && variant,
|
|
829
|
+
collapsed && "collapsed",
|
|
830
|
+
open && "open",
|
|
831
|
+
p.className,
|
|
832
|
+
)}
|
|
833
|
+
style={p.style}
|
|
834
|
+
aria-label={p.label || (aside ? t.filters : t.sidebar)}
|
|
835
|
+
>
|
|
836
|
+
{header && <div className="tf-sidebar-head">{header}</div>}
|
|
837
|
+
<div className="tf-sidebar-body">
|
|
838
|
+
{p.groups.map((g, gi) => {
|
|
839
|
+
const key = groupKey(g, gi);
|
|
840
|
+
const isShut = !collapsed && shut.includes(key);
|
|
841
|
+
const items = g.items || [];
|
|
842
|
+
const options = items.some((it) => it.checked !== undefined);
|
|
843
|
+
// filters and free content have no icons-only form
|
|
844
|
+
if (collapsed && (options || (g.content && !items.length))) return null;
|
|
845
|
+
return (
|
|
846
|
+
<div key={key} className="tf-sidebar-group">
|
|
847
|
+
{(g.label || g.action) && (
|
|
848
|
+
<div className="tf-sidebar-grouphead">
|
|
849
|
+
{g.label && g.collapsible ? (
|
|
850
|
+
<button
|
|
851
|
+
type="button"
|
|
852
|
+
className="tf-sidebar-grouplabel"
|
|
853
|
+
id={`${uid}-g${gi}`}
|
|
854
|
+
aria-expanded={!isShut}
|
|
855
|
+
aria-controls={`${uid}-gl${gi}`}
|
|
856
|
+
onClick={() =>
|
|
857
|
+
setShut(isShut ? shut.filter((x) => x !== key) : shut.concat(key))
|
|
858
|
+
}
|
|
859
|
+
>
|
|
860
|
+
{g.label}
|
|
861
|
+
<Icon name="chevronDown" size={12} className="tf-sidebar-chev" />
|
|
862
|
+
</button>
|
|
863
|
+
) : g.label ? (
|
|
864
|
+
<div className="tf-sidebar-grouplabel" id={`${uid}-g${gi}`}>
|
|
865
|
+
{g.label}
|
|
866
|
+
</div>
|
|
867
|
+
) : null}
|
|
868
|
+
{g.action && !collapsed && (
|
|
869
|
+
<IconButton
|
|
870
|
+
icon={g.action.icon || "plus"}
|
|
871
|
+
label={g.action.label}
|
|
872
|
+
variant="ghost"
|
|
873
|
+
size="sm"
|
|
874
|
+
className="tf-sidebar-groupaction"
|
|
875
|
+
onClick={g.action.onClick}
|
|
876
|
+
/>
|
|
877
|
+
)}
|
|
878
|
+
</div>
|
|
879
|
+
)}
|
|
880
|
+
{!isShut && (items.length > 0 || g.content) && (
|
|
881
|
+
<div
|
|
882
|
+
id={`${uid}-gl${gi}`}
|
|
883
|
+
className="tf-sidebar-groupbody"
|
|
884
|
+
role={options ? "group" : undefined}
|
|
885
|
+
aria-labelledby={g.label ? `${uid}-g${gi}` : undefined}
|
|
886
|
+
>
|
|
887
|
+
{items.length > 0 && (
|
|
888
|
+
<ul aria-labelledby={!options && g.label ? `${uid}-g${gi}` : undefined}>
|
|
889
|
+
{items.map((it) =>
|
|
890
|
+
it.checked !== undefined
|
|
891
|
+
? option(it, gi, g.select === "single")
|
|
892
|
+
: row(it),
|
|
893
|
+
)}
|
|
894
|
+
</ul>
|
|
895
|
+
)}
|
|
896
|
+
{g.content && !collapsed && (
|
|
897
|
+
<div className="tf-sidebar-content">{g.content}</div>
|
|
898
|
+
)}
|
|
899
|
+
</div>
|
|
900
|
+
)}
|
|
901
|
+
</div>
|
|
902
|
+
);
|
|
903
|
+
})}
|
|
904
|
+
</div>
|
|
905
|
+
<div className="tf-sidebar-foot">
|
|
906
|
+
{footer}
|
|
907
|
+
{canCollapse && (
|
|
908
|
+
<IconButton
|
|
909
|
+
icon={collapsed ? "chevronRight" : "chevronLeft"}
|
|
910
|
+
label={collapsed ? t.expandSidebar : t.collapseSidebar}
|
|
911
|
+
variant="ghost"
|
|
912
|
+
size="sm"
|
|
913
|
+
className="tf-sidebar-toggle"
|
|
914
|
+
aria-keyshortcuts={p.shortcut ? "Meta+B Control+B" : undefined}
|
|
915
|
+
onClick={() => setCollapsed(!collapsedState[0])}
|
|
916
|
+
/>
|
|
917
|
+
)}
|
|
918
|
+
</div>
|
|
919
|
+
</Root>
|
|
920
|
+
{fly && flyItem && fly.kind === "tip" && (
|
|
921
|
+
<SideLayer anchor={fly.el} place="right" center className="tf-sidebar-tip" hidden>
|
|
922
|
+
{flyItem.label}
|
|
923
|
+
{flyItem.count ? <span className="tf-sidebar-tipcount">{flyItem.count}</span> : null}
|
|
924
|
+
</SideLayer>
|
|
925
|
+
)}
|
|
926
|
+
{fly && flyItem && fly.kind === "menu" && flyItem.items && (
|
|
927
|
+
<SideLayer
|
|
928
|
+
anchor={fly.el}
|
|
929
|
+
place="right"
|
|
930
|
+
className="tf-sidebar-flyout"
|
|
931
|
+
layerRef={flyRef}
|
|
932
|
+
autoFocus={fly.keyboard}
|
|
933
|
+
role="group"
|
|
934
|
+
label={flyItem.label}
|
|
935
|
+
onPointerEnter={() => clearTimeout(timer.current)}
|
|
936
|
+
onPointerLeave={() => !fly.keyboard && later(() => setFly(null), 160)}
|
|
937
|
+
onKeyDown={(e) => {
|
|
938
|
+
if (e.key === "Escape" || e.key === "ArrowLeft") {
|
|
939
|
+
e.preventDefault();
|
|
940
|
+
const el = fly.el;
|
|
941
|
+
setFly(null);
|
|
942
|
+
el.focus();
|
|
943
|
+
} else moveFocus(e, flyRef.current, "a, button");
|
|
944
|
+
}}
|
|
945
|
+
>
|
|
946
|
+
<div className="tf-sidebar-flytitle">{flyItem.label}</div>
|
|
947
|
+
<ul>
|
|
948
|
+
{flyItem.items.map((c) => {
|
|
949
|
+
const cid = idOf(c);
|
|
950
|
+
const cls = cx("tf-sidebar-item", "nested");
|
|
951
|
+
const cur = cid === p.current ? ("page" as const) : undefined;
|
|
952
|
+
return (
|
|
953
|
+
<li key={cid}>
|
|
954
|
+
{c.href && !c.disabled ? (
|
|
955
|
+
<Link
|
|
956
|
+
href={c.href}
|
|
957
|
+
className={cls}
|
|
958
|
+
aria-current={cur}
|
|
959
|
+
{...(c.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
|
960
|
+
onClick={() => pick(cid)}
|
|
961
|
+
>
|
|
962
|
+
{inner(c, true)}
|
|
963
|
+
</Link>
|
|
964
|
+
) : (
|
|
965
|
+
<button
|
|
966
|
+
type="button"
|
|
967
|
+
className={cls}
|
|
968
|
+
aria-current={cur}
|
|
969
|
+
disabled={c.disabled}
|
|
970
|
+
onClick={() => pick(cid)}
|
|
971
|
+
>
|
|
972
|
+
{inner(c, true)}
|
|
973
|
+
</button>
|
|
974
|
+
)}
|
|
975
|
+
</li>
|
|
976
|
+
);
|
|
977
|
+
})}
|
|
978
|
+
</ul>
|
|
979
|
+
</SideLayer>
|
|
980
|
+
)}
|
|
981
|
+
</>
|
|
982
|
+
);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/** The signed-in person with their account menu (profile, settings, sign out). */
|
|
986
|
+
export function UserMenu(p: UserMenuProps) {
|
|
987
|
+
const t = useStrings(NAVIGATION_STRINGS);
|
|
988
|
+
return (
|
|
989
|
+
<Popover
|
|
990
|
+
className={cx("tf-usermenu-wrap", p.className)}
|
|
991
|
+
width={240}
|
|
992
|
+
side={p.side || "top"}
|
|
993
|
+
block={!p.compact}
|
|
994
|
+
trigger={({ open, toggle }) => (
|
|
995
|
+
<button
|
|
996
|
+
type="button"
|
|
997
|
+
className={cx("tf-usermenu", p.compact && "compact")}
|
|
998
|
+
style={p.style}
|
|
999
|
+
aria-haspopup="menu"
|
|
1000
|
+
aria-expanded={open}
|
|
1001
|
+
onClick={toggle}
|
|
1002
|
+
>
|
|
1003
|
+
<span aria-hidden="true" className="tf-usermenu-avatar">
|
|
1004
|
+
<Avatar name={p.name} src={p.avatar} size={32} />
|
|
1005
|
+
</span>
|
|
1006
|
+
{/* compact: the initials are decoration, the name is read out */}
|
|
1007
|
+
{p.compact && <span className="tf-sidebar-sr">{`${t.account}: ${p.name}`}</span>}
|
|
1008
|
+
{!p.compact && (
|
|
1009
|
+
<>
|
|
1010
|
+
<span className="tf-usermenu-text">
|
|
1011
|
+
<span className="tf-usermenu-name">{p.name}</span>
|
|
1012
|
+
{p.email && <span className="tf-usermenu-mail">{p.email}</span>}
|
|
1013
|
+
</span>
|
|
1014
|
+
<Icon name="chevronDown" size={14} />
|
|
1015
|
+
</>
|
|
1016
|
+
)}
|
|
1017
|
+
</button>
|
|
1018
|
+
)}
|
|
1019
|
+
>
|
|
1020
|
+
{({ close }) => <Menu label={t.account} autoFocus onClose={close} items={p.items || []} />}
|
|
1021
|
+
</Popover>
|
|
442
1022
|
);
|
|
443
1023
|
}
|