Skip to content
Merged
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
49 changes: 49 additions & 0 deletions packages/json-render-ui/src/components/Link.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { JrComponent } from './_shared'
import { h } from 'vue'
import { Icon } from './Icon'

interface LinkProps {
href?: string
label?: string
/** Icon name resolved at runtime (e.g. `ph:arrow-square-out`), rendered before the label. */
icon?: string
/** Open in a new tab. Defaults to `true` for `http(s)` URLs. */
external?: boolean
}

const ALLOWED_SCHEMES = new Set(['http:', 'https:', 'mailto:'])

/**
* Specs can come from a streamed/model-generated source, so a `javascript:`
* href here would execute in the host page. Only resolve to an anchor for
* schemes that can't run script.
*/
function resolveHref(href: string | undefined): string | undefined {
if (!href)
return undefined
try {
const url = new URL(href, typeof location === 'undefined' ? 'http://localhost' : location.href)
return ALLOWED_SCHEMES.has(url.protocol) ? href : undefined
}
catch {
return undefined
}
}

export const Link: JrComponent<LinkProps> = ({ props }) => {
const href = resolveHref(props.href)
const content = [
props.icon ? Icon({ props: { name: props.icon, size: 14 } } as Parameters<typeof Icon>[0]) : null,
h('span', props.label ?? href),
]
if (!href)
return h('span', { class: 'inline-flex items-center gap-1.5' }, content)

const openInNewTab = props.external ?? href.startsWith('http')
return h('a', {
href,
target: openInNewTab ? '_blank' : undefined,
rel: openInNewTab ? 'noopener noreferrer' : undefined,
class: 'inline-flex items-center gap-1.5 color-active hover:underline underline-offset-2',
}, content)
}
89 changes: 89 additions & 0 deletions packages/json-render-ui/src/components/Select.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type { PropType } from 'vue'
import type { JrComponent } from './_shared'
import FormCombobox from '@antfu/design/components/Form/FormCombobox.vue'
import FormSelect from '@antfu/design/components/Form/FormSelect.vue'
import { useBoundProp } from '@json-render/vue'
import { computed, defineComponent, h, ref } from 'vue'

interface SelectOption {
value: string
label?: string
/** Icon/description are accepted by the catalog but not rendered by the reference select. */
icon?: string
description?: string
}

interface SelectProps {
value?: string
options?: (string | SelectOption)[]
placeholder?: string
label?: string
disabled?: boolean
/** Swap the plain select for a searchable combobox. */
searchable?: boolean
}

function normalize(option: string | SelectOption): { value: string, label?: string } {
return typeof option === 'string' ? { value: option } : { value: option.value, label: option.label }
}

// Stateful inner component: a JrComponent render fn can't hold a ref, so the
// uncontrolled selection (no `$bindState` on `value`) lives here; when the spec
// binds `value`, `bindingPath` is set and writes flow back to the state store.
const SelectImpl = defineComponent({
name: 'JrSelectImpl',
props: {
options: { type: Array as PropType<(string | SelectOption)[]>, default: () => [] },
value: { type: String, default: undefined },
placeholder: { type: String, default: undefined },
label: { type: String, default: undefined },
disabled: { type: Boolean, default: undefined },
searchable: { type: Boolean, default: undefined },
bindingPath: { type: String, default: undefined },
onChange: { type: Function as PropType<() => void>, default: undefined },
},
setup(props) {
// `props.value` is already the live resolved value (the provider re-renders
// on store change); `useBoundProp` is used only for its store setter.
const [, setBound] = useBoundProp<string>(props.value, props.bindingPath)
const controlled = props.bindingPath != null
const local = ref<string | undefined>(props.value)
const model = computed(() => (controlled ? props.value : local.value))
const setModel = (next: string | undefined) => {
if (controlled)
setBound(next as string)
else local.value = next
props.onChange?.()
}
const options = computed(() => props.options.map(normalize))
return () => {
const Comp = (props.searchable ? FormCombobox : FormSelect) as unknown as Parameters<typeof h>[0]
const control = h(Comp, {
'options': options.value,
'placeholder': props.placeholder,
'disabled': props.disabled,
'modelValue': model.value,
'onUpdate:modelValue': (next: string) => setModel(next),
})
if (props.label) {
return h('div', { class: 'flex flex-col gap-1' }, [
h('label', { class: 'text-sm font-medium' }, props.label),
control,
])
}
return control
}
},
})

export const Select: JrComponent<SelectProps> = ({ props, on, bindings }) =>
h(SelectImpl, {
options: props.options ?? [],
value: props.value,
placeholder: props.placeholder,
label: props.label,
disabled: props.disabled,
searchable: props.searchable,
bindingPath: bindings?.value,
onChange: () => on('change').emit(),
})
142 changes: 142 additions & 0 deletions packages/json-render-ui/src/components/Tabs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import type { PropType, VNode } from 'vue'
import type { JrComponent } from './_shared'
import { useBoundProp } from '@json-render/vue'
import { computed, defineComponent, h, ref } from 'vue'
import { Badge } from './Badge'
import { Icon } from './Icon'

interface TabDescriptor {
value: string
label: string
/** Icon name resolved at runtime (e.g. `ph:list`). */
icon?: string
badge?: string
badgeVariant?: 'default' | 'info' | 'success' | 'warning' | 'danger'
}

interface TabsProps {
/** `children[i]` renders under `tabs[i]` — the two arrays are positional. */
tabs?: TabDescriptor[]
/** Two-way bindable via `{ $bindState: '...' }`; otherwise local, uncontrolled. */
value?: string
/** Seeds the uncontrolled case only. */
defaultValue?: string
orientation?: 'horizontal' | 'vertical'
}

// `@antfu/design`'s LayoutTabs takes a static icon *class*, but tab icons here
// are runtime-resolved *names* — so this is a thin custom component over the
// shared semantic tokens (like Text/Stack), using the Icon component. Stateful
// so the uncontrolled selection persists across renders (a JrComponent render
// fn can't hold a ref); binds to the state store when `bindingPath` is set.
const TabsImpl = defineComponent({
name: 'JrTabsImpl',
props: {
tabs: { type: Array as PropType<TabDescriptor[]>, default: () => [] },
value: { type: String, default: undefined },
defaultValue: { type: String, default: undefined },
orientation: { type: String as PropType<'horizontal' | 'vertical'>, default: 'horizontal' },
bindingPath: { type: String, default: undefined },
onChange: { type: Function as PropType<() => void>, default: undefined },
},
setup(props, { slots }) {
// `props.value` is already the live bound value; `useBoundProp` is used
// only for its store setter.
const [, setBound] = useBoundProp<string>(props.value, props.bindingPath)
const controlled = props.bindingPath != null
const local = ref<string | undefined>(props.defaultValue ?? props.value ?? props.tabs[0]?.value)
const active = computed(() => (controlled ? props.value : local.value))
const isVertical = computed(() => props.orientation === 'vertical')

const setActive = (next: string) => {
if (controlled)
setBound(next)
else local.value = next
props.onChange?.()
}

// Roving tabindex + arrow-key navigation per WAI-ARIA.
const move = (fromIndex: number, delta: number, list: HTMLElement) => {
const tabs = props.tabs
if (tabs.length === 0)
return
const nextIndex = (fromIndex + delta + tabs.length) % tabs.length
setActive(tabs[nextIndex]!.value)
requestAnimationFrame(() => {
(list.querySelectorAll<HTMLElement>('[role="tab"]')[nextIndex])?.focus()
})
}

return () => {
const tabs = props.tabs
const activeValue = active.value
const panels = slots.default?.() ?? []
const panelArr = (Array.isArray(panels) ? panels : [panels]) as VNode[]
const activeIndex = tabs.findIndex(tab => tab.value === activeValue)

const triggers = tabs.map((tab, index) => {
const isActive = tab.value === activeValue
return h('button', {
'type': 'button',
'role': 'tab',
'aria-selected': isActive ? 'true' : 'false',
'tabindex': isActive ? '0' : '-1',
'class': [
'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',
isVertical.value ? 'border-r-2 -mr-px' : 'border-b-2 -mb-px',
isActive
? 'color-active border-primary-500 dark:border-primary-400 font-medium'
: 'color-muted border-transparent hover:color-base',
],
'onClick': () => setActive(tab.value),
'onKeydown': (e: KeyboardEvent) => {
const list = (e.currentTarget as HTMLElement).parentElement
if (!list)
return
const forward = isVertical.value ? 'ArrowDown' : 'ArrowRight'
const backward = isVertical.value ? 'ArrowUp' : 'ArrowLeft'
if (e.key === forward) {
e.preventDefault()
move(index, 1, list)
}
else if (e.key === backward) {
e.preventDefault()
move(index, -1, list)
}
else if (e.key === 'Home') {
e.preventDefault()
move(index, -index, list)
}
else if (e.key === 'End') {
e.preventDefault()
move(index, tabs.length - 1 - index, list)
}
},
}, [
tab.icon ? Icon({ props: { name: tab.icon, size: 14 } } as Parameters<typeof Icon>[0]) : null,
h('span', tab.label),
tab.badge ? Badge({ props: { text: tab.badge, variant: tab.badgeVariant ?? 'default' } } as Parameters<typeof Badge>[0]) : null,
])
})

return h('div', { class: isVertical.value ? 'flex gap-3' : 'flex flex-col gap-2' }, [
h('div', {
'role': 'tablist',
'aria-orientation': props.orientation,
'class': isVertical.value ? 'flex flex-col border-r border-base shrink-0' : 'flex border-b border-base',
}, triggers),
h('div', { role: 'tabpanel', class: 'flex-1 min-w-0' }, activeIndex >= 0 ? [panelArr[activeIndex]] : []),
])
}
},
})

export const Tabs: JrComponent<TabsProps> = ({ props, children, on, bindings }) =>
h(TabsImpl, {
tabs: props.tabs ?? [],
value: props.value,
defaultValue: props.defaultValue,
orientation: props.orientation ?? 'horizontal',
bindingPath: bindings?.value,
onChange: () => on('change').emit(),
}, () => children)
3 changes: 3 additions & 0 deletions packages/json-render-ui/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@ export { DataTable } from './DataTable'
export { Divider } from './Divider'
export { Icon } from './Icon'
export { KeyValueTable } from './KeyValueTable'
export { Link } from './Link'
export { Progress } from './Progress'
export { Select } from './Select'
export { Stack } from './Stack'
export { Switch } from './Switch'
export { Tabs } from './Tabs'
export { Text } from './Text'
export { TextInput } from './TextInput'
export { Tree } from './Tree'
8 changes: 7 additions & 1 deletion packages/json-render-ui/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@ import {
Divider,
Icon,
KeyValueTable,
Link,
Progress,
Select,
Stack,
Switch,
Tabs,
Text,
TextInput,
Tree,
Expand All @@ -27,7 +30,7 @@ export const ERROR_COMPONENT_TYPE = '__jsonRenderError'
export const UNSUPPORTED_COMPONENT_TYPE = '__jsonRenderUnsupported'

/**
* The base Vue registry: the fourteen catalog-v1 components ported onto
* The base Vue registry: the seventeen catalog-v1 components ported onto
* `@antfu/design` semantic tokens, wrapped as Vue components via upstream
* `defineRegistry`. A third party replaces the whole registry (there is no
* incremental extension in v1).
Expand All @@ -48,6 +51,9 @@ export const baseRegistry: ComponentRegistry = defineRegistry(baseCatalog as any
CodeBlock,
Progress,
Tree,
Tabs,
Link,
Select,
[ERROR_COMPONENT_TYPE]: JsonRenderError,
[UNSUPPORTED_COMPONENT_TYPE]: JsonRenderUnsupported,
} as any,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@ export { Divider }
export { Icon }
export { JrComponent }
export { KeyValueTable }
export { Link }
export { Progress }
export { Select }
export { Stack }
export { Switch }
export { Tabs }
export { Text }
export { TextInput }
export { Tree }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@ export { DataTable }
export { Divider }
export { Icon }
export { KeyValueTable }
export { Link }
export { Progress }
export { Select }
export { Stack }
export { Switch }
export { Tabs }
export { Text }
export { TextInput }
export { Tree }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,12 @@ export declare const JsonRenderView: import("vue").DefineComponent<import("vue")
connectionError: string | null;
}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
export declare const KeyValueTable: JrComponent<KeyValueTableProps>;
export declare const Link: JrComponent<LinkProps>;
export declare const Progress: JrComponent<ProgressProps>;
export declare const Select: JrComponent<SelectProps>;
export declare const Stack: JrComponent<StackProps>;
export declare const Switch: JrComponent<SwitchProps>;
export declare const Tabs: JrComponent<TabsProps>;
export declare const Text: JrComponent<TextProps>;
export declare const TextInput: JrComponent<TextInputProps>;
export declare const Tree: JrComponent<TreeProps>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,12 @@ export var ERROR_COMPONENT_TYPE /* const */
export var Icon /* const */
export var JsonRenderView /* const */
export var KeyValueTable /* const */
export var Link /* const */
export var Progress /* const */
export var Select /* const */
export var Stack /* const */
export var Switch /* const */
export var Tabs /* const */
export var Text /* const */
export var TextInput /* const */
export var Tree /* const */
Expand Down
Loading