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
48 changes: 48 additions & 0 deletions docs/kit/json-render.md
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,22 @@ Clickable button that triggers an action via the `press` event.
{ type: 'Button', props: { icon: 'ph:plus', variant: 'ghost' }, on: { press: { action: 'my-plugin:add' } } }
```

#### Link

Links to `http`, `https` and `mailto` targets — anything else falls back to rendering `label` as plain text.

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `href` | `string` | — | Link target |
| `label` | `string` | — | Link text (defaults to `href`) |
| `icon` | `string` | — | Iconify icon name |
| `external` | `boolean` | `true` for `http(s)` | Open in a new tab |

<!-- eslint-skip -->
```ts
{ type: 'Link', props: { href: 'https://vite.dev', label: 'Vite docs', icon: 'ph:arrow-square-out' } }
```

#### TextInput

Text input field with optional two-way state binding.
Expand All @@ -438,6 +454,38 @@ Text input field with optional two-way state binding.
}
```

#### Select

Dropdown choosing one value from a fixed set of options, with optional two-way state binding.

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `value` | `string` | — | Current value (use `$bindState` for two-way binding) |
| `options` | `(string \| { value, label?, icon?, description? })[]` | — | Available choices |
| `placeholder` | `string` | — | Shown while `value` is unset |
| `label` | `string` | — | Label shown above the select |
| `disabled` | `boolean` | `false` | Disable interaction |
| `searchable` | `boolean` | `false` | Add a substring filter box to the panel |

**Event**: `change` — fires when the selected value changes.

<!-- eslint-skip -->
```ts
{
type: 'Select',
props: {
label: 'Environment',
value: { $bindState: '/env' },
options: [
{ value: 'dev', label: 'Development' },
{ value: 'staging', label: 'Staging' },
{ value: 'prod', label: 'Production', description: 'Live traffic' },
],
},
on: { change: { action: 'my-plugin:switch-env' } },
}
```

See [State and Two-Way Binding](#state-and-two-way-binding) for a full example.

### Data display
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { defineComponent, h, onMounted, ref, shallowRef } from 'vue'
import { computed, defineComponent, h, onMounted, ref, shallowRef } from 'vue'
import FloatingPopover from './FloatingPopover'

// @unocss-include
Expand Down Expand Up @@ -58,6 +58,41 @@ export const MenuContent: Story = {
]), 'Reveal menu'),
}

/**
* A real toggle button drives the popover (rather than the mount-time
* harness the other stories use), to exercise `ignore` — clicking the
* trigger again while open must close it once, not close-then-reopen — and
* `panelClass`, which replaces the default tooltip padding.
*/
export const ToggleTrigger: Story = {
render: () => defineComponent({
setup() {
const triggerEl = ref<HTMLElement | null>(null)
const open = ref(false)
const item = computed(() => (open.value && triggerEl.value)
? { el: triggerEl.value, content: () => h('div', { class: 'flex flex-col gap-0.5 min-w-40' }, [
h('div', { class: 'px2 pt1 pb1.5 op60 text-2.75 uppercase tracking-wide font-medium' }, 'Menu'),
...['Overview', 'Pages', 'Components'].map(label =>
h('button', { class: 'px2 py1.5 rounded text-sm text-left op80 hover:op100 hover:bg-active transition' }, label)),
]) }
: null)
return () => h('div', { class: 'flex items-center justify-center p20 min-h-80 font-sans' }, [
h('button', {
ref: (el: any) => (triggerEl.value = el),
class: 'px3 py1.5 rounded border border-base bg-glass color-base shadow',
onClick: () => (open.value = !open.value),
}, 'Toggle menu'),
h(FloatingPopover, {
item: item.value,
panelClass: '!p0',
ignore: [triggerEl],
onDismiss: () => (open.value = false),
}),
])
},
}),
}

export const CornerAnchors: Story = {
render: () => defineComponent({
setup() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { MaybeElementRef } from '@vueuse/core'
import type { PropType, VNode } from 'vue'
import type { FloatingPopoverProps } from '../../state/floating-tooltip'
import { onClickOutside, useDebounceFn, useEventListener } from '@vueuse/core'
import { defineComponent, h, onMounted, onUpdated, reactive, ref, useTemplateRef, watch } from 'vue'
import { defineComponent, h, nextTick, onMounted, onUpdated, reactive, ref, useTemplateRef, watch } from 'vue'
import { resolveFloatingPosition } from './floating-position'

// @unocss-include
Expand All @@ -17,6 +18,16 @@ const FloatingPopoverComponent = defineComponent({
type: Boolean,
default: true,
},
/** Appended to the panel's class list — lets a consumer replace the default tooltip padding (e.g. a listbox). */
panelClass: {
type: [String, Array] as PropType<string | string[]>,
required: false,
},
/** Elements `dismissOnClickOutside` should not treat as "outside" — typically the trigger that toggles this popover. */
ignore: {
type: Array as PropType<MaybeElementRef[]>,
required: false,
},
},
emits: ['dismiss'],
setup(props, { emit }) {
Expand All @@ -25,6 +36,15 @@ const FloatingPopoverComponent = defineComponent({
const renderCounter = ref(0)

const panelSize = reactive({ width: 0, height: 0 })
// Before the first measurement, `resolveFloatingPosition` centers the panel
// under the anchor via `transform: translateX(-50%)` (it doesn't know the
// panel's real width yet); once measured, it switches to an absolute `left`
// with no transform. Both resolve to the same visual position, but
// transitioning `left` and `transform` independently between them produces
// a visible sideways wobble — so `measured` only flips (re-enabling the
// transition) a tick after `panelSize` updates, letting that one
// size-correcting render apply instantly rather than animate.
const measured = ref(false)

function measurePanel() {
if (!props.item || !panel.value)
Expand All @@ -34,6 +54,9 @@ const FloatingPopoverComponent = defineComponent({
panelSize.width = width
panelSize.height = height
}
nextTick(() => {
measured.value = true
})
}

onMounted(measurePanel)
Expand All @@ -44,18 +67,26 @@ const FloatingPopoverComponent = defineComponent({
renderCounter.value++
})

// The panel is `position: fixed` against a rect measured at render time, so
// scrolling any ancestor (not just the window) needs to trigger a re-measure.
useEventListener(window, 'scroll', () => {
if (el.value)
renderCounter.value++
}, { capture: true, passive: true })

const clearThrottled = useDebounceFn(() => {
if (props.item?.el == null) {
el.value = undefined
panelSize.width = 0
panelSize.height = 0
measured.value = false
}
}, 800)

if (props.dismissOnClickOutside) {
onClickOutside(panel, () => {
emit('dismiss')
})
}, { ignore: props.ignore })
}

watch(
Expand Down Expand Up @@ -84,6 +115,8 @@ const FloatingPopoverComponent = defineComponent({
if (!el.value)
return null

const transitionClass = measured.value ? 'transition-all duration-300' : 'transition-opacity duration-300'

// When dismissing (item is null), keep the last known position
// so the popover fades out in place instead of jumping
if (!props.item) {
Expand All @@ -92,8 +125,9 @@ const FloatingPopoverComponent = defineComponent({
{
ref: 'panel',
class: [
'fixed z-floating-tooltip text-xs transition-all duration-300 w-max bg-glass color-base border border-base rounded px2 p1',
`fixed z-floating-tooltip text-xs ${transitionClass} w-max bg-glass color-base border border-base rounded px2 p1`,
'op0 pointer-events-none',
props.panelClass,
],
style: previousStyle,
},
Expand Down Expand Up @@ -128,8 +162,9 @@ const FloatingPopoverComponent = defineComponent({
{
ref: 'panel',
class: [
'fixed z-floating-tooltip text-xs transition-all duration-300 w-max bg-glass color-base border border-base rounded px2 p1',
`fixed z-floating-tooltip text-xs ${transitionClass} w-max bg-glass color-base border border-base rounded px2 p1`,
props.item ? 'op100' : 'op0 pointer-events-none',
props.panelClass,
],
style,
},
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/client/webcomponents/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ export type {
IconProps,
JsonRenderElement,
KeyValueTableProps,
LinkProps,
ProgressProps,
SelectOption,
SelectProps,
StackProps,
SwitchProps,
TabDescriptor,
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`, `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.',
component: 'The json-render primitive registry (`Stack`, `Card`, `Tabs`, `Text`, `Badge`, `Button`, `Link`, `Icon`, `Divider`, `TextInput`, `Select`, `Switch`, `KeyValueTable`, `DataTable`, `CodeBlock`, `Progress`, `Tree`) rendered from a declarative spec — the same renderer plugins use to build panels without shipping Vue.',
},
},
},
Expand All @@ -48,7 +48,7 @@ export const Gallery: Story = {
root: 'root',
state: { notifications: true },
elements: {
root: { type: 'Stack', props: { direction: 'column', gap: 16, padding: 4 }, children: ['heading', 'badges', 'buttons', 'progress', 'toggle', 'divider', 'kv', 'table', 'code'] },
root: { type: 'Stack', props: { direction: 'column', gap: 16, padding: 4 }, children: ['heading', 'badges', 'buttons', 'progress', 'toggle', 'select', 'links', 'inputs', 'divider', 'kv', 'table', 'code'] },
heading: { type: 'Text', props: { text: 'Build summary', variant: 'heading' } },
badges: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'center' }, children: ['b1', 'b2', 'b3', 'b4'] },
b1: { type: 'Badge', props: { text: 'passing', variant: 'success' } },
Expand All @@ -63,6 +63,23 @@ export const Gallery: Story = {
btn4: { type: 'Button', props: { label: 'Deploying…', variant: 'primary', icon: 'ph:rocket-launch', loading: true } },
progress: { type: 'Progress', props: { value: 68, max: 100, label: 'Bundling' } },
toggle: { type: 'Switch', props: { label: 'Notifications', value: '{{notifications}}' } },
select: { type: 'Select', props: {
label: 'Environment',
placeholder: 'Choose one…',
value: 'staging',
options: [
{ value: 'dev', label: 'Development' },
{ value: 'staging', label: 'Staging' },
{ value: 'prod', label: 'Production', description: 'Live traffic', icon: 'ph:warning' },
],
} },
links: { type: 'Stack', props: { direction: 'row', gap: 16 }, children: ['link', 'rejectedLink'] },
link: { type: 'Link', props: { href: 'https://vite.dev', label: 'Vite docs', icon: 'ph:arrow-square-out' } },
/* `javascript:` is not in the allowed scheme list — this must render as plain text, never as an `<a>`. */
rejectedLink: { type: 'Link', props: { href: 'javascript:alert(1)', label: 'Rejected href (renders as text)' } },
inputs: { type: 'Stack', props: { direction: 'row', gap: 16 }, children: ['search', 'loadingInput'] },
search: { type: 'TextInput', props: { type: 'search', placeholder: 'Filter modules…' } },
loadingInput: { type: 'TextInput', props: { placeholder: 'Saving…', loading: true } },
divider: { type: 'Divider', props: { label: 'Details' } },
kv: { type: 'KeyValueTable', props: { data: {
Vite: '8.1.2',
Expand Down Expand Up @@ -184,6 +201,47 @@ export const Tabs: StoryObj<Meta<TabsArgs>> = {
} as unknown as Spec)),
}

/**
* A popover listbox (built on the shared `FloatingPopover` primitive) bound
* to `/region` — open it with a click or ArrowDown, move the highlight with
* Arrow/Home/End, commit with Enter, and Escape to close without changing
* the value. Toggle `searchable` to add a substring filter box to the panel.
*/
interface SelectArgs {
placeholder: string
disabled: boolean
searchable: boolean
}

export const Select: StoryObj<Meta<SelectArgs>> = {
argTypes: {
placeholder: { control: 'text' },
disabled: { control: 'boolean' },
searchable: { control: 'boolean' },
},
args: { placeholder: 'Choose a region…', disabled: false, searchable: true },
render: args => renderSpec(() => ({
root: 'root',
state: { region: undefined },
elements: {
root: { type: 'Select', props: {
label: 'Region',
placeholder: args.placeholder,
disabled: args.disabled,
searchable: args.searchable,
value: { $bindState: '/region' },
options: [
{ value: 'us-east-1', label: 'US East (N. Virginia)' },
{ value: 'us-west-2', label: 'US West (Oregon)' },
{ value: 'eu-west-1', label: 'Europe (Ireland)' },
{ value: 'eu-west-3', label: 'Europe (Paris)', description: 'Lowest latency from CDG' },
{ value: 'ap-southeast-1', label: 'Asia Pacific (Singapore)' },
],
} },
},
} 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { defineComponent, h } from 'vue'
import DockIcon from '../../components/dock/DockIcon.vue'
import { primary } from './tokens'
import { registryProps } from './types'

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

export interface LinkProps {
href?: string
label?: string
/** Iconify name, rendered before the label. */
icon?: string
/** Open in a new tab. Defaults to `true` for `http(s)` URLs. */
external?: boolean
}

/**
* Specs can come from a streamed/model-generated source (`@json-render/core`'s
* `compileSpecStream`), and the client can run embedded in a host page — so a
* `javascript:` href here would execute in that 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, location.href)
return ALLOWED_SCHEMES.has(url.protocol) ? href : undefined
}
catch {
return undefined
}
}

export const Link = defineComponent({
name: 'JrLink',
props: registryProps<'Link', LinkProps>(),
setup(ctx) {
return () => {
const { label, icon, external } = ctx.element.props
const href = resolveHref(ctx.element.props.href)
const content = [
icon ? h(DockIcon, { icon, class: 'w-3.5 h-3.5' }) : null,
h('span', label ?? href),
]

if (!href) {
return h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: '6px' } }, content)
}

const openInNewTab = external ?? href.startsWith('http')

return h('a', {
href,
target: openInNewTab ? '_blank' : undefined,
rel: openInNewTab ? 'noopener noreferrer' : undefined,
style: {
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
color: primary,
textDecoration: 'none',
},
onMouseenter: (e: MouseEvent) => { (e.currentTarget as HTMLElement).style.textDecoration = 'underline' },
onMouseleave: (e: MouseEvent) => { (e.currentTarget as HTMLElement).style.textDecoration = 'none' },
}, content)
}
},
})
Loading
Loading