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
34 changes: 34 additions & 0 deletions docs/kit/json-render.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

<!-- eslint-skip -->
```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.
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/client/webcomponents/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export type {
KeyValueTableProps,
ProgressProps,
SwitchProps,
TabDescriptor,
TabsProps,
TextInputProps,
TextProps,
TreeProps,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
},
},
},
Expand Down Expand Up @@ -116,6 +116,62 @@ export const Card: StoryObj<Meta<CardArgs>> = {
} 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<Meta<TabsArgs>> = {
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
Expand Down
175 changes: 175 additions & 0 deletions packages/core/src/client/webcomponents/json-render/components/Tabs.ts
Original file line number Diff line number Diff line change
@@ -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<string | undefined>(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<Record<string, string>>({})
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<string>(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] : []),
])
}
},
})
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ 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'
Expand Down Expand Up @@ -37,6 +38,7 @@ export type { IconProps } from './components/Icon'
export type { KeyValueTableProps } from './components/KeyValueTable'
export type { ProgressProps } from './components/Progress'
export type { SwitchProps } from './components/Switch'
export type { TabDescriptor, TabsProps } from './components/Tabs'
export type { TextProps } from './components/Text'
export type { TextInputProps } from './components/TextInput'
export type { TreeProps } from './components/Tree'
Expand All @@ -45,6 +47,7 @@ export type { UIElement as JsonRenderElement } from '@json-render/core'
export const devtoolsRegistry: Record<string, Component> = {
Stack,
Card,
Tabs,
Text,
Badge,
Button,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,19 @@ export interface SwitchProps {
label?: string;
disabled?: boolean;
}
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';
}
export interface TextInputProps {
value?: string;
placeholder?: string;
Expand Down
Loading