Skip to content

Commit 0d8ee87

Browse files
authored
feat(core): Tabs component for json-render, with real per-component prop types (#492)
1 parent 6ccc1d7 commit 0d8ee87

6 files changed

Lines changed: 284 additions & 1 deletion

File tree

docs/kit/json-render.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,40 @@ Container with an optional title and collapsible behavior.
273273
}
274274
```
275275

276+
#### Tabs
277+
278+
Switches which of its `children` is shown, one tab per entry in `tabs` (positionally matched — `children[i]` renders when `tabs[i]` is active).
279+
280+
| Prop | Type | Default | Description |
281+
|------|------|---------|-------------|
282+
| `tabs` | `Array<{ value: string, label: string, icon?: string, badge?: string, badgeVariant?: 'default' \| 'info' \| 'success' \| 'warning' \| 'danger' }>` || The tab list |
283+
| `value` | `string` || Active tab's `value` (use `$bindState` for two-way binding) |
284+
| `defaultValue` | `string` | first tab | Initial active tab when `value` isn't bound |
285+
| `orientation` | `'horizontal' \| 'vertical'` | `'horizontal'` | Underlined top bar vs. a left-hand rail |
286+
287+
**Event**: `change` — fires when the active tab changes (including via arrow-key navigation).
288+
289+
<!-- eslint-skip -->
290+
```ts
291+
{
292+
root: 'root',
293+
elements: {
294+
root: {
295+
type: 'Tabs',
296+
props: {
297+
tabs: [
298+
{ value: 'mfe', label: 'Micro-Frontends', badge: '3', badgeVariant: 'success' },
299+
{ value: 'gateway', label: 'Gateway' },
300+
],
301+
},
302+
children: ['mfe-panel', 'gateway-panel'],
303+
},
304+
'mfe-panel': { type: 'Text', props: { text: '3 active overrides' } },
305+
'gateway-panel': { type: 'Text', props: { text: 'No overrides' } },
306+
},
307+
}
308+
```
309+
276310
#### Divider
277311

278312
Visual separator line with an optional label.

packages/core/src/client/webcomponents/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ export type {
1818
KeyValueTableProps,
1919
ProgressProps,
2020
SwitchProps,
21+
TabDescriptor,
22+
TabsProps,
2123
TextInputProps,
2224
TextProps,
2325
TreeProps,

packages/core/src/client/webcomponents/json-render/JsonRender.stories.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ const meta = {
3333
parameters: {
3434
docs: {
3535
description: {
36-
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.',
36+
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.',
3737
},
3838
},
3939
},
@@ -124,6 +124,62 @@ export const Card: StoryObj<Meta<CardArgs>> = {
124124
} as unknown as Spec)),
125125
}
126126

127+
/**
128+
* `Tabs` switches which of its `children` renders, positionally matched to
129+
* `tabs[]` — no `visible` plumbing needed in the spec. Each panel here is a
130+
* `Card` of `Stack` rows (the same composition the `Card` story above uses
131+
* standalone), showing that a tab panel is an ordinary element tree, not a
132+
* special slot. Uncontrolled (no `value` binding), so each tab click drives
133+
* Tabs' own local state; toggle `orientation` below to compare the
134+
* underlined horizontal bar against the left-rail vertical layout, and use
135+
* arrow keys / Home / End once a tab has focus to exercise the
136+
* roving-tabindex keyboard navigation.
137+
*/
138+
interface TabsArgs {
139+
orientation: 'horizontal' | 'vertical'
140+
}
141+
142+
export const Tabs: StoryObj<Meta<TabsArgs>> = {
143+
argTypes: {
144+
orientation: { control: 'select', options: ['horizontal', 'vertical'] },
145+
},
146+
args: { orientation: 'horizontal' },
147+
render: args => renderSpec(() => ({
148+
root: 'root',
149+
state: {},
150+
elements: {
151+
root: {
152+
type: 'Tabs',
153+
props: {
154+
orientation: args.orientation,
155+
tabs: [
156+
{ value: 'mfe', label: 'Micro-Frontends', badge: '2', badgeVariant: 'success' },
157+
{ value: 'shells', label: 'Shells' },
158+
{ value: 'gateway', label: 'Gateway', badge: '1', badgeVariant: 'danger' },
159+
],
160+
},
161+
children: ['mfeCard', 'shellsCard', 'gatewayCard'],
162+
},
163+
mfeCard: { type: 'Card', props: { title: 'Micro-Frontends', variant: 'secondary' }, children: ['mfeRows'] },
164+
mfeRows: { type: 'Stack', props: { direction: 'column', gap: 4, padding: 4 }, children: ['mfeRow1', 'mfeRow2'] },
165+
mfeRow1: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'center' }, children: ['mfeRow1Text', 'mfeRow1Badge'] },
166+
mfeRow1Text: { type: 'Text', props: { text: 'vite-plugin-inspect', variant: 'code' } },
167+
mfeRow1Badge: { type: 'Badge', props: { text: 'enabled', variant: 'success' } },
168+
mfeRow2: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'center' }, children: ['mfeRow2Text', 'mfeRow2Badge'] },
169+
mfeRow2Text: { type: 'Text', props: { text: 'vite-plugin-vue', variant: 'code' } },
170+
mfeRow2Badge: { type: 'Badge', props: { text: 'enabled', variant: 'success' } },
171+
172+
shellsCard: { type: 'Card', props: { title: 'Shells', variant: 'secondary' }, children: ['shellsBody'] },
173+
shellsBody: { type: 'Text', props: { text: 'No shells running locally.', variant: 'caption' } },
174+
175+
gatewayCard: { type: 'Card', props: { title: 'Gateway', variant: 'secondary' }, children: ['gatewayRow'] },
176+
gatewayRow: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'center', padding: 4 }, children: ['gatewayRowText', 'gatewayRowBadge'] },
177+
gatewayRowText: { type: 'Text', props: { text: 'gateway-web', variant: 'code' } },
178+
gatewayRowBadge: { type: 'Badge', props: { text: 'stale override', variant: 'danger' } },
179+
},
180+
} as unknown as Spec)),
181+
}
182+
127183
/**
128184
* An element whose `type` has no entry in the registry — e.g. authored
129185
* against a newer base-catalog version than this client implements, or a
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { useBoundProp } from '@json-render/vue'
2+
import { defineComponent, h, ref, watchEffect } from 'vue'
3+
import { getIconifySvg } from '../../utils/iconify'
4+
import { colors, primary, surfaceSubtle } from './tokens'
5+
import { registryProps } from './types'
6+
7+
export interface TabDescriptor {
8+
value: string
9+
label: string
10+
icon?: string
11+
badge?: string
12+
badgeVariant?: 'default' | 'info' | 'success' | 'warning' | 'danger'
13+
}
14+
15+
export interface TabsProps {
16+
/** `children[i]` renders when `tabs[i]` is active — the two arrays are positional. */
17+
tabs: TabDescriptor[]
18+
/** Two-way bindable via `{ $bindState: '...' }`; otherwise the tab switches local, uncontrolled state. */
19+
value?: string
20+
/** Seeds the uncontrolled case only — ignored once `value` is bound. */
21+
defaultValue?: string
22+
orientation?: 'horizontal' | 'vertical'
23+
}
24+
25+
export const Tabs = defineComponent({
26+
name: 'JrTabs',
27+
props: registryProps<'Tabs', TabsProps>(),
28+
setup(ctx, { slots }) {
29+
/** Local fallback for when `value` has no `$bindState` binding — `useBoundProp`'s setter is a no-op without one. */
30+
const uncontrolledValue = ref<string | undefined>(ctx.element.props.defaultValue ?? ctx.element.props.tabs?.[0]?.value)
31+
32+
/** Icon SVGs keyed by name, resolved like `Icon.ts` — one tab's icon changing shouldn't refetch the others. */
33+
const iconSvgs = ref<Record<string, string>>({})
34+
watchEffect(async () => {
35+
const names = (ctx.element.props.tabs ?? [])
36+
.map(tab => tab.icon)
37+
.filter((name): name is string => !!name && !(name in iconSvgs.value))
38+
for (const name of names) {
39+
const match = name.match(/^(?:i-)?([\w-]+):([\w-]+)$/)
40+
if (match?.[1] && match[2]) {
41+
const svg = await getIconifySvg(match[1], match[2])
42+
if (svg)
43+
iconSvgs.value = { ...iconSvgs.value, [name]: svg }
44+
}
45+
}
46+
})
47+
48+
return () => {
49+
const tabs: TabDescriptor[] = ctx.element.props.tabs ?? []
50+
const orientation: 'horizontal' | 'vertical' = ctx.element.props.orientation === 'vertical' ? 'vertical' : 'horizontal'
51+
const isVertical = orientation === 'vertical'
52+
53+
const [boundValue, setBoundValue] = useBoundProp<string>(ctx.element.props.value, ctx.bindings?.value)
54+
const controlled = ctx.bindings?.value != null
55+
const activeValue = controlled ? boundValue : uncontrolledValue.value
56+
const change = ctx.on('change')
57+
const setActive = (next: string) => {
58+
if (controlled)
59+
setBoundValue(next)
60+
else uncontrolledValue.value = next
61+
change.emit()
62+
}
63+
64+
/** Roving tabindex per WAI-ARIA — arrow keys move focus and selection together. */
65+
const move = (fromIndex: number, delta: number, container: HTMLElement) => {
66+
if (tabs.length === 0)
67+
return
68+
const nextIndex = (fromIndex + delta + tabs.length) % tabs.length
69+
const nextTab = tabs[nextIndex]!
70+
setActive(nextTab.value)
71+
requestAnimationFrame(() => {
72+
(container.querySelectorAll('[role="tab"]')[nextIndex] as HTMLElement | undefined)?.focus()
73+
})
74+
}
75+
76+
const tabButtons = tabs.map((tab, index) => {
77+
const active = tab.value === activeValue
78+
return h('button', {
79+
'type': 'button',
80+
'role': 'tab',
81+
'aria-selected': active ? 'true' : 'false',
82+
'tabindex': active ? '0' : '-1',
83+
'class': 'jr-tab',
84+
'style': {
85+
display: 'inline-flex',
86+
alignItems: 'center',
87+
gap: '6px',
88+
padding: '6px 10px',
89+
fontSize: '12px',
90+
fontWeight: active ? '600' : '400',
91+
color: active ? primary : 'inherit',
92+
background: 'none',
93+
border: 'none',
94+
borderBottom: !isVertical ? `2px solid ${active ? primary : 'transparent'}` : undefined,
95+
borderLeft: isVertical ? `2px solid ${active ? primary : 'transparent'}` : undefined,
96+
// Rounds the corners away from the active-tab indicator: top for the horizontal underline, right for the vertical rail.
97+
borderRadius: isVertical ? '0 4px 4px 0' : '4px 4px 0 0',
98+
cursor: 'pointer',
99+
whiteSpace: 'nowrap',
100+
transition: 'background-color 0.15s ease',
101+
},
102+
'onClick': () => setActive(tab.value),
103+
'onMouseenter': (e: MouseEvent) => {
104+
if (!active)
105+
(e.currentTarget as HTMLElement).style.backgroundColor = surfaceSubtle
106+
},
107+
'onMouseleave': (e: MouseEvent) => { (e.currentTarget as HTMLElement).style.backgroundColor = '' },
108+
'onKeydown': (e: KeyboardEvent) => {
109+
const container = (e.currentTarget as HTMLElement).parentElement
110+
if (!container)
111+
return
112+
const forward = isVertical ? 'ArrowDown' : 'ArrowRight'
113+
const backward = isVertical ? 'ArrowUp' : 'ArrowLeft'
114+
if (e.key === forward) {
115+
e.preventDefault()
116+
move(index, 1, container)
117+
}
118+
else if (e.key === backward) {
119+
e.preventDefault()
120+
move(index, -1, container)
121+
}
122+
else if (e.key === 'Home') {
123+
e.preventDefault()
124+
move(index, -index, container)
125+
}
126+
else if (e.key === 'End') {
127+
e.preventDefault()
128+
move(index, tabs.length - 1 - index, container)
129+
}
130+
},
131+
}, [
132+
tab.icon && h('span', {
133+
style: { display: 'inline-flex', width: '14px', height: '14px', lineHeight: '1' },
134+
innerHTML: iconSvgs.value[tab.icon] || '',
135+
}),
136+
h('span', tab.label),
137+
tab.badge && h('span', {
138+
class: `jr-badge jr-badge-${tab.badgeVariant ?? 'default'}`,
139+
style: {
140+
display: 'inline-block',
141+
padding: '1px 6px',
142+
borderRadius: '9px',
143+
fontSize: '10px',
144+
fontWeight: '500',
145+
backgroundColor: (colors[tab.badgeVariant ?? 'default'] ?? colors.default).bg,
146+
color: (colors[tab.badgeVariant ?? 'default'] ?? colors.default).fg,
147+
},
148+
}, tab.badge),
149+
])
150+
})
151+
152+
const panels = slots.default?.() ?? []
153+
const activeIndex = tabs.findIndex(tab => tab.value === activeValue)
154+
const activePanel = activeIndex >= 0 ? panels[activeIndex] : undefined
155+
156+
return h('div', {
157+
style: { display: 'flex', flexDirection: isVertical ? 'row' : 'column', gap: '8px' },
158+
}, [
159+
h('div', {
160+
'role': 'tablist',
161+
'aria-orientation': orientation,
162+
'style': {
163+
display: 'flex',
164+
flexDirection: isVertical ? 'column' : 'row',
165+
gap: '2px',
166+
borderBottom: !isVertical ? '1px solid var(--jr-border, rgba(128,128,128,0.2))' : undefined,
167+
borderRight: isVertical ? '1px solid var(--jr-border, rgba(128,128,128,0.2))' : undefined,
168+
flexShrink: '0',
169+
},
170+
}, tabButtons),
171+
h('div', { role: 'tabpanel', style: { flex: '1', minWidth: '0' } }, activePanel ? [activePanel] : []),
172+
])
173+
}
174+
},
175+
})

packages/core/src/client/webcomponents/json-render/registry.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { KeyValueTable } from './components/KeyValueTable'
1010
import { Progress } from './components/Progress'
1111
import { Stack } from './components/Stack'
1212
import { Switch } from './components/Switch'
13+
import { Tabs } from './components/Tabs'
1314
import { Text } from './components/Text'
1415
import { TextInput } from './components/TextInput'
1516
import { Tree } from './components/Tree'
@@ -38,6 +39,7 @@ export type { IconProps } from './components/Icon'
3839
export type { KeyValueTableProps } from './components/KeyValueTable'
3940
export type { ProgressProps } from './components/Progress'
4041
export type { SwitchProps } from './components/Switch'
42+
export type { TabDescriptor, TabsProps } from './components/Tabs'
4143
export type { TextProps } from './components/Text'
4244
export type { TextInputProps } from './components/TextInput'
4345
export type { TreeProps } from './components/Tree'
@@ -46,6 +48,7 @@ export type { UIElement as JsonRenderElement } from '@json-render/core'
4648
export const devtoolsRegistry: Record<string, Component> = {
4749
Stack,
4850
Card,
51+
Tabs,
4952
Text,
5053
Badge,
5154
Button,

test/__snapshots__/tsnapi/@vitejs/devtools/client/webcomponents.snapshot.d.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,19 @@ export interface SwitchProps {
7070
label?: string;
7171
disabled?: boolean;
7272
}
73+
export interface TabDescriptor {
74+
value: string;
75+
label: string;
76+
icon?: string;
77+
badge?: string;
78+
badgeVariant?: 'default' | 'info' | 'success' | 'warning' | 'danger';
79+
}
80+
export interface TabsProps {
81+
tabs: TabDescriptor[];
82+
value?: string;
83+
defaultValue?: string;
84+
orientation?: 'horizontal' | 'vertical';
85+
}
7386
export interface TextInputProps {
7487
value?: string;
7588
placeholder?: string;

0 commit comments

Comments
 (0)