From e2fd72224321e1bd6284f93c3afa67f506a4f676 Mon Sep 17 00:00:00 2001 From: Uyarn Date: Tue, 27 Oct 2026 12:39:32 +0800 Subject: [PATCH 1/4] fix: fix doc badge display result --- .../src/components/td-doc-badge/index.ts | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/site-components/src/components/td-doc-badge/index.ts b/packages/site-components/src/components/td-doc-badge/index.ts index a1d19664f..e3b6fb66e 100644 --- a/packages/site-components/src/components/td-doc-badge/index.ts +++ b/packages/site-components/src/components/td-doc-badge/index.ts @@ -1,4 +1,4 @@ -import { html, define } from 'hybrids'; +import { html, define, type Descriptor } from 'hybrids'; import style from './style.less?inline'; interface BadgeHost { @@ -7,6 +7,24 @@ interface BadgeHost { color: string; } +function attribute(defaultValue: string): Descriptor { + return { + value: (_host, value) => value ?? defaultValue, + connect: (host, key) => { + const attributeName = key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); + const syncAttribute = () => { + host[key] = host.getAttribute(attributeName) ?? defaultValue; + }; + const observer = new MutationObserver(syncAttribute); + + if (host.hasAttribute(attributeName)) syncAttribute(); + observer.observe(host, { attributes: true, attributeFilter: [attributeName] }); + + return () => observer.disconnect(); + }, + }; +} + function getColor(message: string): string { const percentage = parseFloat(message); if (percentage >= 90) { @@ -20,9 +38,9 @@ function getColor(message: string): string { export default define({ tag: 'td-doc-badge', - label: 'coverage', - message: '0%', - color: '', + label: attribute('coverage'), + message: attribute('0%'), + color: attribute(''), render: (host) => { const { label, message, color: defaultColor } = host; let color = defaultColor; From 0e48fc393723652b5eb784207241832917d7614b Mon Sep 17 00:00:00 2001 From: Uyarn Date: Tue, 27 Oct 2026 13:24:26 +0800 Subject: [PATCH 2/4] fix: fix component attribute configuration --- .../src/__tests__/attribute.spec.ts | 55 ++++++++++++++++ .../src/__tests__/td-doc-badge.spec.ts | 51 +++++++++++++++ .../src/__tests__/td-doc-content.spec.ts | 5 +- .../src/__tests__/td-doc-header.spec.ts | 4 ++ .../src/__tests__/td-doc-popup.spec.ts | 5 +- .../src/__tests__/td-select.spec.ts | 64 +++++++++++++++++++ .../src/components/td-avatar/index.ts | 9 +-- .../src/components/td-doc-aside/index.ts | 4 +- .../src/components/td-doc-badge/index.ts | 21 +----- .../src/components/td-doc-content/index.ts | 6 +- .../src/components/td-doc-copy/index.ts | 3 +- .../src/components/td-doc-footer/index.ts | 4 +- .../src/components/td-doc-header/index.ts | 21 ++---- .../src/components/td-doc-layout/index.ts | 3 +- .../src/components/td-doc-popup/index.ts | 14 ++-- .../src/components/td-doc-tabs/index.ts | 6 +- .../src/components/td-header/index.ts | 18 +++--- .../td-header/td-collapse-menu/index.ts | 10 +-- .../src/components/td-logo/index.ts | 2 +- .../src/components/td-select/index.ts | 9 +-- .../src/components/td-switch/index.ts | 5 +- .../src/components/td-tooltip/index.ts | 7 +- packages/site-components/src/utils/index.ts | 32 ++++++++++ 23 files changed, 275 insertions(+), 83 deletions(-) create mode 100644 packages/site-components/src/__tests__/attribute.spec.ts create mode 100644 packages/site-components/src/__tests__/td-doc-badge.spec.ts create mode 100644 packages/site-components/src/__tests__/td-select.spec.ts diff --git a/packages/site-components/src/__tests__/attribute.spec.ts b/packages/site-components/src/__tests__/attribute.spec.ts new file mode 100644 index 000000000..9569f9a21 --- /dev/null +++ b/packages/site-components/src/__tests__/attribute.spec.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { attribute, parseBoolean } from '../utils'; + +interface AttributeHost extends HTMLElement { + enabled: boolean; +} + +type ConnectedHost = AttributeHost & { __property_key__: boolean }; + +describe('attribute descriptor', () => { + it('reads attributes added after element creation and observes updates', async () => { + const host = document.createElement('div') as unknown as ConnectedHost; + host.enabled = false; + host.setAttribute('enabled', ''); + + const descriptor = attribute(false, (value) => parseBoolean(value, false)); + const cleanup = descriptor.connect?.(host, 'enabled' as '__property_key__', vi.fn()); + + expect(host.enabled).toBe(true); + + host.setAttribute('enabled', 'false'); + await new Promise((resolve) => setTimeout(resolve)); + expect(host.enabled).toBe(false); + + cleanup?.(); + }); + + it('preserves properties when the attribute is absent', () => { + const host = document.createElement('div') as unknown as ConnectedHost; + host.enabled = true; + + const descriptor = attribute(false, (value) => parseBoolean(value, false)); + const cleanup = descriptor.connect?.(host, 'enabled' as '__property_key__', vi.fn()); + + expect(host.enabled).toBe(true); + + cleanup?.(); + }); + + it('composes an existing connect lifecycle', () => { + const host = document.createElement('div') as unknown as ConnectedHost; + host.enabled = false; + const lifecycleCleanup = vi.fn(); + const connect = vi.fn(() => lifecycleCleanup); + + const descriptor = attribute(false, undefined, { connect }); + const cleanup = descriptor.connect?.(host, 'enabled' as '__property_key__', vi.fn()); + + expect(connect).toHaveBeenCalledOnce(); + + cleanup?.(); + expect(lifecycleCleanup).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/site-components/src/__tests__/td-doc-badge.spec.ts b/packages/site-components/src/__tests__/td-doc-badge.spec.ts new file mode 100644 index 000000000..7db9fe04f --- /dev/null +++ b/packages/site-components/src/__tests__/td-doc-badge.spec.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import '../components/td-doc-badge'; + +async function waitForRender(): Promise { + await new Promise((resolve) => setTimeout(resolve)); +} + +describe('td-doc-badge', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('reads attributes set after element creation', async () => { + const badge = document.createElement('td-doc-badge'); + badge.setAttribute('label', 'statements'); + badge.setAttribute('message', '87.5%'); + document.body.appendChild(badge); + + await waitForRender(); + + expect(badge.shadowRoot?.querySelector('img')?.getAttribute('src')).toBe( + 'https://img.shields.io/badge/statements-87.5%25-yellow', + ); + }); + + it('updates when an attribute changes', async () => { + const badge = document.createElement('td-doc-badge'); + document.body.appendChild(badge); + await waitForRender(); + + badge.setAttribute('message', '95%'); + await waitForRender(); + + expect(badge.shadowRoot?.querySelector('img')?.getAttribute('src')).toBe( + 'https://img.shields.io/badge/coverage-95%25-brightgreen', + ); + }); + + it('preserves properties set before connection', async () => { + const badge = document.createElement('td-doc-badge') as HTMLElement & { message: string }; + badge.message = '72%'; + document.body.appendChild(badge); + + await waitForRender(); + + expect(badge.shadowRoot?.querySelector('img')?.getAttribute('src')).toBe( + 'https://img.shields.io/badge/coverage-72%25-yellow', + ); + }); +}); diff --git a/packages/site-components/src/__tests__/td-doc-content.spec.ts b/packages/site-components/src/__tests__/td-doc-content.spec.ts index 920b824ee..826a963f9 100644 --- a/packages/site-components/src/__tests__/td-doc-content.spec.ts +++ b/packages/site-components/src/__tests__/td-doc-content.spec.ts @@ -37,7 +37,10 @@ vi.mock('hybrids', () => ({ html: mockHtml, })); -vi.mock('@utils', () => ({ mobileBodyStyle: {} })); +vi.mock('@utils', () => ({ + attribute: vi.fn((value: unknown) => ({ value })), + mobileBodyStyle: {}, +})); vi.mock('../components/td-doc-content/style.less?inline', () => ({ default: '' })); describe('td-doc-content anchor scroll', () => { diff --git a/packages/site-components/src/__tests__/td-doc-header.spec.ts b/packages/site-components/src/__tests__/td-doc-header.spec.ts index efd9a7f55..07108c722 100644 --- a/packages/site-components/src/__tests__/td-doc-header.spec.ts +++ b/packages/site-components/src/__tests__/td-doc-header.spec.ts @@ -20,6 +20,10 @@ vi.mock('@config/locale', () => ({ getLocale: vi.fn(() => ({})) })); vi.mock('@config/spline', () => ({})); vi.mock('@images/history.svg?raw', () => ({ default: '' })); vi.mock('@utils', () => ({ + attribute: vi.fn((value: unknown, _parse?: unknown, descriptor: Record = {}) => ({ + value, + ...descriptor, + })), isComponentPage: vi.fn(), isGlobalConfigPage: vi.fn(), mobileBodyStyle: {}, diff --git a/packages/site-components/src/__tests__/td-doc-popup.spec.ts b/packages/site-components/src/__tests__/td-doc-popup.spec.ts index ca5eeeef5..5c50e5e79 100644 --- a/packages/site-components/src/__tests__/td-doc-popup.spec.ts +++ b/packages/site-components/src/__tests__/td-doc-popup.spec.ts @@ -28,7 +28,10 @@ vi.mock('hybrids', () => ({ })); vi.mock('@popperjs/core', () => ({ createPopper: mockCreatePopper })); -vi.mock('@utils', () => ({ parseBoolean: vi.fn((value: unknown) => Boolean(value)) })); +vi.mock('@utils', () => ({ + attribute: vi.fn((value: unknown) => ({ value })), + parseBoolean: vi.fn((value: unknown) => Boolean(value)), +})); vi.mock('../components/td-doc-popup/style.less?inline', () => ({ default: '' })); interface PortalContainerMock { diff --git a/packages/site-components/src/__tests__/td-select.spec.ts b/packages/site-components/src/__tests__/td-select.spec.ts new file mode 100644 index 000000000..1ffe4a865 --- /dev/null +++ b/packages/site-components/src/__tests__/td-select.spec.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import '../components/td-select'; + +interface SelectElement extends HTMLElement { + borderless: boolean; + value: string | number; + options: Array<{ label: string; value: string | number }>; +} + +async function waitForRender(): Promise { + await new Promise((resolve) => setTimeout(resolve)); +} + +describe('td-select', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('reads attributes set after element creation', async () => { + const select = document.createElement('td-select') as SelectElement; + select.options = [ + { label: 'One', value: '1' }, + { label: 'Two', value: '2' }, + ]; + select.setAttribute('value', '2'); + select.setAttribute('borderless', ''); + document.body.appendChild(select); + + await waitForRender(); + + expect(select.shadowRoot?.querySelector('input')?.value).toBe('Two'); + expect(select.shadowRoot?.querySelector('.TDesign-select-input')?.classList).toContain( + 'TDesign-select-input--borderless', + ); + }); + + it('updates when the value attribute changes', async () => { + const select = document.createElement('td-select') as SelectElement; + select.options = [ + { label: 'One', value: '1' }, + { label: 'Two', value: '2' }, + ]; + document.body.appendChild(select); + await waitForRender(); + + select.setAttribute('value', '1'); + await waitForRender(); + + expect(select.shadowRoot?.querySelector('input')?.value).toBe('One'); + }); + + it('preserves properties set before connection', async () => { + const select = document.createElement('td-select') as SelectElement; + select.options = [{ label: 'Two', value: 2 }]; + select.value = 2; + document.body.appendChild(select); + + await waitForRender(); + + expect(select.value).toBe(2); + expect(select.shadowRoot?.querySelector('input')?.value).toBe('Two'); + }); +}); diff --git a/packages/site-components/src/components/td-avatar/index.ts b/packages/site-components/src/components/td-avatar/index.ts index 91ba41a1e..1ee0e829a 100644 --- a/packages/site-components/src/components/td-avatar/index.ts +++ b/packages/site-components/src/components/td-avatar/index.ts @@ -1,4 +1,5 @@ import { html, define } from 'hybrids'; +import { attribute } from '@utils'; import style from './style.less?inline'; interface AvatarProps { @@ -10,10 +11,10 @@ interface AvatarProps { export default define({ tag: 'td-avatar', - content: '', - username: '', - src: '', - href: '', + content: attribute(''), + username: attribute(''), + src: attribute(''), + href: attribute(''), render: ({ content, username, src, href }) => { const defaultSrc = `https://avatars.githubusercontent.com/${username}`; const defaultHref = `https://github.com/${username}`; diff --git a/packages/site-components/src/components/td-doc-aside/index.ts b/packages/site-components/src/components/td-doc-aside/index.ts index b6ab615b6..061345e65 100644 --- a/packages/site-components/src/components/td-doc-aside/index.ts +++ b/packages/site-components/src/components/td-doc-aside/index.ts @@ -1,7 +1,7 @@ import { html, dispatch, define, type Component } from 'hybrids'; import menuFoldIcon from '@images/menu-fold.svg?raw'; import menuUnfoldIcon from '@images/menu-unfold.svg?raw'; -import { patchShadowDomIntoDom } from '@utils'; +import { attribute, patchShadowDomIntoDom } from '@utils'; import style from './style.less?inline'; const replaceStateEvent = new CustomEvent('replaceState'); @@ -130,7 +130,7 @@ const asideComponent = { routerList: { value: (_host: AsideHost & HTMLElement, v?: NavItem[]) => v || [], }, - title: '', + title: attribute(''), patchDom: { value: (_host: AsideHost & HTMLElement, v?: boolean) => v || false, connect: patchShadowDomIntoDom, diff --git a/packages/site-components/src/components/td-doc-badge/index.ts b/packages/site-components/src/components/td-doc-badge/index.ts index e3b6fb66e..203add76c 100644 --- a/packages/site-components/src/components/td-doc-badge/index.ts +++ b/packages/site-components/src/components/td-doc-badge/index.ts @@ -1,4 +1,5 @@ -import { html, define, type Descriptor } from 'hybrids'; +import { html, define } from 'hybrids'; +import { attribute } from '@utils'; import style from './style.less?inline'; interface BadgeHost { @@ -7,24 +8,6 @@ interface BadgeHost { color: string; } -function attribute(defaultValue: string): Descriptor { - return { - value: (_host, value) => value ?? defaultValue, - connect: (host, key) => { - const attributeName = key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); - const syncAttribute = () => { - host[key] = host.getAttribute(attributeName) ?? defaultValue; - }; - const observer = new MutationObserver(syncAttribute); - - if (host.hasAttribute(attributeName)) syncAttribute(); - observer.observe(host, { attributes: true, attributeFilter: [attributeName] }); - - return () => observer.disconnect(); - }, - }; -} - function getColor(message: string): string { const percentage = parseFloat(message); if (percentage >= 90) { diff --git a/packages/site-components/src/components/td-doc-content/index.ts b/packages/site-components/src/components/td-doc-content/index.ts index 1f5c0a782..adf8650bb 100644 --- a/packages/site-components/src/components/td-doc-content/index.ts +++ b/packages/site-components/src/components/td-doc-content/index.ts @@ -1,5 +1,5 @@ import { html, define } from 'hybrids'; -import { mobileBodyStyle } from '@utils'; +import { attribute, mobileBodyStyle } from '@utils'; import style from './style.less?inline'; const FIXED_HEADER_TOP = 228; @@ -50,8 +50,8 @@ function anchorHighlight(): void { export default define({ tag: 'td-doc-content', - platform: 'web', - pageStatus: 'show', + platform: attribute('web'), + pageStatus: attribute('show'), mobileBodyStyle, fixedAnchor: { value: (_host, v) => v || undefined, diff --git a/packages/site-components/src/components/td-doc-copy/index.ts b/packages/site-components/src/components/td-doc-copy/index.ts index c664a9a36..97c6a9169 100644 --- a/packages/site-components/src/components/td-doc-copy/index.ts +++ b/packages/site-components/src/components/td-doc-copy/index.ts @@ -1,4 +1,5 @@ import { html, dispatch, define } from 'hybrids'; +import { attribute } from '@utils'; import style from './style.less?inline'; import copyIcon from '@images/copy.svg?raw'; @@ -41,7 +42,7 @@ function handleCopy(host: CopyHost & HTMLElement): void { export default define({ tag: 'td-doc-copy', - code: '', + code: attribute(''), render: () => html` diff --git a/packages/site-components/src/components/td-doc-footer/index.ts b/packages/site-components/src/components/td-doc-footer/index.ts index 09af565ea..543e31427 100644 --- a/packages/site-components/src/components/td-doc-footer/index.ts +++ b/packages/site-components/src/components/td-doc-footer/index.ts @@ -1,7 +1,7 @@ import { html, define } from 'hybrids'; import { getFooterConfig } from '@config/footer'; import { getLocale } from '@config/locale'; -import { patchShadowDomIntoDom, mobileBodyStyle } from '@utils'; +import { attribute, patchShadowDomIntoDom, mobileBodyStyle } from '@utils'; import { fetchEcosystemConfig, getEcosystemLinkTarget } from '@config/ecosystem'; import type { FooterGroup } from '@config/types'; import tencentCloudIcon from '@images/tencentcloud-logo.svg?raw'; @@ -60,8 +60,8 @@ interface FooterHost { export default define({ tag: 'td-doc-footer', + platform: attribute('web'), mobileBodyStyle, - platform: 'web', displayQrCode: '', footerLinks: { value: (_host, value) => value || getFooterConfig(), diff --git a/packages/site-components/src/components/td-doc-header/index.ts b/packages/site-components/src/components/td-doc-header/index.ts index 098c8d320..dd3123ce5 100644 --- a/packages/site-components/src/components/td-doc-header/index.ts +++ b/packages/site-components/src/components/td-doc-header/index.ts @@ -1,7 +1,7 @@ import { getLocale } from '@config/locale'; import splineConfig from '@config/spline'; import historyIcon from '@images/history.svg?raw'; -import { isComponentPage, isGlobalConfigPage, mobileBodyStyle, parseBoolean, watchHtmlMode } from '@utils'; +import { attribute, isComponentPage, isGlobalConfigPage, mobileBodyStyle, parseBoolean, watchHtmlMode } from '@utils'; import { define, html } from 'hybrids'; import style from './style.less?inline'; @@ -87,8 +87,7 @@ function iframeOnload(host: HeaderHost & HTMLElement): void { export default define({ tag: 'td-doc-header', - spline: { - value: (_host, v) => v || '', + spline: attribute('', undefined, { connect: (host) => { const observer = watchHtmlMode((themeMode) => handleModeChange(themeMode, host)); @@ -101,14 +100,10 @@ export default define({ handleModeChange(themeMode, host); }, 600); }, - }, - platform: 'web', - changelog: { - value: (_host, v) => parseBoolean(v, true), - }, - changelogEn: { - value: (_host, v) => parseBoolean(v, true), - }, + }), + platform: attribute('web'), + changelog: attribute(true, (value) => parseBoolean(value, true)), + changelogEn: attribute(true, (value) => parseBoolean(value, true)), mobileBodyStyle, shouldShowPopup: { value: (_host, v) => v || false, @@ -232,9 +227,7 @@ export default define({ }; }, }, - showIssue: { - value: (_host, v) => parseBoolean(v, true), - }, + showIssue: attribute(true, (value) => parseBoolean(value, true)), render: (host) => { const { changelog, changelogEn, docInfo, spline, showIssue } = host; diff --git a/packages/site-components/src/components/td-doc-layout/index.ts b/packages/site-components/src/components/td-doc-layout/index.ts index 1c0dd2a6b..e33392378 100644 --- a/packages/site-components/src/components/td-doc-layout/index.ts +++ b/packages/site-components/src/components/td-doc-layout/index.ts @@ -1,5 +1,6 @@ import { html, define } from 'hybrids'; import NProgress from 'nprogress'; +import { attribute } from '@utils'; import style from './style.less?inline'; interface DocLayoutProps { @@ -26,7 +27,7 @@ export default define({ return () => window.removeEventListener('load', onLoad); }, }, - direction: 'row', // row column + direction: attribute('row'), // row column render: ({ direction }) => html`
diff --git a/packages/site-components/src/components/td-doc-popup/index.ts b/packages/site-components/src/components/td-doc-popup/index.ts index 4bbf28b1e..1ba9a3420 100644 --- a/packages/site-components/src/components/td-doc-popup/index.ts +++ b/packages/site-components/src/components/td-doc-popup/index.ts @@ -1,6 +1,6 @@ import { html, define, dispatch } from 'hybrids'; import { createPopper, type Instance, type Placement } from '@popperjs/core'; -import { parseBoolean } from '@utils'; +import { attribute, parseBoolean } from '@utils'; import style from './style.less?inline'; interface PortalElement extends HTMLElement { @@ -49,16 +49,14 @@ function handleClick(host: DocPopupHost): void { export default define({ tag: 'td-doc-popup', reference: ({ render }) => render().querySelector('.TDesign-doc-popup') as HTMLElement, - portalClass: '', - portalStyle: '', - placement: 'bottom-end', - triggerType: 'hover', + portalClass: attribute(''), + portalStyle: attribute(''), + placement: attribute('bottom-end'), + triggerType: attribute('hover'), portals: null, portal: null, popper: null, - equalWidth: { - value: (_host, v) => parseBoolean(v, false), - }, + equalWidth: attribute(false, (value) => parseBoolean(value, false)), visible: { value: (host, v) => v || false, connect: (host) => { diff --git a/packages/site-components/src/components/td-doc-tabs/index.ts b/packages/site-components/src/components/td-doc-tabs/index.ts index 328912282..df1533034 100644 --- a/packages/site-components/src/components/td-doc-tabs/index.ts +++ b/packages/site-components/src/components/td-doc-tabs/index.ts @@ -1,5 +1,5 @@ import { html, dispatch, define } from 'hybrids'; -import { isEn } from '@utils'; +import { attribute, isEn, parseBoolean } from '@utils'; import style from './style.less?inline'; interface TabItem { @@ -53,9 +53,9 @@ const defaultTabs = [ export default define({ tag: 'td-doc-tabs', - tab: 'demo', + tab: attribute('demo'), // 记录每个 tab 滚动条并自动滚动 - autoScroll: true, + autoScroll: attribute(true, (value) => parseBoolean(value, true)), // 记录每个 tab 的滚动距离 tabScrollMap: { value: (host, v) => { diff --git a/packages/site-components/src/components/td-header/index.ts b/packages/site-components/src/components/td-header/index.ts index 60b3d91ea..f09cc66d5 100644 --- a/packages/site-components/src/components/td-header/index.ts +++ b/packages/site-components/src/components/td-header/index.ts @@ -4,7 +4,7 @@ import closeIcon from '@images/close.svg?raw'; import fakeArrowIcon from '@images/fake-arrow.svg?raw'; import githubIcon from '@images/github.svg?raw'; import translateIcon from '@images/translate.svg?raw'; -import { isEn } from '@utils'; +import { attribute, isEn, parseBoolean } from '@utils'; import portalStyle from './portal.less?inline'; import style from './style.less?inline'; @@ -133,7 +133,7 @@ function renderNotice(host: HeaderHost) { function renderLinksPopup(host: HeaderHost, trigger: UpdateFunctionWithMethods) { return html` - + ${trigger}