openpencil/src/components/properties/binding/VariableBindingPicker.vue
Danila Poyarkov 5371ed11b8 feat(app): migrate paint effect and export panels
- Share compact item-row and paint-field anatomy across Fill, Stroke, Effects, and Export
- Add COLOR binding transactions with picker rollback and one-step undo
- Replace bespoke panel test hooks with semantic selectors and visual coverage
2026-07-14 17:06:28 +03:00

209 lines
5.6 KiB
Vue

<script lang="ts">
import type { BindingFieldUI } from '@/components/ui/binding'
export interface VariableBindingPickerProps {
triggerLabel: string
searchPlaceholder: string
emptyLabel: string
detachLabel: string
createLabel?: string
createNamePlaceholder?: string
createSubmitLabel?: string
createDefaultName?: string
disabled?: boolean
derived?: boolean
ui?: BindingFieldUI
}
</script>
<script setup lang="ts">
import {
ComboboxAnchor,
ComboboxContent,
ComboboxInput,
ComboboxItem,
ComboboxItemIndicator,
ComboboxPortal,
ComboboxTrigger,
ComboboxViewport
} from 'reka-ui'
import { computed, nextTick, ref, watch } from 'vue'
import { BindableValuePicker, useBindableValue } from '@open-pencil/vue'
import Tip from '@/components/ui/Tip.vue'
import { BindingTrigger, useBindingFieldUI } from '@/components/ui/binding'
const {
triggerLabel,
searchPlaceholder,
emptyLabel,
detachLabel,
createLabel,
createNamePlaceholder = 'Variable name',
createSubmitLabel = 'Create',
createDefaultName = '',
disabled = false,
derived = false,
ui
} = defineProps<VariableBindingPickerProps>()
const binding = useBindableValue<unknown>()
const creating = ref(false)
const createName = ref('')
const createInput = ref<HTMLInputElement | null>(null)
const canCreate = computed(() => createName.value.trim().length > 0)
const styles = computed(() =>
useBindingFieldUI(
{
state: binding.state.value,
open: binding.open.value,
disabled,
derived
},
ui
)
)
function updateSearch(value: unknown) {
if (typeof value === 'string') binding.actions.setSearchTerm(value)
}
function startCreate() {
creating.value = true
createName.value = createDefaultName
void nextTick(() => {
createInput.value?.focus()
createInput.value?.select()
})
}
function submitCreate() {
const name = createName.value.trim()
if (!name) return
binding.actions.create(name)
}
function detach() {
binding.actions.unbind()
binding.actions.closePicker()
}
watch(binding.open, (open) => {
if (open) return
creating.value = false
binding.actions.setSearchTerm('')
})
defineOptions({ inheritAttrs: false })
</script>
<template>
<BindableValuePicker v-slot="picker">
<ComboboxAnchor class="contents" data-slot="anchor">
<Tip :label="triggerLabel">
<ComboboxTrigger as-child>
<BindingTrigger
:label="triggerLabel"
:state="picker.state"
:open="picker.open"
:disabled="disabled"
:derived="derived"
:ui="ui"
/>
</ComboboxTrigger>
</Tip>
</ComboboxAnchor>
<ComboboxPortal>
<ComboboxContent
v-if="picker.open"
position="popper"
side="left"
align="center"
:side-offset="8"
:collision-padding="8"
:class="styles.pickerContent"
data-slot="content"
>
<ComboboxInput
:model-value="picker.searchTerm"
:placeholder="searchPlaceholder"
:class="styles.pickerSearch"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
:spellcheck="false"
data-slot="search"
@update:model-value="updateSearch"
/>
<ComboboxViewport :class="styles.pickerViewport" data-slot="viewport">
<div v-if="picker.variables.length === 0" :class="styles.pickerEmpty" data-slot="empty">
{{ emptyLabel }}
</div>
<ComboboxItem
v-for="variable in picker.variables"
:key="variable.id"
:value="variable"
:text-value="variable.name"
:class="styles.pickerItem"
data-slot="item"
>
<icon-lucide-diamond :class="styles.pickerItemIcon" data-slot="itemIcon" />
<span :class="styles.pickerItemLabel" data-slot="itemLabel">{{ variable.name }}</span>
<ComboboxItemIndicator :class="styles.pickerItemIndicator" data-slot="itemIndicator">
<icon-lucide-check class="size-3" />
</ComboboxItemIndicator>
</ComboboxItem>
</ComboboxViewport>
<div :class="styles.pickerFooter" data-slot="footer">
<button
v-if="picker.state === 'bound'"
type="button"
:class="styles.pickerAction"
data-slot="action"
@click="detach"
>
<icon-lucide-unlink class="size-3" />
<span>{{ detachLabel }}</span>
</button>
<form
v-if="creating"
:class="styles.createForm"
data-slot="createForm"
@submit.prevent="submitCreate"
@keydown.esc.prevent.stop="creating = false"
>
<input
ref="createInput"
v-model="createName"
:placeholder="createNamePlaceholder"
:class="styles.createInput"
data-slot="createInput"
/>
<button
:disabled="!canCreate"
:class="styles.createSubmit"
data-slot="createSubmit"
type="submit"
>
{{ createSubmitLabel }}
</button>
</form>
<button
v-else-if="createLabel"
type="button"
:class="styles.pickerAction"
data-slot="action"
@click="startCreate"
>
<icon-lucide-plus class="size-3" />
<span class="min-w-0 flex-1 truncate">{{ createLabel }}</span>
</button>
</div>
</ComboboxContent>
</ComboboxPortal>
</BindableValuePicker>
</template>