feat(vue): add responsive property grids

- Add a headless PropertyGrid primitive with themed app composition

- Keep composite paint details full-width and show remove actions consistently

- Tighten color picker and Design-panel field sizing without clipping values
This commit is contained in:
Danila Poyarkov 2026-07-26 12:42:50 +03:00
parent c9cadf6fdd
commit 0071c32544
35 changed files with 287 additions and 187 deletions

View file

@ -19,7 +19,7 @@
- Drag with the Text tool to create a fixed-size text box, or click to create auto-width text.
- Target a specific open document and page from live CLI and MCP automation, including sessions with multiple documents.
- Test OpenAI-compatible provider connections from AI settings with clearer setup errors.
- Build custom property panels with new Vue SDK number fields, bindable values, property sections, segmented controls, property lists, color models, fill controls, and gradient primitives.
- Build custom property panels with new Vue SDK number fields, bindable values, property sections, responsive property grids, segmented controls, property lists, color models, fill controls, and gradient primitives.
- Connect local MCP clients through automatically discovered private Unix sockets on macOS and Linux, with localhost TCP fallback. (#338)
- Create centered frames from current Figma-style device and asset presets, or resize selected frames from the Design panel while preserving their names.

View file

@ -23,6 +23,7 @@ description: Component reference for headless Vue primitives in @open-pencil/vue
<SdkCardGroup>
<SdkCard title="PropertySection" to="/programmable/sdk/api/components/property-section" description="Collapsible property-section anatomy and empty states." />
<SdkCard title="PropertyGrid" to="/programmable/sdk/api/components/property-grid" description="Responsive field-grid and action-rail anatomy." />
<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." />

View file

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

View file

@ -0,0 +1,40 @@
---
title: PropertyGrid
description: Headless field-grid and action-rail anatomy for property panels.
---
<script setup lang="ts">
import { data } from './property-grid.data'
</script>
# PropertyGrid
`PropertyGridRoot` separates responsive property fields from optional intrinsic-width actions. It exposes structural data attributes without imposing column widths, gaps, or presentation.
```vue twoslash
<script setup lang="ts">
import { PropertyGridRoot } from '@open-pencil/vue'
</script>
<template>
<PropertyGridRoot :columns="2">
<label>
Width
<input type="number" />
</label>
<label>
Height
<input type="number" />
</label>
<template #actions>
<button type="button" aria-label="Constrain proportions">Link</button>
</template>
</PropertyGridRoot>
</template>
```
Themes can target `data-slot="fields"`, `data-slot="actions"`, `data-columns`, and `data-distribution`. The `wide-first` distribution is semantic; consumers choose its exact ratio.
## Generated API reference
<SdkComponentAPI :components="data.components" />

View file

@ -246,6 +246,14 @@ export type {
PropertyListRootSlotProps,
PropertyListRootSlots
} from '#vue/primitives/PropertyList'
export { PropertyGridRoot } from '#vue/primitives/PropertyGrid'
export type {
PropertyGridColumns,
PropertyGridDistribution,
PropertyGridRootProps,
PropertyGridRootSlots
} from '#vue/primitives/PropertyGrid'
export {
PropertySectionRoot,
PropertySectionHeader,

View file

@ -0,0 +1,27 @@
<script setup lang="ts">
import type {
PropertyGridRootProps,
PropertyGridRootSlots
} from '#vue/primitives/PropertyGrid/types'
const { columns = 1, distribution = 'equal' } = defineProps<PropertyGridRootProps>()
defineSlots<PropertyGridRootSlots>()
defineOptions({ inheritAttrs: false })
</script>
<template>
<div
v-bind="$attrs"
data-slot="root"
data-property-grid
:data-columns="columns"
:data-distribution="distribution"
>
<div data-slot="fields">
<slot />
</div>
<div v-if="$slots.actions" data-slot="actions">
<slot name="actions" />
</div>
</div>
</template>

View file

@ -0,0 +1,7 @@
export { default as PropertyGridRoot } from '#vue/primitives/PropertyGrid/PropertyGridRoot.vue'
export type {
PropertyGridColumns,
PropertyGridDistribution,
PropertyGridRootProps,
PropertyGridRootSlots
} from '#vue/primitives/PropertyGrid/types'

View file

@ -0,0 +1,18 @@
import type { VNode } from 'vue'
export type PropertyGridColumns = 1 | 2 | 3
export type PropertyGridDistribution = 'equal' | 'wide-first'
export interface PropertyGridRootProps {
/** Number of field columns. @default 1 */
columns?: PropertyGridColumns
/** Relative distribution of field columns. @default 'equal' */
distribution?: PropertyGridDistribution
}
export interface PropertyGridRootSlots {
/** Property fields arranged by the consumer's visual theme. */
default(): VNode[]
/** Optional intrinsic-width controls kept separate from the field grid. */
actions?(): VNode[]
}

View file

@ -77,7 +77,7 @@ const styles = useColorSliderUI(
:max="displayMax"
:step="displayStep"
:suffix="suffix"
:ui="{ leading: 'hidden' }"
:ui="{ leading: 'hidden', field: 'pl-1.5', display: 'pl-1.5' }"
@update:model-value="emit('updateDisplay', $event)"
/>
</div>

View file

@ -72,7 +72,7 @@ const styles = useColorSliderUI(
:max="numberMax"
:step="numberStep"
:suffix="suffix"
:ui="{ leading: 'hidden' }"
:ui="{ leading: 'hidden', field: 'pl-1.5', display: 'pl-1.5' }"
@update:model-value="emit('updateNumber', $event)"
/>
</div>

View file

@ -52,7 +52,7 @@ function cancelFromEscape(event: KeyboardEvent) {
type="button"
:aria-label="panels.fill"
data-test-id="fill-picker-swatch"
class="size-5 shrink-0 cursor-pointer rounded border-0 bg-transparent p-0"
class="size-4 shrink-0 cursor-pointer rounded-sm border-0 bg-transparent p-0"
>
<FillSwatch :fill="fill" class="size-full" v-slot="swatch">
<span

View file

@ -8,7 +8,6 @@ import AppSelect from '@/components/ui/AppSelect.vue'
import IconButton from '@/components/ui/IconButton.vue'
import PanelFieldGroup from '@/components/ui/panel/PanelFieldGroup.vue'
import PanelGrid from '@/components/ui/panel/PanelGrid.vue'
import PanelRail from '@/components/ui/panel/PanelRail.vue'
import PanelSection from '@/components/ui/panel/PanelSection.vue'
import type { BlendMode } from '@open-pencil/scene-graph'
@ -55,7 +54,7 @@ function blendModeOptions(value: BlendMode | typeof MIXED) {
</IconButton>
</template>
<PanelGrid columns="appearance">
<PanelGrid :columns="2" distribution="wide-first">
<PanelFieldGroup :label="panels.blendMode">
<AppSelect
:model-value="blendModeValue === MIXED ? 'MIXED' : blendModeValue"
@ -103,11 +102,7 @@ function blendModeOptions(value: BlendMode | typeof MIXED) {
</PanelFieldGroup>
</PanelGrid>
<PanelGrid
v-if="hasCornerRadius && !showIndependentCorners"
columns="fill-rail"
class="mt-1.5"
>
<PanelGrid v-if="hasCornerRadius && !showIndependentCorners" :columns="2" class="mt-1.5">
<PanelFieldGroup :label="panels.radius">
<VariableNumberField
v-if="node && !isMulti"
@ -137,7 +132,7 @@ function blendModeOptions(value: BlendMode | typeof MIXED) {
</template>
</NumberField>
</PanelFieldGroup>
<PanelRail>
<div class="flex h-6 items-center justify-end">
<IconButton
:label="panels.independentCornerRadii"
size="md"
@ -146,13 +141,13 @@ function blendModeOptions(value: BlendMode | typeof MIXED) {
>
<icon-lucide-square-round-corner class="size-3" />
</IconButton>
</PanelRail>
</div>
</PanelGrid>
<PanelGrid
v-else-if="hasCornerRadius && !isMulti && node"
columns="two-rail"
class="mt-1.5"
:columns="2"
class="mt-1.5 [&>[data-slot=actions]]:self-start"
data-corner-grid
>
<VariableNumberField
@ -173,16 +168,6 @@ function blendModeOptions(value: BlendMode | typeof MIXED) {
@update:model-value="actions.updateCornerProp('topRightRadius', $event)"
@commit="(v: number, p: number) => actions.commitCornerProp('topRightRadius', v, p)"
/>
<PanelRail>
<IconButton
:label="panels.independentCornerRadii"
size="md"
active
@click="actions.toggleIndependentCorners"
>
<icon-lucide-square-round-corner class="size-3" />
</IconButton>
</PanelRail>
<VariableNumberField
label="BL"
:model-value="node.bottomLeftRadius"
@ -201,10 +186,19 @@ function blendModeOptions(value: BlendMode | typeof MIXED) {
@update:model-value="actions.updateCornerProp('bottomRightRadius', $event)"
@commit="(v: number, p: number) => actions.commitCornerProp('bottomRightRadius', v, p)"
/>
<PanelRail />
<template #actions>
<IconButton
:label="panels.independentCornerRadii"
size="md"
active
@click="actions.toggleIndependentCorners"
>
<icon-lucide-square-round-corner class="size-3" />
</IconButton>
</template>
</PanelGrid>
<PanelGrid v-if="hasCornerRadius" columns="fill" class="mt-1.5">
<PanelGrid v-if="hasCornerRadius" :columns="2" class="mt-1.5">
<PanelFieldGroup :label="panels.cornerSmoothing">
<NumberField
suffix="%"

View file

@ -93,67 +93,68 @@ function updateSolidColor(
:visibility-label="panels.toggleVisibility"
:remove-label="panels.removeFill"
>
<div class="flex min-w-0 flex-1 flex-col gap-1.5">
<BindableValueRoot
v-slot="binding"
:provider="colorProvider"
:targets="paintBindingTargets(selectedNodeIds, 'fills', index)"
:value="fill.color"
batch-label="Change fill color"
<BindableValueRoot
v-slot="binding"
:provider="colorProvider"
:targets="paintBindingTargets(selectedNodeIds, 'fills', index)"
:value="fill.color"
batch-label="Change fill color"
>
<PaintField
class="w-full flex-none"
:opacity="fill.opacity"
:opacity-label="panels.opacity"
@update:opacity="actions.patch(index, { opacity: $event })"
>
<PaintField
class="w-full flex-none"
:opacity="fill.opacity"
:opacity-label="panels.opacity"
@update:opacity="actions.patch(index, { opacity: $event })"
>
<template #preview>
<FillPicker
:fill="displayFill(fill, binding.resolvedValue)"
:okhcl="createFillOkhclAdapter(okhcl, activeNode, index)"
@update="
updatePickerFill(binding.actions, flush, $event, (next) =>
actions.update(index, next)
)
"
@open-change="!$event && commitPaintMutation(binding.actions)"
@cancel="cancelPaintMutation(binding.actions)"
/>
</template>
<template #preview>
<FillPicker
:fill="displayFill(fill, binding.resolvedValue)"
:okhcl="createFillOkhclAdapter(okhcl, activeNode, index)"
@update="
updatePickerFill(binding.actions, flush, $event, (next) =>
actions.update(index, next)
)
"
@open-change="!$event && commitPaintMutation(binding.actions)"
@cancel="cancelPaintMutation(binding.actions)"
/>
</template>
<template #value>
<PaintValue
v-if="fill.type === 'SOLID'"
:color="fill.color"
:resolved-color="binding.resolvedValue"
:variable-name="binding.variable?.name"
:label="panels.fill"
@update="
updateSolidColor(binding.actions, flush, fill, $event, (next) =>
actions.update(index, next)
)
"
/>
<span v-else class="min-w-0 flex-1 truncate font-mono text-xs text-surface">
{{ fillLabel(fill) }}
</span>
</template>
<template #value>
<PaintValue
v-if="fill.type === 'SOLID'"
:color="fill.color"
:resolved-color="binding.resolvedValue"
:variable-name="binding.variable?.name"
:label="panels.fill"
@update="
updateSolidColor(binding.actions, flush, fill, $event, (next) =>
actions.update(index, next)
)
"
/>
<span v-else class="min-w-0 flex-1 truncate font-mono text-xs text-surface">
{{ fillLabel(fill) }}
</span>
</template>
<template v-if="fill.type === 'SOLID'" #binding>
<VariableBindingPicker
:trigger-label="panels.applyVariable"
:search-placeholder="dialogs.search"
:empty-label="panels.noVariablesFound"
:detach-label="panels.detachVariable"
:create-label="
panels.createColorVariable({ value: `#${colorToHexRaw(fill.color)}` })
"
:create-name-placeholder="panels.variableName"
:create-submit-label="panels.create"
/>
</template>
</PaintField>
</BindableValueRoot>
<template v-if="fill.type === 'SOLID'" #binding>
<VariableBindingPicker
:trigger-label="panels.applyVariable"
:search-placeholder="dialogs.search"
:empty-label="panels.noVariablesFound"
:detach-label="panels.detachVariable"
:create-label="
panels.createColorVariable({ value: `#${colorToHexRaw(fill.color)}` })
"
:create-name-placeholder="panels.variableName"
:create-submit-label="panels.create"
/>
</template>
</PaintField>
</BindableValueRoot>
<template #details>
<PanelFieldGroup :label="panels.blendMode">
<AppSelect
:model-value="fill.blendMode ?? 'NORMAL'"
@ -167,7 +168,7 @@ function updateSolidColor(
"
/>
</PanelFieldGroup>
</div>
</template>
</PropertyItemRow>
</div>
</PanelSection>

View file

@ -100,7 +100,7 @@ function isGrid(grid: LayoutGrid): boolean {
</Tip>
</template>
</SegmentedControl>
<PanelGrid columns="two">
<PanelGrid :columns="2">
<PanelFieldGroup :label="panels.gridCount">
<NumberField
:model-value="grid.count ?? grid.numSections ?? 1"

View file

@ -48,12 +48,12 @@ const visibleSizeLimits = computed(() =>
</script>
<template>
<PanelGrid columns="two">
<PanelGrid :columns="2">
<SizeAxisField axis="width" icon="W" :label="panels.width" />
<SizeAxisField axis="height" icon="H" :label="panels.height" />
</PanelGrid>
<PanelGrid v-if="visibleSizeLimits.length" columns="two" class="mt-1.5">
<PanelGrid v-if="visibleSizeLimits.length" :columns="2" class="mt-1.5">
<SizeLimitField v-for="item in visibleSizeLimits" :key="item.prop" :item="item" />
</PanelGrid>
</template>

View file

@ -79,7 +79,7 @@ function handleAlign(
</div>
</div>
<PanelGrid columns="two">
<PanelGrid :columns="2">
<Tip :label="panels.xAxis">
<NumberField
icon="X"
@ -102,7 +102,7 @@ function handleAlign(
</Tip>
</PanelGrid>
<PanelGrid v-if="isMulti" columns="two" class="mt-1.5">
<PanelGrid v-if="isMulti" :columns="2" class="mt-1.5">
<Tip :label="panels.width">
<NumberField
icon="W"
@ -127,7 +127,7 @@ function handleAlign(
</Tip>
</PanelGrid>
<div class="mt-1.5 grid grid-cols-[minmax(0,1fr)_repeat(3,24px)] gap-0.5">
<PanelGrid :columns="2" class="mt-1.5">
<Tip :label="panels.rotation">
<NumberField
suffix="°"
@ -144,16 +144,18 @@ function handleAlign(
</template>
</NumberField>
</Tip>
<IconButton :label="panels.flipHorizontal" size="md" @click="actions.flip('horizontal')">
<icon-lucide-flip-horizontal-2 class="size-3.5" />
</IconButton>
<IconButton :label="panels.flipVertical" size="md" @click="actions.flip('vertical')">
<icon-lucide-flip-vertical-2 class="size-3.5" />
</IconButton>
<IconButton :label="panels.rotate90" size="md" @click="actions.rotate(90)">
<icon-lucide-rotate-cw-square class="size-3.5" />
</IconButton>
</div>
<div class="flex h-6 items-center justify-end gap-0.5">
<IconButton :label="panels.flipHorizontal" size="md" @click="actions.flip('horizontal')">
<icon-lucide-flip-horizontal-2 class="size-3.5" />
</IconButton>
<IconButton :label="panels.flipVertical" size="md" @click="actions.flip('vertical')">
<icon-lucide-flip-vertical-2 class="size-3.5" />
</IconButton>
<IconButton :label="panels.rotate90" size="md" @click="actions.rotate(90)">
<icon-lucide-rotate-cw-square class="size-3.5" />
</IconButton>
</div>
</PanelGrid>
</PanelSection>
</PositionControlsRoot>
</template>

View file

@ -154,7 +154,7 @@ function onToggleSides(activeNode: SceneNode | null) {
<button
type="button"
:aria-label="panels.stroke"
class="size-5 shrink-0 cursor-pointer rounded border-0 bg-transparent p-0"
class="size-4 shrink-0 cursor-pointer rounded-sm border-0 bg-transparent p-0"
>
<FillSwatch
:fill="strokePreview(stroke, binding.resolvedValue ?? stroke.color)"
@ -266,7 +266,7 @@ function onToggleSides(activeNode: SceneNode | null) {
</template>
</div>
<PanelGrid v-if="advancedActive" columns="three" class="mt-1.5">
<PanelGrid v-if="advancedActive" :columns="3" class="mt-1.5">
<PanelFieldGroup :label="panels.strokeCap">
<SegmentedControl
:model-value="cap === MIXED ? 'MIXED' : cap"

View file

@ -88,7 +88,7 @@ function featureEnabled(features: Array<{ tag: string; enabled: boolean }>, tag:
</Tip>
</div>
<PanelGrid columns="two" class="mb-3">
<PanelGrid :columns="2" class="mb-3">
<PanelFieldGroup :label="panels.fontWeight">
<AppSelect
:label="panels.fontWeight"
@ -111,7 +111,7 @@ function featureEnabled(features: Array<{ tag: string; enabled: boolean }>, tag:
</PanelFieldGroup>
</PanelGrid>
<PanelGrid columns="two" class="mb-3">
<PanelGrid :columns="2" class="mb-3">
<PanelFieldGroup :label="panels.lineHeight">
<VariableNumberField
:model-value="
@ -196,7 +196,11 @@ function featureEnabled(features: Array<{ tag: string; enabled: boolean }>, tag:
</SegmentedControl>
</PanelFieldGroup>
<PanelFieldGroup :label="panels.textFormatting" class="mb-3" :ui="{ container: 'flex-row gap-1.5' }">
<PanelFieldGroup
:label="panels.textFormatting"
class="mb-3"
:ui="{ container: 'flex-row gap-1.5' }"
>
<div
class="inline-flex items-center gap-0.5 rounded bg-panel-field p-0.5 hover:bg-panel-field-hover"
role="toolbar"
@ -237,7 +241,7 @@ function featureEnabled(features: Array<{ tag: string; enabled: boolean }>, tag:
</div>
</PanelFieldGroup>
<PanelGrid columns="two" class="mb-3">
<PanelGrid :columns="2" class="mb-3">
<PanelFieldGroup :label="panels.textCase">
<AppSelect
:label="panels.textCase"

View file

@ -32,6 +32,7 @@ const emit = defineEmits<{
defineSlots<{
default(props: PropertyListItemSlotProps<K>): VNode[]
rail?(props: PropertyListItemSlotProps<K>): VNode[]
details?(props: PropertyListItemSlotProps<K>): VNode[]
}>()
</script>
@ -47,6 +48,9 @@ defineSlots<{
>
<PanelItemRow>
<slot v-bind="item" />
<template v-if="$slots.details" #details>
<slot name="details" v-bind="item" />
</template>
<template #rail="{ removeClass }">
<slot name="rail" v-bind="item" />
<Tip v-if="showVisibility" :label="visibilityLabel">

View file

@ -56,7 +56,9 @@ const styles = computed(() => tv(paintFieldTheme)())
:max="100"
:ui="{
root: 'h-full rounded-none border-0 bg-transparent shadow-none',
leading: 'hidden'
leading: 'hidden',
field: 'pl-1.5',
display: 'pl-1.5'
}"
data-property="opacity"
@update:model-value="emit('update:opacity', Math.max(0, Math.min(1, $event / 100)))"

View file

@ -45,7 +45,7 @@ function update(value: string) {
</script>
<template>
<PanelGrid v-if="visible" columns="fill" class="mb-1.5">
<PanelGrid v-if="visible" class="mb-1.5">
<PanelFieldGroup :label="label">
<AppSelect
:model-value="styleId === MIXED ? 'MIXED' : (styleId ?? 'NONE')"

View file

@ -16,7 +16,6 @@ import SegmentedControl from '@/components/ui/SegmentedControl.vue'
import PanelFieldGroup from './PanelFieldGroup.vue'
import PanelGrid from './PanelGrid.vue'
import PanelHeader from './PanelHeader.vue'
import PanelRail from './PanelRail.vue'
import PanelSection from './PanelSection.vue'
const meta = {
@ -47,7 +46,6 @@ export const StateMatrix: Story = {
PanelFieldGroup,
PanelGrid,
PanelHeader,
PanelRail,
PanelSection,
RotateIcon,
SegmentedControl,
@ -98,30 +96,30 @@ export const StateMatrix: Story = {
<template #actions>
<IconButton label="Reset layout"><RotateIcon class="size-3.5" /></IconButton>
</template>
<PanelGrid columns="two-rail">
<PanelGrid :columns="2">
<PanelFieldGroup label="Width">
<AppInput v-model="width" tone="panel" data-story-control data-state="idle" aria-label="Width" />
</PanelFieldGroup>
<PanelFieldGroup label="Height">
<AppInput v-model="height" tone="panel" data-story-control data-state="focus" aria-label="Height" />
</PanelFieldGroup>
<PanelRail>
<template #actions>
<IconButton label="Constrain proportions" size="md"><LinkIcon class="size-3.5" /></IconButton>
</PanelRail>
</template>
</PanelGrid>
</PanelSection>
<PanelSection label="Appearance">
<PanelGrid columns="two-rail">
<PanelGrid :columns="2">
<PanelFieldGroup label="Blend mode">
<AppSelect v-model="blendMode" :options="blendModes" data-story-control aria-label="Blend mode" />
</PanelFieldGroup>
<PanelFieldGroup label="Opacity">
<AppInput v-model="mixed" tone="panel" state="mixed" readonly data-story-control aria-label="Mixed opacity" />
</PanelFieldGroup>
<PanelRail>
<template #actions>
<IconButton label="Toggle visibility"><EyeIcon class="size-3.5" /></IconButton>
</PanelRail>
</template>
</PanelGrid>
</PanelSection>

View file

@ -1,37 +1,36 @@
<script lang="ts">
import type { VNode } from 'vue'
import type { PropertyGridRootSlots } from '@open-pencil/vue'
import type { ClassValue } from 'tailwind-variants'
import type { PanelGridTheme } from '@/theme/panel/grid'
type PanelGridColumns = keyof PanelGridTheme['variants']['columns']
export interface PanelGridProps {
columns?: PanelGridColumns
columns?: 1 | 2 | 3
distribution?: 'equal' | 'wide-first'
class?: ClassValue
}
export interface PanelGridSlots {
default(): VNode[]
}
export type PanelGridSlots = PropertyGridRootSlots
</script>
<script setup lang="ts">
import { PropertyGridRoot } from '@open-pencil/vue'
import { tv } from 'tailwind-variants'
import theme from '@/theme/panel/grid'
const { columns = 'two-rail', class: className } = defineProps<PanelGridProps>()
const { columns = 1, distribution = 'equal', class: className } = defineProps<PanelGridProps>()
defineSlots<PanelGridSlots>()
const panelGrid = tv(theme)
</script>
<template>
<div
data-slot="root"
data-panel-grid
:data-columns="columns"
:class="panelGrid({ columns, class: className })"
<PropertyGridRoot
:columns="columns"
:distribution="distribution"
:class="panelGrid({ columns, distribution, class: className })"
>
<slot />
</div>
<template v-if="$slots.actions" #actions>
<slot name="actions" />
</template>
</PropertyGridRoot>
</template>

View file

@ -15,6 +15,7 @@ export interface PanelItemRowProps {
export interface PanelItemRowSlots {
default(): VNode[]
rail?(props: { removeClass: string }): VNode[]
details?(): VNode[]
}
</script>
@ -37,5 +38,8 @@ const styles = computed(() => tv(itemRowTheme)())
<div v-if="$slots.rail" :class="styles.rail({ class: ui?.rail })" data-slot="rail">
<slot name="rail" :remove-class="styles.remove({ class: ui?.remove })" />
</div>
<div v-if="$slots.details" :class="styles.details({ class: ui?.details })" data-slot="details">
<slot name="details" />
</div>
</div>
</template>

View file

@ -1,28 +0,0 @@
<script lang="ts">
import type { VNode } from 'vue'
import type { ClassValue } from 'tailwind-variants'
export interface PanelRailProps {
class?: ClassValue
}
export interface PanelRailSlots {
default(): VNode[]
}
</script>
<script setup lang="ts">
import { tv } from 'tailwind-variants'
import theme from '@/theme/panel/rail'
const { class: className } = defineProps<PanelRailProps>()
defineSlots<PanelRailSlots>()
const panelRail = tv(theme)
</script>
<template>
<div data-slot="root" data-panel-rail :class="panelRail({ class: className })">
<slot />
</div>
</template>

View file

@ -2,5 +2,4 @@ export { default as PanelFieldGroup } from './PanelFieldGroup.vue'
export { default as PanelGrid } from './PanelGrid.vue'
export { default as PanelHeader } from './PanelHeader.vue'
export { default as PanelItemRow } from './PanelItemRow.vue'
export { default as PanelRail } from './PanelRail.vue'
export { default as PanelSection } from './PanelSection.vue'

View file

@ -3,12 +3,12 @@ import { CHECKERBOARD_BACKGROUND } from '@/theme/checkerboard'
export default {
slots: {
root: 'flex items-center gap-2',
label: 'w-4 shrink-0 text-[10px] font-medium text-muted',
label: 'w-7 shrink-0 text-[10px] font-medium text-muted',
slider: 'relative flex h-3 flex-1 touch-none items-center rounded-md select-none',
track: 'absolute inset-0 overflow-hidden rounded-md',
thumb:
'block size-3.5 rounded-full border-2 border-white shadow-sm outline-none ring-offset-1 focus-visible:ring-2 focus-visible:ring-primary',
input: 'w-14 shrink-0'
input: 'w-14 flex-none shrink-0'
},
variants: {
checkerboard: {

View file

@ -1,10 +1,10 @@
export default {
slots: {
root: 'flex h-6 min-w-0 flex-1 items-center overflow-hidden rounded border border-transparent bg-panel-field text-[11px] transition-colors hover:bg-panel-field-hover focus-within:border-panel-focus focus-within:bg-panel-field-hover',
preview: 'flex shrink-0 items-center pl-0.5',
value: 'flex min-w-0 flex-1 items-center px-1.5',
divider: 'h-4 w-px shrink-0 bg-border',
opacity: 'h-full w-14 shrink-0',
preview: 'flex shrink-0 items-center pl-1',
value: 'flex min-w-0 flex-1 items-center pl-1.5 pr-1',
divider: 'h-4 w-px shrink-0 bg-muted/40',
opacity: 'h-full w-12 flex-none shrink-0',
binding: 'flex shrink-0 items-center pr-0.5'
}
} as const

View file

@ -1,17 +1,24 @@
const panelGridTheme = {
base: 'grid min-w-0 items-end gap-1.5',
base: [
'flex min-w-0 items-end gap-1.5',
'[&>[data-slot=fields]]:grid [&>[data-slot=fields]]:min-w-0 [&>[data-slot=fields]]:flex-1 [&>[data-slot=fields]]:items-end [&>[data-slot=fields]]:gap-1.5',
'[&>[data-slot=actions]]:flex [&>[data-slot=actions]]:h-6 [&>[data-slot=actions]]:min-w-[26px] [&>[data-slot=actions]]:shrink-0 [&>[data-slot=actions]]:items-center [&>[data-slot=actions]]:justify-end [&>[data-slot=actions]]:gap-0.5',
'[&>[data-slot=actions]_[data-slot=icon-button]]:size-6 [&>[data-slot=actions]_[data-slot=icon-button]]:rounded'
],
variants: {
columns: {
two: 'grid-cols-2',
three: 'grid-cols-3',
appearance: 'grid-cols-[minmax(0,7fr)_minmax(0,5fr)]',
'two-rail': 'grid-cols-[minmax(0,1fr)_minmax(0,1fr)_26px]',
fill: 'grid-cols-[minmax(0,1fr)]',
'fill-rail': 'grid-cols-[minmax(0,1fr)_26px]'
1: '[&>[data-slot=fields]]:grid-cols-1',
2: '[&>[data-slot=fields]]:grid-cols-2',
3: '[&>[data-slot=fields]]:grid-cols-3'
},
distribution: {
equal: '',
'wide-first': '[&>[data-slot=fields]]:grid-cols-[minmax(0,7fr)_minmax(0,5fr)]'
}
},
defaultVariants: {
columns: 'two-rail' as const
columns: 1 as const,
distribution: 'equal' as const
}
}

View file

@ -1,9 +1,9 @@
export default {
slots: {
root: 'group flex min-h-6 items-center gap-1.5 py-0.5',
content: 'flex min-w-0 flex-1 items-center gap-1.5',
root: 'group grid min-h-6 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-1.5 gap-y-1.5 py-0.5',
content: 'flex min-w-0 items-center gap-1.5',
rail: 'flex shrink-0 items-center gap-0.5',
remove:
'transition-opacity [@media(hover:hover)]:pointer-events-none [@media(hover:hover)]:opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100'
details: 'col-span-2 min-w-0',
remove: ''
}
} as const

View file

@ -1,3 +0,0 @@
export default {
base: 'flex h-6 w-[26px] shrink-0 items-center justify-end gap-0.5 [&_[data-slot=icon-button]]:size-6 [&_[data-slot=icon-button]]:rounded'
}

View file

@ -89,9 +89,19 @@ test('fill section appears with default fill', async () => {
await expect(fillItems.first()).toBeVisible()
})
test('fill item shows color swatch', async () => {
test('fill item shows color swatch and full hex value', async () => {
const swatch = fillSection().getByTestId('fill-picker-swatch').first()
await expect(swatch).toBeVisible()
const fillItem = propertyItems(editor.page, 'fills').first()
await expect(fillItem.getByRole('button', { name: 'Remove fill' })).toBeVisible()
const hex = fillItem.locator('[data-property="color-hex"]')
const metrics = await hex.evaluate((element) => ({
fits: element.scrollWidth <= element.clientWidth,
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth
}))
expect(metrics.fits, JSON.stringify(metrics)).toBe(true)
})
test('clicking color area changes fill color', async () => {
@ -129,6 +139,9 @@ test('adding a stroke creates stroke section item', async () => {
const strokeItems = propertyItems(editor.page, 'strokes')
await expect(strokeItems.first()).toBeVisible()
await expect(strokeItems.first().getByRole('button', { name: 'Remove stroke' })).toBeVisible()
const hex = strokeItems.first().locator('[data-property="color-hex"]')
expect(await hex.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
const id = await getSelectedId()
const node = await getNode(expectDefined(id, 'selected id'))
@ -148,7 +161,7 @@ test('adding an effect creates effect item', async () => {
expect(expectDefined(node, 'node node').effects.length).toBe(1)
})
test('effect settings expand semantically and row remove reveals on hover', async () => {
test('effect settings expand semantically and row remove stays visible', async () => {
const effectItem = propertyItems(editor.page, 'effects').first()
const expand = effectItem.locator('[data-property="effect-expand"]')
await expect(expand).toHaveAttribute('aria-expanded', 'false')
@ -160,8 +173,6 @@ test('effect settings expand semantically and row remove reveals on hover', asyn
await expect(remove).toHaveCSS('opacity', '1')
await editor.page.getByRole('tab', { name: 'Design' }).focus()
await editor.page.mouse.move(0, 0)
await expect(remove).toHaveCSS('opacity', '0')
await effectItem.hover()
await expect(remove).toHaveCSS('opacity', '1')
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB