This is an automated email from the ASF dual-hosted git repository. zqr10159 pushed a commit to branch 2.0.0 in repository https://gitbox.apache.org/repos/asf/hertzbeat.git
commit 435cc244aa093921f698f31705b59d8431aaa015 Author: Logic <[email protected]> AuthorDate: Fri May 29 02:08:31 2026 +0800 feat(web-next): align shared UI primitive parity Validation: vitest run app/loading.test.tsx packages/design-tokens/src/tokens.test.ts components/ui/cold-code-editor.test.tsx components/ui/cold-confirm-dialog.test.tsx components/ui/date-time-range.test.tsx components/ui/label-record-input.test.tsx components/ui/number-stepper.test.tsx components/ui/search-row.test.tsx components/ui/select.test.tsx components/ui/tag-input.test.tsx components/workbench/overlay-dialog.test.tsx components/workbench/workspace-tab-strip.test.tsx app/ui-la [...] --- web-next/app/globals.css | 39 +++++++-- web-next/app/loading.test.tsx | 12 +-- web-next/app/loading.tsx | 25 ++---- web-next/components/ui/cold-code-editor.test.tsx | 76 ++++++++++++++++- web-next/components/ui/cold-code-editor.tsx | 92 +++++++++++++++++++-- .../components/ui/cold-confirm-dialog.test.tsx | 44 ++++++++++ web-next/components/ui/cold-confirm-dialog.tsx | 9 +- web-next/components/ui/date-time-range.test.tsx | 49 +++++++++++ web-next/components/ui/date-time-range.tsx | 95 ++++++++++++++++++---- web-next/components/ui/label-record-input.test.tsx | 5 ++ web-next/components/ui/label-record-input.tsx | 13 ++- web-next/components/ui/number-stepper.test.tsx | 2 + web-next/components/ui/number-stepper.tsx | 9 +- web-next/components/ui/search-row.test.tsx | 20 +++++ web-next/components/ui/search-row.tsx | 9 +- web-next/components/ui/select.test.tsx | 25 ++++++ web-next/components/ui/select.tsx | 39 ++++++++- web-next/components/ui/tag-input.test.tsx | 3 + web-next/components/ui/tag-input.tsx | 9 +- .../components/workbench/overlay-dialog.test.tsx | 32 ++++++++ web-next/components/workbench/overlay-dialog.tsx | 44 ++++++---- .../workbench/workspace-tab-strip.test.tsx | 3 + 22 files changed, 570 insertions(+), 84 deletions(-) diff --git a/web-next/app/globals.css b/web-next/app/globals.css index 47c1c39363..9dd5cb7b76 100644 --- a/web-next/app/globals.css +++ b/web-next/app/globals.css @@ -1,3 +1,6 @@ +@import '../packages/design-tokens/src/tokens.css'; +@import '../packages/design-tokens/src/themes.css'; + @tailwind base; @tailwind components; @tailwind utilities; @@ -61,6 +64,30 @@ --hb-shadow: var(--ops-panel-shadow); --hb-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; --hb-mono: 'SFMono-Regular', ui-monospace, Menlo, Monaco, Consolas, monospace; + + --hz-ui-canvas: #0b0d10; + --hz-ui-surface: #0d1014; + --hz-ui-surface-soft: #10141a; + --hz-ui-surface-raised: #131922; + --hz-ui-surface-graphite: #0c0e12; + --hz-ui-surface-muted: rgba(255, 255, 255, 0.018); + --hz-ui-control: #0e1218; + --hz-ui-code: #090b0e; + --hz-ui-line-strong: rgba(166, 178, 195, 0.135); + --hz-ui-line: rgba(166, 178, 195, 0.085); + --hz-ui-line-soft: rgba(166, 178, 195, 0.052); + --hz-ui-line-faint: rgba(166, 178, 195, 0.032); + --hz-ui-active: rgba(76, 92, 148, 0.22); + --hz-ui-active-soft: rgba(76, 92, 148, 0.095); + --hz-ui-accent: #7c93db; + --hz-ui-accent-muted: rgba(124, 147, 219, 0.32); + --hz-ui-action-primary: #4f6bdc; + --hz-ui-action-primary-hover: #5d78ee; + --hz-ui-action-danger: #7f232f; + --hz-ui-action-danger-hover: #9b2a39; + --hz-ui-scrollbar-size: 7px; + --hz-ui-scrollbar-thumb: rgba(166, 178, 195, 0.18); + --hz-ui-scrollbar-thumb-hover: rgba(166, 178, 195, 0.3); } html[data-theme='light-ops'], @@ -499,15 +526,15 @@ body[data-theme='compact'] { body, .hb-scrollbar { scrollbar-width: thin; - scrollbar-color: hsl(var(--border)) transparent; + scrollbar-color: var(--hz-ui-scrollbar-thumb) transparent; scrollbar-gutter: stable; } html::-webkit-scrollbar, body::-webkit-scrollbar, .hb-scrollbar::-webkit-scrollbar { - width: 10px; - height: 10px; + width: var(--hz-ui-scrollbar-size); + height: var(--hz-ui-scrollbar-size); } html::-webkit-scrollbar-track, @@ -519,16 +546,16 @@ body[data-theme='compact'] { html::-webkit-scrollbar-thumb, body::-webkit-scrollbar-thumb, .hb-scrollbar::-webkit-scrollbar-thumb { - border: 2px solid transparent; + border: 1px solid transparent; border-radius: 999px; - background: linear-gradient(180deg, hsl(var(--accent)), hsl(var(--secondary))); + background: var(--hz-ui-scrollbar-thumb); background-clip: padding-box; } html::-webkit-scrollbar-thumb:hover, body::-webkit-scrollbar-thumb:hover, .hb-scrollbar::-webkit-scrollbar-thumb:hover { - background: linear-gradient(180deg, hsl(var(--ring) / 0.5), hsl(var(--accent))); + background: var(--hz-ui-scrollbar-thumb-hover); background-clip: padding-box; } diff --git a/web-next/app/loading.test.tsx b/web-next/app/loading.test.tsx index a889d2339a..782034c37a 100644 --- a/web-next/app/loading.test.tsx +++ b/web-next/app/loading.test.tsx @@ -3,16 +3,18 @@ import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it } from 'vitest'; describe('global app loading shell', () => { - it('renders a visible operator-facing route loading state instead of a blank screen', async () => { + it('keeps route pending feedback quiet instead of showing a full Loading workspace panel', async () => { const { default: Loading } = await import('./loading'); const html = renderToStaticMarkup(<Loading />); - expect(html).toContain('data-app-route-loading="global-workbench-loading"'); - expect(html).toContain('data-app-route-loading-spinner="true"'); + expect(html).toContain('data-app-route-loading="quiet-route-pending"'); + expect(html).toContain('data-app-route-loading-indicator="true"'); expect(html).toContain('role="status"'); expect(html).toContain('aria-busy="true"'); - expect(html).toContain('正在加载工作台'); - expect(html).toContain('正在准备页面数据'); + expect(html).not.toContain('Loading workspace'); + expect(html).not.toContain('Preparing page data'); + expect(html).not.toContain('rounded-[4px]'); + expect(html).not.toContain('min-h-[360px]'); expect(html).not.toContain('/api/'); expect(html).not.toContain('http://localhost'); }); diff --git a/web-next/app/loading.tsx b/web-next/app/loading.tsx index d0a8f5d27a..f447413db6 100644 --- a/web-next/app/loading.tsx +++ b/web-next/app/loading.tsx @@ -1,28 +1,21 @@ import React from 'react'; -import { Loader2 } from 'lucide-react'; export default function Loading() { return ( <main - data-app-route-loading="global-workbench-loading" + data-app-route-loading="quiet-route-pending" role="status" aria-busy="true" aria-live="polite" - className="min-h-[calc(100vh-56px)] bg-[#07090b] px-6 py-6 text-[#e8edf5]" + aria-label="Route pending" + className="min-h-[calc(100vh-56px)] bg-[#07090b]" > - <section className="mx-auto flex min-h-[360px] w-full max-w-[1600px] items-center justify-center rounded-[4px] border border-[#252b35] bg-[#0d1015] px-6 py-12 shadow-[0_18px_60px_rgba(0,0,0,0.28)]"> - <div className="flex max-w-[420px] flex-col items-center text-center"> - <Loader2 - data-app-route-loading-spinner="true" - className="h-8 w-8 animate-spin text-[#8fb3ff]" - aria-hidden="true" - /> - <h1 className="mt-4 text-[18px] font-semibold tracking-normal text-[#f4f7fb]">正在加载工作台</h1> - <p className="mt-2 text-[13px] leading-6 text-[#9ca7ba]"> - 正在准备页面数据,请稍等。 - </p> - </div> - </section> + <div className="h-px w-full overflow-hidden bg-[#11161d]"> + <div + data-app-route-loading-indicator="true" + className="h-px w-1/3 animate-pulse bg-[#8fb3ff]" + /> + </div> </main> ); } diff --git a/web-next/components/ui/cold-code-editor.test.tsx b/web-next/components/ui/cold-code-editor.test.tsx index f6bbc08151..b20f4e1469 100644 --- a/web-next/components/ui/cold-code-editor.test.tsx +++ b/web-next/components/ui/cold-code-editor.test.tsx @@ -5,14 +5,15 @@ import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it, vi } from 'vitest'; vi.mock('@uiw/react-codemirror', () => ({ - default: ({ value, readOnly, extensions, height, basicSetup, theme, onChange, ...props }: any) => ( + default: ({ value, readOnly, editable, extensions, height, basicSetup, theme, onChange, ...props }: any) => ( <div data-testid={props['data-testid']} data-mocked-codemirror="true" data-readonly={readOnly ? 'true' : 'false'} + data-editable={editable ? 'true' : 'false'} data-height={height} data-basic-setup={basicSetup ? 'true' : 'false'} - data-theme={theme ? 'custom-dark' : 'missing'} + data-theme={typeof theme === 'string' ? theme : theme ? 'custom-dark' : 'missing'} data-extension-count={Array.isArray(extensions) ? String(extensions.length) : '0'} onClick={() => onChange?.(`${value}\nnext`)} > @@ -51,8 +52,13 @@ describe('ColdCodeEditor', () => { expect(source).toContain("from '@codemirror/lang-json'"); expect(source).toContain("from '@codemirror/lang-html'"); expect(source).toContain('EditorView.lineWrapping'); - expect(source).toContain('theme={oneDark}'); + expect(source).toContain("theme={theme === 'vs-dark' ? oneDark : 'light'}"); expect(source).toContain('data-cold-code-editor="codemirror"'); + expect(source).toContain('data-cold-code-editor-theme={theme}'); + expect(source).toContain("data-cold-code-editor-loading={loading ? 'true' : 'false'}"); + expect(source).toContain('data-cold-code-editor-loading-state="angular-nz-code-editor-loading"'); + expect(source).toContain("data-cold-code-editor-folding={folding ? 'true' : 'false'}"); + expect(source).toContain("data-cold-code-editor-automatic-layout={automaticLayout ? 'true' : 'false'}"); expect(source).toContain('data-cold-code-editor-license="codemirror-mit"'); expect(source).not.toContain('<textarea'); @@ -69,10 +75,74 @@ describe('ColdCodeEditor', () => { expect(html).toContain('data-cold-code-editor="codemirror"'); expect(html).toContain('data-cold-code-editor-language="yaml"'); + expect(html).toContain('data-cold-code-editor-theme="vs-dark"'); + expect(html).toContain('data-cold-code-editor-loading="false"'); + expect(html).toContain('data-cold-code-editor-loading-owner="cold-code-editor"'); + expect(html).toContain('data-cold-code-editor-folding="true"'); + expect(html).toContain('data-cold-code-editor-automatic-layout="true"'); expect(html).toContain('data-cold-code-editor-readonly="true"'); expect(html).toContain('data-cold-code-editor-license="codemirror-mit"'); expect(html).toContain('data-mocked-codemirror="true"'); + expect(html).toContain('data-editable="false"'); expect(html).toContain('data-theme="custom-dark"'); expect(html).toContain('apiVersion: hertzbeat/v1'); + expect(html).not.toContain('data-cold-code-editor-loading-state="angular-nz-code-editor-loading"'); + }); + + it('can expose the Angular light editor theme contract', async () => { + const { ColdCodeEditor } = await import('./cold-code-editor'); + const html = renderToStaticMarkup( + <ColdCodeEditor + data-testid="cold-code-editor-light" + language="yaml" + theme="vs" + value="app: mysql" + onChange={vi.fn()} + /> + ); + + expect(html).toContain('data-cold-code-editor-theme="vs"'); + expect(html).toContain('data-theme="light"'); + }); + + it('lets routes surface disabled folding and automatic layout contracts when needed', async () => { + const { ColdCodeEditor } = await import('./cold-code-editor'); + const html = renderToStaticMarkup( + <ColdCodeEditor + data-testid="cold-code-editor-options" + language="yaml" + value="app: custom" + folding={false} + automaticLayout={false} + onChange={vi.fn()} + /> + ); + + expect(html).toContain('data-cold-code-editor-folding="false"'); + expect(html).toContain('data-cold-code-editor-automatic-layout="false"'); + }); + + it('mirrors the Angular nz-code-editor loading contract', async () => { + const { ColdCodeEditor } = await import('./cold-code-editor'); + const html = renderToStaticMarkup( + <ColdCodeEditor + data-testid="cold-code-editor-loading" + language="yaml" + value="app: loading" + loading + loadingLabel="Loading template YAML" + onChange={vi.fn()} + /> + ); + + expect(html).toContain('data-cold-code-editor-loading="true"'); + expect(html).toContain('data-cold-code-editor-loading-owner="cold-code-editor"'); + expect(html).toContain('aria-busy="true"'); + expect(html).toContain('data-cold-code-editor-readonly="true"'); + expect(html).toContain('data-readonly="true"'); + expect(html).toContain('data-editable="false"'); + expect(html).toContain('data-cold-code-editor-loading-state="angular-nz-code-editor-loading"'); + expect(html).toContain('data-cold-code-editor-loading-state-owner="cold-code-editor"'); + expect(html).toContain('Loading template YAML'); }); }); diff --git a/web-next/components/ui/cold-code-editor.tsx b/web-next/components/ui/cold-code-editor.tsx index 0f33b69ea6..734139342f 100644 --- a/web-next/components/ui/cold-code-editor.tsx +++ b/web-next/components/ui/cold-code-editor.tsx @@ -14,12 +14,18 @@ import { cn } from '../../lib/utils'; import { HiddenInput } from './hidden-input'; export type ColdCodeEditorLanguage = 'yaml' | 'json' | 'html' | 'javascript' | 'shell' | 'text'; +export type ColdCodeEditorTheme = 'vs' | 'vs-dark'; export type ColdCodeEditorProps = Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> & { value: string; onChange?: (value: string) => void; language?: ColdCodeEditorLanguage; + theme?: ColdCodeEditorTheme; readOnly?: boolean; + loading?: boolean; + loadingLabel?: React.ReactNode; + folding?: boolean; + automaticLayout?: boolean; height?: string; minHeight?: string; placeholder?: string; @@ -76,6 +82,55 @@ const coldCodeEditorTheme = EditorView.theme({ dark: true }); +const coldCodeEditorLightTheme = EditorView.theme({ + '&': { + backgroundColor: '#f8fafc', + color: '#111827', + border: '1px solid #cbd5e1', + borderRadius: '3px', + fontSize: '12px', + minHeight: 'inherit' + }, + '&.cm-focused': { + borderColor: '#4e74f8', + outline: '2px solid rgba(78,116,248,0.14)' + }, + '.cm-scroller': { + fontFamily: + 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', + lineHeight: '1.6', + minHeight: 'inherit' + }, + '.cm-content': { + padding: '8px 0', + caretColor: '#1f2937', + minHeight: 'inherit' + }, + '.cm-line': { + padding: '0 12px' + }, + '.cm-gutters': { + backgroundColor: '#eef2f7', + borderRight: '1px solid #cbd5e1', + color: '#64748b' + }, + '.cm-activeLine': { + backgroundColor: '#e8f0ff' + }, + '.cm-activeLineGutter': { + backgroundColor: '#e8f0ff', + color: '#1f2937' + }, + '.cm-selectionBackground, &.cm-focused .cm-selectionBackground': { + backgroundColor: 'rgba(78,116,248,0.24)' + }, + '.cm-placeholder': { + color: '#64748b' + } +}, { + dark: false +}); + function getLanguageExtension(language: ColdCodeEditorLanguage): Extension | null { switch (language) { case 'yaml': @@ -97,7 +152,12 @@ export function ColdCodeEditor({ value, onChange, language = 'text', + theme = 'vs-dark', readOnly = false, + loading = false, + loadingLabel = 'Loading editor', + folding = true, + automaticLayout = true, height, minHeight = '220px', placeholder, @@ -109,23 +169,30 @@ export function ColdCodeEditor({ }: ColdCodeEditorProps) { const extensions = useMemo(() => { const languageExtension = getLanguageExtension(language); + const themeExtension = theme === 'vs-dark' ? coldCodeEditorTheme : coldCodeEditorLightTheme; return [ basicSetup, - coldCodeEditorTheme, + themeExtension, EditorView.lineWrapping, ...(languageExtension ? [languageExtension] : []), - ...(readOnly ? [EditorState.readOnly.of(true)] : []) + ...(readOnly || loading ? [EditorState.readOnly.of(true)] : []) ]; - }, [language, readOnly]); + }, [language, loading, readOnly, theme]); return ( <div {...props} data-cold-code-editor="codemirror" data-cold-code-editor-language={language} - data-cold-code-editor-readonly={readOnly ? 'true' : undefined} + data-cold-code-editor-theme={theme} + data-cold-code-editor-loading={loading ? 'true' : 'false'} + data-cold-code-editor-loading-owner="cold-code-editor" + data-cold-code-editor-folding={folding ? 'true' : 'false'} + data-cold-code-editor-automatic-layout={automaticLayout ? 'true' : 'false'} + data-cold-code-editor-readonly={readOnly || loading ? 'true' : undefined} data-cold-code-editor-license="codemirror-mit" - className={cn('min-w-0 overflow-hidden rounded-[3px]', className)} + aria-busy={loading ? 'true' : undefined} + className={cn('relative min-w-0 overflow-hidden rounded-[3px]', className)} style={{ minHeight, ...style }} > {name ? <HiddenInput name={name} value={value} data-cold-code-editor-value="hidden" /> : null} @@ -135,13 +202,22 @@ export function ColdCodeEditor({ minHeight={minHeight} placeholder={placeholder} basicSetup={false} - theme={oneDark} + theme={theme === 'vs-dark' ? oneDark : 'light'} extensions={extensions} - readOnly={readOnly} - editable={!readOnly} + readOnly={readOnly || loading} + editable={!readOnly && !loading} aria-label={ariaLabel} onChange={nextValue => onChange?.(nextValue)} /> + {loading ? ( + <div + data-cold-code-editor-loading-state="angular-nz-code-editor-loading" + data-cold-code-editor-loading-state-owner="cold-code-editor" + className="absolute inset-0 flex items-center justify-center bg-[#0b0c0e]/72 text-[12px] font-semibold text-[#dbe4f0] backdrop-blur-[1px]" + > + {loadingLabel} + </div> + ) : null} </div> ); } diff --git a/web-next/components/ui/cold-confirm-dialog.test.tsx b/web-next/components/ui/cold-confirm-dialog.test.tsx new file mode 100644 index 0000000000..b1a6ef5540 --- /dev/null +++ b/web-next/components/ui/cold-confirm-dialog.test.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; + +import { ColdConfirmDialog } from './cold-confirm-dialog'; + +describe('ColdConfirmDialog', () => { + it('renders shared confirmation chrome from runtime messages while preserving caller labels', () => { + const idleHtml = renderToStaticMarkup( + <ColdConfirmDialog + open + title="确认删除" + copy="删除后不可恢复。" + confirmLabel="确认" + cancelLabel="取消" + onCancel={() => undefined} + onConfirm={() => undefined} + /> + ); + + expect(idleHtml).toContain('data-cold-confirm-dialog="cold-confirm-dialog"'); + expect(idleHtml).toContain('Confirm operation'); + expect(idleHtml).toContain('确认删除'); + expect(idleHtml).toContain('删除后不可恢复。'); + expect(idleHtml).toContain('确认'); + expect(idleHtml).toContain('取消'); + + const pendingHtml = renderToStaticMarkup( + <ColdConfirmDialog + open + title="确认删除" + copy="删除后不可恢复。" + confirmLabel="确认" + cancelLabel="取消" + pending + onCancel={() => undefined} + onConfirm={() => undefined} + /> + ); + + expect(pendingHtml).toContain('Processing'); + expect(pendingHtml).toContain('disabled=""'); + }); +}); diff --git a/web-next/components/ui/cold-confirm-dialog.tsx b/web-next/components/ui/cold-confirm-dialog.tsx index 0f33abb76a..abab8ef957 100644 --- a/web-next/components/ui/cold-confirm-dialog.tsx +++ b/web-next/components/ui/cold-confirm-dialog.tsx @@ -4,6 +4,11 @@ import React from 'react'; import { AlertTriangle } from 'lucide-react'; import { Button } from './button'; import { OverlayDialog } from '../workbench/overlay-dialog'; +import { SUPPLEMENTAL_MESSAGES } from '../../lib/i18n-runtime-messages'; + +function translateColdConfirm(key: string) { + return SUPPLEMENTAL_MESSAGES['en-US']?.[key] ?? SUPPLEMENTAL_MESSAGES['zh-CN']?.[key] ?? key; +} type ColdConfirmDialogProps = { open: boolean; @@ -30,7 +35,7 @@ export function ColdConfirmDialog({ <OverlayDialog open={open} title={title} - kicker="确认操作" + kicker={translateColdConfirm('common.confirm.operation')} onClose={pending ? () => undefined : onCancel} maxWidthClassName="max-w-md" contentClassName="py-4" @@ -40,7 +45,7 @@ export function ColdConfirmDialog({ {cancelLabel} </Button> <Button size="sm" variant="primary" onClick={onConfirm} disabled={pending}> - {pending ? '处理中' : confirmLabel} + {pending ? translateColdConfirm('common.processing') : confirmLabel} </Button> </div> } diff --git a/web-next/components/ui/date-time-range.test.tsx b/web-next/components/ui/date-time-range.test.tsx index 6e9333e294..d4a298ca3b 100644 --- a/web-next/components/ui/date-time-range.test.tsx +++ b/web-next/components/ui/date-time-range.test.tsx @@ -118,4 +118,53 @@ describe('DateTimeRange', () => { expect(html).not.toContain('今天'); expect(html).not.toContain('type="time"'); }); + + it('renders English fallback default trigger labels', () => { + const html = renderToStaticMarkup( + <DateTimeRange + mode="time" + startName="periodStart" + endName="periodEnd" + startValue="" + endValue="" + onStartChange={vi.fn()} + onEndChange={vi.fn()} + /> + ); + + expect(html.match(/>Not set</g)).toHaveLength(2); + expect(html).toContain('aria-label="Start"'); + expect(html).toContain('aria-label="End"'); + expect(html).not.toContain('未设置'); + expect(html).not.toContain('aria-label="开始"'); + expect(html).not.toContain('aria-label="结束"'); + }); + + it('keeps empty-state picker chrome caller-owned', () => { + const html = renderToStaticMarkup( + <DateTimeRange + mode="time" + startName="periodStart" + endName="periodEnd" + startValue="" + endValue="" + onStartChange={vi.fn()} + onEndChange={vi.fn()} + startLabel="Start time" + endLabel="End time" + emptyLabel="Not set" + hourLabel="Hour" + minuteLabel="Minute" + previousMonthLabel="Previous month" + nextMonthLabel="Next month" + clearLabel="Clear" + confirmLabel="OK" + /> + ); + + expect(html.match(/Not set/g)).toHaveLength(2); + expect(html).toContain('aria-label="Start time"'); + expect(html).toContain('aria-label="End time"'); + expect(html).not.toContain('未设置'); + }); }); diff --git a/web-next/components/ui/date-time-range.tsx b/web-next/components/ui/date-time-range.tsx index e87b4a7fd7..15f057cbf1 100644 --- a/web-next/components/ui/date-time-range.tsx +++ b/web-next/components/ui/date-time-range.tsx @@ -3,6 +3,7 @@ import { createPortal } from 'react-dom'; import ReactDatePicker, { registerLocale } from 'react-datepicker'; import { zhCN } from 'date-fns/locale/zh-CN'; import { ArrowRight } from 'lucide-react'; +import { SUPPLEMENTAL_MESSAGES } from '../../lib/i18n-runtime-messages'; import { cn } from '../../lib/utils'; import { HiddenInput } from './hidden-input'; @@ -16,6 +17,13 @@ export interface DateTimeRangeProps extends React.HTMLAttributes<HTMLDivElement> onEndChange: (value: string) => void; startLabel?: string; endLabel?: string; + emptyLabel?: string; + hourLabel?: string; + minuteLabel?: string; + previousMonthLabel?: string; + nextMonthLabel?: string; + clearLabel?: string; + confirmLabel?: string; reserveActionSpace?: boolean; } @@ -34,6 +42,10 @@ const TIME_MINUTES = Array.from({ length: 60 }, (_, minute) => minute); registerLocale('zh-CN', zhCN); +function translateDateTimeRange(key: string) { + return SUPPLEMENTAL_MESSAGES['en-US']?.[key] ?? SUPPLEMENTAL_MESSAGES['zh-CN']?.[key] ?? key; +} + function pad2(value: number) { return String(value).padStart(2, '0'); } @@ -85,8 +97,8 @@ function formatDateChange(mode: DateTimeRangeProps['mode'], value: string, nextD return formatFromDate(mode, nextDate); } -function formatValue(mode: DateTimeRangeProps['mode'], value: string) { - if (!value) return '未设置'; +function formatValue(mode: DateTimeRangeProps['mode'], value: string, emptyLabel: string) { + if (!value) return emptyLabel; return mode === 'time' ? value.slice(0, 5) : value.replace('T', ' '); } @@ -188,11 +200,15 @@ function TimeColumn({ function TimeColumns({ mode, value, - onChange + onChange, + hourLabel, + minuteLabel }: { mode: DateTimeRangeProps['mode']; value: string; onChange: (value: string) => void; + hourLabel: string; + minuteLabel: string; }) { const { hour, minute } = getTimeParts(mode, value); return ( @@ -204,14 +220,14 @@ function TimeColumns({ )} > <TimeColumn - label="时" + label={hourLabel} column="hour" values={TIME_HOURS} selectedValue={hour} onSelect={nextHour => onChange(formatWithTime(mode, value, nextHour, minute))} /> <TimeColumn - label="分" + label={minuteLabel} column="minute" values={TIME_MINUTES} selectedValue={minute} @@ -227,9 +243,29 @@ type ColdDateTimePickerProps = { value: string; label: string; onChange: (value: string) => void; + emptyLabel: string; + hourLabel: string; + minuteLabel: string; + previousMonthLabel: string; + nextMonthLabel: string; + clearLabel: string; + confirmLabel: string; }; -function ColdDateTimePicker({ mode, name, value, label, onChange }: ColdDateTimePickerProps) { +function ColdDateTimePicker({ + mode, + name, + value, + label, + onChange, + emptyLabel, + hourLabel, + minuteLabel, + previousMonthLabel, + nextMonthLabel, + clearLabel, + confirmLabel +}: ColdDateTimePickerProps) { const triggerRef = React.useRef<HTMLButtonElement | null>(null); const [open, setOpen] = React.useState(false); const [draftValue, setDraftValue] = React.useState(value); @@ -301,10 +337,10 @@ function ColdDateTimePicker({ mode, name, value, label, onChange }: ColdDateTime onChange={next => setDraftValue(formatDateChange(mode, draftValue, next))} inline locale="zh-CN" - previousMonthButtonLabel="上个月" - nextMonthButtonLabel="下个月" - previousMonthAriaLabel="上个月" - nextMonthAriaLabel="下个月" + previousMonthButtonLabel={previousMonthLabel} + nextMonthButtonLabel={nextMonthLabel} + previousMonthAriaLabel={previousMonthLabel} + nextMonthAriaLabel={nextMonthLabel} calendarClassName="hertzbeat-date-picker-calendar" shouldCloseOnSelect={false} dateFormat="yyyy-MM-dd HH:mm" @@ -312,7 +348,13 @@ function ColdDateTimePicker({ mode, name, value, label, onChange }: ColdDateTime /> </div> )} - <TimeColumns mode={mode} value={draftValue} onChange={setDraftValue} /> + <TimeColumns + mode={mode} + value={draftValue} + onChange={setDraftValue} + hourLabel={hourLabel} + minuteLabel={minuteLabel} + /> </div> <div data-cold-date-time-picker-panel="body-portal-clear-confirm" className="flex justify-end gap-2 border-t border-[#252b34] p-3"> <button @@ -321,7 +363,7 @@ function ColdDateTimePicker({ mode, name, value, label, onChange }: ColdDateTime className="h-7 min-w-[58px] rounded-[3px] border border-[#2b3039] bg-[#0d1015] px-2 text-[12px] font-semibold text-[#a9b0bb] hover:border-[#3b4454] hover:text-[#dbe4f0]" onClick={clearValue} > - 清除 + {clearLabel} </button> <button type="button" @@ -329,7 +371,7 @@ function ColdDateTimePicker({ mode, name, value, label, onChange }: ColdDateTime className="h-7 min-w-[58px] rounded-[3px] border border-[#31405c] bg-[#182238] px-2 text-[12px] font-semibold text-[#d8e4ff] hover:border-[#4e74f8]" onClick={confirmValue} > - 确定 + {confirmLabel} </button> </div> </div> @@ -344,7 +386,7 @@ function ColdDateTimePicker({ mode, name, value, label, onChange }: ColdDateTime <HiddenInput data-cold-date-time-picker-input="hidden-value" name={name} value={value} /> <PickerTrigger ref={triggerRef} - displayValue={formatValue(mode, open ? draftValue : value)} + displayValue={formatValue(mode, open ? draftValue : value, emptyLabel)} empty={!(open ? draftValue : value)} expanded={open} aria-label={label} @@ -363,8 +405,15 @@ export function DateTimeRange({ endValue, onStartChange, onEndChange, - startLabel = '开始', - endLabel = '结束', + startLabel = translateDateTimeRange('time.range.start'), + endLabel = translateDateTimeRange('time.range.end'), + emptyLabel = translateDateTimeRange('time.range.unset'), + hourLabel = translateDateTimeRange('time.range.hour'), + minuteLabel = translateDateTimeRange('time.range.minute'), + previousMonthLabel = translateDateTimeRange('time.range.previous-month'), + nextMonthLabel = translateDateTimeRange('time.range.next-month'), + clearLabel = translateDateTimeRange('common.clear'), + confirmLabel = translateDateTimeRange('common.button.ok'), reserveActionSpace, className, ...props @@ -392,6 +441,13 @@ export function DateTimeRange({ value={startValue} label={startLabel} onChange={onStartChange} + emptyLabel={emptyLabel} + hourLabel={hourLabel} + minuteLabel={minuteLabel} + previousMonthLabel={previousMonthLabel} + nextMonthLabel={nextMonthLabel} + clearLabel={clearLabel} + confirmLabel={confirmLabel} /> <ArrowRight className="mx-auto h-3.5 w-3.5 shrink-0 text-[#7e8494]" aria-hidden="true" /> <ColdDateTimePicker @@ -400,6 +456,13 @@ export function DateTimeRange({ value={endValue} label={endLabel} onChange={onEndChange} + emptyLabel={emptyLabel} + hourLabel={hourLabel} + minuteLabel={minuteLabel} + previousMonthLabel={previousMonthLabel} + nextMonthLabel={nextMonthLabel} + clearLabel={clearLabel} + confirmLabel={confirmLabel} /> {reserveActionSpace ? <span data-cold-date-time-range-reserved-action="true" aria-hidden="true" /> : null} </div> diff --git a/web-next/components/ui/label-record-input.test.tsx b/web-next/components/ui/label-record-input.test.tsx index 880e8f26e7..5925f3ed95 100644 --- a/web-next/components/ui/label-record-input.test.tsx +++ b/web-next/components/ui/label-record-input.test.tsx @@ -34,6 +34,11 @@ describe('LabelRecordInput', () => { expect(html).toContain('data-cold-label-selector-value-input="searchable-value"'); expect(html).toContain('data-cold-label-selector-value="hidden"'); expect(html).toContain('type="hidden"'); + expect(html).toContain('placeholder="Label key"'); + expect(html).toContain('placeholder="Label value"'); + expect(html).toContain('>Add<'); + expect(html).toContain('>Remove<'); + expect(html).toContain('aria-label="Remove service:checkout"'); expect(html).toContain('rounded-[3px]'); expect(html).not.toContain('data-cold-label-selector-chip-list="true"'); expect(html).not.toContain('data-cold-label-selector-chip='); diff --git a/web-next/components/ui/label-record-input.tsx b/web-next/components/ui/label-record-input.tsx index ae2af381c1..3d1e6683e3 100644 --- a/web-next/components/ui/label-record-input.tsx +++ b/web-next/components/ui/label-record-input.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { Plus, X } from 'lucide-react'; import type { AlertLabelOptions } from '../../lib/alert-label-options'; import { DEFAULT_ALERT_LABEL_OPTIONS } from '../../lib/alert-label-options'; +import { SUPPLEMENTAL_MESSAGES } from '../../lib/i18n-runtime-messages'; import { cn } from '../../lib/utils'; import { HiddenInput } from './hidden-input'; @@ -54,6 +55,10 @@ function filterSuggestions(values: string[], query: string, limit = 8) { .slice(0, limit); } +function translateLabelRecordInput(key: string) { + return SUPPLEMENTAL_MESSAGES['en-US']?.[key] ?? SUPPLEMENTAL_MESSAGES['zh-CN']?.[key] ?? key; +} + export interface LabelRecordInputProps { value: string; onValueChange: (value: string) => void; @@ -75,10 +80,10 @@ export const LabelRecordInput = React.forwardRef<HTMLInputElement, LabelRecordIn name, disabled, labelOptions = DEFAULT_ALERT_LABEL_OPTIONS, - keyPlaceholder = '标签名', - valuePlaceholder = '标签值', - addLabel = '添加', - removeLabel = '删除', + keyPlaceholder = translateLabelRecordInput('common.label.key'), + valuePlaceholder = translateLabelRecordInput('common.label.value'), + addLabel = translateLabelRecordInput('common.add'), + removeLabel = translateLabelRecordInput('common.remove'), containerClassName }, ref diff --git a/web-next/components/ui/number-stepper.test.tsx b/web-next/components/ui/number-stepper.test.tsx index de34123e57..0be0be91cd 100644 --- a/web-next/components/ui/number-stepper.test.tsx +++ b/web-next/components/ui/number-stepper.test.tsx @@ -11,6 +11,8 @@ describe('NumberStepper', () => { expect(html).toContain('data-cold-number-stepper-input="true"'); expect(html).toContain('data-cold-number-stepper-action="decrement"'); expect(html).toContain('data-cold-number-stepper-action="increment"'); + expect(html).toContain('>Decrease<'); + expect(html).toContain('>Increase<'); expect(html).toContain('inputMode="numeric"'); expect(html).toContain('rounded-[3px]'); expect(html).not.toContain('type="number"'); diff --git a/web-next/components/ui/number-stepper.tsx b/web-next/components/ui/number-stepper.tsx index c44026afdc..c769372199 100644 --- a/web-next/components/ui/number-stepper.tsx +++ b/web-next/components/ui/number-stepper.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { Minus, Plus } from 'lucide-react'; +import { SUPPLEMENTAL_MESSAGES } from '../../lib/i18n-runtime-messages'; import { cn } from '../../lib/utils'; function toNumber(value: string | number | undefined, fallback: number) { @@ -18,6 +19,10 @@ function formatNumber(value: number) { return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(6))); } +function translateNumberStepper(key: string) { + return SUPPLEMENTAL_MESSAGES['en-US']?.[key] ?? SUPPLEMENTAL_MESSAGES['zh-CN']?.[key] ?? key; +} + export interface NumberStepperProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type' | 'value' | 'onChange'> { value: string; onValueChange: (value: string) => void; @@ -37,8 +42,8 @@ const NumberStepper = React.forwardRef<HTMLInputElement, NumberStepperProps>( step = 1, value, onValueChange, - decrementLabel = '减少', - incrementLabel = '增加', + decrementLabel = translateNumberStepper('common.decrement'), + incrementLabel = translateNumberStepper('common.increment'), ...props }, ref diff --git a/web-next/components/ui/search-row.test.tsx b/web-next/components/ui/search-row.test.tsx index ae2fd67397..c71b588dd0 100644 --- a/web-next/components/ui/search-row.test.tsx +++ b/web-next/components/ui/search-row.test.tsx @@ -24,6 +24,9 @@ describe('SearchRow', () => { expect(html).toContain('data-cold-search-chrome="no-extra-input-shell"'); expect(html).toContain('data-cold-search-action="submit"'); expect(html).toContain('data-cold-search-action="clear"'); + expect(html).toContain('<form'); + expect(html).toContain('type="search"'); + expect(html).toContain('type="submit"'); expect(html).toContain('w-[320px]'); expect(html).toContain('w-fit'); expect(html).toContain('max-w-full'); @@ -54,4 +57,21 @@ describe('SearchRow', () => { html.indexOf('data-cold-search-action="submit"') ); }); + + it('uses runtime defaults for action labels when callers omit shared copy', () => { + const html = renderToStaticMarkup( + <SearchRow + value="weekday" + placeholder="Policy name" + onValueChange={vi.fn()} + onSearch={vi.fn()} + onClear={vi.fn()} + /> + ); + + expect(html).toContain('data-cold-search-action="submit"'); + expect(html).toContain('data-cold-search-action="clear"'); + expect(html).toContain('Search'); + expect(html).toContain('Clear'); + }); }); diff --git a/web-next/components/ui/search-row.tsx b/web-next/components/ui/search-row.tsx index 2a341e4c9f..300c839d39 100644 --- a/web-next/components/ui/search-row.tsx +++ b/web-next/components/ui/search-row.tsx @@ -2,11 +2,12 @@ import * as React from 'react'; import { Search, X } from 'lucide-react'; import { Button } from './button'; import { cn } from '../../lib/utils'; +import { SUPPLEMENTAL_MESSAGES } from '../../lib/i18n-runtime-messages'; export interface SearchRowProps extends Omit<React.FormHTMLAttributes<HTMLFormElement>, 'onSubmit' | 'onChange'> { value: string; placeholder: string; - searchLabel: string; + searchLabel?: string; clearLabel?: string; filters?: React.ReactNode; inputWidthClassName?: string; @@ -20,12 +21,14 @@ export interface SearchRowProps extends Omit<React.FormHTMLAttributes<HTMLFormEl const searchButtonClassName = 'h-8 min-w-[72px] rounded-[3px] border-[#2b3039] bg-[#101217] px-3 text-[12px] font-semibold text-[#dbe4f0] hover:border-[#4e74f8] hover:bg-[#151b28] hover:text-white'; +const DEFAULT_SEARCH_ROW_SEARCH_LABEL = SUPPLEMENTAL_MESSAGES['en-US']?.['common.search'] ?? 'common.search'; +const DEFAULT_SEARCH_ROW_CLEAR_LABEL = SUPPLEMENTAL_MESSAGES['en-US']?.['common.clear'] ?? 'common.clear'; export function SearchRow({ value, placeholder, - searchLabel, - clearLabel, + searchLabel = DEFAULT_SEARCH_ROW_SEARCH_LABEL, + clearLabel = DEFAULT_SEARCH_ROW_CLEAR_LABEL, filters, inputWidthClassName = 'w-[320px]', searchDisabled = false, diff --git a/web-next/components/ui/select.test.tsx b/web-next/components/ui/select.test.tsx index 78aecac21a..314dc2d0e7 100644 --- a/web-next/components/ui/select.test.tsx +++ b/web-next/components/ui/select.test.tsx @@ -59,6 +59,31 @@ describe('Select', () => { expect(directNativeSelectFiles).toEqual([]); }); + + it('renders the catalog-backed empty trigger label when no option is available', () => { + const html = renderToStaticMarkup(<Select aria-label="empty select" />); + + expect(html).toContain('data-cold-select-control="custom-trigger"'); + expect(html).toContain('None'); + expect(html).not.toContain('>-<'); + }); + + it('can render the Angular searchable dropdown affordance for settings timezones', () => { + const html = renderToStaticMarkup( + <Select searchable searchPlaceholder="Search timezone" defaultOpen defaultValue="Asia/Shanghai" aria-label="Timezone"> + <option value="Asia/Shanghai">Asia/Shanghai (+08:00) Shanghai</option> + <option value="UTC">UTC (+00:00) UTC</option> + </Select> + ); + const source = readFileSync(resolve(process.cwd(), 'components/ui/select.tsx'), 'utf8'); + + expect(html).toContain('data-cold-select-search="angular-nz-show-search"'); + expect(html).toContain('type="search"'); + expect(html).toContain('placeholder="Search timezone"'); + expect(html).toContain('data-cold-select-listbox="custom-menu"'); + expect(source).toContain('searchable?: boolean'); + expect(source).toContain('data-cold-select-empty="search-empty"'); + }); }); function walkSourceFiles(dir: string): string[] { diff --git a/web-next/components/ui/select.tsx b/web-next/components/ui/select.tsx index 16d4064239..6bdfe8317a 100644 --- a/web-next/components/ui/select.tsx +++ b/web-next/components/ui/select.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { createPortal } from 'react-dom'; import { ChevronDown } from 'lucide-react'; +import { SUPPLEMENTAL_MESSAGES } from '../../lib/i18n-runtime-messages'; import { cn } from '../../lib/utils'; import { HiddenInput } from './hidden-input'; @@ -15,6 +16,8 @@ export const coldSelectIconClassName = const coldSelectListboxClassName = 'fixed z-[90] min-w-[220px] overflow-y-auto rounded-[4px] border border-[#2b3039] bg-[#111318] p-1 text-[12px] font-semibold text-[#dbe4f0] shadow-[0_22px_60px_rgba(0,0,0,0.55)]'; +const DEFAULT_SELECT_EMPTY_LABEL = SUPPLEMENTAL_MESSAGES['en-US']?.['common.none'] ?? 'common.none'; + type SelectOption = { value: string; label: React.ReactNode; @@ -24,6 +27,8 @@ type SelectOption = { export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> { containerClassName?: string; defaultOpen?: boolean; + searchable?: boolean; + searchPlaceholder?: string; onValueChange?: (value: string) => void; } @@ -73,6 +78,8 @@ const Select = React.forwardRef<HTMLSelectElement, SelectProps>( id, 'aria-label': ariaLabel, defaultOpen = false, + searchable = false, + searchPlaceholder, ...props }, ref @@ -82,6 +89,7 @@ const Select = React.forwardRef<HTMLSelectElement, SelectProps>( const isControlled = value !== undefined; const [internalValue, setInternalValue] = React.useState(String(defaultValue ?? fallbackValue)); const [open, setOpen] = React.useState(defaultOpen); + const [searchQuery, setSearchQuery] = React.useState(''); const [listboxStyle, setListboxStyle] = React.useState<React.CSSProperties>({ position: 'fixed' }); const shellRef = React.useRef<HTMLSpanElement>(null); const triggerRef = React.useRef<HTMLButtonElement>(null); @@ -91,6 +99,10 @@ const Select = React.forwardRef<HTMLSelectElement, SelectProps>( const listboxId = `${triggerId}-listbox`; const selectedValue = String(isControlled ? value : internalValue); const selectedOption = options.find(option => option.value === selectedValue) ?? options.find(option => !option.disabled); + const normalizedSearchQuery = searchQuery.trim().toLowerCase(); + const visibleOptions = searchable && normalizedSearchQuery + ? options.filter(option => React.Children.toArray(option.label).join(' ').toLowerCase().includes(normalizedSearchQuery)) + : options; const updateListboxGeometry = React.useCallback(() => { if (typeof window === 'undefined' || !triggerRef.current) return; @@ -134,6 +146,10 @@ const Select = React.forwardRef<HTMLSelectElement, SelectProps>( return () => document.removeEventListener('pointerdown', onPointerDown); }, [open]); + React.useEffect(() => { + if (!open) setSearchQuery(''); + }, [open]); + React.useEffect(() => { if (!open || typeof window === 'undefined') return; window.addEventListener('resize', updateListboxGeometry); @@ -172,7 +188,21 @@ const Select = React.forwardRef<HTMLSelectElement, SelectProps>( className={cn(coldSelectListboxClassName)} style={listboxStyle} > - {options.map(option => { + {searchable ? ( + <input + type="search" + value={searchQuery} + placeholder={searchPlaceholder} + aria-label={searchPlaceholder} + data-cold-select-search="angular-nz-show-search" + className="mb-1 h-8 w-full rounded-[3px] border border-[#2b3039] bg-[#0b0c0e] px-2.5 text-[12px] font-semibold text-[#dbe4f0] outline-none placeholder:text-[#858d9a] focus:border-[#4e74f8] focus:ring-2 focus:ring-[rgba(78,116,248,0.12)]" + onChange={event => setSearchQuery(event.target.value)} + onKeyDown={event => { + if (event.key === 'Escape') setOpen(false); + }} + /> + ) : null} + {visibleOptions.map(option => { const selected = option.value === selectedOption?.value; return ( <button @@ -194,6 +224,11 @@ const Select = React.forwardRef<HTMLSelectElement, SelectProps>( </button> ); })} + {visibleOptions.length === 0 ? ( + <div data-cold-select-empty="search-empty" className="px-2.5 py-2 text-[12px] text-[#8f99ab]"> + {DEFAULT_SELECT_EMPTY_LABEL} + </div> + ) : null} </div> ) : null; @@ -257,7 +292,7 @@ const Select = React.forwardRef<HTMLSelectElement, SelectProps>( } }} > - <span className="min-w-0 truncate">{selectedOption?.label ?? '-'}</span> + <span className="min-w-0 truncate">{selectedOption?.label ?? DEFAULT_SELECT_EMPTY_LABEL}</span> </button> <ChevronDown data-cold-select-icon="chevron" className={cn(coldSelectIconClassName, open ? 'text-[#dbe4f0]' : null)} aria-hidden="true" /> {listbox ? (canUsePortal ? createPortal(listbox, document.body) : listbox) : null} diff --git a/web-next/components/ui/tag-input.test.tsx b/web-next/components/ui/tag-input.test.tsx index 3fbc927fdb..f9a4d4871e 100644 --- a/web-next/components/ui/tag-input.test.tsx +++ b/web-next/components/ui/tag-input.test.tsx @@ -27,6 +27,9 @@ describe('TagInput', () => { expect(html).toContain('data-cold-tag-input-control="draft"'); expect(html).toContain('data-cold-tag-input-value="hidden"'); expect(html).toContain('type="hidden"'); + expect(html).toContain('placeholder="Add tag"'); + expect(html).toContain('aria-label="Remove alertname"'); + expect(html).toContain('aria-label="Remove service"'); expect(html).toContain('rounded-[3px]'); expect(html).not.toContain('data-cold-tag-suggestion='); expect(html).not.toContain('data-cold-tag-suggestions-owner='); diff --git a/web-next/components/ui/tag-input.tsx b/web-next/components/ui/tag-input.tsx index e0eadcde2d..ebb31b04eb 100644 --- a/web-next/components/ui/tag-input.tsx +++ b/web-next/components/ui/tag-input.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { X } from 'lucide-react'; +import { SUPPLEMENTAL_MESSAGES } from '../../lib/i18n-runtime-messages'; import { cn } from '../../lib/utils'; import { HiddenInput } from './hidden-input'; @@ -20,6 +21,10 @@ type PopoverMetrics = { width: number; }; +function translateTagInput(key: string) { + return SUPPLEMENTAL_MESSAGES['en-US']?.[key] ?? SUPPLEMENTAL_MESSAGES['zh-CN']?.[key] ?? key; +} + export interface TagInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange'> { value: string; onValueChange: (value: string) => void; @@ -118,7 +123,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>( disabled={disabled} data-cold-tag-remove={tag} className="grid h-4 w-4 place-items-center rounded-[2px] text-[#8f99ab] transition hover:bg-[#202838] hover:text-[#f5f7fb] disabled:pointer-events-none" - aria-label={`删除 ${tag}`} + aria-label={`${translateTagInput('common.remove')} ${tag}`} onClick={() => removeTag(tag)} > <X className="h-3 w-3" aria-hidden="true" /> @@ -135,7 +140,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>( 'h-6 min-w-[120px] flex-1 border-0 bg-transparent px-1 text-[12px] font-semibold text-[#dbe4f0] outline-none placeholder:text-[#858d9a]', className )} - placeholder={tags.length === 0 ? placeholder : '添加标签'} + placeholder={tags.length === 0 ? placeholder : translateTagInput('common.tag.add')} onChange={event => { setDraft(event.target.value); positionPopover(event.currentTarget); diff --git a/web-next/components/workbench/overlay-dialog.test.tsx b/web-next/components/workbench/overlay-dialog.test.tsx index e1245bc632..9361d23e41 100644 --- a/web-next/components/workbench/overlay-dialog.test.tsx +++ b/web-next/components/workbench/overlay-dialog.test.tsx @@ -39,6 +39,7 @@ describe('overlay dialog', () => { const overlay = container.querySelector('[data-overlay-dialog="true"]') as HTMLElement | null; expect(overlay).not.toBeNull(); + expect(overlay?.getAttribute('data-overlay-dialog-mask-closable')).toBe('true'); await act(async () => { overlay?.dispatchEvent(new MouseEvent('click', { bubbles: true })); @@ -86,6 +87,37 @@ describe('overlay dialog', () => { const overlay = container.querySelector('[data-overlay-dialog="true"]') as HTMLElement | null; expect(overlay).not.toBeNull(); + expect(overlay?.getAttribute('data-overlay-dialog-mask-closable')).toBe('false'); + + await act(async () => { + overlay?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(onClose).not.toHaveBeenCalled(); + }); + + it('allows centered dialogs to explicitly preserve non-click-away modal semantics with route markers', async () => { + const onClose = vi.fn(); + + await act(async () => { + root.render( + <OverlayDialog + open={true} + title="标签管理" + onClose={onClose} + maskClosable={false} + overlayProps={{ 'data-label-dialog-mask-closable': 'false' }} + > + <input aria-label="标签名称" /> + </OverlayDialog> + ); + await Promise.resolve(); + }); + + const overlay = container.querySelector('[data-overlay-dialog="true"]') as HTMLElement | null; + expect(overlay?.getAttribute('data-overlay-dialog-mask-closable')).toBe('false'); + expect(overlay?.getAttribute('data-label-dialog-mask-closable')).toBe('false'); await act(async () => { overlay?.dispatchEvent(new MouseEvent('click', { bubbles: true })); diff --git a/web-next/components/workbench/overlay-dialog.tsx b/web-next/components/workbench/overlay-dialog.tsx index 780e27eeb5..3e5be5d21f 100644 --- a/web-next/components/workbench/overlay-dialog.tsx +++ b/web-next/components/workbench/overlay-dialog.tsx @@ -2,20 +2,12 @@ import React from 'react'; import { X } from 'lucide-react'; +import { SUPPLEMENTAL_MESSAGES } from '../../lib/i18n-runtime-messages'; import { cn } from '../../lib/utils'; -export function OverlayDialog({ - open, - title, - kicker, - footer, - onClose, - children, - className, - contentClassName, - maxWidthClassName = 'max-w-5xl', - placement = 'center' -}: { +const DEFAULT_OVERLAY_DIALOG_CLOSE_LABEL = SUPPLEMENTAL_MESSAGES['en-US']?.['common.dialog.close'] ?? 'common.dialog.close'; + +type OverlayDialogProps = { open: boolean; title: React.ReactNode; kicker?: React.ReactNode; @@ -26,19 +18,40 @@ export function OverlayDialog({ contentClassName?: string; maxWidthClassName?: string; placement?: 'center' | 'right'; -}) { + closeLabel?: string; + maskClosable?: boolean; + overlayProps?: Omit<React.HTMLAttributes<HTMLDivElement>, 'children' | 'className' | 'onClick' | 'title'>; +}; + +export function OverlayDialog({ + open, + title, + kicker, + footer, + onClose, + children, + className, + contentClassName, + maxWidthClassName = 'max-w-5xl', + placement = 'center', + closeLabel = DEFAULT_OVERLAY_DIALOG_CLOSE_LABEL, + maskClosable, + overlayProps +}: OverlayDialogProps) { if (!open) { return null; } const isSideDrawer = placement === 'right'; + const isMaskClosable = maskClosable ?? isSideDrawer; const handleOverlayClick = (event: React.MouseEvent<HTMLDivElement>) => { - if (isSideDrawer && event.target === event.currentTarget) { + if (isMaskClosable && event.target === event.currentTarget) { onClose(); } }; return ( <div + {...overlayProps} className={cn( 'fixed inset-0 z-50 flex bg-[rgba(11,12,14,0.88)]', isSideDrawer ? 'items-stretch justify-end p-0' : 'items-center justify-center p-4' @@ -48,6 +61,7 @@ export function OverlayDialog({ aria-label={typeof title === 'string' ? title : undefined} data-overlay-dialog="true" data-overlay-dialog-placement={placement} + data-overlay-dialog-mask-closable={isMaskClosable ? 'true' : 'false'} onClick={handleOverlayClick} > <div @@ -67,7 +81,7 @@ export function OverlayDialog({ <button type="button" onClick={onClose} - aria-label="Close dialog" + aria-label={closeLabel} className="inline-flex h-8 w-8 items-center justify-center rounded-[2px] border border-[var(--ops-border-color)] bg-transparent text-[var(--ops-text-secondary)] transition hover:border-[var(--ops-primary)] hover:bg-[var(--ops-surface-raised)] hover:text-[var(--ops-text-primary)]" > <X size={16} /> diff --git a/web-next/components/workbench/workspace-tab-strip.test.tsx b/web-next/components/workbench/workspace-tab-strip.test.tsx index 74b6d9f4c4..6748e11664 100644 --- a/web-next/components/workbench/workspace-tab-strip.test.tsx +++ b/web-next/components/workbench/workspace-tab-strip.test.tsx @@ -25,6 +25,7 @@ describe('workspace tab strip', () => { expect(html).toContain('Summary'); expect(html).toContain('Details'); + expect(html).toContain('aria-label="Workspace navigation"'); expect(html).toContain('border-[var(--ops-border-color)]'); expect(html).toContain('bg-[var(--ops-surface-panel)]'); expect(html).toContain('bg-[var(--ops-surface-raised)]'); @@ -38,6 +39,7 @@ describe('workspace tab strip', () => { it('renders shared default tabs with ops-shell chrome and badge treatment', () => { const html = renderToStaticMarkup( <WorkspaceTabStrip + ariaLabel="Custom workspace sections" tabs={[ { key: 'all', label: 'All', active: true, badge: '4' }, { key: 'errors', label: 'Errors', onSelect: () => {} } @@ -48,6 +50,7 @@ describe('workspace tab strip', () => { expect(html).toContain('All'); expect(html).toContain('Errors'); expect(html).toContain('>4<'); + expect(html).toContain('aria-label="Custom workspace sections"'); expect(html).toContain('border-[var(--ops-primary)]'); expect(html).toContain('bg-[var(--ops-surface-raised)]'); expect(html).toContain('text-[var(--ops-text-tertiary)]'); --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
