diff --git a/.changeset/ui-dashboard-blocks.md b/.changeset/ui-dashboard-blocks.md new file mode 100644 index 00000000..11ac10b0 --- /dev/null +++ b/.changeset/ui-dashboard-blocks.md @@ -0,0 +1,5 @@ +--- +"@cloudhal/cli": patch +--- + +Studio's list toolbars come from one shared `@cloudhal/ui` block. The search now fills the free width of the row, and the filters, other actions, sort and refresh follow in a fixed order. `@cloudhal/ui` also gains `StatusIndicator`, `UsageMeterRow`, a "View docs" pill on `PageLayout`, and a resource mode for one resource's detail page: its tabs under the top bar, actions on the right, and no large title (#228). diff --git a/apps/studio/scripts/design-lint.ts b/apps/studio/scripts/design-lint.ts index e5be3689..2f6ba116 100644 --- a/apps/studio/scripts/design-lint.ts +++ b/apps/studio/scripts/design-lint.ts @@ -97,6 +97,7 @@ export type Template = | "moderation" | "queue" | "dashboard" + | "resource" | "settings" | "editor" | "auth" @@ -135,6 +136,10 @@ export const TEMPLATE_REQUIRES: Record = { "Pagination", ], dashboard: ["PageLayout", "crumbs=", "title=", "Columns", "SectionCard"], + // One resource's detail page like the dashboard's Worker page (DESIGN.md §2 `resource`): the + // crumbs (and an environment switcher) in the top bar, the resource's tabs under it with the + // actions on the right, no large title (`title` is the hidden h1), then SectionCards. + resource: ["PageLayout", "crumbs=", "title=", "resource=", "SectionCard"], settings: ["PageLayout", "crumbs=", "title=", "SettingsLayout", "SettingsSection"], editor: ["PageLayout", "crumbs=", "title=", "EditorLayout", "SectionCard"], auth: ["AuthLayout", "Tabs", "Input", "Button"], diff --git a/apps/studio/src/components/ListToolbar.tsx b/apps/studio/src/components/ListToolbar.tsx deleted file mode 100644 index bc84dd76..00000000 --- a/apps/studio/src/components/ListToolbar.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { Toolbar } from "@cloudhal/ui"; -import { MagnifyingGlass } from "@phosphor-icons/react"; -import type { ReactNode } from "react"; - -/** - * Search row above every table (DESIGN.md §2a): a Kumo `Toolbar` at the default size, so it lines - * up with `Select` filters and buttons on the same row. Filters/secondary controls go in - * `actions`; the page's primary "Create …" action belongs in `Page actions`, not here. - */ -export function ListToolbar({ - search, - onSearch, - placeholder, - actions, -}: { - search: string; - onSearch: (v: string) => void; - placeholder: string; - actions?: ReactNode; -}) { - return ( -
- - } aria-label={placeholder} /> - onSearch((e.target as HTMLInputElement).value)} - className="flex-1" - /> - - {actions &&
{actions}
} -
- ); -} diff --git a/apps/studio/src/routes/ActivityPage.tsx b/apps/studio/src/routes/ActivityPage.tsx index cd99a73f..e01f7bf8 100644 --- a/apps/studio/src/routes/ActivityPage.tsx +++ b/apps/studio/src/routes/ActivityPage.tsx @@ -6,6 +6,7 @@ import { LayerCard, Link, LinkButton, + ListToolbar, PageLayout, RefreshButton, Select, @@ -29,7 +30,6 @@ import { ActivityDetailDialog } from "../components/ActivityDetailDialog.tsx"; import { ActorCell, DecisionCell, Outcome, ToolCell, useToolLabel } from "../components/Audit.tsx"; import { SiteCrumbs } from "../components/Crumbs.tsx"; import { ErrorBanner } from "../components/ErrorBanner.tsx"; -import { ListToolbar } from "../components/ListToolbar.tsx"; import { RelativeTime } from "../components/RelativeTime.tsx"; import { TableSkeleton } from "../components/TableSkeleton.tsx"; import { useCan, useMemberRoles } from "../components/useMe.ts"; diff --git a/apps/studio/src/routes/AgentsPage.tsx b/apps/studio/src/routes/AgentsPage.tsx index 7a740d76..4e32cbd0 100644 --- a/apps/studio/src/routes/AgentsPage.tsx +++ b/apps/studio/src/routes/AgentsPage.tsx @@ -8,6 +8,7 @@ import { DropdownMenu, Empty, LayerCard, + ListToolbar, PageLayout, Pagination, SectionEmpty, @@ -49,7 +50,6 @@ import { useExtendElevation, } from "../components/Elevation.tsx"; import { ErrorBanner } from "../components/ErrorBanner.tsx"; -import { ListToolbar } from "../components/ListToolbar.tsx"; import { RelativeTime } from "../components/RelativeTime.tsx"; import { RoleBadge } from "../components/Roles.tsx"; import { ScopeBadge } from "../components/ScopeBadge.tsx"; diff --git a/apps/studio/src/routes/ArticlesPage.tsx b/apps/studio/src/routes/ArticlesPage.tsx index 8bf8dc90..3e6c3117 100644 --- a/apps/studio/src/routes/ArticlesPage.tsx +++ b/apps/studio/src/routes/ArticlesPage.tsx @@ -6,6 +6,7 @@ import { ImagePreview, LayerCard, Link, + ListToolbar, PageLayout, Pagination, Select, @@ -34,7 +35,6 @@ import { EntryDeleteDialog } from "../components/EntryDeleteDialog.tsx"; import { EntryPath } from "../components/EntryPath.tsx"; import { EntryStatus } from "../components/EntryStatus.tsx"; import { ErrorBanner } from "../components/ErrorBanner.tsx"; -import { ListToolbar } from "../components/ListToolbar.tsx"; import { ListViewsCell, type ListViewsColumn, diff --git a/apps/studio/src/routes/AutomationsPage.tsx b/apps/studio/src/routes/AutomationsPage.tsx index c3d5e035..3945a90c 100644 --- a/apps/studio/src/routes/AutomationsPage.tsx +++ b/apps/studio/src/routes/AutomationsPage.tsx @@ -8,6 +8,7 @@ import { DropdownMenu, Empty, LayerCard, + ListToolbar, PageLayout, ScheduleStatusBadge, Switch, @@ -22,7 +23,6 @@ import { useState } from "react"; import { ModeBadge } from "../components/AgentModes.tsx"; import { AutomationDialog } from "../components/AutomationDialog.tsx"; import { deleteResourceProps } from "../components/deleteLabels.ts"; -import { ListToolbar } from "../components/ListToolbar.tsx"; import { RelativeTime } from "../components/RelativeTime.tsx"; import { NewScheduleButton, diff --git a/apps/studio/src/routes/CollectionsPage.tsx b/apps/studio/src/routes/CollectionsPage.tsx index 23ea773b..e8bbad4a 100644 --- a/apps/studio/src/routes/CollectionsPage.tsx +++ b/apps/studio/src/routes/CollectionsPage.tsx @@ -5,6 +5,7 @@ import { Empty, LayerCard, Link, + ListToolbar, PageLayout, Pagination, Table, @@ -17,7 +18,6 @@ import { useParams } from "@tanstack/react-router"; import { useState } from "react"; import { SiteCrumbs } from "../components/Crumbs.tsx"; import { ErrorBanner } from "../components/ErrorBanner.tsx"; -import { ListToolbar } from "../components/ListToolbar.tsx"; import { NewCollectionDialog } from "../components/NewCollectionDialog.tsx"; import { TableSkeleton } from "../components/TableSkeleton.tsx"; import { useCan } from "../components/useMe.ts"; diff --git a/apps/studio/src/routes/CommentsPage.tsx b/apps/studio/src/routes/CommentsPage.tsx index ecd9dede..a19b160d 100644 --- a/apps/studio/src/routes/CommentsPage.tsx +++ b/apps/studio/src/routes/CommentsPage.tsx @@ -14,6 +14,7 @@ import { Empty, LayerCard, LinkButton, + ListToolbar, Meter, PageLayout, Pagination, @@ -55,7 +56,6 @@ import { } from "../components/Comments.tsx"; import { SiteCrumbs } from "../components/Crumbs.tsx"; import { ErrorBanner } from "../components/ErrorBanner.tsx"; -import { ListToolbar } from "../components/ListToolbar.tsx"; import { SettingRow } from "../components/Settings.tsx"; import { useToast } from "../components/Toast.ts"; import { useGate, useMe } from "../components/useMe.ts"; diff --git a/apps/studio/src/routes/MediaPage.tsx b/apps/studio/src/routes/MediaPage.tsx index 089d1001..7427fff6 100644 --- a/apps/studio/src/routes/MediaPage.tsx +++ b/apps/studio/src/routes/MediaPage.tsx @@ -3,6 +3,7 @@ import { Empty, LayerCard, Link, + ListToolbar, MediaDropZone, MediaGrid, MediaTile, @@ -21,7 +22,6 @@ import { useNavigate, useParams } from "@tanstack/react-router"; import { useEffect, useState } from "react"; import { SiteCrumbs } from "../components/Crumbs.tsx"; import { ErrorBanner } from "../components/ErrorBanner.tsx"; -import { ListToolbar } from "../components/ListToolbar.tsx"; import { AssetBadges, AssetFrame, UsageBadge } from "../components/Media.tsx"; import { UploadDialog } from "../components/MediaUpload.tsx"; import { RelativeTime } from "../components/RelativeTime.tsx"; diff --git a/apps/studio/src/routes/MembersPage.tsx b/apps/studio/src/routes/MembersPage.tsx index 9ac641f1..10e66e06 100644 --- a/apps/studio/src/routes/MembersPage.tsx +++ b/apps/studio/src/routes/MembersPage.tsx @@ -5,6 +5,7 @@ import { DropdownMenu, Empty, LayerCard, + ListToolbar, PageLayout, Pagination, SectionCard, @@ -38,7 +39,6 @@ import { ConfirmDialog } from "../components/ConfirmDialog.tsx"; import { Crumbs } from "../components/Crumbs.tsx"; import { ErrorBanner } from "../components/ErrorBanner.tsx"; import { InviteDialog, type InviteDraft } from "../components/InviteDialog.tsx"; -import { ListToolbar } from "../components/ListToolbar.tsx"; import { ChangeRoleDialog, MemberIdentity, diff --git a/apps/studio/src/routes/PagesPage.tsx b/apps/studio/src/routes/PagesPage.tsx index bd31145a..a6be2647 100644 --- a/apps/studio/src/routes/PagesPage.tsx +++ b/apps/studio/src/routes/PagesPage.tsx @@ -7,6 +7,7 @@ import { Empty, LayerCard, Link, + ListToolbar, PageLayout, Pagination, Table, @@ -21,7 +22,6 @@ import { type ReactNode, useState } from "react"; import { SiteCrumbs } from "../components/Crumbs.tsx"; import { deleteResourceProps } from "../components/deleteLabels.ts"; import { ErrorBanner } from "../components/ErrorBanner.tsx"; -import { ListToolbar } from "../components/ListToolbar.tsx"; import { ListViewsCell, type ListViewsColumn, diff --git a/apps/studio/src/routes/RedirectsPage.tsx b/apps/studio/src/routes/RedirectsPage.tsx index 985f4a96..52233814 100644 --- a/apps/studio/src/routes/RedirectsPage.tsx +++ b/apps/studio/src/routes/RedirectsPage.tsx @@ -7,6 +7,7 @@ import { Empty, LayerCard, Link, + ListToolbar, PageLayout, Pagination, Select, @@ -35,7 +36,6 @@ import { ConfirmDialog } from "../components/ConfirmDialog.tsx"; import { SiteCrumbs } from "../components/Crumbs.tsx"; import { ErrorBanner } from "../components/ErrorBanner.tsx"; import { ReadOnly, ViewOnlyBanner } from "../components/Gating.tsx"; -import { ListToolbar } from "../components/ListToolbar.tsx"; import { RedirectDialog } from "../components/RedirectDialog.tsx"; import { RelativeTime } from "../components/RelativeTime.tsx"; import { TableSkeleton } from "../components/TableSkeleton.tsx"; diff --git a/apps/studio/src/routes/ScheduleHistoryPage.tsx b/apps/studio/src/routes/ScheduleHistoryPage.tsx index 9d2a9b5a..d9366ddb 100644 --- a/apps/studio/src/routes/ScheduleHistoryPage.tsx +++ b/apps/studio/src/routes/ScheduleHistoryPage.tsx @@ -5,6 +5,7 @@ import { Empty, LayerCard, LinkButton, + ListToolbar, PageLayout, RefreshButton, SCHEDULE_RUN_STATES, @@ -20,7 +21,6 @@ import { ClockCounterClockwise, GitDiff, ListMagnifyingGlass } from "@phosphor-i import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; import { useState } from "react"; -import { ListToolbar } from "../components/ListToolbar.tsx"; import { ScheduleRunDialog } from "../components/ScheduleRunDialog.tsx"; import { itemsLabel, diff --git a/apps/studio/src/routes/SchedulesPage.tsx b/apps/studio/src/routes/SchedulesPage.tsx index e6d8ab32..756bca40 100644 --- a/apps/studio/src/routes/SchedulesPage.tsx +++ b/apps/studio/src/routes/SchedulesPage.tsx @@ -22,6 +22,7 @@ import { LayerCard, LayerDialog, LinkButton, + ListToolbar, PageLayout, Popover, type ScheduleEntry, @@ -48,7 +49,6 @@ import { import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; import { useEffect, useMemo, useRef, useState } from "react"; -import { ListToolbar } from "../components/ListToolbar.tsx"; import { RelativeTime } from "../components/RelativeTime.tsx"; import { ScheduleDialog } from "../components/ScheduleDialog.tsx"; import { diff --git a/apps/studio/src/routes/SitesPage.tsx b/apps/studio/src/routes/SitesPage.tsx index 0da550b0..5d8d96c3 100644 --- a/apps/studio/src/routes/SitesPage.tsx +++ b/apps/studio/src/routes/SitesPage.tsx @@ -6,6 +6,7 @@ import { Empty, LayerCard, Link, + ListToolbar, PageLayout, Pagination, SectionCard, @@ -18,7 +19,6 @@ import { useNavigate } from "@tanstack/react-router"; import { useState } from "react"; import { Crumbs } from "../components/Crumbs.tsx"; import { ErrorBanner } from "../components/ErrorBanner.tsx"; -import { ListToolbar } from "../components/ListToolbar.tsx"; import { NewSiteDialog } from "../components/NewSiteDialog.tsx"; import { TableSkeleton } from "../components/TableSkeleton.tsx"; import { useGate } from "../components/useMe.ts"; diff --git a/apps/studio/src/routes/SkillsPage.tsx b/apps/studio/src/routes/SkillsPage.tsx index 6bda4948..d22f49aa 100644 --- a/apps/studio/src/routes/SkillsPage.tsx +++ b/apps/studio/src/routes/SkillsPage.tsx @@ -7,6 +7,7 @@ import { Empty, InlineCopyText, LayerCard, + ListToolbar, PageLayout, Pagination, SectionCard, @@ -21,7 +22,6 @@ import { useCallback, useState } from "react"; import { CodeHighlighted } from "../components/CodeHighlighted.tsx"; import { Crumbs } from "../components/Crumbs.tsx"; import { ErrorBanner } from "../components/ErrorBanner.tsx"; -import { ListToolbar } from "../components/ListToolbar.tsx"; import { ApprovalTool, SkillDetailDialog, SkillScope } from "../components/SkillDetailDialog.tsx"; import { TableSkeleton } from "../components/TableSkeleton.tsx"; import { usePaging } from "../components/usePaging.ts"; diff --git a/apps/studio/src/routes/SnapshotsPage.tsx b/apps/studio/src/routes/SnapshotsPage.tsx index 41af6688..8518866f 100644 --- a/apps/studio/src/routes/SnapshotsPage.tsx +++ b/apps/studio/src/routes/SnapshotsPage.tsx @@ -6,6 +6,7 @@ import { Input, LayerCard, LayerDialog, + ListToolbar, PageLayout, Pagination, Radio, @@ -18,7 +19,6 @@ import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; import { useState } from "react"; import { SiteCrumbs } from "../components/Crumbs.tsx"; import { ErrorBanner } from "../components/ErrorBanner.tsx"; -import { ListToolbar } from "../components/ListToolbar.tsx"; import { usePublishMode, useRequestedNotice } from "../components/Publish.tsx"; import { RelativeTime } from "../components/RelativeTime.tsx"; import { SnapshotDiffDialog } from "../components/SnapshotDiffDialog.tsx"; diff --git a/apps/studio/src/routes/TaxonomyPage.tsx b/apps/studio/src/routes/TaxonomyPage.tsx index deee8f7f..11217d4f 100644 --- a/apps/studio/src/routes/TaxonomyPage.tsx +++ b/apps/studio/src/routes/TaxonomyPage.tsx @@ -7,6 +7,7 @@ import { Empty, LayerCard, Link, + ListToolbar, PageLayout, Pagination, SectionCard, @@ -43,7 +44,6 @@ import { SiteCrumbs } from "../components/Crumbs.tsx"; import { ErrorBanner } from "../components/ErrorBanner.tsx"; import { ReadOnly, ViewOnlyBanner } from "../components/Gating.tsx"; import { GovernanceDialog, type GovernanceTarget } from "../components/GovernanceDialog.tsx"; -import { ListToolbar } from "../components/ListToolbar.tsx"; import { RelativeTime } from "../components/RelativeTime.tsx"; import { SettingRow } from "../components/Settings.tsx"; import { TableSkeleton } from "../components/TableSkeleton.tsx"; diff --git a/apps/studio/src/routes/TranslationsPage.tsx b/apps/studio/src/routes/TranslationsPage.tsx index d3e0b8b4..28a9beb0 100644 --- a/apps/studio/src/routes/TranslationsPage.tsx +++ b/apps/studio/src/routes/TranslationsPage.tsx @@ -3,6 +3,7 @@ import { Empty, LayerCard, Link, + ListToolbar, PageLayout, Pagination, Select, @@ -16,7 +17,6 @@ import { useParams } from "@tanstack/react-router"; import { useState } from "react"; import { SiteCrumbs } from "../components/Crumbs.tsx"; import { ErrorBanner } from "../components/ErrorBanner.tsx"; -import { ListToolbar } from "../components/ListToolbar.tsx"; import { Status } from "../components/Status.tsx"; import { TableSkeleton } from "../components/TableSkeleton.tsx"; import { usePaging } from "../components/usePaging.ts"; diff --git a/docs/design/DESIGN.md b/docs/design/DESIGN.md index 3d2eeec9..1b24109d 100644 --- a/docs/design/DESIGN.md +++ b/docs/design/DESIGN.md @@ -22,8 +22,8 @@ Studio mirrors this one-to-one: - **Matching.** Commands match with `foldName`, so case and Turkish İ/ı are folded. | | Content container | `PageLayout` (`@cloudhal/ui`) — the only container: `mx-auto max-w-[1400px] px-4 py-6 sm:px-6 md:px-8 md:py-8 lg:px-10`, `width="narrow"` caps at 960 px. Screens never set `mx-auto`/`max-w-*` themselves (lint `page-container`). | | Breadcrumbs | Built only by `Crumbs` / `SiteCrumbs` (`components/Crumbs.tsx`): "Cloudhal Studio › ui-audit › Pages › About", `Breadcrumbs.Current loading`, `Breadcrumbs.Clipboard` for the current resource id. Passed to `PageLayout crumbs`, which portals them into the top bar — never rendered in the content. | -| Page header | Kumo's `PageHeader` block (owned copy: every row flush with the content edge, 14 px description, `icon`, `actions`, controlled `tab`, loading title), rendered by `PageLayout` from its `title`, `description`, `icon` (24 px, top-level list pages only), `actions` (the page's primary action — "New page", "Create key" — lives here, never in the toolbar row) and `tabs`. Tabs are for **sub-views of one page only**; a tab bar that repeats the sidebar is forbidden (the old site tab bar was removed for that reason). | -| Lists | `PageLayout` (+ `aside` for usage/help: the `ResourceListPage` two-column body, 380 px sticky once the content is ≥ 64 rem) + `ListToolbar` (Kumo `Toolbar`, default size so it lines up with `Select`/`Button`) + `LayerCard className="p-0"` + `Table` + `Pagination`; empty → a bare `Empty` in the table's place. | +| Page header | Kumo's `PageHeader` block (owned copy: every row flush with the content edge, 14 px description, `icon`, `actions`, controlled `tab`, loading title), rendered by `PageLayout` from its `title`, `description`, `icon` (24 px, top-level list pages only), `actions` (the page's primary action — "New page", "Create key" — lives here, last, never in the toolbar row), `tabs`, and `docsHref` + `docsLabel` for the small outline "View docs" pill after the title on list pages (`LinkButton size="xs"`, a 14 px book icon, same tab). Tabs are for **sub-views of one page only**; a tab bar that repeats the sidebar is forbidden (the old site tab bar was removed for that reason). | +| Lists | `PageLayout` (+ `aside` for usage/help: the `ResourceListPage` two-column body, 380 px sticky once the content is ≥ 64 rem) + `ListToolbar` (`@cloudhal/ui`; fixed slots in a fixed order — the search, which fills the free width, then `filters`, other `actions`, `sort`, and `refresh` last as Kumo's `RefreshButton`; Kumo `Toolbar` at the default size so it lines up with `Select`/`Button`) + `LayerCard className="p-0"` + `Table` + `Pagination`; empty → a bare `Empty` in the table's place. | | Sections | `SectionCard` (`@cloudhal/ui`): Kumo `LayerCard` with the `Secondary` header row (title, optional 16 px icon, `actions` right: count `Badge`, ghost `LinkButton` "View all", small buttons) and the `Primary` body (`description`, `children` in `grid gap-5 px-5 pt-4 pb-5`, `flush` for a `Table`, `footer` row for Save/Discard). Never nested (lint `layer-card-nesting`); never a bare `LayerCard` with hand-made headers. | | Neutral panels | `InsetPanel` (`@cloudhal/ui`, `outline` / `recessed`) for summaries and quoted agent text inside cards and dialogs ("Now → New", the agent's reason); `Surface` for standalone neutral surfaces. Hand-made `rounded-* ring` divs are forbidden in screens (lint `no-ad-hoc-surface`). | | Feedback | `Banner` (page/section), `Field`/`Input error` (inline), `Toasty` + `useKumoToastManager` (transient). | @@ -55,6 +55,7 @@ Every route file declares one template in `ROUTE_TEMPLATES` (`apps/studio/script | `moderation` | `PageLayout`, `ListToolbar` ↔ `BulkActionBar`, `LayerCard p-0` of `CommentRow`s, `Empty`, `Pagination` (reader comments, §2w) | | `list` | `PageLayout` (crumbs, title, description, `actions`, optional `aside`), `ListToolbar`, `LayerCard p-0` + `Table`, `Empty`, `Pagination` | | `dashboard` | `PageLayout`, `Columns variant="2-1"`, `SectionCard`s | +| `resource` | `PageLayout resource` (crumbs and an optional environment switcher in the top bar, the resource's tabs under it, `actions` right, no large title), `SectionCard`s (one resource's detail page, §2aj) | | `settings` | `PageLayout`, `SettingsLayout` (sticky `TableOfContents`), `SettingsSection` (= `SectionCard`) | | `editor` | `PageLayout`, `EditorLayout` (+ `PreviewPanel`), `SectionCard`s | | `auth` | `AuthLayout` (`@cloudhal/ui`), `Tabs`, `Input`/`PasswordField`, full-width primary `Button`, `Banner` | @@ -80,7 +81,7 @@ Reference implementations: `routes/SitesPage.tsx` (list + aside), `routes/PagesP } title description actions={} aside={}>…}> - + …{rows.map(r => {r.title}…)}
@@ -497,6 +498,25 @@ Words in the lazy `fields` namespace (loaded with the entry editor and collectio ### 2ai. OAuth consent (`/oauth/consent`; RFC-0044 §4, ADR-0045) Template `consent`: where a remote MCP client's authorization request lands after sign-in, inside the account frame. A `PageLayout` (`width="narrow"`, title "Connect ") of three `SectionCard`s, never nested: **Who is asking** (the client's name in an `InsetPanel`, the host it is identified by or "Registered with this Studio", the host it sends the browser back to; an `alert` `Banner` when every redirect is on this computer; an info `Banner` for a step-up), **What it may do** (a `Checkbox.Group` of the requested scopes in the agent-key wording; one the role narrows says so in its label, one the role does not give is disabled with "not in your role"; the refresh-token and site-limited lines as secondary text) and **How it works** (`ModePicker` with the modes the role allows; Deny and Approve in the card's footer, Approve disabled with nothing ticked). The page's query string is the provider's signed request, sent back untouched; Approve and Deny navigate to the URL the server answers. Words in the lazy namespace `oauthConsent`. +### 2aj. Resource pages (`PageLayout resource`) + +One resource's detail page, laid out like the dashboard's Worker page (Overview, Metrics, Deployments, …, Settings). Template `resource`. + +- **Top bar.** The crumbs, then an optional environment switcher (`resource.switcher`, a small `Select` or `DropdownMenu` such as Production / Preview), both portaled into the top bar. +- **Header.** No large title: `title` becomes the page's visually hidden `h1`. In its place are Kumo's underline `Tabs` (`resource.tabs`, controlled by `resource.tab` from the route, `onTabChange` navigates) and `actions`, the one primary action last, over a single hairline. Kumo's own list border is turned off, so the active tab's indicator always sits on that one rule. The row never wraps. + - **Wide** (the page container ≥ 48 rem): tabs and actions share one line over the rule. The tab list is the part that shrinks and scrolls; the actions keep their size. + - **Narrow** (a phone at 390 px): the tab line carries the rule across the full width, and the actions follow on their own line below it. Focus and visual order agree: tabs, then actions. + + A tab bar wider than its line scrolls sideways. Underline tabs have no scroll buttons, and the arrow keys bring the focused tab into view. +- **Tabs.** They are the resource's own sub-views, never a copy of the sidebar. A tab can be a link through its `render`. +- **Focus order.** It follows the DOM: crumbs and switcher in the top bar, then the tab bar (one tab stop; the arrow keys move between tabs and Enter or Space opens one), then the actions, then the content. `packages/ui/test/dashboard-blocks.test.tsx` holds this order. +- **Body.** `SectionCard`s, with `Columns` where a usage rail sits beside the content. + +Dense status and usage on these pages: + +- **`StatusIndicator`.** A dot or a thin bar plus the state's word ("Ready", "Failed", "Running"), for deployment and step rows where a `Badge` per row is too heavy. `active` pulses the mark, but not under reduced motion. The word always carries the state. +- **`UsageMeterRow`.** One row of a usage rail: the label with an optional info toggletip, `used / limit` (formatted by the screen), and a Kumo `Meter`. The meter turns warning at 80 % (`warnAt`) and danger at the limit, with the state's word as a badge. `warningLabel` and `dangerLabel` are required, so a state is never shown by colour alone. The visible row is the block's own, because Kumo's `Meter.label` is a plain string. The meter keeps its label for screen readers and reads the value text and the state as its value. The info text is repeated inside its button, since the tooltip popup describes nothing to screen readers. + ## 3. Sidebar specification ```tsx @@ -620,6 +640,9 @@ Studio must use each component where it fits; "later" means the surface does not | DateTimeField / RecurrenceEditor / NextRuns / RescheduleDialog / ScheduleRunRow (Cloudhal blocks) | date + 24-hour time in the site zone (the lazy `DatePicker` and a typed time), recurrence presets and the RRULE subset, the server's next runs with clock-change badges, "Move to…" with the approval kept or voided, and the run history row with lateness, budget use and the taint marker (§2x) | | EntryFieldsForm (Cloudhal block) | an entry's typed custom fields (RFC-0041): one Kumo control per field type from the collection's definitions — `Input`/`InputArea`, `Switch`, `DateTimeField` (date only, or date and time in the value's zone), `Select`/`Checkbox.Group` — labels and help from the definitions in the Studio language, the "Needed to publish" badge, errors per field, and translation fallbacks (shared fields read-only with a link to the original, the original's value as placeholder); rich text, images and references come from the host (`renderRichText`, `renderImage`, `renderReference`); repeaters render as `RepeaterField`; errors map by path (`speakers/2/person`). Every word is a prop (§2ah) | | FieldControl / RepeaterField / ReferenceList / EntryPicker / UsedByList (Cloudhal blocks) | RFC-0045 §5: `FieldControl` is one typed control (`EntryFieldsForm` and repeater items share it; a reference is a `fieldset` around the host's picker). `RepeaterField`: items as `fieldset`s named by an ICU legend, folded in `Collapsible`s, "Add item" with "3 of 20", up/down icon buttons from `sm` and a ⋯ `DropdownMenu` (move, duplicate, remove), focus to the new item's first control after add and to the next item's toggle (or "Add item") after remove, an undo through `onRemoved`, moves announced politely, items with problems opened on a new error map and the collapsed ones listed above. `ReferenceList`: `InsetPanel` rows (title linked, collection · language, "Draft"/"In review"/"Archived" with "Will not show until published", "Not found" with the id in mono), up/down from `sm` and ⋯ (open, move, remove). `EntryPicker`: a `LayerDialog` opened by its `trigger` (focus returns to it), a labelled search over a combobox listbox (`aria-activedescendant`, arrows, Home, End, Enter), a polite result count, collection and status `Badge`s, one pick closes, several are ticked up to the remaining count, "Show more" / "Keep searching" follows the cursor; it makes no API calls (`search`). `UsedByList`: a two-column `Table` (entry with where the value sits and who also shows it, status) for "Used by" and the delete dialog. Every word is a prop | +| ListToolbar (Cloudhal block) | the toolbar above every list: search filling the width, then `filters`, `actions`, `sort` and `refresh` (Kumo `RefreshButton`) last, in that order (§2a) | +| StatusIndicator (Cloudhal block) | a dot or thin bar plus the state's word for dense rows: deployments, steps, runs (§2aj) | +| UsageMeterRow (Cloudhal block) | one usage-rail row: label + info toggletip, `used / limit`, a `Meter` that warns at 80 % and is danger at the limit, the state's word as a badge (§2aj) | | ToggletipBadge (Cloudhal block) | a badge that explains itself: a Kumo `Badge` (variant, 14 px icon) inside a button that opens its `reason` as a `Tooltip` on hover, keyboard focus and a press (a press keeps it open until a second press, Escape or a press elsewhere), wrapped at 18 rem (`max-w-72`), the reason repeated for screen readers in the accessible name; every word a prop — "Ended (collection rule)" in the collection list and entry editor (§2ah) | | Switch | instant settings; browser notifications (Account › Notifications); page "Hide from search engines" (§2o) | | Table | all lists | diff --git a/packages/ui/src/blocks/ListToolbar.tsx b/packages/ui/src/blocks/ListToolbar.tsx new file mode 100644 index 00000000..05b30ff4 --- /dev/null +++ b/packages/ui/src/blocks/ListToolbar.tsx @@ -0,0 +1,76 @@ +import { cn, RefreshButton, Toolbar } from "@cloudflare/kumo"; +import { MagnifyingGlass } from "@phosphor-icons/react"; +import type { ReactNode } from "react"; + +export interface ListToolbarRefresh { + onRefresh: () => void; + /** The icon button's accessible name ("Refresh"), from the screen's catalog. */ + label: string; + /** Spins while a refetch runs. */ + loading?: boolean; +} + +export interface ListToolbarProps { + search: string; + onSearch: (value: string) => void; + /** The search field's placeholder and accessible name, from the screen's catalog. */ + placeholder: string; + /** Filter controls (`Select`s, a segmented `Tabs`, "Clear filters"), right after the search. */ + filters?: ReactNode; + /** The sort control, after the filters. */ + sort?: ReactNode; + /** Other controls a list needs, between the filters and the sort. */ + actions?: ReactNode; + /** The refresh icon button, always last. */ + refresh?: ListToolbarRefresh; + className?: string; +} + +/** + * The toolbar above every list (DESIGN.md §2a), with fixed slots in a fixed order: the search + * (it fills the free width), filters, other actions, sort, then refresh last as an icon button. + * One component keeps every list's row the same. The search is a Kumo `Toolbar` at the default + * size, so it lines up with `Select` filters and buttons on the same row. The page's primary + * "Create …" action belongs in `PageLayout actions`, never here. Every word is a prop. + */ +export function ListToolbar({ + search, + onSearch, + placeholder, + filters, + sort, + actions, + refresh, + className, +}: ListToolbarProps) { + const controls = filters || actions || sort || refresh; + return ( +
+ + } aria-label={placeholder} /> + onSearch((e.target as HTMLInputElement).value)} + className="flex-1" + /> + + {controls && ( +
+ {filters} + {actions} + {sort} + {refresh && ( + + )} +
+ )} +
+ ); +} diff --git a/packages/ui/src/blocks/PageLayout.tsx b/packages/ui/src/blocks/PageLayout.tsx index f33c95d2..5d917bb8 100644 --- a/packages/ui/src/blocks/PageLayout.tsx +++ b/packages/ui/src/blocks/PageLayout.tsx @@ -1,12 +1,15 @@ -import { cn, type TabsItem } from "@cloudflare/kumo"; +import { cn, LinkButton, type TabsItem } from "@cloudflare/kumo"; +import { BookOpen } from "@phosphor-icons/react"; import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from "react"; import { createPortal } from "react-dom"; import { PageHeader } from "./kumo/page-header/page-header.tsx"; +import { ResourceHeader, type ResourceMode } from "./ResourceHeader.tsx"; /** * The page anatomy every admin screen shares (DESIGN.md §1–2): breadcrumbs in the app's top bar, * one content container, Kumo's `PageHeader` (title, description, actions, sub-view tabs), then - * the body — one column, or main + a sticky side column (the `ResourceListPage` layout). + * the body — one column, or main + a sticky side column (the `ResourceListPage` layout). In + * resource mode (`resource`, DESIGN.md §2 `resource`) a tab bar takes the header's place. * * Screens never build their own container, header or columns; they pass content to `PageLayout` * (and split a body with `Columns`, which reads this container's width). @@ -68,8 +71,19 @@ export interface PageLayoutProps { /** 24px Phosphor icon before the title (top-level list pages). */ icon?: ReactNode; loading?: boolean; - /** Page-level actions, right of the title (the primary "Create …" button lives here). */ + /** Page-level actions, right of the title (the primary "Create …" button lives here, last). */ actions?: ReactNode; + /** A "View docs" pill after the title (list pages): where it links, with `docsLabel`. */ + docsHref?: string; + /** The pill's word ("View docs"), from the screen's catalog. */ + docsLabel?: string; + /** + * Resource mode: one resource's detail page, laid out like the dashboard's Worker page. The + * crumbs (and an optional environment `switcher`) stay in the top bar, the resource's tabs sit + * under it with `actions` on the right, and there is no large title: `title` becomes the page's + * visually hidden `h1`. `tabs` and `description` are not used in this mode. + */ + resource?: ResourceMode; /** Sub-views of this page only — never a copy of the sidebar. */ tabs?: TabsItem[]; tab?: string; @@ -94,6 +108,9 @@ export function PageLayout({ icon, loading, actions, + docsHref, + docsLabel, + resource, tabs, tab, onTabChange, @@ -103,8 +120,30 @@ export function PageLayout({ children, }: PageLayoutProps) { const frame = useContext(PageFrameContext); - const portaled = crumbs && frame?.crumbsSlot ? createPortal(crumbs, frame.crumbsSlot) : null; + // In resource mode the environment switcher follows the crumbs in the top bar. + const top = + resource?.switcher && crumbs ? ( +
+ {crumbs} + {resource.switcher} +
+ ) : ( + (crumbs ?? resource?.switcher) + ); + const portaled = top && frame?.crumbsSlot ? createPortal(top, frame.crumbsSlot) : null; const hasHeader = title !== undefined || loading || description || actions || tabs; + const docs = + docsHref && docsLabel ? ( + } + className="rounded-full" + > + {docsLabel} + + ) : undefined; return (
{portaled} - {crumbs && !frame &&
{crumbs}
} - {hasHeader && ( - + {top && !frame &&
{top}
} + {resource ? ( + + ) : ( + hasHeader && ( + + ) )} {aside ? (
diff --git a/packages/ui/src/blocks/ResourceHeader.tsx b/packages/ui/src/blocks/ResourceHeader.tsx new file mode 100644 index 00000000..57439b63 --- /dev/null +++ b/packages/ui/src/blocks/ResourceHeader.tsx @@ -0,0 +1,65 @@ +import { Tabs, type TabsItem } from "@cloudflare/kumo"; +import type { ReactNode } from "react"; + +export interface ResourceMode { + /** The resource's sub-views, in the dashboard's order: Overview, Metrics, Deployments, …, Settings. */ + tabs: TabsItem[]; + /** The current tab (from the route), so other navigation keeps it in sync. */ + tab: string; + onTabChange: (value: string) => void; + /** An environment switcher shown after the breadcrumbs in the top bar (production, preview). */ + switcher?: ReactNode; +} + +/** + * The header of a resource page (`PageLayout resource`): Kumo's underline `Tabs` with the + * page's actions on the right, the primary action last, over one hairline (Kumo's own list border + * is turned off, so the active indicator always sits on this one rule: under the whole row when + * the page is wide, under the tab line when it is narrow and the actions go below). The title is the + * page's visually hidden `h1`, so the page keeps one heading without a large title. In the DOM the + * tabs come before the actions, so focus moves: crumbs and switcher (top bar), the tab bar (one + * stop; arrow keys move between tabs, Enter or Space opens one), then the actions, then the + * content. A tab bar wider than the page (a phone) scrolls sideways; underline tabs have no + * scroll buttons, and the arrow keys bring the focused tab into view. + */ +export function ResourceHeader({ + title, + actions, + resource, +}: { + title?: ReactNode; + actions?: ReactNode; + resource: ResourceMode; +}) { + // Never a wrapping row: wrapped, the rule would sit under the actions and leave the tabs and + // their indicator above it with none. Wide (the page container ≥ 48 rem): one line over one rule, + // the tab list the part that shrinks and scrolls, the actions fixed. Narrow: the tab line carries + // the rule across the full width and the actions follow on their own line, so the focus order + // (tabs, then actions) is also the visual one. + return ( +
+ {title !== undefined &&

{title}

} +
+ resource.onTabChange(String(next))} + className="w-full min-w-0" + listClassName="border-b-0" + /> +
+ {actions && ( +
+ {actions} +
+ )} +
+ ); +} diff --git a/packages/ui/src/blocks/StatusIndicator.tsx b/packages/ui/src/blocks/StatusIndicator.tsx new file mode 100644 index 00000000..4e48ae27 --- /dev/null +++ b/packages/ui/src/blocks/StatusIndicator.tsx @@ -0,0 +1,56 @@ +import { cn, Text } from "@cloudflare/kumo"; + +export type StatusTone = "success" | "warning" | "danger" | "info" | "neutral"; + +export interface StatusIndicatorProps { + /** The state's word ("Ready", "Failed", "Running"), from the screen's catalog; it always says the state, the colour only repeats it. */ + label: string; + tone: StatusTone; + /** `dot` (default) or a thin vertical `bar`, as the dashboard shows deployment and step status. */ + shape?: "dot" | "bar"; + /** Pulses the mark for work in progress (not under reduced motion). */ + active?: boolean; + className?: string; +} + +const TONE: Record = { + success: "bg-kumo-success", + warning: "bg-kumo-warning", + danger: "bg-kumo-danger", + info: "bg-kumo-info", + // Kumo has no inactive fill token: the mark takes the inactive text colour. + neutral: "bg-current text-kumo-inactive", +}; + +/** + * A light status mark for dense rows: a coloured dot or thin bar and the state's word, where a + * `Badge` per row would be too heavy (deployments, steps, runs). The mark is decorative and + * aligned with the first line of text; the word carries the meaning. Every word is a prop. + */ +export function StatusIndicator({ + label, + tone, + shape = "dot", + active = false, + className, +}: StatusIndicatorProps) { + return ( + + + ); +} diff --git a/packages/ui/src/blocks/ToggletipBadge.tsx b/packages/ui/src/blocks/ToggletipBadge.tsx index 9a3a2b4c..e84ce9cc 100644 --- a/packages/ui/src/blocks/ToggletipBadge.tsx +++ b/packages/ui/src/blocks/ToggletipBadge.tsx @@ -1,5 +1,6 @@ import { Badge, type BadgeVariant, cn, Tooltip } from "@cloudflare/kumo"; -import { type ReactNode, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import { useToggletip } from "./toggletip.ts"; export interface ToggletipBadgeProps { /** The badge's word, from the screen's catalog. */ @@ -13,9 +14,6 @@ export interface ToggletipBadgeProps { className?: string; } -/** How Kumo's `Tooltip` (Base UI) says why it wants to open or close. */ -type OpenChange = { reason?: string } | undefined; - /** * A badge that explains itself: a Kumo `Badge` inside a button whose `reason` opens in a Kumo * `Tooltip` on hover, on keyboard focus and on a press — Base UI tooltips never open on touch, so @@ -31,20 +29,10 @@ export function ToggletipBadge({ icon, className, }: ToggletipBadgeProps) { - const [open, setOpen] = useState(false); - /** Opened by a press: hover and focus no longer close it, only a press, Escape or outside. */ - const pinned = useRef(false); - const onOpenChange = (next: boolean, details: OpenChange) => { - // The trigger's own press is handled by `onClick` (the tooltip would only ever close on it). - if (details?.reason === "trigger-press") return; - if (!next && pinned.current && details?.reason === "trigger-hover") return; - if (!next) pinned.current = false; - setOpen(next); - }; + const { tooltip, onPress } = useToggletip(); return ( {reason}} // A render function, so our classes are merged with the tooltip trigger's, not replaced. render={(props) => ( @@ -53,8 +41,7 @@ export function ToggletipBadge({ type="button" onClick={(event) => { props.onClick?.(event); - pinned.current = !(open && pinned.current); - setOpen(pinned.current); + onPress(); }} className={cn( props.className, diff --git a/packages/ui/src/blocks/UsageMeterRow.tsx b/packages/ui/src/blocks/UsageMeterRow.tsx new file mode 100644 index 00000000..ba928649 --- /dev/null +++ b/packages/ui/src/blocks/UsageMeterRow.tsx @@ -0,0 +1,129 @@ +import { Badge, cn, Meter, Text, Tooltip } from "@cloudflare/kumo"; +import { Info } from "@phosphor-icons/react"; +import { useToggletip } from "./toggletip.ts"; + +export type UsageState = "normal" | "warning" | "danger"; + +/** The share of the limit at which a row warns, unless a screen passes its own. */ +export const USAGE_WARN_AT = 0.8; + +/** `normal` below `warnAt` of the limit, `warning` from there, `danger` at or above the limit. */ +export function usageState(used: number, limit: number, warnAt = USAGE_WARN_AT): UsageState { + if (limit <= 0) return used > 0 ? "danger" : "normal"; + if (used >= limit) return "danger"; + return used >= warnAt * limit ? "warning" : "normal"; +} + +export interface UsageMeterRowProps { + /** What is measured ("Requests today"), from the screen's catalog. */ + label: string; + used: number; + limit: number; + /** `used / limit` as the screen formats it (`formatNumber`), so digits follow the language. */ + valueText: string; + /** An explanation next to the label, in a toggletip; the button's name is `label` then `text`. */ + info?: { text: string; label: string }; + /** The words for the warning and the limit state, shown as a badge and read with the value, so a state is never shown by colour alone. */ + warningLabel: string; + dangerLabel: string; + /** The share of the limit that warns (default 0.8). */ + warnAt?: number; + className?: string; +} + +const BAR: Record = { + normal: undefined, + warning: "from-kumo-warning via-kumo-warning to-kumo-warning", + danger: "from-kumo-danger via-kumo-danger to-kumo-danger", +}; + +/** + * One row of a usage rail (the dashboard's Usage column): the label with an optional info + * toggletip, `used / limit`, and a Kumo `Meter` that turns warning at 80 % and danger at the + * limit, with the state's word as a badge, so it is never shown by colour alone. Kumo's + * `Meter.label` is a plain string, so the visible row is this block's own. The meter keeps its + * label for screen readers and reads `valueText` (and the state) as its value. Every word is a + * prop. + */ +export function UsageMeterRow({ + label, + used, + limit, + valueText, + info, + warningLabel, + dangerLabel, + warnAt = USAGE_WARN_AT, + className, +}: UsageMeterRowProps) { + const state = usageState(used, limit, warnAt); + const word = state === "danger" ? dangerLabel : state === "warning" ? warningLabel : undefined; + // A fractional limit (0.5 GB) is a real maximum; only a missing one falls back to 1. + const max = limit > 0 ? limit : 1; + return ( +
+
+ + + {info && } + + +
+ (word ? `${valueText}, ${word}` : valueText)} + // Kumo's own label row stays for screen readers (it names the meter); the row above shows it. + className="gap-0 [&>div:first-child]:sr-only" + indicatorClassName={cn("motion-reduce:transition-none", BAR[state])} + /> +
+ ); +} + +/** + * A 16 px info button whose text opens on hover, focus and press (a toggletip). The text is also + * inside the button for screen readers (the tooltip popup is portaled and describes nothing): the + * button's name is its contents, `label` then `text`, as `ToggletipBadge` does. + */ +function InfoToggletip({ text, label }: { text: string; label: string }) { + const { tooltip, onPress } = useToggletip(); + return ( + {text}} + render={(props) => ( +
)} {description && ( diff --git a/packages/ui/src/blocks/toggletip.ts b/packages/ui/src/blocks/toggletip.ts new file mode 100644 index 00000000..1f85147d --- /dev/null +++ b/packages/ui/src/blocks/toggletip.ts @@ -0,0 +1,31 @@ +import { useRef, useState } from "react"; + +/** How Kumo's `Tooltip` (Base UI) says why it wants to open or close. */ +type OpenChange = { reason?: string } | undefined; + +/** + * A Kumo `Tooltip` that also works as a toggletip: it opens on hover and keyboard focus, and a + * press toggles it (Base UI tooltips never open on touch). A pressed-open tip stays when the + * pointer leaves; a second press, Escape or a press elsewhere closes it. Spread `tooltip` on the + * `Tooltip` and call `onPress` from the trigger's own `onClick`. + */ +export function useToggletip(): { + tooltip: { open: boolean; onOpenChange: (next: boolean, details: OpenChange) => void }; + onPress: () => void; +} { + const [open, setOpen] = useState(false); + /** Opened by a press: hover and focus no longer close it, only a press, Escape or outside. */ + const pinned = useRef(false); + const onOpenChange = (next: boolean, details: OpenChange) => { + // The trigger's own press is handled by `onPress` (the tooltip would only ever close on it). + if (details?.reason === "trigger-press") return; + if (!next && pinned.current && details?.reason === "trigger-hover") return; + if (!next) pinned.current = false; + setOpen(next); + }; + const onPress = () => { + pinned.current = !(open && pinned.current); + setOpen(pinned.current); + }; + return { tooltip: { open, onOpenChange }, onPress }; +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index c0e4f98d..156d04a5 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -303,6 +303,11 @@ export { type LanguageChipsLabels, type LanguageChipsProps, } from "./blocks/LanguageChips.tsx"; +export { + ListToolbar, + type ListToolbarProps, + type ListToolbarRefresh, +} from "./blocks/ListToolbar.tsx"; export { MediaDropZone, type MediaDropZoneLabels, @@ -392,6 +397,7 @@ export { type RescheduleDialogLabels, type RescheduleDialogProps, } from "./blocks/RescheduleDialog.tsx"; +export type { ResourceMode } from "./blocks/ResourceHeader.tsx"; export { DEFAULT_RECURRENCE, RECURRENCE_PRESETS, @@ -471,6 +477,11 @@ export { type StatTileProps, } from "./blocks/StatTile.tsx"; export { StatusBadge, type StatusBadgeProps } from "./blocks/StatusBadge.tsx"; +export { + StatusIndicator, + type StatusIndicatorProps, + type StatusTone, +} from "./blocks/StatusIndicator.tsx"; export { SurfaceOutline, type SurfaceOutlineField, @@ -527,6 +538,13 @@ export { type TimezoneNoteProps, } from "./blocks/TimezoneNote.tsx"; export { ToggletipBadge, type ToggletipBadgeProps } from "./blocks/ToggletipBadge.tsx"; +export { + USAGE_WARN_AT, + UsageMeterRow, + type UsageMeterRowProps, + type UsageState, + usageState, +} from "./blocks/UsageMeterRow.tsx"; export { UsedByList, type UsedByListLabels, diff --git a/packages/ui/test/dashboard-blocks.test.tsx b/packages/ui/test/dashboard-blocks.test.tsx new file mode 100644 index 00000000..cdc3cb7a --- /dev/null +++ b/packages/ui/test/dashboard-blocks.test.tsx @@ -0,0 +1,301 @@ +/** + * The dashboard-pattern blocks (#228): ListToolbar's fixed slots, StatusIndicator, + * UsageMeterRow, PageLayout's docs pill and its resource mode, with the keyboard and focus order + * across the resource tabs (DESIGN.md §2a, §2aj). + */ +import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import { useState } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + Button, + ListToolbar, + PageLayout, + StatusIndicator, + UsageMeterRow, + usageState, +} from "../src/index.ts"; + +afterEach(cleanup); + +/** The elements Tab visits, in order (tabindex ≥ 0, not disabled, document order). */ +function tabOrder(root: HTMLElement): HTMLElement[] { + const candidates = root.querySelectorAll( + "a[href], button, input, select, textarea, [tabindex]", + ); + return [...candidates].filter( + (el) => + el.tabIndex >= 0 && + !(el as HTMLButtonElement).disabled && + el.getAttribute("aria-hidden") !== "true" && + !el.closest('[aria-hidden="true"]'), + ); +} +const nameOf = (el: HTMLElement) => + el.getAttribute("aria-label") ?? el.textContent?.trim() ?? el.tagName; + +describe("ListToolbar", () => { + it("keeps its slots in one order: search, filters, actions, sort, refresh last", () => { + const onRefresh = vi.fn(); + const { container } = render( + {}} + placeholder="Search Workers" + sort={} + refresh={{ onRefresh, label: "Refresh" }} + actions={} + filters={} + />, + ); + const order = tabOrder(container).map(nameOf); + expect(order.slice(-4)).toEqual(["Status", "Clear filters", "Sort", "Refresh"]); + expect(order.indexOf("Search Workers")).toBeLessThan(order.indexOf("Status")); + fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + expect(onRefresh).toHaveBeenCalledTimes(1); + }); + + it("is a named search field that reports what is typed", () => { + const onSearch = vi.fn(); + render(); + fireEvent.change(screen.getByRole("textbox", { name: "Search Workers" }), { + target: { value: "api" }, + }); + expect(onSearch).toHaveBeenCalledWith("api"); + expect(screen.queryByRole("button", { name: "Refresh" })).toBeNull(); + }); +}); + +describe("StatusIndicator", () => { + it("says the state in words; the coloured mark is decorative", () => { + const { container } = render(); + const root = container.querySelector("[data-status-tone]") as HTMLElement; + expect(root).toHaveAttribute("data-status-tone", "info"); + expect(root).toHaveAttribute("data-active"); + expect(screen.getByText("Running")).toBeVisible(); + expect(root.querySelector('[aria-hidden="true"]')).not.toBeNull(); + }); + + it("draws a dot or a thin bar", () => { + const { container, rerender } = render(); + expect(container.querySelector(".rounded-full")).not.toBeNull(); + rerender(); + expect(container.querySelector(".rounded-full")).toBeNull(); + expect(container.querySelector("[data-active]")).toBeNull(); + }); +}); + +describe("UsageMeterRow", () => { + it("warns at 80 % and is danger at the limit", () => { + expect(usageState(79, 100)).toBe("normal"); + expect(usageState(80, 100)).toBe("warning"); + expect(usageState(100, 100)).toBe("danger"); + expect(usageState(150, 100)).toBe("danger"); + expect(usageState(5, 10, 0.5)).toBe("warning"); + expect(usageState(0, 0)).toBe("normal"); + expect(usageState(1, 0)).toBe("danger"); + }); + + it("is a named meter that reads the value and the state, with an info toggletip", async () => { + const { container } = render( + , + ); + const meter = screen.getByRole("meter", { name: "Requests today" }); + expect(meter).toHaveAttribute("aria-valuetext", "85,000 / 100,000, Near the limit"); + expect(container.querySelector("[data-usage-state]")).toHaveAttribute( + "data-usage-state", + "warning", + ); + expect(screen.getByText("Near the limit")).toBeInTheDocument(); + // The explanation is part of the button's name: the tooltip popup describes nothing. + const info = screen.getByRole("button", { + name: "About requests today. Resets at 00:00 UTC.", + }); + await act(async () => { + fireEvent.click(info); + }); + expect(await screen.findByText("Resets at 00:00 UTC.")).toBeInTheDocument(); + }); + + it("keeps Kumo's own label row first in the meter (the row it hides for sighted users)", () => { + render( + , + ); + const meter = screen.getByRole("meter", { name: "Storage" }); + expect(meter.firstElementChild?.textContent).toContain("Storage"); + expect(meter).toHaveAttribute("aria-valuetext", "2 GB / 10 GB"); + expect(screen.queryByRole("button")).toBeNull(); + }); +}); + +describe("UsageMeterRow with a fractional limit", () => { + it("fills to the real limit: 0.5 of 0.5 is a full, danger bar", () => { + const { container } = render( + , + ); + const meter = screen.getByRole("meter", { name: "Storage" }); + expect(meter).toHaveAttribute("aria-valuemax", "0.5"); + expect(meter).toHaveAttribute("aria-valuenow", "0.5"); + expect(container.querySelector("[data-usage-state]")).toHaveAttribute( + "data-usage-state", + "danger", + ); + expect(meter).toHaveAttribute("aria-valuetext", "0.5 GB / 0.5 GB, Limit reached"); + }); +}); + +describe("PageLayout", () => { + it("shows the docs pill after the title only with a link and its word", () => { + const { rerender } = render( + , + ); + expect(screen.getByRole("link", { name: "View docs" })).toHaveAttribute( + "href", + "/docs/workers", + ); + rerender(); + expect(screen.queryByRole("link")).toBeNull(); + }); +}); + +const TABS = [ + { value: "overview", label: "Overview" }, + { value: "metrics", label: "Metrics" }, + { value: "deployments", label: "Deployments" }, + { value: "settings", label: "Settings" }, +]; + +function ResourcePage({ onTab }: { onTab?: (v: string) => void }) { + const [tab, setTab] = useState("overview"); + return ( + Workers} + title="api-gateway" + actions={ + <> + + + + } + resource={{ + tabs: TABS, + tab, + onTabChange: (v) => { + setTab(v); + onTab?.(v); + }, + switcher: , + }} + > +

Tab body: {tab}

+
+ ); +} + +describe("PageLayout resource mode", () => { + it("has no large title, keeps a hidden h1, and puts the tabs under the top bar", () => { + const { container } = render(); + const h1 = screen.getByRole("heading", { level: 1, name: "api-gateway" }); + expect(h1).toHaveClass("sr-only"); + expect(container.querySelector(".text-3xl")).toBeNull(); + const header = container.querySelector("[data-resource-header]") as HTMLElement; + expect( + within(header) + .getAllByRole("tab") + .map((t) => t.textContent), + ).toEqual(["Overview", "Metrics", "Deployments", "Settings"]); + expect(within(header).getByRole("button", { name: "Deploy" })).toBeInTheDocument(); + }); + + it("focus order: crumbs, switcher, the tab bar (one stop), the actions (primary last), content", () => { + const { container } = render(); + const order = tabOrder(container).map(nameOf); + expect(order).toEqual(["Workers", "Production", "Overview", "Edit code", "Deploy"]); + }); + + const keyOn = (el: Element, k: string, up = false) => + act(async () => { + fireEvent.keyDown(el, { key: k, code: k === " " ? "Space" : k }); + if (up) fireEvent.keyUp(el, { key: k, code: k === " " ? "Space" : k }); + }); + + it("arrow keys move focus without opening; the keyboard opens a tab; one tab stop", async () => { + const onTab = vi.fn(); + const { container } = render(); + const overview = screen.getByRole("tab", { name: "Overview" }); + await act(async () => overview.focus()); + await keyOn(overview, "ArrowRight"); + const metrics = screen.getByRole("tab", { name: "Metrics" }); + expect(document.activeElement).toBe(metrics); + // Focus alone opens nothing (Kumo's default manual activation). + expect(onTab).not.toHaveBeenCalled(); + // The keyboard opens it. A tab is a native button: browsers turn Enter into a click, which + // happy-dom does not simulate, so the test presses Space (keydown + keyup), which it does. + await keyOn(metrics, " ", true); + expect(onTab).toHaveBeenCalledTimes(1); + expect(onTab).toHaveBeenLastCalledWith("metrics"); + expect(metrics).toHaveAttribute("aria-selected", "true"); + expect(screen.getByText("Tab body: metrics")).toBeInTheDocument(); + // Still one tab stop for the bar, now on the selected tab. + const stops = tabOrder(container).filter((el) => el.getAttribute("role") === "tab"); + expect(stops.map(nameOf)).toEqual(["Metrics"]); + await keyOn(metrics, "End"); + expect(document.activeElement).toBe(screen.getByRole("tab", { name: "Settings" })); + await keyOn(document.activeElement as Element, "Home"); + expect(document.activeElement).toBe(overview); + }); + + it("a click opens a tab", async () => { + const onTab = vi.fn(); + render(); + const settings = screen.getByRole("tab", { name: "Settings" }); + await act(async () => { + fireEvent.click(settings); + }); + expect(onTab).toHaveBeenCalledWith("settings"); + expect(settings).toHaveAttribute("aria-selected", "true"); + }); + + it("draws one rule under the tabs, wide and narrow, and never wraps the row", () => { + const { container } = render(); + const header = container.querySelector("[data-resource-header]") as HTMLElement; + const tabsLine = container.querySelector("[data-resource-tabs]") as HTMLElement; + const classes = (el: Element) => el.className.split(" "); + // Kumo's own list border is off: the indicator sits on our rule only. + const list = within(header).getByRole("tablist"); + expect(classes(list)).toContain("border-b-0"); + expect(classes(list)).not.toContain("border-b"); + // Narrow: the tab line carries the rule; the header has none of its own. + expect(classes(tabsLine)).toEqual(expect.arrayContaining(["border-b", "border-kumo-hairline"])); + expect(classes(header)).not.toContain("border-b"); + // Wide: the rule moves to the header row, under tabs and actions alike. + expect(classes(tabsLine)).toContain("@3xl/page:border-b-0"); + expect(classes(header)).toEqual( + expect.arrayContaining(["@3xl/page:flex-row", "@3xl/page:border-b"]), + ); + // No wrapping row in either layout (wrapped, the rule left the tabs). + expect(classes(header)).not.toContain("flex-wrap"); + expect(header.children[1]).toBe(tabsLine); + }); +});