@fikar-ai/design-react 1.5.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.
Files changed (46) hide show
  1. package/README.md +122 -0
  2. package/dist/app-icons.d.ts +2 -0
  3. package/dist/app-icons.js +11 -0
  4. package/dist/app-launcher.d.ts +20 -0
  5. package/dist/app-launcher.js +26 -0
  6. package/dist/app-shell.d.ts +47 -0
  7. package/dist/app-shell.js +83 -0
  8. package/dist/brand-logo.d.ts +15 -0
  9. package/dist/brand-logo.js +9 -0
  10. package/dist/context.d.ts +12 -0
  11. package/dist/context.js +8 -0
  12. package/dist/detail-panel.d.ts +26 -0
  13. package/dist/detail-panel.js +14 -0
  14. package/dist/entity-badge.d.ts +7 -0
  15. package/dist/entity-badge.js +12 -0
  16. package/dist/filter-chip.d.ts +10 -0
  17. package/dist/filter-chip.js +5 -0
  18. package/dist/format-time.d.ts +10 -0
  19. package/dist/format-time.js +35 -0
  20. package/dist/index.d.ts +33 -0
  21. package/dist/index.js +18 -0
  22. package/dist/list-row.d.ts +22 -0
  23. package/dist/list-row.js +14 -0
  24. package/dist/nav-item.d.ts +20 -0
  25. package/dist/nav-item.js +31 -0
  26. package/dist/relative-time.d.ts +12 -0
  27. package/dist/relative-time.js +35 -0
  28. package/dist/search-box.d.ts +31 -0
  29. package/dist/search-box.js +17 -0
  30. package/dist/search-results.d.ts +39 -0
  31. package/dist/search-results.js +46 -0
  32. package/dist/status-badge.d.ts +9 -0
  33. package/dist/status-badge.js +5 -0
  34. package/dist/storage.d.ts +3 -0
  35. package/dist/storage.js +26 -0
  36. package/dist/theme-menu.d.ts +14 -0
  37. package/dist/theme-menu.js +23 -0
  38. package/dist/use-menu.d.ts +24 -0
  39. package/dist/use-menu.js +71 -0
  40. package/dist/use-shortcut.d.ts +7 -0
  41. package/dist/use-shortcut.js +43 -0
  42. package/dist/user-initials.d.ts +8 -0
  43. package/dist/user-initials.js +18 -0
  44. package/dist/user-menu.d.ts +36 -0
  45. package/dist/user-menu.js +23 -0
  46. package/package.json +66 -0
@@ -0,0 +1,20 @@
1
+ import type { ComponentPropsWithoutRef, ElementType, ReactNode } from 'react';
2
+ interface NavItemOwnProps {
3
+ icon?: ReactNode;
4
+ label: ReactNode;
5
+ /** Small pill at the right of the item. Hidden in the rail. */
6
+ badge?: ReactNode;
7
+ active?: boolean;
8
+ /** `back` is the link that leads out of this surface to where the person came from. It renders quieter and sits apart from the items below it. */
9
+ variant?: 'default' | 'back';
10
+ }
11
+ export type NavItemProps<C extends ElementType = 'a'> = NavItemOwnProps & {
12
+ /** The element or router link to render. It must forward its ref and accept a string className. */
13
+ component?: C;
14
+ } & Omit<ComponentPropsWithoutRef<C>, keyof NavItemOwnProps | 'component' | 'className' | 'children'>;
15
+ /** Renders a group heading between nav items. Clipped in the rail, still read by screen readers. */
16
+ export declare function NavHeading({ children }: {
17
+ children: ReactNode;
18
+ }): JSX.Element;
19
+ export declare function NavItem<C extends ElementType = 'a'>({ icon, label, badge, active, variant, component, onClick, ...rest }: NavItemProps<C>): JSX.Element;
20
+ export {};
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useContext } from 'react';
3
+ import * as Tooltip from '@radix-ui/react-tooltip';
4
+ import { AppShellContext } from './context.js';
5
+ let warnedNavLink = false;
6
+ /** Renders a group heading between nav items. Clipped in the rail, still read by screen readers. */
7
+ export function NavHeading({ children }) {
8
+ return _jsx("div", { className: "nav-heading", children: children });
9
+ }
10
+ export function NavItem({ icon, label, badge, active = false, variant = 'default', component, onClick, ...rest }) {
11
+ const shell = useContext(AppShellContext);
12
+ const Component = component ?? 'a';
13
+ // NavLink sets its own active class and aria-current from the route, which fights the
14
+ // `active` prop and doubles the class. The displayName is only present in development builds.
15
+ if (!warnedNavLink && Component.displayName === 'NavLink') {
16
+ warnedNavLink = true;
17
+ console.warn('NavItem: pass the router Link as `component` and compute `active` in the app, not NavLink.');
18
+ }
19
+ const handleClick = (event) => {
20
+ onClick?.(event);
21
+ // A tapped link should not leave the drawer covering the page it opened.
22
+ if (!event.defaultPrevented)
23
+ shell?.setDrawerOpen(false);
24
+ };
25
+ // Always a plain string. Radix's asChild joins className into a string, so a
26
+ // router NavLink that receives a function here loses every class (KAN-311).
27
+ const link = (_jsxs(Component, { ...rest, className: ['nav-item', active && 'active', variant === 'back' && 'nav-item-back'].filter(Boolean).join(' '), "aria-current": active ? 'page' : undefined, onClick: handleClick, children: [icon, _jsx("span", { className: "nav-label", children: label }), badge ? _jsx("span", { className: "nav-badge", children: badge }) : null] }));
28
+ if (!shell?.isRail)
29
+ return link;
30
+ return (_jsxs(Tooltip.Root, { children: [_jsx(Tooltip.Trigger, { asChild: true, children: link }), _jsx(Tooltip.Portal, { children: _jsx(Tooltip.Content, { side: "right", sideOffset: 8, className: "fk-tooltip", children: label }) })] }));
31
+ }
@@ -0,0 +1,12 @@
1
+ export interface RelativeTimeProps {
2
+ /** The moment, as a Date or an ISO string. Anything that is not a date renders nothing. */
3
+ date: Date | string;
4
+ /** The current time, for tests. When it is given the text is fixed and never refreshes. */
5
+ now?: Date;
6
+ }
7
+ /**
8
+ * A moment as "4 h ago", with the exact time in the title for a hover. Renders the <time> of recipes/time.html: `datetime` is the machine value and the
9
+ * text and title are formatRelative and formatExact. It refreshes itself while mounted, every 30 seconds under an hour, every 5 minutes under a day, and
10
+ * not at all once the text is an exact time. The title is a plain attribute and not a tooltip, so it needs no provider and costs nothing in a long list.
11
+ */
12
+ export declare function RelativeTime({ date, now }: RelativeTimeProps): JSX.Element | null;
@@ -0,0 +1,35 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { formatExact, formatRelative } from './format-time.js';
4
+ const HALF_MINUTE = 30000;
5
+ const FIVE_MINUTES = 300000;
6
+ const HOUR = 3600000;
7
+ const DAY = 24 * HOUR;
8
+ /** How long until the text may have changed, or undefined when it is written exactly and only a much later render would change it. Under an hour the text changes each minute, and under a day each hour. */
9
+ function refreshAfter(distanceMs) {
10
+ if (distanceMs < HOUR)
11
+ return HALF_MINUTE;
12
+ if (distanceMs < DAY)
13
+ return FIVE_MINUTES;
14
+ return undefined;
15
+ }
16
+ /**
17
+ * A moment as "4 h ago", with the exact time in the title for a hover. Renders the <time> of recipes/time.html: `datetime` is the machine value and the
18
+ * text and title are formatRelative and formatExact. It refreshes itself while mounted, every 30 seconds under an hour, every 5 minutes under a day, and
19
+ * not at all once the text is an exact time. The title is a plain attribute and not a tooltip, so it needs no provider and costs nothing in a long list.
20
+ */
21
+ export function RelativeTime({ date, now }) {
22
+ const [ticks, setTicks] = useState(0);
23
+ const clock = now ?? new Date();
24
+ const time = new Date(date).getTime();
25
+ const delay = now || Number.isNaN(time) ? undefined : refreshAfter(Math.abs(clock.getTime() - time));
26
+ useEffect(() => {
27
+ if (delay === undefined)
28
+ return;
29
+ const timer = setTimeout(() => setTicks((n) => n + 1), delay);
30
+ return () => clearTimeout(timer);
31
+ }, [delay, ticks]);
32
+ if (Number.isNaN(time))
33
+ return null;
34
+ return (_jsx("time", { className: "fk-time", dateTime: new Date(time).toISOString(), title: formatExact(date, clock), children: formatRelative(date, clock) }));
35
+ }
@@ -0,0 +1,31 @@
1
+ import type { InputHTMLAttributes, ReactNode } from 'react';
2
+ type OwnProps = 'className' | 'children' | 'type' | 'size' | 'value' | 'onChange' | 'placeholder' | 'role' | 'aria-label' | 'aria-expanded' | 'aria-controls' | 'aria-activedescendant' | 'aria-autocomplete';
3
+ export interface SearchBoxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, OwnProps> {
4
+ value: string;
5
+ onChange: (value: string) => void;
6
+ /** Required: the CSS shows the shortcut hint or the clear button from whether the placeholder is showing. */
7
+ placeholder: string;
8
+ /** The box has no visible label, so it needs one. */
9
+ 'aria-label': string;
10
+ /** `'bar'` fills the top bar's start slot, `'panel'` fills a sidebar or panel. Defaults to `'bar'`. */
11
+ size?: 'bar' | 'panel';
12
+ /** The text of the trailing hint, such as "/" or "⌘K". The page says what it registered with useShortcut or data-fk-search. Without it there is no hint. */
13
+ shortcutHint?: string;
14
+ /** Renders the clear button. Called when it is pressed, after which focus goes back to the input. Usually `() => setValue('')`. */
15
+ onClear?: () => void;
16
+ /** The id of the results listbox, which makes the input a combobox. Give it the `id` of the `SearchResults` under this box. */
17
+ listboxId?: string;
18
+ /** Whether the results are showing. Only read with `listboxId`. */
19
+ expanded?: boolean;
20
+ /** The id of the active result row, which is the row's own `id`. Only read with `listboxId`. */
21
+ activeId?: string;
22
+ /** Put `SearchResults` here, so it is positioned by the box. */
23
+ children?: ReactNode;
24
+ }
25
+ /**
26
+ * The search box: a leading icon, the input, a clear button and a trailing shortcut hint. Renders recipes/search.html. The ref is the input's, for useShortcut.
27
+ * It never renders data-fk-search, because the component owns the behaviour and shell.js would otherwise bind it too.
28
+ * The clear button is there only when `onClear` is given, and the hint only when `shortcutHint` is.
29
+ */
30
+ export declare const SearchBox: import("react").ForwardRefExoticComponent<SearchBoxProps & import("react").RefAttributes<HTMLInputElement>>;
31
+ export {};
@@ -0,0 +1,17 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef, useImperativeHandle, useRef } from 'react';
3
+ /**
4
+ * The search box: a leading icon, the input, a clear button and a trailing shortcut hint. Renders recipes/search.html. The ref is the input's, for useShortcut.
5
+ * It never renders data-fk-search, because the component owns the behaviour and shell.js would otherwise bind it too.
6
+ * The clear button is there only when `onClear` is given, and the hint only when `shortcutHint` is.
7
+ */
8
+ export const SearchBox = forwardRef(function SearchBox({ value, onChange, placeholder, size = 'bar', shortcutHint, onClear, listboxId, expanded = false, activeId, children, 'aria-label': ariaLabel, ...input }, ref) {
9
+ const inner = useRef(null);
10
+ useImperativeHandle(ref, () => inner.current);
11
+ return (_jsxs("div", { className: size === 'panel' ? 'search search-panel' : 'search search-bar', children: [_jsxs("svg", { className: "search-icon", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [_jsx("circle", { cx: "11", cy: "11", r: "8" }), _jsx("path", { d: "m21 21-4.3-4.3" })] }), _jsx("input", { ...input, ref: inner, className: "inp", type: "search", autoComplete: "off", value: value, placeholder: placeholder, "aria-label": ariaLabel, onChange: (event) => onChange(event.target.value), ...(listboxId
12
+ ? { role: 'combobox', 'aria-expanded': expanded, 'aria-controls': listboxId, 'aria-autocomplete': 'list', 'aria-activedescendant': activeId }
13
+ : {}) }), onClear ? (_jsx("button", { className: "search-clear", type: "button", "aria-label": "Clear search", onClick: () => {
14
+ onClear();
15
+ inner.current?.focus();
16
+ }, children: _jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: _jsx("path", { d: "M18 6 6 18M6 6l12 12" }) }) })) : null, shortcutHint ? _jsx("kbd", { className: "search-kbd", children: shortcutHint }) : null, children] }));
17
+ });
@@ -0,0 +1,39 @@
1
+ import type { RefObject } from 'react';
2
+ import type { EntityType } from './entity-badge.js';
3
+ export interface SearchResultItem {
4
+ /** Becomes the row's DOM id, so it is unique on the page and is what `activeId` and the input's aria-activedescendant name. */
5
+ id: string;
6
+ title: string;
7
+ /** Shows the entity badge. Without it the row has none. */
8
+ type?: EntityType;
9
+ /** Shown after the badge, such as how many mentions there are. */
10
+ count?: number;
11
+ /** One line under the title, cut with an ellipsis. */
12
+ description?: string;
13
+ }
14
+ export interface SearchResultsProps {
15
+ /** The listbox's id. Pass the same string as `listboxId` on the `SearchBox` above it. */
16
+ id: string;
17
+ items: readonly SearchResultItem[];
18
+ /** The `id` of the active row, or undefined for none. The app keeps it. */
19
+ activeId?: string;
20
+ onActiveChange: (id: string) => void;
21
+ /** Called with the row that was clicked, or the active row when Enter is pressed. */
22
+ onSelect: (item: SearchResultItem) => void;
23
+ /** Called on Escape. The app hides the list. Focus never left the input, so it stays there. */
24
+ onClose: () => void;
25
+ /** The `SearchBox` ref. The keys are heard on the input, because focus stays in it. */
26
+ inputRef: RefObject<HTMLInputElement>;
27
+ /** What the list says when `items` is empty. */
28
+ emptyText: string;
29
+ /** Names the listbox. Defaults to "Results". */
30
+ 'aria-label'?: string;
31
+ }
32
+ /**
33
+ * The results listbox under a SearchBox. Renders the .search-results panel of recipes/search-results.html. It is shown while it is mounted,
34
+ * so the app mounts it while the list is open. Focus stays in the input, and the active row is marked with
35
+ * aria-selected, which the SearchBox's aria-activedescendant names. While it is mounted the input's ArrowDown and ArrowUp move the active row and wrap,
36
+ * Enter selects it and Escape asks the app to close the list. That is not the roving focus of the menus, which moves focus onto the rows.
37
+ * A press on a row keeps focus in the input, so the box does not lose focus before the click lands.
38
+ */
39
+ export declare function SearchResults({ id, items, activeId, onActiveChange, onSelect, onClose, inputRef, emptyText, 'aria-label': label }: SearchResultsProps): JSX.Element;
@@ -0,0 +1,46 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect } from 'react';
3
+ import { EntityBadge } from './entity-badge.js';
4
+ /**
5
+ * The results listbox under a SearchBox. Renders the .search-results panel of recipes/search-results.html. It is shown while it is mounted,
6
+ * so the app mounts it while the list is open. Focus stays in the input, and the active row is marked with
7
+ * aria-selected, which the SearchBox's aria-activedescendant names. While it is mounted the input's ArrowDown and ArrowUp move the active row and wrap,
8
+ * Enter selects it and Escape asks the app to close the list. That is not the roving focus of the menus, which moves focus onto the rows.
9
+ * A press on a row keeps focus in the input, so the box does not lose focus before the click lands.
10
+ */
11
+ export function SearchResults({ id, items, activeId, onActiveChange, onSelect, onClose, inputRef, emptyText, 'aria-label': label = 'Results' }) {
12
+ useEffect(() => {
13
+ const input = inputRef.current;
14
+ if (!input)
15
+ return;
16
+ const onKeyDown = (event) => {
17
+ if (event.isComposing)
18
+ return;
19
+ if (event.key === 'Escape') {
20
+ onClose();
21
+ return;
22
+ }
23
+ const at = items.findIndex((item) => item.id === activeId);
24
+ if (event.key === 'Enter') {
25
+ const active = items[at];
26
+ if (!active)
27
+ return;
28
+ event.preventDefault();
29
+ onSelect(active);
30
+ return;
31
+ }
32
+ const next = { ArrowDown: at + 1, ArrowUp: at < 0 ? items.length - 1 : at - 1 }[event.key];
33
+ if (next === undefined || items.length === 0)
34
+ return;
35
+ event.preventDefault();
36
+ onActiveChange(items[(next + items.length) % items.length].id);
37
+ };
38
+ input.addEventListener('keydown', onKeyDown);
39
+ return () => input.removeEventListener('keydown', onKeyDown);
40
+ }, [inputRef, items, activeId, onActiveChange, onSelect, onClose]);
41
+ useEffect(() => {
42
+ if (activeId)
43
+ document.getElementById(activeId)?.scrollIntoView?.({ block: 'nearest' });
44
+ }, [activeId]);
45
+ return (_jsx("div", { className: "launcher-panel search-results", id: id, role: "listbox", "aria-label": label, onMouseDown: (event) => event.preventDefault(), children: items.length === 0 ? (_jsx("div", { className: "search-empty", role: "option", "aria-disabled": "true", "aria-selected": "false", children: emptyText })) : (items.map((item) => (_jsxs("div", { className: "search-row", id: item.id, role: "option", "aria-selected": item.id === activeId, onClick: () => onSelect(item), children: [_jsx("span", { className: "search-row-title", children: item.title }), item.type ? _jsx(EntityBadge, { type: item.type }) : null, item.count === undefined ? null : _jsx("span", { className: "search-row-meta", children: item.count }), item.description ? _jsx("span", { className: "search-row-desc", children: item.description }) : null] }, item.id)))) }));
46
+ }
@@ -0,0 +1,9 @@
1
+ import type { ReactNode } from 'react';
2
+ export interface StatusBadgeProps {
3
+ /** Sets the colour of the dot: `success`, `warning`, `destructive` or `muted`. The word is what says the status, so it is never left out. */
4
+ status: 'success' | 'warning' | 'destructive' | 'muted';
5
+ /** The status in one plain word, such as "Ready" or "Failed". */
6
+ children: ReactNode;
7
+ }
8
+ /** A dot in the status colour and the word, from recipes/status-badge.html. Renders a span. */
9
+ export declare function StatusBadge({ status, children }: StatusBadgeProps): JSX.Element;
@@ -0,0 +1,5 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /** A dot in the status colour and the word, from recipes/status-badge.html. Renders a span. */
3
+ export function StatusBadge({ status, children }) {
4
+ return _jsx("span", { className: `badge b-${status}`, children: children });
5
+ }
@@ -0,0 +1,3 @@
1
+ /** Returns null when nothing usable is stored or storage is unavailable. */
2
+ export declare function readCollapsed(): boolean | null;
3
+ export declare function writeCollapsed(collapsed: boolean): void;
@@ -0,0 +1,26 @@
1
+ // Same key and values as the plain-HTML contract in @fikar-ai/design, so a
2
+ // person's choice is read the same way by every surface on one origin.
3
+ const KEY = 'fikar.shell.collapsed';
4
+ /** Returns null when nothing usable is stored or storage is unavailable. */
5
+ export function readCollapsed() {
6
+ try {
7
+ const value = window.localStorage.getItem(KEY);
8
+ if (value === '1')
9
+ return true;
10
+ if (value === '0')
11
+ return false;
12
+ return null;
13
+ }
14
+ catch {
15
+ // Blocked or full storage only means the choice is not remembered.
16
+ return null;
17
+ }
18
+ }
19
+ export function writeCollapsed(collapsed) {
20
+ try {
21
+ window.localStorage.setItem(KEY, collapsed ? '1' : '0');
22
+ }
23
+ catch {
24
+ // Same as reading: losing persistence must never break the shell.
25
+ }
26
+ }
@@ -0,0 +1,14 @@
1
+ export type ThemeChoice = 'light' | 'dark' | 'system';
2
+ export interface ThemeMenuProps {
3
+ /** The theme the app has now. The menu keeps no state of its own, so the chosen row follows this. */
4
+ value: ThemeChoice;
5
+ /** Called with the row the person chose, unless it is the one already chosen. The app applies and saves the theme in its own way. */
6
+ onChange: (value: ThemeChoice) => void;
7
+ }
8
+ /**
9
+ * The theme button and its panel of Light, Dark and System, for the second slot of the shell cluster. Renders recipes/theme-menu.html.
10
+ * Both icons are always rendered and the CSS shows the sun or the moon from the theme on the root, so it needs no script and no theme prop.
11
+ * Opens on click and closes on Escape (focus returns to the button), on a press outside and on choosing a row, which also returns focus to the button.
12
+ * Arrow keys, Home and End move between rows.
13
+ */
14
+ export declare function ThemeMenu({ value, onChange }: ThemeMenuProps): JSX.Element;
@@ -0,0 +1,23 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useMenu } from './use-menu.js';
3
+ const CHOICES = [
4
+ { value: 'light', label: 'Light' },
5
+ { value: 'dark', label: 'Dark' },
6
+ { value: 'system', label: 'System' },
7
+ ];
8
+ /**
9
+ * The theme button and its panel of Light, Dark and System, for the second slot of the shell cluster. Renders recipes/theme-menu.html.
10
+ * Both icons are always rendered and the CSS shows the sun or the moon from the theme on the root, so it needs no script and no theme prop.
11
+ * Opens on click and closes on Escape (focus returns to the button), on a press outside and on choosing a row, which also returns focus to the button.
12
+ * Arrow keys, Home and End move between rows.
13
+ */
14
+ export function ThemeMenu({ value, onChange }) {
15
+ const { open, toggle, root, trigger, rootClassName, moveFocus, closeOnFocusLeave, closeOnRow } = useMenu();
16
+ return (_jsxs("div", { ref: root, className: rootClassName, onKeyDown: moveFocus, onBlur: closeOnFocusLeave, children: [_jsxs("button", { ref: trigger, className: "launcher-btn theme-menu-btn", type: "button", "aria-haspopup": "menu", "aria-expanded": open, "aria-label": "Choose theme", onClick: toggle, children: [_jsxs("svg", { className: "theme-icon-sun", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [_jsx("circle", { cx: "12", cy: "12", r: "4" }), _jsx("path", { d: "M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" })] }), _jsx("svg", { className: "theme-icon-moon", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: _jsx("path", { d: "M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" }) })] }), _jsx("div", { className: "launcher-panel theme-menu-panel", role: "menu", "aria-label": "Theme", onClick: closeOnRow, children: CHOICES.map((choice) => {
17
+ const chosen = choice.value === value;
18
+ return (_jsx("button", { className: chosen ? 'launcher-item theme-menu-item current' : 'launcher-item theme-menu-item', type: "button", role: "menuitemradio", "aria-checked": chosen, "data-value": choice.value, onClick: () => {
19
+ if (!chosen)
20
+ onChange(choice.value);
21
+ }, children: choice.label }, choice.value));
22
+ }) })] }));
23
+ }
@@ -0,0 +1,24 @@
1
+ import type { FocusEvent, KeyboardEvent, MouseEvent, RefObject } from 'react';
2
+ export interface MenuState {
3
+ open: boolean;
4
+ toggle: () => void;
5
+ root: RefObject<HTMLDivElement>;
6
+ trigger: RefObject<HTMLButtonElement>;
7
+ /** The class of the `.launcher` root, with `.open` while the panel shows. */
8
+ rootClassName: string;
9
+ /** For the root's onKeyDown: arrow keys, Home and End move focus between the rows. */
10
+ moveFocus: (event: KeyboardEvent<HTMLDivElement>) => void;
11
+ /** For the root's onBlur: focus moving to an element outside the menu closes it, so tabbing away or to another menu's trigger leaves one panel open. */
12
+ closeOnFocusLeave: (event: FocusEvent<HTMLDivElement>) => void;
13
+ /** For the panel's onClick: choosing a row closes the panel. */
14
+ closeOnRow: (event: MouseEvent<HTMLDivElement>) => void;
15
+ }
16
+ /**
17
+ * The open and close behaviour that the launcher, the theme menu and the user menu share, in the same terms as shell.js.
18
+ * A click on the trigger toggles it. Escape closes it and returns focus to the trigger, a press outside closes it, and a
19
+ * chosen row closes it. Focus moving to an element outside the menu closes it too, so tabbing to another menu's trigger and
20
+ * pressing Enter leaves one panel open, as a trigger click does in shell.js. A blur with no target (a click on the panel's
21
+ * padding, the window losing focus) does not close it. Another menu's press outside is what closes this one when that menu opens.
22
+ * The document listeners exist only while the panel is open. Not exported from the package.
23
+ */
24
+ export declare function useMenu(): MenuState;
@@ -0,0 +1,71 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ const ROWS = '[role="menuitem"], [role="menuitemradio"]';
3
+ /**
4
+ * The open and close behaviour that the launcher, the theme menu and the user menu share, in the same terms as shell.js.
5
+ * A click on the trigger toggles it. Escape closes it and returns focus to the trigger, a press outside closes it, and a
6
+ * chosen row closes it. Focus moving to an element outside the menu closes it too, so tabbing to another menu's trigger and
7
+ * pressing Enter leaves one panel open, as a trigger click does in shell.js. A blur with no target (a click on the panel's
8
+ * padding, the window losing focus) does not close it. Another menu's press outside is what closes this one when that menu opens.
9
+ * The document listeners exist only while the panel is open. Not exported from the package.
10
+ */
11
+ export function useMenu() {
12
+ const [open, setOpen] = useState(false);
13
+ const root = useRef(null);
14
+ const trigger = useRef(null);
15
+ useEffect(() => {
16
+ if (!open)
17
+ return;
18
+ const onPointerDown = (event) => {
19
+ if (!root.current?.contains(event.target))
20
+ setOpen(false);
21
+ };
22
+ const onKeyDown = (event) => {
23
+ if (event.key !== 'Escape')
24
+ return;
25
+ setOpen(false);
26
+ trigger.current?.focus();
27
+ };
28
+ document.addEventListener('pointerdown', onPointerDown);
29
+ document.addEventListener('keydown', onKeyDown);
30
+ return () => {
31
+ document.removeEventListener('pointerdown', onPointerDown);
32
+ document.removeEventListener('keydown', onKeyDown);
33
+ };
34
+ }, [open]);
35
+ const moveFocus = (event) => {
36
+ if (!open || !root.current)
37
+ return;
38
+ const rows = Array.from(root.current.querySelectorAll(ROWS));
39
+ const at = rows.indexOf(document.activeElement);
40
+ const next = { ArrowDown: at + 1, ArrowUp: at < 0 ? rows.length - 1 : at - 1, Home: 0, End: rows.length - 1 }[event.key];
41
+ if (next === undefined || rows.length === 0)
42
+ return;
43
+ event.preventDefault();
44
+ rows[(next + rows.length) % rows.length].focus();
45
+ };
46
+ const closeOnFocusLeave = (event) => {
47
+ const next = event.relatedTarget;
48
+ if (next && !event.currentTarget.contains(next))
49
+ setOpen(false);
50
+ };
51
+ // Focus stays with whatever the row does (a link navigates, a button acts). A radio row acts on the menu itself and is hidden once it
52
+ // closes, so focus goes back to the trigger.
53
+ const closeOnRow = (event) => {
54
+ const row = event.target.closest(ROWS);
55
+ if (!row)
56
+ return;
57
+ setOpen(false);
58
+ if (row.getAttribute('role') === 'menuitemradio')
59
+ trigger.current?.focus();
60
+ };
61
+ return {
62
+ open,
63
+ toggle: () => setOpen((wasOpen) => !wasOpen),
64
+ root,
65
+ trigger,
66
+ rootClassName: open ? 'launcher open' : 'launcher',
67
+ moveFocus,
68
+ closeOnFocusLeave,
69
+ closeOnRow,
70
+ };
71
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Runs `handler` when one of the key combos is pressed, the same way shell.js binds / and Cmd-K for a plain page. Combos are "/" or "mod+k", and `keys` is one or a list.
3
+ * The hook calls preventDefault, so a "/" is not typed into the box the handler has just focused. A combo with no "mod" is ignored while
4
+ * the person is typing in an input, a textarea, a select or an editable element, and one with "mod" works from anywhere. A key already handled
5
+ * elsewhere and one pressed in the middle of an IME composition are ignored. The latest handler is always the one called.
6
+ */
7
+ export declare function useShortcut(keys: string | readonly string[], handler: () => void): void;
@@ -0,0 +1,43 @@
1
+ import { useEffect, useRef } from 'react';
2
+ const TYPING = /^(INPUT|TEXTAREA|SELECT)$/;
3
+ function isTyping(target) {
4
+ const node = target;
5
+ return !!node && (TYPING.test(node.tagName) || node.isContentEditable === true);
6
+ }
7
+ // A combo is a key, optionally after "mod+". "mod" is Cmd or Ctrl, as shell.js accepts both on every platform. Alt never matches.
8
+ function parse(combo) {
9
+ const parts = combo.toLowerCase().split('+');
10
+ const key = parts.pop();
11
+ return { key, mod: parts.includes('mod') };
12
+ }
13
+ function matches({ key, mod }, event) {
14
+ return !event.altKey && mod === (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === key;
15
+ }
16
+ /**
17
+ * Runs `handler` when one of the key combos is pressed, the same way shell.js binds / and Cmd-K for a plain page. Combos are "/" or "mod+k", and `keys` is one or a list.
18
+ * The hook calls preventDefault, so a "/" is not typed into the box the handler has just focused. A combo with no "mod" is ignored while
19
+ * the person is typing in an input, a textarea, a select or an editable element, and one with "mod" works from anywhere. A key already handled
20
+ * elsewhere and one pressed in the middle of an IME composition are ignored. The latest handler is always the one called.
21
+ */
22
+ export function useShortcut(keys, handler) {
23
+ const latest = useRef(handler);
24
+ useEffect(() => {
25
+ latest.current = handler;
26
+ });
27
+ const spec = typeof keys === 'string' ? keys : keys.join(',');
28
+ useEffect(() => {
29
+ const combos = (typeof keys === 'string' ? [keys] : keys).map(parse);
30
+ const onKeyDown = (event) => {
31
+ if (event.defaultPrevented || event.isComposing)
32
+ return;
33
+ const combo = combos.find((one) => matches(one, event));
34
+ if (!combo || (!combo.mod && isTyping(event.target)))
35
+ return;
36
+ event.preventDefault();
37
+ latest.current();
38
+ };
39
+ document.addEventListener('keydown', onKeyDown);
40
+ return () => document.removeEventListener('keydown', onKeyDown);
41
+ // `spec` stands for `keys`: an inline array is a new object on every render, and its contents are what matter.
42
+ }, [spec]);
43
+ }
@@ -0,0 +1,8 @@
1
+ /** The words of a name, split on any whitespace. Extra spaces and an empty or missing name give no words. */
2
+ export declare function nameWords(name: string | null | undefined): string[];
3
+ /**
4
+ * The initials every surface shows: the first letter of the first word of the name and of the last word, upper
5
+ * case. One word gives one letter. With no name it is the first letter of the email. Same rule as the
6
+ * `user_initials` Jinja macro.
7
+ */
8
+ export declare function userInitials(name: string | null | undefined, email: string | null | undefined): string;
@@ -0,0 +1,18 @@
1
+ /** The words of a name, split on any whitespace. Extra spaces and an empty or missing name give no words. */
2
+ export function nameWords(name) {
3
+ return (name ?? '').split(/\s+/).filter(Boolean);
4
+ }
5
+ // Array.from walks code points, so a letter outside the basic plane is not cut in half.
6
+ const firstLetter = (text) => Array.from(text)[0] ?? '';
7
+ /**
8
+ * The initials every surface shows: the first letter of the first word of the name and of the last word, upper
9
+ * case. One word gives one letter. With no name it is the first letter of the email. Same rule as the
10
+ * `user_initials` Jinja macro.
11
+ */
12
+ export function userInitials(name, email) {
13
+ const words = nameWords(name);
14
+ if (words.length === 0)
15
+ return firstLetter(email ?? '').toUpperCase();
16
+ const ends = words.length === 1 ? words : [words[0], words[words.length - 1]];
17
+ return ends.map(firstLetter).join('').toUpperCase();
18
+ }
@@ -0,0 +1,36 @@
1
+ import type { ComponentPropsWithoutRef, ElementType, ReactNode } from 'react';
2
+ /** The props of one link row, as NavItem takes them: `{ href }`, or `{ component: Link, to }`. The element or router link must forward its ref and accept a string className. Pass the router's `Link`, never `NavLink`. */
3
+ export type UserMenuLinkProps<C extends ElementType = 'a'> = {
4
+ component?: C;
5
+ } & Omit<ComponentPropsWithoutRef<C>, 'component' | 'className' | 'children'>;
6
+ export type UserMenuItemProps<C extends ElementType = 'a'> = UserMenuLinkProps<C> & {
7
+ children: ReactNode;
8
+ };
9
+ /** One row of the user menu, for the app's own items. Renders a link, or the router link passed as `component`. */
10
+ export declare function UserMenuItem<C extends ElementType = 'a'>({ component, children, ...rest }: UserMenuItemProps<C>): JSX.Element;
11
+ /** Where Sign out goes: an address, or a handler for an app that clears local state first. Exactly one. */
12
+ export type UserMenuSignOut = {
13
+ href: string;
14
+ onSelect?: never;
15
+ } | {
16
+ onSelect: () => void;
17
+ href?: never;
18
+ };
19
+ export interface UserMenuProps<C extends ElementType = 'a'> {
20
+ email: string;
21
+ /** Full name. Without it the panel shows the email once and the initials come from the email. */
22
+ name?: string | null;
23
+ /** Picture. Without it, or when it fails to load, the avatar shows the initials on blue. */
24
+ avatarUrl?: string | null;
25
+ /** The Account settings row. Leave it out and the row is not rendered, for the page that would link to itself. */
26
+ accountSettings?: UserMenuLinkProps<C>;
27
+ signOut: UserMenuSignOut;
28
+ /** The app's own rows, as UserMenuItem elements. They render after Account settings and before Sign out. */
29
+ children?: ReactNode;
30
+ }
31
+ /**
32
+ * The avatar and account panel for the last slot of the shell cluster. Renders recipes/user-menu.html.
33
+ * Opens on click, and closes on Escape (focus returns to the avatar), on a press outside and on choosing a row.
34
+ * Arrow keys, Home and End move between rows. Because it closes on an outside press, opening another menu closes it.
35
+ */
36
+ export declare function UserMenu<C extends ElementType = 'a'>({ email, name, avatarUrl, accountSettings, signOut, children }: UserMenuProps<C>): JSX.Element;
@@ -0,0 +1,23 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Children, useState } from 'react';
3
+ import { useMenu } from './use-menu.js';
4
+ import { nameWords, userInitials } from './user-initials.js';
5
+ /** One row of the user menu, for the app's own items. Renders a link, or the router link passed as `component`. */
6
+ export function UserMenuItem({ component, children, ...rest }) {
7
+ const Component = component ?? 'a';
8
+ return (_jsx(Component, { ...rest, className: "launcher-item user-menu-item", role: "menuitem", children: children }));
9
+ }
10
+ /**
11
+ * The avatar and account panel for the last slot of the shell cluster. Renders recipes/user-menu.html.
12
+ * Opens on click, and closes on Escape (focus returns to the avatar), on a press outside and on choosing a row.
13
+ * Arrow keys, Home and End move between rows. Because it closes on an outside press, opening another menu closes it.
14
+ */
15
+ export function UserMenu({ email, name, avatarUrl, accountSettings, signOut, children }) {
16
+ const { open, toggle, root, trigger, rootClassName, moveFocus, closeOnFocusLeave, closeOnRow } = useMenu();
17
+ // Keyed by address so a new avatarUrl gets a fresh try after an earlier one failed.
18
+ const [failedUrl, setFailedUrl] = useState(null);
19
+ const fullName = nameWords(name).join(' ');
20
+ const initials = userInitials(name, email);
21
+ const hasRowsBeforeSignOut = accountSettings !== undefined || Children.toArray(children).length > 0;
22
+ return (_jsxs("div", { ref: root, className: rootClassName, onKeyDown: moveFocus, onBlur: closeOnFocusLeave, children: [_jsx("button", { ref: trigger, className: "user-menu-btn", type: "button", "aria-haspopup": "menu", "aria-expanded": open, "aria-label": "Open account menu", "data-initials": initials, onClick: toggle, children: avatarUrl && failedUrl !== avatarUrl ? (_jsx("img", { className: "avatar", src: avatarUrl, alt: "", onError: () => setFailedUrl(avatarUrl) })) : (_jsx("span", { className: "avatar", "aria-hidden": "true", children: initials })) }), _jsxs("div", { className: "launcher-panel user-menu-panel", role: "menu", onClick: closeOnRow, children: [_jsxs("div", { className: "user-menu-id", children: [_jsx("span", { className: "user-menu-name", children: fullName || email }), fullName ? _jsx("span", { className: "user-menu-email", children: email }) : null] }), _jsx("div", { className: "user-menu-sep", role: "separator" }), accountSettings ? _jsx(UserMenuItem, { ...accountSettings, children: "Account settings" }) : null, children, hasRowsBeforeSignOut ? _jsx("div", { className: "user-menu-sep", role: "separator" }) : null, signOut.onSelect ? (_jsx("button", { className: "launcher-item user-menu-item", type: "button", role: "menuitem", onClick: signOut.onSelect, children: "Sign out" })) : (_jsx("a", { className: "launcher-item user-menu-item", role: "menuitem", href: signOut.href, children: "Sign out" }))] })] }));
23
+ }