@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
package/README.md ADDED
@@ -0,0 +1,122 @@
1
+ # @fikar-ai/design-react
2
+
3
+ React components for the Fikar Ink design language. It provides `AppShell`, `NavItem`, `NavHeading`, `BrandLogo`, `AppLauncher`, `ThemeMenu`, `UserMenu`, `UserMenuItem`, `userInitials`, `SearchBox`, `SearchResults`, `FilterChip`, `EntityBadge`, `DetailPanel`, `DetailSection`, `ListRow`, `DaySeparator`, `StatusBadge`, `RelativeTime`, `formatRelative`, `formatExact`, `useShortcut` and `useAppShell`. `AppShell` renders the canonical app shell from `@fikar-ai/design` (`recipes/shell.html`), or the no-sidebar shell (`recipes/shell-no-sidebar.html`) with `sidebar={false}`, and owns the drawer, the desktop collapse and the rail tooltips.
4
+
5
+ The package ships no CSS. All styling comes from the class names in `@fikar-ai/design/components.css`, so import that too. It releases at the same version as `@fikar-ai/design`. What a version number promises, and how things are deprecated and removed, is in the "Versioning" and "Retiring something" sections of the root README (`@fikar-ai/design`).
6
+
7
+ Peer ranges: `@fikar-ai/design` `>=1.0.0 <2` (optional, because this package publishes after the CSS package), `react` and `react-dom` `^18.3.0`, `@radix-ui/react-tooltip` `>=1.1.6 <2`.
8
+
9
+ ```sh
10
+ npm install @fikar-ai/design-react @fikar-ai/design @radix-ui/react-tooltip react react-dom
11
+ ```
12
+
13
+ ```css
14
+ @import '@fikar-ai/design/tokens.css';
15
+ @import '@fikar-ai/design/components.css';
16
+ ```
17
+
18
+ ```tsx
19
+ import { AppShell, NavHeading, NavItem } from '@fikar-ai/design-react';
20
+ import { Link } from 'react-router-dom';
21
+
22
+ <AppShell
23
+ brand={<img src={logo} alt="" />}
24
+ brandName="FIKAR"
25
+ nav={
26
+ <>
27
+ <NavHeading>Workspace</NavHeading>
28
+ <NavItem component={Link} to="/" icon={<HomeIcon />} label="Home" active={pathname === '/'} />
29
+ <NavItem component={Link} to="/settings" icon={<GearIcon />} label="Settings" badge="New" active={pathname === '/settings'} />
30
+ </>
31
+ }
32
+ footer={<AccountBlock />}
33
+ headerActions={<><AppLauncher apps={apps} currentId="platform" /><ThemeMenu value={theme} onChange={setTheme} /><UserMenu email={user.email} signOut={{ href: signOutUrl }} /></>}
34
+ >
35
+ <Outlet />
36
+ </AppShell>
37
+ ```
38
+
39
+ ## API
40
+
41
+ `AppShell` props: `brand`, `brandName`, `navLabel` (default "Main"), `nav`, `navRail`, `navScroll`, `footer`, `headerStart`, `headerActions`, `children`, `fill` (default `false`), `sidebar` (default `true`), `collapsed`, `defaultCollapsed`, `onCollapsedChange`.
42
+
43
+ The props are a union on `sidebar`. With the sidebar (the default) `nav` is required. With `sidebar={false}` the shell is a top bar and the content, and `nav`, `navRail`, `navScroll`, `footer`, `navLabel`, `collapsed`, `defaultCollapsed` and `onCollapsedChange` are `never`, so passing one is a type error. `sidebar` must be the literal `true` or `false`. In that mode the component reads and writes no storage, renders no drawer, and `useAppShell()` returns `collapsed: false`, `drawerOpen: false`, `isRail: false` with setters that do nothing. `headerStart` is worth using there: it sits between the brand and the cluster, and scrolls sideways inside itself when the bar is short of room, so the page does not widen and no link is lost.
44
+
45
+ ```tsx
46
+ <AppShell sidebar={false} brand={<BrandLogo lightSrc={light} darkSrc={dark} />} brandName="FIKAR" headerStart={<AdminLinks />} headerActions={<Cluster />}>
47
+ <Outlet />
48
+ </AppShell>
49
+ ```
50
+
51
+ `footer` is optional. Without it `AppShell` renders no `.side-foot`, so there is no bare divider above the collapse control. `fill` makes the shell one viewport tall at every width with a padless content column, for a page that scrolls inside itself (a conversation with a docked composer).
52
+
53
+ `BrandLogo` props: `lightSrc`, `darkSrc` (both required strings), `alt` (default `""`, given to both images) and `height` (number of px, inline style on both, absent by default so the package's 18px applies). It renders `<img class="logo-light">` and `<img class="logo-dark">`; the package CSS shows the one that matches the theme (`.dark` or `[data-theme='dark']` on an ancestor), and the classes only take effect inside `.brand`, so use it in the `brand` slot. `brand` itself stays a free slot: if you render your own logo, put `logo-light` and `logo-dark` on the two elements, `<img>` or inline `<svg>`. The old wrapper spans are not needed.
54
+
55
+ `NavItem` props: `label`, `icon`, `badge`, `active`, `variant` (`'default' | 'back'`, default `'default'`), `component` (default `a`), plus every prop the component accepts except `className` and `children`. `variant="back"` adds `nav-item-back`, the quieter link that leads out of this surface back to where the person came from; put it first. There is no `className` passthrough on purpose (KAN-311). It always renders a plain string `className`. Pass the router's `Link` as `component` and compute `active` in the app; NavLink manages its own class and `aria-current`, which fights the prop. A click closes the drawer.
56
+
57
+ `NavHeading` takes children and renders a group heading between nav items.
58
+
59
+ `useAppShell()` returns `{ collapsed, setCollapsed, drawerOpen, setDrawerOpen, isRail }` for content inside the shell. `isRail` is true only while the rail is showing (collapsed and at least 1024px wide). `brandName` is separate from `brand` because the wordmark must sit inside `.brand` for the rail to hide it. `headerStart` fills the top bar left of the cluster, and `navScroll` makes a long nav scroll inside the sidebar.
60
+
61
+ The collapsed choice is stored in `localStorage` under `fikar.shell.collapsed` (`"1"` collapsed, `"0"` or absent open) unless you control it with `collapsed`. `@fikar-ai/design/shell.js` writes the same values since 0.8.0.
62
+
63
+ Also new in 0.8.0 in the CSS, no React API needed: `.rail-hidden` and `.rail-only` for content that shows only open or only in the rail (`.rail-hidden` also leaves the tab order and the accessibility tree, so a list of links can go in it), and a fix that keeps a closed drawer out of the tab order. Upgrading from 0.7: the footer identity rule now matches direct children of `.meta`, so a plan pill in a nested span no longer needs a forced colour; the top bar and main area may shrink below their content; and an omitted `footer` renders no `.side-foot`. Full guide: the "React apps" section of the `@fikar-ai/design` README.
64
+
65
+ ## Theme menu (since 1.2.0)
66
+
67
+ `ThemeMenu` is the sun or moon button and the panel of Light, Dark and System, for the second slot of the shell cluster. It renders `recipes/theme-menu.html` and needs `@fikar-ai/design` 1.2.0 or later for its CSS, so install both at the same version. It keeps no state and stores nothing. The app passes the theme it has and a handler, and applies and saves the choice in its own way.
68
+
69
+ ```tsx
70
+ import { ThemeMenu } from '@fikar-ai/design-react';
71
+
72
+ <ThemeMenu value={theme} onChange={(next) => { applyTheme(next); saveTheme(next); }} />
73
+ ```
74
+
75
+ `value` is `'light'`, `'dark'` or `'system'`, and the chosen row follows it. `onChange` is called with the row the person chose, unless it is the one already chosen. The sun or moon on the button comes from the theme on the root (`.dark` or `[data-theme='dark']`) in the CSS, so it is right on first paint and the component takes no theme for it. It opens on click and closes on Escape, on a press outside and on choosing a row, and the arrow keys move between the rows. The markup and behaviour are documented under "Theme menu" in the `@fikar-ai/design` README.
76
+
77
+ ## User menu (since 1.1.0)
78
+
79
+ `UserMenu` is the avatar and account panel for the last slot of the shell cluster. It renders `recipes/user-menu.html` and needs `@fikar-ai/design` 1.1.0 or later for its CSS, so install both at the same version. It opens on click and closes on Escape, on a press outside and on choosing a row, with no extra dependency.
80
+
81
+ ```tsx
82
+ import { UserMenu, UserMenuItem } from '@fikar-ai/design-react';
83
+ import { Link } from 'react-router-dom';
84
+
85
+ <UserMenu
86
+ name={user.name}
87
+ email={user.email}
88
+ avatarUrl={user.avatarUrl}
89
+ accountSettings={{ component: Link, to: '/account' }}
90
+ signOut={{ onSelect: () => { clearLocalState(); signOut(); } }}
91
+ >
92
+ <UserMenuItem component={Link} to="/workspace">Workspace settings</UserMenuItem>
93
+ </UserMenu>
94
+ ```
95
+
96
+ `signOut` takes `{ href }` or `{ onSelect }`. Leave out `accountSettings` to omit that row. `userInitials(name, email)` is the rule for the initials. The props, the markup and the behaviour are documented under "User menu" in the `@fikar-ai/design` README.
97
+
98
+ ## App launcher (since 1.2.0)
99
+
100
+ `AppLauncher` is the grid button and app list for the first slot of the shell cluster. It renders `recipes/launcher.html` and needs `@fikar-ai/design` 1.2.0 or later for the focus ring on its button, so install both at the same version. It opens on click and closes on Escape, on a press outside and on choosing an app, with no extra dependency. It fetches nothing, and it renders nothing when `apps` is empty.
101
+
102
+ ```tsx
103
+ import { AppLauncher } from '@fikar-ai/design-react';
104
+
105
+ <AppLauncher
106
+ apps={[
107
+ { id: 'chat', name: 'Chat', url: 'https://chat.fikar.ai', hint: "Ask across your organization's memory" },
108
+ { id: 'platform', name: 'Platform', url: 'https://platform.fikar.ai' },
109
+ ]}
110
+ currentId="chat"
111
+ />
112
+ ```
113
+
114
+ Each app has `id`, `name`, `url` and an optional `hint`. The launcher is a grid of tiles, a glyph picked by `id` over the name, and the hint is not shown. The app whose `id` is `currentId` gets `.current` and `aria-current="page"`. The rows are plain links, because they leave the app. The props, the markup and the behaviour are documented under "App launcher recipe" in the `@fikar-ai/design` README.
115
+
116
+ ## Search
117
+
118
+ `SearchBox` (a controlled search input with an icon, a clear button and a shortcut hint, in a `'bar'` or `'panel'` size), `SearchResults` (the typeahead listbox, which hears ArrowDown, ArrowUp, Enter and Escape on the input), `FilterChip` (a toggle pill with `pressed` and `onToggle`), `EntityBadge` (`type` is `'PERSON'`, `'ORGANIZATION'`, `'EVENT'` or `'GEO'`) and `useShortcut(keys, handler)` (`'/'` and `'mod+k'`, the React counterpart of what `shell.js` binds). Each renders a recipe from `@fikar-ai/design` (`search.html`, `search-results.html`, `filter-chip.html`, `entity-badge.html`) and adds no styling of its own. `SearchBox` forwards its ref to the input and never renders `data-fk-search`, so the app registers the shortcut with `useShortcut`. The props, an example and the drift check rule are in the root README under "Search".
119
+
120
+ ## Detail panel, list rows, status badges and relative time
121
+
122
+ `DetailPanel` (a type line with a dot, a serif title, a description and an optional close button) with `DetailSection` (a heading over rows or chips), `ListRow` (a title and a muted line, a button by default and a link with `component`) with `DaySeparator`, `StatusBadge` (`status` is `'success'`, `'warning'`, `'destructive'` or `'muted'`, and the word is the children) and `RelativeTime` (a `<time>` that says "4 h ago" and keeps the exact time in its title, refreshing itself while mounted). `formatRelative(value, now?)` and `formatExact(value, now?)` are the same wording as `FikarShell.formatRelative` in `shell.js`. Each component renders a recipe from `@fikar-ai/design` (`detail-panel.html`, `list-row.html`, `status-badge.html`, `time.html`) and adds no styling of its own. The props, the wording rules, the placement of the panel and what each app changes to adopt them are in the root README under "Detail panel, list row, status badge and relative time".
@@ -0,0 +1,2 @@
1
+ /** The inner markup of each app's launcher glyph, keyed by app id. Each is drawn in the shared 24px, 1.5 stroke frame. */
2
+ export declare const APP_ICONS: Readonly<Record<string, string>>;
@@ -0,0 +1,11 @@
1
+ // Generated by scripts/build-app-icons.mjs from brand/apps/*.svg. Do not edit; change the SVG and run the script.
2
+ /** The inner markup of each app's launcher glyph, keyed by app id. Each is drawn in the shared 24px, 1.5 stroke frame. */
3
+ export const APP_ICONS = {
4
+ "account": "<circle cx=\"12\" cy=\"8\" r=\"5\"/><path d=\"M20 21a8 8 0 0 0-16 0\"/>",
5
+ "chat": "<path d=\"M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z\"/>",
6
+ "cost-calculator": "<rect width=\"16\" height=\"20\" x=\"4\" y=\"2\" rx=\"2\"/><line x1=\"8\" x2=\"16\" y1=\"6\" y2=\"6\"/><line x1=\"16\" x2=\"16\" y1=\"14\" y2=\"18\"/><path d=\"M16 10h.01\"/><path d=\"M12 10h.01\"/><path d=\"M8 10h.01\"/><path d=\"M12 14h.01\"/><path d=\"M8 14h.01\"/><path d=\"M12 18h.01\"/><path d=\"M8 18h.01\"/>",
7
+ "crs": "<circle cx=\"12\" cy=\"4.5\" r=\"2.5\"/><path d=\"m10.2 6.3-3.9 3.9\"/><circle cx=\"4.5\" cy=\"12\" r=\"2.5\"/><path d=\"M7 12h10\"/><circle cx=\"19.5\" cy=\"12\" r=\"2.5\"/><path d=\"m13.8 17.7 3.9-3.9\"/><circle cx=\"12\" cy=\"19.5\" r=\"2.5\"/>",
8
+ "dictionary": "<path d=\"M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20\"/><path d=\"m8 13 4-7 4 7\"/><path d=\"M9.1 11h5.7\"/>",
9
+ "platform": "<line x1=\"21\" x2=\"14\" y1=\"4\" y2=\"4\"/><line x1=\"10\" x2=\"3\" y1=\"4\" y2=\"4\"/><line x1=\"21\" x2=\"12\" y1=\"12\" y2=\"12\"/><line x1=\"8\" x2=\"3\" y1=\"12\" y2=\"12\"/><line x1=\"21\" x2=\"16\" y1=\"20\" y2=\"20\"/><line x1=\"12\" x2=\"3\" y1=\"20\" y2=\"20\"/><line x1=\"14\" x2=\"14\" y1=\"2\" y2=\"6\"/><line x1=\"8\" x2=\"8\" y1=\"10\" y2=\"14\"/><line x1=\"16\" x2=\"16\" y1=\"18\" y2=\"22\"/>",
10
+ "super-admin": "<path d=\"M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z\"/><path d=\"M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2\"/><path d=\"M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2\"/><path d=\"M10 6h4\"/><path d=\"M10 10h4\"/><path d=\"M10 14h4\"/><path d=\"M10 18h4\"/>",
11
+ };
@@ -0,0 +1,20 @@
1
+ export interface LauncherApp {
2
+ id: string;
3
+ name: string;
4
+ url: string;
5
+ /** Accepted so hosts keep passing it. The grid does not show it. */
6
+ hint?: string;
7
+ }
8
+ export interface AppLauncherProps {
9
+ /** The apps the person may open, already filtered and ordered by the app. The component fetches nothing. */
10
+ apps: readonly LauncherApp[];
11
+ /** The `id` of the app the person is in. That row is marked with `.current` and `aria-current="page"`. */
12
+ currentId?: string;
13
+ }
14
+ /**
15
+ * The app launcher for the first slot of the shell cluster. Renders recipes/launcher.html, or nothing when there are no apps.
16
+ * The apps are a grid of tiles, a glyph over the name.
17
+ * Opens on click and closes on Escape (focus returns to the button), on a press outside and on choosing an app.
18
+ * Arrow keys, Home and End move between tiles. The rows are plain links, because they leave the app.
19
+ */
20
+ export declare function AppLauncher({ apps, currentId }: AppLauncherProps): JSX.Element | null;
@@ -0,0 +1,26 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { APP_ICONS } from './app-icons.js';
3
+ import { useMenu } from './use-menu.js';
4
+ const GridIcon = () => (_jsxs("svg", { viewBox: "0 0 24 24", fill: "currentColor", "aria-hidden": "true", children: [_jsx("circle", { cx: "5", cy: "5", r: "2" }), _jsx("circle", { cx: "12", cy: "5", r: "2" }), _jsx("circle", { cx: "19", cy: "5", r: "2" }), _jsx("circle", { cx: "5", cy: "12", r: "2" }), _jsx("circle", { cx: "12", cy: "12", r: "2" }), _jsx("circle", { cx: "19", cy: "12", r: "2" }), _jsx("circle", { cx: "5", cy: "19", r: "2" }), _jsx("circle", { cx: "12", cy: "19", r: "2" }), _jsx("circle", { cx: "19", cy: "19", r: "2" })] }));
5
+ // The glyph is the package's, picked by app id. An id it does not know gets the first letter of the name, in the same tile.
6
+ const AppGlyph = ({ app }) => {
7
+ const body = Object.prototype.hasOwnProperty.call(APP_ICONS, app.id) ? APP_ICONS[app.id] : undefined;
8
+ return (_jsx("span", { className: "launcher-tile", "aria-hidden": "true", children: body === undefined ? (Array.from(app.name)[0]?.toUpperCase()) : (_jsx("svg", { className: "app-icon", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true",
9
+ // The body is a constant generated from brand/apps/*.svg, never app data.
10
+ dangerouslySetInnerHTML: { __html: body } })) }));
11
+ };
12
+ /**
13
+ * The app launcher for the first slot of the shell cluster. Renders recipes/launcher.html, or nothing when there are no apps.
14
+ * The apps are a grid of tiles, a glyph over the name.
15
+ * Opens on click and closes on Escape (focus returns to the button), on a press outside and on choosing an app.
16
+ * Arrow keys, Home and End move between tiles. The rows are plain links, because they leave the app.
17
+ */
18
+ export function AppLauncher({ apps, currentId }) {
19
+ const { open, toggle, root, trigger, rootClassName, moveFocus, closeOnFocusLeave, closeOnRow } = useMenu();
20
+ if (apps.length === 0)
21
+ return null;
22
+ return (_jsxs("div", { ref: root, className: rootClassName, onKeyDown: moveFocus, onBlur: closeOnFocusLeave, children: [_jsx("button", { ref: trigger, className: "launcher-btn", type: "button", "aria-haspopup": "menu", "aria-expanded": open, "aria-label": "Switch app", onClick: toggle, children: _jsx(GridIcon, {}) }), _jsx("div", { className: "launcher-panel launcher-apps", role: "menu", onClick: closeOnRow, children: apps.map((app) => {
23
+ const current = app.id === currentId;
24
+ return (_jsxs("a", { className: current ? 'launcher-item current' : 'launcher-item', role: "menuitem", href: app.url, "aria-current": current ? 'page' : undefined, children: [_jsx(AppGlyph, { app: app }), app.name] }, app.id));
25
+ }) })] }));
26
+ }
@@ -0,0 +1,47 @@
1
+ import type { ReactNode } from 'react';
2
+ interface SharedShellProps {
3
+ /** The logo, usually a BrandLogo or an img from @fikar-ai/design/brand/. Shown in the sidebar and the top bar, or in the top bar alone without a sidebar. */
4
+ brand?: ReactNode;
5
+ /** The wordmark text. Hidden by the package CSS in the rail. */
6
+ brandName?: string;
7
+ /** Top bar content to the left of the cluster, such as a search box or a page title. Rendered only when given. Without a sidebar it scrolls sideways inside itself when the bar is short of room. */
8
+ headerStart?: ReactNode;
9
+ /** Top right cluster: launcher, theme control, avatar, in that order. */
10
+ headerActions?: ReactNode;
11
+ children?: ReactNode;
12
+ /** Fill mode: the shell is one viewport tall at every width and the content area is a padless flex column, for a page that scrolls inside itself. Default false. */
13
+ fill?: boolean;
14
+ }
15
+ interface SidebarShellProps extends SharedShellProps {
16
+ /** Default true. Pass `false` for a top bar with no sidebar. */
17
+ sidebar?: true;
18
+ /** Accessible name of the sidebar navigation. Default "Main". */
19
+ navLabel?: string;
20
+ /** The sidebar navigation. Shown when the sidebar is open, and in the rail when navRail is omitted. */
21
+ nav: ReactNode;
22
+ /** The sidebar navigation for the desktop rail. Use it when nav is not a list of links. */
23
+ navRail?: ReactNode;
24
+ /** Makes the nav scroll inside the sidebar, for long lists. The foot and the collapse control stay in view. */
25
+ navScroll?: boolean;
26
+ /** Content of the sidebar foot, usually the signed-in person. The foot and its divider are not rendered when this is empty. */
27
+ footer?: ReactNode;
28
+ /** Controlled collapsed state. When set, storage is not read or written. */
29
+ collapsed?: boolean;
30
+ /** Initial state when nothing is stored. Default false. */
31
+ defaultCollapsed?: boolean;
32
+ onCollapsedChange?: (collapsed: boolean) => void;
33
+ }
34
+ interface TopbarShellProps extends SharedShellProps {
35
+ sidebar: false;
36
+ navLabel?: never;
37
+ nav?: never;
38
+ navRail?: never;
39
+ navScroll?: never;
40
+ footer?: never;
41
+ collapsed?: never;
42
+ defaultCollapsed?: never;
43
+ onCollapsedChange?: never;
44
+ }
45
+ export type AppShellProps = SidebarShellProps | TopbarShellProps;
46
+ export declare function AppShell(props: AppShellProps): JSX.Element;
47
+ export {};
@@ -0,0 +1,83 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
3
+ import * as Tooltip from '@radix-ui/react-tooltip';
4
+ import { AppShellContext } from './context.js';
5
+ import { readCollapsed, writeCollapsed } from './storage.js';
6
+ const DESKTOP_QUERY = '(min-width: 1024px)';
7
+ // useLayoutEffect warns during server rendering; the effect body only matters in the browser.
8
+ const useIsoLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
9
+ const MenuIcon = () => (_jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", "aria-hidden": "true", children: _jsx("path", { d: "M4 6h16M4 12h16M4 18h16" }) }));
10
+ const ChevronIcon = () => (_jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: _jsx("path", { d: "M15 6l-6 6 6 6" }) }));
11
+ function SidebarShell({ brand, brandName, navLabel = 'Main', nav, navRail, navScroll = false, footer, headerStart, headerActions, children, fill = false, collapsed: collapsedProp, defaultCollapsed = false, onCollapsedChange, }) {
12
+ const isControlled = collapsedProp !== undefined;
13
+ const [innerCollapsed, setInnerCollapsed] = useState(defaultCollapsed);
14
+ const collapsed = isControlled ? collapsedProp : innerCollapsed;
15
+ const [drawerOpen, setDrawerOpen] = useState(false);
16
+ const [desktop, setDesktop] = useState(false);
17
+ const menuRef = useRef(null);
18
+ // Storage is read after the first render so server and client markup match.
19
+ useIsoLayoutEffect(() => {
20
+ if (isControlled)
21
+ return;
22
+ const stored = readCollapsed();
23
+ if (stored !== null)
24
+ setInnerCollapsed(stored);
25
+ // Mount only: later changes come from the toggle, not from storage.
26
+ }, []);
27
+ useIsoLayoutEffect(() => {
28
+ if (typeof window.matchMedia !== 'function')
29
+ return;
30
+ const query = window.matchMedia(DESKTOP_QUERY);
31
+ const sync = () => {
32
+ setDesktop(query.matches);
33
+ if (query.matches)
34
+ setDrawerOpen(false);
35
+ };
36
+ sync();
37
+ query.addEventListener('change', sync);
38
+ return () => query.removeEventListener('change', sync);
39
+ }, []);
40
+ const setCollapsed = useCallback((next) => {
41
+ if (!isControlled) {
42
+ setInnerCollapsed(next);
43
+ writeCollapsed(next);
44
+ }
45
+ onCollapsedChange?.(next);
46
+ }, [isControlled, onCollapsedChange]);
47
+ const closeDrawer = useCallback(() => {
48
+ setDrawerOpen(false);
49
+ menuRef.current?.focus();
50
+ }, []);
51
+ useEffect(() => {
52
+ if (!drawerOpen)
53
+ return;
54
+ const onKeyDown = (event) => {
55
+ if (event.key === 'Escape')
56
+ closeDrawer();
57
+ };
58
+ document.addEventListener('keydown', onKeyDown);
59
+ return () => document.removeEventListener('keydown', onKeyDown);
60
+ }, [drawerOpen, closeDrawer]);
61
+ const isRail = collapsed && desktop;
62
+ const state = useMemo(() => ({ collapsed, setCollapsed, drawerOpen, setDrawerOpen, isRail }), [collapsed, setCollapsed, drawerOpen, isRail]);
63
+ const shellClass = ['shell', 'with-topbar', fill && 'is-fill', drawerOpen && 'nav-open', collapsed && 'is-collapsed']
64
+ .filter(Boolean)
65
+ .join(' ');
66
+ const brandBlock = (_jsxs(_Fragment, { children: [brand, brandName ? _jsx("span", { className: "word", children: brandName }) : null] }));
67
+ return (_jsx(AppShellContext.Provider, { value: state, children: _jsx(Tooltip.Provider, { delayDuration: 300, children: _jsxs("div", { className: shellClass, id: "shell", children: [_jsxs("aside", { className: "sidebar", id: "sidebar", children: [_jsxs("div", { className: "brand", children: [brandBlock, _jsx("button", { className: "shell-close iconbtn", type: "button", "aria-label": "Close navigation", onClick: closeDrawer, children: "\u00D7" })] }), _jsx("nav", { className: navScroll ? 'side is-scroll' : 'side', "aria-label": navLabel, children: isRail && navRail !== undefined ? navRail : nav }), footer ? _jsx("div", { className: "side-foot", children: footer }) : null, _jsxs("button", { className: "shell-collapse", type: "button", "aria-controls": "sidebar", "aria-pressed": collapsed, title: collapsed ? 'Expand sidebar' : 'Collapse sidebar', onClick: () => setCollapsed(!collapsed), children: [_jsx(ChevronIcon, {}), _jsx("span", { className: "nav-label", children: "Collapse" })] })] }), _jsxs("header", { className: "shell-topbar", children: [_jsx("button", { ref: menuRef, className: "shell-menu", type: "button", "aria-label": "Open navigation", "aria-controls": "sidebar", "aria-expanded": drawerOpen, onClick: () => setDrawerOpen(true), children: _jsx(MenuIcon, {}) }), _jsx("div", { className: "brand", children: brandBlock }), headerStart ? _jsx("div", { className: "shell-topbar-start", children: headerStart }) : null, _jsx("div", { className: "cluster", children: headerActions })] }), _jsx("div", { className: "shell-scrim", hidden: !drawerOpen, onClick: closeDrawer }), _jsx("main", { className: "main", children: _jsx("div", { className: "content", children: children }) })] }) }) }));
68
+ }
69
+ // No drawer, no rail, no stored choice: the state is fixed, so slot content that reads it
70
+ // gets the answer for an open, full-width layout, and its setters do nothing.
71
+ const NO_SIDEBAR_STATE = {
72
+ collapsed: false,
73
+ setCollapsed: () => { },
74
+ drawerOpen: false,
75
+ setDrawerOpen: () => { },
76
+ isRail: false,
77
+ };
78
+ function TopbarShell({ brand, brandName, headerStart, headerActions, children, fill = false }) {
79
+ return (_jsx(AppShellContext.Provider, { value: NO_SIDEBAR_STATE, children: _jsxs("div", { className: ['shell', 'with-topbar', 'no-sidebar', fill && 'is-fill'].filter(Boolean).join(' '), id: "shell", children: [_jsxs("header", { className: "shell-topbar", children: [_jsxs("div", { className: "brand", children: [brand, brandName ? _jsx("span", { className: "word", children: brandName }) : null] }), headerStart ? _jsx("div", { className: "shell-topbar-start", children: headerStart }) : null, _jsx("div", { className: "cluster", children: headerActions })] }), _jsx("main", { className: "main", children: _jsx("div", { className: "content", children: children }) })] }) }));
80
+ }
81
+ export function AppShell(props) {
82
+ return props.sidebar === false ? _jsx(TopbarShell, { ...props }) : _jsx(SidebarShell, { ...props });
83
+ }
@@ -0,0 +1,15 @@
1
+ export interface BrandLogoProps {
2
+ /** The logo for the light theme, usually @fikar-ai/design/brand/fikar-logo.svg. */
3
+ lightSrc: string;
4
+ /** The logo for the dark theme, usually @fikar-ai/design/brand/fikar-logo-reversed.svg. */
5
+ darkSrc: string;
6
+ /** Accessible name, given to both images. Only one is ever displayed. Default "", for a logo next to the wordmark. */
7
+ alt?: string;
8
+ /** Rendered height in px. Default is the package's 18px. */
9
+ height?: number;
10
+ }
11
+ /**
12
+ * Both logo variants as images. The package CSS shows the one that matches the theme, so it belongs in the
13
+ * shell's `brand` slot: the selectors are `.brand .logo-light` and `.brand .logo-dark`.
14
+ */
15
+ export declare function BrandLogo({ lightSrc, darkSrc, alt, height }: BrandLogoProps): JSX.Element;
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Both logo variants as images. The package CSS shows the one that matches the theme, so it belongs in the
4
+ * shell's `brand` slot: the selectors are `.brand .logo-light` and `.brand .logo-dark`.
5
+ */
6
+ export function BrandLogo({ lightSrc, darkSrc, alt = '', height }) {
7
+ const style = height === undefined ? undefined : { height };
8
+ return (_jsxs(_Fragment, { children: [_jsx("img", { className: "logo-light", src: lightSrc, alt: alt, style: style }), _jsx("img", { className: "logo-dark", src: darkSrc, alt: alt, style: style })] }));
9
+ }
@@ -0,0 +1,12 @@
1
+ export interface AppShellState {
2
+ /** True when the desktop rail is the chosen state. It has no visual effect below 1024px. */
3
+ collapsed: boolean;
4
+ setCollapsed: (collapsed: boolean) => void;
5
+ /** True while the drawer below 1024px is open. */
6
+ drawerOpen: boolean;
7
+ setDrawerOpen: (open: boolean) => void;
8
+ /** True only while the rail is actually showing: collapsed and at least 1024px wide. */
9
+ isRail: boolean;
10
+ }
11
+ export declare const AppShellContext: import("react").Context<AppShellState | null>;
12
+ export declare function useAppShell(): AppShellState;
@@ -0,0 +1,8 @@
1
+ import { createContext, useContext } from 'react';
2
+ export const AppShellContext = createContext(null);
3
+ export function useAppShell() {
4
+ const state = useContext(AppShellContext);
5
+ if (!state)
6
+ throw new Error('useAppShell must be used inside <AppShell>');
7
+ return state;
8
+ }
@@ -0,0 +1,26 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { EntityType } from './entity-badge.js';
3
+ export interface DetailPanelProps {
4
+ /** The type line, in words such as "Person". It is drawn in capitals. */
5
+ type: string;
6
+ /** The colour of the dot before the type line, as one of the entity types. Without it the dot is muted. */
7
+ dot?: EntityType;
8
+ title: ReactNode;
9
+ description?: ReactNode;
10
+ /** Renders the close button, and is called when it is pressed. Without it the panel has no close button. */
11
+ onClose?: () => void;
12
+ /** `DetailSection`s, or anything else that belongs in the panel. */
13
+ children?: ReactNode;
14
+ }
15
+ export interface DetailSectionProps {
16
+ heading: ReactNode;
17
+ /** The rows (`.detail-row`) and chips (`.detail-chips`) of the section, as the README shows them. */
18
+ children: ReactNode;
19
+ }
20
+ /** A section of a DetailPanel: an upper case heading over its rows or chips. */
21
+ export declare function DetailSection({ heading, children }: DetailSectionProps): JSX.Element;
22
+ /**
23
+ * The panel that describes one thing, from recipes/detail-panel.html. It is the surface only. The page or the app places it, and it fills its container below
24
+ * 768px. The title names the panel for assistive technology. The dot colours come from the --entity-* roles, so they follow the theme.
25
+ */
26
+ export declare function DetailPanel({ type, dot, title, description, onClose, children }: DetailPanelProps): JSX.Element;
@@ -0,0 +1,14 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId } from 'react';
3
+ /** A section of a DetailPanel: an upper case heading over its rows or chips. */
4
+ export function DetailSection({ heading, children }) {
5
+ return (_jsxs("section", { className: "detail-section", children: [_jsx("h3", { className: "detail-heading", children: heading }), children] }));
6
+ }
7
+ /**
8
+ * The panel that describes one thing, from recipes/detail-panel.html. It is the surface only. The page or the app places it, and it fills its container below
9
+ * 768px. The title names the panel for assistive technology. The dot colours come from the --entity-* roles, so they follow the theme.
10
+ */
11
+ export function DetailPanel({ type, dot, title, description, onClose, children }) {
12
+ const titleId = useId();
13
+ return (_jsxs("aside", { className: "detail-panel", "aria-labelledby": titleId, children: [onClose ? (_jsx("button", { className: "detail-close", type: "button", "aria-label": "Close", onClick: onClose, 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, _jsxs("p", { className: "detail-type", children: [_jsx("i", { className: dot ? `detail-dot detail-dot-${dot.toLowerCase()}` : 'detail-dot', "aria-hidden": "true" }), type] }), _jsx("h2", { className: "detail-title", id: titleId, children: title }), description ? _jsx("p", { className: "detail-desc", children: description }) : null, children] }));
14
+ }
@@ -0,0 +1,7 @@
1
+ export type EntityType = 'PERSON' | 'ORGANIZATION' | 'EVENT' | 'GEO';
2
+ export interface EntityBadgeProps {
3
+ /** The entity type as the knowledge graph names it, in upper case. */
4
+ type: EntityType;
5
+ }
6
+ /** The badge for one of the four entity types. Renders a span from recipes/entity-badge.html, coloured by the --entity-* roles. */
7
+ export declare function EntityBadge({ type }: EntityBadgeProps): JSX.Element;
@@ -0,0 +1,12 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ const BADGES = {
3
+ PERSON: { className: 'badge b-person', label: 'Person' },
4
+ ORGANIZATION: { className: 'badge b-organization', label: 'Organization' },
5
+ EVENT: { className: 'badge b-event', label: 'Event' },
6
+ GEO: { className: 'badge b-geo', label: 'Geo' },
7
+ };
8
+ /** The badge for one of the four entity types. Renders a span from recipes/entity-badge.html, coloured by the --entity-* roles. */
9
+ export function EntityBadge({ type }) {
10
+ const { className, label } = BADGES[type];
11
+ return _jsx("span", { className: className, children: label });
12
+ }
@@ -0,0 +1,10 @@
1
+ import type { ReactNode } from 'react';
2
+ export interface FilterChipProps {
3
+ /** Whether the filter is on. It is the chip's only state, so the chip follows this. */
4
+ pressed: boolean;
5
+ /** Called with the state the person asked for, the opposite of `pressed`. */
6
+ onToggle: (pressed: boolean) => void;
7
+ children: ReactNode;
8
+ }
9
+ /** A filter chip: a toggle button styled as a pill. Renders a button from recipes/filter-chip.html, and `aria-pressed` is what the CSS draws. */
10
+ export declare function FilterChip({ pressed, onToggle, children }: FilterChipProps): JSX.Element;
@@ -0,0 +1,5 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /** A filter chip: a toggle button styled as a pill. Renders a button from recipes/filter-chip.html, and `aria-pressed` is what the CSS draws. */
3
+ export function FilterChip({ pressed, onToggle, children }) {
4
+ return (_jsx("button", { className: "pill p-toggle", type: "button", "aria-pressed": pressed, onClick: () => onToggle(!pressed), children: children }));
5
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The moment as "22 Sep, 08:16": day, month, then the time on a 24 hour clock, in the reader's time zone. From a year away, in the past or ahead, the year is
3
+ * there too ("22 Sep 2025, 08:16"). The month names are written out because Intl gives "Sept" for September in en-GB. A value that is not a date gives an empty string.
4
+ */
5
+ export declare function formatExact(value: Date | string, now?: Date): string;
6
+ /**
7
+ * "just now" under a minute, "4 min ago" under an hour and "4 h ago" under a day, then formatExact. A moment up to a minute ahead is "just now", since a clock a
8
+ * few seconds fast is common, and one further ahead is a date and not an age, so it is given exactly. `now` is for tests and defaults to the current time.
9
+ */
10
+ export declare function formatRelative(value: Date | string, now?: Date): string;
@@ -0,0 +1,35 @@
1
+ // The one wording for a moment, the same as shell.js gives a plain page (FikarShell.formatRelative). Both are tested against test/fixtures/time-rules.json.
2
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
3
+ const MINUTE = 60000;
4
+ const HOUR = 60 * MINUTE;
5
+ const DAY = 24 * HOUR;
6
+ const YEAR = 365 * DAY;
7
+ const two = (n) => String(n).padStart(2, '0');
8
+ /**
9
+ * The moment as "22 Sep, 08:16": day, month, then the time on a 24 hour clock, in the reader's time zone. From a year away, in the past or ahead, the year is
10
+ * there too ("22 Sep 2025, 08:16"). The month names are written out because Intl gives "Sept" for September in en-GB. A value that is not a date gives an empty string.
11
+ */
12
+ export function formatExact(value, now = new Date()) {
13
+ const date = new Date(value);
14
+ if (Number.isNaN(date.getTime()))
15
+ return '';
16
+ const year = Math.abs(now.getTime() - date.getTime()) >= YEAR ? ` ${date.getFullYear()}` : '';
17
+ return `${date.getDate()} ${MONTHS[date.getMonth()]}${year}, ${two(date.getHours())}:${two(date.getMinutes())}`;
18
+ }
19
+ /**
20
+ * "just now" under a minute, "4 min ago" under an hour and "4 h ago" under a day, then formatExact. A moment up to a minute ahead is "just now", since a clock a
21
+ * few seconds fast is common, and one further ahead is a date and not an age, so it is given exactly. `now` is for tests and defaults to the current time.
22
+ */
23
+ export function formatRelative(value, now = new Date()) {
24
+ const time = new Date(value).getTime();
25
+ if (Number.isNaN(time))
26
+ return '';
27
+ const age = now.getTime() - time;
28
+ if (age < -MINUTE || age >= DAY)
29
+ return formatExact(value, now);
30
+ if (age < MINUTE)
31
+ return 'just now';
32
+ if (age < HOUR)
33
+ return `${Math.floor(age / MINUTE)} min ago`;
34
+ return `${Math.floor(age / HOUR)} h ago`;
35
+ }
@@ -0,0 +1,33 @@
1
+ export { AppShell } from './app-shell.js';
2
+ export type { AppShellProps } from './app-shell.js';
3
+ export { BrandLogo } from './brand-logo.js';
4
+ export type { BrandLogoProps } from './brand-logo.js';
5
+ export { NavItem, NavHeading } from './nav-item.js';
6
+ export type { NavItemProps } from './nav-item.js';
7
+ export { useAppShell } from './context.js';
8
+ export type { AppShellState } from './context.js';
9
+ export { AppLauncher } from './app-launcher.js';
10
+ export type { AppLauncherProps, LauncherApp } from './app-launcher.js';
11
+ export { ThemeMenu } from './theme-menu.js';
12
+ export type { ThemeMenuProps, ThemeChoice } from './theme-menu.js';
13
+ export { UserMenu, UserMenuItem } from './user-menu.js';
14
+ export type { UserMenuProps, UserMenuItemProps, UserMenuLinkProps, UserMenuSignOut } from './user-menu.js';
15
+ export { userInitials } from './user-initials.js';
16
+ export { SearchBox } from './search-box.js';
17
+ export type { SearchBoxProps } from './search-box.js';
18
+ export { SearchResults } from './search-results.js';
19
+ export type { SearchResultsProps, SearchResultItem } from './search-results.js';
20
+ export { FilterChip } from './filter-chip.js';
21
+ export type { FilterChipProps } from './filter-chip.js';
22
+ export { EntityBadge } from './entity-badge.js';
23
+ export type { EntityBadgeProps, EntityType } from './entity-badge.js';
24
+ export { useShortcut } from './use-shortcut.js';
25
+ export { DetailPanel, DetailSection } from './detail-panel.js';
26
+ export type { DetailPanelProps, DetailSectionProps } from './detail-panel.js';
27
+ export { ListRow, DaySeparator } from './list-row.js';
28
+ export type { ListRowProps } from './list-row.js';
29
+ export { StatusBadge } from './status-badge.js';
30
+ export type { StatusBadgeProps } from './status-badge.js';
31
+ export { RelativeTime } from './relative-time.js';
32
+ export type { RelativeTimeProps } from './relative-time.js';
33
+ export { formatRelative, formatExact } from './format-time.js';
package/dist/index.js ADDED
@@ -0,0 +1,18 @@
1
+ export { AppShell } from './app-shell.js';
2
+ export { BrandLogo } from './brand-logo.js';
3
+ export { NavItem, NavHeading } from './nav-item.js';
4
+ export { useAppShell } from './context.js';
5
+ export { AppLauncher } from './app-launcher.js';
6
+ export { ThemeMenu } from './theme-menu.js';
7
+ export { UserMenu, UserMenuItem } from './user-menu.js';
8
+ export { userInitials } from './user-initials.js';
9
+ export { SearchBox } from './search-box.js';
10
+ export { SearchResults } from './search-results.js';
11
+ export { FilterChip } from './filter-chip.js';
12
+ export { EntityBadge } from './entity-badge.js';
13
+ export { useShortcut } from './use-shortcut.js';
14
+ export { DetailPanel, DetailSection } from './detail-panel.js';
15
+ export { ListRow, DaySeparator } from './list-row.js';
16
+ export { StatusBadge } from './status-badge.js';
17
+ export { RelativeTime } from './relative-time.js';
18
+ export { formatRelative, formatExact } from './format-time.js';
@@ -0,0 +1,22 @@
1
+ import type { ComponentPropsWithoutRef, ElementType, ReactNode } from 'react';
2
+ interface ListRowOwnProps {
3
+ title: ReactNode;
4
+ /** The muted line under the title. It can hold a `RelativeTime`. */
5
+ meta?: ReactNode;
6
+ /** The row the page is on. It is drawn from `aria-current`, "page" on a link and "true" on a button. */
7
+ active?: boolean;
8
+ }
9
+ export type ListRowProps<C extends ElementType = 'button'> = ListRowOwnProps & {
10
+ /** The element or router link to render. A button by default. Pass "a" with `href`, or the router's Link with `to`. It must forward its ref and accept a string className. */
11
+ component?: C;
12
+ } & Omit<ComponentPropsWithoutRef<C>, keyof ListRowOwnProps | 'component' | 'className' | 'children'>;
13
+ /**
14
+ * A row in a list, from recipes/list-row.html: a title and a muted line under it. A button that selects by default, so `onClick` is the handler, and a link
15
+ * when `component` is a link. Every other prop, such as `href`, `to` or `onClick`, goes to that element.
16
+ */
17
+ export declare function ListRow<C extends ElementType = 'button'>({ title, meta, active, component, ...rest }: ListRowProps<C>): JSX.Element;
18
+ /** The label that starts a day's rows in a list, from recipes/list-row.html. The page decides the groups and their words. It is an h3, so put it under a heading of the list. */
19
+ export declare function DaySeparator({ label }: {
20
+ label: string;
21
+ }): JSX.Element;
22
+ export {};
@@ -0,0 +1,14 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * A row in a list, from recipes/list-row.html: a title and a muted line under it. A button that selects by default, so `onClick` is the handler, and a link
4
+ * when `component` is a link. Every other prop, such as `href`, `to` or `onClick`, goes to that element.
5
+ */
6
+ export function ListRow({ title, meta, active = false, component, ...rest }) {
7
+ const Component = component ?? 'button';
8
+ const isButton = Component === 'button';
9
+ return (_jsxs(Component, { type: isButton ? 'button' : undefined, ...rest, className: "list-row", "aria-current": active ? (isButton ? 'true' : 'page') : undefined, children: [_jsx("span", { className: "list-row-title", children: title }), meta ? _jsx("span", { className: "list-row-meta", children: meta }) : null] }));
10
+ }
11
+ /** The label that starts a day's rows in a list, from recipes/list-row.html. The page decides the groups and their words. It is an h3, so put it under a heading of the list. */
12
+ export function DaySeparator({ label }) {
13
+ return _jsx("h3", { className: "day-sep", children: label });
14
+ }