From 661169d41c16f712b92eb7c39666303b12c2aebd Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Tue, 28 Jul 2026 16:18:19 +0200 Subject: [PATCH] feat(core): Tabs component for json-render, with real per-component prop types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `Tabs` to the json-render base catalog — horizontal (underlined bar) or vertical (left rail) — since building a tabbed panel today means either a Card per section or client-side `visible` wiring the spec author has to build themselves. `children[i]` renders when `tabs[i]` is active, so there's no `visible` plumbing needed in the spec; two-way bindable via `$bindState` on `value`, otherwise switches local uncontrolled state. Includes roving -tabindex keyboard navigation (arrow keys, Home/End) per the WAI-ARIA tabs pattern. Also types `Tabs`' own props (`TabsProps`, `TabDescriptor`) co-located in `Tabs.ts` itself, and adds `registryProps()` — a typed alternative to the untyped `props: ['element', 'emit', ...]` array form every registry component currently uses, so `defineComponent` infers `setup`'s `ctx.element.props` as the component's own type instead of `Record`. `RegistryComponentProps` stays generic with the same permissive default, so every other untouched component is unaffected. `TabsProps`/`TabDescriptor` are re-exported from the client webcomponents entry (`@vitejs/devtools/client/webcomponents`, already public) as opt-in strict types — most spec authors keep using kit's fully open `JsonRenderElement`. Deliberately not added to `@vitejs/devtools-kit`: kit depends on nothing in core (core depends on kit, so the reverse would cycle), and duplicating each component's props into a second, separately -maintained types package is exactly the drift risk co-location avoids. --- docs/kit/json-render.md | 34 ++++ .../core/src/client/webcomponents/index.ts | 7 + .../json-render/JsonRender.stories.ts | 58 +++++- .../json-render/components/Tabs.ts | 175 ++++++++++++++++++ .../json-render/components/types.ts | 22 ++- .../webcomponents/json-render/registry.ts | 11 ++ .../client/webcomponents.snapshot.d.ts | 28 +++ 7 files changed, 332 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/client/webcomponents/json-render/components/Tabs.ts diff --git a/docs/kit/json-render.md b/docs/kit/json-render.md index 1c546c5c2..36ad9a1a7 100644 --- a/docs/kit/json-render.md +++ b/docs/kit/json-render.md @@ -273,6 +273,40 @@ Container with an optional title and collapsible behavior. } ``` +#### Tabs + +Switches which of its `children` is shown, one tab per entry in `tabs` (positionally matched — `children[i]` renders when `tabs[i]` is active). + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `tabs` | `Array<{ value: string, label: string, icon?: string, badge?: string, badgeVariant?: 'default' \| 'info' \| 'success' \| 'warning' \| 'danger' }>` | — | The tab list | +| `value` | `string` | — | Active tab's `value` (use `$bindState` for two-way binding) | +| `defaultValue` | `string` | first tab | Initial active tab when `value` isn't bound | +| `orientation` | `'horizontal' \| 'vertical'` | `'horizontal'` | Underlined top bar vs. a left-hand rail | + +**Event**: `change` — fires when the active tab changes (including via arrow-key navigation). + + +```ts +{ + root: 'root', + elements: { + root: { + type: 'Tabs', + props: { + tabs: [ + { value: 'mfe', label: 'Micro-Frontends', badge: '3', badgeVariant: 'success' }, + { value: 'gateway', label: 'Gateway' }, + ], + }, + children: ['mfe-panel', 'gateway-panel'], + }, + 'mfe-panel': { type: 'Text', props: { text: '3 active overrides' } }, + 'gateway-panel': { type: 'Text', props: { text: 'No overrides' } }, + }, +} +``` + #### Divider Visual separator line with an optional label. diff --git a/packages/core/src/client/webcomponents/index.ts b/packages/core/src/client/webcomponents/index.ts index da4da9640..0b766375b 100644 --- a/packages/core/src/client/webcomponents/index.ts +++ b/packages/core/src/client/webcomponents/index.ts @@ -1,3 +1,10 @@ export * from './components/DockEmbedded' export * from './components/DockStandalone' +/** + * Opt-in strict types for the json-render base catalog — most spec authors + * use the fully open `JsonRenderElement` from `@vitejs/devtools-kit` instead; + * import these only to narrow a specific element (`JsonRenderElement<'Tabs', TabsProps>`). + */ +export type { JsonRenderElement, TabDescriptor, TabsProps } from './json-render/registry' + export * from './state/docks' diff --git a/packages/core/src/client/webcomponents/json-render/JsonRender.stories.ts b/packages/core/src/client/webcomponents/json-render/JsonRender.stories.ts index 8e6a4eb7e..fcbf52d8c 100644 --- a/packages/core/src/client/webcomponents/json-render/JsonRender.stories.ts +++ b/packages/core/src/client/webcomponents/json-render/JsonRender.stories.ts @@ -33,7 +33,7 @@ const meta = { parameters: { docs: { description: { - component: 'The json-render primitive registry (`Stack`, `Card`, `Text`, `Badge`, `Button`, `Icon`, `Divider`, `Switch`, `KeyValueTable`, `DataTable`, `CodeBlock`, `Progress`) rendered from a declarative spec — the same renderer plugins use to build panels without shipping Vue.', + component: 'The json-render primitive registry (`Stack`, `Card`, `Tabs`, `Text`, `Badge`, `Button`, `Icon`, `Divider`, `Switch`, `KeyValueTable`, `DataTable`, `CodeBlock`, `Progress`) rendered from a declarative spec — the same renderer plugins use to build panels without shipping Vue.', }, }, }, @@ -116,6 +116,62 @@ export const Card: StoryObj> = { } as unknown as Spec)), } +/** + * `Tabs` switches which of its `children` renders, positionally matched to + * `tabs[]` — no `visible` plumbing needed in the spec. Each panel here is a + * `Card` of `Stack` rows (the same composition the `Card` story above uses + * standalone), showing that a tab panel is an ordinary element tree, not a + * special slot. Uncontrolled (no `value` binding), so each tab click drives + * Tabs' own local state; toggle `orientation` below to compare the + * underlined horizontal bar against the left-rail vertical layout, and use + * arrow keys / Home / End once a tab has focus to exercise the + * roving-tabindex keyboard navigation. + */ +interface TabsArgs { + orientation: 'horizontal' | 'vertical' +} + +export const Tabs: StoryObj> = { + argTypes: { + orientation: { control: 'select', options: ['horizontal', 'vertical'] }, + }, + args: { orientation: 'horizontal' }, + render: args => renderSpec(() => ({ + root: 'root', + state: {}, + elements: { + root: { + type: 'Tabs', + props: { + orientation: args.orientation, + tabs: [ + { value: 'mfe', label: 'Micro-Frontends', badge: '2', badgeVariant: 'success' }, + { value: 'shells', label: 'Shells' }, + { value: 'gateway', label: 'Gateway', badge: '1', badgeVariant: 'danger' }, + ], + }, + children: ['mfeCard', 'shellsCard', 'gatewayCard'], + }, + mfeCard: { type: 'Card', props: { title: 'Micro-Frontends', variant: 'secondary' }, children: ['mfeRows'] }, + mfeRows: { type: 'Stack', props: { direction: 'column', gap: 4, padding: 4 }, children: ['mfeRow1', 'mfeRow2'] }, + mfeRow1: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'center' }, children: ['mfeRow1Text', 'mfeRow1Badge'] }, + mfeRow1Text: { type: 'Text', props: { text: 'vite-plugin-inspect', variant: 'code' } }, + mfeRow1Badge: { type: 'Badge', props: { text: 'enabled', variant: 'success' } }, + mfeRow2: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'center' }, children: ['mfeRow2Text', 'mfeRow2Badge'] }, + mfeRow2Text: { type: 'Text', props: { text: 'vite-plugin-vue', variant: 'code' } }, + mfeRow2Badge: { type: 'Badge', props: { text: 'enabled', variant: 'success' } }, + + shellsCard: { type: 'Card', props: { title: 'Shells', variant: 'secondary' }, children: ['shellsBody'] }, + shellsBody: { type: 'Text', props: { text: 'No shells running locally.', variant: 'caption' } }, + + gatewayCard: { type: 'Card', props: { title: 'Gateway', variant: 'secondary' }, children: ['gatewayRow'] }, + gatewayRow: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'center', padding: 4 }, children: ['gatewayRowText', 'gatewayRowBadge'] }, + gatewayRowText: { type: 'Text', props: { text: 'gateway-web', variant: 'code' } }, + gatewayRowBadge: { type: 'Badge', props: { text: 'stale override', variant: 'danger' } }, + }, + } as unknown as Spec)), +} + /** * An element whose `type` has no entry in the registry — e.g. authored * against a newer base-catalog version than this client implements, or a diff --git a/packages/core/src/client/webcomponents/json-render/components/Tabs.ts b/packages/core/src/client/webcomponents/json-render/components/Tabs.ts new file mode 100644 index 000000000..f3145325e --- /dev/null +++ b/packages/core/src/client/webcomponents/json-render/components/Tabs.ts @@ -0,0 +1,175 @@ +import { useBoundProp } from '@json-render/vue' +import { defineComponent, h, ref, watchEffect } from 'vue' +import { getIconifySvg } from '../../utils/iconify' +import { colors, primary, surfaceSubtle } from './tokens' +import { registryProps } from './types' + +export interface TabDescriptor { + value: string + label: string + icon?: string + badge?: string + badgeVariant?: 'default' | 'info' | 'success' | 'warning' | 'danger' +} + +export interface TabsProps { + /** `children[i]` renders when `tabs[i]` is active — the two arrays are positional. */ + tabs: TabDescriptor[] + /** Two-way bindable via `{ $bindState: '...' }`; otherwise the tab switches local, uncontrolled state. */ + value?: string + /** Seeds the uncontrolled case only — ignored once `value` is bound. */ + defaultValue?: string + orientation?: 'horizontal' | 'vertical' +} + +export const Tabs = defineComponent({ + name: 'JrTabs', + props: registryProps<'Tabs', TabsProps>(), + setup(ctx, { slots }) { + /** Local fallback for when `value` has no `$bindState` binding — `useBoundProp`'s setter is a no-op without one. */ + const uncontrolledValue = ref(ctx.element.props.defaultValue ?? ctx.element.props.tabs?.[0]?.value) + + /** Icon SVGs keyed by name, resolved like `Icon.ts` — one tab's icon changing shouldn't refetch the others. */ + const iconSvgs = ref>({}) + watchEffect(async () => { + const names = (ctx.element.props.tabs ?? []) + .map(tab => tab.icon) + .filter((name): name is string => !!name && !(name in iconSvgs.value)) + for (const name of names) { + const match = name.match(/^(?:i-)?([\w-]+):([\w-]+)$/) + if (match?.[1] && match[2]) { + const svg = await getIconifySvg(match[1], match[2]) + if (svg) + iconSvgs.value = { ...iconSvgs.value, [name]: svg } + } + } + }) + + return () => { + const tabs: TabDescriptor[] = ctx.element.props.tabs ?? [] + const orientation: 'horizontal' | 'vertical' = ctx.element.props.orientation === 'vertical' ? 'vertical' : 'horizontal' + const isVertical = orientation === 'vertical' + + const [boundValue, setBoundValue] = useBoundProp(ctx.element.props.value, ctx.bindings?.value) + const controlled = ctx.bindings?.value != null + const activeValue = controlled ? boundValue : uncontrolledValue.value + const change = ctx.on('change') + const setActive = (next: string) => { + if (controlled) + setBoundValue(next) + else uncontrolledValue.value = next + change.emit() + } + + /** Roving tabindex per WAI-ARIA — arrow keys move focus and selection together. */ + const move = (fromIndex: number, delta: number, container: HTMLElement) => { + if (tabs.length === 0) + return + const nextIndex = (fromIndex + delta + tabs.length) % tabs.length + const nextTab = tabs[nextIndex]! + setActive(nextTab.value) + requestAnimationFrame(() => { + (container.querySelectorAll('[role="tab"]')[nextIndex] as HTMLElement | undefined)?.focus() + }) + } + + const tabButtons = tabs.map((tab, index) => { + const active = tab.value === activeValue + return h('button', { + 'type': 'button', + 'role': 'tab', + 'aria-selected': active ? 'true' : 'false', + 'tabindex': active ? '0' : '-1', + 'class': 'jr-tab', + 'style': { + display: 'inline-flex', + alignItems: 'center', + gap: '6px', + padding: '6px 10px', + fontSize: '12px', + fontWeight: active ? '600' : '400', + color: active ? primary : 'inherit', + background: 'none', + border: 'none', + borderBottom: !isVertical ? `2px solid ${active ? primary : 'transparent'}` : undefined, + borderLeft: isVertical ? `2px solid ${active ? primary : 'transparent'}` : undefined, + // Rounds the corners away from the active-tab indicator: top for the horizontal underline, right for the vertical rail. + borderRadius: isVertical ? '0 4px 4px 0' : '4px 4px 0 0', + cursor: 'pointer', + whiteSpace: 'nowrap', + transition: 'background-color 0.15s ease', + }, + 'onClick': () => setActive(tab.value), + 'onMouseenter': (e: MouseEvent) => { + if (!active) + (e.currentTarget as HTMLElement).style.backgroundColor = surfaceSubtle + }, + 'onMouseleave': (e: MouseEvent) => { (e.currentTarget as HTMLElement).style.backgroundColor = '' }, + 'onKeydown': (e: KeyboardEvent) => { + const container = (e.currentTarget as HTMLElement).parentElement + if (!container) + return + const forward = isVertical ? 'ArrowDown' : 'ArrowRight' + const backward = isVertical ? 'ArrowUp' : 'ArrowLeft' + if (e.key === forward) { + e.preventDefault() + move(index, 1, container) + } + else if (e.key === backward) { + e.preventDefault() + move(index, -1, container) + } + else if (e.key === 'Home') { + e.preventDefault() + move(index, -index, container) + } + else if (e.key === 'End') { + e.preventDefault() + move(index, tabs.length - 1 - index, container) + } + }, + }, [ + tab.icon && h('span', { + style: { display: 'inline-flex', width: '14px', height: '14px', lineHeight: '1' }, + innerHTML: iconSvgs.value[tab.icon] || '', + }), + h('span', tab.label), + tab.badge && h('span', { + class: `jr-badge jr-badge-${tab.badgeVariant ?? 'default'}`, + style: { + display: 'inline-block', + padding: '1px 6px', + borderRadius: '9px', + fontSize: '10px', + fontWeight: '500', + backgroundColor: (colors[tab.badgeVariant ?? 'default'] ?? colors.default).bg, + color: (colors[tab.badgeVariant ?? 'default'] ?? colors.default).fg, + }, + }, tab.badge), + ]) + }) + + const panels = slots.default?.() ?? [] + const activeIndex = tabs.findIndex(tab => tab.value === activeValue) + const activePanel = activeIndex >= 0 ? panels[activeIndex] : undefined + + return h('div', { + style: { display: 'flex', flexDirection: isVertical ? 'row' : 'column', gap: '8px' }, + }, [ + h('div', { + 'role': 'tablist', + 'aria-orientation': orientation, + 'style': { + display: 'flex', + flexDirection: isVertical ? 'column' : 'row', + gap: '2px', + borderBottom: !isVertical ? '1px solid var(--jr-border, rgba(128,128,128,0.2))' : undefined, + borderRight: isVertical ? '1px solid var(--jr-border, rgba(128,128,128,0.2))' : undefined, + flexShrink: '0', + }, + }, tabButtons), + h('div', { role: 'tabpanel', style: { flex: '1', minWidth: '0' } }, activePanel ? [activePanel] : []), + ]) + } + }, +}) diff --git a/packages/core/src/client/webcomponents/json-render/components/types.ts b/packages/core/src/client/webcomponents/json-render/components/types.ts index 67906b487..e7bf116fd 100644 --- a/packages/core/src/client/webcomponents/json-render/components/types.ts +++ b/packages/core/src/client/webcomponents/json-render/components/types.ts @@ -1,14 +1,32 @@ +import type { UIElement } from '@json-render/core' +import type { PropType } from 'vue' import { ref, watchEffect } from 'vue' import { getIconifySvg } from '../../utils/iconify' -export interface RegistryComponentProps { - element: { type: string, props: Record } +export interface RegistryComponentProps> { + element: UIElement emit: (event: string) => void on: (event: string) => { emit: () => void, shouldPreventDefault: boolean, bound: boolean } bindings?: Record loading?: boolean } +/** + * Vue props for a registry component, typed to its own element shape. + * Replaces the untyped `props: ['element', 'emit', ...]` array form so + * `defineComponent` infers `setup`'s `ctx` as `RegistryComponentProps` + * instead of leaving `element.props` an untyped `Record`. + */ +export function registryProps>() { + return { + element: { type: Object as PropType>, required: true as const }, + emit: { type: Function as PropType<(event: string) => void>, required: true as const }, + on: { type: Function as PropType<(event: string) => { emit: () => void, shouldPreventDefault: boolean, bound: boolean }>, required: true as const }, + bindings: { type: Object as PropType>, required: false as const }, + loading: { type: Boolean, required: false as const }, + } +} + export function useIconSvg(getName: () => string | undefined) { const svg = ref(null) watchEffect(async () => { diff --git a/packages/core/src/client/webcomponents/json-render/registry.ts b/packages/core/src/client/webcomponents/json-render/registry.ts index 06824684b..4ea44080a 100644 --- a/packages/core/src/client/webcomponents/json-render/registry.ts +++ b/packages/core/src/client/webcomponents/json-render/registry.ts @@ -10,11 +10,21 @@ import { KeyValueTable } from './components/KeyValueTable' import { Progress } from './components/Progress' import { Stack } from './components/Stack' import { Switch } from './components/Switch' +import { Tabs } from './components/Tabs' import { Text } from './components/Text' import { TextInput } from './components/TextInput' import { Tree } from './components/Tree' import { UnsupportedComponent } from './components/UnsupportedComponent' +/** + * Per-component `props` shapes, co-located with each component and + * re-exported here as this catalog's public type surface — the single + * source of truth a spec author can opt into for strict typing, instead of + * each component's props duplicated into a separate types package. + */ +export type { TabDescriptor, TabsProps } from './components/Tabs' +export type { UIElement as JsonRenderElement } from '@json-render/core' + /** * Fallback for any spec element whose `type` isn't a key in * {@link devtoolsRegistry} — pass as `` so unrecognized @@ -25,6 +35,7 @@ export { UnsupportedComponent } export const devtoolsRegistry: Record = { Stack, Card, + Tabs, Text, Badge, Button, diff --git a/test/__snapshots__/tsnapi/@vitejs/devtools/client/webcomponents.snapshot.d.ts b/test/__snapshots__/tsnapi/@vitejs/devtools/client/webcomponents.snapshot.d.ts index 8e82716b5..fa184d0c1 100644 --- a/test/__snapshots__/tsnapi/@vitejs/devtools/client/webcomponents.snapshot.d.ts +++ b/test/__snapshots__/tsnapi/@vitejs/devtools/client/webcomponents.snapshot.d.ts @@ -1,6 +1,34 @@ /** * Generated by tsnapi — public API snapshot of `@vitejs/devtools/client/webcomponents` */ +// #region Interfaces +export interface JsonRenderElement> { + type: T; + props: P; + children?: string[]; + visible?: VisibilityCondition; + on?: Record; + repeat?: { + statePath: string; + key?: string; + }; + watch?: Record; +} +export interface TabDescriptor { + value: string; + label: string; + icon?: string; + badge?: string; + badgeVariant?: 'default' | 'info' | 'success' | 'warning' | 'danger'; +} +export interface TabsProps { + tabs: TabDescriptor[]; + value?: string; + defaultValue?: string; + orientation?: 'horizontal' | 'vertical'; +} +// #endregion + // #region Functions export declare function createDockEntryState(_: DevToolsDockEntry, _: Ref): DockEntryState; export declare function DEFAULT_DOCK_PANEL_STORE(): DockPanelStorage;