mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-07 02:55:24 +00:00
feat: shared toolbar, footer and host dot pieces for plugin UI
This commit is contained in:
1 parent
3c6229671c
commit
020f30541a
25 files changed
+539
-39
No files matched your search
@@ -11,6 +11,7 @@
|
||||
- Plugins can build pages of tiles you drag and resize, and add many copies of one dashboard card, each with its own settings
|
||||
- A plugin a newer release ships is downloaded on its own when your image does not have it, and tried again on the next start when offline
|
||||
- Plugins can decide who may connect to a host, so a host can need approved access before an SSH or remote desktop session opens
|
||||
- When more than one plugin flags a host, its row shows one status dot with every reason in its tooltip
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -25,6 +26,7 @@
|
||||
- The Linux Flatpak now installs from flatpak.termix.site on x86_64 and arm64, and `flatpak update` keeps it current
|
||||
- Dropdowns and text areas are the same size as the text fields next to them
|
||||
- A page's own tabs sit above its search bar
|
||||
- A plugin view that is still starting shows the same loading spinner as the rest of Termix
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -43,6 +45,9 @@
|
||||
- The hint at the bottom of Browse and the stored data footer on plugin pages have the same padding as the rest of the page
|
||||
- Sign in methods on the Security page have a line between them
|
||||
- Chart axis labels with long units, like KiB/s, are no longer cut off
|
||||
- Filters, view toggles and small buttons in a toolbar are the same height as the search box next to them
|
||||
- Save and Cancel at the bottom of a form are the same height as other buttons
|
||||
- State badges in tables no longer stretch across the whole column
|
||||
|
||||
## 26.11.0
|
||||
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
# Changelog
|
||||
|
||||
## 1.1.2
|
||||
|
||||
Plugin API 1.15.0.
|
||||
|
||||
### Added
|
||||
|
||||
- `/ui` exports `ToolbarBand` (a toolbar row outside the PanelShell toolbar), `ToolbarSelect` (a small select for those rows) and `TOOLBAR_FIT` (the classes that make every control in a row 32px)
|
||||
- `PANEL.section`, the one inner layout for a SectionCard body
|
||||
- `TextAreaField` takes `fill`, so a config or log editor uses the rest of the height
|
||||
- `registerHostBadge` takes `useDot`. Every plugin's dots merge into one dot on the host row, with the worst tone and each reason in its tooltip. `/ui` exports `HostDotGroup` for layouts that draw host rows
|
||||
|
||||
### Changed
|
||||
|
||||
- Controls in the PanelShell toolbar, tab band and footer are all 32px, so small buttons line up with search boxes and selects
|
||||
- `Segmented` and `ViewToggle` match the height of inputs and buttons, and `Segmented` keeps its natural width
|
||||
- `FormFooter` buttons are full height
|
||||
- `PANEL.body` is `p-3`, the same padding `InlineView` gives a form
|
||||
- A body with a pinned footer no longer draws a closing line just above the footer's own line
|
||||
- The placeholder for a plugin view that is still starting uses `LoadingState`
|
||||
- `termix-plugin validate` warns about UI drift: raw loading spinners, small back buttons, native selects, stretched filters, filled headers, capped log views and footers that scroll away
|
||||
- A `ToolbarBand` right inside a `TileStack` leaves the line under it to the stack
|
||||
- `ListBadge` and `StatusBadge` keep their own width in a table cell instead of filling it
|
||||
|
||||
## 1.1.1
|
||||
|
||||
Plugin API 1.15.0.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Termix plugin guide
|
||||
|
||||
SDK 1.1.1 | Plugin API 1.15.0
|
||||
SDK 1.1.2 | Plugin API 1.15.0
|
||||
|
||||
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
|
||||
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
|
||||
@@ -782,9 +782,27 @@ exports with props is `ui.api.txt` in the Termix repo
|
||||
`StatTile` or `MetricCard`, and use `TileFrame` for a titled section. Cards drop their own box inside
|
||||
these. Forms keep `PANEL.body` padding inside their section. An `InlineView` form made of `SectionCard`s
|
||||
takes `stacked`.
|
||||
- Page views go in `PanelShell`'s `tabs` band as a `TabStrip`. `Segmented` is for filters and view modes.
|
||||
A page with many sections uses `SideNav` on the left. Put the refresh button in the header with
|
||||
`RefreshButton`, and the docs link with the `docs` prop.
|
||||
- Page views go in `PanelShell`'s `tabs` band as a `TabStrip`. `Segmented` is for filters and view modes and
|
||||
keeps its natural width, never stretched across the pane. A page with many sections uses `SideNav` on the
|
||||
left. Put the refresh button in the header with `RefreshButton`, and the docs link with the `docs` prop.
|
||||
- Toolbars: search, filters and buttons go in `PanelShell`'s `toolbar` slot, or in a `ToolbarBand` for a second
|
||||
row. Both make every control in the row 32px, so a `size="sm"` button lines up with a search box. Use
|
||||
`ToolbarSelect` for a small select there, never a native `<select>`. Don't build your own row with its own
|
||||
padding.
|
||||
- Padding: loose content in a page body (a hint, a single field) takes `PANEL.body`. A `SectionCard` body uses
|
||||
`PANEL.section` for its fields.
|
||||
- Headers have no background. Label a group with `GroupHeading`, never a row with `bg-muted`.
|
||||
- Sub views: a detail or edit view opens as an `InlineView`, which has the one back button. Anywhere else use
|
||||
`BackButton` in `PanelShell`'s `leading` slot. Never make a smaller back button.
|
||||
- Save and Add always sit pinned at the bottom: pass `FormFooter` to `InlineView` or `PanelShell` as `footer`.
|
||||
Inside a tab body, wrap the form in `<PanelShell chrome={false} footer={...}>`.
|
||||
- Loading: `LoadingState` for anything still loading (`compact` in cards and rows). A spinner on its own is
|
||||
only for a button or an activity like "working".
|
||||
- Config and log editors fill the rest of the view: `TextAreaField fill`, or `min-h-0 flex-1` on a log, never a
|
||||
fixed number of rows or a `max-h-[60vh]`.
|
||||
- Host row dots: report a problem with `registerHostBadge({ useDot })` instead of drawing a dot. The layout
|
||||
merges every plugin's dots into one.
|
||||
- `termix-plugin validate` lists drift from these rules under `ui`. They are warnings, so fix what applies.
|
||||
- States and data: `StatusBadge` and `StatusDot` (pass a state like "running" and the color follows),
|
||||
`InfoGrid` for label and value pairs, `DataTable` for a plain table, `Pager` and `usePaged` under long lists.
|
||||
- Tile pages use `TileGrid` with `TileFrame` around each tile and `TilePicker` beside it while editing. Slots are
|
||||
|
||||
@@ -51,6 +51,9 @@ export async function validate({ cwd }) {
|
||||
for (const warning of docsWarnings(cwd, raw)) {
|
||||
console.warn(` warn ${warning}`);
|
||||
}
|
||||
for (const warning of uiWarnings(cwd)) {
|
||||
console.warn(` ui ${warning}`);
|
||||
}
|
||||
|
||||
if (problems.length > 0) {
|
||||
for (const problem of problems) console.error(` ${problem}`);
|
||||
@@ -152,6 +155,92 @@ function walkSources(dir, out = []) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whether line i sits inside a still open Button within the lines before it. */
|
||||
function insideButton(lines, i, reach = 12) {
|
||||
const before = lines.slice(Math.max(0, i - reach), i + 1).join("\n");
|
||||
const open = before.lastIndexOf("<Button");
|
||||
const openLower = before.lastIndexOf("<button");
|
||||
const start = Math.max(open, openLower);
|
||||
if (start < 0) return false;
|
||||
return !/<\/(Button|button)>/.test(before.slice(start));
|
||||
}
|
||||
|
||||
const UI_RULES = [
|
||||
{
|
||||
test: (line) => /animate-spin|<Loader2\b/.test(line),
|
||||
// Spinners inside a button, and activity spinners like "working", are fine.
|
||||
skip: (lines, i) =>
|
||||
insideButton(lines, i) ||
|
||||
!/loading|Loading/.test(lines.slice(Math.max(0, i - 4), i + 4).join(" ")),
|
||||
message: "raw spinner while loading, use LoadingState",
|
||||
},
|
||||
{
|
||||
test: (line) => /<(ArrowLeft|ChevronLeft)\b/.test(line),
|
||||
// Only real back buttons, not previous page or month arrows.
|
||||
skip: (lines, i) =>
|
||||
!insideButton(lines, i, 6) ||
|
||||
!/back|onBack|close\b/i.test(
|
||||
lines.slice(Math.max(0, i - 6), i + 4).join(" "),
|
||||
),
|
||||
message:
|
||||
"hand made back button, use BackButton in PanelShell leading or InlineView",
|
||||
},
|
||||
{
|
||||
test: (line) => /<select\b/.test(line),
|
||||
message: "native select, use Select or SelectField",
|
||||
},
|
||||
{
|
||||
test: (line) => /\[&>button\]:flex-1/.test(line),
|
||||
message: "stretched Segmented, keep filters at their natural width",
|
||||
},
|
||||
{
|
||||
test: (line) =>
|
||||
/(^|[\s"'`])bg-(muted|card|accent)(\/\d+)?(?=[\s"'`]|$)/.test(line) &&
|
||||
/\bborder-b\b/.test(line) &&
|
||||
/(uppercase|GroupHeading|py-1\.5)/.test(line),
|
||||
message: "filled section header, headers have no background",
|
||||
},
|
||||
{
|
||||
test: (line) => /max-h-\[\d+vh\]/.test(line),
|
||||
message: "viewer capped by screen height, let it fill the view",
|
||||
},
|
||||
{
|
||||
test: (line) => /<FormFooter\b/.test(line),
|
||||
skip: (lines, i) =>
|
||||
/footer\s*=/i.test(lines.slice(Math.max(0, i - 4), i + 1).join(" ")),
|
||||
message:
|
||||
"FormFooter in the body, pass it to an InlineView or PanelShell footer (PanelShell chrome={false} inside a tab) so it stays pinned",
|
||||
},
|
||||
{
|
||||
test: (line) =>
|
||||
/\{t\(["'][\w.]*[Ll]oading["']\)\}/.test(line) && !/label=/.test(line),
|
||||
message: "plain loading text, use LoadingState",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Drift from the shared UI kit in src/frontend: raw spinners, hand made back
|
||||
* buttons, native selects, stretched filters, filled headers and footers that
|
||||
* scroll away. Warnings only, since some cases are on purpose.
|
||||
*/
|
||||
export function uiWarnings(cwd) {
|
||||
const warnings = [];
|
||||
for (const file of walkSources(path.join(cwd, "src", "frontend"))) {
|
||||
if (!file.endsWith(".tsx")) continue;
|
||||
const lines = fs.readFileSync(file, "utf8").split(/\r?\n/);
|
||||
const rel = path.relative(cwd, file).replace(/\\/g, "/");
|
||||
lines.forEach((line, i) => {
|
||||
if (/^\s*(\/\/|\*|import\b)/.test(line)) return;
|
||||
for (const rule of UI_RULES) {
|
||||
if (rule.test(line) && !(rule.skip && rule.skip(lines, i))) {
|
||||
warnings.push(`${rel}:${i + 1} ${rule.message}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Docs gaps that do not block a release: no docs/index.md, or a process.env
|
||||
* read the manifest does not list in env.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@termix-ssh/plugin-sdk",
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.2",
|
||||
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -656,7 +656,7 @@ export interface HostProtocolContribution {
|
||||
quickConnect?: { showDomain?: boolean };
|
||||
}
|
||||
|
||||
export interface HostBadgeContribution {
|
||||
export interface HostRowContribution {
|
||||
id: string;
|
||||
when: (host: PluginHostRecord) => boolean;
|
||||
component: ComponentType<{ host: PluginHostRecord }>;
|
||||
@@ -664,11 +664,32 @@ export interface HostBadgeContribution {
|
||||
order?: number;
|
||||
}
|
||||
|
||||
/** A problem shown as the host row's status dot. */
|
||||
export interface HostDot {
|
||||
tone: "bad" | "warn";
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface HostBadgeContribution {
|
||||
id: string;
|
||||
when: (host: PluginHostRecord) => boolean;
|
||||
/** Drawn as is on the row. Leave out for a badge that is only a dot. */
|
||||
component?: ComponentType<{ host: PluginHostRecord }>;
|
||||
/**
|
||||
* A hook that returns a problem or null. Every plugin's dots merge into one
|
||||
* dot on the row, with the worst tone and each label in its tooltip, so two
|
||||
* plugins reporting the same host never draw two dots. Core 26.11.1+.
|
||||
*/
|
||||
useDot?: (host: PluginHostRecord) => HostDot | null;
|
||||
/** Lower comes first. Ties sort by id. */
|
||||
order?: number;
|
||||
}
|
||||
|
||||
/** The dot or stripe that shows a host's status on its row. */
|
||||
export type HostStatusIndicatorContribution = HostBadgeContribution;
|
||||
export type HostStatusIndicatorContribution = HostRowContribution;
|
||||
|
||||
/** A control in the host editor's toolbar. */
|
||||
export type HostEditorToolbarItemContribution = HostBadgeContribution;
|
||||
export type HostEditorToolbarItemContribution = HostRowContribution;
|
||||
|
||||
export interface HostSectionProps {
|
||||
host: PluginHostRecord;
|
||||
|
||||
@@ -149,6 +149,7 @@ HostDefaultsContextValue (type): HostDefaultsContextValue
|
||||
HostDefaultsLevel (type): HostDefaultsLevel
|
||||
HostDefaultsTarget (type): HostDefaultsTarget
|
||||
HostDensity (type): HostDensity
|
||||
HostDotGroup (value): ({ host, badges, className, }: { host: Host; badges: HostBadgeDef[]; className?: string; }) => Element
|
||||
HostFeatureFields (value): ({ form, updateForm, }: HostFeatureFieldsProps) => Element
|
||||
HostFeatureFieldsProps (type): HostFeatureFieldsProps
|
||||
HostFolder (type): HostFolder
|
||||
@@ -210,7 +211,7 @@ NoticePanel (value): ({ icon: Icon, title, children, action, tone, className, }:
|
||||
NumberField (value): ({ label, value, onChange, placeholder, hint, error, min, max, disabled, className, }: { label: string; value: number; onChange: (value: number) => void; placeholder?: string; hint?: ReactNode; error?: string; min?: number; max?: number; disabled?: boolean; className?: string; }) => Element
|
||||
OPEN_PLUGINS_EVENT (value): "termix:open-plugins"
|
||||
OpenTabRecord (type): OpenTabRecord
|
||||
PANEL (value): { readonly body: "p-2.5"; readonly gap: "gap-2"; readonly band: "px-3 py-2"; readonly cardHead: "px-3 py-2.5"; readonly header: "h-12.5"; }
|
||||
PANEL (value): { readonly body: "p-3"; readonly section: "flex flex-col gap-3 py-3"; readonly gap: "gap-2"; readonly band: "px-3 py-2"; readonly cardHead: "px-3 py-2.5"; readonly header: "h-12.5"; }
|
||||
PRESETS (value): Record<"simple" | "balanced" | "advanced", UiAreaPreferences>
|
||||
PROMPT_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 border border-border px-3 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||
PROMPT_DESTRUCTIVE_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 bg-destructive px-3 text-xs font-medium text-white transition-colors hover:bg-destructive/90 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||
@@ -309,6 +310,7 @@ SurfaceScope (value): ({ kind, onEditingChange, onClose, className, children, }:
|
||||
Switch (value): ({ className, ...props }: SwitchProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
SwitchRow (value): ({ label, description, badge, checked, onChange, disabled, defaultKey, rowId, }: { label: string; description?: ReactNode; badge?: string; checked: boolean; onChange: (value: boolean) => void; disabled?: boolean; defaultKey?: string; rowId?: string; }) => Element
|
||||
TILE_COLUMNS (value): 12
|
||||
TOOLBAR_FIT (value): "[&_[data-slot=button][data-size=sm]]:h-8 [&_[data-slot=button][data-size=xs]]:h-8 [&_[data-slot=button][data-size=icon-sm]]:size-8 [&_[data-slot=button][data-size=icon-xs]]:size-8 [&_[data-slot=input]]:h-8 [&_[data-slot=select-trigger]]:h-8"
|
||||
TOTPDialog (value): ({ isOpen, prompt, mode, waiting, onSubmit, onCancel, backgroundColor, }: TOTPDialogProps) => Element
|
||||
Tab (type): Tab
|
||||
TabShellCallbacks (type): TabShellCallbacks
|
||||
@@ -318,7 +320,7 @@ TabStrip (value): ({ tabs, activeTab, onTabChange, isActive, variant, trailing,
|
||||
TabStripItem (type): TabStripItem
|
||||
TabType (type): TabType
|
||||
TagInput (value): ({ tags, input, placeholder, onTagsChange, onInputChange, }: { tags: string[]; input: string; placeholder: string; onTagsChange: (tags: string[]) => void; onInputChange: (value: string) => void; }) => Element
|
||||
TextAreaField (value): ({ label, value, onChange, placeholder, rows, hint, error, aside, mono, disabled, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; rows?: number; hint?: ReactNode; error?: string; aside?: ReactNode; mono?: boolean; disabled?: boolean; className?: string; }) => Element
|
||||
TextAreaField (value): ({ label, value, onChange, placeholder, rows, hint, error, aside, mono, disabled, fill, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; rows?: number; hint?: ReactNode; error?: string; aside?: ReactNode; mono?: boolean; disabled?: boolean; fill?: boolean; className?: string; }) => Element
|
||||
TextField (value): ({ label, value, onChange, placeholder, hint, error, aside, disabled, mono, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; hint?: ReactNode; error?: string; aside?: ReactNode; disabled?: boolean; mono?: boolean; className?: string; }) => Element
|
||||
Textarea (value): ForwardRefExoticComponent<TextareaProps & RefAttributes<HTMLTextAreaElement>>
|
||||
TextareaProps (type): TextareaProps
|
||||
@@ -335,6 +337,8 @@ TileSlot (type): TileSlot
|
||||
TileStack (value): ({ className, children, }: { className?: string; children: ReactNode; }) => Element
|
||||
TileWall (value): ({ size, minWidth, gridClassName, className, children, }: { size?: GridSize; minWidth?: number; gridClassName?: string; className?: string; children: ReactNode; }) => Element
|
||||
TlsStatus (type): TlsStatus
|
||||
ToolbarBand (value): ({ className, children, }: { className?: string; children: ReactNode; }) => Element
|
||||
ToolbarSelect (value): <T extends string>({ value, onChange, options, label, disabled, className, }: { value: T; onChange: (value: NoInfer<T>) => void; options: { value: NoInfer<T>; label: string; }[]; label: string; disabled?: boolean; className?: string; }) => Element
|
||||
ToolsTab (type): ToolsTab
|
||||
Tooltip (value): ({ ...props }: TooltipProps) => Element
|
||||
TooltipContent (value): ({ className, sideOffset, children, ...props }: TooltipContentProps & RefAttributes<HTMLDivElement>) => Element
|
||||
|
||||
@@ -20,6 +20,10 @@ const TSCONFIG = path.join(ROOT, "tsconfig.app.json");
|
||||
|
||||
const ALLOWED = [
|
||||
"SeamlessTileContext",
|
||||
"ToolbarBand",
|
||||
"ToolbarSelect",
|
||||
"HostDotGroup",
|
||||
"TOOLBAR_FIT",
|
||||
"TileEditBar",
|
||||
"TileStack",
|
||||
"TileColumn",
|
||||
|
||||
@@ -253,6 +253,7 @@ export function TextAreaField({
|
||||
aside,
|
||||
mono,
|
||||
disabled,
|
||||
fill,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
@@ -265,6 +266,8 @@ export function TextAreaField({
|
||||
aside?: ReactNode;
|
||||
mono?: boolean;
|
||||
disabled?: boolean;
|
||||
/** Takes the rest of the height, for config and log editors. */
|
||||
fill?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
@@ -273,7 +276,7 @@ export function TextAreaField({
|
||||
hint={hint}
|
||||
error={error}
|
||||
aside={aside}
|
||||
className={className}
|
||||
className={cn(fill && "min-h-0 flex-1", className)}
|
||||
>
|
||||
<textarea
|
||||
rows={rows}
|
||||
@@ -284,6 +287,7 @@ export function TextAreaField({
|
||||
className={cn(
|
||||
"w-full resize-none border border-input bg-background px-3 py-2 text-xs text-foreground transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30",
|
||||
mono && "font-mono text-[10px]",
|
||||
fill && "min-h-40 flex-1",
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -220,7 +220,7 @@ export function ListBadge({
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-0.5 border px-1 py-px text-[9px] uppercase leading-none tracking-wider [&_svg]:size-2.5",
|
||||
"inline-flex w-fit shrink-0 items-center gap-0.5 border px-1 py-px text-[9px] uppercase leading-none tracking-wider [&_svg]:size-2.5",
|
||||
tone === "brand" &&
|
||||
"border-accent-brand/30 bg-accent-brand/10 text-accent-brand",
|
||||
tone === "warning" && "border-warning/30 bg-warning/10 text-warning",
|
||||
@@ -428,7 +428,6 @@ export function FormFooter({
|
||||
{onDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
disabled={saving}
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive dark:hover:bg-destructive/10"
|
||||
@@ -444,7 +443,7 @@ export function FormFooter({
|
||||
))}
|
||||
</div>
|
||||
{onCancel && (
|
||||
<Button variant="ghost" size="sm" onClick={onCancel} disabled={saving}>
|
||||
<Button variant="ghost" onClick={onCancel} disabled={saving}>
|
||||
{cancelLabel ?? t("common.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
@@ -454,7 +453,6 @@ export function FormFooter({
|
||||
type={saveType}
|
||||
form={form}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onSave}
|
||||
disabled={disabled || saving}
|
||||
className="border-accent-brand/40 px-6 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
|
||||
|
||||
@@ -19,6 +19,7 @@ export function LoadingState({
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
data-slot="loading-state"
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground",
|
||||
className,
|
||||
@@ -32,6 +33,7 @@ export function LoadingState({
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
data-slot="loading-state"
|
||||
className={cn(
|
||||
"flex flex-1 flex-col items-center justify-center gap-2 px-4 py-12 text-xs text-muted-foreground",
|
||||
className,
|
||||
|
||||
@@ -102,7 +102,7 @@ export function ManagerSearch({
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder ?? t("managerCard.filter")}
|
||||
className="h-7 w-full border border-border bg-background pl-7 pr-2 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
className="h-8 w-full border border-border bg-background pl-7 pr-2 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
{count != null && (
|
||||
|
||||
@@ -16,6 +16,7 @@ import { cn } from "@/lib/utils";
|
||||
import { Separator } from "@/components/separator";
|
||||
import { DocsLink } from "@/components/docs-link";
|
||||
import { useInSeamlessTile } from "@/components/tile-grid/context";
|
||||
import { FooterClosed } from "@/components/tile-grid/edge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -26,13 +27,81 @@ import {
|
||||
|
||||
/** One spacing rhythm for every panel and tab. */
|
||||
export const PANEL = {
|
||||
body: "p-2.5",
|
||||
body: "p-3",
|
||||
section: "flex flex-col gap-3 py-3",
|
||||
gap: "gap-2",
|
||||
band: "px-3 py-2",
|
||||
cardHead: "px-3 py-2.5",
|
||||
header: "h-12.5",
|
||||
} as const;
|
||||
|
||||
/** Makes every control in a toolbar row the same 32px height. */
|
||||
export const TOOLBAR_FIT =
|
||||
"[&_[data-slot=button][data-size=sm]]:h-8 [&_[data-slot=button][data-size=xs]]:h-8 [&_[data-slot=button][data-size=icon-sm]]:size-8 [&_[data-slot=button][data-size=icon-xs]]:size-8 [&_[data-slot=input]]:h-8 [&_[data-slot=select-trigger]]:h-8";
|
||||
|
||||
/** A small unlabelled select for toolbar rows, as tall as the other controls. */
|
||||
export function ToolbarSelect<T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
label,
|
||||
disabled,
|
||||
className,
|
||||
}: {
|
||||
value: T;
|
||||
onChange: (value: NoInfer<T>) => void;
|
||||
options: { value: NoInfer<T>; label: string }[];
|
||||
/** Read by screen readers, since the select has no visible label. */
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={(next) => onChange(next as T)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={label}
|
||||
className={cn("h-8 w-auto text-xs", className)}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
/** A toolbar row for controls outside the PanelShell toolbar slot. */
|
||||
export function ToolbarBand({
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="toolbar-band"
|
||||
className={cn(
|
||||
"flex shrink-0 flex-wrap items-center gap-2 border-b border-border",
|
||||
PANEL.band,
|
||||
TOOLBAR_FIT,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type PanelViewMode = "grid" | "list";
|
||||
export type PanelDensity = "comfortable" | "compact";
|
||||
|
||||
@@ -112,18 +181,20 @@ export function PanelShell({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<FooterClosed closed={!!footer}>{children}</FooterClosed>
|
||||
</div>
|
||||
);
|
||||
|
||||
const bands = (
|
||||
<>
|
||||
{tabs && (
|
||||
<div className="shrink-0 border-b border-border px-1">{tabs}</div>
|
||||
<div className={`shrink-0 border-b border-border px-1 ${TOOLBAR_FIT}`}>
|
||||
{tabs}
|
||||
</div>
|
||||
)}
|
||||
{toolbar && (
|
||||
<div
|
||||
className={`flex shrink-0 items-center gap-2 border-b border-border ${PANEL.band}`}
|
||||
className={`flex shrink-0 items-center gap-2 border-b border-border ${PANEL.band} ${TOOLBAR_FIT}`}
|
||||
>
|
||||
{toolbar}
|
||||
</div>
|
||||
@@ -132,7 +203,9 @@ export function PanelShell({
|
||||
);
|
||||
|
||||
const foot = footer && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-t border-border px-3 py-2">
|
||||
<div
|
||||
className={`flex shrink-0 items-center gap-2 border-t border-border px-3 py-2 ${TOOLBAR_FIT}`}
|
||||
>
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
@@ -375,7 +448,11 @@ export function Segmented<T extends string>({
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
className={cn("flex items-center border border-border", className)}
|
||||
data-slot="segmented"
|
||||
className={cn(
|
||||
"flex w-fit shrink-0 items-center border border-border",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{options.map((option, i) => {
|
||||
const active = option.value === value;
|
||||
@@ -388,7 +465,7 @@ export function Segmented<T extends string>({
|
||||
title={option.title ?? option.label}
|
||||
onClick={() => onChange(option.value)}
|
||||
className={cn(
|
||||
"flex h-[30px] items-center justify-center gap-1.5 px-2.5 text-[11px] font-medium transition-colors focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring",
|
||||
"flex h-[calc(2rem-2px)] items-center justify-center gap-1.5 px-2.5 text-[11px] font-medium transition-colors focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring",
|
||||
i > 0 && "border-l border-border",
|
||||
active
|
||||
? "bg-accent-brand/10 text-accent-brand"
|
||||
@@ -428,7 +505,10 @@ export function ViewToggle({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const toggle = (
|
||||
<div className="flex items-center border border-border">
|
||||
<div
|
||||
data-slot="view-toggle"
|
||||
className="flex items-center border border-border"
|
||||
>
|
||||
<ToggleButton
|
||||
active={view === "grid"}
|
||||
onClick={() => onView("grid")}
|
||||
@@ -513,7 +593,7 @@ function ToggleButton({
|
||||
aria-label={title}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"flex size-[30px] items-center justify-center transition-colors focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring",
|
||||
"flex size-[calc(2rem-2px)] items-center justify-center transition-colors focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring",
|
||||
bordered && "border-l border-border",
|
||||
active
|
||||
? "bg-accent-brand/10 text-accent-brand"
|
||||
|
||||
@@ -50,7 +50,7 @@ const SNAP = 8;
|
||||
const SCROLL_EDGE = 48;
|
||||
|
||||
const styles = `
|
||||
.tg-grid { position: relative; }
|
||||
.tg-grid { position: relative; overflow-x: clip; }
|
||||
.tg-tile { position: absolute; left: 0; top: 0; min-width: 0; display: flex; flex-direction: column; }
|
||||
.tg-grid[data-animate="true"] .tg-tile:not([data-active]) { transition: transform 160ms ease, width 160ms ease, height 160ms ease; }
|
||||
.tg-tile[data-active] { z-index: 40; }
|
||||
|
||||
@@ -21,7 +21,7 @@ export function TileStack({
|
||||
{Children.toArray(children).map((section, index) => (
|
||||
<div
|
||||
key={keyOf(section, index)}
|
||||
className="min-w-0 shrink-0 border-b border-border"
|
||||
className="min-w-0 shrink-0 border-b border-border [&>[data-slot=toolbar-band]]:border-b-0"
|
||||
>
|
||||
{section}
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,23 @@ export function EdgeOwned({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** True in a body with a pinned footer, whose top line already closes it. */
|
||||
const FooterClosedContext = createContext(false);
|
||||
|
||||
export function FooterClosed({
|
||||
closed,
|
||||
children,
|
||||
}: {
|
||||
closed: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<FooterClosedContext.Provider value={closed}>
|
||||
{children}
|
||||
</FooterClosedContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the element's end reaches the bottom of the visible scroll area,
|
||||
* where a closing line would sit on the screen edge and only add noise.
|
||||
@@ -65,7 +82,7 @@ function useReachesBottom(ref: React.RefObject<HTMLElement | null>) {
|
||||
* Wraps tiles whose last row draws a line under itself. The line stays when
|
||||
* there is room below and is clipped when the tiles reach the bottom of the
|
||||
* screen, the same rule the tile grid uses. Inside a stack section the
|
||||
* section owns the line, so it is always clipped.
|
||||
* section owns the line, so it is always clipped, and so does a pinned footer.
|
||||
*/
|
||||
export function BottomEdge({
|
||||
className,
|
||||
@@ -77,11 +94,14 @@ export function BottomEdge({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const owned = useContext(EdgeOwnedContext);
|
||||
const closed = useContext(FooterClosedContext);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const reaches = useReachesBottom(ref);
|
||||
return (
|
||||
<div ref={ref} className={cn("min-w-0 overflow-clip", className)}>
|
||||
<div className={cn((owned || reaches) && "-mb-px", innerClassName)}>
|
||||
<div
|
||||
className={cn((owned || closed || reaches) && "-mb-px", innerClassName)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -261,7 +261,7 @@ export function RepairView({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col border border-border">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-border bg-muted/30 px-3 py-2">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-border px-3 py-2">
|
||||
<span className="truncate text-xs text-foreground">
|
||||
{name}
|
||||
{status.pluginId && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CircleSlash } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/button";
|
||||
import { LoadingState } from "@/components/loading-state";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import { pluginName } from "@/lib/plugin-i18n";
|
||||
import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins";
|
||||
@@ -32,12 +33,11 @@ export function PluginViewPlaceholder({
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div
|
||||
className="flex h-full w-full items-center justify-center"
|
||||
aria-label={t("plugins.runtime.loading", { name })}
|
||||
>
|
||||
<div className="size-5 rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground/70 animate-spin" />
|
||||
</div>
|
||||
<LoadingState
|
||||
compact={compact}
|
||||
label={t("plugins.runtime.loading", { name })}
|
||||
className={compact ? undefined : "h-full w-full"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import {
|
||||
registerHostAction,
|
||||
registerHostBadge,
|
||||
hostDotReporter,
|
||||
registerHostEditorToolbarItem,
|
||||
registerHostStatusIndicator,
|
||||
registerHostContextMenuItem,
|
||||
@@ -398,7 +399,12 @@ export function createPluginApp(
|
||||
id: badge.id,
|
||||
pluginId,
|
||||
when: hostCallback(badge.when) as never,
|
||||
component: scoped(badge.component) as never,
|
||||
component: badge.component
|
||||
? (scoped(badge.component) as never)
|
||||
: undefined,
|
||||
dot: badge.useDot
|
||||
? (scoped(hostDotReporter(badge.useDot)) as never)
|
||||
: undefined,
|
||||
order: badge.order,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -323,6 +323,9 @@ export {
|
||||
PanelShell,
|
||||
RefreshButton,
|
||||
Segmented,
|
||||
TOOLBAR_FIT,
|
||||
ToolbarBand,
|
||||
ToolbarSelect,
|
||||
ViewToggle,
|
||||
type GridSize,
|
||||
type PanelDensity,
|
||||
@@ -834,6 +837,7 @@ export {
|
||||
useHostEditorToolbarItems,
|
||||
useHostStatusIndicators,
|
||||
} from "@/sidebar/host-contributions";
|
||||
export { HostDotGroup } from "@/sidebar/HostDotGroup";
|
||||
export { useEditorDefaultKeys } from "@/sidebar/host-defaults/catalog";
|
||||
export {
|
||||
enabledHostProtocols,
|
||||
|
||||
@@ -65,6 +65,7 @@ export function TabStage({ className }: { className?: string }) {
|
||||
elements) by TabPortals. Hidden while a split is active. */}
|
||||
<div
|
||||
ref={store.setNormalView}
|
||||
data-slot="tab-stage"
|
||||
className="absolute inset-0"
|
||||
style={{ display: activeSplit ? "none" : undefined }}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { StatusDot } from "@/components/status";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/tooltip";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import type { HostBadgeDef, HostDot } from "./host-contributions";
|
||||
|
||||
function DotSlot({
|
||||
badge,
|
||||
host,
|
||||
onDot,
|
||||
}: {
|
||||
badge: HostBadgeDef;
|
||||
host: Host;
|
||||
onDot: (id: string, dot: HostDot | null) => void;
|
||||
}) {
|
||||
const Reporter = badge.dot!;
|
||||
const report = useCallback(
|
||||
(dot: HostDot | null) => onDot(badge.id, dot),
|
||||
[badge.id, onDot],
|
||||
);
|
||||
return <Reporter host={host} report={report} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one status dot on a host row. Every badge that reports a dot feeds it,
|
||||
* so the worst tone wins and the tooltip lists each reason.
|
||||
*/
|
||||
export function HostDotGroup({
|
||||
host,
|
||||
badges,
|
||||
className,
|
||||
}: {
|
||||
host: Host;
|
||||
badges: HostBadgeDef[];
|
||||
className?: string;
|
||||
}) {
|
||||
const [dots, setDots] = useState<Record<string, HostDot | null>>({});
|
||||
const onDot = useCallback((id: string, dot: HostDot | null) => {
|
||||
setDots((current) => {
|
||||
const before = current[id];
|
||||
if (before?.tone === dot?.tone && before?.label === dot?.label)
|
||||
return current;
|
||||
return { ...current, [id]: dot };
|
||||
});
|
||||
}, []);
|
||||
const sources = badges.filter((badge) => badge.dot);
|
||||
const active = useMemo(
|
||||
() =>
|
||||
sources
|
||||
.map((badge) => dots[badge.id])
|
||||
.filter((dot): dot is HostDot => !!dot),
|
||||
[sources, dots],
|
||||
);
|
||||
const tone = active.some((dot) => dot.tone === "bad") ? "bad" : "warn";
|
||||
const lines = [
|
||||
...new Set(active.map((dot) => dot.label).filter(Boolean)),
|
||||
].join("\n");
|
||||
|
||||
return (
|
||||
<>
|
||||
{sources.map((badge) => (
|
||||
<DotSlot key={badge.id} badge={badge} host={host} onDot={onDot} />
|
||||
))}
|
||||
{active.length > 0 && (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
data-testid="host-dot"
|
||||
className="flex shrink-0 items-center"
|
||||
>
|
||||
<StatusDot tone={tone} className={className} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{lines && (
|
||||
<TooltipContent side="right" className="whitespace-pre-line">
|
||||
{lines}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { useEffect, type ComponentType } from "react";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import { byOrderThenId, createRegistry } from "@/lib/registry";
|
||||
import type { TabShellCallbacks } from "@/shell/tab-registry";
|
||||
@@ -37,11 +37,26 @@ export interface HostActionDef {
|
||||
}[];
|
||||
}
|
||||
|
||||
/** A problem a plugin wants shown as the host row's one status dot. */
|
||||
export interface HostDot {
|
||||
tone: "bad" | "warn";
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Calls a plugin's dot hook and passes the answer up to the dot group. */
|
||||
export type HostDotReporter = ComponentType<{
|
||||
host: Host;
|
||||
report: (dot: HostDot | null) => void;
|
||||
}>;
|
||||
|
||||
export interface HostBadgeDef {
|
||||
id: string;
|
||||
pluginId?: string;
|
||||
when: (host: Host) => boolean;
|
||||
component: ComponentType<{ host: Host }>;
|
||||
/** Drawn as is. Leave out when the badge is only a dot. */
|
||||
component?: ComponentType<{ host: Host }>;
|
||||
/** Merged with every other dot into one dot on the row. */
|
||||
dot?: HostDotReporter;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
@@ -114,6 +129,22 @@ export function defaultConnectAction(
|
||||
)[0];
|
||||
}
|
||||
|
||||
/** Turns a plugin's dot hook into a component the dot group can mount. */
|
||||
export function hostDotReporter(
|
||||
useDot: (host: Host) => HostDot | null,
|
||||
): HostDotReporter {
|
||||
return function HostDotReport({ host, report }) {
|
||||
const dot = useDot(host);
|
||||
const tone = dot?.tone;
|
||||
const label = dot?.label ?? "";
|
||||
useEffect(() => {
|
||||
report(tone ? { tone, label } : null);
|
||||
}, [tone, label, report]);
|
||||
useEffect(() => () => report(null), [report]);
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
export function hostBadgesFor(all: HostBadgeDef[], host: Host): HostBadgeDef[] {
|
||||
return all.filter((badge) => safeWhen(badge.when, host));
|
||||
}
|
||||
|
||||
@@ -10,9 +10,12 @@ import {
|
||||
PanelSearch,
|
||||
PanelShell,
|
||||
Segmented,
|
||||
ToolbarBand,
|
||||
ViewToggle,
|
||||
gridSizeStyle,
|
||||
} from "@/components/panel-layout";
|
||||
import { TileColumn } from "@/components/tile-grid/TileColumn";
|
||||
import { TileStack } from "@/components/tile-grid/TileStack";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
@@ -148,3 +151,61 @@ describe("PanelShell", () => {
|
||||
expect(screen.getByText("footer")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolbar sizing", () => {
|
||||
it("fits small controls in the toolbar and in ToolbarBand", () => {
|
||||
render(
|
||||
<>
|
||||
<PanelShell title="X" toolbar={<span>tools</span>}>
|
||||
body
|
||||
</PanelShell>
|
||||
<ToolbarBand>band</ToolbarBand>
|
||||
</>,
|
||||
);
|
||||
const band = screen.getByText("band");
|
||||
expect(band.className).toContain(
|
||||
"[&_[data-slot=button][data-size=sm]]:h-8",
|
||||
);
|
||||
expect(screen.getByText("tools").parentElement!.className).toContain(
|
||||
"[&_[data-slot=input]]:h-8",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps Segmented at its natural width", () => {
|
||||
render(
|
||||
<Segmented<"a" | "b">
|
||||
value="a"
|
||||
onChange={() => {}}
|
||||
options={[
|
||||
{ value: "a", label: "A" },
|
||||
{ value: "b", label: "B" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("radiogroup").className).toContain("w-fit");
|
||||
});
|
||||
|
||||
it("clips the last tile line when a footer is pinned", () => {
|
||||
const { container } = render(
|
||||
<PanelShell title="Form" footer={<span>save</span>}>
|
||||
<TileColumn>
|
||||
<div>field</div>
|
||||
</TileColumn>
|
||||
</PanelShell>,
|
||||
);
|
||||
expect(container.querySelector(".-mb-px")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ToolbarBand in a tile stack", () => {
|
||||
it("leaves the line under it to the stack", () => {
|
||||
render(
|
||||
<TileStack>
|
||||
<ToolbarBand>inside</ToolbarBand>
|
||||
</TileStack>,
|
||||
);
|
||||
expect(screen.getByText("inside").parentElement!.className).toContain(
|
||||
"[&>[data-slot=toolbar-band]]:border-b-0",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { HostDotGroup } from "@/sidebar/HostDotGroup";
|
||||
import {
|
||||
hostDotReporter,
|
||||
type HostBadgeDef,
|
||||
type HostDot,
|
||||
} from "@/sidebar/host-contributions";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const host = { id: 1, name: "web" } as unknown as Host;
|
||||
|
||||
function badge(id: string, dot: HostDot | null): HostBadgeDef {
|
||||
return { id, when: () => true, dot: hostDotReporter(() => dot) };
|
||||
}
|
||||
|
||||
describe("HostDotGroup", () => {
|
||||
it("draws one dot with the worst tone for many reports", () => {
|
||||
render(
|
||||
<HostDotGroup
|
||||
host={host}
|
||||
badges={[
|
||||
badge("uptime", { tone: "bad", label: "1 monitor down" }),
|
||||
badge("ops", { tone: "warn", label: "Disk: 91%" }),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
const dots = screen.getAllByTestId("host-dot");
|
||||
expect(dots).toHaveLength(1);
|
||||
expect(dots[0].firstElementChild!.className).toContain("bg-destructive");
|
||||
});
|
||||
|
||||
it("draws nothing when no badge reports", () => {
|
||||
render(<HostDotGroup host={host} badges={[badge("uptime", null)]} />);
|
||||
expect(screen.queryByTestId("host-dot")).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user