feat(ui): add binding field skins

- Show one variable picker affordance with a quiet identity pill and accessible combobox

- Keep bound NumberField focus non-destructive until the first value mutation

- Prefer accessible and semantic test selectors over compound test IDs
This commit is contained in:
Danila Poyarkov 2026-07-13 14:04:13 +03:00
parent 1b7934b98e
commit 1d60d3a407
31 changed files with 978 additions and 92 deletions

View file

@ -267,7 +267,7 @@ Self-review checklist:
- `src/components/Shell/**` is for app shell chrome and global app services rendered as components (menu bar, toast viewport, update/status chrome). Shell components may use app shell/editor stores. - `src/components/Shell/**` is for app shell chrome and global app services rendered as components (menu bar, toast viewport, update/status chrome). Shell components may use app shell/editor stores.
- `src/components/properties/**`, `src/components/chat/**`, `src/components/LayerTree/**`, `src/components/Toolbar/**`, and similar folders are feature/domain component namespaces. Keep feature-specific controls there unless they are genuinely reusable UI primitives. - `src/components/properties/**`, `src/components/chat/**`, `src/components/LayerTree/**`, `src/components/Toolbar/**`, and similar folders are feature/domain component namespaces. Keep feature-specific controls there unless they are genuinely reusable UI primitives.
- Treat existing root-level picker/input/control components as migration candidates when touched; do not expand that pattern. - Treat existing root-level picker/input/control components as migration candidates when touched; do not expand that pattern.
- Test hooks should be `data-test-id` attributes owned by the rendered markup or generated internally from semantic component state. Do not add `testId`, `visibilityTestId`, `triggerTestId`, or other test-id props to component APIs. - Test locators follow Playwright's user-facing priority: role/name, label, and text first. Multi-part components expose scoped `data-slot` anatomy; app concepts use semantic attributes such as `data-property`, `data-command`, and `data-node-id` when accessible identity is insufficient. Reserve `data-test-id` for rare integration boundaries such as the canvas/editor host, never add `testId`/`testHook` props, and do not manufacture globally unique compound IDs inside shared components.
- Use reka-ui for UI components (Splitter, ContextMenu, DropdownMenu, etc.) - Use reka-ui for UI components (Splitter, ContextMenu, DropdownMenu, etc.)
- Vue UI styling APIs follow the Nuxt UI architecture: static Tailwind Variants themes live under `src/theme/**` with `slots`, `variants`, `compoundVariants`, and `defaultVariants`; components resolve the theme with `tv()` and merge per-instance `ui` overrides at each rendered slot. Single-root components expose `class` rather than a one-slot `ui` object. Do not add one-off `fooClass`, `barClass`, `emptyActionClass`, etc. props. Use `UI` casing in type names (`SelectUI`, not `SelectUi`). - Vue UI styling APIs follow the Nuxt UI architecture: static Tailwind Variants themes live under `src/theme/**` with `slots`, `variants`, `compoundVariants`, and `defaultVariants`; components resolve the theme with `tv()` and merge per-instance `ui` overrides at each rendered slot. Single-root components expose `class` rather than a one-slot `ui` object. Do not add one-off `fooClass`, `barClass`, `emptyActionClass`, etc. props. Use `UI` casing in type names (`SelectUI`, not `SelectUi`).
@ -287,6 +287,7 @@ Self-review checklist:
- Mac keyboards: use `e.code` not `e.key` for shortcuts with modifiers (Option transforms characters) - Mac keyboards: use `e.code` not `e.key` for shortcuts with modifiers (Option transforms characters)
- Icons: use unplugin-icons with Iconify/Lucide (`<icon-lucide-*>`) — don't use raw SVG or Unicode symbols - Icons: use unplugin-icons with Iconify/Lucide (`<icon-lucide-*>`) — don't use raw SVG or Unicode symbols
- App menu (`src/components/Shell/AppMenu.vue`) — browser-only menu bar using reka-ui Menubar components; Tauri uses native menus, so menu is hidden when `IS_TAURI` is true - App menu (`src/components/Shell/AppMenu.vue`) — browser-only menu bar using reka-ui Menubar components; Tauri uses native menus, so menu is hidden when `IS_TAURI` is true
- Binding-aware fields must not mutate or detach on focus. Start detach/edit-variable transactions only on the first actual value mutation; opening the variable picker is also non-destructive.
- Preserve established UI gotchas in nearby components before refactoring: splitter handle sizing, NumberField pointer ownership, section drag targets, side-panel containment, and global number-spinner styling. - Preserve established UI gotchas in nearby components before refactoring: splitter handle sizing, NumberField pointer ownership, section drag targets, side-panel containment, and global number-spinner styling.
## File format ## File format

View file

@ -17,6 +17,7 @@
- Add a headless Vue SDK NumberField with pointer scrubbing, keyboard stepping, safe arithmetic expressions, and mixed/bound states; remove the superseded ScrubInput API. - Add a headless Vue SDK NumberField with pointer scrubbing, keyboard stepping, safe arithmetic expressions, and mixed/bound states; remove the superseded ScrubInput API.
- Add provider-driven BindableValue primitives for variable and token binding, including detach-on-edit, read-only, edit-variable, mixed-value, and undo-batched interactions. - Add provider-driven BindableValue primitives for variable and token binding, including detach-on-edit, read-only, edit-variable, mixed-value, and undo-batched interactions.
- Add headless PropertySection, SegmentedControl, and typed PropertyList anatomy, with controlled list events and an undo-aware OpenPencil adapter. - Add headless PropertySection, SegmentedControl, and typed PropertyList anatomy, with controlled list events and an undo-aware OpenPencil adapter.
- Refine variable-bound number fields with a quiet identity pill, one picker affordance, an accessible variable combobox, and non-destructive focus behavior.
- Upgrade Vue SDK documentation with shared Tailwind demos, source-generated component API tables, and type-aware Twoslash examples in VitePress. - Upgrade Vue SDK documentation with shared Tailwind demos, source-generated component API tables, and type-aware Twoslash examples in VitePress.
- Add desktop image drag-and-drop into the Tauri app window. - Add desktop image drag-and-drop into the Tauri app window.
- Add open-document discovery for live CLI and MCP automation so agents can target the intended document and page. - Add open-document discovery for live CLI and MCP automation so agents can target the intended document and page.

View file

@ -64,6 +64,17 @@ bun run check
- **Functions/variables** — camelCase - **Functions/variables** — camelCase
- **Types/interfaces** — PascalCase - **Types/interfaces** — PascalCase
### Test selectors
Playwright tests should locate behavior the way users and assistive technology do: prefer roles and
accessible names, labels, and visible text. Scope repeated controls to a named region. Multi-part UI
components expose local `data-slot` anatomy, while stable app concepts may expose semantic
attributes such as `data-property`, `data-command`, or `data-node-id`.
Reserve `data-test-id` for integration boundaries that have no meaningful user-facing or domain
identity. Do not add test-ID props to reusable components or generate compound IDs from current
component nesting.
### AI Agent Conventions ### AI Agent Conventions
Developers and AI agents working on the codebase should read `AGENTS.md` in the repo root ([view on GitHub](https://github.com/open-pencil/open-pencil/blob/master/AGENTS.md)). Covers rendering, scene graph, components & instances, layout, UI, file format, Tauri conventions, and known issues. Developers and AI agents working on the codebase should read `AGENTS.md` in the repo root ([view on GitHub](https://github.com/open-pencil/open-pencil/blob/master/AGENTS.md)). Covers rendering, scene graph, components & instances, layout, UI, file format, Tauri conventions, and known issues.

View file

@ -24,12 +24,16 @@ automatically when nested beneath `BindableValueRoot`.
## Policies ## Policies
- `detach-on-edit` unbinds targets and keeps the complete interaction in one provider undo batch. - `detach-on-edit` unbinds targets on the first value mutation and keeps the complete interaction
in one provider undo batch.
- `readonly-when-bound` blocks field editing, scrubbing, and keyboard stepping. - `readonly-when-bound` blocks field editing, scrubbing, and keyboard stepping.
- `edit-variable` sends changes to `provider.setValue()` instead of changing the target value. - `edit-variable` sends changes to `provider.setValue()` instead of changing the target value.
Cancellation rolls back an open provider batch. Providers without undo support still receive Focusing a bound NumberField or opening its picker is non-destructive. The policy starts only when
binding changes, with binding snapshots restored where possible. the user types a changed draft, steps the value, or crosses the pointer-scrub threshold. Committing
an unchanged field creates no undo entry. Cancellation rolls back an open provider batch.
Providers without undo support still receive binding changes, with binding snapshots restored
where possible.
## Provider example ## Provider example

View file

@ -26,6 +26,24 @@ For list-style panels, use:
- `useStrokeControls()` - `useStrokeControls()`
- `useEffectsControls()` - `useEffectsControls()`
## Binding-aware fields
Compose `BindableValueRoot` around fields that can reference variables or external design tokens.
The primitive is presentation-agnostic, but binding-aware interfaces should keep focus
non-destructive:
- Show variable identity while the field is idle; expose the resolved value in supporting UI such
as a tooltip.
- Focusing or opening the picker must not detach a binding.
- Apply `detach-on-edit`, `readonly-when-bound`, or `edit-variable` only when the user actually
changes the value.
- Put explicit detach actions in the picker rather than on a destructive one-click field icon.
- Keep binding replacement, detach-on-edit, and multi-target changes in one provider batch.
OpenPencil's app skin uses a violet variable-name pill at rest and reveals the resolved numeric
value when NumberField enters editing mode. Custom editor shells can present the same headless
state differently.
## Example: position panel ## Example: position panel
```vue ```vue

View file

@ -95,7 +95,8 @@ These components coordinate structure and state, but do not impose app styling.
adds pointer scrubbing, Arrow-key stepping, mixed/bound state attributes, and safe arithmetic adds pointer scrubbing, Arrow-key stepping, mixed/bound state attributes, and safe arithmetic
expressions such as `+10`, `*2`, `50%`, and `12*8+4`. `BindableValue` composes fields with a expressions such as `+10`, `*2`, `50%`, and `12*8+4`. `BindableValue` composes fields with a
generic `BindingProvider` and supports detach-on-edit, read-only, and edit-variable policies. generic `BindingProvider` and supports detach-on-edit, read-only, and edit-variable policies.
`PropertyListRoot` is controlled and editor-agnostic; OpenPencil panels connect it to selection and Focusing a bound NumberField is non-destructive; the configured policy begins only on the first
value mutation. `PropertyListRoot` is controlled and editor-agnostic; OpenPencil panels connect it to selection and
undo through `useEditorPropertyList()`. undo through `useEditorPropertyList()`.
## Public API tiers ## Public API tiers

View file

@ -72,7 +72,7 @@ const ariaLabelValue = computed(() => ariaLabel)
let interactionStartValue = 0 let interactionStartValue = 0
let interactionStartedMixed = false let interactionStartedMixed = false
let detachRequested = false let mutationRequested = false
let stopMove: (() => void) | undefined let stopMove: (() => void) | undefined
let stopUp: (() => void) | undefined let stopUp: (() => void) | undefined
let stopCancel: (() => void) | undefined let stopCancel: (() => void) | undefined
@ -84,17 +84,13 @@ function canMutate(): boolean {
} }
function requestMutation(source: NumberFieldMutationSource): boolean { function requestMutation(source: NumberFieldMutationSource): boolean {
if (mutationRequested) return true
if (!canMutate()) return false if (!canMutate()) return false
if (binding && !binding.actions.beginMutation(source)) return false if (binding && !binding.actions.beginMutation(source)) return false
if ( if (!binding && bound.value && effectiveEditPolicy.value === 'detach-on-edit') {
!binding &&
bound.value &&
effectiveEditPolicy.value === 'detach-on-edit' &&
!detachRequested
) {
detachRequested = true
emit('detach-request', source) emit('detach-request', source)
} }
mutationRequested = true
return true return true
} }
@ -102,7 +98,7 @@ function beginInteraction() {
interactionStartValue = numericValue.value interactionStartValue = numericValue.value
interactionStartedMixed = isMixed.value interactionStartedMixed = isMixed.value
workingValue.value = numericValue.value workingValue.value = numericValue.value
detachRequested = false mutationRequested = false
invalidReason.value = null invalidReason.value = null
} }
@ -134,7 +130,6 @@ function finishCommit(value: number) {
function startEdit() { function startEdit() {
if (editing.value || !canMutate()) return if (editing.value || !canMutate()) return
beginInteraction() beginInteraction()
if (!requestMutation('edit')) return
draftValue.value = interactionStartedMixed ? '' : String(interactionStartValue) draftValue.value = interactionStartedMixed ? '' : String(interactionStartValue)
editing.value = true editing.value = true
void nextTick(() => { void nextTick(() => {
@ -144,6 +139,7 @@ function startEdit() {
} }
function setDraft(value: string) { function setDraft(value: string) {
if (value !== draftValue.value && !requestMutation('edit')) return
draftValue.value = value draftValue.value = value
const absoluteNumber = /^\s*(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?\s*$/i.test(value) const absoluteNumber = /^\s*(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?\s*$/i.test(value)
if (absoluteNumber) updateValue(Number(value)) if (absoluteNumber) updateValue(Number(value))

View file

@ -1,5 +1,10 @@
<script lang="ts"> <script lang="ts">
import type { NumberExpressionError, NumberFieldEditPolicy } from '@open-pencil/vue' import type {
NumberExpressionError,
NumberFieldEditPolicy,
NumberFieldSlotProps
} from '@open-pencil/vue'
import type { VNode } from 'vue'
import type { ComponentUI } from '@/components/ui/types' import type { ComponentUI } from '@/components/ui/types'
import type { NumberFieldTheme } from '@/theme/number-field' import type { NumberFieldTheme } from '@/theme/number-field'
@ -21,21 +26,26 @@ export interface NumberFieldProps {
editPolicy?: NumberFieldEditPolicy editPolicy?: NumberFieldEditPolicy
ui?: NumberFieldUI ui?: NumberFieldUI
} }
export interface NumberFieldSlots {
icon?(): VNode[]
suffix?(): VNode[]
display?(props: NumberFieldSlotProps & { value: string }): VNode[]
bound?(props: NumberFieldSlotProps & { value: string }): VNode[]
}
</script> </script>
<script setup lang="ts"> <script setup lang="ts">
import { computed, normalizeClass, useAttrs, useSlots } from 'vue' import { computed, normalizeClass, useAttrs } from 'vue'
import { tv } from 'tailwind-variants' import { tv } from 'tailwind-variants'
import { NumberFieldRoot, NumberFieldInput, NumberFieldValue, testId } from '@open-pencil/vue' import { NumberFieldRoot, NumberFieldInput, NumberFieldValue } from '@open-pencil/vue'
import { useEditorStore } from '@/app/editor/active-store' import { useEditorStore } from '@/app/editor/active-store'
import theme from '@/theme/number-field' import theme from '@/theme/number-field'
const attrs = useAttrs() const attrs = useAttrs()
const slots = useSlots() const slots = defineSlots<NumberFieldSlots>()
const store = useEditorStore() const store = useEditorStore()
const rootTestId = computed(() => (attrs['data-test-id'] as string | undefined) ?? 'number-field')
const { const {
modelValue, modelValue,
min, min,
@ -95,7 +105,8 @@ defineOptions({ inheritAttrs: false })
" "
> >
<div <div
v-bind="{ ...attrs, ...rootAttrs, ...testId(rootTestId) }" v-bind="{ ...attrs, ...rootAttrs }"
data-slot="root"
:class="styles.root({ class: [ui?.root, normalizeClass(attrs.class)] })" :class="styles.root({ class: [ui?.root, normalizeClass(attrs.class)] })"
@pointerdown=" @pointerdown="
!editing && !editing &&
@ -103,25 +114,29 @@ defineOptions({ inheritAttrs: false })
actions.startScrub($event) actions.startScrub($event)
" "
> >
<span v-if="attrs['data-test-id']" data-test-id="number-field" class="hidden" />
<span :class="styles.leading({ class: ui?.leading })"> <span :class="styles.leading({ class: ui?.leading })">
<slot name="icon"> <slot name="icon">
<span v-if="icon" class="text-[11px] leading-none">{{ icon }}</span> <span v-if="icon" class="text-[11px] leading-none">{{ icon }}</span>
</slot> </slot>
<span v-if="label" class="text-[11px] leading-none">{{ label }}</span> <span v-if="label" class="text-[11px] leading-none">{{ label }}</span>
</span> </span>
<NumberFieldInput <NumberFieldInput :class="styles.field({ class: ui?.field })" />
data-test-id="number-field-input"
:class="styles.field({ class: ui?.field })"
/>
<slot v-if="editing" name="suffix" /> <slot v-if="editing" name="suffix" />
<NumberFieldValue :class="styles.display({ class: ui?.display })"> <NumberFieldValue :class="styles.display({ class: ui?.display })">
<template #default="{ value, isMixed: mixed }"> <template #default="display">
<span v-if="mixed" :class="styles.mixed({ class: ui?.mixed })">{{ ph }}</span> <slot name="display" v-bind="display">
<slot v-if="display.bound" name="bound" v-bind="display">
<span :class="styles.value({ class: ui?.value })">{{ display.value }}</span>
<span v-if="suffix" :class="styles.suffix({ class: ui?.suffix })">{{ suffix }}</span>
</slot>
<span v-else-if="display.isMixed" :class="styles.mixed({ class: ui?.mixed })">
{{ ph }}
</span>
<template v-else> <template v-else>
<span :class="styles.value({ class: ui?.value })">{{ value }}</span> <span :class="styles.value({ class: ui?.value })">{{ display.value }}</span>
<span v-if="suffix" :class="styles.suffix({ class: ui?.suffix })">{{ suffix }}</span> <span v-if="suffix" :class="styles.suffix({ class: ui?.suffix })">{{ suffix }}</span>
</template> </template>
</slot>
<slot name="suffix" /> <slot name="suffix" />
</template> </template>
</NumberFieldValue> </NumberFieldValue>

View file

@ -87,7 +87,7 @@ const blendModeSelectValue = computed<BlendModeSelectValue>({
</script> </script>
<template> <template>
<PanelSection v-if="active" :label="panels.appearance" data-test-id="appearance-section"> <PanelSection v-if="active" :label="panels.appearance">
<template #actions> <template #actions>
<IconButton <IconButton
:label="panels.toggleVisibility" :label="panels.toggleVisibility"
@ -108,7 +108,6 @@ const blendModeSelectValue = computed<BlendModeSelectValue>({
class="w-full" class="w-full"
:label="panels.blendMode" :label="panels.blendMode"
:options="blendModeOptions" :options="blendModeOptions"
data-test-id="appearance-blend-mode"
/> />
</Tip> </Tip>
@ -148,7 +147,6 @@ const blendModeSelectValue = computed<BlendModeSelectValue>({
<Tip :label="panels.radius"> <Tip :label="panels.radius">
<VariableNumberField <VariableNumberField
v-if="!showIndependentCorners && node" v-if="!showIndependentCorners && node"
data-test-id="corner-radius-input"
:model-value="cornerRadiusValue" :model-value="cornerRadiusValue"
:min="0" :min="0"
:node-id="node.id" :node-id="node.id"
@ -162,7 +160,6 @@ const blendModeSelectValue = computed<BlendModeSelectValue>({
</VariableNumberField> </VariableNumberField>
<NumberField <NumberField
v-else-if="!showIndependentCorners" v-else-if="!showIndependentCorners"
data-test-id="corner-radius-input"
:model-value="cornerRadiusValue" :model-value="cornerRadiusValue"
:min="0" :min="0"
@update:model-value="updateProp('cornerRadius', $event)" @update:model-value="updateProp('cornerRadius', $event)"

View file

@ -88,6 +88,7 @@ function handleAlign(
<Tip :label="panels.xAxis"> <Tip :label="panels.xAxis">
<NumberField <NumberField
icon="X" icon="X"
data-property="x"
:model-value="xValue" :model-value="xValue"
@update:model-value="actions.updateProp('x', $event)" @update:model-value="actions.updateProp('x', $event)"
@commit="(v: number, p: number) => actions.commitProp('x', v, p)" @commit="(v: number, p: number) => actions.commitProp('x', v, p)"
@ -96,6 +97,7 @@ function handleAlign(
<Tip :label="panels.yAxis"> <Tip :label="panels.yAxis">
<NumberField <NumberField
icon="Y" icon="Y"
data-property="y"
:model-value="yValue" :model-value="yValue"
@update:model-value="actions.updateProp('y', $event)" @update:model-value="actions.updateProp('y', $event)"
@commit="(v: number, p: number) => actions.commitProp('y', v, p)" @commit="(v: number, p: number) => actions.commitProp('y', v, p)"
@ -107,6 +109,7 @@ function handleAlign(
<Tip :label="panels.width"> <Tip :label="panels.width">
<NumberField <NumberField
icon="W" icon="W"
data-property="width"
:model-value="wValue" :model-value="wValue"
:min="1" :min="1"
@update:model-value="actions.updateProp('width', $event)" @update:model-value="actions.updateProp('width', $event)"
@ -116,6 +119,7 @@ function handleAlign(
<Tip :label="panels.height"> <Tip :label="panels.height">
<NumberField <NumberField
icon="H" icon="H"
data-property="height"
:model-value="hValue" :model-value="hValue"
:min="1" :min="1"
@update:model-value="actions.updateProp('height', $event)" @update:model-value="actions.updateProp('height', $event)"
@ -129,6 +133,8 @@ function handleAlign(
<NumberField <NumberField
class="flex-1" class="flex-1"
suffix="°" suffix="°"
data-property="rotation"
:aria-label="panels.rotation"
:model-value="rotationValue" :model-value="rotationValue"
:min="-360" :min="-360"
:max="360" :max="360"

View file

@ -1,10 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, useAttrs } from 'vue'
import { BindableValueRoot, useI18n, useNumberBindingProvider } from '@open-pencil/vue' import { BindableValueRoot, useI18n, useNumberBindingProvider } from '@open-pencil/vue'
import NumberField from '@/components/inputs/NumberField.vue' import NumberField from '@/components/inputs/NumberField.vue'
import BoundVariableButton from '@/components/properties/BoundVariableButton.vue' import VariableBindingPicker from '@/components/properties/binding/VariableBindingPicker.vue'
import VariablePickerPopover from '@/components/properties/VariablePickerPopover.vue' import { BindingPill, useBindingFieldUI } from '@/components/ui/binding'
import type { BindingTarget, NumberBindingPath } from '@open-pencil/vue' import type { BindingTarget, NumberBindingPath } from '@open-pencil/vue'
@ -41,7 +41,18 @@ const emit = defineEmits<{
const { panels, dialogs } = useI18n() const { panels, dialogs } = useI18n()
const provider = useNumberBindingProvider() const provider = useNumberBindingProvider()
const attrs = useAttrs()
const targets = computed<BindingTarget[]>(() => [{ nodeId, path: bindingPath }]) const targets = computed<BindingTarget[]>(() => [{ nodeId, path: bindingPath }])
const accessibleLabel = computed(() => {
const ariaLabel = attrs['aria-label']
return typeof ariaLabel === 'string' ? ariaLabel : (label ?? bindingPath)
})
const bindingStyles = useBindingFieldUI()
function bindingTooltip(name: string, resolvedValue: unknown) {
if (typeof resolvedValue !== 'number') return name
return `${name} · ${resolvedValue}${suffix ?? ''}`
}
defineOptions({ inheritAttrs: false }) defineOptions({ inheritAttrs: false })
</script> </script>
@ -64,26 +75,28 @@ defineOptions({ inheritAttrs: false })
:min="min" :min="min"
:max="max" :max="max"
:step="step" :step="step"
:ui="{ root: bindingStyles.root }"
:data-property="bindingPath"
:aria-label="accessibleLabel"
@update:model-value="emit('update:modelValue', $event)" @update:model-value="emit('update:modelValue', $event)"
@commit="(value: number, previous: number) => emit('commit', value, previous)" @commit="(value: number, previous: number) => emit('commit', value, previous)"
> >
<template v-if="$slots.icon" #icon> <template v-if="$slots.icon" #icon>
<slot name="icon" /> <slot name="icon" />
</template> </template>
<template v-if="binding.variable" #bound>
<BindingPill
:label="binding.variable.name"
:tooltip="bindingTooltip(binding.variable.name, binding.resolvedValue)"
/>
</template>
<template #suffix> <template #suffix>
<span :class="$slots['after-variable'] ? '' : 'pr-1'" class="flex items-center"> <span :class="$slots['after-variable'] ? '' : 'pr-1'" class="flex items-center">
<BoundVariableButton <VariableBindingPicker
v-if="binding.state === 'bound'"
:label="panels.detachVariable"
@detach="binding.actions.unbind"
/>
<VariablePickerPopover
v-else
:search-term="binding.searchTerm"
:variables="binding.variables"
:trigger-label="panels.applyVariable" :trigger-label="panels.applyVariable"
:search-placeholder="dialogs.search" :search-placeholder="dialogs.search"
:empty-label="panels.noVariablesFound" :empty-label="panels.noVariablesFound"
:detach-label="panels.detachVariable"
:create-label=" :create-label="
panels.createNumberVariable({ panels.createNumberVariable({
value: typeof modelValue === 'number' ? Math.round(modelValue) : 0 value: typeof modelValue === 'number' ? Math.round(modelValue) : 0
@ -91,9 +104,6 @@ defineOptions({ inheritAttrs: false })
" "
:create-name-placeholder="panels.variableName" :create-name-placeholder="panels.variableName"
:create-submit-label="panels.create" :create-submit-label="panels.create"
@update:search-term="binding.actions.setSearchTerm"
@select="binding.actions.bind($event.id)"
@create="binding.actions.create"
/> />
</span> </span>
<slot name="after-variable" /> <slot name="after-variable" />

View file

@ -0,0 +1,54 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { expect, userEvent, within } from 'storybook/test'
import BindingFieldDemo from './demo/BindingFieldDemo.vue'
const meta = {
title: 'Design System/Properties/Binding Field',
component: BindingFieldDemo,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'App-private binding skins for quiet fields, variable identity pills, hover affordances, and the variable picker.'
}
}
}
} satisfies Meta<typeof BindingFieldDemo>
export default meta
type Story = StoryObj<typeof meta>
export const StateMatrix: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
const page = within(canvasElement.ownerDocument.body)
const controls = Array.from(canvasElement.querySelectorAll<HTMLElement>('[data-story-control]'))
for (const control of controls) await expect(control).toHaveStyle({ height: '26px' })
const detachField = canvas.getByLabelText('Detach bound field')
await expect(detachField).toHaveAttribute('data-bound')
await userEvent.click(detachField)
const detachInput = canvas.getByRole('spinbutton', { name: 'Detach bound field' })
await expect(detachInput).toBeVisible()
await expect(detachField).toHaveAttribute('data-bound')
await userEvent.clear(detachInput)
await userEvent.type(detachInput, '32')
await expect(detachField).toHaveAttribute('data-unbound')
await userEvent.keyboard('{Escape}')
await expect(canvas.getByLabelText('Detach bound field')).toHaveAttribute('data-bound')
const unboundField = canvas.getByLabelText('Unbound field')
const trigger = within(unboundField).getByRole('button', { name: 'Apply variable' })
await userEvent.click(trigger)
const search = page.getByPlaceholderText('Search variables')
await expect(search).toBeVisible()
await userEvent.type(search, 'lg')
await userEvent.keyboard('{ArrowDown}{Enter}')
await expect(canvas.getByLabelText('Unbound field')).toHaveAttribute('data-bound')
}
}

View file

@ -0,0 +1,208 @@
<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<number>()
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>

View file

@ -0,0 +1,191 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { Variable } from '@open-pencil/scene-graph'
import type { BindingProvider, BindingState, BindingTarget } from '@open-pencil/vue'
import BindingFieldDemoItem from './BindingFieldDemoItem.vue'
const variables: Variable[] = [
{
id: 'space/sm',
name: 'Space/sm',
type: 'FLOAT',
collectionId: 'demo',
valuesByMode: { default: 8 },
description: '',
hiddenFromPublishing: false
},
{
id: 'space/md',
name: 'Space/md',
type: 'FLOAT',
collectionId: 'demo',
valuesByMode: { default: 16 },
description: '',
hiddenFromPublishing: false
},
{
id: 'space/lg',
name: 'Space/lg',
type: 'FLOAT',
collectionId: 'demo',
valuesByMode: { default: 24 },
description: '',
hiddenFromPublishing: false
}
]
const revision = ref(0)
const bindings = ref<Record<string, string | undefined>>({
'detach:width': 'space/md',
'readonly:width': 'space/lg',
'edit-variable:width': 'space/md',
'mixed-a:width': 'space/sm',
'mixed-b:width': 'space/lg',
'disabled:width': 'space/md',
'derived:width': 'space/sm'
})
const values = ref({
unbound: 12,
detach: 16,
readonly: 24,
editVariable: 16,
mixed: 0,
disabled: 16,
derived: 8
})
function key(target: BindingTarget) {
return `${target.nodeId}:${target.path}`
}
const provider: BindingProvider<number> = {
revision,
listVariables: () => variables,
filterVariables: (term) =>
variables.filter((variable) => variable.name.toLowerCase().includes(term.toLowerCase())),
getBound: (target) => variables.find((variable) => variable.id === bindings.value[key(target)]),
getState(targets): BindingState {
const ids = new Set(targets.map((target) => bindings.value[key(target)]))
if (ids.size > 1) return 'mixed'
return ids.has(undefined) ? 'unbound' : 'bound'
},
resolve: (variableId) => {
const value = variables.find((variable) => variable.id === variableId)?.valuesByMode.default
return typeof value === 'number' ? value : undefined
},
bind(target, variableId) {
bindings.value[key(target)] = variableId
revision.value++
},
unbind(target) {
bindings.value[key(target)] = undefined
revision.value++
},
setValue(variableId, value) {
const variable = variables.find((item) => item.id === variableId)
if (variable) variable.valuesByMode.default = value
revision.value++
},
create(target, value, name) {
const id = `created:${name}`
variables.push({
id,
name,
type: 'FLOAT',
collectionId: 'demo',
valuesByMode: { default: value },
description: '',
hiddenFromPublishing: false
})
bindings.value[key(target)] = id
revision.value++
}
}
const target = (nodeId: string): BindingTarget[] => [{ nodeId, path: 'width' }]
const mixedTargets: BindingTarget[] = [
{ nodeId: 'mixed-a', path: 'width' },
{ nodeId: 'mixed-b', path: 'width' }
]
</script>
<template>
<div class="w-[320px] overflow-hidden rounded-lg border border-border bg-panel shadow-xl">
<header class="border-b border-border px-panel-x py-panel-y">
<p class="text-xs font-semibold">Binding field states</p>
<p class="mt-1 text-[11px] text-muted">Pill at rest, resolved value while editing</p>
</header>
<div class="grid grid-cols-2 gap-panel p-panel-x">
<label class="space-y-1">
<span class="text-[11px] text-muted">Unbound</span>
<BindingFieldDemoItem
v-model="values.unbound"
label="Unbound field"
:provider="provider"
:targets="target('unbound')"
/>
</label>
<label class="space-y-1">
<span class="text-[11px] text-muted">Detach on edit</span>
<BindingFieldDemoItem
v-model="values.detach"
label="Detach bound field"
:provider="provider"
:targets="target('detach')"
/>
</label>
<label class="space-y-1">
<span class="text-[11px] text-muted">Read only</span>
<BindingFieldDemoItem
v-model="values.readonly"
label="Readonly bound field"
:provider="provider"
:targets="target('readonly')"
policy="readonly-when-bound"
/>
</label>
<label class="space-y-1">
<span class="text-[11px] text-muted">Edit variable</span>
<BindingFieldDemoItem
v-model="values.editVariable"
label="Edit variable field"
:provider="provider"
:targets="target('edit-variable')"
policy="edit-variable"
/>
</label>
<label class="space-y-1">
<span class="text-[11px] text-muted">Mixed</span>
<BindingFieldDemoItem
v-model="values.mixed"
label="Mixed binding field"
:provider="provider"
:targets="mixedTargets"
/>
</label>
<label class="space-y-1">
<span class="text-[11px] text-muted">Disabled</span>
<BindingFieldDemoItem
v-model="values.disabled"
label="Disabled bound field"
:provider="provider"
:targets="target('disabled')"
disabled
/>
</label>
<label class="col-span-2 space-y-1">
<span class="text-[11px] text-muted">Derived by auto layout</span>
<BindingFieldDemoItem
v-model="values.derived"
label="Derived bound field"
:provider="provider"
:targets="target('derived')"
derived
/>
</label>
</div>
</div>
</template>

View file

@ -0,0 +1,92 @@
<script setup lang="ts">
import type { BindingProvider, BindingTarget, BoundEditPolicy } from '@open-pencil/vue'
import {
BindableValueRoot,
NumberFieldInput,
NumberFieldRoot,
NumberFieldValue
} from '@open-pencil/vue'
import VariableBindingPicker from '@/components/properties/binding/VariableBindingPicker.vue'
import { BindingPill } from '@/components/ui/binding'
const {
provider,
targets,
label,
policy = 'detach-on-edit',
disabled = false,
derived = false
} = defineProps<{
provider: BindingProvider<number>
targets: BindingTarget[]
label: string
policy?: BoundEditPolicy
disabled?: boolean
derived?: boolean
}>()
const value = defineModel<number>({ required: true })
function tooltip(variableName: string, resolvedValue: unknown) {
return typeof resolvedValue === 'number' ? `${variableName} · ${resolvedValue}px` : variableName
}
</script>
<template>
<BindableValueRoot
v-slot="binding"
:provider="provider"
:targets="targets"
:value="value"
:policy="policy"
>
<NumberFieldRoot
v-slot="{ attrs, editing, actions }"
v-model="value"
:aria-label="label"
:disabled="disabled"
>
<div
v-bind="{ ...attrs, ...binding.stateAttrs }"
data-story-control
class="group/binding flex h-control min-w-0 items-center rounded-panel border border-transparent bg-panel-field text-xs text-surface outline-none hover:bg-panel-field-hover focus-within:border-panel-focus"
:class="derived ? 'text-muted' : ''"
:data-derived="derived ? '' : undefined"
@pointerdown="
!editing &&
!($event.target as HTMLElement)?.closest?.('button') &&
actions.startScrub($event)
"
>
<NumberFieldInput class="min-w-0 flex-1 border-0 bg-transparent px-2 outline-none" />
<NumberFieldValue class="flex min-w-0 flex-1 items-center overflow-hidden px-1">
<template #default="display">
<BindingPill
v-if="binding.state === 'bound' && binding.variable"
:label="binding.variable.name"
:tooltip="tooltip(binding.variable.name, binding.resolvedValue)"
:disabled="disabled"
:derived="derived"
/>
<span v-else-if="display.isMixed" class="min-w-0 flex-1 truncate px-1 text-muted">
Mixed
</span>
<span v-else class="min-w-0 flex-1 truncate px-1">{{ display.value }}</span>
</template>
</NumberFieldValue>
<VariableBindingPicker
trigger-label="Apply variable"
search-placeholder="Search variables"
empty-label="No variables found"
detach-label="Detach variable"
create-label="Create number variable"
create-name-placeholder="Variable name"
create-submit-label="Create"
:disabled="disabled"
:derived="derived"
/>
</div>
</NumberFieldRoot>
</BindableValueRoot>
</template>

View file

@ -8,7 +8,6 @@ export interface SegmentedControlOption {
value: string value: string
label: string label: string
disabled?: boolean disabled?: boolean
testHook?: string
} }
export type SegmentedControlUI = ComponentUI<SegmentedControlTheme> export type SegmentedControlUI = ComponentUI<SegmentedControlTheme>
@ -39,13 +38,6 @@ const emit = defineEmits<{ change: [value: string] }>()
const styles = computed(() => tv(theme)({ size })) const styles = computed(() => tv(theme)({ size }))
function itemClass(option: SegmentedControlOption) {
return tv(theme)({
size,
selected: modelValue.value === option.value
}).item({ class: ui?.item })
}
function select(value: string | string[] | undefined) { function select(value: string | string[] | undefined) {
if (typeof value !== 'string') return if (typeof value !== 'string') return
modelValue.value = value modelValue.value = value
@ -65,10 +57,9 @@ function select(value: string | string[] | undefined) {
:key="option.value" :key="option.value"
v-slot="{ selected }" v-slot="{ selected }"
:value="option.value" :value="option.value"
:data-test-id="option.testHook"
:aria-label="option.label" :aria-label="option.label"
:disabled="option.disabled" :disabled="option.disabled"
:class="itemClass(option)" :class="styles.item({ class: ui?.item })"
> >
<slot name="option" :option="option" :selected="selected"> <slot name="option" :option="option" :selected="selected">
<span class="truncate">{{ option.label }}</span> <span class="truncate">{{ option.label }}</span>

View file

@ -0,0 +1,53 @@
<script lang="ts">
import type { HTMLAttributes } from 'vue'
import type { BindingFieldUI } from './ui'
export interface BindingPillProps {
label: string
tooltip?: string
disabled?: boolean
derived?: boolean
class?: HTMLAttributes['class']
ui?: BindingFieldUI
}
</script>
<script setup lang="ts">
import { computed, normalizeClass } from 'vue'
import Tip from '@/components/ui/Tip.vue'
import { useBindingFieldUI } from '@/components/ui/binding/ui'
const {
label,
tooltip,
disabled = false,
derived = false,
class: className,
ui
} = defineProps<BindingPillProps>()
const styles = computed(() =>
useBindingFieldUI(
{ state: 'bound', disabled, derived },
{ ...ui, pill: [ui?.pill, normalizeClass(className)].filter(Boolean).join(' ') }
)
)
defineOptions({ inheritAttrs: false })
</script>
<template>
<Tip :label="tooltip" :disabled="!tooltip">
<span
v-bind="$attrs"
:class="styles.pill"
:data-disabled="disabled ? '' : undefined"
:data-derived="derived ? '' : undefined"
data-slot="pill"
>
<span :class="styles.pillLabel">{{ label }}</span>
</span>
</Tip>
</template>

View file

@ -0,0 +1,69 @@
<script lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { BindingState } from '@open-pencil/vue'
import type { BindingFieldUI } from './ui'
export interface BindingTriggerProps extends PrimitiveProps {
label: string
state?: BindingState
open?: boolean
disabled?: boolean
derived?: boolean
class?: HTMLAttributes['class']
ui?: BindingFieldUI
}
</script>
<script setup lang="ts">
import { computed, normalizeClass } from 'vue'
import { Primitive } from 'reka-ui'
import { useBindingFieldUI } from '@/components/ui/binding/ui'
const {
as = 'button',
asChild = false,
label,
state = 'unbound',
open = false,
disabled = false,
derived = false,
class: className,
ui
} = defineProps<BindingTriggerProps>()
const styles = computed(() =>
useBindingFieldUI(
{ state, open, disabled, derived },
{ ...ui, trigger: [ui?.trigger, normalizeClass(className)].filter(Boolean).join(' ') }
)
)
defineOptions({ inheritAttrs: false })
</script>
<template>
<Primitive
v-bind="$attrs"
:as="as"
:as-child="asChild"
:class="styles.trigger"
:type="!asChild && as === 'button' ? 'button' : undefined"
:disabled="!asChild && as === 'button' ? disabled : undefined"
:aria-label="label"
:aria-disabled="disabled ? 'true' : undefined"
:data-state="state"
:data-open="open ? '' : undefined"
:data-disabled="disabled ? '' : undefined"
:data-derived="derived ? '' : undefined"
data-slot="trigger"
>
<slot>
<icon-lucide-diamond v-if="state === 'bound'" class="size-3" />
<icon-lucide-diamond-plus v-else class="size-3" />
</slot>
</Primitive>
</template>

View file

@ -0,0 +1,4 @@
export { default as BindingPill } from './BindingPill.vue'
export { default as BindingTrigger } from './BindingTrigger.vue'
export { useBindingFieldUI } from './ui'
export type { BindingFieldUI, BindingFieldUIOptions } from './ui'

View file

@ -0,0 +1,39 @@
import { tv } from 'tailwind-variants'
import type { BindingState } from '@open-pencil/vue'
import type { ComponentUI } from '@/components/ui/types'
import theme from '@/theme/binding-field'
import type { BindingFieldTheme } from '@/theme/binding-field'
export type BindingFieldUI = ComponentUI<BindingFieldTheme>
export interface BindingFieldUIOptions {
state?: BindingState
open?: boolean
disabled?: boolean
derived?: boolean
}
export function useBindingFieldUI(options: BindingFieldUIOptions = {}, ui?: BindingFieldUI) {
const styles = tv(theme)(options)
return {
root: styles.root({ class: ui?.root }),
pill: styles.pill({ class: ui?.pill }),
pillLabel: styles.pillLabel({ class: ui?.pillLabel }),
trigger: styles.trigger({ class: ui?.trigger }),
pickerContent: styles.pickerContent({ class: ui?.pickerContent }),
pickerSearch: styles.pickerSearch({ class: ui?.pickerSearch }),
pickerViewport: styles.pickerViewport({ class: ui?.pickerViewport }),
pickerItem: styles.pickerItem({ class: ui?.pickerItem }),
pickerItemIcon: styles.pickerItemIcon({ class: ui?.pickerItemIcon }),
pickerItemLabel: styles.pickerItemLabel({ class: ui?.pickerItemLabel }),
pickerItemIndicator: styles.pickerItemIndicator({ class: ui?.pickerItemIndicator }),
pickerEmpty: styles.pickerEmpty({ class: ui?.pickerEmpty }),
pickerFooter: styles.pickerFooter({ class: ui?.pickerFooter }),
pickerAction: styles.pickerAction({ class: ui?.pickerAction }),
createForm: styles.createForm({ class: ui?.createForm }),
createInput: styles.createInput({ class: ui?.createInput }),
createSubmit: styles.createSubmit({ class: ui?.createSubmit })
}
}

View file

@ -57,6 +57,7 @@ const styles = computed(() => tv(theme)({ actions: Boolean(slots.actions) }))
v-bind="controlled ? { open } : {}" v-bind="controlled ? { open } : {}"
:default-open="defaultOpen" :default-open="defaultOpen"
:empty="empty" :empty="empty"
:aria-label="label"
:class="styles.root({ class: [ui?.root, className] })" :class="styles.root({ class: [ui?.root, className] })"
@update:open="emit('update:open', $event)" @update:open="emit('update:open', $event)"
> >

View file

@ -0,0 +1,69 @@
import { panelIconButtonBase } from './panel/field'
const bindingFieldTheme = {
slots: {
root: 'group/binding min-w-0',
pill: 'flex min-w-0 flex-1 items-center overflow-hidden rounded-sm px-1 text-component outline-none',
pillLabel: 'min-w-0 flex-1 truncate text-[11px] font-medium',
trigger: [
panelIconButtonBase,
'size-5 rounded-sm transition-opacity data-[state=unbound]:opacity-0 data-[state=mixed]:opacity-0 group-hover/binding:opacity-100 group-focus-within/binding:opacity-100 data-[open]:bg-hover data-[open]:text-component disabled:opacity-0 data-[disabled]:opacity-0'
],
pickerContent:
'z-[100] w-56 overflow-hidden rounded-lg border border-border bg-panel text-surface shadow-xl',
pickerSearch:
'h-control w-full border-0 border-b border-border bg-transparent px-2 text-[11px] text-surface outline-none placeholder:text-muted focus:border-panel-focus',
pickerViewport: 'max-h-48 overflow-y-auto p-1',
pickerItem:
'flex h-control cursor-pointer items-center gap-2 rounded-panel px-2 text-[11px] text-surface outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[highlighted]:bg-hover',
pickerItemIcon: 'size-3 shrink-0 text-component',
pickerItemLabel: 'min-w-0 flex-1 truncate',
pickerItemIndicator: 'flex size-3 shrink-0 items-center justify-center text-component',
pickerEmpty: 'px-2 py-3 text-center text-[11px] text-muted',
pickerFooter: 'border-t border-border p-1',
pickerAction:
'flex h-control w-full cursor-pointer items-center gap-2 rounded-panel border-0 bg-transparent px-2 text-left text-[11px] text-muted outline-none hover:bg-hover hover:text-surface focus-visible:border-panel-focus',
createForm: 'flex items-center gap-1.5 p-1',
createInput:
'h-control min-w-0 flex-1 rounded-panel border border-transparent bg-panel-field px-2 text-[11px] text-surface outline-none placeholder:text-muted focus:border-panel-focus',
createSubmit:
'h-control shrink-0 rounded-panel border border-transparent bg-panel-field px-2 text-[11px] text-surface outline-none hover:bg-panel-field-hover focus-visible:border-panel-focus disabled:cursor-not-allowed disabled:opacity-50'
},
variants: {
state: {
unbound: {},
bound: {
trigger: 'text-component opacity-100'
},
mixed: {}
},
open: {
true: {
trigger: 'bg-hover text-component opacity-100'
},
false: {}
},
disabled: {
true: {
pill: 'text-muted opacity-60',
trigger: 'pointer-events-none opacity-0'
},
false: {}
},
derived: {
true: {
pill: 'text-muted'
},
false: {}
}
},
defaultVariants: {
state: 'unbound' as const,
open: false,
disabled: false,
derived: false
}
}
export type BindingFieldTheme = typeof bindingFieldTheme
export default bindingFieldTheme

View file

@ -1,12 +1,12 @@
const panelSectionTheme = { const panelSectionTheme = {
slots: { slots: {
root: 'border-b border-border px-panel-x py-panel-y text-surface', root: 'border-b border-border px-panel-x py-panel-y text-surface data-[disabled]:opacity-60',
header: 'mb-panel grid min-w-0 items-center gap-panel', header: 'mb-panel grid min-w-0 items-center gap-panel',
title: title:
'min-w-0 cursor-pointer truncate border-0 bg-transparent p-0 text-left text-[11px] leading-none font-semibold text-surface', 'min-w-0 cursor-pointer truncate border-0 bg-transparent p-0 text-left text-[11px] leading-none font-semibold text-surface',
actions: actions:
'flex h-control w-panel-rail shrink-0 items-center justify-end gap-0.5 [&_[data-slot=icon-button]]:size-control [&_[data-slot=icon-button]]:rounded-panel', 'flex h-control w-panel-rail shrink-0 items-center justify-end gap-0.5 [&_[data-slot=icon-button]]:size-control [&_[data-slot=icon-button]]:rounded-panel',
body: 'min-w-0' body: 'min-w-0 data-[state=closed]:hidden'
}, },
variants: { variants: {
actions: { actions: {

View file

@ -3,20 +3,16 @@ import { panelFieldBase } from './panel/field'
const segmentedControlTheme = { const segmentedControlTheme = {
slots: { slots: {
root: [panelFieldBase, 'inline-flex items-center gap-0.5 p-0.5'], root: [panelFieldBase, 'inline-flex items-center gap-0.5 p-0.5'],
item: 'flex h-[22px] min-w-0 flex-1 cursor-pointer items-center justify-center gap-1 rounded-sm text-muted outline-none hover:text-surface focus-visible:ring-1 focus-visible:ring-accent disabled:cursor-not-allowed disabled:opacity-50' item: 'flex h-[22px] min-w-0 flex-1 cursor-pointer items-center justify-center gap-1 rounded-sm text-muted outline-none hover:bg-hover hover:text-surface focus-visible:ring-1 focus-visible:ring-panel-focus data-[state=on]:bg-panel-selected-muted data-[state=on]:text-surface data-[state=on]:hover:bg-panel-selected-muted disabled:cursor-not-allowed disabled:opacity-50'
}, },
variants: { variants: {
size: { size: {
sm: { item: 'px-1.5 text-[11px]' }, sm: { item: 'px-1.5 text-[11px]' },
md: { item: 'px-2 text-xs' } md: { item: 'px-2 text-xs' }
},
selected: {
true: { item: 'bg-accent text-white hover:text-white' }
} }
}, },
defaultVariants: { defaultVariants: {
size: 'sm' as const, size: 'sm' as const
selected: false as const
} }
} }

View file

@ -1,5 +1,6 @@
import { expect, expectInViewport, test, useEditorSetup } from '#tests/e2e/fixtures' import { expect, expectInViewport, test, useEditorSetup } from '#tests/e2e/fixtures'
import { expectDefined } from '#tests/helpers/assert' import { expectDefined } from '#tests/helpers/assert'
import { propertySection } from '#tests/helpers/properties'
const editor = useEditorSetup() const editor = useEditorSetup()
@ -79,7 +80,7 @@ test('selecting a rectangle shows design panel with type and name', async () =>
test('position section shows X, Y, rotation inputs', async () => { test('position section shows X, Y, rotation inputs', async () => {
await expect(positionSection()).toBeVisible() await expect(positionSection()).toBeVisible()
const inputs = positionSection().getByTestId('number-field') const inputs = propertySection(editor.page, 'Position').getByRole('spinbutton')
const count = await inputs.count() const count = await inputs.count()
expect(count).toBeGreaterThanOrEqual(3) expect(count).toBeGreaterThanOrEqual(3)
}) })
@ -165,7 +166,9 @@ test('adding a second fill shows two fill items', async () => {
test('blend mode select updates the selected layer', async () => { test('blend mode select updates the selected layer', async () => {
const id = await getSelectedId() const id = await getSelectedId()
const blendModeSelect = editor.page.getByTestId('appearance-blend-mode') const blendModeSelect = propertySection(editor.page, 'Appearance').getByRole('combobox', {
name: 'Blend mode'
})
await expect(blendModeSelect).toBeVisible() await expect(blendModeSelect).toBeVisible()
await blendModeSelect.click() await blendModeSelect.click()
@ -188,7 +191,9 @@ test('multi-select blend mode change is one undo step', async () => {
ids.map(async (id) => expectDefined(await getNode(id), 'selected node').blendMode) ids.map(async (id) => expectDefined(await getNode(id), 'selected node').blendMode)
) )
const blendModeSelect = editor.page.getByTestId('appearance-blend-mode') const blendModeSelect = propertySection(editor.page, 'Appearance').getByRole('combobox', {
name: 'Blend mode'
})
await blendModeSelect.click() await blendModeSelect.click()
await editor.page.getByRole('option', { name: 'Multiply' }).click() await editor.page.getByRole('option', { name: 'Multiply' }).click()
await editor.canvas.waitForRender() await editor.canvas.waitForRender()

View file

@ -120,16 +120,14 @@ test('padding controls set horizontal and vertical padding pairs', async () => {
await canvas.waitForRender() await canvas.waitForRender()
const horizontalInput = page const horizontalInput = page
.getByTestId('layout-horizontal-padding-input') .getByTestId('layout-horizontal-padding-input')
.getByTestId('number-field-input') .getByRole('spinbutton')
await horizontalInput.fill('24') await horizontalInput.fill('24')
await horizontalInput.press('Enter') await horizontalInput.press('Enter')
await canvas.waitForRender() await canvas.waitForRender()
await page.getByTestId('layout-vertical-padding-input').click() await page.getByTestId('layout-vertical-padding-input').click()
await canvas.waitForRender() await canvas.waitForRender()
const verticalInput = page const verticalInput = page.getByTestId('layout-vertical-padding-input').getByRole('spinbutton')
.getByTestId('layout-vertical-padding-input')
.getByTestId('number-field-input')
await verticalInput.fill('16') await verticalInput.fill('16')
await verticalInput.press('Enter') await verticalInput.press('Enter')
await canvas.waitForRender() await canvas.waitForRender()

View file

@ -57,7 +57,7 @@ test('independent corners toggle shows per-corner inputs', async () => {
expect((await getSelectedNodeFlags())?.independentCorners).toBe(true) expect((await getSelectedNodeFlags())?.independentCorners).toBe(true)
const grid = page.getByTestId('independent-corners-grid') const grid = page.getByTestId('independent-corners-grid')
await expect(grid).toBeVisible() await expect(grid).toBeVisible()
const cornerInputs = grid.getByTestId('number-field') const cornerInputs = grid.getByRole('spinbutton')
expect(await cornerInputs.count()).toBe(4) expect(await cornerInputs.count()).toBe(4)
await toggle.click() await toggle.click()
@ -79,7 +79,7 @@ test('stroke sides toggle shows per-side weight inputs', async () => {
const sectionInputsBefore = await page const sectionInputsBefore = await page
.getByTestId('stroke-section') .getByTestId('stroke-section')
.getByTestId('number-field') .getByRole('spinbutton')
.count() .count()
await toggle.click() await toggle.click()
@ -87,7 +87,7 @@ test('stroke sides toggle shows per-side weight inputs', async () => {
const sectionInputsAfter = await page const sectionInputsAfter = await page
.getByTestId('stroke-section') .getByTestId('stroke-section')
.getByTestId('number-field') .getByRole('spinbutton')
.count() .count()
expect(sectionInputsAfter).toBeGreaterThan(sectionInputsBefore) expect(sectionInputsAfter).toBeGreaterThan(sectionInputsBefore)
@ -96,7 +96,7 @@ test('stroke sides toggle shows per-side weight inputs', async () => {
const sectionInputsFinal = await page const sectionInputsFinal = await page
.getByTestId('stroke-section') .getByTestId('stroke-section')
.getByTestId('number-field') .getByRole('spinbutton')
.count() .count()
expect(sectionInputsFinal).toBe(sectionInputsBefore) expect(sectionInputsFinal).toBe(sectionInputsBefore)
}) })

View file

@ -1,16 +1,17 @@
import { expect, test, useEditorSetup } from '#tests/e2e/fixtures' import { expect, test, useEditorSetup } from '#tests/e2e/fixtures'
import { expectDefined } from '#tests/helpers/assert' import { expectDefined } from '#tests/helpers/assert'
import { propertyField } from '#tests/helpers/properties'
import { getSelectedNode } from '#tests/helpers/store' import { getSelectedNode } from '#tests/helpers/store'
const editor = useEditorSetup() const editor = useEditorSetup()
function xField() { function xField() {
return editor.page.getByTestId('position-section').getByTestId('number-field').first() return propertyField(editor.page, 'x')
} }
async function editField(field: ReturnType<typeof xField>) { async function editField(field: ReturnType<typeof xField>) {
await field.click() await field.click()
return field.getByTestId('number-field-input') return field.getByRole('spinbutton', { name: 'X' })
} }
async function numericFieldValue(field: ReturnType<typeof xField>): Promise<number> { async function numericFieldValue(field: ReturnType<typeof xField>): Promise<number> {

View file

@ -1,5 +1,6 @@
import { expect, test, useEditorSetup } from '#tests/e2e/fixtures' import { expect, test, useEditorSetup } from '#tests/e2e/fixtures'
import { expectDefined } from '#tests/helpers/assert' import { expectDefined } from '#tests/helpers/assert'
import { propertyField, propertySection } from '#tests/helpers/properties'
import { getPageChildren, getSelectedNode } from '#tests/helpers/store' import { getPageChildren, getSelectedNode } from '#tests/helpers/store'
const editor = useEditorSetup() const editor = useEditorSetup()
@ -8,8 +9,9 @@ test('property sections collapse and reopen from their title', async () => {
await editor.canvas.clearCanvas() await editor.canvas.clearCanvas()
await editor.canvas.drawRect(200, 200, 80, 80) await editor.canvas.drawRect(200, 200, 80, 80)
const title = editor.page.getByRole('button', { name: 'Appearance' }) const section = propertySection(editor.page, 'Appearance')
const blendMode = editor.page.getByTestId('appearance-blend-mode') const title = section.getByRole('button', { name: 'Appearance' })
const blendMode = section.getByRole('combobox', { name: 'Blend mode' })
await expect(blendMode).toBeVisible() await expect(blendMode).toBeVisible()
await title.click() await title.click()
await expect(blendMode).toBeHidden() await expect(blendMode).toBeHidden()
@ -24,7 +26,7 @@ test('NumberField drag changes X position', async () => {
const before = await getSelectedNode(editor.page) const before = await getSelectedNode(editor.page)
const initialX = expectDefined(before, 'selected rectangle before drag').x const initialX = expectDefined(before, 'selected rectangle before drag').x
const xField = editor.page.getByTestId('position-section').getByTestId('number-field').first() const xField = propertyField(editor.page, 'x')
await editor.canvas.dragNumberField(xField, 50) await editor.canvas.dragNumberField(xField, 50)
const after = await getSelectedNode(editor.page) const after = await getSelectedNode(editor.page)
@ -36,10 +38,10 @@ test('corner radius uniform sets cornerRadius', async () => {
await editor.canvas.clearCanvas() await editor.canvas.clearCanvas()
await editor.canvas.drawRect(200, 200, 80, 80) await editor.canvas.drawRect(200, 200, 80, 80)
const scrubContainer = editor.page.getByTestId('corner-radius-input') const scrubContainer = propertyField(editor.page, 'cornerRadius')
await scrubContainer.click() await scrubContainer.click()
await editor.canvas.waitForRender() await editor.canvas.waitForRender()
const input = editor.page.getByTestId('corner-radius-input').getByTestId('number-field-input') const input = scrubContainer.getByRole('spinbutton', { name: 'cornerRadius' })
await input.fill('12') await input.fill('12')
await input.press('Enter') await input.press('Enter')
await editor.canvas.waitForRender() await editor.canvas.waitForRender()
@ -199,7 +201,7 @@ test('width can create, bind, and detach a number variable', async () => {
const widthField = editor.page.getByTestId('layout-width-input') const widthField = editor.page.getByTestId('layout-width-input')
await widthField.click() await widthField.click()
const widthInput = widthField.getByTestId('number-field-input') const widthInput = widthField.getByRole('spinbutton')
await widthInput.fill('120') await widthInput.fill('120')
await widthInput.press('Enter') await widthInput.press('Enter')
await editor.canvas.waitForRender() await editor.canvas.waitForRender()
@ -220,13 +222,13 @@ test('bound NumberField detach edit is one undo step', async () => {
await editor.canvas.clearCanvas() await editor.canvas.clearCanvas()
await editor.canvas.drawRect(200, 200, 80, 80) await editor.canvas.drawRect(200, 200, 80, 80)
const field = editor.page.getByTestId('corner-radius-input') const field = propertyField(editor.page, 'cornerRadius')
await field.getByLabel('Apply variable').click() await field.getByLabel('Apply variable').click()
await editor.page.getByText('Create number variable from 0').click() await editor.page.getByText('Create number variable from 0').click()
await editor.page.getByPlaceholder('Variable name').fill('Radius/default') await editor.page.getByPlaceholder('Variable name').fill('Radius/default')
await editor.page.getByRole('button', { name: 'Create', exact: true }).click() await editor.page.getByRole('button', { name: 'Create', exact: true }).click()
await editor.canvas.waitForRender() await editor.canvas.waitForRender()
await expect(field.getByLabel('Detach variable')).toBeVisible() await expect(field.getByText('Radius/default')).toBeVisible()
const readState = () => const readState = () =>
editor.page.evaluate(() => { editor.page.evaluate(() => {
@ -244,7 +246,22 @@ test('bound NumberField detach edit is one undo step', async () => {
}) })
await field.click({ position: { x: 40, y: 13 } }) await field.click({ position: { x: 40, y: 13 } })
const input = field.getByTestId('number-field-input') const input = field.getByRole('spinbutton', { name: 'cornerRadius' })
await input.press('Tab')
expect(await readState()).toEqual({ radius: 0, binding: 'Radius/default' })
await editor.canvas.pressKey('Meta+z')
await editor.canvas.waitForRender()
expect(await readState()).toEqual({ radius: 0, binding: null })
await editor.canvas.pressKey('Meta+Shift+z')
await editor.canvas.waitForRender()
expect(await readState()).toEqual({ radius: 0, binding: 'Radius/default' })
await field.getByLabel('Apply variable').click()
await expect(editor.page.getByPlaceholder('Search')).toBeVisible()
await editor.page.getByPlaceholder('Search').press('Escape')
expect(await readState()).toEqual({ radius: 0, binding: 'Radius/default' })
await field.click({ position: { x: 40, y: 13 } })
await input.fill('12') await input.fill('12')
await input.press('Escape') await input.press('Escape')
await editor.canvas.waitForRender() await editor.canvas.waitForRender()

View file

@ -0,0 +1,9 @@
import type { Locator, Page } from '@playwright/test'
export function propertySection(page: Page, name: string): Locator {
return page.getByRole('region', { name })
}
export function propertyField(page: Page, property: string): Locator {
return page.locator(`[data-property=${JSON.stringify(property)}]`)
}

View file

@ -386,6 +386,34 @@ function vuePropName(prop: VueTemplateNode) {
return null return null
} }
const SHARED_TEST_ID_ALLOWLIST = new Set([
'packages/vue/src/primitives/ColorPicker/ColorPickerRoot.vue'
])
const noProductionTestIdsInSharedLayers = createTextRule(
'open-pencil/no-production-test-ids-in-shared-layers',
(sourceRel, content) => {
const inSharedLayer =
sourceRel.startsWith('src/components/ui/') ||
sourceRel.startsWith('packages/vue/src/primitives/')
const isFixture = sourceRel.includes('/demo/') || sourceRel.endsWith('.stories.ts')
if (!inSharedLayer || isFixture || SHARED_TEST_ID_ALLOWLIST.has(sourceRel)) return []
const diagnostics: Array<{ message: string; line?: number; column?: number }> = []
for (const match of content.matchAll(/\b(?:data-test-id|v-test-id|testId|testHook)\b/gu)) {
const before = content.slice(0, match.index)
const lines = before.split('\n')
diagnostics.push({
message:
'Shared UI and SDK primitives must expose accessible semantics, data-slot anatomy, and domain state instead of production test IDs.',
line: lines.length,
column: lines.at(-1)?.length ?? 0
})
}
return diagnostics
}
)
const noNativeTitleAttributesInVue = createTextRule( const noNativeTitleAttributesInVue = createTextRule(
'open-pencil/no-native-title-attributes-in-vue', 'open-pencil/no-native-title-attributes-in-vue',
(sourceRel, content) => { (sourceRel, content) => {
@ -483,6 +511,7 @@ export const openPencilArchitecturePlugin = {
noNonUiImportsInSharedUi, noNonUiImportsInSharedUi,
noAppImportsInSharedUi, noAppImportsInSharedUi,
noPropertyPanelInternalsOutsidePanel, noPropertyPanelInternalsOutsidePanel,
noProductionTestIdsInSharedLayers,
noNativeTitleAttributesInVue, noNativeTitleAttributesInVue,
noShortcutTextInLabels, noShortcutTextInLabels,
noHardcodedMacOSShortcutGlyphs, noHardcodedMacOSShortcutGlyphs,