diff --git a/packages/json-render-ui/src/components/Link.ts b/packages/json-render-ui/src/components/Link.ts new file mode 100644 index 00000000..5cc2ad39 --- /dev/null +++ b/packages/json-render-ui/src/components/Link.ts @@ -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 = ({ props }) => { + const href = resolveHref(props.href) + const content = [ + props.icon ? Icon({ props: { name: props.icon, size: 14 } } as Parameters[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) +} diff --git a/packages/json-render-ui/src/components/Select.ts b/packages/json-render-ui/src/components/Select.ts new file mode 100644 index 00000000..15bcb68b --- /dev/null +++ b/packages/json-render-ui/src/components/Select.ts @@ -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(props.value, props.bindingPath) + const controlled = props.bindingPath != null + const local = ref(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[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 = ({ 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(), + }) diff --git a/packages/json-render-ui/src/components/Tabs.ts b/packages/json-render-ui/src/components/Tabs.ts new file mode 100644 index 00000000..57c0c013 --- /dev/null +++ b/packages/json-render-ui/src/components/Tabs.ts @@ -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, 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(props.value, props.bindingPath) + const controlled = props.bindingPath != null + const local = ref(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('[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[0]) : null, + h('span', tab.label), + tab.badge ? Badge({ props: { text: tab.badge, variant: tab.badgeVariant ?? 'default' } } as Parameters[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 = ({ 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) diff --git a/packages/json-render-ui/src/components/index.ts b/packages/json-render-ui/src/components/index.ts index c9baa6bd..8adfc2d6 100644 --- a/packages/json-render-ui/src/components/index.ts +++ b/packages/json-render-ui/src/components/index.ts @@ -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' diff --git a/packages/json-render-ui/src/registry.ts b/packages/json-render-ui/src/registry.ts index c3ea3740..afe81ceb 100644 --- a/packages/json-render-ui/src/registry.ts +++ b/packages/json-render-ui/src/registry.ts @@ -10,9 +10,12 @@ import { Divider, Icon, KeyValueTable, + Link, Progress, + Select, Stack, Switch, + Tabs, Text, TextInput, Tree, @@ -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). @@ -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, diff --git a/tests/__snapshots__/tsnapi/@devframes/json-render-ui/components.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/json-render-ui/components.snapshot.d.ts index b994a352..ec96a0be 100644 --- a/tests/__snapshots__/tsnapi/@devframes/json-render-ui/components.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/json-render-ui/components.snapshot.d.ts @@ -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 } diff --git a/tests/__snapshots__/tsnapi/@devframes/json-render-ui/components.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/json-render-ui/components.snapshot.js index d5fac876..a2a52e9e 100644 --- a/tests/__snapshots__/tsnapi/@devframes/json-render-ui/components.snapshot.js +++ b/tests/__snapshots__/tsnapi/@devframes/json-render-ui/components.snapshot.js @@ -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 } diff --git a/tests/__snapshots__/tsnapi/@devframes/json-render-ui/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/json-render-ui/index.snapshot.d.ts index c8575e28..2ffe6e34 100644 --- a/tests/__snapshots__/tsnapi/@devframes/json-render-ui/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/json-render-ui/index.snapshot.d.ts @@ -130,9 +130,12 @@ export declare const JsonRenderView: import("vue").DefineComponent; export declare const KeyValueTable: JrComponent; +export declare const Link: JrComponent; export declare const Progress: JrComponent; +export declare const Select: JrComponent; export declare const Stack: JrComponent; export declare const Switch: JrComponent; +export declare const Tabs: JrComponent; export declare const Text: JrComponent; export declare const TextInput: JrComponent; export declare const Tree: JrComponent; diff --git a/tests/__snapshots__/tsnapi/@devframes/json-render-ui/index.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/json-render-ui/index.snapshot.js index 22909215..423bdf50 100644 --- a/tests/__snapshots__/tsnapi/@devframes/json-render-ui/index.snapshot.js +++ b/tests/__snapshots__/tsnapi/@devframes/json-render-ui/index.snapshot.js @@ -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 */