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/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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
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
|
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>
|
||||||
|
|
|
||||||
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 } : {}"
|
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)"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
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 = {
|
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: {
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
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
|
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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue