Skip to content
Closed
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
3 changes: 2 additions & 1 deletion packages/json-render-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@
}
},
"dependencies": {
"@json-render/vue": "catalog:frontend"
"@json-render/vue": "catalog:frontend",
"@vueuse/core": "catalog:frontend"
},
"devDependencies": {
"@antfu/design": "catalog:frontend",
Expand Down
14 changes: 10 additions & 4 deletions packages/json-render-ui/src/components/Select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ 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'
import { computed, defineComponent, h } from 'vue'
import { useUncontrolledValue } from '../composables/useUncontrolledValue'

interface SelectOption {
value: string
Expand All @@ -28,8 +29,9 @@ function normalize(option: string | SelectOption): { value: string, label?: stri
}

// 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.
// uncontrolled selection (no `$bindState` on `value`) lives here, session-
// persisted so it survives a reload; when the spec binds `value`,
// `bindingPath` is set and writes flow back to the state store instead.
const SelectImpl = defineComponent({
name: 'JrSelectImpl',
props: {
Expand All @@ -47,7 +49,11 @@ const SelectImpl = defineComponent({
// 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 local = useUncontrolledValue<string | undefined>(
'Select',
{ options: props.options, searchable: props.searchable },
props.value,
)
const model = computed(() => (controlled ? props.value : local.value))
const setModel = (next: string | undefined) => {
if (controlled)
Expand Down
56 changes: 44 additions & 12 deletions packages/json-render-ui/src/components/Switch.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,55 @@
import type { PropType } from 'vue'
import type { JrComponent } from './_shared'
import FormSwitch from '@antfu/design/components/Form/FormSwitch.vue'
import { useBoundProp } from '@json-render/vue'
import { h } from 'vue'
import { defineComponent, h } from 'vue'
import { useUncontrolledValue } from '../composables/useUncontrolledValue'

interface SwitchProps {
value?: boolean
label?: string
disabled?: boolean
}

export const Switch: JrComponent<SwitchProps> = ({ props, on, bindings }) => {
const [value, setValue] = useBoundProp(props.value, bindings?.value)
return h(FormSwitch, {
'modelValue': !!value,
'onUpdate:modelValue': (next: boolean) => {
setValue(next)
on('change').emit()
},
'label': props.label,
'disabled': props.disabled,
// Stateful inner component: a JrComponent render fn can't hold a ref, so the
// uncontrolled value (no `$bindState` on `value`) lives here, session-
// persisted so it survives a reload; when the spec binds `value`,
// `bindingPath` is set and writes flow back to the state store instead.
const SwitchImpl = defineComponent({
name: 'JrSwitchImpl',
props: {
value: { type: Boolean, default: undefined },
label: { type: String, default: undefined },
disabled: { 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; `useBoundProp` is used
// only for its store setter.
const [, setBound] = useBoundProp<boolean>(props.value, props.bindingPath)
const controlled = props.bindingPath != null
const local = useUncontrolledValue('Switch', { label: props.label }, props.value ?? false)
const setModel = (next: boolean) => {
if (controlled)
setBound(next)
else local.value = next
props.onChange?.()
}
return () => h(FormSwitch, {
'modelValue': !!(controlled ? props.value : local.value),
'onUpdate:modelValue': setModel,
'label': props.label,
'disabled': props.disabled,
})
},
})

export const Switch: JrComponent<SwitchProps> = ({ props, on, bindings }) =>
h(SwitchImpl, {
value: props.value,
label: props.label,
disabled: props.disabled,
bindingPath: bindings?.value,
onChange: () => on('change').emit(),
})
}
13 changes: 10 additions & 3 deletions packages/json-render-ui/src/components/Tabs.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
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 { computed, defineComponent, h } from 'vue'
import { useUncontrolledValue } from '../composables/useUncontrolledValue'
import { Badge } from './Badge'
import { Icon } from './Icon'

Expand All @@ -28,7 +29,8 @@ interface TabsProps {
// 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.
// fn can't hold a ref) and across a reload (session-persisted); binds to the
// state store when `bindingPath` is set.
const TabsImpl = defineComponent({
name: 'JrTabsImpl',
props: {
Expand All @@ -44,7 +46,12 @@ const TabsImpl = defineComponent({
// 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)
// Session-persisted so the uncontrolled selection survives a reload.
const local = useUncontrolledValue<string | undefined>(
'Tabs',
{ tabs: props.tabs, orientation: props.orientation },
props.defaultValue ?? props.value ?? props.tabs[0]?.value,
)
const active = computed(() => (controlled ? props.value : local.value))
const isVertical = computed(() => props.orientation === 'vertical')

Expand Down
81 changes: 60 additions & 21 deletions packages/json-render-ui/src/components/TextInput.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { PropType } from 'vue'
import type { JrComponent } from './_shared'
import FormTextInput from '@antfu/design/components/Form/FormTextInput.vue'
import { useBoundProp } from '@json-render/vue'
import { h } from 'vue'
import { defineComponent, h } from 'vue'
import { useUncontrolledValue } from '../composables/useUncontrolledValue'

interface TextInputProps {
value?: string
Expand All @@ -12,24 +14,61 @@ interface TextInputProps {
loading?: boolean
}

export const TextInput: JrComponent<TextInputProps> = ({ props, on, bindings }) => {
const [value, setValue] = useBoundProp(props.value, bindings?.value)
const input = h(FormTextInput, {
'modelValue': value ?? '',
'onUpdate:modelValue': (next: string) => {
// Carry the new value into bound state, then fire the `change` action.
setValue(next)
on('change').emit()
},
'placeholder': props.placeholder,
'type': props.type ?? 'text',
'disabled': props.disabled || props.loading,
// Stateful inner component: a JrComponent render fn can't hold a ref, so the
// uncontrolled value (no `$bindState` on `value`) lives here, session-
// persisted so it survives a reload; when the spec binds `value`,
// `bindingPath` is set and writes flow back to the state store instead.
const TextInputImpl = defineComponent({
name: 'JrTextInputImpl',
props: {
value: { type: String, default: undefined },
placeholder: { type: String, default: undefined },
label: { type: String, default: undefined },
disabled: { type: Boolean, default: undefined },
type: { type: String as PropType<TextInputProps['type']>, default: 'text' },
loading: { 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; `useBoundProp` is used
// only for its store setter.
const [, setBound] = useBoundProp<string>(props.value, props.bindingPath)
const controlled = props.bindingPath != null
const local = useUncontrolledValue('TextInput', { placeholder: props.placeholder, type: props.type }, props.value ?? '')
const setModel = (next: string) => {
if (controlled)
setBound(next)
else local.value = next
props.onChange?.()
}
return () => {
const input = h(FormTextInput, {
'modelValue': (controlled ? props.value : local.value) ?? '',
'onUpdate:modelValue': setModel,
'placeholder': props.placeholder,
'type': props.type ?? 'text',
'disabled': props.disabled || props.loading,
})
if (props.label) {
return h('label', { class: 'flex flex-col gap-1 text-sm color-muted' }, [
h('span', props.label),
input,
])
}
return input
}
},
})

export const TextInput: JrComponent<TextInputProps> = ({ props, on, bindings }) =>
h(TextInputImpl, {
value: props.value,
placeholder: props.placeholder,
label: props.label,
disabled: props.disabled,
type: props.type,
loading: props.loading,
bindingPath: bindings?.value,
onChange: () => on('change').emit(),
})
if (props.label) {
return h('label', { class: 'flex flex-col gap-1 text-sm color-muted' }, [
h('span', props.label),
input,
])
}
return input
}
11 changes: 11 additions & 0 deletions packages/json-render-ui/src/composables/dock-entry-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { InjectionKey } from 'vue'

/**
* Injection key for the current dock's own identity — the `viewId`
* {@link JsonRenderView} is mounted with (a shared-state `stateKey`, or a
* client-synthesized dock id). `JsonRenderView` `provide()`s it once per
* mounted view; {@link useUncontrolledValue}'s session-persistence key
* `inject()`s it instead of threading the id through every registry
* component's props.
*/
export const DOCK_ENTRY_ID_KEY: InjectionKey<string | undefined> = Symbol('devframes:json-render:dock-entry-id')
26 changes: 26 additions & 0 deletions packages/json-render-ui/src/composables/useUncontrolledValue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { Ref } from 'vue'
import { useSessionStorage } from '@vueuse/core'
import { inject } from 'vue'
import { DOCK_ENTRY_ID_KEY } from './dock-entry-id'

/**
* Session-persisted fallback for a json-render element's own *uncontrolled*
* value — the local state `Tabs`/`Select`/`Switch`/`TextInput` fall back to
* when the bindable prop has no `$bindState` binding (`useBoundProp`'s setter
* is a no-op without one). On by default: calling this instead of a plain
* `ref(defaultValue)` survives a reload within the same tab.
*
* The key combines the current dock's id ({@link DOCK_ENTRY_ID_KEY}, absent
* outside a devframe dock) with a caller-supplied `signature` identifying the
* element within that dock — `kind` (the component, e.g. `'Tabs'`) plus the
* element's own static props. There is no element id to key off directly here
* (unlike some other json-render integrations' render context): a shape
* change yields a different key, so persistence falls back to `defaultValue`
* instead of restoring a stale value for a different element — intended, not
* a bug.
*/
export function useUncontrolledValue<T>(kind: string, signature: Record<string, unknown>, defaultValue: T): Ref<T> {
const dockEntryId = inject(DOCK_ENTRY_ID_KEY, undefined)
const key = `devframes-json-render-uncontrolled:${dockEntryId ?? '~'}:${kind}:${JSON.stringify(signature)}`
return useSessionStorage<T>(key, defaultValue)
}
33 changes: 31 additions & 2 deletions packages/json-render-ui/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import type { Component, PropType } from 'vue'
import type { ActionBridgeRpc } from './action-bridge'
import { basePropSchemas } from '@devframes/json-render'
import { JSONUIProvider, Renderer } from '@json-render/vue'
import { computed, defineComponent, h } from 'vue'
import { useDebounceFn, useSessionStorage } from '@vueuse/core'
import { computed, defineComponent, h, provide, ref, watchEffect } from 'vue'
import { createActionBridge } from './action-bridge'
import { DOCK_ENTRY_ID_KEY } from './composables/dock-entry-id'
import { baseRegistry, ERROR_COMPONENT_TYPE, UNSUPPORTED_COMPONENT_TYPE } from './registry'

// Upstream ships these as heavily-typed `DefineComponent`s; render them through
Expand Down Expand Up @@ -82,10 +84,37 @@ export const JsonRenderView = defineComponent({
setup(props) {
const bridge = createActionBridge(props.rpc, { interactive: props.interactive })

/**
* Descendants (e.g. `useUncontrolledValue`) `inject()` this to scope
* session-persisted state to "this dock" — the mounted view's own id,
* stable while a given `resetKey` subtree is alive (a `viewId` change
* remounts that subtree below, via the `key` on `ProviderC`).
*/
provide(DOCK_ENTRY_ID_KEY, props.viewId)

// Reset the provider (reseed state) only on identity change.
const resetKey = computed(() => props.viewId)
const effectiveSpec = computed(() => (props.spec ? sanitizeSpec(props.spec, props.registry) : null))

/**
* Restores/persists the scroll position of this view, per tab, across a
* reload — keyed by `viewId` so switching views doesn't bleed one view's
* scroll into another's. The key is a getter (not a plain string) since,
* unlike the registry components below, this component instance itself is
* not guaranteed to remount when `viewId` changes (e.g. the reference SPA
* keeps one `JsonRenderView` alive across its own view switcher) — the
* `watchEffect` below re-fires on both a fresh mount and a `viewId` change.
*/
const scrollEl = ref<HTMLElement | null>(null)
const scrollTop = useSessionStorage(() => `devframes-json-render-scroll:${props.viewId}`, 0)
watchEffect(() => {
if (scrollEl.value)
scrollEl.value.scrollTop = scrollTop.value
})
const persistScrollTop = useDebounceFn(() => {
scrollTop.value = scrollEl.value?.scrollTop ?? 0
}, 200)

return () => {
if (props.loading)
return h('div', { class: surface }, 'Loading…')
Expand All @@ -107,7 +136,7 @@ export const JsonRenderView = defineComponent({
}, 'Interactive actions are unavailable in static output.')
: null

return h('div', { class: 'color-base' }, [
return h('div', { class: 'color-base w-full h-full overflow-auto', ref: scrollEl, onScroll: persistScrollTop }, [
staticNote,
banner,
h(
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading