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:
parent
1b7934b98e
commit
1d60d3a407
|
|
@ -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/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.
|
||||
- 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.)
|
||||
- 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)
|
||||
- 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
|
||||
- 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.
|
||||
|
||||
## File format
|
||||
|
|
|
|||
|
|
@ -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 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -64,6 +64,17 @@ bun run check
|
|||
- **Functions/variables** — camelCase
|
||||
- **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
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -24,12 +24,16 @@ automatically when nested beneath `BindableValueRoot`.
|
|||
|
||||
## 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.
|
||||
- `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
|
||||
binding changes, with binding snapshots restored where possible.
|
||||
Focusing a bound NumberField or opening its picker is non-destructive. The policy starts only when
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,24 @@ For list-style panels, use:
|
|||
- `useStrokeControls()`
|
||||
- `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
|
||||
|
||||
```vue
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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.
|
||||
`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()`.
|
||||
|
||||
## Public API tiers
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ const ariaLabelValue = computed(() => ariaLabel)
|
|||
|
||||
let interactionStartValue = 0
|
||||
let interactionStartedMixed = false
|
||||
let detachRequested = false
|
||||
let mutationRequested = false
|
||||
let stopMove: (() => void) | undefined
|
||||
let stopUp: (() => void) | undefined
|
||||
let stopCancel: (() => void) | undefined
|
||||
|
|
@ -84,17 +84,13 @@ function canMutate(): boolean {
|
|||
}
|
||||
|
||||
function requestMutation(source: NumberFieldMutationSource): boolean {
|
||||
if (mutationRequested) return true
|
||||
if (!canMutate()) return false
|
||||
if (binding && !binding.actions.beginMutation(source)) return false
|
||||
if (
|
||||
!binding &&
|
||||
bound.value &&
|
||||
effectiveEditPolicy.value === 'detach-on-edit' &&
|
||||
!detachRequested
|
||||
) {
|
||||
detachRequested = true
|
||||
if (!binding && bound.value && effectiveEditPolicy.value === 'detach-on-edit') {
|
||||
emit('detach-request', source)
|
||||
}
|
||||
mutationRequested = true
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -102,7 +98,7 @@ function beginInteraction() {
|
|||
interactionStartValue = numericValue.value
|
||||
interactionStartedMixed = isMixed.value
|
||||
workingValue.value = numericValue.value
|
||||
detachRequested = false
|
||||
mutationRequested = false
|
||||
invalidReason.value = null
|
||||
}
|
||||
|
||||
|
|
@ -134,7 +130,6 @@ function finishCommit(value: number) {
|
|||
function startEdit() {
|
||||
if (editing.value || !canMutate()) return
|
||||
beginInteraction()
|
||||
if (!requestMutation('edit')) return
|
||||
draftValue.value = interactionStartedMixed ? '' : String(interactionStartValue)
|
||||
editing.value = true
|
||||
void nextTick(() => {
|
||||
|
|
@ -144,6 +139,7 @@ function startEdit() {
|
|||
}
|
||||
|
||||
function setDraft(value: string) {
|
||||
if (value !== draftValue.value && !requestMutation('edit')) return
|
||||
draftValue.value = value
|
||||
const absoluteNumber = /^\s*(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?\s*$/i.test(value)
|
||||
if (absoluteNumber) updateValue(Number(value))
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
<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 { NumberFieldTheme } from '@/theme/number-field'
|
||||
|
|
@ -21,21 +26,26 @@ export interface NumberFieldProps {
|
|||
editPolicy?: NumberFieldEditPolicy
|
||||
ui?: NumberFieldUI
|
||||
}
|
||||
|
||||
export interface NumberFieldSlots {
|
||||
icon?(): VNode[]
|
||||
suffix?(): VNode[]
|
||||
display?(props: NumberFieldSlotProps & { value: string }): VNode[]
|
||||
bound?(props: NumberFieldSlotProps & { value: string }): VNode[]
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, normalizeClass, useAttrs, useSlots } from 'vue'
|
||||
import { computed, normalizeClass, useAttrs } from 'vue'
|
||||
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 theme from '@/theme/number-field'
|
||||
|
||||
const attrs = useAttrs()
|
||||
const slots = useSlots()
|
||||
const slots = defineSlots<NumberFieldSlots>()
|
||||
const store = useEditorStore()
|
||||
|
||||
const rootTestId = computed(() => (attrs['data-test-id'] as string | undefined) ?? 'number-field')
|
||||
|
||||
const {
|
||||
modelValue,
|
||||
min,
|
||||
|
|
@ -95,7 +105,8 @@ defineOptions({ inheritAttrs: false })
|
|||
"
|
||||
>
|
||||
<div
|
||||
v-bind="{ ...attrs, ...rootAttrs, ...testId(rootTestId) }"
|
||||
v-bind="{ ...attrs, ...rootAttrs }"
|
||||
data-slot="root"
|
||||
:class="styles.root({ class: [ui?.root, normalizeClass(attrs.class)] })"
|
||||
@pointerdown="
|
||||
!editing &&
|
||||
|
|
@ -103,25 +114,29 @@ defineOptions({ inheritAttrs: false })
|
|||
actions.startScrub($event)
|
||||
"
|
||||
>
|
||||
<span v-if="attrs['data-test-id']" data-test-id="number-field" class="hidden" />
|
||||
<span :class="styles.leading({ class: ui?.leading })">
|
||||
<slot name="icon">
|
||||
<span v-if="icon" class="text-[11px] leading-none">{{ icon }}</span>
|
||||
</slot>
|
||||
<span v-if="label" class="text-[11px] leading-none">{{ label }}</span>
|
||||
</span>
|
||||
<NumberFieldInput
|
||||
data-test-id="number-field-input"
|
||||
:class="styles.field({ class: ui?.field })"
|
||||
/>
|
||||
<NumberFieldInput :class="styles.field({ class: ui?.field })" />
|
||||
<slot v-if="editing" name="suffix" />
|
||||
<NumberFieldValue :class="styles.display({ class: ui?.display })">
|
||||
<template #default="{ value, isMixed: mixed }">
|
||||
<span v-if="mixed" :class="styles.mixed({ class: ui?.mixed })">{{ ph }}</span>
|
||||
<template #default="display">
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
</slot>
|
||||
<slot name="suffix" />
|
||||
</template>
|
||||
</NumberFieldValue>
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ const blendModeSelectValue = computed<BlendModeSelectValue>({
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<PanelSection v-if="active" :label="panels.appearance" data-test-id="appearance-section">
|
||||
<PanelSection v-if="active" :label="panels.appearance">
|
||||
<template #actions>
|
||||
<IconButton
|
||||
:label="panels.toggleVisibility"
|
||||
|
|
@ -108,7 +108,6 @@ const blendModeSelectValue = computed<BlendModeSelectValue>({
|
|||
class="w-full"
|
||||
:label="panels.blendMode"
|
||||
:options="blendModeOptions"
|
||||
data-test-id="appearance-blend-mode"
|
||||
/>
|
||||
</Tip>
|
||||
|
||||
|
|
@ -148,7 +147,6 @@ const blendModeSelectValue = computed<BlendModeSelectValue>({
|
|||
<Tip :label="panels.radius">
|
||||
<VariableNumberField
|
||||
v-if="!showIndependentCorners && node"
|
||||
data-test-id="corner-radius-input"
|
||||
:model-value="cornerRadiusValue"
|
||||
:min="0"
|
||||
:node-id="node.id"
|
||||
|
|
@ -162,7 +160,6 @@ const blendModeSelectValue = computed<BlendModeSelectValue>({
|
|||
</VariableNumberField>
|
||||
<NumberField
|
||||
v-else-if="!showIndependentCorners"
|
||||
data-test-id="corner-radius-input"
|
||||
:model-value="cornerRadiusValue"
|
||||
:min="0"
|
||||
@update:model-value="updateProp('cornerRadius', $event)"
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ function handleAlign(
|
|||
<Tip :label="panels.xAxis">
|
||||
<NumberField
|
||||
icon="X"
|
||||
data-property="x"
|
||||
:model-value="xValue"
|
||||
@update:model-value="actions.updateProp('x', $event)"
|
||||
@commit="(v: number, p: number) => actions.commitProp('x', v, p)"
|
||||
|
|
@ -96,6 +97,7 @@ function handleAlign(
|
|||
<Tip :label="panels.yAxis">
|
||||
<NumberField
|
||||
icon="Y"
|
||||
data-property="y"
|
||||
:model-value="yValue"
|
||||
@update:model-value="actions.updateProp('y', $event)"
|
||||
@commit="(v: number, p: number) => actions.commitProp('y', v, p)"
|
||||
|
|
@ -107,6 +109,7 @@ function handleAlign(
|
|||
<Tip :label="panels.width">
|
||||
<NumberField
|
||||
icon="W"
|
||||
data-property="width"
|
||||
:model-value="wValue"
|
||||
:min="1"
|
||||
@update:model-value="actions.updateProp('width', $event)"
|
||||
|
|
@ -116,6 +119,7 @@ function handleAlign(
|
|||
<Tip :label="panels.height">
|
||||
<NumberField
|
||||
icon="H"
|
||||
data-property="height"
|
||||
:model-value="hValue"
|
||||
:min="1"
|
||||
@update:model-value="actions.updateProp('height', $event)"
|
||||
|
|
@ -129,6 +133,8 @@ function handleAlign(
|
|||
<NumberField
|
||||
class="flex-1"
|
||||
suffix="°"
|
||||
data-property="rotation"
|
||||
:aria-label="panels.rotation"
|
||||
:model-value="rotationValue"
|
||||
:min="-360"
|
||||
:max="360"
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, useAttrs } from 'vue'
|
||||
import { BindableValueRoot, useI18n, useNumberBindingProvider } from '@open-pencil/vue'
|
||||
|
||||
import NumberField from '@/components/inputs/NumberField.vue'
|
||||
import BoundVariableButton from '@/components/properties/BoundVariableButton.vue'
|
||||
import VariablePickerPopover from '@/components/properties/VariablePickerPopover.vue'
|
||||
import VariableBindingPicker from '@/components/properties/binding/VariableBindingPicker.vue'
|
||||
import { BindingPill, useBindingFieldUI } from '@/components/ui/binding'
|
||||
|
||||
import type { BindingTarget, NumberBindingPath } from '@open-pencil/vue'
|
||||
|
||||
|
|
@ -41,7 +41,18 @@ const emit = defineEmits<{
|
|||
|
||||
const { panels, dialogs } = useI18n()
|
||||
const provider = useNumberBindingProvider()
|
||||
const attrs = useAttrs()
|
||||
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 })
|
||||
</script>
|
||||
|
|
@ -64,26 +75,28 @@ defineOptions({ inheritAttrs: false })
|
|||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:ui="{ root: bindingStyles.root }"
|
||||
:data-property="bindingPath"
|
||||
:aria-label="accessibleLabel"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
@commit="(value: number, previous: number) => emit('commit', value, previous)"
|
||||
>
|
||||
<template v-if="$slots.icon" #icon>
|
||||
<slot name="icon" />
|
||||
</template>
|
||||
<template v-if="binding.variable" #bound>
|
||||
<BindingPill
|
||||
:label="binding.variable.name"
|
||||
:tooltip="bindingTooltip(binding.variable.name, binding.resolvedValue)"
|
||||
/>
|
||||
</template>
|
||||
<template #suffix>
|
||||
<span :class="$slots['after-variable'] ? '' : 'pr-1'" class="flex items-center">
|
||||
<BoundVariableButton
|
||||
v-if="binding.state === 'bound'"
|
||||
:label="panels.detachVariable"
|
||||
@detach="binding.actions.unbind"
|
||||
/>
|
||||
<VariablePickerPopover
|
||||
v-else
|
||||
:search-term="binding.searchTerm"
|
||||
:variables="binding.variables"
|
||||
<VariableBindingPicker
|
||||
:trigger-label="panels.applyVariable"
|
||||
:search-placeholder="dialogs.search"
|
||||
:empty-label="panels.noVariablesFound"
|
||||
:detach-label="panels.detachVariable"
|
||||
:create-label="
|
||||
panels.createNumberVariable({
|
||||
value: typeof modelValue === 'number' ? Math.round(modelValue) : 0
|
||||
|
|
@ -91,9 +104,6 @@ defineOptions({ inheritAttrs: false })
|
|||
"
|
||||
:create-name-placeholder="panels.variableName"
|
||||
:create-submit-label="panels.create"
|
||||
@update:search-term="binding.actions.setSearchTerm"
|
||||
@select="binding.actions.bind($event.id)"
|
||||
@create="binding.actions.create"
|
||||
/>
|
||||
</span>
|
||||
<slot name="after-variable" />
|
||||
|
|
|
|||
54
src/components/properties/binding/BindingField.stories.ts
Normal file
54
src/components/properties/binding/BindingField.stories.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
208
src/components/properties/binding/VariableBindingPicker.vue
Normal file
208
src/components/properties/binding/VariableBindingPicker.vue
Normal 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>
|
||||
191
src/components/properties/binding/demo/BindingFieldDemo.vue
Normal file
191
src/components/properties/binding/demo/BindingFieldDemo.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -8,7 +8,6 @@ export interface SegmentedControlOption {
|
|||
value: string
|
||||
label: string
|
||||
disabled?: boolean
|
||||
testHook?: string
|
||||
}
|
||||
|
||||
export type SegmentedControlUI = ComponentUI<SegmentedControlTheme>
|
||||
|
|
@ -39,13 +38,6 @@ const emit = defineEmits<{ change: [value: string] }>()
|
|||
|
||||
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) {
|
||||
if (typeof value !== 'string') return
|
||||
modelValue.value = value
|
||||
|
|
@ -65,10 +57,9 @@ function select(value: string | string[] | undefined) {
|
|||
:key="option.value"
|
||||
v-slot="{ selected }"
|
||||
:value="option.value"
|
||||
:data-test-id="option.testHook"
|
||||
:aria-label="option.label"
|
||||
:disabled="option.disabled"
|
||||
:class="itemClass(option)"
|
||||
:class="styles.item({ class: ui?.item })"
|
||||
>
|
||||
<slot name="option" :option="option" :selected="selected">
|
||||
<span class="truncate">{{ option.label }}</span>
|
||||
|
|
|
|||
53
src/components/ui/binding/BindingPill.vue
Normal file
53
src/components/ui/binding/BindingPill.vue
Normal 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>
|
||||
69
src/components/ui/binding/BindingTrigger.vue
Normal file
69
src/components/ui/binding/BindingTrigger.vue
Normal 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>
|
||||
4
src/components/ui/binding/index.ts
Normal file
4
src/components/ui/binding/index.ts
Normal 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'
|
||||
39
src/components/ui/binding/ui.ts
Normal file
39
src/components/ui/binding/ui.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -57,6 +57,7 @@ const styles = computed(() => tv(theme)({ actions: Boolean(slots.actions) }))
|
|||
v-bind="controlled ? { open } : {}"
|
||||
:default-open="defaultOpen"
|
||||
:empty="empty"
|
||||
:aria-label="label"
|
||||
:class="styles.root({ class: [ui?.root, className] })"
|
||||
@update:open="emit('update:open', $event)"
|
||||
>
|
||||
|
|
|
|||
69
src/theme/binding-field.ts
Normal file
69
src/theme/binding-field.ts
Normal 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
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
const panelSectionTheme = {
|
||||
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',
|
||||
title:
|
||||
'min-w-0 cursor-pointer truncate border-0 bg-transparent p-0 text-left text-[11px] leading-none font-semibold text-surface',
|
||||
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',
|
||||
body: 'min-w-0'
|
||||
body: 'min-w-0 data-[state=closed]:hidden'
|
||||
},
|
||||
variants: {
|
||||
actions: {
|
||||
|
|
|
|||
|
|
@ -3,20 +3,16 @@ import { panelFieldBase } from './panel/field'
|
|||
const segmentedControlTheme = {
|
||||
slots: {
|
||||
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: {
|
||||
size: {
|
||||
sm: { item: 'px-1.5 text-[11px]' },
|
||||
md: { item: 'px-2 text-xs' }
|
||||
},
|
||||
selected: {
|
||||
true: { item: 'bg-accent text-white hover:text-white' }
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'sm' as const,
|
||||
selected: false as const
|
||||
size: 'sm' as const
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { expect, expectInViewport, test, useEditorSetup } from '#tests/e2e/fixtures'
|
||||
import { expectDefined } from '#tests/helpers/assert'
|
||||
import { propertySection } from '#tests/helpers/properties'
|
||||
|
||||
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 () => {
|
||||
await expect(positionSection()).toBeVisible()
|
||||
|
||||
const inputs = positionSection().getByTestId('number-field')
|
||||
const inputs = propertySection(editor.page, 'Position').getByRole('spinbutton')
|
||||
const count = await inputs.count()
|
||||
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 () => {
|
||||
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 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)
|
||||
)
|
||||
|
||||
const blendModeSelect = editor.page.getByTestId('appearance-blend-mode')
|
||||
const blendModeSelect = propertySection(editor.page, 'Appearance').getByRole('combobox', {
|
||||
name: 'Blend mode'
|
||||
})
|
||||
await blendModeSelect.click()
|
||||
await editor.page.getByRole('option', { name: 'Multiply' }).click()
|
||||
await editor.canvas.waitForRender()
|
||||
|
|
|
|||
|
|
@ -120,16 +120,14 @@ test('padding controls set horizontal and vertical padding pairs', async () => {
|
|||
await canvas.waitForRender()
|
||||
const horizontalInput = page
|
||||
.getByTestId('layout-horizontal-padding-input')
|
||||
.getByTestId('number-field-input')
|
||||
.getByRole('spinbutton')
|
||||
await horizontalInput.fill('24')
|
||||
await horizontalInput.press('Enter')
|
||||
await canvas.waitForRender()
|
||||
|
||||
await page.getByTestId('layout-vertical-padding-input').click()
|
||||
await canvas.waitForRender()
|
||||
const verticalInput = page
|
||||
.getByTestId('layout-vertical-padding-input')
|
||||
.getByTestId('number-field-input')
|
||||
const verticalInput = page.getByTestId('layout-vertical-padding-input').getByRole('spinbutton')
|
||||
await verticalInput.fill('16')
|
||||
await verticalInput.press('Enter')
|
||||
await canvas.waitForRender()
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ test('independent corners toggle shows per-corner inputs', async () => {
|
|||
expect((await getSelectedNodeFlags())?.independentCorners).toBe(true)
|
||||
const grid = page.getByTestId('independent-corners-grid')
|
||||
await expect(grid).toBeVisible()
|
||||
const cornerInputs = grid.getByTestId('number-field')
|
||||
const cornerInputs = grid.getByRole('spinbutton')
|
||||
expect(await cornerInputs.count()).toBe(4)
|
||||
|
||||
await toggle.click()
|
||||
|
|
@ -79,7 +79,7 @@ test('stroke sides toggle shows per-side weight inputs', async () => {
|
|||
|
||||
const sectionInputsBefore = await page
|
||||
.getByTestId('stroke-section')
|
||||
.getByTestId('number-field')
|
||||
.getByRole('spinbutton')
|
||||
.count()
|
||||
|
||||
await toggle.click()
|
||||
|
|
@ -87,7 +87,7 @@ test('stroke sides toggle shows per-side weight inputs', async () => {
|
|||
|
||||
const sectionInputsAfter = await page
|
||||
.getByTestId('stroke-section')
|
||||
.getByTestId('number-field')
|
||||
.getByRole('spinbutton')
|
||||
.count()
|
||||
expect(sectionInputsAfter).toBeGreaterThan(sectionInputsBefore)
|
||||
|
||||
|
|
@ -96,7 +96,7 @@ test('stroke sides toggle shows per-side weight inputs', async () => {
|
|||
|
||||
const sectionInputsFinal = await page
|
||||
.getByTestId('stroke-section')
|
||||
.getByTestId('number-field')
|
||||
.getByRole('spinbutton')
|
||||
.count()
|
||||
expect(sectionInputsFinal).toBe(sectionInputsBefore)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,16 +1,17 @@
|
|||
import { expect, test, useEditorSetup } from '#tests/e2e/fixtures'
|
||||
import { expectDefined } from '#tests/helpers/assert'
|
||||
import { propertyField } from '#tests/helpers/properties'
|
||||
import { getSelectedNode } from '#tests/helpers/store'
|
||||
|
||||
const editor = useEditorSetup()
|
||||
|
||||
function xField() {
|
||||
return editor.page.getByTestId('position-section').getByTestId('number-field').first()
|
||||
return propertyField(editor.page, 'x')
|
||||
}
|
||||
|
||||
async function editField(field: ReturnType<typeof xField>) {
|
||||
await field.click()
|
||||
return field.getByTestId('number-field-input')
|
||||
return field.getByRole('spinbutton', { name: 'X' })
|
||||
}
|
||||
|
||||
async function numericFieldValue(field: ReturnType<typeof xField>): Promise<number> {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { expect, test, useEditorSetup } from '#tests/e2e/fixtures'
|
||||
import { expectDefined } from '#tests/helpers/assert'
|
||||
import { propertyField, propertySection } from '#tests/helpers/properties'
|
||||
import { getPageChildren, getSelectedNode } from '#tests/helpers/store'
|
||||
|
||||
const editor = useEditorSetup()
|
||||
|
|
@ -8,8 +9,9 @@ test('property sections collapse and reopen from their title', async () => {
|
|||
await editor.canvas.clearCanvas()
|
||||
await editor.canvas.drawRect(200, 200, 80, 80)
|
||||
|
||||
const title = editor.page.getByRole('button', { name: 'Appearance' })
|
||||
const blendMode = editor.page.getByTestId('appearance-blend-mode')
|
||||
const section = propertySection(editor.page, 'Appearance')
|
||||
const title = section.getByRole('button', { name: 'Appearance' })
|
||||
const blendMode = section.getByRole('combobox', { name: 'Blend mode' })
|
||||
await expect(blendMode).toBeVisible()
|
||||
await title.click()
|
||||
await expect(blendMode).toBeHidden()
|
||||
|
|
@ -24,7 +26,7 @@ test('NumberField drag changes X position', async () => {
|
|||
const before = await getSelectedNode(editor.page)
|
||||
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)
|
||||
|
||||
const after = await getSelectedNode(editor.page)
|
||||
|
|
@ -36,10 +38,10 @@ test('corner radius uniform sets cornerRadius', async () => {
|
|||
await editor.canvas.clearCanvas()
|
||||
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 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.press('Enter')
|
||||
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')
|
||||
await widthField.click()
|
||||
const widthInput = widthField.getByTestId('number-field-input')
|
||||
const widthInput = widthField.getByRole('spinbutton')
|
||||
await widthInput.fill('120')
|
||||
await widthInput.press('Enter')
|
||||
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.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 editor.page.getByText('Create number variable from 0').click()
|
||||
await editor.page.getByPlaceholder('Variable name').fill('Radius/default')
|
||||
await editor.page.getByRole('button', { name: 'Create', exact: true }).click()
|
||||
await editor.canvas.waitForRender()
|
||||
await expect(field.getByLabel('Detach variable')).toBeVisible()
|
||||
await expect(field.getByText('Radius/default')).toBeVisible()
|
||||
|
||||
const readState = () =>
|
||||
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 } })
|
||||
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.press('Escape')
|
||||
await editor.canvas.waitForRender()
|
||||
|
|
|
|||
9
tests/helpers/properties.ts
Normal file
9
tests/helpers/properties.ts
Normal 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)}]`)
|
||||
}
|
||||
|
|
@ -386,6 +386,34 @@ function vuePropName(prop: VueTemplateNode) {
|
|||
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(
|
||||
'open-pencil/no-native-title-attributes-in-vue',
|
||||
(sourceRel, content) => {
|
||||
|
|
@ -483,6 +511,7 @@ export const openPencilArchitecturePlugin = {
|
|||
noNonUiImportsInSharedUi,
|
||||
noAppImportsInSharedUi,
|
||||
noPropertyPanelInternalsOutsidePanel,
|
||||
noProductionTestIdsInSharedLayers,
|
||||
noNativeTitleAttributesInVue,
|
||||
noShortcutTextInLabels,
|
||||
noHardcodedMacOSShortcutGlyphs,
|
||||
|
|
|
|||
Loading…
Reference in a new issue