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:
parent
ffb12824eb
commit
1b7934b98e
|
|
@ -21,7 +21,6 @@ const config: StorybookConfig = {
|
|||
const excludedPluginPrefixes = [
|
||||
'copy-canvaskit-wasm',
|
||||
'open-pencil-automation',
|
||||
'raw-text-assets',
|
||||
'vite-plugin-pwa'
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
}))
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -4,6 +4,9 @@
|
|||
"license": "MIT",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
"#docs/*": "./.vitepress/*"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vitepress dev",
|
||||
"build": "vitepress build",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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." />
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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" />
|
||||
|
|
@ -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)
|
||||
|
|
@ -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" />
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()`
|
||||
|
|
|
|||
1
packages/vue/src/controls/property-list/index.ts
Normal file
1
packages/vue/src/controls/property-list/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { useEditorPropertyList } from '#vue/controls/property-list/use'
|
||||
163
packages/vue/src/controls/property-list/use.ts
Normal file
163
packages/vue/src/controls/property-list/use.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
39
packages/vue/src/primitives/PropertyList/PropertyListAdd.vue
Normal file
39
packages/vue/src/primitives/PropertyList/PropertyListAdd.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
86
packages/vue/src/primitives/PropertyList/types.ts
Normal file
86
packages/vue/src/primitives/PropertyList/types.ts
Normal 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>
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
5
packages/vue/src/primitives/PropertySection/context.ts
Normal file
5
packages/vue/src/primitives/PropertySection/context.ts
Normal 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')
|
||||
|
|
@ -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>
|
||||
16
packages/vue/src/primitives/PropertySection/index.ts
Normal file
16
packages/vue/src/primitives/PropertySection/index.ts
Normal 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'
|
||||
49
packages/vue/src/primitives/PropertySection/types.ts
Normal file
49
packages/vue/src/primitives/PropertySection/types.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
5
packages/vue/src/primitives/SegmentedControl/context.ts
Normal file
5
packages/vue/src/primitives/SegmentedControl/context.ts
Normal 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')
|
||||
13
packages/vue/src/primitives/SegmentedControl/index.ts
Normal file
13
packages/vue/src/primitives/SegmentedControl/index.ts
Normal 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'
|
||||
55
packages/vue/src/primitives/SegmentedControl/types.ts
Normal file
55
packages/vue/src/primitives/SegmentedControl/types.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
48
src/components/properties/PropertyListRoot.vue
Normal file
48
src/components/properties/PropertyListRoot.vue
Normal 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>
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue