feat(vue): add property panel primitives

- Add headless PropertySection and accessible SegmentedControl anatomy

- Make PropertyList controlled and exactly typed with an undo-aware editor adapter

- Consolidate SDK metadata loaders and link untranslated docs to canonical pages
This commit is contained in:
Danila Poyarkov 2026-07-13 12:36:03 +03:00
parent ffb12824eb
commit 1b7934b98e
71 changed files with 1709 additions and 437 deletions

View file

@ -21,7 +21,6 @@ const config: StorybookConfig = {
const excludedPluginPrefixes = [
'copy-canvaskit-wasm',
'open-pencil-automation',
'raw-text-assets',
'vite-plugin-pwa'
]

View file

@ -48,6 +48,8 @@ The app editor session (`src/app/editor/session/create.ts`) is a Vue wrapper aro
Headless SDK fields compose variable/token binding through `BindingProvider` and the `BindableValue` primitives in `packages/vue/src/controls/binding-provider/` and `packages/vue/src/primitives/BindableValue/`. Keep numeric interaction in `NumberField`; providers own binding lookup, mutation, and undo batching.
Property-panel anatomy in `packages/vue/src/primitives/PropertySection/`, `SegmentedControl/`, and `PropertyList/` is controlled and editor-agnostic. Connect PropertyList events to OpenPencil selection and undo through `useEditorPropertyList()` or an app adapter; never call `useEditor()` from these primitives.
## Commands
- `bun run check` — type-aware lint + typecheck via oxlint + tsgo + architecture checks (run before committing)
@ -88,7 +90,7 @@ Production Cloudflare Pages deploys are intentionally release/manual only: `app.
- `CHANGELOG.md` — all user-facing changes, grouped by version. "Unreleased" section at top for in-progress work.
- `README.md` — user-facing: features, getting started, CLI, project structure. No implementation details.
- `AGENTS.md` (this file) — contributor/agent reference: architecture, conventions, how to release.
- `packages/docs/` — VitePress site deployed at `openpencil.dev`. User guide, SDK, automation, reference, and development docs.
- `packages/docs/` — VitePress site deployed at `openpencil.dev`. User guide, SDK, automation, reference, and development docs. Do not create English placeholder copies under locale directories; until a real translation exists, localized navigation should link to the canonical English page.
When adding features, update `CHANGELOG.md` (Unreleased section) and `README.md` (if user-facing). Update `AGENTS.md` when architecture or conventions change. Do not put speculative/internal implementation plans in `packages/docs/**`; VitePress docs are published. Keep temporary plans in ignored `scratch/` or distill durable public direction into the canonical roadmap.

View file

@ -16,6 +16,7 @@
- Standardize Vue SDK and app override type names on the `UI` acronym, including `FontPickerUI`.
- 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.
- 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.

View file

@ -51,6 +51,7 @@ export default defineConfig({
vite: {
resolve: {
alias: {
'#docs': fileURLToPath(new URL('.', import.meta.url)),
'#vue': fileURLToPath(new URL('../../vue/src', import.meta.url))
}
},

View file

@ -8,6 +8,8 @@ const SDK_COMPONENT_PAGES = [
{ text: 'ToolbarRoot', slug: 'toolbar-root' },
{ text: 'ToolbarItem', slug: 'toolbar-item' },
{ text: 'PageListRoot', slug: 'page-list-root' },
{ text: 'PropertySection', slug: 'property-section', canonical: true },
{ text: 'SegmentedControl', slug: 'segmented-control', canonical: true },
{ text: 'PropertyListRoot', slug: 'property-list-root' },
{ text: 'PropertyListItem', slug: 'property-list-item' },
{ text: 'ColorPickerRoot', slug: 'color-picker-root' },
@ -17,8 +19,8 @@ const SDK_COMPONENT_PAGES = [
{ text: 'GradientEditorRoot', slug: 'gradient-editor-root' },
{ text: 'GradientEditorBar', slug: 'gradient-editor-bar' },
{ text: 'GradientEditorStop', slug: 'gradient-editor-stop' },
{ text: 'NumberField', slug: 'number-field' },
{ text: 'BindableValue', slug: 'bindable-value' },
{ text: 'NumberField', slug: 'number-field', canonical: true },
{ text: 'BindableValue', slug: 'bindable-value', canonical: true },
{ text: 'LayoutControlsRoot', slug: 'layout-controls-root' },
{ text: 'AppearanceControlsRoot', slug: 'appearance-controls-root' },
{ text: 'PositionControlsRoot', slug: 'position-controls-root' },
@ -89,7 +91,7 @@ export const sdkSidebar = (prefix: string): DefaultTheme.SidebarItem[] => [
{ text: 'Overview', link: `${prefix}/programmable/sdk/api/components/` },
...SDK_COMPONENT_PAGES.map((page) => ({
text: page.text,
link: `${prefix}/programmable/sdk/api/components/${page.slug}`
link: `${'canonical' in page ? '' : prefix}/programmable/sdk/api/components/${page.slug}`
}))
]
},

View file

@ -1,7 +1,9 @@
import { resolve } from 'node:path'
import { existsSync, readFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createChecker } from 'vue-component-meta'
import type { Loader } from 'vitepress'
export interface SdkComponentPropMeta {
name: string
@ -38,9 +40,34 @@ export interface SdkComponentMeta {
exposed: SdkComponentExposeMeta[]
}
const repoRoot = fileURLToPath(new URL('../../../..', import.meta.url))
function findWorkspaceRoot(start: string): string {
let directory = start
while (true) {
const packagePath = join(directory, 'package.json')
if (existsSync(packagePath)) {
const packageJSON = JSON.parse(readFileSync(packagePath, 'utf8')) as { workspaces?: unknown }
if (Array.isArray(packageJSON.workspaces)) return directory
}
const parent = dirname(directory)
if (parent === directory) throw new Error('Unable to locate workspace root')
directory = parent
}
}
const repoRoot = findWorkspaceRoot(fileURLToPath(new URL('.', import.meta.url)))
const checker = createChecker(resolve(repoRoot, 'packages/vue/tsconfig.json'), { schema: false })
export interface SdkComponentData {
components: SdkComponentMeta[]
}
export function defineComponentMetaLoader(sources: string[]): Loader<SdkComponentData> {
return {
watch: sources.map((source) => resolve(repoRoot, source)),
load: () => ({ components: sources.map(readComponentMeta) })
}
}
export function readComponentMeta(source: string): SdkComponentMeta {
const absoluteSource = resolve(repoRoot, source)
const meta = checker.getComponentMeta(absoluteSource)

View file

@ -1,10 +0,0 @@
---
title: BindableValue
description: Provider-driven value binding primitives for custom editor controls.
---
# BindableValue
The generated BindableValue API reference and interactive demo currently share one canonical source.
[Open the BindableValue reference](/programmable/sdk/api/components/bindable-value)

View file

@ -1,10 +0,0 @@
---
title: NumberField
description: Headless numeric field with scrubbing, expressions, and keyboard stepping.
---
# NumberField
The generated NumberField API reference and interactive demo currently share one canonical source.
[Open the NumberField reference](/programmable/sdk/api/components/number-field)

View file

@ -1,10 +0,0 @@
---
title: BindableValue
description: Provider-driven value binding primitives for custom editor controls.
---
# BindableValue
The generated BindableValue API reference and interactive demo currently share one canonical source.
[Open the BindableValue reference](/programmable/sdk/api/components/bindable-value)

View file

@ -1,10 +0,0 @@
---
title: NumberField
description: Headless numeric field with scrubbing, expressions, and keyboard stepping.
---
# NumberField
The generated NumberField API reference and interactive demo currently share one canonical source.
[Open the NumberField reference](/programmable/sdk/api/components/number-field)

View file

@ -1,10 +0,0 @@
---
title: BindableValue
description: Provider-driven value binding primitives for custom editor controls.
---
# BindableValue
The generated BindableValue API reference and interactive demo currently share one canonical source.
[Open the BindableValue reference](/programmable/sdk/api/components/bindable-value)

View file

@ -1,10 +0,0 @@
---
title: NumberField
description: Headless numeric field with scrubbing, expressions, and keyboard stepping.
---
# NumberField
The generated NumberField API reference and interactive demo currently share one canonical source.
[Open the NumberField reference](/programmable/sdk/api/components/number-field)

View file

@ -1,10 +0,0 @@
---
title: BindableValue
description: Provider-driven value binding primitives for custom editor controls.
---
# BindableValue
The generated BindableValue API reference and interactive demo currently share one canonical source.
[Open the BindableValue reference](/programmable/sdk/api/components/bindable-value)

View file

@ -1,10 +0,0 @@
---
title: NumberField
description: Headless numeric field with scrubbing, expressions, and keyboard stepping.
---
# NumberField
The generated NumberField API reference and interactive demo currently share one canonical source.
[Open the NumberField reference](/programmable/sdk/api/components/number-field)

View file

@ -4,6 +4,9 @@
"license": "MIT",
"private": true,
"type": "module",
"imports": {
"#docs/*": "./.vitepress/*"
},
"scripts": {
"dev": "vitepress dev",
"build": "vitepress build",

View file

@ -1,10 +0,0 @@
---
title: BindableValue
description: Provider-driven value binding primitives for custom editor controls.
---
# BindableValue
The generated BindableValue API reference and interactive demo currently share one canonical source.
[Open the BindableValue reference](/programmable/sdk/api/components/bindable-value)

View file

@ -1,10 +0,0 @@
---
title: NumberField
description: Headless numeric field with scrubbing, expressions, and keyboard stepping.
---
# NumberField
The generated NumberField API reference and interactive demo currently share one canonical source.
[Open the NumberField reference](/programmable/sdk/api/components/number-field)

View file

@ -7,7 +7,9 @@ description: Primitive context helper for PropertyListRoot descendants.
`usePropertyList()` reads the local property-list context provided by `PropertyListRoot`.
Use it inside descendants that need current items, mixed-state info, or row-level handlers for fills, strokes, or effects.
Use it inside descendants that need controlled items, mixed-state information, or row-level handlers for fills, strokes, or effects. It does not access an editor.
OpenPencil editor panels can use `useEditorPropertyList(propKey)` in an adapter component to connect the controlled primitive to selection, undo batching, and multi-node mutations.
## Related APIs

View file

@ -1,9 +1,4 @@
import type { Loader } from 'vitepress'
import {
readComponentMeta,
type SdkComponentMeta
} from '../../../../.vitepress/sdk/component-meta'
import { defineComponentMetaLoader } from '#docs/sdk/component-meta'
const sources = [
'packages/vue/src/primitives/BindableValue/BindableValueRoot.vue',
@ -11,13 +6,4 @@ const sources = [
'packages/vue/src/primitives/BindableValue/BindableValuePicker.vue'
]
export interface BindableValueComponentData {
components: SdkComponentMeta[]
}
export default {
watch: sources.map((source) => `../../../../../../${source}`),
load(): BindableValueComponentData {
return { components: sources.map(readComponentMeta) }
}
} satisfies Loader
export default defineComponentMetaLoader(sources)

View file

@ -4,7 +4,7 @@ description: Provider-driven value binding primitives for custom editor controls
---
<script setup lang="ts">
import BindableValueDemo from '../../../../../vue/src/primitives/BindableValue/demo/BindableValueDemo.vue'
import BindableValueDemo from '#vue/primitives/BindableValue/demo/BindableValueDemo.vue'
import { data } from './bindable-value.data'
</script>

View file

@ -22,6 +22,8 @@ description: Component reference for headless Vue primitives in @open-pencil/vue
## Property panel primitives
<SdkCardGroup>
<SdkCard title="PropertySection" to="/programmable/sdk/api/components/property-section" description="Collapsible property-section anatomy and empty states." />
<SdkCard title="SegmentedControl" to="/programmable/sdk/api/components/segmented-control" description="Accessible selection and action-only segment groups." />
<SdkCard title="PropertyListRoot" to="/programmable/sdk/api/components/property-list-root" description="Headless property list primitive." />
<SdkCard title="PropertyListItem" to="/programmable/sdk/api/components/property-list-item" description="Single fills, strokes, or effects row primitive." />
<SdkCard title="PositionControlsRoot" to="/programmable/sdk/api/components/position-controls-root" description="Position, size, and transform controls." />

View file

@ -1,9 +1,4 @@
import type { Loader } from 'vitepress'
import {
readComponentMeta,
type SdkComponentMeta
} from '../../../../.vitepress/sdk/component-meta'
import { defineComponentMetaLoader } from '#docs/sdk/component-meta'
const sources = [
'packages/vue/src/primitives/NumberField/NumberFieldRoot.vue',
@ -11,13 +6,4 @@ const sources = [
'packages/vue/src/primitives/NumberField/NumberFieldValue.vue'
]
export interface NumberFieldComponentData {
components: SdkComponentMeta[]
}
export default {
watch: sources.map((source) => `../../../../../../${source}`),
load(): NumberFieldComponentData {
return { components: sources.map(readComponentMeta) }
}
} satisfies Loader
export default defineComponentMetaLoader(sources)

View file

@ -4,7 +4,7 @@ description: Headless numeric field primitives with scrubbing, expressions, and
---
<script setup lang="ts">
import NumberFieldDemo from '../../../../../vue/src/primitives/NumberField/demo/NumberFieldDemo.vue'
import NumberFieldDemo from '#vue/primitives/NumberField/demo/NumberFieldDemo.vue'
import { data } from './number-field.data'
</script>

View file

@ -1,36 +1,68 @@
---
title: PropertyListRoot
description: Headless structural primitive for fills, strokes, and effects list UIs.
title: PropertyList
description: Precisely typed headless list anatomy for fills, strokes, and effects.
---
# PropertyListRoot
<script setup lang="ts">
import { data } from './property-list.data'
</script>
`PropertyListRoot` is a headless structural primitive for array-based property editors.
# PropertyList
It is intended for property UIs like:
PropertyList is a controlled, headless list primitive for fills, strokes, and effects. The
`propKey` discriminator gives slots and actions exact `Fill`, `Stroke`, or `Effect` types. Editor
mutation and undo behavior stay in `useEditorPropertyList()` or an application adapter.
- fills
- strokes
- effects
## Anatomy
It provides slot props for:
- `PropertyListRoot` — controlled items, identity, mixed state, and semantic events
- `PropertyListItem` — exact item type plus `data-hidden` and `data-dragging`
- `PropertyListAdd` — adds a typed item
- `PropertyListRemove` — removes an indexed item
- `PropertyListVisibility` — toggles indexed visibility and exposes `aria-pressed`
- current items
- mixed-state detection
- add/remove/update/patch operations
- visibility toggling per item
```vue twoslash
<script setup lang="ts">
import { ref } from 'vue'
import type { Fill } from '@open-pencil/scene-graph'
import {
PropertyListItem,
PropertyListRemove,
PropertyListRoot
} from '@open-pencil/vue'
## Usage
const fills = ref<Fill[]>([])
</script>
```vue
<PropertyListRoot prop-key="fills" v-slot="{ items, add, remove }">
<div v-for="(fill, index) in items" :key="index">
<button @click="remove(index)">Remove</button>
</div>
<button @click="add(defaultFill)">Add fill</button>
</PropertyListRoot>
<template>
<PropertyListRoot
prop-key="fills"
:items="fills"
@remove="fills.splice($event, 1)"
v-slot="{ items }"
>
<PropertyListItem
v-for="(_, index) in items"
:key="index"
prop-key="fills"
:index="index"
v-slot="{ item }"
>
<span>{{ item?.type }}</span>
<PropertyListRemove prop-key="fills" :index="index">Remove</PropertyListRemove>
</PropertyListItem>
</PropertyListRoot>
</template>
```
## Related APIs
See the [PropertySection demo](./property-section) for the shared interactive state matrix.
- [SDK API Overview](../)
## Editor adapter
OpenPencil panels use `useEditorPropertyList(propKey)` to connect controlled events to selection,
multi-node updates, undo batching, and reordering. Third-party SDK consumers can provide their own
state adapter without an OpenPencil editor context.
## Generated API reference
<SdkComponentAPI :components="data.components" />

View file

@ -0,0 +1,11 @@
import { defineComponentMetaLoader } from '#docs/sdk/component-meta'
const sources = [
'packages/vue/src/primitives/PropertyList/PropertyListRoot.vue',
'packages/vue/src/primitives/PropertyList/PropertyListItem.vue',
'packages/vue/src/primitives/PropertyList/PropertyListAdd.vue',
'packages/vue/src/primitives/PropertyList/PropertyListRemove.vue',
'packages/vue/src/primitives/PropertyList/PropertyListVisibility.vue'
]
export default defineComponentMetaLoader(sources)

View file

@ -0,0 +1,12 @@
import { defineComponentMetaLoader } from '#docs/sdk/component-meta'
const sources = [
'packages/vue/src/primitives/PropertySection/PropertySectionRoot.vue',
'packages/vue/src/primitives/PropertySection/PropertySectionHeader.vue',
'packages/vue/src/primitives/PropertySection/PropertySectionTitle.vue',
'packages/vue/src/primitives/PropertySection/PropertySectionActions.vue',
'packages/vue/src/primitives/PropertySection/PropertySectionContent.vue',
'packages/vue/src/primitives/PropertySection/PropertySectionEmptyAction.vue'
]
export default defineComponentMetaLoader(sources)

View file

@ -0,0 +1,49 @@
---
title: PropertySection
description: Headless collapsible anatomy for property-panel sections.
---
<script setup lang="ts">
import PropertyPrimitivesDemo from '#vue/primitives/PropertySection/demo/PropertyPrimitivesDemo.vue'
import { data } from './property-section.data'
</script>
# PropertySection
PropertySection supplies collapsible section anatomy and canonical open, empty, and disabled state
attributes without imposing presentation.
<PropertyPrimitivesDemo />
## Anatomy
- `PropertySectionRoot` — controlled or uncontrolled Collapsible state
- `PropertySectionHeader` — structural header container
- `PropertySectionTitle` — accessible Collapsible trigger
- `PropertySectionActions` — sibling action area, avoiding nested buttons
- `PropertySectionContent` — collapsible content region
- `PropertySectionEmptyAction` — empty-only action that opens before emitting `activate`
```vue twoslash
<script setup lang="ts">
import {
PropertySectionContent,
PropertySectionHeader,
PropertySectionRoot,
PropertySectionTitle
} from '@open-pencil/vue'
</script>
<template>
<PropertySectionRoot default-open>
<PropertySectionHeader>
<PropertySectionTitle>Layout</PropertySectionTitle>
</PropertySectionHeader>
<PropertySectionContent>Panel fields</PropertySectionContent>
</PropertySectionRoot>
</template>
```
## Generated API reference
<SdkComponentAPI :components="data.components" />

View file

@ -0,0 +1,8 @@
import { defineComponentMetaLoader } from '#docs/sdk/component-meta'
const sources = [
'packages/vue/src/primitives/SegmentedControl/SegmentedControlRoot.vue',
'packages/vue/src/primitives/SegmentedControl/SegmentedControlItem.vue'
]
export default defineComponentMetaLoader(sources)

View file

@ -0,0 +1,43 @@
---
title: SegmentedControl
description: Accessible selection and action-only segmented controls.
---
<script setup lang="ts">
import { data } from './segmented-control.data'
</script>
# SegmentedControl
SegmentedControl delegates selection, roving focus, and keyboard behavior to Reka UI. Single and
multiple modes retain selection; action mode emits commands without toggle state.
## Selection mode
```vue twoslash
<script setup lang="ts">
import { ref } from 'vue'
import { SegmentedControlItem, SegmentedControlRoot } from '@open-pencil/vue'
const alignment = ref('left')
</script>
<template>
<SegmentedControlRoot v-model="alignment" aria-label="Alignment">
<SegmentedControlItem value="left">Left</SegmentedControlItem>
<SegmentedControlItem value="center">Center</SegmentedControlItem>
<SegmentedControlItem value="right">Right</SegmentedControlItem>
</SegmentedControlRoot>
</template>
```
## Action mode
Use `mode="action"` for grouped commands such as flip and rotate. Arrow keys move focus, while
Space or Enter emits `action(value)` without leaving a selected segment.
The complete state matrix is shown in the [PropertySection demo](./property-section).
## Generated API reference
<SdkComponentAPI :components="data.components" />

View file

@ -28,6 +28,8 @@ This package is organized by domain.
- `LayerTree/`
- `PageList/`
- `PropertyList/`
- `PropertySection/`
- `SegmentedControl/`
- `NumberField/`
- `Toolbar/`
@ -47,6 +49,7 @@ These contain structural/headless primitives and local helpers.
- `useEffectsControls`
- `useNodeProps`
- `usePropScrub`
- `useEditorPropertyList`
### Variables
@ -82,11 +85,13 @@ Examples:
- `PageListRoot`
- `PropertyListRoot`
- `PropertySectionRoot`
- `SegmentedControlRoot`
- `ToolbarRoot`
### Avoid broad context-dump slots
Prefer focused slot props or direct composable usage over giant `v-slot="ctx"` payloads.
Prefer focused slot props or direct composable usage over giant `v-slot="ctx"` payloads. Controlled primitives such as `PropertyListRoot` emit semantic events; editor selection and undo wiring belongs in an adapter or control composable, not the primitive.
## App vs SDK responsibility

View file

@ -49,19 +49,31 @@ const { x, y, width, height, updateProp, commitProp } = usePosition()
```vue
<script setup lang="ts">
import { PropertyListRoot, useFillControls } from '@open-pencil/vue'
import {
PropertyListRoot,
useEditorPropertyList,
useFillControls
} from '@open-pencil/vue'
const fillControls = useFillControls()
const fills = useEditorPropertyList('fills')
</script>
<template>
<PropertyListRoot prop-key="fills" v-slot="{ items, add, remove }">
<PropertyListRoot
prop-key="fills"
:items="fills.items.value"
:mixed="fills.isMixed.value"
@add="fills.actions.add"
@remove="fills.actions.remove"
v-slot="{ items, actions }"
>
<div v-for="(fill, index) in items" :key="index">
{{ fill.type }}
<button @click="remove(index)">Remove</button>
<button @click="actions.remove(index)">Remove</button>
</div>
<button @click="add(fillControls.defaultFill)">Add fill</button>
<button @click="actions.add(fillControls.defaultFill)">Add fill</button>
</PropertyListRoot>
</template>
```

View file

@ -1,10 +0,0 @@
---
title: BindableValue
description: Provider-driven value binding primitives for custom editor controls.
---
# BindableValue
The generated BindableValue API reference and interactive demo currently share one canonical source.
[Open the BindableValue reference](/programmable/sdk/api/components/bindable-value)

View file

@ -1,10 +0,0 @@
---
title: NumberField
description: Headless numeric field with scrubbing, expressions, and keyboard stepping.
---
# NumberField
The generated NumberField API reference and interactive demo currently share one canonical source.
[Open the NumberField reference](/programmable/sdk/api/components/number-field)

View file

@ -82,6 +82,8 @@ Main structural primitives include:
- `LayerTreeRoot`
- `PageListRoot`
- `PropertyListRoot`
- `PropertySectionRoot`
- `SegmentedControlRoot`
- `ToolbarRoot`
- `ColorPickerRoot`
- `FillPickerRoot`
@ -93,6 +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
undo through `useEditorPropertyList()`.
## Public API tiers
@ -139,6 +143,11 @@ These are the main APIs most SDK consumers should start with.
- `LayerTreeRoot`
- `PageListRoot`
- `PropertyListRoot`
- `PropertyListItem`
- `PropertyListAdd` / `PropertyListRemove` / `PropertyListVisibility`
- `PropertySectionRoot` / `PropertySectionHeader` / `PropertySectionTitle`
- `PropertySectionActions` / `PropertySectionContent` / `PropertySectionEmptyAction`
- `SegmentedControlRoot` / `SegmentedControlItem`
- `ToolbarRoot`
- `NumberFieldRoot`
- `NumberFieldInput`
@ -156,6 +165,7 @@ These are the main APIs most SDK consumers should start with.
These exports are intentionally public, but they are lower-level or more specialized.
- `useNodeProps()`
- `useEditorPropertyList()`
- `useSceneComputed()`
- `useColorVariableBinding()`
- `provideBindingProvider()`

View file

@ -0,0 +1 @@
export { useEditorPropertyList } from '#vue/controls/property-list/use'

View file

@ -0,0 +1,163 @@
import { computed } from 'vue'
import type { SceneNode } from '@open-pencil/scene-graph'
import { useNodeProps } from '#vue/controls/node-props/use'
import { useUndoBatch } from '#vue/controls/undo-batch/use'
import { useEditor } from '#vue/editor/context'
import { useSceneComputed } from '#vue/internal/scene-computed/use'
import type {
PropertyListActions,
PropertyListItemFor,
PropertyListKey,
PropertyListPatchFor
} from '#vue/primitives/PropertyList/types.ts'
function moveItem<T>(items: T[], fromIndex: number, toIndex: number): T[] {
if (fromIndex === toIndex || fromIndex < 0 || fromIndex >= items.length) return items
const next = [...items]
const moved = next.splice(fromIndex, 1)[0]
if (moved === undefined) return items
next.splice(Math.max(0, Math.min(toIndex, next.length)), 0, moved)
return next
}
export function useEditorPropertyList<K extends PropertyListKey>(propKey: K) {
const editor = useEditor()
const { isArrayMixed } = useNodeProps()
const batch = useUndoBatch(editor.undo)
const selectedNodes = useSceneComputed(() => {
void editor.state.sceneVersion
return editor.getSelectedNodes()
})
const activeNode = useSceneComputed<SceneNode | null>(() => {
void editor.state.sceneVersion
return selectedNodes.value[0] ?? null
})
const isMulti = computed(() => selectedNodes.value.length > 1)
const active = computed(() => selectedNodes.value.length > 0)
const isMixed = computed(() => isArrayMixed(propKey))
const items = useSceneComputed<PropertyListItemFor<K>[]>(() => {
void editor.state.sceneVersion
if (isMixed.value) return []
return (activeNode.value?.[propKey] ?? []) as PropertyListItemFor<K>[]
})
function targetNodes(): SceneNode[] {
if (isMulti.value) return selectedNodes.value
return activeNode.value ? [activeNode.value] : []
}
function propArray(node: SceneNode): PropertyListItemFor<K>[] {
return node[propKey] as PropertyListItemFor<K>[]
}
function updateArray(node: SceneNode, value: PropertyListItemFor<K>[], label: string) {
editor.updateNodeWithUndo(node.id, { [propKey]: value } as Partial<SceneNode>, label)
}
function add(item: PropertyListItemFor<K>) {
batch.flush()
const nodes = targetNodes()
const label = isMulti.value ? `Set ${propKey}` : `Add ${propKey}`
const apply = () => {
for (const node of nodes) {
const nextItem = structuredClone(item)
const next = isMulti.value ? [nextItem] : [...propArray(node), nextItem]
updateArray(node, next, label)
}
}
if (nodes.length > 1) editor.undo.runBatch(label, apply)
else apply()
}
function remove(index: number) {
batch.flush()
const nodes = targetNodes()
const label = `Remove ${propKey}`
const apply = () => {
for (const node of nodes) {
updateArray(
node,
propArray(node).filter((_, itemIndex) => itemIndex !== index),
label
)
}
}
if (nodes.length > 1) editor.undo.runBatch(label, apply)
else apply()
}
function update(index: number, item: PropertyListItemFor<K>) {
const nodes = targetNodes()
if (nodes.length === 0) return
batch.ensure(
`update:${propKey}:${index}:${nodes.map((node) => node.id).join(',')}`,
`Change ${propKey}`
)
for (const node of nodes) {
const next = [...propArray(node)]
next[index] = structuredClone(item)
updateArray(node, next, `Change ${propKey}`)
}
}
function patch(index: number, changes: PropertyListPatchFor<K>) {
const nodes = targetNodes()
if (nodes.length === 0) return
batch.ensure(
`patch:${propKey}:${index}:${nodes.map((node) => node.id).join(',')}`,
`Change ${propKey}`
)
for (const node of nodes) {
const current = propArray(node)[index]
if (!current) continue
const next = [...propArray(node)]
next[index] = { ...current, ...structuredClone(changes) }
updateArray(node, next, `Change ${propKey}`)
}
}
function toggleVisibility(index: number) {
batch.flush()
const nodes = targetNodes()
if (nodes.length === 0) return
const apply = () => {
for (const node of nodes) {
const liveNode = editor.getNode(node.id)
if (!liveNode) continue
const current = propArray(liveNode)[index]
if (!current) continue
const next = [...propArray(liveNode)]
next[index] = { ...current, visible: !current.visible }
updateArray(liveNode, next, `Toggle ${propKey} visibility`)
}
}
if (nodes.length > 1) editor.undo.runBatch(`Toggle ${propKey} visibility`, apply)
else apply()
}
function reorder(fromIndex: number, toIndex: number) {
batch.flush()
const nodes = targetNodes()
if (nodes.length === 0 || fromIndex === toIndex) return
const apply = () => {
for (const node of nodes) {
updateArray(node, moveItem(propArray(node), fromIndex, toIndex), `Reorder ${propKey}`)
}
}
if (nodes.length > 1) editor.undo.runBatch(`Reorder ${propKey}`, apply)
else apply()
}
const actions: PropertyListActions<K> = {
add,
remove,
update,
patch,
toggleVisibility,
reorder
}
return { items, isMixed, isMulti, active, activeNode, actions }
}

View file

@ -141,8 +141,63 @@ export type { LayoutControlsContext } from '#vue/primitives/LayoutControls'
export { AppearanceControlsRoot } from '#vue/primitives/AppearanceControls'
export { PageListRoot } from '#vue/primitives/PageList'
export { PositionControlsRoot } from '#vue/primitives/PositionControls'
export { PropertyListRoot, PropertyListItem, usePropertyList } from '#vue/primitives/PropertyList'
export type { PropertyListContext } from '#vue/primitives/PropertyList'
export { useEditorPropertyList } from '#vue/controls/property-list'
export {
PropertyListRoot,
PropertyListItem,
PropertyListAdd,
PropertyListRemove,
PropertyListVisibility,
usePropertyList
} from '#vue/primitives/PropertyList'
export type {
PropertyListActions,
PropertyListContext,
PropertyListIdentity,
PropertyListItemActions,
PropertyListItemFor,
PropertyListItemMap,
PropertyListItemSlotProps,
PropertyListKey,
PropertyListPartProps,
PropertyListPatchFor,
PropertyListRootProps,
PropertyListRootSlotProps,
PropertyListRootSlots
} from '#vue/primitives/PropertyList'
export {
PropertySectionRoot,
PropertySectionHeader,
PropertySectionTitle,
PropertySectionActions,
PropertySectionContent,
PropertySectionEmptyAction,
usePropertySection
} from '#vue/primitives/PropertySection'
export type {
PropertySectionActionAPI,
PropertySectionContext,
PropertySectionPartProps,
PropertySectionRootProps,
PropertySectionRootSlots,
PropertySectionSlotProps,
PropertySectionStateAttrs
} from '#vue/primitives/PropertySection'
export {
SegmentedControlRoot,
SegmentedControlItem,
useSegmentedControl
} from '#vue/primitives/SegmentedControl'
export type {
SegmentedControlContext,
SegmentedControlItemProps,
SegmentedControlItemSlotProps,
SegmentedControlItemSlots,
SegmentedControlMode,
SegmentedControlOrientation,
SegmentedControlRootProps,
SegmentedControlRootSlots
} from '#vue/primitives/SegmentedControl'
export {
BindableValueRoot,
BindableValueTrigger,

View file

@ -1,4 +1,5 @@
import type { Component, ComputedRef, Ref, VNode } from 'vue'
import type { PrimitiveProps } from 'reka-ui'
import type { ComputedRef, Ref, VNode } from 'vue'
import type { Variable } from '@open-pencil/scene-graph'
@ -10,12 +11,7 @@ import type {
BoundEditPolicy
} from '#vue/controls/binding-provider/types'
export interface BindableValueTriggerProps {
/** Element or component rendered by the trigger. @default 'button' */
as?: string | Component
/** Merge trigger behavior into the single child element. @default false */
asChild?: boolean
}
export type BindableValueTriggerProps = PrimitiveProps
export interface BindableValueRootProps<V = unknown> {
/** Binding implementation. Falls back to the nearest injected provider. */

View file

@ -0,0 +1,39 @@
<script setup lang="ts" generic="K extends PropertyListKey">
import { computed } from 'vue'
import { Primitive } from 'reka-ui'
import { usePropertyListPart } from '#vue/primitives/PropertyList/context'
import type { PropertyListItemFor, PropertyListKey, PropertyListPartProps } from './types'
const {
propKey,
item,
as = 'button',
asChild = false,
disabled: disabledProp = false
} = defineProps<PropertyListPartProps<K> & { item: PropertyListItemFor<K> }>()
const emit = defineEmits<{ add: [item: PropertyListItemFor<K>] }>()
const context = usePropertyListPart(propKey)
const disabled = computed(() => disabledProp || context.disabled.value)
defineOptions({ inheritAttrs: false })
function add() {
if (disabled.value) return
emit('add', item)
context.actions.add(item)
}
</script>
<template>
<Primitive
v-bind="$attrs"
:as="as"
:as-child="asChild"
:type="!asChild && as === 'button' ? 'button' : undefined"
:disabled="disabled"
data-slot="add"
@click="add"
>
<slot />
</Primitive>
</template>

View file

@ -1,39 +1,91 @@
<script setup lang="ts">
import { usePropertyList } from '#vue/primitives/PropertyList/context'
<script setup lang="ts" generic="K extends PropertyListKey">
import { computed } from 'vue'
import { Primitive } from 'reka-ui'
const { index } = defineProps<{
import { usePropertyListPart } from '#vue/primitives/PropertyList/context'
import type {
PropertyListItemActions,
PropertyListItemFor,
PropertyListItemSlotProps,
PropertyListKey,
PropertyListPatchFor
} from './types'
import type { Component, VNode } from 'vue'
const {
propKey,
index,
dragging = false,
disabled: disabledProp = false,
as = 'div',
asChild = false
} = defineProps<{
propKey: K
index: number
dragging?: boolean
disabled?: boolean
as?: string | Component
asChild?: boolean
}>()
const emit = defineEmits<{
update: [index: number, item: unknown]
patch: [index: number, changes: Record<string, unknown>]
update: [index: number, item: PropertyListItemFor<K>]
patch: [index: number, changes: PropertyListPatchFor<K>]
remove: [index: number]
toggleVisibility: [index: number]
}>()
defineSlots<{
default(props: PropertyListItemSlotProps<K>): VNode[]
}>()
defineOptions({ inheritAttrs: false })
const { update, patch, remove, toggleVisibility } = usePropertyList()
const context = usePropertyListPart(propKey)
const actions = {
update: (item: unknown) => {
emit('update', index, item)
update(index, item)
const item = computed(() => context.items.value[index])
const hidden = computed(() => item.value?.visible === false)
const disabled = computed(() => disabledProp || context.disabled.value)
const actions: PropertyListItemActions<K> = {
update: (nextItem) => {
if (disabled.value) return
emit('update', index, nextItem)
context.actions.update(index, nextItem)
},
patch: (changes: Record<string, unknown>) => {
patch: (changes) => {
if (disabled.value) return
emit('patch', index, changes)
patch(index, changes)
context.actions.patch(index, changes)
},
remove: () => {
if (disabled.value) return
emit('remove', index)
remove(index)
context.actions.remove(index)
},
toggleVisibility: () => {
if (disabled.value) return
emit('toggleVisibility', index)
toggleVisibility(index)
context.actions.toggleVisibility(index)
}
}
const slotProps = computed<PropertyListItemSlotProps<K>>(() => ({
item: item.value,
index,
hidden: hidden.value,
dragging,
disabled: disabled.value,
actions
}))
</script>
<template>
<slot :index="index" :actions="actions" />
<Primitive
v-bind="$attrs"
:as="as"
:as-child="asChild"
:data-hidden="hidden ? '' : undefined"
:data-dragging="dragging ? '' : undefined"
:data-disabled="disabled ? '' : undefined"
data-slot="item"
>
<slot v-bind="slotProps" />
</Primitive>
</template>

View file

@ -0,0 +1,39 @@
<script setup lang="ts" generic="K extends PropertyListKey">
import { computed } from 'vue'
import { Primitive } from 'reka-ui'
import { usePropertyListPart } from '#vue/primitives/PropertyList/context'
import type { PropertyListKey, PropertyListPartProps } from './types'
const {
propKey,
index,
as = 'button',
asChild = false,
disabled: disabledProp = false
} = defineProps<PropertyListPartProps<K> & { index: number }>()
const emit = defineEmits<{ remove: [index: number] }>()
const context = usePropertyListPart(propKey)
const disabled = computed(() => disabledProp || context.disabled.value)
defineOptions({ inheritAttrs: false })
function remove() {
if (disabled.value) return
emit('remove', index)
context.actions.remove(index)
}
</script>
<template>
<Primitive
v-bind="$attrs"
:as="as"
:as-child="asChild"
:type="!asChild && as === 'button' ? 'button' : undefined"
:disabled="disabled"
data-slot="remove"
@click="remove"
>
<slot />
</Primitive>
</template>

View file

@ -1,187 +1,72 @@
<script setup lang="ts" generic="K extends ArrayPropKey">
<script setup lang="ts" generic="K extends PropertyListKey">
import { computed } from 'vue'
import { useEditor } from '#vue/editor/context'
import { useNodeProps } from '#vue/controls/node-props/use'
import { useUndoBatch } from '#vue/controls/undo-batch/use'
import { useSceneComputed } from '#vue/internal/scene-computed/use'
import { providePropertyList } from '#vue/primitives/PropertyList/context'
import type {
PropertyListActions,
PropertyListIdentity,
PropertyListItemFor,
PropertyListKey,
PropertyListPatchFor,
PropertyListRootProps,
PropertyListRootSlots
} from './types'
import type { Effect, Fill, SceneNode, Stroke } from '@open-pencil/scene-graph'
import type { ArrayPropKey } from '#vue/primitives/PropertyList/context'
type ArrayItemFor<T extends ArrayPropKey> = T extends 'fills'
? Fill
: T extends 'strokes'
? Stroke
: Effect
type ArrayItemType = Fill | Stroke | Effect
type PropertyListItem = ArrayItemFor<K>
type PropertyListPatch = Partial<PropertyListItem>
const { propKey } = defineProps<{
propKey: K
label?: string
}>()
const emit = defineEmits<{
add: [item: PropertyListItem]
remove: [index: number]
update: [index: number, item: PropertyListItem]
patch: [index: number, changes: PropertyListPatch]
toggleVisibility: [index: number]
}>()
const editor = useEditor()
const { isArrayMixed } = useNodeProps()
const batch = useUndoBatch(editor.undo)
const selectedNodes = useSceneComputed(() => {
void editor.state.sceneVersion
return editor.getSelectedNodes()
})
const activeNode = useSceneComputed<SceneNode | null>(() => {
void editor.state.sceneVersion
return editor.getSelectedNode() ?? selectedNodes.value[0] ?? null
})
const isMulti = computed(() => selectedNodes.value.length > 1)
const active = computed(() => selectedNodes.value.length > 0)
const isMixed = computed(() => isArrayMixed(propKey))
const items = useSceneComputed<PropertyListItem[]>(() => {
void editor.state.sceneVersion
if (isMixed.value) return []
return (activeNode.value?.[propKey] ?? []) as PropertyListItem[]
})
function targetNodes(): SceneNode[] {
if (isMulti.value) return selectedNodes.value
return activeNode.value ? [activeNode.value] : []
}
function propArray(node: SceneNode): PropertyListItem[] {
return node[propKey] as PropertyListItem[]
}
function add(defaults: PropertyListItem) {
batch.flush()
emit('add', defaults)
for (const n of targetNodes()) {
const arr = isMulti.value ? [defaults] : [...propArray(n), defaults]
editor.updateNodeWithUndo(
n.id,
{ [propKey]: arr } as Partial<SceneNode>,
isMulti.value ? `Set ${propKey}` : `Add ${propKey}`
)
}
}
function remove(index: number) {
batch.flush()
emit('remove', index)
for (const n of targetNodes()) {
editor.updateNodeWithUndo(
n.id,
{
[propKey]: propArray(n).filter((_, i) => i !== index)
} as Partial<SceneNode>,
`Remove ${propKey}`
)
}
}
function update(index: number, item: PropertyListItem) {
emit('update', index, item)
const nodes = targetNodes()
if (nodes.length === 0) return
const key = `update:${propKey}:${index}:${nodes.map((n) => n.id).join(',')}`
batch.ensure(key, `Change ${propKey}`)
for (const n of nodes) {
const arr = [...propArray(n)]
arr[index] = item
editor.updateNodeWithUndo(n.id, { [propKey]: arr } as Partial<SceneNode>, `Change ${propKey}`)
}
}
function patch(index: number, changes: PropertyListPatch) {
emit('patch', index, changes)
const nodes = targetNodes()
if (nodes.length === 0) return
const key = `patch:${propKey}:${index}:${nodes.map((n) => n.id).join(',')}`
batch.ensure(key, `Change ${propKey}`)
for (const n of nodes) {
const arr = [...propArray(n)]
arr[index] = { ...arr[index], ...changes } as PropertyListItem
editor.updateNodeWithUndo(n.id, { [propKey]: arr } as Partial<SceneNode>, `Change ${propKey}`)
}
}
function toggleVisibility(index: number) {
batch.flush()
emit('toggleVisibility', index)
const nodes = targetNodes()
if (nodes.length === 0) return
const updateVisibility = () => {
for (const n of nodes) {
const liveNode = editor.getNode(n.id)
if (!liveNode) continue
const arr = liveNode[propKey] as Array<{ visible: boolean }>
if (!arr[index]) continue
const newArr = [...liveNode[propKey]] as Array<{ visible: boolean }>
newArr[index] = { ...newArr[index], visible: !arr[index].visible }
editor.updateNodeWithUndo(
n.id,
{ [propKey]: newArr as ArrayItemType[] } as Partial<SceneNode>,
`Toggle ${propKey} visibility`
)
}
}
if (nodes.length > 1) editor.undo.runBatch(`Toggle ${propKey} visibility`, updateVisibility)
else updateVisibility()
}
const actions = {
add,
remove,
update,
patch,
toggleVisibility
}
providePropertyList({
editor,
const {
propKey,
items,
isMixed,
activeNode,
isMulti,
add,
remove,
update,
patch,
toggleVisibility
})
defineSlots<{
default?: (props: {
items: PropertyListItem[]
isMixed: boolean
isMulti: boolean
activeNode: SceneNode | null
actions: typeof actions
}) => unknown
items: itemsProp,
mixed = false,
disabled: disabledProp = false,
getKey
} = defineProps<PropertyListRootProps<K>>()
const emit = defineEmits<{
add: [item: PropertyListItemFor<K>]
remove: [index: number]
update: [index: number, item: PropertyListItemFor<K>]
patch: [index: number, changes: PropertyListPatchFor<K>]
toggleVisibility: [index: number]
reorder: [fromIndex: number, toIndex: number]
}>()
defineSlots<PropertyListRootSlots<K>>()
const items = computed(() => itemsProp)
const isMixed = computed(() => mixed)
const disabled = computed(() => disabledProp)
function keyOf(item: PropertyListItemFor<K>, index: number): PropertyListIdentity {
return getKey?.(item, index) ?? index
}
const actions: PropertyListActions<K> = {
add: (item) => {
if (!disabled.value) emit('add', item)
},
remove: (index) => {
if (!disabled.value) emit('remove', index)
},
update: (index, item) => {
if (!disabled.value) emit('update', index, item)
},
patch: (index, changes) => {
if (!disabled.value) emit('patch', index, changes)
},
toggleVisibility: (index) => {
if (!disabled.value) emit('toggleVisibility', index)
},
reorder: (fromIndex, toIndex) => {
if (!disabled.value) emit('reorder', fromIndex, toIndex)
}
}
providePropertyList({ propKey, items, isMixed, disabled, keyOf, actions })
</script>
<template>
<slot
v-if="active"
:items="items"
:is-mixed="isMixed"
:is-multi="isMulti"
:active-node="activeNode"
:disabled="disabled"
:key-of="keyOf"
:actions="actions"
/>
</template>

View file

@ -0,0 +1,42 @@
<script setup lang="ts" generic="K extends PropertyListKey">
import { computed } from 'vue'
import { Primitive } from 'reka-ui'
import { usePropertyListPart } from '#vue/primitives/PropertyList/context'
import type { PropertyListKey, PropertyListPartProps } from './types'
const {
propKey,
index,
as = 'button',
asChild = false,
disabled: disabledProp = false
} = defineProps<PropertyListPartProps<K> & { index: number }>()
const emit = defineEmits<{ toggle: [index: number] }>()
const context = usePropertyListPart(propKey)
const disabled = computed(() => disabledProp || context.disabled.value)
const visible = computed(() => context.items.value[index]?.visible !== false)
defineOptions({ inheritAttrs: false })
function toggle() {
if (disabled.value) return
emit('toggle', index)
context.actions.toggleVisibility(index)
}
</script>
<template>
<Primitive
v-bind="$attrs"
:as="as"
:as-child="asChild"
:type="!asChild && as === 'button' ? 'button' : undefined"
:disabled="disabled"
:aria-pressed="visible"
:data-hidden="visible ? undefined : ''"
data-slot="visibility"
@click="toggle"
>
<slot :visible="visible" />
</Primitive>
</template>

View file

@ -1,32 +1,27 @@
import { type ComputedRef, type InjectionKey, inject, provide } from 'vue'
import { inject, provide } from 'vue'
import type { InjectionKey } from 'vue'
import type { Editor } from '@open-pencil/core/editor'
import type { SceneNode } from '@open-pencil/scene-graph'
import type { PropertyListContext, PropertyListKey } from './types'
export type ArrayPropKey = 'fills' | 'strokes' | 'effects'
const PROPERTY_LIST_KEY: InjectionKey<PropertyListContext> = Symbol('PropertyList')
export interface PropertyListContext<T = unknown> {
editor: Editor
propKey: ArrayPropKey
items: ComputedRef<T[]>
isMixed: ComputedRef<boolean>
activeNode: ComputedRef<SceneNode | null>
isMulti: ComputedRef<boolean>
add: (defaults: T) => void
remove: (index: number) => void
update: (index: number, item: T) => void
patch: (index: number, changes: Partial<T>) => void
toggleVisibility: (index: number) => void
export function providePropertyList<K extends PropertyListKey>(context: PropertyListContext<K>) {
provide(PROPERTY_LIST_KEY, context as PropertyListContext)
}
const PROPERTY_LIST_KEY: InjectionKey<PropertyListContext> = Symbol('property-list')
export function providePropertyList<T>(ctx: PropertyListContext<T>) {
provide(PROPERTY_LIST_KEY, ctx as PropertyListContext)
export function usePropertyList<K extends PropertyListKey>(): PropertyListContext<K> {
const context = inject(PROPERTY_LIST_KEY)
if (!context)
throw new Error('[open-pencil] PropertyList part must be used inside PropertyListRoot')
return context as PropertyListContext<K>
}
export function usePropertyList<T = unknown>(): PropertyListContext<T> {
const ctx = inject(PROPERTY_LIST_KEY)
if (!ctx) throw new Error('[open-pencil] usePropertyList() called outside <PropertyListRoot>')
return ctx as PropertyListContext<T>
export function usePropertyListPart<K extends PropertyListKey>(propKey: K): PropertyListContext<K> {
const context = usePropertyList<K>()
if (context.propKey !== propKey) {
throw new Error(
`[open-pencil] PropertyList part propKey must match PropertyListRoot (${propKey})`
)
}
return context
}

View file

@ -1,4 +1,21 @@
export { default as PropertyListRoot } from '#vue/primitives/PropertyList/PropertyListRoot.vue'
export { default as PropertyListItem } from '#vue/primitives/PropertyList/PropertyListItem.vue'
export { default as PropertyListAdd } from '#vue/primitives/PropertyList/PropertyListAdd.vue'
export { default as PropertyListRemove } from '#vue/primitives/PropertyList/PropertyListRemove.vue'
export { default as PropertyListVisibility } from '#vue/primitives/PropertyList/PropertyListVisibility.vue'
export { usePropertyList } from '#vue/primitives/PropertyList/context'
export type { PropertyListContext } from '#vue/primitives/PropertyList/context'
export type {
PropertyListActions,
PropertyListContext,
PropertyListIdentity,
PropertyListItemActions,
PropertyListItemFor,
PropertyListItemMap,
PropertyListItemSlotProps,
PropertyListKey,
PropertyListPartProps,
PropertyListPatchFor,
PropertyListRootProps,
PropertyListRootSlotProps,
PropertyListRootSlots
} from '#vue/primitives/PropertyList/types.ts'

View file

@ -0,0 +1,86 @@
import type { Component, ComputedRef, VNode } from 'vue'
import type { Effect, Fill, Stroke } from '@open-pencil/scene-graph'
export interface PropertyListItemMap {
fills: Fill
strokes: Stroke
effects: Effect
}
export type PropertyListKey = keyof PropertyListItemMap
export type PropertyListItemFor<K extends PropertyListKey> = PropertyListItemMap[K]
export type PropertyListPatchFor<K extends PropertyListKey> = Partial<PropertyListItemFor<K>>
export type PropertyListIdentity = string | number
export interface PropertyListActions<K extends PropertyListKey> {
add(item: PropertyListItemFor<K>): void
remove(index: number): void
update(index: number, item: PropertyListItemFor<K>): void
patch(index: number, changes: PropertyListPatchFor<K>): void
toggleVisibility(index: number): void
reorder(fromIndex: number, toIndex: number): void
}
export interface PropertyListContext<K extends PropertyListKey = PropertyListKey> {
propKey: K
items: ComputedRef<PropertyListItemFor<K>[]>
isMixed: ComputedRef<boolean>
disabled: ComputedRef<boolean>
keyOf(item: PropertyListItemFor<K>, index: number): PropertyListIdentity
actions: PropertyListActions<K>
}
export interface PropertyListRootProps<K extends PropertyListKey> {
/** Discriminator that provides exact Fill, Stroke, or Effect types to slots and actions. */
propKey: K
/** Controlled list items. */
items: PropertyListItemFor<K>[]
/** Marks values across the current selection as inconsistent. @default false */
mixed?: boolean
/** Prevents item actions. @default false */
disabled?: boolean
/** Stable identity for keyed rows. Defaults to the item index. */
getKey?: (item: PropertyListItemFor<K>, index: number) => PropertyListIdentity
/** Optional accessible label exposed to consumers. */
label?: string
}
export interface PropertyListRootSlotProps<K extends PropertyListKey> {
items: PropertyListItemFor<K>[]
isMixed: boolean
disabled: boolean
keyOf(item: PropertyListItemFor<K>, index: number): PropertyListIdentity
actions: PropertyListActions<K>
}
export interface PropertyListRootSlots<K extends PropertyListKey> {
default?(props: PropertyListRootSlotProps<K>): VNode[]
}
export interface PropertyListItemActions<K extends PropertyListKey> {
update(item: PropertyListItemFor<K>): void
patch(changes: PropertyListPatchFor<K>): void
remove(): void
toggleVisibility(): void
}
export interface PropertyListPartProps<K extends PropertyListKey> {
/** Must match the nearest PropertyListRoot and preserves generic inference. */
propKey: K
/** Element or component rendered by this part. @default 'button' */
as?: string | Component
/** Merge behavior into the single child element. @default false */
asChild?: boolean
/** Prevent activation. @default false */
disabled?: boolean
}
export interface PropertyListItemSlotProps<K extends PropertyListKey> {
item: PropertyListItemFor<K> | undefined
index: number
hidden: boolean
dragging: boolean
disabled: boolean
actions: PropertyListItemActions<K>
}

View file

@ -0,0 +1,52 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { expect, userEvent, within } from 'storybook/test'
import PropertyPrimitivesDemo from './demo/PropertyPrimitivesDemo.vue'
const meta = {
title: 'Vue SDK/Primitives/Property Primitives',
component: PropertyPrimitivesDemo,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Headless PropertySection, SegmentedControl, and typed PropertyList composition states.'
}
}
}
} satisfies Meta<typeof PropertyPrimitivesDemo>
export default meta
type Story = StoryObj<typeof meta>
export const StateMatrix: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
const layer = canvas.getByRole('button', { name: 'Layer' })
await expect(canvas.getByText('Collapsible content')).toBeVisible()
await userEvent.click(layer)
await expect(layer).toHaveAttribute('data-state', 'closed')
await userEvent.click(canvas.getByRole('button', { name: 'Add first effect' }))
await expect(canvas.getByText('Drop shadow')).toBeVisible()
const center = canvas.getByRole('button', { name: 'center' })
await userEvent.click(center)
await expect(center).toHaveAttribute('data-state', 'on')
await userEvent.keyboard('{ArrowRight}{Enter}')
await expect(canvas.getByRole('button', { name: 'right' })).toHaveAttribute('data-state', 'on')
await userEvent.click(canvas.getByRole('button', { name: 'rotate-90' }))
await expect(canvas.getByText('Action: rotate-90')).toBeVisible()
const hiddenItem = canvas.getByText('Fill 2').closest('[data-slot="item"]')
await expect(hiddenItem).toHaveAttribute('data-hidden')
await userEvent.click(canvas.getByRole('button', { name: 'Show' }))
await expect(hiddenItem).not.toHaveAttribute('data-hidden')
await userEvent.click(canvas.getByRole('button', { name: 'Add fill' }))
await expect(canvas.getByText('Fill 3')).toBeVisible()
}
}

View file

@ -0,0 +1,21 @@
<script setup lang="ts">
import { Primitive } from 'reka-ui'
import { usePropertySection } from '#vue/primitives/PropertySection/context'
import type { PropertySectionPartProps } from '#vue/primitives/PropertySection/types'
const { as = 'div', asChild = false } = defineProps<PropertySectionPartProps>()
const ctx = usePropertySection()
defineOptions({ inheritAttrs: false })
</script>
<template>
<Primitive
v-bind="{ ...$attrs, ...ctx.stateAttrs.value }"
:as="as"
:as-child="asChild"
data-slot="actions"
>
<slot v-bind="ctx.slotProps.value" />
</Primitive>
</template>

View file

@ -0,0 +1,21 @@
<script setup lang="ts">
import { CollapsibleContent } from 'reka-ui'
import { usePropertySection } from '#vue/primitives/PropertySection/context'
import type { PropertySectionPartProps } from '#vue/primitives/PropertySection/types'
const { as = 'div', asChild = false } = defineProps<PropertySectionPartProps>()
const ctx = usePropertySection()
defineOptions({ inheritAttrs: false })
</script>
<template>
<CollapsibleContent
v-bind="{ ...$attrs, ...ctx.stateAttrs.value }"
:as="as"
:as-child="asChild"
data-slot="content"
>
<slot v-bind="ctx.slotProps.value" />
</CollapsibleContent>
</template>

View file

@ -0,0 +1,31 @@
<script setup lang="ts">
import { Primitive } from 'reka-ui'
import { usePropertySection } from '#vue/primitives/PropertySection/context'
import type { PropertySectionPartProps } from '#vue/primitives/PropertySection/types'
const { as = 'button', asChild = false } = defineProps<PropertySectionPartProps>()
const emit = defineEmits<{ activate: [] }>()
const ctx = usePropertySection()
defineOptions({ inheritAttrs: false })
function activate() {
if (ctx.disabled.value) return
ctx.actions.open()
emit('activate')
}
</script>
<template>
<Primitive
v-if="ctx.empty.value"
v-bind="{ ...$attrs, ...ctx.stateAttrs.value }"
:as="as"
:as-child="asChild"
:type="!asChild && as === 'button' ? 'button' : undefined"
data-slot="empty-action"
@click="activate"
>
<slot v-bind="ctx.slotProps.value" />
</Primitive>
</template>

View file

@ -0,0 +1,21 @@
<script setup lang="ts">
import { Primitive } from 'reka-ui'
import { usePropertySection } from '#vue/primitives/PropertySection/context'
import type { PropertySectionPartProps } from '#vue/primitives/PropertySection/types'
const { as = 'div', asChild = false } = defineProps<PropertySectionPartProps>()
const ctx = usePropertySection()
defineOptions({ inheritAttrs: false })
</script>
<template>
<Primitive
v-bind="{ ...$attrs, ...ctx.stateAttrs.value }"
:as="as"
:as-child="asChild"
data-slot="header"
>
<slot v-bind="ctx.slotProps.value" />
</Primitive>
</template>

View file

@ -0,0 +1,77 @@
<script setup lang="ts">
import { computed, getCurrentInstance, ref } from 'vue'
import { CollapsibleRoot } from 'reka-ui'
import { providePropertySection } from '#vue/primitives/PropertySection/context'
import type {
PropertySectionActionAPI,
PropertySectionRootProps,
PropertySectionRootSlots,
PropertySectionSlotProps,
PropertySectionStateAttrs
} from '#vue/primitives/PropertySection/types'
const {
open: openProp,
defaultOpen = true,
empty: emptyProp = false,
disabled: disabledProp = false,
unmountOnHide = false
} = defineProps<PropertySectionRootProps>()
const emit = defineEmits<{
'update:open': [open: boolean]
}>()
defineSlots<PropertySectionRootSlots>()
defineOptions({ inheritAttrs: false })
const vnodeProps = getCurrentInstance()?.vnode.props
const controlled = vnodeProps ? Object.hasOwn(vnodeProps, 'open') : false
const uncontrolledOpen = ref(defaultOpen)
const open = computed({
get: () => (controlled ? openProp : uncontrolledOpen.value),
set: (value: boolean) => {
if (!controlled) uncontrolledOpen.value = value
emit('update:open', value)
}
})
const empty = computed(() => emptyProp)
const disabled = computed(() => disabledProp)
const stateAttrs = computed<PropertySectionStateAttrs>(() => ({
'data-state': open.value ? 'open' : 'closed',
'data-empty': empty.value ? '' : undefined,
'data-disabled': disabled.value ? '' : undefined
}))
const actions: PropertySectionActionAPI = {
open: () => {
if (!disabled.value) open.value = true
},
close: () => {
if (!disabled.value) open.value = false
},
toggle: () => {
if (!disabled.value) open.value = !open.value
}
}
const slotProps = computed<PropertySectionSlotProps>(() => ({
open: open.value,
empty: empty.value,
stateAttrs: stateAttrs.value,
actions
}))
providePropertySection({ open, empty, disabled, stateAttrs, slotProps, actions })
</script>
<template>
<CollapsibleRoot
v-bind="{ ...$attrs, ...stateAttrs }"
:open="open"
:disabled="disabled"
:unmount-on-hide="unmountOnHide"
@update:open="open = $event"
>
<slot v-bind="slotProps" />
</CollapsibleRoot>
</template>

View file

@ -0,0 +1,21 @@
<script setup lang="ts">
import { CollapsibleTrigger } from 'reka-ui'
import { usePropertySection } from '#vue/primitives/PropertySection/context'
import type { PropertySectionPartProps } from '#vue/primitives/PropertySection/types'
const { as = 'button', asChild = false } = defineProps<PropertySectionPartProps>()
const ctx = usePropertySection()
defineOptions({ inheritAttrs: false })
</script>
<template>
<CollapsibleTrigger
v-bind="{ ...$attrs, ...ctx.stateAttrs.value }"
:as="as"
:as-child="asChild"
data-slot="title"
>
<slot v-bind="ctx.slotProps.value" />
</CollapsibleTrigger>
</template>

View file

@ -0,0 +1,5 @@
import { createContext } from '#vue/internal/create-context'
import type { PropertySectionContext } from '#vue/primitives/PropertySection/types'
export const [usePropertySection, providePropertySection] =
createContext<PropertySectionContext>('PropertySection')

View file

@ -0,0 +1,153 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { Fill } from '@open-pencil/scene-graph'
import PropertyListAdd from '#vue/primitives/PropertyList/PropertyListAdd.vue'
import PropertyListItem from '#vue/primitives/PropertyList/PropertyListItem.vue'
import PropertyListRemove from '#vue/primitives/PropertyList/PropertyListRemove.vue'
import PropertyListRoot from '#vue/primitives/PropertyList/PropertyListRoot.vue'
import PropertyListVisibility from '#vue/primitives/PropertyList/PropertyListVisibility.vue'
import PropertySectionActions from '#vue/primitives/PropertySection/PropertySectionActions.vue'
import PropertySectionContent from '#vue/primitives/PropertySection/PropertySectionContent.vue'
import PropertySectionEmptyAction from '#vue/primitives/PropertySection/PropertySectionEmptyAction.vue'
import PropertySectionHeader from '#vue/primitives/PropertySection/PropertySectionHeader.vue'
import PropertySectionRoot from '#vue/primitives/PropertySection/PropertySectionRoot.vue'
import PropertySectionTitle from '#vue/primitives/PropertySection/PropertySectionTitle.vue'
import SegmentedControlItem from '#vue/primitives/SegmentedControl/SegmentedControlItem.vue'
import SegmentedControlRoot from '#vue/primitives/SegmentedControl/SegmentedControlRoot.vue'
const alignment = ref('left')
const empty = ref(true)
const lastAction = ref('None')
const defaultFill: Fill = {
type: 'SOLID',
color: { r: 0.4, g: 0.5, b: 1, a: 1 },
opacity: 1,
visible: true,
blendMode: 'NORMAL'
}
const fills = ref<Fill[]>([
structuredClone(defaultFill),
{ ...structuredClone(defaultFill), visible: false }
])
function addFill(fill: Fill) {
fills.value.push(structuredClone(fill))
}
function removeFill(index: number) {
fills.value.splice(index, 1)
}
function toggleFill(index: number) {
const fill = fills.value[index]
if (fill) fill.visible = !fill.visible
}
</script>
<template>
<div
class="grid w-full max-w-[620px] gap-4 rounded-lg border border-[var(--vp-c-divider)] bg-[var(--vp-c-bg-soft)] p-5 text-xs text-[var(--vp-c-text-1)] sm:grid-cols-2"
>
<div class="space-y-3">
<PropertySectionRoot
class="rounded border border-[var(--vp-c-divider)]"
aria-label="Layer section"
>
<PropertySectionHeader class="flex items-center justify-between px-3 py-2">
<PropertySectionTitle class="font-semibold">Layer</PropertySectionTitle>
<PropertySectionActions class="text-[var(--vp-c-text-2)]">⌘ L</PropertySectionActions>
</PropertySectionHeader>
<PropertySectionContent class="border-t border-[var(--vp-c-divider)] px-3 py-2">
Collapsible content
</PropertySectionContent>
</PropertySectionRoot>
<PropertySectionRoot
:empty="empty"
class="rounded border border-[var(--vp-c-divider)] px-3 py-2"
>
<PropertySectionHeader>
<PropertySectionTitle class="font-semibold">Effects</PropertySectionTitle>
</PropertySectionHeader>
<PropertySectionContent>
<PropertySectionEmptyAction
class="mt-2 rounded bg-[var(--vp-c-bg-alt)] px-2 py-1"
@activate="empty = false"
>
Add first effect
</PropertySectionEmptyAction>
<p v-if="!empty" class="mt-2 text-[var(--vp-c-text-2)]">Drop shadow</p>
</PropertySectionContent>
</PropertySectionRoot>
</div>
<div class="space-y-3">
<SegmentedControlRoot
v-model="alignment"
aria-label="Alignment"
class="grid grid-cols-3 rounded bg-[var(--vp-c-bg-alt)] p-0.5"
>
<SegmentedControlItem
v-for="value in ['left', 'center', 'right']"
:key="value"
:value="value"
class="rounded px-2 py-1 capitalize data-[state=on]:bg-[var(--vp-c-bg-soft)]"
>
{{ value }}
</SegmentedControlItem>
</SegmentedControlRoot>
<SegmentedControlRoot
mode="action"
aria-label="Transform actions"
class="grid grid-cols-3 rounded bg-[var(--vp-c-bg-alt)] p-0.5"
@action="lastAction = $event"
>
<SegmentedControlItem
v-for="value in ['flip-x', 'flip-y', 'rotate-90']"
:key="value"
:value="value"
class="rounded px-2 py-1"
>
{{ value }}
</SegmentedControlItem>
</SegmentedControlRoot>
<p aria-live="polite" class="text-[var(--vp-c-text-2)]">Action: {{ lastAction }}</p>
</div>
<PropertyListRoot
v-slot="{ items }"
prop-key="fills"
:items="fills"
@add="addFill"
@remove="removeFill"
@toggle-visibility="toggleFill"
>
<div class="space-y-1 rounded border border-[var(--vp-c-divider)] p-2 sm:col-span-2">
<PropertyListItem
v-for="(_, index) in items"
:key="index"
v-slot="{ hidden }"
prop-key="fills"
:index="index"
class="flex items-center gap-2 rounded bg-[var(--vp-c-bg-alt)] px-2 py-1 data-[hidden]:opacity-50"
>
<span class="min-w-0 flex-1 truncate">Fill {{ index + 1 }}</span>
<PropertyListVisibility prop-key="fills" :index="index">
{{ hidden ? 'Show' : 'Hide' }}
</PropertyListVisibility>
<PropertyListRemove prop-key="fills" :index="index">Remove</PropertyListRemove>
</PropertyListItem>
<PropertyListAdd
prop-key="fills"
:item="defaultFill"
class="rounded bg-[var(--vp-c-bg-alt)] px-2 py-1"
>
Add fill
</PropertyListAdd>
</div>
</PropertyListRoot>
</div>
</template>

View file

@ -0,0 +1,16 @@
export { default as PropertySectionRoot } from '#vue/primitives/PropertySection/PropertySectionRoot.vue'
export { default as PropertySectionHeader } from '#vue/primitives/PropertySection/PropertySectionHeader.vue'
export { default as PropertySectionTitle } from '#vue/primitives/PropertySection/PropertySectionTitle.vue'
export { default as PropertySectionActions } from '#vue/primitives/PropertySection/PropertySectionActions.vue'
export { default as PropertySectionContent } from '#vue/primitives/PropertySection/PropertySectionContent.vue'
export { default as PropertySectionEmptyAction } from '#vue/primitives/PropertySection/PropertySectionEmptyAction.vue'
export { usePropertySection } from '#vue/primitives/PropertySection/context'
export type {
PropertySectionActionAPI,
PropertySectionContext,
PropertySectionPartProps,
PropertySectionRootProps,
PropertySectionRootSlots,
PropertySectionSlotProps,
PropertySectionStateAttrs
} from '#vue/primitives/PropertySection/types'

View file

@ -0,0 +1,49 @@
import type { PrimitiveProps } from 'reka-ui'
import type { ComputedRef, VNode } from 'vue'
export interface PropertySectionRootProps {
/** Controlled expanded state. */
open?: boolean
/** Initial expanded state when uncontrolled. @default true */
defaultOpen?: boolean
/** Marks the section as having no current items. @default false */
empty?: boolean
/** Prevents the section from being toggled. @default false */
disabled?: boolean
/** Keep collapsed content mounted in the DOM. @default false */
unmountOnHide?: boolean
}
export type PropertySectionPartProps = PrimitiveProps
export interface PropertySectionStateAttrs {
'data-state': 'open' | 'closed'
'data-empty'?: ''
'data-disabled'?: ''
}
export interface PropertySectionActionAPI {
open(): void
close(): void
toggle(): void
}
export interface PropertySectionSlotProps {
open: boolean
empty: boolean
stateAttrs: PropertySectionStateAttrs
actions: PropertySectionActionAPI
}
export interface PropertySectionRootSlots {
default(props: PropertySectionSlotProps): VNode[]
}
export interface PropertySectionContext {
open: ComputedRef<boolean>
empty: ComputedRef<boolean>
disabled: ComputedRef<boolean>
stateAttrs: ComputedRef<PropertySectionStateAttrs>
slotProps: ComputedRef<PropertySectionSlotProps>
actions: PropertySectionActionAPI
}

View file

@ -0,0 +1,62 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Primitive, RovingFocusItem, ToggleGroupItem } from 'reka-ui'
import { useSegmentedControl } from '#vue/primitives/SegmentedControl/context'
import type {
SegmentedControlItemProps,
SegmentedControlItemSlots
} from '#vue/primitives/SegmentedControl/types'
const {
value,
disabled: disabledProp = false,
as = 'button',
asChild = false
} = defineProps<SegmentedControlItemProps>()
defineSlots<SegmentedControlItemSlots>()
defineOptions({ inheritAttrs: false })
const ctx = useSegmentedControl()
const disabled = computed(() => disabledProp || ctx.disabled.value)
const selected = computed(() => ctx.selected(value))
const slotProps = computed(() => ({
value,
selected: selected.value,
disabled: disabled.value,
mode: ctx.mode.value
}))
function activate() {
if (!disabled.value) ctx.activate(value)
}
</script>
<template>
<ToggleGroupItem
v-if="ctx.mode.value !== 'action'"
v-bind="$attrs"
:value="value"
:disabled="disabled"
:as="as"
:as-child="asChild"
data-slot="item"
>
<slot v-bind="slotProps" />
</ToggleGroupItem>
<RovingFocusItem v-else :focusable="!disabled" as-child>
<Primitive
v-bind="$attrs"
:as="as"
:as-child="asChild"
:type="!asChild && as === 'button' ? 'button' : undefined"
:disabled="disabled"
data-slot="item"
data-state="off"
@click="activate"
>
<slot v-bind="slotProps" />
</Primitive>
</RovingFocusItem>
</template>

View file

@ -0,0 +1,102 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RovingFocusGroup, ToggleGroupRoot } from 'reka-ui'
import { provideSegmentedControl } from '#vue/primitives/SegmentedControl/context'
import type {
SegmentedControlRootProps,
SegmentedControlRootSlots
} from '#vue/primitives/SegmentedControl/types'
const {
mode: modeProp = 'single',
modelValue: modelValueProp,
orientation = 'horizontal',
disabled: disabledProp = false,
required = false,
rovingFocus = true,
loop = true
} = defineProps<SegmentedControlRootProps>()
const emit = defineEmits<{
'update:modelValue': [value: string | string[] | undefined]
action: [value: string]
}>()
defineSlots<SegmentedControlRootSlots>()
defineOptions({ inheritAttrs: false })
const mode = computed(() => modeProp)
const modelValue = computed(() => modelValueProp)
const disabled = computed(() => disabledProp)
function selected(value: string): boolean {
if (Array.isArray(modelValue.value)) return modelValue.value.includes(value)
return modelValue.value === value
}
function activate(value: string) {
if (!disabled.value) emit('action', value)
}
function updateSingle(value: unknown) {
if (required && typeof value !== 'string') return
emit('update:modelValue', typeof value === 'string' ? value : undefined)
}
function updateMultiple(value: unknown) {
if (!Array.isArray(value)) return
emit(
'update:modelValue',
value.filter((item): item is string => typeof item === 'string')
)
}
provideSegmentedControl({ mode, modelValue, disabled, selected, activate })
</script>
<template>
<ToggleGroupRoot
v-if="mode === 'single'"
v-bind="$attrs"
type="single"
:model-value="typeof modelValue === 'string' ? modelValue : undefined"
:orientation="orientation"
:disabled="disabled"
:required="required"
:roving-focus="rovingFocus"
:loop="loop"
data-slot="root"
data-mode="single"
@update:model-value="updateSingle"
>
<slot :mode="mode" :model-value="modelValue" />
</ToggleGroupRoot>
<ToggleGroupRoot
v-else-if="mode === 'multiple'"
v-bind="$attrs"
type="multiple"
:model-value="Array.isArray(modelValue) ? modelValue : []"
:orientation="orientation"
:disabled="disabled"
:roving-focus="rovingFocus"
:loop="loop"
data-slot="root"
data-mode="multiple"
@update:model-value="updateMultiple"
>
<slot :mode="mode" :model-value="modelValue" />
</ToggleGroupRoot>
<RovingFocusGroup
v-else
v-bind="$attrs"
:orientation="orientation"
:loop="loop"
role="group"
data-slot="root"
data-mode="action"
>
<slot :mode="mode" :model-value="modelValue" />
</RovingFocusGroup>
</template>

View file

@ -0,0 +1,5 @@
import { createContext } from '#vue/internal/create-context'
import type { SegmentedControlContext } from '#vue/primitives/SegmentedControl/types'
export const [useSegmentedControl, provideSegmentedControl] =
createContext<SegmentedControlContext>('SegmentedControl')

View file

@ -0,0 +1,13 @@
export { default as SegmentedControlRoot } from '#vue/primitives/SegmentedControl/SegmentedControlRoot.vue'
export { default as SegmentedControlItem } from '#vue/primitives/SegmentedControl/SegmentedControlItem.vue'
export { useSegmentedControl } from '#vue/primitives/SegmentedControl/context'
export type {
SegmentedControlContext,
SegmentedControlItemProps,
SegmentedControlItemSlotProps,
SegmentedControlItemSlots,
SegmentedControlMode,
SegmentedControlOrientation,
SegmentedControlRootProps,
SegmentedControlRootSlots
} from '#vue/primitives/SegmentedControl/types'

View file

@ -0,0 +1,55 @@
import type { Component, ComputedRef, VNode } from 'vue'
export type SegmentedControlMode = 'single' | 'multiple' | 'action'
export type SegmentedControlOrientation = 'horizontal' | 'vertical'
export interface SegmentedControlRootProps {
/** Selection behavior or stateless action behavior. @default 'single' */
mode?: SegmentedControlMode
/** Controlled selected value or values. */
modelValue?: string | string[]
/** Arrow-key navigation axis. @default 'horizontal' */
orientation?: SegmentedControlOrientation
/** Disable every item. @default false */
disabled?: boolean
/** Require a value in single-selection mode. @default false */
required?: boolean
/** Enable arrow-key roving focus. @default true */
rovingFocus?: boolean
/** Wrap keyboard focus at the first and last item. @default true */
loop?: boolean
}
export interface SegmentedControlItemProps {
/** Stable selection or action identifier. */
value: string
/** Disable this item. @default false */
disabled?: boolean
/** Element or component rendered by this item. @default 'button' */
as?: string | Component
/** Merge item behavior into the single child element. @default false */
asChild?: boolean
}
export interface SegmentedControlItemSlotProps {
value: string
selected: boolean
disabled: boolean
mode: SegmentedControlMode
}
export interface SegmentedControlRootSlots {
default(props: { mode: SegmentedControlMode; modelValue: string | string[] | undefined }): VNode[]
}
export interface SegmentedControlItemSlots {
default(props: SegmentedControlItemSlotProps): VNode[]
}
export interface SegmentedControlContext {
mode: ComputedRef<SegmentedControlMode>
modelValue: ComputedRef<string | string[] | undefined>
disabled: ComputedRef<boolean>
selected(value: string): boolean
activate(value: string): void
}

View file

@ -5,7 +5,8 @@ import NumberField from '@/components/inputs/NumberField.vue'
import IconButton from '@/components/ui/IconButton.vue'
import PanelSection from '@/components/ui/panel/PanelSection.vue'
import Tip from '@/components/ui/Tip.vue'
import { PropertyListRoot, vTestId, useEffectsControls, useI18n } from '@open-pencil/vue'
import PropertyListRoot from '@/components/properties/PropertyListRoot.vue'
import { vTestId, useEffectsControls, useI18n } from '@open-pencil/vue'
import { colorToCSS } from '@open-pencil/core/color'

View file

@ -1,8 +1,9 @@
<script setup lang="ts">
import { PropertyListRoot, useFillControls, useOkHCL, useI18n, inputValue } from '@open-pencil/vue'
import { useFillControls, useOkHCL, useI18n, inputValue } from '@open-pencil/vue'
import { colorToHexRaw, parseColor } from '@open-pencil/core/color'
import FillPicker from '@/components/fill-picker/FillPicker.vue'
import PropertyListRoot from '@/components/properties/PropertyListRoot.vue'
import IconButton from '@/components/ui/IconButton.vue'
import PanelSection from '@/components/ui/panel/PanelSection.vue'
import ColorStyleRow from '@/components/properties/ColorStyleRow.vue'

View file

@ -0,0 +1,48 @@
<script setup lang="ts" generic="K extends PropertyListKey">
import {
PropertyListRoot as HeadlessPropertyListRoot,
useEditorPropertyList
} from '@open-pencil/vue'
import type { SceneNode } from '@open-pencil/scene-graph'
import type { PropertyListKey, PropertyListRootSlotProps } from '@open-pencil/vue'
import type { VNode } from 'vue'
const { propKey, label } = defineProps<{
propKey: K
label?: string
}>()
defineSlots<{
default?(
props: PropertyListRootSlotProps<K> & {
isMulti: boolean
activeNode: SceneNode | null
}
): VNode[]
}>()
const context = useEditorPropertyList(propKey)
</script>
<template>
<HeadlessPropertyListRoot
v-if="context.active.value"
v-slot="slotProps"
:prop-key="propKey"
:label="label"
:items="context.items.value"
:mixed="context.isMixed.value"
@add="context.actions.add"
@remove="context.actions.remove"
@update="context.actions.update"
@patch="context.actions.patch"
@toggle-visibility="context.actions.toggleVisibility"
@reorder="context.actions.reorder"
>
<slot
v-bind="slotProps"
:is-multi="context.isMulti.value"
:active-node="context.activeNode.value"
/>
</HeadlessPropertyListRoot>
</template>

View file

@ -3,7 +3,6 @@ import { ref } from 'vue'
import {
applySolidStrokeColor,
PropertyListRoot,
useColorVariableBinding,
useStrokeControls,
useOkHCL,
@ -11,6 +10,7 @@ import {
} from '@open-pencil/vue'
import ColorStyleRow from '@/components/properties/ColorStyleRow.vue'
import PropertyListRoot from '@/components/properties/PropertyListRoot.vue'
import { boundVariableColor } from '@/components/properties/color-style-row'
import AppSelect from '@/components/ui/AppSelect.vue'
import ColorInput from '@/components/ColorPicker/ColorInput.vue'

View file

@ -28,6 +28,7 @@ export interface SegmentedControlSlots {
<script setup lang="ts">
import { computed } from 'vue'
import { tv } from 'tailwind-variants'
import { SegmentedControlItem, SegmentedControlRoot } from '@open-pencil/vue'
import theme from '@/theme/segmented-control'
@ -45,29 +46,33 @@ function itemClass(option: SegmentedControlOption) {
}).item({ class: ui?.item })
}
function select(value: string) {
function select(value: string | string[] | undefined) {
if (typeof value !== 'string') return
modelValue.value = value
emit('change', value)
}
</script>
<template>
<div role="radiogroup" :aria-label="label" :class="styles.root({ class: ui?.root })">
<button
<SegmentedControlRoot
:model-value="modelValue"
:aria-label="label"
:class="styles.root({ class: ui?.root })"
@update:model-value="select"
>
<SegmentedControlItem
v-for="option in options"
:key="option.value"
v-slot="{ selected }"
:value="option.value"
:data-test-id="option.testHook"
type="button"
role="radio"
:aria-label="option.label"
:aria-checked="modelValue === option.value"
:disabled="option.disabled"
:class="itemClass(option)"
@click="select(option.value)"
>
<slot name="option" :option="option" :selected="modelValue === option.value">
<slot name="option" :option="option" :selected="selected">
<span class="truncate">{{ option.label }}</span>
</slot>
</button>
</div>
</SegmentedControlItem>
</SegmentedControlRoot>
</template>

View file

@ -7,6 +7,9 @@ import type { PanelSectionTheme } from '@/theme/panel/section'
export interface PanelSectionProps {
label: string
open?: boolean
defaultOpen?: boolean
empty?: boolean
class?: ClassValue
ui?: ComponentUI<PanelSectionTheme>
}
@ -14,31 +17,62 @@ export interface PanelSectionProps {
export interface PanelSectionSlots {
default(): VNode[]
actions?(): VNode[]
emptyAction?(): VNode[]
}
</script>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, getCurrentInstance } from 'vue'
import { tv } from 'tailwind-variants'
import {
PropertySectionActions,
PropertySectionContent,
PropertySectionEmptyAction,
PropertySectionHeader,
PropertySectionRoot,
PropertySectionTitle
} from '@open-pencil/vue'
import theme from '@/theme/panel/section'
const { label, class: className, ui } = defineProps<PanelSectionProps>()
const {
label,
open,
defaultOpen = true,
empty = false,
class: className,
ui
} = defineProps<PanelSectionProps>()
const vnodeProps = getCurrentInstance()?.vnode.props
const controlled = vnodeProps ? Object.hasOwn(vnodeProps, 'open') : false
const emit = defineEmits<{ 'update:open': [open: boolean] }>()
const slots = defineSlots<PanelSectionSlots>()
const styles = computed(() => tv(theme)({ actions: Boolean(slots.actions) }))
</script>
<template>
<section data-slot="root" :class="styles.root({ class: [ui?.root, className] })">
<div data-slot="header" :class="styles.header({ class: ui?.header })">
<h3 data-slot="title" :class="styles.title({ class: ui?.title })">{{ label }}</h3>
<div v-if="slots.actions" data-slot="actions" :class="styles.actions({ class: ui?.actions })">
<PropertySectionRoot
as="section"
v-bind="controlled ? { open } : {}"
:default-open="defaultOpen"
:empty="empty"
:class="styles.root({ class: [ui?.root, className] })"
@update:open="emit('update:open', $event)"
>
<PropertySectionHeader :class="styles.header({ class: ui?.header })">
<PropertySectionTitle :class="styles.title({ class: ui?.title })">
<span role="heading" aria-level="3">{{ label }}</span>
</PropertySectionTitle>
<PropertySectionActions v-if="slots.actions" :class="styles.actions({ class: ui?.actions })">
<slot name="actions" />
</div>
</div>
<div data-slot="body" :class="styles.body({ class: ui?.body })">
</PropertySectionActions>
</PropertySectionHeader>
<PropertySectionContent :class="styles.body({ class: ui?.body })">
<slot />
</div>
</section>
<PropertySectionEmptyAction v-if="slots.emptyAction" as-child>
<slot name="emptyAction" />
</PropertySectionEmptyAction>
</PropertySectionContent>
</PropertySectionRoot>
</template>

View file

@ -2,7 +2,8 @@ const panelSectionTheme = {
slots: {
root: 'border-b border-border px-panel-x py-panel-y text-surface',
header: 'mb-panel grid min-w-0 items-center gap-panel',
title: 'min-w-0 truncate text-[11px] leading-none font-semibold text-surface',
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'

View file

@ -4,6 +4,20 @@ import { getPageChildren, getSelectedNode } from '#tests/helpers/store'
const editor = useEditorSetup()
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')
await expect(blendMode).toBeVisible()
await title.click()
await expect(blendMode).toBeHidden()
await expect(title).toHaveAttribute('data-state', 'closed')
await title.click()
await expect(blendMode).toBeVisible()
})
test('NumberField drag changes X position', async () => {
await editor.canvas.clearCanvas()
await editor.canvas.drawRect(100, 100, 80, 80)

View file

@ -70,6 +70,27 @@ test('stroke visibility supports repeat click and undo redo', async () => {
).toBe(true)
})
test('multi-selection list add is one undo step', async () => {
await editor.canvas.clearCanvas()
await editor.canvas.drawRect(100, 100, 80, 80)
await editor.canvas.drawRect(240, 100, 80, 80)
await editor.canvas.pressKey('Meta+a')
const strokeCounts = () =>
editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
return [...store.state.selectedIds].map((id) => store.getNode(id)?.strokes.length ?? -1)
})
await editor.page.getByTestId('stroke-section-add').click()
await editor.canvas.waitForRender()
expect(await strokeCounts()).toEqual([1, 1])
await editor.canvas.undo()
expect(await strokeCounts()).toEqual([0, 0])
})
test('appearance visibility supports repeat click and undo redo in one step', async () => {
const visibilityButton = editor.page.getByTestId('appearance-visibility')
await expect(visibilityButton).toBeVisible()