Skip to content

Commit f75d5dc

Browse files
committed
feat(json-render-ui): implement Tabs, Link, Select renderers
Implements the three catalog components added in the previous commit, so the reference Vue renderer covers all seventeen. Link is a thin anchor (safe-scheme only) with a runtime Icon; Select wraps @antfu/design FormSelect (or FormCombobox when searchable); Tabs is a thin custom component (dynamic tab icons need the runtime Icon, which @antfu/design LayoutTabs' class-based icon can't do) with a stateful inner component so uncontrolled selection persists and a $bindState binding writes back to the state store. Registered in baseRegistry (now 17) and exported from ./components.
1 parent 1d4d2c5 commit f75d5dc

9 files changed

Lines changed: 302 additions & 1 deletion

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type { JrComponent } from './_shared'
2+
import { h } from 'vue'
3+
import { Icon } from './Icon'
4+
5+
interface LinkProps {
6+
href?: string
7+
label?: string
8+
/** Icon name resolved at runtime (e.g. `ph:arrow-square-out`), rendered before the label. */
9+
icon?: string
10+
/** Open in a new tab. Defaults to `true` for `http(s)` URLs. */
11+
external?: boolean
12+
}
13+
14+
const ALLOWED_SCHEMES = new Set(['http:', 'https:', 'mailto:'])
15+
16+
/**
17+
* Specs can come from a streamed/model-generated source, so a `javascript:`
18+
* href here would execute in the host page. Only resolve to an anchor for
19+
* schemes that can't run script.
20+
*/
21+
function resolveHref(href: string | undefined): string | undefined {
22+
if (!href)
23+
return undefined
24+
try {
25+
const url = new URL(href, typeof location === 'undefined' ? 'http://localhost' : location.href)
26+
return ALLOWED_SCHEMES.has(url.protocol) ? href : undefined
27+
}
28+
catch {
29+
return undefined
30+
}
31+
}
32+
33+
export const Link: JrComponent<LinkProps> = ({ props }) => {
34+
const href = resolveHref(props.href)
35+
const content = [
36+
props.icon ? Icon({ props: { name: props.icon, size: 14 } } as Parameters<typeof Icon>[0]) : null,
37+
h('span', props.label ?? href),
38+
]
39+
if (!href)
40+
return h('span', { class: 'inline-flex items-center gap-1.5' }, content)
41+
42+
const openInNewTab = props.external ?? href.startsWith('http')
43+
return h('a', {
44+
href,
45+
target: openInNewTab ? '_blank' : undefined,
46+
rel: openInNewTab ? 'noopener noreferrer' : undefined,
47+
class: 'inline-flex items-center gap-1.5 color-active hover:underline underline-offset-2',
48+
}, content)
49+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import type { PropType } from 'vue'
2+
import type { JrComponent } from './_shared'
3+
import FormCombobox from '@antfu/design/components/Form/FormCombobox.vue'
4+
import FormSelect from '@antfu/design/components/Form/FormSelect.vue'
5+
import { useBoundProp } from '@json-render/vue'
6+
import { computed, defineComponent, h, ref } from 'vue'
7+
8+
interface SelectOption {
9+
value: string
10+
label?: string
11+
/** Icon/description are accepted by the catalog but not rendered by the reference select. */
12+
icon?: string
13+
description?: string
14+
}
15+
16+
interface SelectProps {
17+
value?: string
18+
options?: (string | SelectOption)[]
19+
placeholder?: string
20+
label?: string
21+
disabled?: boolean
22+
/** Swap the plain select for a searchable combobox. */
23+
searchable?: boolean
24+
}
25+
26+
function normalize(option: string | SelectOption): { value: string, label?: string } {
27+
return typeof option === 'string' ? { value: option } : { value: option.value, label: option.label }
28+
}
29+
30+
// Stateful inner component: a JrComponent render fn can't hold a ref, so the
31+
// uncontrolled selection (no `$bindState` on `value`) lives here; when the spec
32+
// binds `value`, `bindingPath` is set and writes flow back to the state store.
33+
const SelectImpl = defineComponent({
34+
name: 'JrSelectImpl',
35+
props: {
36+
options: { type: Array as PropType<(string | SelectOption)[]>, default: () => [] },
37+
value: { type: String, default: undefined },
38+
placeholder: { type: String, default: undefined },
39+
label: { type: String, default: undefined },
40+
disabled: { type: Boolean, default: undefined },
41+
searchable: { type: Boolean, default: undefined },
42+
bindingPath: { type: String, default: undefined },
43+
onChange: { type: Function as PropType<() => void>, default: undefined },
44+
},
45+
setup(props) {
46+
// `props.value` is already the live resolved value (the provider re-renders
47+
// on store change); `useBoundProp` is used only for its store setter.
48+
const [, setBound] = useBoundProp<string>(props.value, props.bindingPath)
49+
const controlled = props.bindingPath != null
50+
const local = ref<string | undefined>(props.value)
51+
const model = computed(() => (controlled ? props.value : local.value))
52+
const setModel = (next: string | undefined) => {
53+
if (controlled)
54+
setBound(next as string)
55+
else local.value = next
56+
props.onChange?.()
57+
}
58+
const options = computed(() => props.options.map(normalize))
59+
return () => {
60+
const Comp = (props.searchable ? FormCombobox : FormSelect) as unknown as Parameters<typeof h>[0]
61+
const control = h(Comp, {
62+
'options': options.value,
63+
'placeholder': props.placeholder,
64+
'disabled': props.disabled,
65+
'modelValue': model.value,
66+
'onUpdate:modelValue': (next: string) => setModel(next),
67+
})
68+
if (props.label) {
69+
return h('div', { class: 'flex flex-col gap-1' }, [
70+
h('label', { class: 'text-sm font-medium' }, props.label),
71+
control,
72+
])
73+
}
74+
return control
75+
}
76+
},
77+
})
78+
79+
export const Select: JrComponent<SelectProps> = ({ props, on, bindings }) =>
80+
h(SelectImpl, {
81+
options: props.options ?? [],
82+
value: props.value,
83+
placeholder: props.placeholder,
84+
label: props.label,
85+
disabled: props.disabled,
86+
searchable: props.searchable,
87+
bindingPath: bindings?.value,
88+
onChange: () => on('change').emit(),
89+
})
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import type { PropType, VNode } from 'vue'
2+
import type { JrComponent } from './_shared'
3+
import { useBoundProp } from '@json-render/vue'
4+
import { computed, defineComponent, h, ref } from 'vue'
5+
import { Badge } from './Badge'
6+
import { Icon } from './Icon'
7+
8+
interface TabDescriptor {
9+
value: string
10+
label: string
11+
/** Icon name resolved at runtime (e.g. `ph:list`). */
12+
icon?: string
13+
badge?: string
14+
badgeVariant?: 'default' | 'info' | 'success' | 'warning' | 'danger'
15+
}
16+
17+
interface TabsProps {
18+
/** `children[i]` renders under `tabs[i]` — the two arrays are positional. */
19+
tabs?: TabDescriptor[]
20+
/** Two-way bindable via `{ $bindState: '...' }`; otherwise local, uncontrolled. */
21+
value?: string
22+
/** Seeds the uncontrolled case only. */
23+
defaultValue?: string
24+
orientation?: 'horizontal' | 'vertical'
25+
}
26+
27+
// `@antfu/design`'s LayoutTabs takes a static icon *class*, but tab icons here
28+
// are runtime-resolved *names* — so this is a thin custom component over the
29+
// shared semantic tokens (like Text/Stack), using the Icon component. Stateful
30+
// so the uncontrolled selection persists across renders (a JrComponent render
31+
// fn can't hold a ref); binds to the state store when `bindingPath` is set.
32+
const TabsImpl = defineComponent({
33+
name: 'JrTabsImpl',
34+
props: {
35+
tabs: { type: Array as PropType<TabDescriptor[]>, default: () => [] },
36+
value: { type: String, default: undefined },
37+
defaultValue: { type: String, default: undefined },
38+
orientation: { type: String as PropType<'horizontal' | 'vertical'>, default: 'horizontal' },
39+
bindingPath: { type: String, default: undefined },
40+
onChange: { type: Function as PropType<() => void>, default: undefined },
41+
},
42+
setup(props, { slots }) {
43+
// `props.value` is already the live bound value; `useBoundProp` is used
44+
// only for its store setter.
45+
const [, setBound] = useBoundProp<string>(props.value, props.bindingPath)
46+
const controlled = props.bindingPath != null
47+
const local = ref<string | undefined>(props.defaultValue ?? props.value ?? props.tabs[0]?.value)
48+
const active = computed(() => (controlled ? props.value : local.value))
49+
const isVertical = computed(() => props.orientation === 'vertical')
50+
51+
const setActive = (next: string) => {
52+
if (controlled)
53+
setBound(next)
54+
else local.value = next
55+
props.onChange?.()
56+
}
57+
58+
// Roving tabindex + arrow-key navigation per WAI-ARIA.
59+
const move = (fromIndex: number, delta: number, list: HTMLElement) => {
60+
const tabs = props.tabs
61+
if (tabs.length === 0)
62+
return
63+
const nextIndex = (fromIndex + delta + tabs.length) % tabs.length
64+
setActive(tabs[nextIndex]!.value)
65+
requestAnimationFrame(() => {
66+
(list.querySelectorAll<HTMLElement>('[role="tab"]')[nextIndex])?.focus()
67+
})
68+
}
69+
70+
return () => {
71+
const tabs = props.tabs
72+
const activeValue = active.value
73+
const panels = slots.default?.() ?? []
74+
const panelArr = (Array.isArray(panels) ? panels : [panels]) as VNode[]
75+
const activeIndex = tabs.findIndex(tab => tab.value === activeValue)
76+
77+
const triggers = tabs.map((tab, index) => {
78+
const isActive = tab.value === activeValue
79+
return h('button', {
80+
'type': 'button',
81+
'role': 'tab',
82+
'aria-selected': isActive ? 'true' : 'false',
83+
'tabindex': isActive ? '0' : '-1',
84+
'class': [
85+
'inline-flex items-center gap-1.5 px-3 py-2 text-sm whitespace-nowrap outline-none transition focus-visible:ring-2 focus-visible:ring-primary-500/40',
86+
isVertical.value ? 'border-r-2 -mr-px' : 'border-b-2 -mb-px',
87+
isActive
88+
? 'color-active border-primary-500 dark:border-primary-400 font-medium'
89+
: 'color-muted border-transparent hover:color-base',
90+
],
91+
'onClick': () => setActive(tab.value),
92+
'onKeydown': (e: KeyboardEvent) => {
93+
const list = (e.currentTarget as HTMLElement).parentElement
94+
if (!list)
95+
return
96+
const forward = isVertical.value ? 'ArrowDown' : 'ArrowRight'
97+
const backward = isVertical.value ? 'ArrowUp' : 'ArrowLeft'
98+
if (e.key === forward) {
99+
e.preventDefault()
100+
move(index, 1, list)
101+
}
102+
else if (e.key === backward) {
103+
e.preventDefault()
104+
move(index, -1, list)
105+
}
106+
else if (e.key === 'Home') {
107+
e.preventDefault()
108+
move(index, -index, list)
109+
}
110+
else if (e.key === 'End') {
111+
e.preventDefault()
112+
move(index, tabs.length - 1 - index, list)
113+
}
114+
},
115+
}, [
116+
tab.icon ? Icon({ props: { name: tab.icon, size: 14 } } as Parameters<typeof Icon>[0]) : null,
117+
h('span', tab.label),
118+
tab.badge ? Badge({ props: { text: tab.badge, variant: tab.badgeVariant ?? 'default' } } as Parameters<typeof Badge>[0]) : null,
119+
])
120+
})
121+
122+
return h('div', { class: isVertical.value ? 'flex gap-3' : 'flex flex-col gap-2' }, [
123+
h('div', {
124+
'role': 'tablist',
125+
'aria-orientation': props.orientation,
126+
'class': isVertical.value ? 'flex flex-col border-r border-base shrink-0' : 'flex border-b border-base',
127+
}, triggers),
128+
h('div', { role: 'tabpanel', class: 'flex-1 min-w-0' }, activeIndex >= 0 ? [panelArr[activeIndex]] : []),
129+
])
130+
}
131+
},
132+
})
133+
134+
export const Tabs: JrComponent<TabsProps> = ({ props, children, on, bindings }) =>
135+
h(TabsImpl, {
136+
tabs: props.tabs ?? [],
137+
value: props.value,
138+
defaultValue: props.defaultValue,
139+
orientation: props.orientation ?? 'horizontal',
140+
bindingPath: bindings?.value,
141+
onChange: () => on('change').emit(),
142+
}, () => children)

packages/json-render-ui/src/components/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@ export { DataTable } from './DataTable'
88
export { Divider } from './Divider'
99
export { Icon } from './Icon'
1010
export { KeyValueTable } from './KeyValueTable'
11+
export { Link } from './Link'
1112
export { Progress } from './Progress'
13+
export { Select } from './Select'
1214
export { Stack } from './Stack'
1315
export { Switch } from './Switch'
16+
export { Tabs } from './Tabs'
1417
export { Text } from './Text'
1518
export { TextInput } from './TextInput'
1619
export { Tree } from './Tree'

packages/json-render-ui/src/registry.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,12 @@ import {
1010
Divider,
1111
Icon,
1212
KeyValueTable,
13+
Link,
1314
Progress,
15+
Select,
1416
Stack,
1517
Switch,
18+
Tabs,
1619
Text,
1720
TextInput,
1821
Tree,
@@ -27,7 +30,7 @@ export const ERROR_COMPONENT_TYPE = '__jsonRenderError'
2730
export const UNSUPPORTED_COMPONENT_TYPE = '__jsonRenderUnsupported'
2831

2932
/**
30-
* The base Vue registry: the fourteen catalog-v1 components ported onto
33+
* The base Vue registry: the seventeen catalog-v1 components ported onto
3134
* `@antfu/design` semantic tokens, wrapped as Vue components via upstream
3235
* `defineRegistry`. A third party replaces the whole registry (there is no
3336
* incremental extension in v1).
@@ -48,6 +51,9 @@ export const baseRegistry: ComponentRegistry = defineRegistry(baseCatalog as any
4851
CodeBlock,
4952
Progress,
5053
Tree,
54+
Tabs,
55+
Link,
56+
Select,
5157
[ERROR_COMPONENT_TYPE]: JsonRenderError,
5258
[UNSUPPORTED_COMPONENT_TYPE]: JsonRenderUnsupported,
5359
} as any,

tests/__snapshots__/tsnapi/@devframes/json-render-ui/components.snapshot.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,12 @@ export { Divider }
1111
export { Icon }
1212
export { JrComponent }
1313
export { KeyValueTable }
14+
export { Link }
1415
export { Progress }
16+
export { Select }
1517
export { Stack }
1618
export { Switch }
19+
export { Tabs }
1720
export { Text }
1821
export { TextInput }
1922
export { Tree }

tests/__snapshots__/tsnapi/@devframes/json-render-ui/components.snapshot.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,12 @@ export { DataTable }
1010
export { Divider }
1111
export { Icon }
1212
export { KeyValueTable }
13+
export { Link }
1314
export { Progress }
15+
export { Select }
1416
export { Stack }
1517
export { Switch }
18+
export { Tabs }
1619
export { Text }
1720
export { TextInput }
1821
export { Tree }

tests/__snapshots__/tsnapi/@devframes/json-render-ui/index.snapshot.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,9 +130,12 @@ export declare const JsonRenderView: import("vue").DefineComponent<import("vue")
130130
connectionError: string | null;
131131
}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
132132
export declare const KeyValueTable: JrComponent<KeyValueTableProps>;
133+
export declare const Link: JrComponent<LinkProps>;
133134
export declare const Progress: JrComponent<ProgressProps>;
135+
export declare const Select: JrComponent<SelectProps>;
134136
export declare const Stack: JrComponent<StackProps>;
135137
export declare const Switch: JrComponent<SwitchProps>;
138+
export declare const Tabs: JrComponent<TabsProps>;
136139
export declare const Text: JrComponent<TextProps>;
137140
export declare const TextInput: JrComponent<TextInputProps>;
138141
export declare const Tree: JrComponent<TreeProps>;

tests/__snapshots__/tsnapi/@devframes/json-render-ui/index.snapshot.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,12 @@ export var ERROR_COMPONENT_TYPE /* const */
2020
export var Icon /* const */
2121
export var JsonRenderView /* const */
2222
export var KeyValueTable /* const */
23+
export var Link /* const */
2324
export var Progress /* const */
25+
export var Select /* const */
2426
export var Stack /* const */
2527
export var Switch /* const */
28+
export var Tabs /* const */
2629
export var Text /* const */
2730
export var TextInput /* const */
2831
export var Tree /* const */

0 commit comments

Comments
 (0)