Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/kit/json-render.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,8 @@ Flex layout container. Arranges children vertically or horizontally.
| `wrap` | `boolean` | `false` | Allow children to wrap onto multiple lines |
| `flex` | `number \| string` | — | `flex` shorthand for the container |
| `padding` | `number` | — | Padding in pixels |
| `variant` | `'primary' \| 'secondary' \| 'ghost' \| 'danger' \| 'info' \| 'success' \| 'warning'` | `'primary'` | Background tint (`danger`/`info`/`success`/`warning` also draw a matching border) |
| `interactive` | `boolean` | `false` | Tint the background on hover — useful for clickable-looking rows |

<!-- eslint-skip -->
```ts
Expand Down Expand Up @@ -262,6 +264,8 @@ Container with an optional title and collapsible behavior.
| `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`) |
| `variant` | `'primary' \| 'secondary' \| 'ghost' \| 'danger' \| 'info' \| 'success' \| 'warning'` | `'primary'` | A light background tint on the body, a stronger same-hue tint on the header bar, and a matching border (`danger`/`info`/`success`/`warning`) |
| `interactive` | `boolean` | `false` | Brighten the border on hover |
| `loading` | `boolean` | `false` | Show a loading state |

<!-- eslint-skip -->
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/client/webcomponents/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type {
JsonRenderElement,
KeyValueTableProps,
ProgressProps,
StackProps,
SwitchProps,
TabDescriptor,
TabsProps,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,15 +84,18 @@ export const Gallery: Story = {
}

/**
* A `Card` grouping content under a titled, bordered surface. `variant`
* tints the background (`secondary`/`danger`) or leaves it untouched
* (`primary`/`ghost`); `interactive` strengthens the border on hover and
* tints each row's (`Stack`) background. `collapsible` + `defaultCollapsed`
* A `Card` grouping content under a titled, bordered surface. `secondary`
* tints the background with no border; `ghost` and `primary` are untinted.
* `info`/`success`/`warning`/`danger` each draw a light body tint, a
* stronger same-hue header bar, and a matching border. `interactive`
* strengthens the border on hover (same hue for the four semantic variants)
* 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'
variant: 'primary' | 'secondary' | 'ghost' | 'danger' | 'info' | 'success' | 'warning'
rowVariant: 'primary' | 'secondary' | 'ghost' | 'danger' | 'info' | 'success' | 'warning'
interactive: boolean
title: string
collapsible: boolean
Expand All @@ -101,23 +104,24 @@ interface CardArgs {

export const Card: StoryObj<Meta<CardArgs>> = {
argTypes: {
variant: { control: 'select', options: ['primary', 'secondary', 'ghost', 'danger'] },
variant: { control: 'select', options: ['primary', 'secondary', 'ghost', 'danger', 'info', 'success', 'warning'] },
rowVariant: { control: 'select', options: ['primary', 'secondary', 'ghost', 'danger', 'info', 'success', 'warning'] },
interactive: { control: 'boolean' },
title: { control: 'text' },
collapsible: { control: 'boolean' },
defaultCollapsed: { control: 'boolean' },
},
args: { variant: 'primary', interactive: false, title: 'Plugin', collapsible: true, defaultCollapsed: false },
args: { variant: 'primary', rowVariant: 'ghost', interactive: false, title: 'Plugin', collapsible: true, defaultCollapsed: false },
render: args => renderSpec(() => ({
root: 'root',
state: {},
elements: {
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'] },
row1: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'center', variant: args.rowVariant, interactive: args.interactive }, children: ['t', 'badge'] },
t: { type: 'Text', props: { text: 'vite-plugin-inspect', variant: 'code' } },
badge: { type: 'Badge', props: { text: 'enabled', variant: 'success' } },
row2: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'center', interactive: args.interactive }, children: ['t2', 'badge2'] },
row2: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'center', variant: args.rowVariant, interactive: args.interactive }, children: ['t2', 'badge2'] },
t2: { type: 'Text', props: { text: 'vite-plugin-vue', variant: 'code' } },
badge2: { type: 'Badge', props: { text: 'enabled', variant: 'success' } },
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,27 +1,15 @@
import { defineComponent, h, ref } from 'vue'
import { border, borderSolid, borderStrong, colors, surfaceMuted } from './tokens'
import { border, borderSolid, variantSurface } from './tokens'
import { registryProps } from './types'

export type CardVariant = 'primary' | 'secondary' | 'ghost' | 'danger'

export interface CardProps {
title?: string
collapsible?: boolean
defaultCollapsed?: boolean
variant?: CardVariant
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'info' | 'success' | 'warning'
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<CardVariant, string | undefined> = {
primary: undefined,
secondary: surfaceMuted,
ghost: undefined,
danger: colors.danger.bg,
}

export const Card = defineComponent({
name: 'JrCard',
props: registryProps<'Card', CardProps>(),
Expand All @@ -34,20 +22,22 @@ export const Card = defineComponent({
collapsed.value = next
toggle.emit()
}
const surface = variantSurface(variant)
const borderColor = surface.border ?? border
return h('div', {
class: 'jr-card',
style: {
border: borderSolid(border),
border: borderSolid(borderColor),
borderRadius: '6px',
overflow: 'hidden',
backgroundColor: variantBackground[variant],
backgroundColor: surface.background,
transition: interactive ? 'border-color 0.15s ease' : undefined,
},
// `interactive` strengthens the border on hover rather than tinting
// the background (already set by `variant`) — same transition timing
// as Stack's row hover, just on `border-color` instead of `background`.
onMouseenter: interactive ? (e: MouseEvent) => { (e.currentTarget as HTMLElement).style.borderColor = borderStrong } : undefined,
onMouseleave: interactive ? (e: MouseEvent) => { (e.currentTarget as HTMLElement).style.borderColor = border } : undefined,
onMouseenter: interactive ? (e: MouseEvent) => { (e.currentTarget as HTMLElement).style.borderColor = surface.hoverBorder ?? borderColor } : undefined,
onMouseleave: interactive ? (e: MouseEvent) => { (e.currentTarget as HTMLElement).style.borderColor = borderColor } : undefined,
}, [
// Renders without a `title` too — a collapsible card needs this as its click target.
(title || collapsible) && h('div', {
Expand All @@ -60,7 +50,8 @@ export const Card = defineComponent({
alignItems: 'center',
justifyContent: 'space-between',
cursor: collapsible ? 'pointer' : undefined,
borderBottom: collapsed.value ? 'none' : borderSolid(border),
borderBottom: collapsed.value ? 'none' : borderSolid(borderColor),
backgroundColor: surface.headerBackground,
userSelect: 'none',
},
onClick: collapsible ? () => setCollapsed(!collapsed.value) : undefined,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,29 +1,32 @@
import type { RegistryComponentProps } from './types'
import { defineComponent, h } from 'vue'
import { colors, surfaceMuted, surfaceSubtle } from './tokens'
import { borderSolid, variantSurface } from './tokens'
import { registryProps } from './types'

export interface StackProps {
direction?: 'row' | 'column'
gap?: number
align?: 'start' | 'center' | 'end' | 'stretch'
justify?: 'start' | 'center' | 'end' | 'between' | 'around'
padding?: number
wrap?: boolean
flex?: number | string
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'info' | 'success' | 'warning'
interactive?: boolean
}

// Map the base catalog's `align` / `justify` enums onto CSS flexbox values.
const alignMap: Record<string, string> = { start: 'flex-start', center: 'center', end: 'flex-end', stretch: 'stretch' }
const justifyMap: Record<string, string> = { start: 'flex-start', center: 'center', end: 'flex-end', between: 'space-between', around: 'space-around' }

// 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<string, string | undefined> = {
primary: undefined,
secondary: surfaceMuted,
ghost: undefined,
danger: colors.danger.bg,
}

export const Stack = defineComponent({
name: 'JrStack',
props: ['element', 'emit', 'on', 'bindings', 'loading'],
setup(ctx: RegistryComponentProps, { slots }) {
props: registryProps<'Stack', StackProps>(),
setup(ctx, { slots }) {
return () => {
const { direction = 'column', gap = 8, align, justify, padding, wrap, flex, variant = 'primary', interactive = false } = ctx.element.props
const isHorizontal = direction === 'row'
const restingBackground = variantBackground[variant]
const surface = variantSurface(variant)
const restingBackground = surface.background
return h('div', {
class: 'jr-stack',
style: {
Expand All @@ -40,13 +43,15 @@ export const Stack = defineComponent({
// Matches Card's own borderRadius so a row's hover tint reads
// consistently with the card surfaces it sits inside.
borderRadius: interactive ? '6px' : undefined,
transition: interactive ? 'background-color 0.15s ease' : undefined,
// Only the four semantic variants draw a border; primary/secondary/ghost stay borderless.
border: surface.border ? borderSolid(surface.border) : undefined,
transition: interactive ? 'background-color 0.15s ease, border-color 0.15s ease' : undefined,
flex: flex != null ? String(flex) : undefined,
backgroundColor: restingBackground,
},
// `interactive` only ever tints on hover — Stack has no click/press
// semantics of its own, so rows built from it stay non-clickable.
onMouseenter: interactive ? (e: MouseEvent) => { (e.currentTarget as HTMLElement).style.backgroundColor = surfaceSubtle } : undefined,
onMouseenter: interactive ? (e: MouseEvent) => { (e.currentTarget as HTMLElement).style.backgroundColor = surface.hoverBackground ?? '' } : undefined,
onMouseleave: interactive ? (e: MouseEvent) => { (e.currentTarget as HTMLElement).style.backgroundColor = restingBackground ?? '' } : undefined,
}, slots.default?.())
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const bg = 'var(--jr-bg, inherit)'

// --- Semantic palette ---

/** Badge's pill fill. Card/Stack use their own lighter tints below — a badge-strength fill would read as a heavy block over a whole card. */
export const colors = {
info: { bg: 'rgba(59,130,246,0.15)', fg: 'rgb(59,130,246)' },
success: { bg: 'rgba(34,197,94,0.15)', fg: 'rgb(34,197,94)' },
Expand All @@ -41,3 +42,36 @@ export const syntaxNumber = '#dcdcaa'
export function borderSolid(token = border) {
return `1px solid ${token}`
}

/** Resting/hover background + border a `variant` resolves to on Card/Stack. */
export interface VariantSurface {
background?: string
/** Card-only — its header bar reads a step stronger than the body it sits above, so a titled card is a two-tone surface rather than one flat tint. */
headerBackground?: string
border?: string
hoverBackground?: string
hoverBorder?: string
}

/** No semantic hue — border stays unset, hover stays a neutral grey. */
const neutralSurfaces: Record<'primary' | 'secondary' | 'ghost', VariantSurface> = {
primary: { hoverBackground: surfaceSubtle, hoverBorder: borderStrong },
secondary: { background: surfaceMuted, hoverBackground: surfaceSubtle, hoverBorder: borderStrong },
ghost: { hoverBackground: surfaceSubtle, hoverBorder: borderStrong },
}

const semanticSurfaces: Record<'info' | 'success' | 'warning' | 'danger', Required<Pick<VariantSurface, 'background' | 'headerBackground' | 'border' | 'hoverBackground' | 'hoverBorder'>>> = {
info: { background: 'rgba(59,130,246,0.08)', headerBackground: 'rgba(59,130,246,0.18)', border: 'rgba(59,130,246,0.25)', hoverBackground: 'rgba(59,130,246,0.25)', hoverBorder: 'rgba(59,130,246,0.6)' },
success: { background: 'rgba(34,197,94,0.08)', headerBackground: 'rgba(34,197,94,0.18)', border: 'rgba(34,197,94,0.25)', hoverBackground: 'rgba(34,197,94,0.25)', hoverBorder: 'rgba(34,197,94,0.6)' },
warning: { background: 'rgba(234,179,8,0.08)', headerBackground: 'rgba(234,179,8,0.18)', border: 'rgba(234,179,8,0.25)', hoverBackground: 'rgba(234,179,8,0.25)', hoverBorder: 'rgba(234,179,8,0.6)' },
danger: { background: 'rgba(239,68,68,0.08)', headerBackground: 'rgba(239,68,68,0.18)', border: 'rgba(239,68,68,0.25)', hoverBackground: 'rgba(239,68,68,0.25)', hoverBorder: 'rgba(239,68,68,0.6)' },
}

/** Resolves a `variant` prop to the surface Card and Stack render at rest and on hover. */
export function variantSurface(variant: string): VariantSurface {
if (variant in neutralSurfaces)
return neutralSurfaces[variant as keyof typeof neutralSurfaces]
if (variant in semanticSurfaces)
return semanticSurfaces[variant as keyof typeof semanticSurfaces]
return neutralSurfaces.primary
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export type { DividerProps } from './components/Divider'
export type { IconProps } from './components/Icon'
export type { KeyValueTableProps } from './components/KeyValueTable'
export type { ProgressProps } from './components/Progress'
export type { StackProps } from './components/Stack'
export type { SwitchProps } from './components/Switch'
export type { TabDescriptor, TabsProps } from './components/Tabs'
export type { TextProps } from './components/Text'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export interface CardProps {
title?: string;
collapsible?: boolean;
defaultCollapsed?: boolean;
variant?: CardVariant;
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'info' | 'success' | 'warning';
interactive?: boolean;
}
export interface CodeBlockProps {
Expand Down Expand Up @@ -65,6 +65,17 @@ export interface ProgressProps {
max?: number;
label?: string;
}
export interface StackProps {
direction?: 'row' | 'column';
gap?: number;
align?: 'start' | 'center' | 'end' | 'stretch';
justify?: 'start' | 'center' | 'end' | 'between' | 'around';
padding?: number;
wrap?: boolean;
flex?: number | string;
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'info' | 'success' | 'warning';
interactive?: boolean;
}
export interface SwitchProps {
value?: boolean;
label?: string;
Expand Down
Loading