mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 02:55:51 +00:00
Canonicalize copy value controls
This commit is contained in:
@@ -166,6 +166,7 @@ work extends shared components instead of creating new local variants.
|
||||
137. `frontend-modern/src/components/shared/Button.tsx`
|
||||
138. `frontend-modern/src/components/shared/buttonModel.ts`
|
||||
139. `frontend-modern/src/components/shared/Button.test.tsx`
|
||||
140. `frontend-modern/src/components/shared/CopyableCodeRow.tsx`
|
||||
|
||||
## Shared Boundaries
|
||||
|
||||
@@ -473,6 +474,12 @@ not a replacement status card, CTA band, or page-local nested card.
|
||||
If a new surface needs a variant that the shared primitive does not expose,
|
||||
extend the primitive and registry guard rather than adding a page-local
|
||||
class string.
|
||||
Copy-value affordances belong to the same shared button family. Feature
|
||||
surfaces may own the copied value, success/error notification, and adjacent
|
||||
product copy, but icon/chip copy controls must use `CopyValueButton`, and
|
||||
copyable command/path/value rows must use `CopyableCodeRow` instead of
|
||||
recreating local copy icons, copied-state checks, disabled empty-value
|
||||
handling, or `font-mono` code-row shells.
|
||||
Framed product table surfaces must consume the shared `TableCard` frame and
|
||||
`TableCardHeader` title/action band instead of composing page-local `Card`
|
||||
border, background, overflow, or table-title chrome. Feature owners may own
|
||||
|
||||
@@ -220,6 +220,66 @@
|
||||
"scripts/shared-template-audit.mjs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "copy-value-action-shell",
|
||||
"category": "copy-action",
|
||||
"summary": "Copy-value icon and chip controls must compose CopyValueButton for shared copied-state iconography, disabled handling, focus, sizing, and aria behavior instead of recreating page-local copy buttons.",
|
||||
"canonical": {
|
||||
"path": "src/components/shared/Button.tsx",
|
||||
"export": "CopyValueButton"
|
||||
},
|
||||
"requiredConsumers": [
|
||||
{ "path": "src/components/Discovery/DiscoveryTab.tsx" },
|
||||
{ "path": "src/components/shared/WebInterfaceUrlField.tsx" }
|
||||
],
|
||||
"forbiddenPatterns": [
|
||||
{
|
||||
"path": "src/components/Discovery/DiscoveryTab.tsx",
|
||||
"patterns": [
|
||||
"interface CopyValueButtonProps",
|
||||
"const CopyValueButton",
|
||||
"CopyIcon class=\"h-3.5 w-3.5\"",
|
||||
"CheckIcon class=\"h-3.5 w-3.5 text-emerald-600"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/components/shared/WebInterfaceUrlField.tsx",
|
||||
"patterns": [
|
||||
"CopyIcon class=\"h-3.5 w-3.5\"",
|
||||
"CheckIcon class=\"h-3.5 w-3.5 text-emerald-600"
|
||||
]
|
||||
}
|
||||
],
|
||||
"proof": [
|
||||
"src/components/shared/SharedPrimitives.guardrails.test.ts",
|
||||
"src/components/shared/Button.test.tsx",
|
||||
"scripts/shared-template-audit.mjs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "copyable-code-row-shell",
|
||||
"category": "copy-action",
|
||||
"summary": "Copyable code/value rows must compose CopyableCodeRow for the shared code surface and copy action instead of recreating local font-mono row shells.",
|
||||
"canonical": {
|
||||
"path": "src/components/shared/CopyableCodeRow.tsx",
|
||||
"export": "CopyableCodeRow"
|
||||
},
|
||||
"requiredConsumers": [{ "path": "src/components/Discovery/DiscoveryTab.tsx" }],
|
||||
"forbiddenPatterns": [
|
||||
{
|
||||
"path": "src/components/Discovery/DiscoveryTab.tsx",
|
||||
"patterns": [
|
||||
"const CopyableCodeRow",
|
||||
"flex items-start gap-2 rounded bg-surface-alt px-2 py-1.5"
|
||||
]
|
||||
}
|
||||
],
|
||||
"proof": [
|
||||
"src/components/shared/SharedPrimitives.guardrails.test.ts",
|
||||
"src/components/shared/Button.test.tsx",
|
||||
"scripts/shared-template-audit.mjs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "form-select-shell",
|
||||
"category": "form-control",
|
||||
@@ -1100,6 +1160,94 @@
|
||||
"scripts/shared-template-audit.mjs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "copy-value-neutral-local-button-shell",
|
||||
"category": "copy-action",
|
||||
"summary": "Copy-value controls must not copy the neutral bordered icon-button shell; use CopyValueButton with the neutral variant.",
|
||||
"canonical": {
|
||||
"path": "src/components/shared/Button.tsx",
|
||||
"export": "CopyValueButton"
|
||||
},
|
||||
"scopes": ["src/components", "src/features", "src/pages"],
|
||||
"extensions": [".tsx"],
|
||||
"allPatterns": [
|
||||
"inline-flex min-h-7 min-w-7 shrink-0 items-center justify-center rounded border border-border bg-surface px-2 text-muted transition-colors hover:bg-surface-hover hover:text-base-content",
|
||||
"fallback={<CopyIcon class=\"h-3.5 w-3.5\" />}"
|
||||
],
|
||||
"legacyReason": "Retired migration debt. Neutral copy-value icon buttons belong to CopyValueButton.",
|
||||
"allowedPaths": [],
|
||||
"proof": [
|
||||
"src/components/shared/SharedPrimitives.guardrails.test.ts",
|
||||
"src/components/shared/Button.test.tsx",
|
||||
"scripts/shared-template-audit.mjs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "copy-value-muted-local-button-shell",
|
||||
"category": "copy-action",
|
||||
"summary": "Copy-value controls must not copy the muted icon-button shell; use CopyValueButton with the ghost variant.",
|
||||
"canonical": {
|
||||
"path": "src/components/shared/Button.tsx",
|
||||
"export": "CopyValueButton"
|
||||
},
|
||||
"scopes": ["src/components", "src/features", "src/pages"],
|
||||
"extensions": [".tsx"],
|
||||
"allPatterns": [
|
||||
"inline-flex min-h-8 min-w-8 items-center justify-center rounded-md text-muted transition-colors hover:bg-surface-hover hover:text-base-content",
|
||||
"fallback={<CopyIcon class=\"h-3.5 w-3.5\" />}"
|
||||
],
|
||||
"legacyReason": "Retired migration debt. Muted copy-value icon buttons belong to CopyValueButton.",
|
||||
"allowedPaths": [],
|
||||
"proof": [
|
||||
"src/components/shared/SharedPrimitives.guardrails.test.ts",
|
||||
"src/components/shared/Button.test.tsx",
|
||||
"scripts/shared-template-audit.mjs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "copy-value-accent-local-button-shell",
|
||||
"category": "copy-action",
|
||||
"summary": "Copy-value controls must not copy the blue suggested-value icon-button shell; use CopyValueButton with the accent variant.",
|
||||
"canonical": {
|
||||
"path": "src/components/shared/Button.tsx",
|
||||
"export": "CopyValueButton"
|
||||
},
|
||||
"scopes": ["src/components", "src/features", "src/pages"],
|
||||
"extensions": [".tsx"],
|
||||
"allPatterns": [
|
||||
"inline-flex min-h-7 min-w-7 shrink-0 items-center justify-center rounded text-blue-700 transition-colors hover:bg-blue-100 dark:text-blue-200 dark:hover:bg-blue-950",
|
||||
"fallback={<CopyIcon class=\"h-3.5 w-3.5\" />}"
|
||||
],
|
||||
"legacyReason": "Retired migration debt. Accent copy-value icon buttons belong to CopyValueButton.",
|
||||
"allowedPaths": [],
|
||||
"proof": [
|
||||
"src/components/shared/SharedPrimitives.guardrails.test.ts",
|
||||
"src/components/shared/Button.test.tsx",
|
||||
"scripts/shared-template-audit.mjs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "copyable-code-row-local-shell",
|
||||
"category": "copy-action",
|
||||
"summary": "Copyable code/value rows must not copy the local surface-alt font-mono row shell; use CopyableCodeRow.",
|
||||
"canonical": {
|
||||
"path": "src/components/shared/CopyableCodeRow.tsx",
|
||||
"export": "CopyableCodeRow"
|
||||
},
|
||||
"scopes": ["src/components", "src/features", "src/pages"],
|
||||
"extensions": [".tsx"],
|
||||
"allPatterns": [
|
||||
"flex items-start gap-2 rounded bg-surface-alt px-2 py-1.5",
|
||||
"break-all font-mono text-xs text-base-content"
|
||||
],
|
||||
"legacyReason": "Retired migration debt. Copyable code rows belong to CopyableCodeRow so code styling and copy behavior stay canonical.",
|
||||
"allowedPaths": [],
|
||||
"proof": [
|
||||
"src/components/shared/SharedPrimitives.guardrails.test.ts",
|
||||
"src/components/shared/Button.test.tsx",
|
||||
"scripts/shared-template-audit.mjs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "button-secondary-settings-action-local-shell",
|
||||
"category": "action-button",
|
||||
@@ -1154,9 +1302,7 @@
|
||||
},
|
||||
"scopes": ["src/components/Settings", "src/features", "src/pages"],
|
||||
"extensions": [".tsx"],
|
||||
"allPatterns": [
|
||||
"rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white"
|
||||
],
|
||||
"allPatterns": ["rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white"],
|
||||
"legacyReason": "Retired migration debt. Primary settings actions belong to the shared Button primitive family.",
|
||||
"allowedPaths": [],
|
||||
"ignoredPaths": ["src/components/shared/Button.test.tsx"],
|
||||
@@ -1176,9 +1322,7 @@
|
||||
},
|
||||
"scopes": ["src/components/Settings", "src/features", "src/pages"],
|
||||
"extensions": [".tsx"],
|
||||
"allPatterns": [
|
||||
"rounded-md bg-rose-600 px-3 py-2 text-sm font-medium text-white"
|
||||
],
|
||||
"allPatterns": ["rounded-md bg-rose-600 px-3 py-2 text-sm font-medium text-white"],
|
||||
"legacyReason": "Retired migration debt. Confirmed destructive settings actions belong to the shared Button primitive family.",
|
||||
"allowedPaths": [],
|
||||
"ignoredPaths": ["src/components/shared/Button.test.tsx"],
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { Component, For, Show, createMemo } from 'solid-js';
|
||||
import CheckIcon from 'lucide-solid/icons/check';
|
||||
import CopyIcon from 'lucide-solid/icons/copy';
|
||||
import ExternalLinkIcon from 'lucide-solid/icons/external-link';
|
||||
import TriangleAlertIcon from 'lucide-solid/icons/triangle-alert';
|
||||
import type { ResourceType } from '../../types/discovery';
|
||||
@@ -31,6 +29,8 @@ import {
|
||||
DISCOVERY_ANALYSIS_EXPLANATION,
|
||||
DISCOVERY_ANALYSIS_REASONING_LABEL,
|
||||
} from '@/utils/resourceAnalysisPresentation';
|
||||
import { CopyValueButton } from '@/components/shared/Button';
|
||||
import { CopyableCodeRow } from '@/components/shared/CopyableCodeRow';
|
||||
import { useDiscoveryTabState } from './useDiscoveryTabState';
|
||||
import { orderFactsByActionability } from './factOrdering';
|
||||
import { deriveCliCommand } from './cliCommand';
|
||||
@@ -51,54 +51,6 @@ interface DiscoveryTabProps {
|
||||
showManualRunAction?: boolean;
|
||||
}
|
||||
|
||||
interface CopyValueButtonProps {
|
||||
value?: string | null;
|
||||
copiedValue: () => string;
|
||||
onCopy: (value?: string | null) => void | Promise<void>;
|
||||
label: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const CopyValueButton: Component<CopyValueButtonProps> = (props) => {
|
||||
const trimmedValue = () => (props.value || '').trim();
|
||||
const copied = () => Boolean(trimmedValue()) && props.copiedValue() === trimmedValue();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class={
|
||||
props.class ||
|
||||
'inline-flex min-h-7 min-w-7 shrink-0 items-center justify-center rounded border border-border bg-surface px-2 text-muted transition-colors hover:bg-surface-hover hover:text-base-content'
|
||||
}
|
||||
onClick={() => void props.onCopy(trimmedValue())}
|
||||
disabled={!trimmedValue()}
|
||||
title={props.label}
|
||||
aria-label={props.label}
|
||||
>
|
||||
<Show when={copied()} fallback={<CopyIcon class="h-3.5 w-3.5" />}>
|
||||
<CheckIcon class="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
|
||||
</Show>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
interface CopyableCodeRowProps extends CopyValueButtonProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
const CopyableCodeRow: Component<CopyableCodeRowProps> = (props) => (
|
||||
<div class="flex items-start gap-2 rounded bg-surface-alt px-2 py-1.5">
|
||||
<code class="min-w-0 flex-1 break-all font-mono text-xs text-base-content">{props.value}</code>
|
||||
<CopyValueButton
|
||||
value={props.value}
|
||||
copiedValue={props.copiedValue}
|
||||
onCopy={props.onCopy}
|
||||
label={props.label}
|
||||
class="inline-flex min-h-6 min-w-6 shrink-0 items-center justify-center rounded text-muted transition-colors hover:bg-surface-hover hover:text-base-content"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
|
||||
const {
|
||||
canTriggerDiscovery,
|
||||
@@ -787,10 +739,11 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
|
||||
<span class="text-xs text-muted">Version {d().service_version}</span>
|
||||
<CopyValueButton
|
||||
value={d().service_version}
|
||||
copiedValue={copiedDiscoveryValue}
|
||||
onCopy={handleCopyDiscoveryValue}
|
||||
copied={copiedDiscoveryValue() === (d().service_version ?? '').trim()}
|
||||
onCopyValue={handleCopyDiscoveryValue}
|
||||
label="Copy service version"
|
||||
class="inline-flex min-h-5 min-w-5 shrink-0 items-center justify-center rounded text-muted transition-colors hover:bg-surface-hover hover:text-base-content"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -867,10 +820,11 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
|
||||
</a>
|
||||
<CopyValueButton
|
||||
value={d().suggested_url}
|
||||
copiedValue={copiedDiscoveryValue}
|
||||
onCopy={handleCopyDiscoveryValue}
|
||||
copied={copiedDiscoveryValue() === (d().suggested_url ?? '').trim()}
|
||||
onCopyValue={handleCopyDiscoveryValue}
|
||||
label="Copy suggested URL"
|
||||
class={getDiscoverySuggestedURLActionClass()}
|
||||
variant="accent"
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
<p class={`mt-1.5 text-[11px] ${getDiscoverySuggestedURLTextClass()}`}>
|
||||
@@ -898,8 +852,8 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
|
||||
>
|
||||
<CopyableCodeRow
|
||||
value={cliCommand()!}
|
||||
copiedValue={copiedDiscoveryValue}
|
||||
onCopy={handleCopyDiscoveryValue}
|
||||
copied={copiedDiscoveryValue() === cliCommand()!.trim()}
|
||||
onCopyValue={handleCopyDiscoveryValue}
|
||||
label="Copy CLI command"
|
||||
/>
|
||||
<p class="mt-1.5 text-[11px] text-muted">
|
||||
@@ -930,8 +884,8 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
|
||||
{(path) => (
|
||||
<CopyableCodeRow
|
||||
value={path}
|
||||
copiedValue={copiedDiscoveryValue}
|
||||
onCopy={handleCopyDiscoveryValue}
|
||||
copied={copiedDiscoveryValue() === path.trim()}
|
||||
onCopyValue={handleCopyDiscoveryValue}
|
||||
label="Copy config path"
|
||||
/>
|
||||
)}
|
||||
@@ -950,8 +904,8 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
|
||||
{(path) => (
|
||||
<CopyableCodeRow
|
||||
value={path}
|
||||
copiedValue={copiedDiscoveryValue}
|
||||
onCopy={handleCopyDiscoveryValue}
|
||||
copied={copiedDiscoveryValue() === path.trim()}
|
||||
onCopyValue={handleCopyDiscoveryValue}
|
||||
label="Copy data path"
|
||||
/>
|
||||
)}
|
||||
@@ -970,8 +924,8 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
|
||||
{(path) => (
|
||||
<CopyableCodeRow
|
||||
value={path}
|
||||
copiedValue={copiedDiscoveryValue}
|
||||
onCopy={handleCopyDiscoveryValue}
|
||||
copied={copiedDiscoveryValue() === path.trim()}
|
||||
onCopyValue={handleCopyDiscoveryValue}
|
||||
label="Copy log path"
|
||||
/>
|
||||
)}
|
||||
@@ -997,8 +951,8 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
|
||||
<div class="space-y-0.5">
|
||||
<CopyableCodeRow
|
||||
value={mount.source}
|
||||
copiedValue={copiedDiscoveryValue}
|
||||
onCopy={handleCopyDiscoveryValue}
|
||||
copied={copiedDiscoveryValue() === mount.source.trim()}
|
||||
onCopyValue={handleCopyDiscoveryValue}
|
||||
label="Copy host path"
|
||||
/>
|
||||
<div class="pl-1 text-[10px] text-muted">
|
||||
@@ -1024,14 +978,13 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<For each={d().ports}>
|
||||
{(port) => (
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded bg-surface-alt px-1.5 py-0.5 text-[10px] text-base-content transition-colors hover:bg-surface-hover"
|
||||
onClick={() =>
|
||||
void handleCopyDiscoveryValue(`${port.port}/${port.protocol}`)
|
||||
}
|
||||
title="Copy port"
|
||||
aria-label={`Copy ${port.port}/${port.protocol}`}
|
||||
<CopyValueButton
|
||||
value={`${port.port}/${port.protocol}`}
|
||||
copied={copiedDiscoveryValue() === `${port.port}/${port.protocol}`}
|
||||
onCopyValue={handleCopyDiscoveryValue}
|
||||
label={`Copy ${port.port}/${port.protocol}`}
|
||||
variant="chip"
|
||||
size="chip"
|
||||
>
|
||||
<span>
|
||||
{port.port}/{port.protocol}
|
||||
@@ -1039,13 +992,7 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
|
||||
<Show when={port.process}>
|
||||
<span class="text-muted">({port.process})</span>
|
||||
</Show>
|
||||
<Show
|
||||
when={copiedDiscoveryValue() === `${port.port}/${port.protocol}`}
|
||||
fallback={<CopyIcon class="h-3 w-3 text-muted" />}
|
||||
>
|
||||
<CheckIcon class="h-3 w-3 text-emerald-600 dark:text-emerald-400" />
|
||||
</Show>
|
||||
</button>
|
||||
</CopyValueButton>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
@@ -1070,10 +1017,11 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
|
||||
</span>
|
||||
<CopyValueButton
|
||||
value={fact.value}
|
||||
copiedValue={copiedDiscoveryValue}
|
||||
onCopy={handleCopyDiscoveryValue}
|
||||
copied={copiedDiscoveryValue() === fact.value.trim()}
|
||||
onCopyValue={handleCopyDiscoveryValue}
|
||||
label={`Copy ${fact.key}`}
|
||||
class="inline-flex min-h-5 min-w-5 shrink-0 items-center justify-center rounded text-muted transition-colors hover:bg-surface-hover hover:text-base-content"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Route, Router } from '@solidjs/router';
|
||||
import { cleanup, render, screen } from '@solidjs/testing-library';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Button, ButtonLink, CommandCopyButton } from './Button';
|
||||
import { Button, ButtonLink, CommandCopyButton, CopyValueButton } from './Button';
|
||||
import buttonSource from './Button.tsx?raw';
|
||||
import buttonModelSource from './buttonModel.ts?raw';
|
||||
import { CopyableCodeRow } from './CopyableCodeRow';
|
||||
import copyableCodeRowSource from './CopyableCodeRow.tsx?raw';
|
||||
|
||||
describe('Button', () => {
|
||||
afterEach(() => {
|
||||
@@ -14,16 +16,25 @@ describe('Button', () => {
|
||||
it('keeps shell styling in the shared model', () => {
|
||||
expect(buttonSource).toContain('getButtonClass');
|
||||
expect(buttonModelSource).toContain('export const BUTTON_VARIANT_CLASSES');
|
||||
expect(buttonModelSource).toContain('export const COPY_VALUE_BUTTON_VARIANT_CLASSES');
|
||||
expect(buttonModelSource).toContain('export const COPY_VALUE_BUTTON_SIZE_CLASSES');
|
||||
expect(buttonModelSource).toContain('getCopyValueButtonClass');
|
||||
expect(buttonModelSource).toContain(
|
||||
"secondary: 'border border-border bg-surface text-base-content shadow-sm hover:bg-surface-hover'",
|
||||
);
|
||||
expect(buttonModelSource).toContain('primaryFlat:');
|
||||
expect(buttonModelSource).toContain(
|
||||
"'border border-border bg-surface text-muted hover:bg-surface-hover hover:text-base-content'",
|
||||
);
|
||||
expect(buttonModelSource).toContain(
|
||||
"accent: 'text-blue-700 hover:bg-blue-100 dark:text-blue-200 dark:hover:bg-blue-950'",
|
||||
);
|
||||
expect(buttonModelSource).toContain('dangerOutline:');
|
||||
expect(buttonModelSource).toContain('export const BUTTON_SIZE_CLASSES');
|
||||
expect(buttonModelSource).toContain("xs: 'px-2.5 py-1 text-xs'");
|
||||
expect(buttonModelSource).toContain("mdCompact: 'px-3 py-2 text-sm'");
|
||||
expect(buttonModelSource).toContain(
|
||||
"settingsAction: 'min-h-10 px-3 py-2 text-sm sm:min-h-9'",
|
||||
);
|
||||
expect(buttonModelSource).toContain("settingsAction: 'min-h-10 px-3 py-2 text-sm sm:min-h-9'");
|
||||
expect(buttonModelSource).toContain("chip: 'gap-1 px-1.5 py-0.5 text-[10px]'");
|
||||
expect(buttonModelSource).toContain("iconMd: 'h-9 w-9 p-0'");
|
||||
});
|
||||
|
||||
@@ -99,6 +110,65 @@ describe('Button', () => {
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders copy-value icon and chip buttons through the shared primitive', () => {
|
||||
const onCopy = vi.fn();
|
||||
|
||||
render(() => (
|
||||
<>
|
||||
<CopyValueButton
|
||||
value=" https://example.test "
|
||||
copied={false}
|
||||
onCopyValue={onCopy}
|
||||
label="Copy URL"
|
||||
/>
|
||||
<CopyValueButton
|
||||
value="8443/tcp"
|
||||
copied
|
||||
onCopyValue={onCopy}
|
||||
label="Copy 8443/tcp"
|
||||
variant="chip"
|
||||
size="chip"
|
||||
>
|
||||
<span>8443/tcp</span>
|
||||
</CopyValueButton>
|
||||
<CopyValueButton value=" " onCopyValue={onCopy} label="Copy blank" />
|
||||
</>
|
||||
));
|
||||
|
||||
const copyUrlButton = screen.getByRole('button', { name: 'Copy URL' });
|
||||
expect(copyUrlButton).toHaveClass('border-border');
|
||||
expect(copyUrlButton).toHaveClass('min-h-7');
|
||||
copyUrlButton.click();
|
||||
expect(onCopy).toHaveBeenCalledWith('https://example.test');
|
||||
|
||||
const chipButton = screen.getByRole('button', { name: 'Copy 8443/tcp' });
|
||||
expect(chipButton).toHaveClass('bg-surface-alt');
|
||||
expect(chipButton).toHaveClass('text-[10px]');
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Copy blank' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('renders copyable code rows through the shared copy primitive', () => {
|
||||
const onCopy = vi.fn();
|
||||
|
||||
render(() => (
|
||||
<CopyableCodeRow
|
||||
value="/etc/pulse/config.yml"
|
||||
copied={false}
|
||||
onCopyValue={onCopy}
|
||||
label="Copy config path"
|
||||
/>
|
||||
));
|
||||
|
||||
expect(copyableCodeRowSource).toContain('CopyValueButton');
|
||||
expect(screen.getByText('/etc/pulse/config.yml')).toHaveClass('font-mono');
|
||||
|
||||
const copyButton = screen.getByRole('button', { name: 'Copy config path' });
|
||||
expect(copyButton).toHaveClass('min-h-6');
|
||||
copyButton.click();
|
||||
expect(onCopy).toHaveBeenCalledWith('/etc/pulse/config.yml');
|
||||
});
|
||||
|
||||
it('renders in-app button links through the router', () => {
|
||||
render(() => (
|
||||
<Router>
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { A } from '@solidjs/router';
|
||||
import CheckIcon from 'lucide-solid/icons/check';
|
||||
import CopyIcon from 'lucide-solid/icons/copy';
|
||||
import { JSX, Show, mergeProps, splitProps } from 'solid-js';
|
||||
import { getButtonClass, type ButtonSize, type ButtonVariant } from './buttonModel';
|
||||
import {
|
||||
getButtonClass,
|
||||
getCopyValueButtonClass,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
type CopyValueButtonSize,
|
||||
type CopyValueButtonVariant,
|
||||
} from './buttonModel';
|
||||
|
||||
export interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
@@ -18,11 +26,27 @@ export interface ButtonLinkProps extends JSX.AnchorHTMLAttributes<HTMLAnchorElem
|
||||
hardNavigation?: boolean;
|
||||
}
|
||||
|
||||
export interface CommandCopyButtonProps
|
||||
extends Omit<ButtonProps, 'children' | 'isLoading' | 'size' | 'variant'> {
|
||||
export interface CommandCopyButtonProps extends Omit<
|
||||
ButtonProps,
|
||||
'children' | 'isLoading' | 'size' | 'variant'
|
||||
> {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface CopyValueButtonProps extends Omit<
|
||||
JSX.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
'children' | 'onClick' | 'value'
|
||||
> {
|
||||
value?: string | null;
|
||||
copied?: boolean;
|
||||
onCopyValue: (value: string) => void | Promise<void>;
|
||||
label: string;
|
||||
variant?: CopyValueButtonVariant;
|
||||
size?: CopyValueButtonSize;
|
||||
class?: string;
|
||||
children?: JSX.Element;
|
||||
}
|
||||
|
||||
export function Button(props: ButtonProps) {
|
||||
const merged = mergeProps(
|
||||
{ variant: 'secondary' as ButtonVariant, size: 'md' as ButtonSize, type: 'button' as const },
|
||||
@@ -97,6 +121,56 @@ export function CommandCopyButton(props: CommandCopyButtonProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function CopyValueButton(props: CopyValueButtonProps) {
|
||||
const merged = mergeProps(
|
||||
{
|
||||
variant: 'neutral' as CopyValueButtonVariant,
|
||||
size: 'md' as CopyValueButtonSize,
|
||||
type: 'button' as const,
|
||||
},
|
||||
props,
|
||||
);
|
||||
const [local, rest] = splitProps(merged, [
|
||||
'value',
|
||||
'copied',
|
||||
'onCopyValue',
|
||||
'label',
|
||||
'variant',
|
||||
'size',
|
||||
'class',
|
||||
'children',
|
||||
'disabled',
|
||||
'title',
|
||||
'aria-label',
|
||||
]);
|
||||
const trimmedValue = () => (local.value ?? '').trim();
|
||||
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
type="button"
|
||||
class={getCopyValueButtonClass({
|
||||
variant: local.variant,
|
||||
size: local.size,
|
||||
class: local.class,
|
||||
})}
|
||||
disabled={local.disabled || !trimmedValue()}
|
||||
onClick={() => {
|
||||
const value = trimmedValue();
|
||||
if (!value) return;
|
||||
void local.onCopyValue(value);
|
||||
}}
|
||||
title={local.title ?? local.label}
|
||||
aria-label={local['aria-label'] ?? local.label}
|
||||
>
|
||||
{local.children}
|
||||
<Show when={local.copied} fallback={<CopyIcon class="h-3.5 w-3.5" aria-hidden="true" />}>
|
||||
<CheckIcon class="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" aria-hidden="true" />
|
||||
</Show>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ButtonLink(props: ButtonLinkProps) {
|
||||
const merged = mergeProps(
|
||||
{ variant: 'secondary' as ButtonVariant, size: 'md' as ButtonSize },
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Component } from 'solid-js';
|
||||
import { CopyValueButton, type CopyValueButtonProps } from './Button';
|
||||
|
||||
export interface CopyableCodeRowProps extends Pick<
|
||||
CopyValueButtonProps,
|
||||
'copied' | 'label' | 'onCopyValue'
|
||||
> {
|
||||
value: string;
|
||||
class?: string;
|
||||
codeClass?: string;
|
||||
}
|
||||
|
||||
export const CopyableCodeRow: Component<CopyableCodeRowProps> = (props) => (
|
||||
<div
|
||||
class={['flex items-start gap-2 rounded bg-surface-alt px-2 py-1.5', props.class]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<code
|
||||
class={['min-w-0 flex-1 break-all font-mono text-xs text-base-content', props.codeClass]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{props.value}
|
||||
</code>
|
||||
<CopyValueButton
|
||||
value={props.value}
|
||||
copied={props.copied}
|
||||
onCopyValue={props.onCopyValue}
|
||||
label={props.label}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default CopyableCodeRow;
|
||||
@@ -8,6 +8,7 @@ import aiChatSource from '@/components/AI/Chat/index.tsx?raw';
|
||||
import aiModelPickerSource from '@/components/shared/AIModelPicker.tsx?raw';
|
||||
import buttonSource from '@/components/shared/Button.tsx?raw';
|
||||
import buttonModelSource from '@/components/shared/buttonModel.ts?raw';
|
||||
import copyableCodeRowSource from '@/components/shared/CopyableCodeRow.tsx?raw';
|
||||
import commandPaletteModalSource from '@/components/shared/CommandPaletteModal.tsx?raw';
|
||||
import commandPaletteModelSource from '@/components/shared/commandPaletteModel.ts?raw';
|
||||
import columnPickerSource from '@/components/shared/ColumnPicker.tsx?raw';
|
||||
@@ -101,6 +102,7 @@ import webInterfaceUrlFieldStateSource from '@/components/shared/useWebInterface
|
||||
import webInterfaceNameLinkSource from '@/components/shared/WebInterfaceNameLink.tsx?raw';
|
||||
import inlineDetailTableRowSource from '@/components/shared/InlineDetailTableRow.tsx?raw';
|
||||
import sharedTemplateRegistrySource from '../../../scripts/shared-template-registry.json?raw';
|
||||
import discoveryTabSource from '@/components/Discovery/DiscoveryTab.tsx?raw';
|
||||
import emailProviderSelectSource from '@/components/Alerts/EmailProviderSelect.tsx?raw';
|
||||
import incidentTimelinePanelSource from '@/components/Alerts/IncidentTimelinePanel.tsx?raw';
|
||||
import thresholdsTableDockerIgnoredPrefixesSectionSource from '@/components/Alerts/ThresholdsTableDockerIgnoredPrefixesSection.tsx?raw';
|
||||
@@ -1540,6 +1542,22 @@ describe('shared primitive guardrails', () => {
|
||||
const settingsDialogCloseGuard = registry.patternGuards?.find(
|
||||
(guard) => guard.id === 'button-outline-settings-dialog-close-local-shell',
|
||||
);
|
||||
const copyValueRule = registry.rules?.find((rule) => rule.id === 'copy-value-action-shell');
|
||||
const copyableCodeRowRule = registry.rules?.find(
|
||||
(rule) => rule.id === 'copyable-code-row-shell',
|
||||
);
|
||||
const copyValueNeutralGuard = registry.patternGuards?.find(
|
||||
(guard) => guard.id === 'copy-value-neutral-local-button-shell',
|
||||
);
|
||||
const copyValueMutedGuard = registry.patternGuards?.find(
|
||||
(guard) => guard.id === 'copy-value-muted-local-button-shell',
|
||||
);
|
||||
const copyValueAccentGuard = registry.patternGuards?.find(
|
||||
(guard) => guard.id === 'copy-value-accent-local-button-shell',
|
||||
);
|
||||
const copyableCodeRowGuard = registry.patternGuards?.find(
|
||||
(guard) => guard.id === 'copyable-code-row-local-shell',
|
||||
);
|
||||
|
||||
expect(registeredRule?.canonical?.path).toBe('src/components/shared/Button.tsx');
|
||||
expect(registeredRule?.canonical?.export).toBe('Button');
|
||||
@@ -1625,9 +1643,7 @@ describe('shared primitive guardrails', () => {
|
||||
expect(settingsPrimaryActionGuard?.ignoredPaths).toEqual([
|
||||
'src/components/shared/Button.test.tsx',
|
||||
]);
|
||||
expect(settingsDangerActionGuard?.canonical?.path).toBe(
|
||||
'src/components/shared/buttonModel.ts',
|
||||
);
|
||||
expect(settingsDangerActionGuard?.canonical?.path).toBe('src/components/shared/buttonModel.ts');
|
||||
expect(settingsDangerActionGuard?.canonical?.export).toBe('getButtonClass');
|
||||
expect(settingsDangerActionGuard?.allPatterns).toEqual([
|
||||
'rounded-md bg-rose-600 px-3 py-2 text-sm font-medium text-white',
|
||||
@@ -1669,9 +1685,7 @@ describe('shared primitive guardrails', () => {
|
||||
]);
|
||||
expect(settingsRowActionGuard?.allowedPaths ?? []).toHaveLength(0);
|
||||
expect(settingsRowActionGuard?.ignoredPaths).toEqual(['src/components/shared/Button.test.tsx']);
|
||||
expect(settingsDialogCloseGuard?.canonical?.path).toBe(
|
||||
'src/components/shared/buttonModel.ts',
|
||||
);
|
||||
expect(settingsDialogCloseGuard?.canonical?.path).toBe('src/components/shared/buttonModel.ts');
|
||||
expect(settingsDialogCloseGuard?.canonical?.export).toBe('getButtonClass');
|
||||
expect(settingsDialogCloseGuard?.allPatterns).toEqual([
|
||||
'h-9 w-9 items-center justify-center rounded-md border border-border text-base-content transition-colors hover:bg-surface-hover',
|
||||
@@ -1685,15 +1699,57 @@ describe('shared primitive guardrails', () => {
|
||||
expect(settingsDialogCloseGuard?.ignoredPaths).toEqual([
|
||||
'src/components/shared/Button.test.tsx',
|
||||
]);
|
||||
expect(copyValueRule?.canonical?.path).toBe('src/components/shared/Button.tsx');
|
||||
expect(copyValueRule?.canonical?.export).toBe('CopyValueButton');
|
||||
expect(copyValueRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual([
|
||||
'src/components/Discovery/DiscoveryTab.tsx',
|
||||
'src/components/shared/WebInterfaceUrlField.tsx',
|
||||
]);
|
||||
expect(copyableCodeRowRule?.canonical?.path).toBe('src/components/shared/CopyableCodeRow.tsx');
|
||||
expect(copyableCodeRowRule?.canonical?.export).toBe('CopyableCodeRow');
|
||||
expect(copyableCodeRowRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual([
|
||||
'src/components/Discovery/DiscoveryTab.tsx',
|
||||
]);
|
||||
expect(copyValueNeutralGuard?.canonical?.path).toBe('src/components/shared/Button.tsx');
|
||||
expect(copyValueNeutralGuard?.canonical?.export).toBe('CopyValueButton');
|
||||
expect(copyValueNeutralGuard?.allPatterns).toEqual([
|
||||
'inline-flex min-h-7 min-w-7 shrink-0 items-center justify-center rounded border border-border bg-surface px-2 text-muted transition-colors hover:bg-surface-hover hover:text-base-content',
|
||||
'fallback={<CopyIcon class="h-3.5 w-3.5" />}',
|
||||
]);
|
||||
expect(copyValueMutedGuard?.canonical?.path).toBe('src/components/shared/Button.tsx');
|
||||
expect(copyValueMutedGuard?.canonical?.export).toBe('CopyValueButton');
|
||||
expect(copyValueMutedGuard?.allPatterns).toEqual([
|
||||
'inline-flex min-h-8 min-w-8 items-center justify-center rounded-md text-muted transition-colors hover:bg-surface-hover hover:text-base-content',
|
||||
'fallback={<CopyIcon class="h-3.5 w-3.5" />}',
|
||||
]);
|
||||
expect(copyValueAccentGuard?.canonical?.path).toBe('src/components/shared/Button.tsx');
|
||||
expect(copyValueAccentGuard?.canonical?.export).toBe('CopyValueButton');
|
||||
expect(copyValueAccentGuard?.allPatterns).toEqual([
|
||||
'inline-flex min-h-7 min-w-7 shrink-0 items-center justify-center rounded text-blue-700 transition-colors hover:bg-blue-100 dark:text-blue-200 dark:hover:bg-blue-950',
|
||||
'fallback={<CopyIcon class="h-3.5 w-3.5" />}',
|
||||
]);
|
||||
expect(copyableCodeRowGuard?.canonical?.path).toBe('src/components/shared/CopyableCodeRow.tsx');
|
||||
expect(copyableCodeRowGuard?.canonical?.export).toBe('CopyableCodeRow');
|
||||
expect(copyableCodeRowGuard?.allPatterns).toEqual([
|
||||
'flex items-start gap-2 rounded bg-surface-alt px-2 py-1.5',
|
||||
'break-all font-mono text-xs text-base-content',
|
||||
]);
|
||||
|
||||
expect(buttonSource).toContain('export function Button');
|
||||
expect(buttonSource).toContain('export function CommandCopyButton');
|
||||
expect(buttonSource).toContain('export function CopyValueButton');
|
||||
expect(buttonSource).toContain('export function ButtonLink');
|
||||
expect(buttonSource).toContain('getButtonClass');
|
||||
expect(buttonSource).toContain('getCopyValueButtonClass');
|
||||
expect(copyableCodeRowSource).toContain('CopyValueButton');
|
||||
expect(buttonModelSource).toContain('BUTTON_VARIANT_CLASSES');
|
||||
expect(buttonModelSource).toContain('BUTTON_SIZE_CLASSES');
|
||||
expect(buttonModelSource).toContain('primaryFlat:');
|
||||
expect(buttonModelSource).toContain('COPY_VALUE_BUTTON_VARIANT_CLASSES');
|
||||
expect(buttonModelSource).toContain('COPY_VALUE_BUTTON_SIZE_CLASSES');
|
||||
expect(buttonModelSource).toContain('dangerOutline:');
|
||||
expect(buttonModelSource).toContain('settingsAction:');
|
||||
expect(buttonModelSource).toContain('getCopyValueButtonClass');
|
||||
expect(chatMessagesSource).toContain('@/components/shared/Button');
|
||||
expect(chatMessagesSource).not.toContain(
|
||||
'rounded-md border border-border bg-surface px-3 py-1.5 text-xs font-medium text-base-content',
|
||||
@@ -1800,6 +1856,24 @@ describe('shared primitive guardrails', () => {
|
||||
expect(infrastructureWorkspaceSource).not.toContain(
|
||||
'h-9 w-9 items-center justify-center rounded-md border border-border text-base-content transition-colors hover:bg-surface-hover',
|
||||
);
|
||||
expect(discoveryTabSource).toContain('@/components/shared/Button');
|
||||
expect(discoveryTabSource).toContain('@/components/shared/CopyableCodeRow');
|
||||
expect(discoveryTabSource).toContain('CopyValueButton');
|
||||
expect(discoveryTabSource).toContain('CopyableCodeRow');
|
||||
expect(discoveryTabSource).not.toContain('interface CopyValueButtonProps');
|
||||
expect(discoveryTabSource).not.toContain('const CopyValueButton');
|
||||
expect(discoveryTabSource).not.toContain('const CopyableCodeRow');
|
||||
expect(discoveryTabSource).not.toContain(
|
||||
'inline-flex min-h-7 min-w-7 shrink-0 items-center justify-center rounded border border-border bg-surface px-2 text-muted transition-colors hover:bg-surface-hover hover:text-base-content',
|
||||
);
|
||||
expect(discoveryTabSource).not.toContain(
|
||||
'flex items-start gap-2 rounded bg-surface-alt px-2 py-1.5',
|
||||
);
|
||||
expect(webInterfaceUrlFieldSource).toContain('CopyValueButton');
|
||||
expect(webInterfaceUrlFieldSource).not.toContain('CopyIcon class="h-3.5 w-3.5"');
|
||||
expect(webInterfaceUrlFieldSource).not.toContain(
|
||||
'inline-flex min-h-8 min-w-8 items-center justify-center rounded-md text-muted transition-colors hover:bg-surface-hover hover:text-base-content',
|
||||
);
|
||||
expect(patrolIntelligenceWorkspaceSource).toContain('@/components/shared/Button');
|
||||
expect(patrolIntelligenceWorkspaceSource).not.toContain(
|
||||
'rounded-md border border-border bg-surface px-3 py-1.5 text-xs font-medium text-base-content',
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Component, Show } from 'solid-js';
|
||||
import CheckIcon from 'lucide-solid/icons/check';
|
||||
import CopyIcon from 'lucide-solid/icons/copy';
|
||||
import ExternalLinkIcon from 'lucide-solid/icons/external-link';
|
||||
import { Button, CopyValueButton } from './Button';
|
||||
import { DiscoveryProvenanceMarker } from './DiscoveryProvenanceMarker';
|
||||
import { useWebInterfaceUrlFieldState } from './useWebInterfaceUrlFieldState';
|
||||
import type { WebInterfaceUrlFieldProps } from './webInterfaceUrlFieldModel';
|
||||
@@ -36,14 +35,14 @@ export const WebInterfaceUrlField: Component<WebInterfaceUrlFieldProps> = (props
|
||||
}}
|
||||
disabled={state.urlSaving()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="px-2.5 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
<Button
|
||||
variant="primaryFlat"
|
||||
size="sm"
|
||||
disabled={state.urlSaving() || state.urlValue().trim() === state.normalizedCurrentUrl()}
|
||||
onClick={() => void state.handleSaveUrl()}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</Button>
|
||||
<Show when={state.normalizedCurrentUrl()}>
|
||||
<a
|
||||
href={state.normalizedCurrentUrl()}
|
||||
@@ -57,31 +56,25 @@ export const WebInterfaceUrlField: Component<WebInterfaceUrlFieldProps> = (props
|
||||
</a>
|
||||
</Show>
|
||||
<Show when={state.normalizedCurrentUrl()}>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex min-h-8 min-w-8 items-center justify-center rounded-md text-muted transition-colors hover:bg-surface-hover hover:text-base-content"
|
||||
onClick={() => void state.handleCopyUrl(state.normalizedCurrentUrl())}
|
||||
title="Copy URL"
|
||||
aria-label="Copy URL"
|
||||
>
|
||||
<Show
|
||||
when={state.copiedUrlValue() === state.normalizedCurrentUrl()}
|
||||
fallback={<CopyIcon class="h-3.5 w-3.5" />}
|
||||
>
|
||||
<CheckIcon class="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
|
||||
</Show>
|
||||
</button>
|
||||
<CopyValueButton
|
||||
value={state.normalizedCurrentUrl()}
|
||||
copied={state.copiedUrlValue() === state.normalizedCurrentUrl()}
|
||||
onCopyValue={state.handleCopyUrl}
|
||||
label="Copy URL"
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
/>
|
||||
</Show>
|
||||
<Show when={state.normalizedCurrentUrl()}>
|
||||
<button
|
||||
type="button"
|
||||
class="px-2.5 py-1.5 text-xs font-medium rounded-md text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900 disabled:opacity-50 transition-colors"
|
||||
<Button
|
||||
variant="dangerOutline"
|
||||
size="sm"
|
||||
disabled={state.urlSaving()}
|
||||
onClick={() => void state.handleDeleteUrl()}
|
||||
title="Remove URL"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -145,28 +138,23 @@ export const WebInterfaceUrlField: Component<WebInterfaceUrlFieldProps> = (props
|
||||
>
|
||||
<ExternalLinkIcon class="h-3.5 w-3.5" />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex min-h-7 min-w-7 shrink-0 items-center justify-center rounded text-blue-700 transition-colors hover:bg-blue-100 dark:text-blue-200 dark:hover:bg-blue-950"
|
||||
onClick={() => void state.handleCopyUrl(state.normalizedSuggestedUrl())}
|
||||
title="Copy suggested URL"
|
||||
aria-label="Copy suggested URL"
|
||||
>
|
||||
<Show
|
||||
when={state.copiedUrlValue() === state.normalizedSuggestedUrl()}
|
||||
fallback={<CopyIcon class="h-3.5 w-3.5" />}
|
||||
>
|
||||
<CheckIcon class="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
|
||||
</Show>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-2 py-1 text-xs font-medium rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors flex-shrink-0"
|
||||
<CopyValueButton
|
||||
value={state.normalizedSuggestedUrl()}
|
||||
copied={state.copiedUrlValue() === state.normalizedSuggestedUrl()}
|
||||
onCopyValue={state.handleCopyUrl}
|
||||
label="Copy suggested URL"
|
||||
variant="accent"
|
||||
size="md"
|
||||
/>
|
||||
<Button
|
||||
variant="primaryFlat"
|
||||
size="xs"
|
||||
class="flex-shrink-0"
|
||||
onClick={() => state.setUrlValue(state.normalizedSuggestedUrl())}
|
||||
disabled={state.urlSaving()}
|
||||
>
|
||||
{state.normalizedCurrentUrl() ? 'Use instead' : 'Use this'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export type ButtonVariant =
|
||||
| 'primary'
|
||||
| 'primaryFlat'
|
||||
| 'secondary'
|
||||
| 'danger'
|
||||
| 'dangerOutline'
|
||||
@@ -20,6 +21,7 @@ export const BUTTON_BASE_CLASS =
|
||||
|
||||
export const BUTTON_VARIANT_CLASSES: Record<ButtonVariant, string> = {
|
||||
primary: 'border border-transparent bg-blue-600 text-white shadow-sm hover:bg-blue-700',
|
||||
primaryFlat: 'border border-transparent bg-blue-600 text-white hover:bg-blue-700',
|
||||
secondary: 'border border-border bg-surface text-base-content shadow-sm hover:bg-surface-hover',
|
||||
danger: 'border border-transparent bg-rose-600 text-white shadow-sm hover:bg-rose-700',
|
||||
dangerOutline:
|
||||
@@ -54,3 +56,41 @@ export const getButtonClass = (options: ButtonClassOptions = {}): string =>
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
export type CopyValueButtonVariant = 'neutral' | 'ghost' | 'accent' | 'chip';
|
||||
export type CopyValueButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'chip';
|
||||
|
||||
export const COPY_VALUE_BUTTON_BASE_CLASS =
|
||||
'inline-flex shrink-0 items-center justify-center rounded transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50';
|
||||
|
||||
export const COPY_VALUE_BUTTON_VARIANT_CLASSES: Record<CopyValueButtonVariant, string> = {
|
||||
neutral:
|
||||
'border border-border bg-surface text-muted hover:bg-surface-hover hover:text-base-content',
|
||||
ghost: 'text-muted hover:bg-surface-hover hover:text-base-content',
|
||||
accent: 'text-blue-700 hover:bg-blue-100 dark:text-blue-200 dark:hover:bg-blue-950',
|
||||
chip: 'bg-surface-alt text-base-content hover:bg-surface-hover',
|
||||
};
|
||||
|
||||
export const COPY_VALUE_BUTTON_SIZE_CLASSES: Record<CopyValueButtonSize, string> = {
|
||||
xs: 'min-h-5 min-w-5',
|
||||
sm: 'min-h-6 min-w-6',
|
||||
md: 'min-h-7 min-w-7',
|
||||
lg: 'min-h-8 min-w-8 rounded-md',
|
||||
chip: 'gap-1 px-1.5 py-0.5 text-[10px]',
|
||||
};
|
||||
|
||||
export type CopyValueButtonClassOptions = {
|
||||
variant?: CopyValueButtonVariant;
|
||||
size?: CopyValueButtonSize;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
export const getCopyValueButtonClass = (options: CopyValueButtonClassOptions = {}): string =>
|
||||
[
|
||||
COPY_VALUE_BUTTON_BASE_CLASS,
|
||||
COPY_VALUE_BUTTON_VARIANT_CLASSES[options.variant ?? 'neutral'],
|
||||
COPY_VALUE_BUTTON_SIZE_CLASSES[options.size ?? 'md'],
|
||||
options.class,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
Reference in New Issue
Block a user