From 7632c2270b3b36224524e02f7740cf1cb7559771 Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Tue, 28 Jul 2026 16:22:33 +0200 Subject: [PATCH] fix(core): Card's collapse toggle now respects defaultCollapsed, and works without a title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `defaultCollapsed` was declared in the schema but never read — collapse state was a hardcoded `ref(false)`, so a Card always rendered expanded on first paint regardless of the prop. It's now seeded from `defaultCollapsed`. Also: a collapsible Card without a `title` had no header to click at all (the header only rendered when `title` was truthy) — it now renders whenever either is set. Toggling now emits a `toggle` event, so a spec can react via `on: { toggle: ... }`. Also types Card's own props (`CardProps`) co-located in `Card.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`. `CardProps` is re-exported from the client webcomponents entry (`@vitejs/devtools/client/webcomponents`, already public) as an opt-in strict type. --- docs/kit/json-render.md | 2 +- .../core/src/client/webcomponents/index.ts | 7 +++++ .../json-render/JsonRender.stories.ts | 20 ++++++++----- .../json-render/components/Card.ts | 30 ++++++++++++++----- .../json-render/components/types.ts | 22 ++++++++++++-- .../webcomponents/json-render/registry.ts | 9 ++++++ .../client/webcomponents.snapshot.d.ts | 22 ++++++++++++++ 7 files changed, 95 insertions(+), 17 deletions(-) diff --git a/docs/kit/json-render.md b/docs/kit/json-render.md index 1c546c5c2..ab2ef3456 100644 --- a/docs/kit/json-render.md +++ b/docs/kit/json-render.md @@ -259,7 +259,7 @@ Container with an optional title and collapsible behavior. | Prop | Type | Default | Description | |------|------|---------|-------------| -| `title` | `string` | — | Header title | +| `title` | `string` | — | Header title. A collapsible card still shows its (empty) header without one — otherwise there'd be nothing to click | | `collapsible` | `boolean` | `false` | Whether the card can be collapsed | | `defaultCollapsed` | `boolean` | `false` | Start collapsed (when `collapsible`) | | `loading` | `boolean` | `false` | Show a loading state | diff --git a/packages/core/src/client/webcomponents/index.ts b/packages/core/src/client/webcomponents/index.ts index da4da9640..dff116c9f 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<'Card', CardProps>`). + */ +export type { CardProps, JsonRenderElement } 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..f75e618cf 100644 --- a/packages/core/src/client/webcomponents/json-render/JsonRender.stories.ts +++ b/packages/core/src/client/webcomponents/json-render/JsonRender.stories.ts @@ -82,29 +82,35 @@ export const Gallery: Story = { } /** - * A `Card` grouping content under a titled, bordered surface. Toggle the - * Controls below to compare today's default (`primary`, non-`interactive` — - * unchanged from before this fix) against the new opt-in look: `variant` + * A `Card` grouping content under a titled, bordered surface. `variant` * tints the background (`secondary`/`danger`) or leaves it untouched - * (`primary`/`ghost`); `interactive` strengthens the Card's border on hover - * and tints each row's (`Stack`) background on hover. + * (`primary`/`ghost`); `interactive` strengthens the border on hover and + * tints each row's (`Stack`) background. `collapsible` + `defaultCollapsed` + * control the initial collapse state; clear `title` to confirm the header + * (and its chevron) still renders without one. */ interface CardArgs { variant: 'primary' | 'secondary' | 'ghost' | 'danger' interactive: boolean + title: string + collapsible: boolean + defaultCollapsed: boolean } export const Card: StoryObj> = { argTypes: { variant: { control: 'select', options: ['primary', 'secondary', 'ghost', 'danger'] }, interactive: { control: 'boolean' }, + title: { control: 'text' }, + collapsible: { control: 'boolean' }, + defaultCollapsed: { control: 'boolean' }, }, - args: { variant: 'primary', interactive: false }, + args: { variant: 'primary', interactive: false, title: 'Plugin', collapsible: true, defaultCollapsed: false }, render: args => renderSpec(() => ({ root: 'root', state: {}, elements: { - root: { type: 'Card', props: { title: 'Plugin', collapsible: false, variant: args.variant, interactive: args.interactive }, children: ['body'] }, + root: { type: 'Card', props: { title: args.title, collapsible: args.collapsible, defaultCollapsed: args.defaultCollapsed, variant: args.variant, interactive: args.interactive }, children: ['body'] }, body: { type: 'Stack', props: { direction: 'column', gap: 4, padding: 4 }, children: ['row1', 'row2'] }, row1: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'center', interactive: args.interactive }, children: ['t', 'badge'] }, t: { type: 'Text', props: { text: 'vite-plugin-inspect', variant: 'code' } }, diff --git a/packages/core/src/client/webcomponents/json-render/components/Card.ts b/packages/core/src/client/webcomponents/json-render/components/Card.ts index aa6a092d7..45b5b0f16 100644 --- a/packages/core/src/client/webcomponents/json-render/components/Card.ts +++ b/packages/core/src/client/webcomponents/json-render/components/Card.ts @@ -1,11 +1,21 @@ -import type { RegistryComponentProps } from './types' import { defineComponent, h, ref } from 'vue' import { border, borderSolid, borderStrong, colors, surfaceMuted } from './tokens' +import { registryProps } from './types' + +export type CardVariant = 'primary' | 'secondary' | 'ghost' | 'danger' + +export interface CardProps { + title?: string + collapsible?: boolean + defaultCollapsed?: boolean + variant?: CardVariant + interactive?: boolean +} // Mirrors `ContainerCard.vue`'s (packages/ui) opt-in `variant` model: `primary` // (default) keeps today's fully transparent look, so existing specs render // unchanged; `ghost` is the same no-fill look under a more intentional name. -const variantBackground: Record = { +const variantBackground: Record = { primary: undefined, secondary: surfaceMuted, ghost: undefined, @@ -14,11 +24,16 @@ const variantBackground: Record = { export const Card = defineComponent({ name: 'JrCard', - props: ['element', 'emit', 'on', 'bindings', 'loading'], - setup(ctx: RegistryComponentProps, { slots }) { - const collapsed = ref(false) + props: registryProps<'Card', CardProps>(), + setup(ctx, { slots }) { + const collapsed = ref(!!ctx.element.props.defaultCollapsed) return () => { const { title, collapsible, variant = 'primary', interactive = false } = ctx.element.props + const toggle = ctx.on('toggle') + const setCollapsed = (next: boolean) => { + collapsed.value = next + toggle.emit() + } return h('div', { class: 'jr-card', style: { @@ -34,7 +49,8 @@ export const Card = defineComponent({ onMouseenter: interactive ? (e: MouseEvent) => { (e.currentTarget as HTMLElement).style.borderColor = borderStrong } : undefined, onMouseleave: interactive ? (e: MouseEvent) => { (e.currentTarget as HTMLElement).style.borderColor = border } : undefined, }, [ - title && h('div', { + // Renders without a `title` too — a collapsible card needs this as its click target. + (title || collapsible) && h('div', { class: 'jr-card-header', style: { padding: '8px 12px', @@ -47,7 +63,7 @@ export const Card = defineComponent({ borderBottom: collapsed.value ? 'none' : borderSolid(border), userSelect: 'none', }, - onClick: collapsible ? () => { collapsed.value = !collapsed.value } : undefined, + onClick: collapsible ? () => setCollapsed(!collapsed.value) : undefined, }, [ h('span', title), collapsible && h('span', { 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..1a0a007b0 100644 --- a/packages/core/src/client/webcomponents/json-render/registry.ts +++ b/packages/core/src/client/webcomponents/json-render/registry.ts @@ -22,6 +22,15 @@ import { UnsupportedComponent } from './components/UnsupportedComponent' */ export { 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 { CardProps } from './components/Card' +export type { UIElement as JsonRenderElement } from '@json-render/core' + export const devtoolsRegistry: Record = { Stack, Card, 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..bd685f647 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,28 @@ /** * Generated by tsnapi — public API snapshot of `@vitejs/devtools/client/webcomponents` */ +// #region Interfaces +export interface CardProps { + title?: string; + collapsible?: boolean; + defaultCollapsed?: boolean; + variant?: CardVariant; + interactive?: boolean; +} +export interface JsonRenderElement> { + type: T; + props: P; + children?: string[]; + visible?: VisibilityCondition; + on?: Record; + repeat?: { + statePath: string; + key?: string; + }; + watch?: Record; +} +// #endregion + // #region Functions export declare function createDockEntryState(_: DevToolsDockEntry, _: Ref): DockEntryState; export declare function DEFAULT_DOCK_PANEL_STORE(): DockPanelStorage;