From 3a1d0005d2500cb700b156091e71fcf5510c4fb7 Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Mon, 3 Aug 2026 14:11:11 +0200 Subject: [PATCH] feat(core): Select and Link components for json-render Adds a popover-based Select (built on FloatingPopover, with optional substring search) and a Link component with a scheme allowlist (http/https/mailto) since specs can be streamed/model-generated and the client can run embedded in a host page. Also implements TextInput's `type` and `loading` props, which were already documented and in the upstream base-catalog schema but not wired up. FloatingPopover gains `panelClass`, `ignore` (so a toggle trigger doesn't immediately reopen its own popover), and ancestor-scroll repositioning, plus a fix so the very first open of a wide, unmeasured panel doesn't animate a positional wobble between the centered-transform guess and the corrected absolute position. --- docs/kit/json-render.md | 48 ++++ .../floating/FloatingPopover.stories.ts | 37 ++- .../components/floating/FloatingPopover.ts | 43 ++- .../core/src/client/webcomponents/index.ts | 3 + .../json-render/JsonRender.stories.ts | 62 ++++- .../json-render/components/Link.ts | 69 +++++ .../json-render/components/Select.ts | 244 ++++++++++++++++++ .../json-render/components/TextInput.ts | 21 +- .../webcomponents/json-render/registry.ts | 6 + .../client/webcomponents.snapshot.d.ts | 22 ++ 10 files changed, 543 insertions(+), 12 deletions(-) create mode 100644 packages/core/src/client/webcomponents/json-render/components/Link.ts create mode 100644 packages/core/src/client/webcomponents/json-render/components/Select.ts diff --git a/docs/kit/json-render.md b/docs/kit/json-render.md index f4dd19b9..7ed863db 100644 --- a/docs/kit/json-render.md +++ b/docs/kit/json-render.md @@ -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 | + + +```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. @@ -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. + + +```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 diff --git a/packages/core/src/client/webcomponents/components/floating/FloatingPopover.stories.ts b/packages/core/src/client/webcomponents/components/floating/FloatingPopover.stories.ts index ca1e7324..e770fce1 100644 --- a/packages/core/src/client/webcomponents/components/floating/FloatingPopover.stories.ts +++ b/packages/core/src/client/webcomponents/components/floating/FloatingPopover.stories.ts @@ -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 @@ -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(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() { diff --git a/packages/core/src/client/webcomponents/components/floating/FloatingPopover.ts b/packages/core/src/client/webcomponents/components/floating/FloatingPopover.ts index 7566a54d..e3caeb3f 100644 --- a/packages/core/src/client/webcomponents/components/floating/FloatingPopover.ts +++ b/packages/core/src/client/webcomponents/components/floating/FloatingPopover.ts @@ -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 @@ -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, + required: false, + }, + /** Elements `dismissOnClickOutside` should not treat as "outside" — typically the trigger that toggles this popover. */ + ignore: { + type: Array as PropType, + required: false, + }, }, emits: ['dismiss'], setup(props, { emit }) { @@ -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) @@ -34,6 +54,9 @@ const FloatingPopoverComponent = defineComponent({ panelSize.width = width panelSize.height = height } + nextTick(() => { + measured.value = true + }) } onMounted(measurePanel) @@ -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( @@ -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) { @@ -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, }, @@ -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, }, diff --git a/packages/core/src/client/webcomponents/index.ts b/packages/core/src/client/webcomponents/index.ts index 76bfbcd0..c7c0ba58 100644 --- a/packages/core/src/client/webcomponents/index.ts +++ b/packages/core/src/client/webcomponents/index.ts @@ -16,7 +16,10 @@ export type { IconProps, JsonRenderElement, KeyValueTableProps, + LinkProps, ProgressProps, + SelectOption, + SelectProps, StackProps, SwitchProps, TabDescriptor, diff --git a/packages/core/src/client/webcomponents/json-render/JsonRender.stories.ts b/packages/core/src/client/webcomponents/json-render/JsonRender.stories.ts index a9e935b7..87af083e 100644 --- a/packages/core/src/client/webcomponents/json-render/JsonRender.stories.ts +++ b/packages/core/src/client/webcomponents/json-render/JsonRender.stories.ts @@ -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.', }, }, }, @@ -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' } }, @@ -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 ``. */ + 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', @@ -184,6 +201,47 @@ export const Tabs: StoryObj> = { } 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> = { + 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 diff --git a/packages/core/src/client/webcomponents/json-render/components/Link.ts b/packages/core/src/client/webcomponents/json-render/components/Link.ts new file mode 100644 index 00000000..d7a37ac2 --- /dev/null +++ b/packages/core/src/client/webcomponents/json-render/components/Link.ts @@ -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) + } + }, +}) diff --git a/packages/core/src/client/webcomponents/json-render/components/Select.ts b/packages/core/src/client/webcomponents/json-render/components/Select.ts new file mode 100644 index 00000000..ea74d1e8 --- /dev/null +++ b/packages/core/src/client/webcomponents/json-render/components/Select.ts @@ -0,0 +1,244 @@ +import { useBoundProp } from '@json-render/vue' +import { defineComponent, h, ref, useId, useTemplateRef, watch } from 'vue' +import DockIcon from '../../components/dock/DockIcon.vue' +import FloatingPopover from '../../components/floating/FloatingPopover' +import { bg, borderInput, borderSolid, surfaceSubtle } from './tokens' +import { registryProps } from './types' + +// @unocss-include + +export interface SelectOption { + value: string + label?: string + /** Iconify name, rendered before the label. */ + icon?: string + /** Secondary line under the label, also the option's `title`. */ + description?: string +} + +export interface SelectProps { + /** Two-way bindable via `{ $bindState: '...' }`. */ + value?: string + options?: (string | SelectOption)[] + /** Shown on the trigger while `value` is unset. */ + placeholder?: string + label?: string + disabled?: boolean + /** Adds a substring filter box at the top of the panel. */ + searchable?: boolean +} + +function normalizeOption(option: string | SelectOption): SelectOption { + return typeof option === 'string' ? { value: option } : option +} + +export const Select = defineComponent({ + name: 'JrSelect', + props: registryProps<'Select', SelectProps>(), + setup(ctx) { + const trigger = useTemplateRef('trigger') + const open = ref(false) + const query = ref('') + const activeIndex = ref(0) + const listboxId = useId() + + const close = (options: { refocus?: boolean } = {}) => { + open.value = false + if (options.refocus) + trigger.value?.focus() + } + + // Resets the filter and highlights the current selection each time the + // panel opens — reading `element.props` directly here (rather than + // `useBoundProp`) since this runs outside the render pass and the latter + // injects from the JSONUIProvider context, which is only available then. + watch(open, (isOpen) => { + if (!isOpen) + return + query.value = '' + const options = (ctx.element.props.options ?? []).map(normalizeOption) + const index = options.findIndex(option => option.value === ctx.element.props.value) + activeIndex.value = index >= 0 ? index : 0 + }) + + return () => { + const { placeholder, label, disabled, searchable } = ctx.element.props + const options = (ctx.element.props.options ?? []).map(normalizeOption) + const [value, setValue] = useBoundProp(ctx.element.props.value, ctx.bindings?.value) + const change = ctx.on('change') + + const filtered = searchable && query.value + ? options.filter(option => (option.label ?? option.value).toLowerCase().includes(query.value.toLowerCase())) + : options + + const selected = options.find(option => option.value === value) + const activeOption = filtered[activeIndex.value] + + const commit = (option: SelectOption) => { + setValue(option.value) + change.emit() + close({ refocus: true }) + } + + const moveActive = (delta: number) => { + if (filtered.length === 0) + return + activeIndex.value = (activeIndex.value + delta + filtered.length) % filtered.length + } + + const onKeydown = (e: KeyboardEvent) => { + if (disabled) + return + if (!open.value) { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + open.value = true + } + return + } + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + moveActive(1) + break + case 'ArrowUp': + e.preventDefault() + moveActive(-1) + break + case 'Home': + e.preventDefault() + activeIndex.value = 0 + break + case 'End': + e.preventDefault() + activeIndex.value = filtered.length - 1 + break + case 'Enter': + case ' ': + e.preventDefault() + if (activeOption) + commit(activeOption) + break + case 'Escape': + e.preventDefault() + close({ refocus: true }) + break + case 'Tab': + close() + break + } + } + + const optionId = (index: number) => `${listboxId}-option-${index}` + + const listbox = h('div', { + style: { display: 'flex', flexDirection: 'column' as const, minWidth: trigger.value ? `${trigger.value.offsetWidth}px` : undefined, maxHeight: '240px' }, + }, [ + searchable && h('input', { + 'type': 'text', + 'autofocus': true, + 'value': query.value, + 'placeholder': 'Filter…', + 'aria-label': 'Filter options', + 'style': { + padding: '6px 10px', + border: 'none', + borderBottom: borderSolid(borderInput), + fontSize: '12px', + backgroundColor: 'transparent', + color: 'inherit', + outline: 'none', + flexShrink: '0', + }, + 'onInput': (e: Event) => { + query.value = (e.target as HTMLInputElement).value + activeIndex.value = 0 + }, + 'onKeydown': onKeydown, + }), + h('div', { + role: 'listbox', + id: listboxId, + style: { display: 'flex', flexDirection: 'column' as const, padding: '4px', overflowY: 'auto' as const }, + }, filtered.length === 0 + ? [h('div', { style: { padding: '6px 10px', fontSize: '12px', opacity: '0.6' } }, 'No matches')] + : filtered.map((option, index) => h('div', { + 'id': optionId(index), + 'role': 'option', + 'aria-selected': option.value === value ? 'true' : 'false', + 'title': option.description, + 'style': { + display: 'flex', + flexDirection: 'column' as const, + padding: '6px 10px', + borderRadius: '4px', + fontSize: '12px', + cursor: 'pointer', + backgroundColor: index === activeIndex.value ? surfaceSubtle : 'transparent', + }, + 'onMouseenter': () => { activeIndex.value = index }, + 'onClick': () => commit(option), + }, [ + h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px' } }, [ + option.icon ? h(DockIcon, { icon: option.icon, class: 'w-3.5 h-3.5' }) : null, + h('span', option.label ?? option.value), + ]), + option.description ? h('span', { style: { opacity: '0.6', fontSize: '11px' } }, option.description) : null, + ]))), + ]) + + const triggerButton = h('button', { + 'ref': 'trigger', + 'type': 'button', + 'role': 'combobox', + 'disabled': disabled, + 'aria-haspopup': 'listbox', + 'aria-expanded': open.value ? 'true' : 'false', + 'aria-controls': listboxId, + 'aria-activedescendant': open.value && activeOption ? optionId(activeIndex.value) : undefined, + 'style': { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: '8px', + width: '100%', + padding: '6px 10px', + border: borderSolid(borderInput), + borderRadius: '4px', + fontSize: '12px', + backgroundColor: bg, + opacity: disabled ? '0.5' : '1', + cursor: disabled ? 'not-allowed' : 'pointer', + }, + 'onClick': () => { + if (!disabled) + open.value = !open.value + }, + 'onKeydown': onKeydown, + }, [ + h('span', { + style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', opacity: selected ? '1' : '0.6' }, + }, selected?.label ?? selected?.value ?? placeholder ?? ''), + h(DockIcon, { icon: 'ph:caret-down', class: 'w-3.5 h-3.5 flex-none' }), + ]) + + const control = h('div', { style: { position: 'relative' as const, flex: '1' } }, [ + triggerButton, + h(FloatingPopover, { + item: open.value && trigger.value ? { el: trigger.value, content: () => listbox, placement: 'bottom' as const } : null, + panelClass: ['!p0', 'overflow-hidden'], + ignore: [trigger], + onDismiss: () => close(), + }), + ]) + + if (label) { + return h('div', { style: { display: 'flex', flexDirection: 'column' as const, gap: '4px', flex: '1' } }, [ + h('label', { style: { fontSize: '12px', fontWeight: '500' } }, label), + control, + ]) + } + return control + } + }, +}) diff --git a/packages/core/src/client/webcomponents/json-render/components/TextInput.ts b/packages/core/src/client/webcomponents/json-render/components/TextInput.ts index 7fcf0a3a..a56dfcd2 100644 --- a/packages/core/src/client/webcomponents/json-render/components/TextInput.ts +++ b/packages/core/src/client/webcomponents/json-render/components/TextInput.ts @@ -1,5 +1,6 @@ import { useBoundProp } from '@json-render/vue' import { defineComponent, h } from 'vue' +import DockIcon from '../../components/dock/DockIcon.vue' import { borderInput, borderSolid } from './tokens' import { registryProps } from './types' @@ -8,7 +9,10 @@ export interface TextInputProps { value?: string placeholder?: string label?: string + type?: 'text' | 'search' | 'number' | 'password' | 'email' disabled?: boolean + /** Implies `disabled` and shows a spinner alongside the input. */ + loading?: boolean } export const TextInput = defineComponent({ @@ -16,16 +20,16 @@ export const TextInput = defineComponent({ props: registryProps<'TextInput', TextInputProps>(), setup(ctx) { return () => { - const { placeholder, label, disabled } = ctx.element.props + const { placeholder, label, type = 'text', disabled, loading } = ctx.element.props const [value, setValue] = useBoundProp(ctx.element.props.value, ctx.bindings?.value) const change = ctx.on('change') const input = h('input', { class: 'jr-text-input', - type: 'text', + type, value: value ?? '', placeholder, - disabled, + disabled: disabled || loading, style: { flex: '1', padding: '6px 10px', @@ -44,13 +48,20 @@ export const TextInput = defineComponent({ }, }) + const field = loading + ? h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px', flex: '1' } }, [ + input, + h(DockIcon, { icon: 'ph:spinner-gap-duotone', class: 'w-3.5 h-3.5 animate-spin flex-none' }), + ]) + : input + if (label) { return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '4px', flex: '1' } }, [ h('label', { style: { fontSize: '12px', fontWeight: '500' } }, label), - input, + field, ]) } - return input + return field } }, }) diff --git a/packages/core/src/client/webcomponents/json-render/registry.ts b/packages/core/src/client/webcomponents/json-render/registry.ts index ba542143..6af529af 100644 --- a/packages/core/src/client/webcomponents/json-render/registry.ts +++ b/packages/core/src/client/webcomponents/json-render/registry.ts @@ -7,7 +7,9 @@ import { DataTable } from './components/DataTable' import { Divider } from './components/Divider' import { Icon } from './components/Icon' import { KeyValueTable } from './components/KeyValueTable' +import { Link } from './components/Link' import { Progress } from './components/Progress' +import { Select } from './components/Select' import { Stack } from './components/Stack' import { Switch } from './components/Switch' import { Tabs } from './components/Tabs' @@ -37,7 +39,9 @@ export type { DataTableColumn, DataTableProps } from './components/DataTable' export type { DividerProps } from './components/Divider' export type { IconProps } from './components/Icon' export type { KeyValueTableProps } from './components/KeyValueTable' +export type { LinkProps } from './components/Link' export type { ProgressProps } from './components/Progress' +export type { SelectOption, SelectProps } from './components/Select' export type { StackProps } from './components/Stack' export type { SwitchProps } from './components/Switch' export type { TabDescriptor, TabsProps } from './components/Tabs' @@ -53,9 +57,11 @@ export const devtoolsRegistry: Record = { Text, Badge, Button, + Link, Icon, Divider, TextInput, + Select, Switch, KeyValueTable, DataTable, diff --git a/test/__snapshots__/tsnapi/@vitejs/devtools/client/webcomponents.snapshot.d.ts b/test/__snapshots__/tsnapi/@vitejs/devtools/client/webcomponents.snapshot.d.ts index 87fa660f..a26256ea 100644 --- a/test/__snapshots__/tsnapi/@vitejs/devtools/client/webcomponents.snapshot.d.ts +++ b/test/__snapshots__/tsnapi/@vitejs/devtools/client/webcomponents.snapshot.d.ts @@ -60,11 +60,31 @@ export interface JsonRenderElement; } +export interface LinkProps { + href?: string; + label?: string; + icon?: string; + external?: boolean; +} export interface ProgressProps { value: number; max?: number; label?: string; } +export interface SelectOption { + value: string; + label?: string; + icon?: string; + description?: string; +} +export interface SelectProps { + value?: string; + options?: (string | SelectOption)[]; + placeholder?: string; + label?: string; + disabled?: boolean; + searchable?: boolean; +} export interface StackProps { direction?: 'row' | 'column'; gap?: number; @@ -98,7 +118,9 @@ export interface TextInputProps { value?: string; placeholder?: string; label?: string; + type?: 'text' | 'search' | 'number' | 'password' | 'email'; disabled?: boolean; + loading?: boolean; } export interface TextProps { text?: string;