From fe19cdf1b5236fd3671279a989f0dc43d25eb8ef Mon Sep 17 00:00:00 2001 From: gene9831 Date: Tue, 18 Aug 2026 15:38:30 +0800 Subject: [PATCH 01/11] feat(extension-manager): add Card/CardGrid primitives and component test setup --- .gitignore | 2 +- .../components/ExtensionCard.vue | 291 ++++++++++++++++++ .../components/ExtensionCardActions.vue | 222 +++++++++++++ .../components/ExtensionCardGrid.vue | 118 +++++++ .../components/ExtensionCardMoreMenu.vue | 177 +++++++++++ .../components/ExtensionCardPopover.vue | 110 +++++++ .../src/extension-manager/components/index.ts | 2 + .../extension-manager/composables/index.ts | 2 + .../composables/useAsChild.ts | 53 ++++ .../useExtensionCardPopoverPosition.ts | 84 +++++ .../src/extension-manager/index.type.ts | 166 ++++++++++ .../src/extension-manager/internal.type.ts | 1 + .../ExtensionCard.fixture.vue | 106 +++++++ .../extension-manager/ExtensionCard.spec.ts | 138 +++++++++ .../ExtensionCardActionEvent.fixture.vue | 56 ++++ .../ExtensionCardActionEvent.spec.ts | 42 +++ .../ExtensionCardGrid.fixture.vue | 193 ++++++++++++ .../ExtensionCardGrid.spec.ts | 196 ++++++++++++ .../ExtensionCardPopover.fixture.vue | 46 +++ .../ExtensionCardPopover.spec.ts | 26 ++ packages/test/package.json | 6 +- packages/test/playwright-ct.config.ts | 33 ++ packages/test/playwright.config.ts | 1 + packages/test/playwright/index.html | 12 + packages/test/playwright/index.ts | 1 + packages/test/vite.config.ts | 3 +- 26 files changed, 2084 insertions(+), 3 deletions(-) create mode 100644 packages/components/src/extension-manager/components/ExtensionCard.vue create mode 100644 packages/components/src/extension-manager/components/ExtensionCardActions.vue create mode 100644 packages/components/src/extension-manager/components/ExtensionCardGrid.vue create mode 100644 packages/components/src/extension-manager/components/ExtensionCardMoreMenu.vue create mode 100644 packages/components/src/extension-manager/components/ExtensionCardPopover.vue create mode 100644 packages/components/src/extension-manager/components/index.ts create mode 100644 packages/components/src/extension-manager/composables/index.ts create mode 100644 packages/components/src/extension-manager/composables/useAsChild.ts create mode 100644 packages/components/src/extension-manager/composables/useExtensionCardPopoverPosition.ts create mode 100644 packages/components/src/extension-manager/index.type.ts create mode 100644 packages/components/src/extension-manager/internal.type.ts create mode 100644 packages/test/component/extension-manager/ExtensionCard.fixture.vue create mode 100644 packages/test/component/extension-manager/ExtensionCard.spec.ts create mode 100644 packages/test/component/extension-manager/ExtensionCardActionEvent.fixture.vue create mode 100644 packages/test/component/extension-manager/ExtensionCardActionEvent.spec.ts create mode 100644 packages/test/component/extension-manager/ExtensionCardGrid.fixture.vue create mode 100644 packages/test/component/extension-manager/ExtensionCardGrid.spec.ts create mode 100644 packages/test/component/extension-manager/ExtensionCardPopover.fixture.vue create mode 100644 packages/test/component/extension-manager/ExtensionCardPopover.spec.ts create mode 100644 packages/test/playwright-ct.config.ts create mode 100644 packages/test/playwright/index.html create mode 100644 packages/test/playwright/index.ts diff --git a/.gitignore b/.gitignore index 462db155f..8f597464d 100644 --- a/.gitignore +++ b/.gitignore @@ -37,7 +37,7 @@ test-results playwright-report blob-report -playwright/.cache +**/playwright/.cache/ package-lock.json pnpm-lock.yaml yarn.lock diff --git a/packages/components/src/extension-manager/components/ExtensionCard.vue b/packages/components/src/extension-manager/components/ExtensionCard.vue new file mode 100644 index 000000000..5916c0cee --- /dev/null +++ b/packages/components/src/extension-manager/components/ExtensionCard.vue @@ -0,0 +1,291 @@ + + + + + + + diff --git a/packages/components/src/extension-manager/components/ExtensionCardActions.vue b/packages/components/src/extension-manager/components/ExtensionCardActions.vue new file mode 100644 index 000000000..951fb365e --- /dev/null +++ b/packages/components/src/extension-manager/components/ExtensionCardActions.vue @@ -0,0 +1,222 @@ + + + + + diff --git a/packages/components/src/extension-manager/components/ExtensionCardGrid.vue b/packages/components/src/extension-manager/components/ExtensionCardGrid.vue new file mode 100644 index 000000000..f6fbbd4a0 --- /dev/null +++ b/packages/components/src/extension-manager/components/ExtensionCardGrid.vue @@ -0,0 +1,118 @@ + + + + + diff --git a/packages/components/src/extension-manager/components/ExtensionCardMoreMenu.vue b/packages/components/src/extension-manager/components/ExtensionCardMoreMenu.vue new file mode 100644 index 000000000..e1b8d5467 --- /dev/null +++ b/packages/components/src/extension-manager/components/ExtensionCardMoreMenu.vue @@ -0,0 +1,177 @@ + + + + + diff --git a/packages/components/src/extension-manager/components/ExtensionCardPopover.vue b/packages/components/src/extension-manager/components/ExtensionCardPopover.vue new file mode 100644 index 000000000..f394ca92e --- /dev/null +++ b/packages/components/src/extension-manager/components/ExtensionCardPopover.vue @@ -0,0 +1,110 @@ + + + + + diff --git a/packages/components/src/extension-manager/components/index.ts b/packages/components/src/extension-manager/components/index.ts new file mode 100644 index 000000000..5728c159f --- /dev/null +++ b/packages/components/src/extension-manager/components/index.ts @@ -0,0 +1,2 @@ +export { default as ExtensionCard } from './ExtensionCard.vue' +export { default as ExtensionCardGrid } from './ExtensionCardGrid.vue' diff --git a/packages/components/src/extension-manager/composables/index.ts b/packages/components/src/extension-manager/composables/index.ts new file mode 100644 index 000000000..de1a16f3e --- /dev/null +++ b/packages/components/src/extension-manager/composables/index.ts @@ -0,0 +1,2 @@ +export { useAsChild } from './useAsChild' +export { useExtensionCardPopoverPosition } from './useExtensionCardPopoverPosition' diff --git a/packages/components/src/extension-manager/composables/useAsChild.ts b/packages/components/src/extension-manager/composables/useAsChild.ts new file mode 100644 index 000000000..3691d7273 --- /dev/null +++ b/packages/components/src/extension-manager/composables/useAsChild.ts @@ -0,0 +1,53 @@ +import type { VNode, VNodeProps } from 'vue' +import { cloneVNode, Comment, Fragment, isVNode, Suspense, Teleport, Text } from 'vue' + +interface UseAsChildOptions { + getSlot: () => ((props: TSlotProps) => VNode[]) | undefined + componentName?: string +} + +type AsChildInjectedProps = VNodeProps & Record + +const flattenAsChildVNodes = (nodes: VNode[]): VNode[] => { + return nodes.flatMap((node) => { + if (node.type === Comment) return [] + if (node.type === Text && typeof node.children === 'string' && node.children.trim() === '') return [] + if (node.type !== Fragment || !Array.isArray(node.children)) return [node] + + return flattenAsChildVNodes(node.children.filter(isVNode)) + }) +} + +const isSupportedAsChildVNode = (node: VNode) => { + if (node.type === Teleport || node.type === Suspense) return false + + return typeof node.type === 'string' || typeof node.type === 'object' || typeof node.type === 'function' +} + +export function useAsChild({ getSlot, componentName }: UseAsChildOptions) { + const warningPrefix = componentName ? `[TinyRobot] ${componentName} with asChild` : '[TinyRobot] asChild' + + const renderAsChild = (slotProps: TSlotProps, injectedProps: AsChildInjectedProps = {}): VNode | null => { + const children = flattenAsChildVNodes(getSlot()?.(slotProps) ?? []) + + if (children.length !== 1) { + if (import.meta.env.DEV) { + console.warn(`${warningPrefix} expects exactly one child, but received ${children.length}.`) + } + return null + } + + if (!isSupportedAsChildVNode(children[0])) { + if (import.meta.env.DEV) { + console.warn(`${warningPrefix} received an unsupported child node.`) + } + return null + } + + return cloneVNode(children[0], injectedProps, true) + } + + return { + renderAsChild, + } +} diff --git a/packages/components/src/extension-manager/composables/useExtensionCardPopoverPosition.ts b/packages/components/src/extension-manager/composables/useExtensionCardPopoverPosition.ts new file mode 100644 index 000000000..3bff53231 --- /dev/null +++ b/packages/components/src/extension-manager/composables/useExtensionCardPopoverPosition.ts @@ -0,0 +1,84 @@ +import { useEventListener } from '@vueuse/core' +import type { Ref } from 'vue' +import { nextTick, ref, watch } from 'vue' +import type { ExtensionCardPopoverPlacement } from '../internal.type' + +interface ExtensionCardPopoverPosition { + x: number + y: number + placement: ExtensionCardPopoverPlacement +} + +interface UseExtensionCardPopoverPositionOptions { + triggerRef: Ref + popoverRef: Ref + open: Ref + placement: Readonly> +} + +export const useExtensionCardPopoverPosition = ({ + triggerRef, + popoverRef, + open, + placement, +}: UseExtensionCardPopoverPositionOptions) => { + const position = ref() + + const updatePosition = () => { + if (!open.value || !triggerRef.value || !popoverRef.value) return + + const triggerRect = triggerRef.value.getBoundingClientRect() + const popoverRect = popoverRef.value.getBoundingClientRect() + const viewportPadding = 8 + const gap = 8 + const maxLeft = Math.max( + viewportPadding, + document.documentElement.clientWidth - popoverRect.width - viewportPadding, + ) + const maxTop = Math.max( + viewportPadding, + document.documentElement.clientHeight - popoverRect.height - viewportPadding, + ) + const left = Math.min(maxLeft, Math.max(viewportPadding, triggerRect.right - popoverRect.width)) + const bottomTop = triggerRect.bottom + gap + const topTop = triggerRect.top - popoverRect.height - gap + const fitsBottom = bottomTop + popoverRect.height <= document.documentElement.clientHeight - viewportPadding + const fitsTop = topTop >= viewportPadding + let resolvedPlacement = placement.value + + if (resolvedPlacement === 'bottom-end' && !fitsBottom && fitsTop) { + resolvedPlacement = 'top-end' + } else if (resolvedPlacement === 'top-end' && !fitsTop && fitsBottom) { + resolvedPlacement = 'bottom-end' + } + + const preferredTop = resolvedPlacement === 'bottom-end' ? bottomTop : topTop + const top = Math.min(maxTop, Math.max(viewportPadding, preferredTop)) + + position.value = { + x: left, + y: top, + placement: resolvedPlacement, + } + } + + watch( + [open, placement], + async ([isOpen]) => { + if (!isOpen) return + await nextTick() + updatePosition() + }, + { flush: 'post' }, + ) + + useEventListener(window, 'resize', updatePosition, { passive: true }) + useEventListener(window, 'scroll', updatePosition, { + capture: true, + passive: true, + }) + + return { + position, + } +} diff --git a/packages/components/src/extension-manager/index.type.ts b/packages/components/src/extension-manager/index.type.ts new file mode 100644 index 000000000..368875f74 --- /dev/null +++ b/packages/components/src/extension-manager/index.type.ts @@ -0,0 +1,166 @@ +import type { Component, VNode } from 'vue' + +export type ExtensionCardOverflowMenuPlacement = 'bottom-end' | 'top-end' + +export interface ExtensionCardActionBase { + id: string + label: string + icon?: Component + hidden?: boolean + disabled?: boolean + danger?: boolean +} + +export interface ExtensionCardSwitchAction extends ExtensionCardActionBase { + type: 'switch' + checked: boolean +} + +export interface ExtensionCardButtonAction extends ExtensionCardActionBase { + type: 'button' +} + +export interface ExtensionCardCustomAction extends ExtensionCardActionBase { + type: 'custom' + data?: unknown +} + +export type ExtensionCardAction = ExtensionCardSwitchAction | ExtensionCardButtonAction | ExtensionCardCustomAction + +/** Actions passed from Card to its internal renderers after visibility is resolved. */ +type DistributiveOmit = T extends unknown ? Omit : never +export type ExtensionCardRenderableAction = DistributiveOmit + +export interface ExtensionCardActionEvent { + id: string + type: ExtensionCardAction['type'] + checked?: boolean + payload?: unknown +} + +export interface ExtensionCardProps { + name: string + description?: string + icon?: string | Component + actions?: ExtensionCardAction[] + primaryActionsLimit?: number + progress?: number | 'indeterminate' + nameClickable?: boolean + overflowMenuLabel?: string + overflowMenuPlacement?: ExtensionCardOverflowMenuPlacement + overflowMenuShowIcons?: boolean +} + +export interface ExtensionCardSlots { + 'primary-action'?: (props: { + action: Extract + trigger: (payload?: unknown) => void + }) => VNode[] +} + +export interface ExtensionCardEmits { + (e: 'name-click', event: MouseEvent | KeyboardEvent): void + (e: 'action', payload: ExtensionCardActionEvent): void +} + +export type ExtensionCardGridItem = ExtensionCardProps & { + id: string +} + +export interface ExtensionCardGridProps extends Pick< + ExtensionCardProps, + 'primaryActionsLimit' | 'nameClickable' | 'overflowMenuLabel' | 'overflowMenuPlacement' | 'overflowMenuShowIcons' +> { + items: ExtensionCardGridItem[] + emptyText?: string +} + +export interface ExtensionCardGridSlots { + item?: (props: { item: ExtensionCardGridItem; index: number }) => VNode[] + empty?: () => VNode[] +} + +export interface ExtensionCardGridActionEvent { + itemId: string + action: ExtensionCardActionEvent +} + +export interface ExtensionCardGridNameClickEvent { + itemId: string + event: MouseEvent | KeyboardEvent +} + +export interface ExtensionCardGridEmits { + (e: 'action', payload: ExtensionCardGridActionEvent): void + (e: 'name-click', payload: ExtensionCardGridNameClickEvent): void +} + +export type ExtensionManagerSectionKey = 'installed' | 'available' + +export interface ExtensionManagerTagOption { + value: string + label: string +} + +export type ExtensionManagerItem = ExtensionCardGridItem & { + installed?: boolean + tags?: string[] +} + +export interface ExtensionManagerTab { + id: string + label: string + items: ExtensionManagerItem[] +} + +export interface ExtensionManagerProps { + tabs: ExtensionManagerTab[] + activeTab?: string + defaultActiveTab?: string + title?: string + emptyText?: string +} + +export interface ExtensionManagerSlots { + 'header-actions'?: () => VNode[] + tab?: (props: { tab: ExtensionManagerTab; active: boolean; select: () => void }) => VNode[] + item?: (props: { + tab: ExtensionManagerTab + sectionKey: ExtensionManagerSectionKey + item: ExtensionCardGridItem + index: number + }) => VNode[] + empty?: (props: { tab: ExtensionManagerTab; sectionKey: ExtensionManagerSectionKey; title: string }) => VNode[] +} + +export interface ExtensionManagerTabChangeEvent { + tabId: string +} + +export interface ExtensionManagerSectionToggleEvent { + tabId: string + sectionKey: ExtensionManagerSectionKey + expanded: boolean +} + +export interface ExtensionManagerActionEvent { + tabId: string + sectionKey: ExtensionManagerSectionKey + itemId: string + action: ExtensionCardActionEvent +} + +export interface ExtensionManagerNameClickEvent { + tabId: string + sectionKey: ExtensionManagerSectionKey + itemId: string + event: MouseEvent | KeyboardEvent +} + +export interface ExtensionManagerEmits { + (e: 'update:active-tab', tabId: string | undefined): void + (e: 'tab-change', event: ExtensionManagerTabChangeEvent): void + (e: 'section-toggle', event: ExtensionManagerSectionToggleEvent): void + (e: 'action', event: ExtensionManagerActionEvent): void + (e: 'name-click', event: ExtensionManagerNameClickEvent): void +} diff --git a/packages/components/src/extension-manager/internal.type.ts b/packages/components/src/extension-manager/internal.type.ts new file mode 100644 index 000000000..cf7b78942 --- /dev/null +++ b/packages/components/src/extension-manager/internal.type.ts @@ -0,0 +1 @@ +export type ExtensionCardPopoverPlacement = 'bottom-end' | 'top-end' diff --git a/packages/test/component/extension-manager/ExtensionCard.fixture.vue b/packages/test/component/extension-manager/ExtensionCard.fixture.vue new file mode 100644 index 000000000..d4d90d2d0 --- /dev/null +++ b/packages/test/component/extension-manager/ExtensionCard.fixture.vue @@ -0,0 +1,106 @@ + + + diff --git a/packages/test/component/extension-manager/ExtensionCard.spec.ts b/packages/test/component/extension-manager/ExtensionCard.spec.ts new file mode 100644 index 000000000..04ea0fde5 --- /dev/null +++ b/packages/test/component/extension-manager/ExtensionCard.spec.ts @@ -0,0 +1,138 @@ +import { expect, test } from '@playwright/experimental-ct-vue' +import ExtensionCardFixture from './ExtensionCard.fixture.vue' + +test.describe('standalone ExtensionCard', () => { + test('renders direct presentation props without an item', async ({ mount }) => { + const component = await mount(ExtensionCardFixture) + const card = component.getByTestId('presentation-card') + + await expect(card.locator('.tr-extension-card__name')).toHaveText('Item name') + await expect(card.locator('.tr-extension-card__description')).toHaveText('Item description') + await expect(card.locator('img')).toHaveAttribute('src', 'https://example.com/item-icon.png') + await expect(card.locator('img')).toHaveAttribute('alt', 'Item name') + }) + + test('renders a component icon without an image element', async ({ mount }) => { + const component = await mount(ExtensionCardFixture) + const card = component.getByTestId('component-icon-card') + + await expect(card.locator('img')).toHaveCount(0) + await expect(card.locator('svg')).toHaveCount(1) + }) + + test('filters hidden actions before partitioning and keeps disabled slots in the primary region', async ({ + mount, + }) => { + const component = await mount(ExtensionCardFixture) + const card = component.getByTestId('actions-card') + const primaryRegion = card.locator('.tr-extension-card-primary-actions') + const overflowRegion = card.locator('.tr-extension-card__more-action') + const overflowMenu = overflowRegion.locator('.tr-extension-card__more-menu') + + await expect(primaryRegion.getByRole('checkbox', { name: '启用扩展' })).toBeVisible() + await expect(primaryRegion.getByRole('button', { name: '禁用操作' })).toBeDisabled() + await expect(primaryRegion.getByRole('button', { name: '隐藏操作' })).toHaveCount(0) + await expect(primaryRegion.getByRole('button', { name: '安装' })).toHaveCount(0) + await expect(primaryRegion.getByRole('button', { name: '检查' })).toHaveCount(0) + await expect(overflowRegion.getByRole('button', { name: '扩展操作' })).toBeVisible() + await expect(overflowMenu).toBeHidden() + + await overflowRegion.getByRole('button', { name: '扩展操作' }).click() + await expect(primaryRegion.getByRole('checkbox', { name: '启用扩展' })).toBeVisible() + await expect(primaryRegion.getByRole('button', { name: '禁用操作' })).toBeDisabled() + await expect(primaryRegion.getByRole('button', { name: '安装' })).toHaveCount(0) + await expect(primaryRegion.getByRole('button', { name: '检查' })).toHaveCount(0) + await expect(overflowMenu).toBeVisible() + await expect(overflowMenu.getByRole('button', { name: '安装' })).toBeVisible() + await expect(overflowMenu.getByRole('button', { name: '检查' })).toBeVisible() + await expect(overflowMenu.getByRole('checkbox', { name: '启用扩展' })).toHaveCount(0) + await expect(overflowMenu.getByRole('button', { name: '禁用操作' })).toHaveCount(0) + await expect(overflowMenu.getByRole('button', { name: '隐藏操作' })).toHaveCount(0) + }) + + test('falls back to a normal button for a custom primary action without a slot', async ({ mount }) => { + const component = await mount(ExtensionCardFixture) + const card = component.getByTestId('custom-fallback-card') + + await expect(card.getByRole('button', { name: '自定义操作' })).toBeVisible() + await card.getByRole('button', { name: '自定义操作' }).click() + await expect(component.getByTestId('event-id')).toHaveText('fallback') + await expect(component.getByTestId('event-type')).toHaveText('custom') + }) + + test('uses overflowMenuLabel and emits switch state from the overflow menu', async ({ mount }) => { + const component = await mount(ExtensionCardFixture) + const card = component.getByTestId('overflow-switch-card') + const trigger = card.getByRole('button', { name: '扩展操作' }) + + await expect(trigger).toBeVisible() + await expect(trigger).toHaveAttribute('title', '扩展操作') + await trigger.click() + + const switchItem = card.getByRole('button', { name: '停止跟踪更新' }) + + await expect(switchItem).toHaveAttribute('aria-pressed', 'true') + await expect(switchItem.locator('.tr-extension-card__more-menu-item-check')).toHaveCount(0) + await switchItem.click() + await expect(component.getByTestId('event-id')).toHaveText('overflow-enabled') + await expect(component.getByTestId('event-type')).toHaveText('switch') + await expect(component.getByTestId('event-checked')).toHaveText('false') + + await trigger.click() + await expect(card.getByRole('button', { name: '跟踪更新' })).toHaveAttribute('aria-pressed', 'false') + }) + + test('renders custom overflow actions as buttons and keeps danger styling', async ({ mount }) => { + const component = await mount(ExtensionCardFixture) + const card = component.getByTestId('overflow-switch-card') + + await card.getByRole('button', { name: '扩展操作' }).click() + + const customItem = card.getByRole('button', { name: '自定义溢出操作' }) + const dangerItem = card.getByRole('button', { name: '危险操作' }) + + await expect(customItem).toBeVisible() + await expect(dangerItem).toHaveClass(/is-danger/) + await customItem.click() + await expect(component.getByTestId('event-id')).toHaveText('overflow-custom') + await expect(component.getByTestId('event-type')).toHaveText('custom') + }) + + test('hides overflow action icons while preserving labels and events', async ({ mount }) => { + const component = await mount(ExtensionCardFixture) + const card = component.getByTestId('overflow-without-icons-card') + + await card.getByRole('button', { name: '无图标溢出菜单' }).click() + + const menu = card.locator('.tr-extension-card__more-menu') + await expect(menu.getByRole('button', { name: '带图标操作' })).toBeVisible() + await expect(menu.getByRole('button', { name: '无图标操作' })).toBeVisible() + await expect(menu.locator('svg')).toHaveCount(0) + + await menu.getByRole('button', { name: '无图标操作' }).click() + await expect(component.getByTestId('event-id')).toHaveText('overflow-text') + await expect(component.getByTestId('event-type')).toHaveText('button') + }) + + test('renders indeterminate and clamped determinate progress', async ({ mount }) => { + const component = await mount(ExtensionCardFixture) + + await expect(component.getByTestId('progress-card').locator('.tr-extension-card__progress-bar')).toHaveClass( + /is-indeterminate/, + ) + await expect( + component.getByTestId('high-progress-card').locator('.tr-extension-card__progress-bar'), + ).toHaveAttribute('style', /width: 100%/) + await expect( + component.getByTestId('low-progress-card').locator('.tr-extension-card__progress-bar'), + ).toHaveAttribute('style', /width: 0%/) + }) + + test('renders a non-clickable name as text', async ({ mount }) => { + const component = await mount(ExtensionCardFixture) + const card = component.getByTestId('presentation-card') + + await expect(card.getByRole('button', { name: 'Item name' })).toHaveCount(0) + await expect(card.locator('.tr-extension-card__name')).toHaveText('Item name') + }) +}) diff --git a/packages/test/component/extension-manager/ExtensionCardActionEvent.fixture.vue b/packages/test/component/extension-manager/ExtensionCardActionEvent.fixture.vue new file mode 100644 index 000000000..19ca9a1cd --- /dev/null +++ b/packages/test/component/extension-manager/ExtensionCardActionEvent.fixture.vue @@ -0,0 +1,56 @@ + + + diff --git a/packages/test/component/extension-manager/ExtensionCardActionEvent.spec.ts b/packages/test/component/extension-manager/ExtensionCardActionEvent.spec.ts new file mode 100644 index 000000000..5e37575fa --- /dev/null +++ b/packages/test/component/extension-manager/ExtensionCardActionEvent.spec.ts @@ -0,0 +1,42 @@ +import { expect, test } from '@playwright/experimental-ct-vue' +import ExtensionCardActionEventFixture from './ExtensionCardActionEvent.fixture.vue' + +test.describe('standalone ExtensionCard action events', () => { + test('emits a controlled switch event with the resulting checked value', async ({ mount }) => { + const component = await mount(ExtensionCardActionEventFixture) + const toggle = component.getByRole('checkbox', { name: '扩展开关' }) + + await expect(toggle).toBeChecked() + await component.locator('.tr-extension-card-primary-actions__switch-track').click() + await expect(toggle).not.toBeChecked() + await expect(component.getByTestId('event-id')).toHaveText('toggle-extension') + await expect(component.getByTestId('event-type')).toHaveText('switch') + await expect(component.getByTestId('event-checked')).toHaveText('false') + }) + + test('emits a button action with its presentation type', async ({ mount }) => { + const component = await mount(ExtensionCardActionEventFixture) + + await component.getByRole('button', { name: '安装' }).click() + await expect(component.getByTestId('event-id')).toHaveText('install-extension') + await expect(component.getByTestId('event-type')).toHaveText('button') + }) + + test('emits a custom primary action with its payload', async ({ mount }) => { + const component = await mount(ExtensionCardActionEventFixture) + + await component.getByRole('button', { name: '检查' }).click() + await expect(component.getByTestId('event-id')).toHaveText('inspect-extension') + await expect(component.getByTestId('event-type')).toHaveText('custom') + await expect(component.getByTestId('event-payload')).toHaveText('{"origin":"fixture"}') + }) + + test('emits an overflow action through the same event shape', async ({ mount }) => { + const component = await mount(ExtensionCardActionEventFixture) + + await component.getByRole('button', { name: '扩展操作菜单' }).click() + await component.getByRole('button', { name: '删除' }).click() + await expect(component.getByTestId('event-id')).toHaveText('delete-extension') + await expect(component.getByTestId('event-type')).toHaveText('button') + }) +}) diff --git a/packages/test/component/extension-manager/ExtensionCardGrid.fixture.vue b/packages/test/component/extension-manager/ExtensionCardGrid.fixture.vue new file mode 100644 index 000000000..8ef4180a6 --- /dev/null +++ b/packages/test/component/extension-manager/ExtensionCardGrid.fixture.vue @@ -0,0 +1,193 @@ + + + diff --git a/packages/test/component/extension-manager/ExtensionCardGrid.spec.ts b/packages/test/component/extension-manager/ExtensionCardGrid.spec.ts new file mode 100644 index 000000000..08974d438 --- /dev/null +++ b/packages/test/component/extension-manager/ExtensionCardGrid.spec.ts @@ -0,0 +1,196 @@ +import { expect, test } from '@playwright/experimental-ct-vue' +import ExtensionCardGridFixture from './ExtensionCardGrid.fixture.vue' + +test.describe('standalone ExtensionCardGrid', () => { + test('renders flat items as default Cards and keeps the Grid id on the item li', async ({ mount }) => { + const component = await mount(ExtensionCardGridFixture) + const grid = component.getByTestId('default-grid') + const alphaItem = grid.locator(':scope > li[data-card-id="alpha"]') + const alphaCard = alphaItem.locator(':scope > *') + + await expect(grid).toHaveJSProperty('tagName', 'UL') + await expect(grid.locator(':scope > li')).toHaveCount(2) + await expect(grid.locator(':scope > li[data-card-id]')).toHaveCount(2) + await expect(alphaItem).toHaveCount(1) + await expect(grid.locator(':scope > li[data-card-id="alpha"]')).toHaveCount(1) + await expect(alphaCard).toHaveCount(1) + await expect(alphaCard.getByText('Alpha extension', { exact: true })).toBeVisible() + await expect(alphaCard.getByText('Alpha description', { exact: true })).toBeVisible() + await expect(alphaCard.getByRole('checkbox', { name: 'Enable Alpha' })).toBeVisible() + await expect(alphaCard.getByRole('button', { name: 'Install Alpha' })).toBeVisible() + await expect(alphaCard.getByRole('button', { name: 'Inspect Alpha' })).toBeVisible() + }) + + test('passes the full flat item and index to the item slot while retaining Grid-owned markup', async ({ mount }) => { + const component = await mount(ExtensionCardGridFixture) + const grid = component.getByTestId('slot-grid') + const alphaItem = grid.locator(':scope > li[data-card-id="alpha"]') + const betaItem = grid.locator(':scope > li[data-card-id="beta"]') + + await expect(grid).toHaveJSProperty('tagName', 'UL') + await expect(grid.locator(':scope > li')).toHaveCount(2) + await expect(grid.locator(':scope > li[data-card-id]')).toHaveCount(2) + await expect(alphaItem.locator(':scope > [data-testid="slot-item-alpha"]')).toHaveCount(1) + const alphaValue = JSON.parse((await alphaItem.getByTestId('slot-item-alpha-value').textContent()) ?? '{}') + const betaValue = JSON.parse((await betaItem.getByTestId('slot-item-beta-value').textContent()) ?? '{}') + + expect(alphaValue.id).toBe('alpha') + expect(alphaValue.name).toBe('Alpha extension') + expect(alphaValue.actions?.[2]?.data).toEqual({ origin: 'grid-fixture', nested: { enabled: true } }) + await expect(alphaItem.getByTestId('slot-item-alpha-index')).toHaveText('0') + expect(betaValue.id).toBe('beta') + expect(betaValue.name).toBe('Beta extension') + await expect(betaItem.getByTestId('slot-item-beta-index')).toHaveText('1') + }) + + test('uses the Grid fallback while preserving item overrides including explicit zero', async ({ mount }) => { + const component = await mount(ExtensionCardGridFixture) + const grid = component.getByTestId('fallback-grid') + + await expect(grid.getByRole('button', { name: 'Fallback primary' })).toHaveCount(1) + await expect(grid.locator('[data-card-id="fallback"] [aria-label="更多操作"]')).toHaveCount(1) + await expect(grid.getByRole('button', { name: 'Override primary 1' })).toHaveCount(1) + await expect(grid.getByRole('button', { name: 'Override primary 2' })).toHaveCount(1) + await expect(grid.getByRole('button', { name: 'Zero overflow' })).toHaveCount(0) + await expect(grid.locator('[data-card-id="zero"] [aria-label="更多操作"]')).toHaveCount(1) + + const fallbackCard = grid.locator('[data-card-id="fallback"]') + await fallbackCard.getByRole('button', { name: '更多操作' }).click() + const fallbackMenu = fallbackCard.locator('.tr-extension-card-popover__content') + await expect(fallbackMenu).toBeVisible() + await expect(fallbackMenu.locator('.tr-extension-card__more-menu-item-icon-slot')).toHaveCount(1) + await expect + .poll(async () => + fallbackMenu.evaluate((element) => { + const trigger = element.parentElement?.querySelector('[popovertarget]') + return trigger + ? Number.parseFloat(getComputedStyle(element).top) >= trigger.getBoundingClientRect().bottom + : false + }), + ) + .toBe(true) + }) + + test('uses the Grid name-clickable fallback while preserving an explicit false item override', async ({ mount }) => { + const component = await mount(ExtensionCardGridFixture) + const grid = component.getByTestId('name-clickable-fallback-grid') + + await expect( + grid.locator('[data-card-id="fallback-name"]').getByRole('button', { name: 'Fallback name' }), + ).toHaveCount(1) + await expect( + grid.locator('[data-card-id="disabled-name"]').getByRole('button', { name: 'Disabled name' }), + ).toHaveCount(0) + }) + + test('uses the Card name-clickable default when the standalone Grid does not provide a fallback', async ({ + mount, + }) => { + const component = await mount(ExtensionCardGridFixture) + const grid = component.getByTestId('implicit-name-clickable-grid') + + await expect(grid.getByRole('button', { name: 'Implicit name' })).toHaveCount(1) + }) + + test('uses Grid overflow fallbacks and preserves item-level label, placement, and false icon override', async ({ + mount, + }) => { + const component = await mount(ExtensionCardGridFixture) + const grid = component.getByTestId('menu-fallback-grid') + const fallbackCard = grid.locator('[data-card-id="menu-fallback"]') + const overrideCard = grid.locator('[data-card-id="menu-override"]') + + await fallbackCard.getByRole('button', { name: 'Grid actions' }).click() + const fallbackMenu = fallbackCard.locator('.tr-extension-card-popover__content') + await expect(fallbackMenu).toBeVisible() + await expect(fallbackMenu.getByRole('button', { name: 'Fallback menu action' })).toBeVisible() + await expect(fallbackMenu.locator('.tr-extension-card__more-menu-item-icon')).toHaveCount(1) + await expect + .poll(async () => + fallbackMenu.evaluate((element) => { + const trigger = element.parentElement?.querySelector('[popovertarget]') + return trigger + ? Number.parseFloat(getComputedStyle(element).top) < trigger.getBoundingClientRect().top + : false + }), + ) + .toBe(true) + + await overrideCard.getByRole('button', { name: 'Item actions' }).click() + const overrideMenu = overrideCard.locator('.tr-extension-card-popover__content') + await expect(overrideMenu).toBeVisible() + await expect(overrideMenu.locator('.tr-extension-card__more-menu-item-icon')).toHaveCount(0) + await expect + .poll(async () => + overrideMenu.evaluate((element) => { + const trigger = element.parentElement?.querySelector('[popovertarget]') + return trigger + ? Number.parseFloat(getComputedStyle(element).top) >= trigger.getBoundingClientRect().bottom + : false + }), + ) + .toBe(true) + }) + + test('wraps default Card actions and controlled name clicks with the item id', async ({ mount }) => { + const component = await mount(ExtensionCardGridFixture) + const card = component.getByTestId('default-grid').locator(':scope > li[data-card-id="alpha"] > *') + + await card.getByRole('checkbox', { name: 'Enable Alpha' }).uncheck({ force: true }) + await card.getByRole('button', { name: 'Install Alpha' }).click() + await card.getByRole('button', { name: 'Inspect Alpha' }).click() + + await expect(component.getByTestId('action-events')).toHaveText( + 'alpha:toggle-alpha:switch:false|alpha:install-alpha:button:|alpha:inspect-alpha:custom:', + ) + + await card.getByRole('button', { name: 'Alpha extension' }).click() + await expect(component.getByTestId('name-click-item-id')).toHaveText('alpha') + }) + + test('renders one empty li and gives the empty slot precedence over emptyText', async ({ mount }) => { + const component = await mount(ExtensionCardGridFixture) + + const defaultEmptyGrid = component.getByTestId('default-empty-grid') + await expect(defaultEmptyGrid).toHaveJSProperty('tagName', 'UL') + await expect(defaultEmptyGrid.locator(':scope > li')).toHaveCount(1) + await expect(defaultEmptyGrid.locator(':scope > li')).toHaveText('暂无内容') + + const textEmptyGrid = component.getByTestId('text-empty-grid') + await expect(textEmptyGrid).toHaveJSProperty('tagName', 'UL') + await expect(textEmptyGrid.locator(':scope > li')).toHaveCount(1) + await expect(textEmptyGrid.locator(':scope > li')).toHaveText('Nothing to show') + + const slotEmptyGrid = component.getByTestId('slot-empty-grid') + await expect(slotEmptyGrid).toHaveJSProperty('tagName', 'UL') + await expect(slotEmptyGrid.locator(':scope > li')).toHaveCount(1) + await expect(slotEmptyGrid.getByTestId('custom-empty')).toHaveText('Custom empty slot') + await expect(slotEmptyGrid.getByText('Fallback empty text', { exact: true })).toHaveCount(0) + }) + + test('uses the container width and card minimum-width variable to derive tracks', async ({ mount }) => { + const component = await mount(ExtensionCardGridFixture) + const readRenderedTrackCount = (testId: string) => + component.getByTestId(testId).evaluate((element) => { + const style = getComputedStyle(element) + if (style.display !== 'grid' || style.gridTemplateColumns === 'none') return 0 + + const leftPositions = Array.from(element.children, (child) => Math.round(child.getBoundingClientRect().left)) + return new Set(leftPositions).size + }) + + await expect.poll(() => readRenderedTrackCount('default-min-width-grid')).toBe(2) + await expect.poll(() => readRenderedTrackCount('narrow-min-width-grid')).toBe(1) + await expect.poll(() => readRenderedTrackCount('custom-min-width-grid')).toBe(3) + }) + + test('preserves input item order', async ({ mount }) => { + const component = await mount(ExtensionCardGridFixture) + const itemIds = await component + .getByTestId('default-grid') + .locator(':scope > li[data-card-id]') + .evaluateAll((elements) => elements.map((element) => element.getAttribute('data-card-id'))) + + expect(itemIds).toEqual(['alpha', 'beta']) + }) +}) diff --git a/packages/test/component/extension-manager/ExtensionCardPopover.fixture.vue b/packages/test/component/extension-manager/ExtensionCardPopover.fixture.vue new file mode 100644 index 000000000..5e5e9851a --- /dev/null +++ b/packages/test/component/extension-manager/ExtensionCardPopover.fixture.vue @@ -0,0 +1,46 @@ + + + diff --git a/packages/test/component/extension-manager/ExtensionCardPopover.spec.ts b/packages/test/component/extension-manager/ExtensionCardPopover.spec.ts new file mode 100644 index 000000000..23e860944 --- /dev/null +++ b/packages/test/component/extension-manager/ExtensionCardPopover.spec.ts @@ -0,0 +1,26 @@ +import { expect, test } from '@playwright/experimental-ct-vue' +import ExtensionCardPopoverFixture from './ExtensionCardPopover.fixture.vue' + +test.describe('ExtensionCardPopover asChild CT', () => { + test('原生元素作为唯一触发节点且能够打开浮层', async ({ mount }) => { + const component = await mount(ExtensionCardPopoverFixture) + const section = component.getByTestId('native-trigger-section') + + await expect(section.locator(':scope > [data-testid="native-trigger"]')).toHaveCount(1) + await expect(section.locator(':scope > div.tr-extension-card-popover__trigger')).toHaveCount(0) + + await component.getByTestId('native-trigger').click() + await expect(component.getByText('原生触发器内容')).toBeVisible() + }) + + test('单根 Vue 组件作为唯一触发节点且能够打开浮层', async ({ mount }) => { + const component = await mount(ExtensionCardPopoverFixture) + const section = component.getByTestId('component-trigger-section') + + await expect(section.locator(':scope > [data-testid="component-trigger"]')).toHaveCount(1) + await expect(section.locator(':scope > div.tr-extension-card-popover__trigger')).toHaveCount(0) + + await component.getByTestId('component-trigger').click() + await expect(component.getByText('组件触发器内容')).toBeVisible() + }) +}) diff --git a/packages/test/package.json b/packages/test/package.json index 11eb2dd65..c2f0c8639 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -8,6 +8,7 @@ "build": "vue-tsc && vite build", "preview": "vite preview", "test": "playwright test", + "test:ct": "playwright test -c playwright-ct.config.ts", "test:ui": "playwright test --ui", "test:codegen": "playwright codegen localhost:3333", "test:headed": "playwright test --headed", @@ -21,9 +22,12 @@ "vue": "^3.3.11" }, "devDependencies": { - "@playwright/test": "^1.40.0", + "@playwright/experimental-ct-vue": "1.62.0", + "@playwright/test": "1.62.0", "@types/node": "^22.13.17", "@vitejs/plugin-vue": "^4.5.2", + "@vitejs/plugin-vue-jsx": "^4.1.2", + "@vue/compiler-dom": "3.5.29", "typescript": "^5.2.2", "vite": "^5.0.8", "vue-tsc": "^2.2.8" diff --git a/packages/test/playwright-ct.config.ts b/packages/test/playwright-ct.config.ts new file mode 100644 index 000000000..f58524e7e --- /dev/null +++ b/packages/test/playwright-ct.config.ts @@ -0,0 +1,33 @@ +import { defineConfig, devices } from '@playwright/experimental-ct-vue' +import vue from '@vitejs/plugin-vue' +import vuejsx from '@vitejs/plugin-vue-jsx' + +process.env.NODE_ENV = 'development' + +export default defineConfig({ + testDir: './component', + testMatch: '**/*.spec.ts', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 4 : undefined, + reporter: [['html', { open: 'never', outputFolder: 'component/playwright-report' }]], + outputDir: 'component/test-results', + use: { + ctPort: 3100, + ctCacheDir: 'playwright/.cache-development-mode', + ctViteConfig: { + mode: 'development', + plugins: [vue(), vuejsx()], + }, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/packages/test/playwright.config.ts b/packages/test/playwright.config.ts index 3f8131d01..79ff5bc44 100644 --- a/packages/test/playwright.config.ts +++ b/packages/test/playwright.config.ts @@ -5,6 +5,7 @@ import { defineConfig, devices } from '@playwright/test' */ export default defineConfig({ testDir: './', + testIgnore: 'component/**', /* Run tests in files in parallel */ fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ diff --git a/packages/test/playwright/index.html b/packages/test/playwright/index.html new file mode 100644 index 000000000..35842aadc --- /dev/null +++ b/packages/test/playwright/index.html @@ -0,0 +1,12 @@ + + + + + + Tiny Robot Component Tests + + +
+ + + diff --git a/packages/test/playwright/index.ts b/packages/test/playwright/index.ts new file mode 100644 index 000000000..653d0eb19 --- /dev/null +++ b/packages/test/playwright/index.ts @@ -0,0 +1 @@ +import '@opentiny/tiny-robot/dist/style.css' diff --git a/packages/test/vite.config.ts b/packages/test/vite.config.ts index 76e3ff32d..7fde75e70 100644 --- a/packages/test/vite.config.ts +++ b/packages/test/vite.config.ts @@ -1,8 +1,9 @@ import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' +import vueJsx from '@vitejs/plugin-vue-jsx' export default defineConfig({ - plugins: [vue()], + plugins: [vue(), vueJsx()], server: { port: 3333, host: true, From 2333dc59c81561622f8e4c4917701d844a4a9cd3 Mon Sep 17 00:00:00 2001 From: gene9831 Date: Tue, 18 Aug 2026 15:38:56 +0800 Subject: [PATCH 02/11] feat(extension-manager): add manager composition and public API --- .../components/ExtensionFilterControls.vue | 164 ++++++++ .../ExtensionManagerSection.types.ts | 8 + .../components/ExtensionManagerSection.vue | 109 ++++++ .../components/ExtensionManagerTabs.vue | 165 ++++++++ .../useExtensionManagerFilterState.ts | 31 ++ .../composables/useExtensionManagerState.ts | 179 +++++++++ .../composables/useFilter.ts | 85 ++++ .../src/extension-manager/filter.type.ts | 34 ++ .../components/src/extension-manager/index.ts | 28 ++ .../src/extension-manager/index.type.test.ts | 230 +++++++++++ .../src/extension-manager/index.vue | 221 +++++++++++ .../src/extension-manager/public.type.ts | 30 ++ packages/components/src/index.ts | 8 +- .../ExtensionManager.fixture.vue | 178 +++++++++ .../ExtensionManager.spec.ts | 362 ++++++++++++++++++ .../ExtensionManagerNamespace.fixture.vue | 32 ++ .../ExtensionManagerNamespace.spec.ts | 16 + .../ExtensionManagerUncontrolled.fixture.vue | 67 ++++ 18 files changed, 1945 insertions(+), 2 deletions(-) create mode 100644 packages/components/src/extension-manager/components/ExtensionFilterControls.vue create mode 100644 packages/components/src/extension-manager/components/ExtensionManagerSection.types.ts create mode 100644 packages/components/src/extension-manager/components/ExtensionManagerSection.vue create mode 100644 packages/components/src/extension-manager/components/ExtensionManagerTabs.vue create mode 100644 packages/components/src/extension-manager/composables/useExtensionManagerFilterState.ts create mode 100644 packages/components/src/extension-manager/composables/useExtensionManagerState.ts create mode 100644 packages/components/src/extension-manager/composables/useFilter.ts create mode 100644 packages/components/src/extension-manager/filter.type.ts create mode 100644 packages/components/src/extension-manager/index.ts create mode 100644 packages/components/src/extension-manager/index.type.test.ts create mode 100644 packages/components/src/extension-manager/index.vue create mode 100644 packages/components/src/extension-manager/public.type.ts create mode 100644 packages/test/component/extension-manager/ExtensionManager.fixture.vue create mode 100644 packages/test/component/extension-manager/ExtensionManager.spec.ts create mode 100644 packages/test/component/extension-manager/ExtensionManagerNamespace.fixture.vue create mode 100644 packages/test/component/extension-manager/ExtensionManagerNamespace.spec.ts create mode 100644 packages/test/component/extension-manager/ExtensionManagerUncontrolled.fixture.vue diff --git a/packages/components/src/extension-manager/components/ExtensionFilterControls.vue b/packages/components/src/extension-manager/components/ExtensionFilterControls.vue new file mode 100644 index 000000000..ff194125a --- /dev/null +++ b/packages/components/src/extension-manager/components/ExtensionFilterControls.vue @@ -0,0 +1,164 @@ + + + + + diff --git a/packages/components/src/extension-manager/components/ExtensionManagerSection.types.ts b/packages/components/src/extension-manager/components/ExtensionManagerSection.types.ts new file mode 100644 index 000000000..57b950f26 --- /dev/null +++ b/packages/components/src/extension-manager/components/ExtensionManagerSection.types.ts @@ -0,0 +1,8 @@ +import type { ExtensionCardGridItem } from '../index.type' +import type { ExtensionManagerSectionKey } from '../index.type' + +export interface ExtensionManagerSectionState { + key: ExtensionManagerSectionKey + title: string + items: ExtensionCardGridItem[] +} diff --git a/packages/components/src/extension-manager/components/ExtensionManagerSection.vue b/packages/components/src/extension-manager/components/ExtensionManagerSection.vue new file mode 100644 index 000000000..455916cfc --- /dev/null +++ b/packages/components/src/extension-manager/components/ExtensionManagerSection.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/packages/components/src/extension-manager/components/ExtensionManagerTabs.vue b/packages/components/src/extension-manager/components/ExtensionManagerTabs.vue new file mode 100644 index 000000000..c99607eaa --- /dev/null +++ b/packages/components/src/extension-manager/components/ExtensionManagerTabs.vue @@ -0,0 +1,165 @@ + + + + + diff --git a/packages/components/src/extension-manager/composables/useExtensionManagerFilterState.ts b/packages/components/src/extension-manager/composables/useExtensionManagerFilterState.ts new file mode 100644 index 000000000..2fe5c4224 --- /dev/null +++ b/packages/components/src/extension-manager/composables/useExtensionManagerFilterState.ts @@ -0,0 +1,31 @@ +import { reactive, toValue, watch, type MaybeRefOrGetter } from 'vue' +import type { ExtensionManagerTab } from '../index.type' +import type { FilterState } from '../filter.type' + +type TabStateRecord = Record + +const createSafeRecord = () => Object.create(null) as T +const createFilterState = (): FilterState => reactive({ selectedTag: '', searchValue: '' }) + +export const useExtensionManagerFilterState = (tabs: MaybeRefOrGetter[]>) => { + const states = reactive(createSafeRecord()) + const noTabState = createFilterState() + + watch( + () => toValue(tabs).map((tab) => tab.id), + (tabIds) => { + const allowed = new Set(tabIds) + for (const tabId of Object.keys(states)) { + if (!allowed.has(tabId)) delete states[tabId] + } + }, + { immediate: true }, + ) + + const getFilterState = (tabId: string | undefined): FilterState => { + if (tabId === undefined) return noTabState + return states[tabId] ?? (states[tabId] = createFilterState()) + } + + return { getFilterState } +} diff --git a/packages/components/src/extension-manager/composables/useExtensionManagerState.ts b/packages/components/src/extension-manager/composables/useExtensionManagerState.ts new file mode 100644 index 000000000..4b874be03 --- /dev/null +++ b/packages/components/src/extension-manager/composables/useExtensionManagerState.ts @@ -0,0 +1,179 @@ +import { computed, ref, watch, type ComputedRef } from 'vue' +import type { + ExtensionManagerEmits, + ExtensionManagerProps, + ExtensionManagerSectionKey, + ExtensionManagerTab, +} from '../index.type' + +type SectionExpansionState = Record>> + +type ExtensionManagerState = { + activeTabId: ComputedRef + activeTab: ComputedRef + selectTab: (tabId: string) => void + isSectionExpanded: (tabId: string, sectionKey: ExtensionManagerSectionKey) => boolean + toggleSection: (tabId: string, sectionKey: ExtensionManagerSectionKey) => void +} + +const hasOwn = (record: object, key: PropertyKey) => Object.prototype.hasOwnProperty.call(record, key) + +const createSafeRecord = () => Object.create(null) as T + +const setRecordValue = (record: T, key: K, value: V) => { + Object.defineProperty(record, key, { + configurable: true, + enumerable: true, + value, + writable: true, + }) +} + +const cloneSectionExpansionState = (record: SectionExpansionState): SectionExpansionState => { + const clone = createSafeRecord() + + for (const [tabId, sectionRecord] of Object.entries(record)) { + const sectionClone = createSafeRecord>>() + + for (const [sectionKey, expanded] of Object.entries(sectionRecord ?? {})) { + if (sectionKey === 'installed' || sectionKey === 'available') { + setRecordValue(sectionClone, sectionKey, expanded) + } + } + + setRecordValue(clone, tabId, sectionClone) + } + + return clone +} + +const getSectionOverride = (record: SectionExpansionState, tabId: string, sectionKey: ExtensionManagerSectionKey) => { + if (!hasOwn(record, tabId)) return undefined + + const sectionRecord = record[tabId] + if (sectionRecord === undefined || !hasOwn(sectionRecord, sectionKey)) return undefined + + return sectionRecord[sectionKey] +} + +const setSectionOverride = ( + record: SectionExpansionState, + tabId: string, + sectionKey: ExtensionManagerSectionKey, + expanded: boolean, +) => { + const sectionRecord = + hasOwn(record, tabId) && record[tabId] !== undefined + ? record[tabId]! + : createSafeRecord>>() + + if (!hasOwn(record, tabId)) setRecordValue(record, tabId, sectionRecord) + setRecordValue(sectionRecord, sectionKey, expanded) +} + +const getExpanded = (record: SectionExpansionState, tabId: string, sectionKey: ExtensionManagerSectionKey) => + getSectionOverride(record, tabId, sectionKey) ?? true + +export const useExtensionManagerState = ( + props: Readonly, + emit: ExtensionManagerEmits, +): ExtensionManagerState => { + const uncontrolledActiveTabId = ref(props.defaultActiveTab) + const sectionExpansionState = ref(createSafeRecord()) + + const tabIds = computed(() => props.tabs.map((tab) => tab.id)) + const firstTabId = computed(() => tabIds.value[0]) + + watch( + tabIds, + (tabIds) => { + const allowedTabIds = new Set(tabIds) + const next = createSafeRecord() + + for (const [tabId, sectionRecord] of Object.entries(sectionExpansionState.value)) { + if (allowedTabIds.has(tabId)) setRecordValue(next, tabId, sectionRecord) + } + + sectionExpansionState.value = next + }, + { immediate: true }, + ) + + const normalizedUncontrolledActiveTab = (nextTabIds: string[]) => { + if (nextTabIds.includes(uncontrolledActiveTabId.value ?? '')) return + + uncontrolledActiveTabId.value = nextTabIds[0] + } + + watch( + [tabIds, () => props.activeTab], + ([nextTabIds, activeTab]) => { + if (activeTab !== undefined) return + + normalizedUncontrolledActiveTab(nextTabIds) + }, + { immediate: true }, + ) + + const activeTabId = computed(() => { + const requestedTabId = props.activeTab ?? uncontrolledActiveTabId.value + const requestedTab = props.tabs.find((tab) => tab.id === requestedTabId) + + return requestedTab?.id ?? firstTabId.value + }) + + const activeTab = computed(() => + activeTabId.value === undefined ? undefined : props.tabs.find((tab) => tab.id === activeTabId.value), + ) + + let lastEmittedActiveTabId = props.activeTab + watch( + activeTabId, + (nextActiveTabId) => { + if (props.activeTab !== undefined && nextActiveTabId === props.activeTab) { + lastEmittedActiveTabId = nextActiveTabId + return + } + + if (nextActiveTabId === lastEmittedActiveTabId) return + + lastEmittedActiveTabId = nextActiveTabId + emit('update:active-tab', nextActiveTabId) + }, + { immediate: true }, + ) + + const isSectionExpanded = (tabId: string, sectionKey: ExtensionManagerSectionKey) => { + return getExpanded(sectionExpansionState.value, tabId, sectionKey) + } + + const selectTab = (tabId: string) => { + const tab = props.tabs.find((candidate) => candidate.id === tabId) + if (!tab || activeTabId.value === tab.id) return + + if (props.activeTab === undefined) uncontrolledActiveTabId.value = tab.id + else emit('update:active-tab', tab.id) + + emit('tab-change', { tabId: tab.id }) + } + + const toggleSection = (tabId: string, sectionKey: ExtensionManagerSectionKey) => { + if (!props.tabs.some((tab) => tab.id === tabId)) return + + const expanded = !isSectionExpanded(tabId, sectionKey) + + const nextSectionExpansionState = cloneSectionExpansionState(sectionExpansionState.value) + setSectionOverride(nextSectionExpansionState, tabId, sectionKey, expanded) + sectionExpansionState.value = nextSectionExpansionState + + emit('section-toggle', { tabId, sectionKey, expanded }) + } + + return { + activeTabId, + activeTab, + selectTab, + isSectionExpanded, + toggleSection, + } +} diff --git a/packages/components/src/extension-manager/composables/useFilter.ts b/packages/components/src/extension-manager/composables/useFilter.ts new file mode 100644 index 000000000..e379e052b --- /dev/null +++ b/packages/components/src/extension-manager/composables/useFilter.ts @@ -0,0 +1,85 @@ +import { computed, reactive, toValue, watch } from 'vue' +import type { FilterState, UseFilterOptions, UseFilterResult } from '../filter.type' + +const createFilterState = (): FilterState => reactive({ selectedTag: '', searchValue: '' }) + +const normalizeTags = (tags: readonly T[]) => { + const seen = new Set() + const normalized: T[] = [] + + for (const tag of tags) { + if (!tag.value || seen.has(tag.value)) continue + seen.add(tag.value) + normalized.push(tag) + } + + return normalized +} + +const defaultGetItemTags = (item: T): readonly string[] => { + const tags = (item as { tags?: unknown }).tags + return Array.isArray(tags) && tags.every((tag): tag is string => typeof tag === 'string') ? tags : [] +} + +const defaultSearch = (item: T, query: string) => { + const searchable = item as { name?: unknown; description?: unknown } + const name = typeof searchable.name === 'string' ? searchable.name : '' + const description = typeof searchable.description === 'string' ? searchable.description : '' + return `${name} ${description}`.toLowerCase().includes(query) +} + +export const useFilter = (options: UseFilterOptions): UseFilterResult => { + const internalState = createFilterState() + const currentState = computed(() => toValue(options.state) ?? internalState) + + const selectedTag = computed({ + get: () => currentState.value.selectedTag, + set: (value: string) => { + currentState.value.selectedTag = value + }, + }) + const searchValue = computed({ + get: () => currentState.value.searchValue, + set: (value: string) => { + currentState.value.searchValue = value + }, + }) + + const normalizedTags = computed(() => normalizeTags(toValue(options.tags))) + + watch( + normalizedTags, + (tags) => { + if (selectedTag.value && !tags.some((tag) => tag.value === selectedTag.value)) { + selectedTag.value = '' + } + }, + { immediate: true }, + ) + + const filteredItems = computed(() => { + const query = searchValue.value.trim().toLowerCase() + const getItemTags = options.rules?.getItemTags ?? defaultGetItemTags + const search = options.rules?.search ?? defaultSearch + + return toValue(options.items).filter((item) => { + const matchesTag = !selectedTag.value || getItemTags(item).includes(selectedTag.value) + const matchesSearch = !query || search(item, query) + return matchesTag && matchesSearch + }) + }) + + const controls = computed(() => ({ + tags: normalizedTags.value, + selectedTag: selectedTag.value, + searchValue: searchValue.value, + 'onUpdate:selectedTag': (value: string) => { + selectedTag.value = value + }, + 'onUpdate:searchValue': (value: string) => { + searchValue.value = value + }, + })) + + return { filteredItems, selectedTag, searchValue, controls } +} diff --git a/packages/components/src/extension-manager/filter.type.ts b/packages/components/src/extension-manager/filter.type.ts new file mode 100644 index 000000000..f56b3a168 --- /dev/null +++ b/packages/components/src/extension-manager/filter.type.ts @@ -0,0 +1,34 @@ +import type { ComputedRef, MaybeRefOrGetter, WritableComputedRef } from 'vue' +import type { ExtensionManagerTagOption } from './index.type' + +export interface FilterState { + selectedTag: string + searchValue: string +} + +export interface FilterRules { + getItemTags?: (item: T) => readonly string[] + search?: (item: T, query: string) => boolean +} + +export interface UseFilterOptions { + items: MaybeRefOrGetter + tags: MaybeRefOrGetter + rules?: FilterRules + state?: MaybeRefOrGetter +} + +export interface FilterControls { + tags: readonly ExtensionManagerTagOption[] + selectedTag: string + searchValue: string + 'onUpdate:selectedTag': (value: string) => void + 'onUpdate:searchValue': (value: string) => void +} + +export interface UseFilterResult { + filteredItems: ComputedRef + selectedTag: WritableComputedRef + searchValue: WritableComputedRef + controls: ComputedRef +} diff --git a/packages/components/src/extension-manager/index.ts b/packages/components/src/extension-manager/index.ts new file mode 100644 index 000000000..83014f3fb --- /dev/null +++ b/packages/components/src/extension-manager/index.ts @@ -0,0 +1,28 @@ +import type { App } from 'vue' +import ExtensionManager from './index.vue' +import { ExtensionCard, ExtensionCardGrid } from './components' + +ExtensionManager.name = 'ExtensionManager' +ExtensionCard.name = 'ExtensionCard' +ExtensionCardGrid.name = 'ExtensionCardGrid' + +const install = function (app: App) { + app.component(ExtensionManager.name!, ExtensionManager) + app.component(ExtensionCard.name!, ExtensionCard) + app.component(ExtensionCardGrid.name!, ExtensionCardGrid) +} + +const ExtensionManagerWithSubComponents = Object.assign(ExtensionManager, { + install, + Card: ExtensionCard, + CardGrid: ExtensionCardGrid, +}) + +export { ExtensionManager } +export * from './public.type' + +export default ExtensionManagerWithSubComponents as typeof ExtensionManager & { + install: typeof install + Card: typeof ExtensionCard + CardGrid: typeof ExtensionCardGrid +} diff --git a/packages/components/src/extension-manager/index.type.test.ts b/packages/components/src/extension-manager/index.type.test.ts new file mode 100644 index 000000000..d401d2de2 --- /dev/null +++ b/packages/components/src/extension-manager/index.type.test.ts @@ -0,0 +1,230 @@ +import type { Component } from 'vue' +import type { + ExtensionCardGridActionEvent, + ExtensionCardGridEmits, + ExtensionCardGridItem, + ExtensionCardGridNameClickEvent, + ExtensionCardGridProps, + ExtensionCardGridSlots, + ExtensionCardAction, + ExtensionCardActionEvent, + ExtensionCardProps, + ExtensionManagerActionEvent, + ExtensionManagerEmits, + ExtensionManagerItem, + ExtensionManagerNameClickEvent, + ExtensionManagerProps, + ExtensionManagerSectionKey, + ExtensionManagerSectionToggleEvent, + ExtensionManagerSlots, + ExtensionManagerTab, + ExtensionManagerTabChangeEvent, +} from './index.type' + +const icon = {} as Component + +const cardProps: ExtensionCardProps = { + name: 'Standalone card', + description: 'Card description', + icon, + actions: [ + { id: 'enabled', type: 'switch', label: '启用扩展', checked: true }, + { id: 'install', type: 'button', label: '安装' }, + { id: 'inspect', type: 'custom', label: '检查', data: { origin: 'type-test' } }, + ], + primaryActionsLimit: 1, + progress: 'indeterminate', + nameClickable: false, + overflowMenuLabel: '扩展操作', + overflowMenuPlacement: 'top-end', +} + +const switchAction: ExtensionCardAction = { + id: 'enabled', + type: 'switch', + label: '启用扩展', + checked: false, +} +const buttonAction: ExtensionCardAction = { + id: 'install', + type: 'button', + label: '安装', +} +const customAction: ExtensionCardAction = { + id: 'inspect', + type: 'custom', + label: '检查', +} +const event: ExtensionCardActionEvent = { + id: 'enabled', + type: 'switch', + checked: true, +} + +const cardGridItem: ExtensionCardGridItem = { + ...cardProps, + id: 'grid-item', +} + +const cardGridProps: ExtensionCardGridProps = { + items: [cardGridItem], + emptyText: 'No cards', + primaryActionsLimit: 1, + nameClickable: true, + overflowMenuLabel: 'Grid actions', + overflowMenuPlacement: 'top-end', + overflowMenuShowIcons: false, +} +const cardGridDefaultProps: ExtensionCardGridProps = { + items: [cardGridItem], +} + +const cardGridSlots: ExtensionCardGridSlots = { + item: ({ item, index }) => { + const itemId: string = item.id + const itemName: string = item.name + const itemIndex: number = index + + void itemId + void itemName + void itemIndex + return [] + }, + empty: () => [], +} + +const cardGridActionEvent: ExtensionCardGridActionEvent = { + itemId: cardGridItem.id, + action: event, +} +const cardGridNameClickEvent: ExtensionCardGridNameClickEvent = { + itemId: cardGridItem.id, + event: {} as MouseEvent, +} + +declare const cardGridEmit: ExtensionCardGridEmits +cardGridEmit('action', cardGridActionEvent) +cardGridEmit('name-click', cardGridNameClickEvent) + +const managerItem: ExtensionManagerItem = { + ...cardProps, + id: 'manager-item', + installed: true, +} + +const managerTab: ExtensionManagerTab = { + id: 'catalog', + label: 'Catalog', + items: [managerItem], +} +const secondManagerTab: ExtensionManagerTab = { + id: 'updates', + label: 'Updates', + items: [], +} + +const managerProps: ExtensionManagerProps = { + tabs: [managerTab, secondManagerTab], + activeTab: 'catalog', + defaultActiveTab: 'updates', + title: 'Extensions', + emptyText: 'No enabled tabs', +} + +const managerTabChangeEvent: ExtensionManagerTabChangeEvent = { + tabId: managerTab.id, +} +const managerSectionKey: ExtensionManagerSectionKey = 'installed' +const managerSectionToggleEvent: ExtensionManagerSectionToggleEvent = { + tabId: managerTab.id, + sectionKey: managerSectionKey, + expanded: true, +} +const managerActionEvent: ExtensionManagerActionEvent = { + tabId: managerTab.id, + sectionKey: managerSectionKey, + itemId: managerItem.id, + action: event, +} +const managerNameClickEvent: ExtensionManagerNameClickEvent = { + tabId: managerTab.id, + sectionKey: managerSectionKey, + itemId: managerItem.id, + event: {} as MouseEvent, +} + +const managerSlots: ExtensionManagerSlots = { + 'header-actions': () => [], + tab: ({ tab, active, select }) => { + const tabId: string = tab.id + const isActive: boolean = active + select() + + void tabId + void isActive + return [] + }, + item: ({ tab, sectionKey, item, index }) => { + const tabId: string = tab.id + const itemId: string = item.id + const itemIndex: number = index + + void tabId + void sectionKey + void itemId + void itemIndex + return [] + }, + empty: ({ tab, sectionKey, title }) => { + const tabId: string = tab.id + const sectionTitle: string = title + + void tabId + void sectionKey + void sectionTitle + return [] + }, +} + +declare const managerEmit: ExtensionManagerEmits +managerEmit('update:active-tab', managerTab.id) +managerEmit('update:active-tab', undefined) +managerEmit('tab-change', managerTabChangeEvent) +managerEmit('section-toggle', managerSectionToggleEvent) +managerEmit('action', managerActionEvent) +managerEmit('name-click', managerNameClickEvent) + +// @ts-expect-error Manager items retain Grid-owned identity. +const managerItemWithoutId: ExtensionManagerItem = { name: 'Missing Manager identity' } +// @ts-expect-error ExtensionCardGridItem requires the Grid-owned id. +const cardGridItemWithoutId: ExtensionCardGridItem = { name: 'Missing Grid identity' } +// @ts-expect-error Every action requires a label. +const unlabeledAction: ExtensionCardAction = { id: 'install', type: 'button' } +// @ts-expect-error Domain-specific install action is not a Card action type. +const installAction: ExtensionCardAction = { id: 'install', type: 'install', label: '安装' } + +void cardProps +void switchAction +void buttonAction +void customAction +void event +void cardGridItem +void cardGridProps +void cardGridDefaultProps +void cardGridSlots +void cardGridActionEvent +void cardGridNameClickEvent +void managerItem +void managerTab +void secondManagerTab +void managerProps +void managerTabChangeEvent +void managerSectionToggleEvent +void managerActionEvent +void managerNameClickEvent +void managerSectionKey +void managerSlots +void managerItemWithoutId +void cardGridItemWithoutId +void unlabeledAction +void installAction diff --git a/packages/components/src/extension-manager/index.vue b/packages/components/src/extension-manager/index.vue new file mode 100644 index 000000000..ef68200b7 --- /dev/null +++ b/packages/components/src/extension-manager/index.vue @@ -0,0 +1,221 @@ + + + + + diff --git a/packages/components/src/extension-manager/public.type.ts b/packages/components/src/extension-manager/public.type.ts new file mode 100644 index 000000000..bf95edb30 --- /dev/null +++ b/packages/components/src/extension-manager/public.type.ts @@ -0,0 +1,30 @@ +export type { + ExtensionCardAction, + ExtensionCardActionBase, + ExtensionCardActionEvent, + ExtensionCardButtonAction, + ExtensionCardCustomAction, + ExtensionCardEmits, + ExtensionCardGridActionEvent, + ExtensionCardGridEmits, + ExtensionCardGridItem, + ExtensionCardGridNameClickEvent, + ExtensionCardGridProps, + ExtensionCardGridSlots, + ExtensionCardOverflowMenuPlacement, + ExtensionCardProps, + ExtensionCardRenderableAction, + ExtensionCardSlots, + ExtensionCardSwitchAction, + ExtensionManagerActionEvent, + ExtensionManagerEmits, + ExtensionManagerItem, + ExtensionManagerNameClickEvent, + ExtensionManagerProps, + ExtensionManagerSectionKey, + ExtensionManagerSectionToggleEvent, + ExtensionManagerSlots, + ExtensionManagerTagOption, + ExtensionManagerTab, + ExtensionManagerTabChangeEvent, +} from './index.type' diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index ff084365d..99003826c 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -1,7 +1,7 @@ import './styles/root.css' import './styles/components/index.css' -import { App } from 'vue' +import type { App } from 'vue' import Attachments from './attachments' import { Bubble, BubbleList, BubbleProvider } from './bubble' import Container from './container' @@ -22,6 +22,7 @@ import ThemeProvider from './theme-provider' import Welcome from './welcome' import McpServerPicker from './mcp-server-picker' import McpAddForm from './mcp-add-form' +import ExtensionManager from './extension-manager' import { ActionButton, SubmitButton, @@ -54,6 +55,7 @@ export * from './theme-provider/index.type' export * from './welcome/index.type' export * from './mcp-server-picker/index.type' export * from './mcp-add-form/index.type' +export * from './extension-manager/public.type' export { useSenderContentRegistration } from './shared/composables/useSenderContentRegistration' export type { SenderContentRegister } from './shared/composables/useSenderContentRegistration' @@ -72,7 +74,6 @@ export { useTheme } from './theme-provider/useTheme' export { useSenderContext } from './sender' export { vDropzone } from './drag-overlay/directives/vDropzone' export { useAutoScroll, useTouchDevice } from './shared/composables' - const components = [ Attachments, Bubble, @@ -100,6 +101,7 @@ const components = [ Welcome, McpServerPicker, McpAddForm, + ExtensionManager, ActionButton, SubmitButton, ClearButton, @@ -171,6 +173,8 @@ export { McpServerPicker as TrMcpServerPicker, McpAddForm, McpAddForm as TrMcpAddForm, + ExtensionManager, + ExtensionManager as TrExtensionManager, ActionButton, ActionButton as TrActionButton, SubmitButton, diff --git a/packages/test/component/extension-manager/ExtensionManager.fixture.vue b/packages/test/component/extension-manager/ExtensionManager.fixture.vue new file mode 100644 index 000000000..4a8b59553 --- /dev/null +++ b/packages/test/component/extension-manager/ExtensionManager.fixture.vue @@ -0,0 +1,178 @@ + + + diff --git a/packages/test/component/extension-manager/ExtensionManager.spec.ts b/packages/test/component/extension-manager/ExtensionManager.spec.ts new file mode 100644 index 000000000..72a5fb4a7 --- /dev/null +++ b/packages/test/component/extension-manager/ExtensionManager.spec.ts @@ -0,0 +1,362 @@ +import { expect, test } from '@playwright/experimental-ct-vue' +import ExtensionManagerFixture from './ExtensionManager.fixture.vue' +import ExtensionManagerUncontrolledFixture from './ExtensionManagerUncontrolled.fixture.vue' + +test.describe('ExtensionManager section model', () => { + test('derives stable installed and available sections from the active tab items', async ({ mount }) => { + const component = await mount(ExtensionManagerFixture) + const manager = component.getByTestId('manager-host') + + await expect(manager.locator('[data-section-key="installed"]')).toHaveCount(1) + await expect(manager.locator('[data-section-key="available"]')).toHaveCount(1) + await expect( + manager.locator('section[data-section-key="installed"]').getByRole('button', { name: '已安装', exact: true }), + ).toHaveCount(1) + await expect( + manager.locator('section[data-section-key="available"]').getByRole('button', { name: '可安装', exact: true }), + ).toHaveCount(1) + + const installedIds = await manager + .locator('[data-section-key="installed"] li[data-card-id]') + .evaluateAll((elements) => elements.map((element) => element.getAttribute('data-card-id'))) + const availableIds = await manager + .locator('[data-section-key="available"] li[data-card-id]') + .evaluateAll((elements) => elements.map((element) => element.getAttribute('data-card-id'))) + + expect(installedIds).toEqual(['alpha']) + expect(availableIds).toEqual(['beta', 'gamma']) + }) + + test('keeps both sections visible and renders the empty slot for an empty derived section', async ({ mount }) => { + const component = await mount(ExtensionManagerFixture) + + await component.getByTestId('set-active-market').click() + + const manager = component.getByTestId('manager-host') + await expect( + manager.locator('section[data-section-key="installed"]').getByRole('button', { name: '已安装', exact: true }), + ).toHaveCount(1) + await expect( + manager.locator('section[data-section-key="available"]').getByRole('button', { name: '可安装', exact: true }), + ).toHaveCount(1) + await expect(manager.getByTestId('empty-slot-market-available')).toHaveText('Empty available') + }) + + test('defaults derived Cards to one primary action', async ({ mount }) => { + const component = await mount(ExtensionManagerFixture) + const manager = component.getByTestId('manager-host') + + await expect(manager.getByRole('button', { name: 'Install Beta' })).toHaveCount(1) + await expect(manager.locator('[data-card-id="beta"] [aria-label="更多操作"]')).toHaveCount(1) + }) + + test('moves an item between derived sections when installed changes', async ({ mount }) => { + const component = await mount(ExtensionManagerFixture) + const manager = component.getByTestId('manager-host') + + await component.getByTestId('install-beta').click() + + await expect(manager.locator('[data-section-key="installed"] li[data-card-id="beta"]')).toHaveCount(1) + await expect(manager.locator('[data-section-key="available"] li[data-card-id="beta"]')).toHaveCount(0) + }) + + test('uses default section headers and empty slot context', async ({ mount }) => { + const component = await mount(ExtensionManagerFixture) + const manager = component.getByTestId('manager-host') + + await expect( + manager.locator('section[data-section-key="installed"]').getByRole('button', { name: '已安装', exact: true }), + ).toHaveText('已安装') + await expect( + manager.locator('section[data-section-key="available"]').getByRole('button', { name: '可安装', exact: true }), + ).toHaveText('可安装') + + await component.getByTestId('set-active-market').click() + await expect(manager.getByTestId('empty-slot-context-market-available')).toHaveText('market/available/可安装') + }) + + test('keeps Manager-only metadata out of the Card/Grid item boundary', async ({ mount }) => { + const component = await mount(ExtensionManagerFixture) + + await component.getByTestId('show-item-slot-manager').click() + + const itemSlotKeys = component.getByTestId('item-slot-manager').getByTestId('item-slot-keys') + await expect(itemSlotKeys).toHaveCount(3) + for (const keys of await itemSlotKeys.allTextContents()) { + expect(JSON.parse(keys)).not.toContain('installed') + expect(JSON.parse(keys)).not.toContain('tags') + } + }) + + test('preserves collapse state per tab and section key', async ({ mount }) => { + const component = await mount(ExtensionManagerFixture) + const manager = component.getByTestId('manager-host') + const libraryInstalled = manager + .locator('section[data-tab-id="library"][data-section-key="installed"]') + .getByRole('button', { name: '已安装', exact: true }) + const libraryAvailable = manager + .locator('section[data-tab-id="library"][data-section-key="available"]') + .getByRole('button', { name: '可安装', exact: true }) + + await libraryInstalled.click() + await expect(libraryInstalled).toHaveAttribute('aria-expanded', 'false') + await expect(libraryAvailable).toHaveAttribute('aria-expanded', 'true') + + await component.getByTestId('set-active-market').click() + const marketInstalled = manager + .locator('section[data-tab-id="market"][data-section-key="installed"]') + .getByRole('button', { name: '已安装', exact: true }) + await expect(marketInstalled).toHaveAttribute('aria-expanded', 'true') + + await marketInstalled.click() + await component.getByTestId('set-active-library').click() + await expect(libraryInstalled).toHaveAttribute('aria-expanded', 'false') + await expect(manager.locator('section[data-tab-id="market"]')).toHaveCount(0) + }) + + test('routes Card action and name-click events with section keys', async ({ mount }) => { + const component = await mount(ExtensionManagerFixture) + const item = component.getByTestId('manager-host').locator('li[data-card-id="alpha"]') + + await item.getByRole('checkbox', { name: 'Enable Alpha' }).uncheck({ force: true }) + await item.getByRole('button', { name: 'Alpha extension', exact: true }).click() + + await expect(component.getByTestId('event-log')).toContainText( + 'action:{"tabId":"library","sectionKey":"installed","itemId":"alpha","action":{"id":"toggle-alpha","type":"switch","checked":false}}', + ) + await expect(component.getByTestId('event-log')).toContainText( + 'name-click:{"tabId":"library","sectionKey":"installed","itemId":"alpha","event":{"type":"click"}}', + ) + }) +}) + +test.describe('ExtensionManager uncontrolled state', () => { + test('activates and focuses tabs with Arrow, Home, and End keys', async ({ mount }) => { + const component = await mount(ExtensionManagerUncontrolledFixture) + const manager = component.getByTestId('uncontrolled-manager') + const slashTab = manager.getByRole('tab', { name: /Slash tab/ }) + const dashTab = manager.getByRole('tab', { name: /Dash tab/ }) + + await dashTab.focus() + await dashTab.press('ArrowRight') + await expect(slashTab).toHaveAttribute('aria-selected', 'true') + await expect(slashTab).toHaveAttribute('tabindex', '0') + await expect(dashTab).toHaveAttribute('tabindex', '-1') + await expect(slashTab).toBeFocused() + + await slashTab.press('ArrowLeft') + await expect(dashTab).toHaveAttribute('aria-selected', 'true') + await expect(dashTab).toBeFocused() + + await dashTab.press('Home') + await expect(slashTab).toHaveAttribute('aria-selected', 'true') + await expect(slashTab).toBeFocused() + + await slashTab.press('End') + await expect(dashTab).toHaveAttribute('aria-selected', 'true') + await expect(dashTab).toBeFocused() + }) + + test('moves the shared indicator to the active tab', async ({ mount }) => { + const component = await mount(ExtensionManagerUncontrolledFixture) + const manager = component.getByTestId('uncontrolled-manager') + const indicator = manager.locator('.extension-manager-tabs__indicator') + const slashTab = manager.getByRole('tab', { name: /Slash tab/ }) + const dashTab = manager.getByRole('tab', { name: /Dash tab/ }) + + const expectIndicatorAt = async (tab: typeof slashTab) => { + await expect + .poll(async () => { + const target = await tab.evaluate((element) => ({ + left: (element as HTMLElement).offsetLeft, + width: (element as HTMLElement).offsetWidth, + })) + const indicatorPosition = await indicator.evaluate((element) => ({ + transform: (element as HTMLElement).style.transform, + width: (element as HTMLElement).style.width, + })) + + return ( + indicatorPosition.transform === `translateX(${target.left}px)` && + indicatorPosition.width === `${target.width}px` + ) + }) + .toBe(true) + } + + await expectIndicatorAt(dashTab) + await slashTab.click() + await expectIndicatorAt(slashTab) + }) + + test('keeps Enter and Space activation through native tab buttons', async ({ mount }) => { + const component = await mount(ExtensionManagerUncontrolledFixture) + const manager = component.getByTestId('uncontrolled-manager') + const slashTab = manager.getByRole('tab', { name: /Slash tab/ }) + const dashTab = manager.getByRole('tab', { name: /Dash tab/ }) + + await slashTab.focus() + await slashTab.press('Enter') + await expect(slashTab).toHaveAttribute('aria-selected', 'true') + + await dashTab.focus() + await dashTab.press('Space') + await expect(dashTab).toHaveAttribute('aria-selected', 'true') + }) + + test('uses internal active and section expansion state without v-model bindings', async ({ mount }) => { + const component = await mount(ExtensionManagerUncontrolledFixture) + const manager = component.getByTestId('uncontrolled-manager') + const dashTab = manager.getByRole('tab', { name: /Dash tab/ }) + const slashTab = manager.getByRole('tab', { name: /Slash tab/ }) + const dashSection = manager + .locator('section[data-tab-id="a-b"][data-section-key="installed"]') + .getByRole('button', { name: '已安装', exact: true }) + + await expect(dashTab).toHaveAttribute('aria-selected', 'true') + await expect(dashSection).toHaveAttribute('aria-expanded', 'true') + + await dashSection.click() + + await expect(dashSection).toHaveAttribute('aria-expanded', 'false') + await expect(component.getByTestId('uncontrolled-event-log')).toContainText('section-toggle:a-b/installed/false') + + await slashTab.click() + + await expect(slashTab).toHaveAttribute('aria-selected', 'true') + await expect(dashTab).toHaveAttribute('aria-selected', 'false') + }) + + test('keeps arbitrary tab ids safe in the nested expansion model', async ({ mount }) => { + const component = await mount(ExtensionManagerUncontrolledFixture) + const manager = component.getByTestId('identity-manager') + const slashSection = manager + .locator('section[data-tab-id="a/b"][data-section-key="installed"]') + .getByRole('button', { name: '已安装', exact: true }) + + await expect(slashSection).toHaveAttribute('aria-expanded', 'true') + await slashSection.click() + await expect(slashSection).toHaveAttribute('aria-expanded', 'false') + + await manager.getByRole('tab', { name: /Dash identity tab/ }).click() + + await expect( + manager + .locator('section[data-tab-id="a-b"][data-section-key="installed"]') + .getByRole('button', { name: '已安装', exact: true }), + ).toHaveAttribute('aria-expanded', 'true') + }) +}) + +test.describe('ExtensionManager Filter acceptance', () => { + test('responds to the manager container width instead of the viewport width', async ({ mount, page }) => { + await page.setViewportSize({ width: 1200, height: 800 }) + const component = await mount(ExtensionManagerFixture) + const manager = component.getByTestId('manager-host') + + await manager.evaluate((element) => { + element.style.width = '440px' + }) + + const tagBox = await manager.getByTestId('filter-tag').boundingBox() + const searchBox = await manager.getByTestId('filter-search').boundingBox() + + expect(tagBox).not.toBeNull() + expect(searchBox).not.toBeNull() + expect(searchBox!.y).toBeGreaterThan(tagBox!.y) + expect(searchBox!.x).toBe(tagBox!.x) + }) + + test('filters by trimmed case-insensitive name and description search', async ({ mount }) => { + const component = await mount(ExtensionManagerFixture) + const manager = component.getByTestId('manager-host') + const search = manager.getByTestId('filter-search') + + await search.fill(' ALPHA ') + await expect(manager.locator('li[data-card-id="alpha"]')).toHaveCount(1) + await expect(manager.locator('li[data-card-id="beta"]')).toHaveCount(0) + await search.fill('DESCRIPTION') + await expect(manager.locator('li[data-card-id]')).toHaveCount(3) + }) + + test('shows a themed search clear button only when needed and clears the query', async ({ mount }) => { + const component = await mount(ExtensionManagerFixture) + const manager = component.getByTestId('manager-host') + const search = manager.getByTestId('filter-search') + const clear = manager.getByTestId('filter-search-clear') + + await expect(clear).toHaveCount(0) + await search.fill('alpha') + await expect(clear).toBeVisible() + await clear.click() + await expect(search).toHaveValue('') + await expect(clear).toHaveCount(0) + }) + + test('combines selected tag and search with AND semantics', async ({ mount }) => { + const component = await mount(ExtensionManagerFixture) + const manager = component.getByTestId('manager-host') + + await manager.getByTestId('filter-tag').selectOption('recommended') + await manager.getByTestId('filter-search').fill('gamma') + + await expect(manager.locator('li[data-card-id="gamma"]')).toHaveCount(1) + await expect(manager.locator('li[data-card-id="alpha"]')).toHaveCount(0) + await expect(manager.locator('li[data-card-id="beta"]')).toHaveCount(0) + }) + + test('preserves order and installed/available partitioning after filtering', async ({ mount }) => { + const component = await mount(ExtensionManagerFixture) + const manager = component.getByTestId('manager-host') + + await manager.getByTestId('filter-search').fill('description') + const availableIds = await manager + .locator('[data-section-key="available"] li[data-card-id]') + .evaluateAll((elements) => elements.map((element) => element.getAttribute('data-card-id'))) + await expect(manager.locator('[data-section-key="installed"] li[data-card-id]')).toHaveCount(1) + await expect(manager.locator('[data-section-key="available"] li[data-card-id]')).toHaveCount(2) + expect(availableIds).toEqual(['beta', 'gamma']) + }) + + test('derives item tags, disables the selector without tags, and keeps the filter row for empty tabs', async ({ + mount, + }) => { + const component = await mount(ExtensionManagerFixture) + const manager = component.getByTestId('manager-host') + + await expect(manager.getByTestId('filter-tag').locator('option')).toHaveCount(3) + await manager.getByTestId('filter-tag').selectOption('recommended') + await component.getByTestId('remove-recommended-tag').click() + await manager.getByTestId('filter-tag').selectOption('writing') + await component.getByTestId('remove-writing-tag').click() + await expect(manager.getByTestId('filter-tag')).toBeDisabled() + await component.getByTestId('set-active-market').click() + await expect(manager.getByTestId('filter-tag')).toBeEnabled() + await component.getByTestId('empty-library').click() + await expect(manager.getByTestId('filter-root')).toBeVisible() + await component.getByTestId('remove-market-tab').click() + await expect(manager.getByTestId('filter-root')).toBeVisible() + }) + + test('isolates tab filter state and clears invalid or removed tab state', async ({ mount }) => { + const component = await mount(ExtensionManagerFixture) + const manager = component.getByTestId('manager-host') + + await manager.getByTestId('filter-tag').selectOption('recommended') + await manager.getByTestId('filter-search').fill('alpha') + await component.getByTestId('set-active-market').click() + await expect(manager.getByTestId('filter-search')).toHaveValue('') + await component.getByTestId('set-active-library').click() + await component.getByTestId('remove-recommended-tag').click() + await expect(manager.getByTestId('filter-tag')).toHaveValue('') + await expect(manager.getByTestId('filter-search')).toHaveValue('alpha') + await component.getByTestId('remove-market-tab').click() + await expect(manager.getByTestId('filter-root')).toBeVisible() + }) + + test('renders the configured title', async ({ mount }) => { + const component = await mount(ExtensionManagerFixture) + + await expect(component.getByTestId('manager-host').getByText('Extension manager', { exact: true })).toBeVisible() + }) +}) diff --git a/packages/test/component/extension-manager/ExtensionManagerNamespace.fixture.vue b/packages/test/component/extension-manager/ExtensionManagerNamespace.fixture.vue new file mode 100644 index 000000000..bd004704d --- /dev/null +++ b/packages/test/component/extension-manager/ExtensionManagerNamespace.fixture.vue @@ -0,0 +1,32 @@ + + + diff --git a/packages/test/component/extension-manager/ExtensionManagerNamespace.spec.ts b/packages/test/component/extension-manager/ExtensionManagerNamespace.spec.ts new file mode 100644 index 000000000..0f3a44376 --- /dev/null +++ b/packages/test/component/extension-manager/ExtensionManagerNamespace.spec.ts @@ -0,0 +1,16 @@ +import { expect, test } from '@playwright/experimental-ct-vue' +import ExtensionManagerNamespaceFixture from './ExtensionManagerNamespace.fixture.vue' + +test.describe('ExtensionManager namespace', () => { + test('exposes and installs the current Manager, Card, and CardGrid facade', async ({ mount }) => { + const component = await mount(ExtensionManagerNamespaceFixture) + + await expect(component.getByTestId('manager-surface')).toBeVisible() + await expect(component.getByTestId('manager-name')).toHaveText('ExtensionManager') + await expect(component.getByTestId('card-name')).toHaveText('ExtensionCard') + await expect(component.getByTestId('card-grid-name')).toHaveText('ExtensionCardGrid') + await expect(component.getByTestId('manager-registration')).toHaveText('true') + await expect(component.getByTestId('card-registration')).toHaveText('true') + await expect(component.getByTestId('card-grid-registration')).toHaveText('true') + }) +}) diff --git a/packages/test/component/extension-manager/ExtensionManagerUncontrolled.fixture.vue b/packages/test/component/extension-manager/ExtensionManagerUncontrolled.fixture.vue new file mode 100644 index 000000000..fdbd35f4a --- /dev/null +++ b/packages/test/component/extension-manager/ExtensionManagerUncontrolled.fixture.vue @@ -0,0 +1,67 @@ + + + From 918a48b092ac2aac27bed668a01af49a86c3d312 Mon Sep 17 00:00:00 2001 From: gene9831 Date: Tue, 18 Aug 2026 15:50:39 +0800 Subject: [PATCH 03/11] feat: add playwright cache-development-mode to .gitignore and create index.ts file --- .gitignore | 1 + packages/components/src/extension-manager/index.ts | 0 2 files changed, 1 insertion(+) create mode 100644 packages/components/src/extension-manager/index.ts diff --git a/.gitignore b/.gitignore index 8f597464d..480d4bf83 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ test-results playwright-report blob-report **/playwright/.cache/ +**/playwright/.cache-development-mode/ package-lock.json pnpm-lock.yaml yarn.lock diff --git a/packages/components/src/extension-manager/index.ts b/packages/components/src/extension-manager/index.ts new file mode 100644 index 000000000..e69de29bb From 780d521fb7fcf58c880332a57aa4c881887d0cba Mon Sep 17 00:00:00 2001 From: gene9831 Date: Tue, 18 Aug 2026 17:58:35 +0800 Subject: [PATCH 04/11] feat: update ExtensionCard and ExtensionCardActions to use role="switch" for better accessibility --- .../components/ExtensionCard.vue | 10 +++++++- .../components/ExtensionCardActions.vue | 23 +++++++++++-------- .../extension-manager/ExtensionCard.spec.ts | 6 ++--- .../ExtensionCardActionEvent.fixture.vue | 7 +++++- .../ExtensionCardActionEvent.spec.ts | 21 +++++++++++++++-- .../ExtensionCardGrid.spec.ts | 5 ++-- 6 files changed, 53 insertions(+), 19 deletions(-) diff --git a/packages/components/src/extension-manager/components/ExtensionCard.vue b/packages/components/src/extension-manager/components/ExtensionCard.vue index 5916c0cee..3aef13a18 100644 --- a/packages/components/src/extension-manager/components/ExtensionCard.vue +++ b/packages/components/src/extension-manager/components/ExtensionCard.vue @@ -131,7 +131,15 @@ const handleNameKeydown = (event: KeyboardEvent) => { /> -
+
, event: Event) => { if (action.disabled) return + const input = event.currentTarget as HTMLInputElement + const checked = input.checked + + input.checked = Boolean(action.checked) + emit('action', { id: action.id, type: action.type, - checked: (event.target as HTMLInputElement).checked, + checked, }) } @@ -57,6 +62,7 @@ const handleCustom = (action: Extract { const overflowRegion = card.locator('.tr-extension-card__more-action') const overflowMenu = overflowRegion.locator('.tr-extension-card__more-menu') - await expect(primaryRegion.getByRole('checkbox', { name: '启用扩展' })).toBeVisible() + await expect(primaryRegion.getByRole('switch', { name: '启用扩展' })).toBeVisible() await expect(primaryRegion.getByRole('button', { name: '禁用操作' })).toBeDisabled() await expect(primaryRegion.getByRole('button', { name: '隐藏操作' })).toHaveCount(0) await expect(primaryRegion.getByRole('button', { name: '安装' })).toHaveCount(0) @@ -38,14 +38,14 @@ test.describe('standalone ExtensionCard', () => { await expect(overflowMenu).toBeHidden() await overflowRegion.getByRole('button', { name: '扩展操作' }).click() - await expect(primaryRegion.getByRole('checkbox', { name: '启用扩展' })).toBeVisible() + await expect(primaryRegion.getByRole('switch', { name: '启用扩展' })).toBeVisible() await expect(primaryRegion.getByRole('button', { name: '禁用操作' })).toBeDisabled() await expect(primaryRegion.getByRole('button', { name: '安装' })).toHaveCount(0) await expect(primaryRegion.getByRole('button', { name: '检查' })).toHaveCount(0) await expect(overflowMenu).toBeVisible() await expect(overflowMenu.getByRole('button', { name: '安装' })).toBeVisible() await expect(overflowMenu.getByRole('button', { name: '检查' })).toBeVisible() - await expect(overflowMenu.getByRole('checkbox', { name: '启用扩展' })).toHaveCount(0) + await expect(overflowMenu.getByRole('switch', { name: '启用扩展' })).toHaveCount(0) await expect(overflowMenu.getByRole('button', { name: '禁用操作' })).toHaveCount(0) await expect(overflowMenu.getByRole('button', { name: '隐藏操作' })).toHaveCount(0) }) diff --git a/packages/test/component/extension-manager/ExtensionCardActionEvent.fixture.vue b/packages/test/component/extension-manager/ExtensionCardActionEvent.fixture.vue index 19ca9a1cd..25a392b11 100644 --- a/packages/test/component/extension-manager/ExtensionCardActionEvent.fixture.vue +++ b/packages/test/component/extension-manager/ExtensionCardActionEvent.fixture.vue @@ -13,6 +13,7 @@ const actions = ref([ { id: 'delete-extension', type: 'button', label: '删除' }, ]) const lastEvent = ref() +const commitSwitchChanges = ref(true) const eventChecked = computed(() => { return typeof lastEvent.value?.checked === 'boolean' ? String(lastEvent.value.checked) : '' @@ -22,7 +23,7 @@ const eventPayload = computed(() => JSON.stringify(lastEvent.value?.payload ?? n const handleAction = (event: ExtensionCardActionEvent) => { lastEvent.value = event - if (event.type !== 'switch' || typeof event.checked !== 'boolean') return + if (event.type !== 'switch' || typeof event.checked !== 'boolean' || !commitSwitchChanges.value) return actions.value = actions.value.map((action) => action.id === event.id && action.type === 'switch' ? { ...action, checked: event.checked } : action, @@ -32,6 +33,10 @@ const handleAction = (event: ExtensionCardActionEvent) => {