From f1c923e128e7ac4711d62d665b872e7ba3dd71ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jochen=20K=C3=BChner?= Date: Sun, 2 Aug 2026 16:15:08 +0200 Subject: [PATCH 1/3] node-projects/web-component-designer#464: unit editor addons --- .../elements/controls/NumericStyleInput.ts | 42 +++++++++++++------ .../services/propertiesService/IProperty.ts | 6 ++- .../propertyEditors/UnitPropertyEditor.ts | 24 ++++++++++- .../UnitPropertyEditorConfig.ts | 27 ++++++++++-- packages/web-component-designer/src/index.ts | 1 + .../tests/NumericStyleInput.test.ts | 17 +++++++- 6 files changed, 96 insertions(+), 21 deletions(-) diff --git a/packages/web-component-designer/src/elements/controls/NumericStyleInput.ts b/packages/web-component-designer/src/elements/controls/NumericStyleInput.ts index 85b071faa..629dcae31 100644 --- a/packages/web-component-designer/src/elements/controls/NumericStyleInput.ts +++ b/packages/web-component-designer/src/elements/controls/NumericStyleInput.ts @@ -39,7 +39,7 @@ export class NumericStyleInput extends BaseCustomWebComponentConstructorAppend { #container { display: grid; gap: 0; - grid-template-columns: minmax(0, 1fr) auto 16px; + grid-template-columns: minmax(0, 1fr) auto 16px auto; width: 100%; height: 24px; align-items: stretch; @@ -151,11 +151,12 @@ export class NumericStyleInput extends BaseCustomWebComponentConstructorAppend { -
- - -
- +
+ + +
+
+ `; @@ -251,8 +252,9 @@ export class NumericStyleInput extends BaseCustomWebComponentConstructorAppend { this._unitValueConverter = value; } - private _input: HTMLInputElement; - private _select: HTMLSelectElement; + private _input: HTMLInputElement; + private _select: HTMLSelectElement; + private _addonContainer: HTMLDivElement; private _measure: HTMLSpanElement; private _scrubberButton: HTMLButtonElement; private _increaseButton: HTMLButtonElement; @@ -282,19 +284,33 @@ export class NumericStyleInput extends BaseCustomWebComponentConstructorAppend { constructor() { super(); this._restoreCachedInititalValues(); - this._input = this._getDomElement('input'); - this._select = this._getDomElement('select'); + this._input = this._getDomElement('input'); + this._select = this._getDomElement('select'); + this._addonContainer = this._getDomElement('addon'); this._measure = this._getDomElement('measure'); this._scrubberButton = this._getDomElement('scrubber'); this._increaseButton = this._getDomElement('increase'); this._decreaseButton = this._getDomElement('decrease'); } - ready() { + ready() { this._parseAttributesToProperties(); this._wireEvents(); this._updateValue(); - } + } + + private _addon: HTMLElement; + public get addon() { + return this._addon; + } + public set addon(value: HTMLElement) { + if (this._addon === value) + return; + this._addon?.remove(); + this._addon = value ?? null; + if (this._addon) + this._addonContainer.appendChild(this._addon); + } private _wireEvents() { this._input.addEventListener('change', () => this._applyTypedValue()); @@ -909,4 +925,4 @@ export class NumericStyleInput extends BaseCustomWebComponentConstructorAppend { } } -customElements.define('node-projects-numeric-style-input', NumericStyleInput); \ No newline at end of file +customElements.define('node-projects-numeric-style-input', NumericStyleInput); diff --git a/packages/web-component-designer/src/elements/services/propertiesService/IProperty.ts b/packages/web-component-designer/src/elements/services/propertiesService/IProperty.ts index d9aa1339d..1fdf36f68 100644 --- a/packages/web-component-designer/src/elements/services/propertiesService/IProperty.ts +++ b/packages/web-component-designer/src/elements/services/propertiesService/IProperty.ts @@ -1,7 +1,8 @@ import { IPropertiesService } from './IPropertiesService.js'; import { IPropertyEditor } from './IPropertyEditor.js'; import { PropertyType } from './PropertyType.js'; -import type { IDesignItem } from '../../item/IDesignItem.js'; +import type { IDesignItem } from '../../item/IDesignItem.js'; +import type { UnitEditorAddon } from './propertyEditors/UnitPropertyEditorConfig.js'; export interface IProperty { name: string; @@ -22,7 +23,8 @@ export interface IProperty { units?: string[]; // selectable units for editors that support unit changes unitSteps?: Record; numericValueDecimalPlaces?: number; // rounding used by numeric unit conversions - numericValueConverter?: (value: number, fromUnit: string, toUnit: string, property: IProperty, numericType: string, numberText?: string, rawValue?: string, designItems?: IDesignItem[]) => string | number | null | undefined; + numericValueConverter?: (value: number, fromUnit: string, toUnit: string, property: IProperty, numericType: string, numberText?: string, rawValue?: string, designItems?: IDesignItem[]) => string | number | null | undefined; + unitEditorAddon?: UnitEditorAddon; enumValues?: [name: string, value: string | number][]; // list selectable enum values createEditor?: (property: IProperty) => IPropertyEditor; value?: any; diff --git a/packages/web-component-designer/src/elements/services/propertiesService/propertyEditors/UnitPropertyEditor.ts b/packages/web-component-designer/src/elements/services/propertiesService/propertyEditors/UnitPropertyEditor.ts index 77012bc16..3196f3509 100644 --- a/packages/web-component-designer/src/elements/services/propertiesService/propertyEditors/UnitPropertyEditor.ts +++ b/packages/web-component-designer/src/elements/services/propertiesService/propertyEditors/UnitPropertyEditor.ts @@ -28,6 +28,28 @@ export class UnitPropertyEditor extends BasePropertyEditor { return; await this._valueChanged(e.newValue === '' ? null : e.newValue); }); + if (config?.addon) { + const thisEditor = this; + const context = { + property, + get value() { + return selector.value; + }, + get designItems() { + return thisEditor.designItems; + }, + setValue: async (value: string | null) => { + selector.value = value ?? ''; + await this._valueChanged(value); + }, + previewValue: async (value: string | null) => { + selector.value = value ?? ''; + await this._previewValueChanged(value); + }, + removePreviewValue: async () => this._removePreviewValue() + }; + selector.addon = config.addon(context); + } this.element = selector; } @@ -38,4 +60,4 @@ export class UnitPropertyEditor extends BasePropertyEditor { refreshValue(valueType: ValueType, value: any) { this.element.value = value == null ? '' : String(value); } -} \ No newline at end of file +} diff --git a/packages/web-component-designer/src/elements/services/propertiesService/propertyEditors/UnitPropertyEditorConfig.ts b/packages/web-component-designer/src/elements/services/propertiesService/propertyEditors/UnitPropertyEditorConfig.ts index e2c5c9582..7951daa56 100644 --- a/packages/web-component-designer/src/elements/services/propertiesService/propertyEditors/UnitPropertyEditorConfig.ts +++ b/packages/web-component-designer/src/elements/services/propertiesService/propertyEditors/UnitPropertyEditorConfig.ts @@ -5,6 +5,25 @@ export type UnitPropertyType = 'css-length' | 'css-angle' | 'css-time' | 'css-fr export type UnitConversionResult = string | number | null | undefined; +/** + * Context supplied to an optional control hosted next to a numeric unit editor. + * An addon can use the callbacks to commit or preview values selected by its own popup. + */ +export type UnitEditorAddonContext = { + property: IProperty, + readonly value: string, + readonly designItems: IDesignItem[], + setValue: (value: string | null) => Promise, + previewValue: (value: string | null) => Promise, + removePreviewValue: () => Promise +}; + +/** + * Creates a control, such as a button that opens a specialised unit picker, + * which is displayed beside the standard numeric unit controls. + */ +export type UnitEditorAddon = (context: UnitEditorAddonContext) => HTMLElement; + export type UnitConversionContext = { property: IProperty, numericType: UnitPropertyType, @@ -21,7 +40,8 @@ export type UnitEditorConfig = { units: string[], fixedValues: string[], unitSteps: Record, - convertValue: (context: Omit) => string + convertValue: (context: Omit) => string, + addon?: UnitEditorAddon }; const cssNumericKeywordValues = ['initial', 'inherit', 'unset']; @@ -542,6 +562,7 @@ export function getCssNumericEditorConfig(property: IProperty): UnitEditorConfig units: property.units?.length ? property.units : defaultCssNumericUnits[numericType], fixedValues: getCssNumericKeywordValues(property.values), unitSteps: { ...defaultUnitSteps, ...(property.unitSteps ?? {}) }, - convertValue: context => convertNumericUnitValue({ ...context, property, numericType }) + convertValue: context => convertNumericUnitValue({ ...context, property, numericType }), + addon: property.unitEditorAddon }; -} \ No newline at end of file +} diff --git a/packages/web-component-designer/src/index.ts b/packages/web-component-designer/src/index.ts index 378cdca11..75cd749ec 100644 --- a/packages/web-component-designer/src/index.ts +++ b/packages/web-component-designer/src/index.ts @@ -158,6 +158,7 @@ export * from "./elements/services/propertiesService/propertyEditors/BooleanProp export * from "./elements/services/propertiesService/propertyEditors/ColorPropertyEditor.js"; export * from "./elements/services/propertiesService/propertyEditors/CssPropertyEditor.js"; export * from "./elements/services/propertiesService/propertyEditors/UnitPropertyEditor.js"; +export type { UnitEditorAddon, UnitEditorAddonContext } from "./elements/services/propertiesService/propertyEditors/UnitPropertyEditorConfig.js"; export * from "./elements/services/propertiesService/propertyEditors/DatePropertyEditor.js"; export * from "./elements/services/propertiesService/propertyEditors/ImageButtonListPropertyEditor.js"; export * from "./elements/services/propertiesService/propertyEditors/JsonPropertyEditor.js"; diff --git a/packages/web-component-designer/tests/NumericStyleInput.test.ts b/packages/web-component-designer/tests/NumericStyleInput.test.ts index 2d0277259..c9557ad38 100644 --- a/packages/web-component-designer/tests/NumericStyleInput.test.ts +++ b/packages/web-component-designer/tests/NumericStyleInput.test.ts @@ -2,7 +2,7 @@ import { expect, test } from '@jest/globals'; import type { IProperty } from '../src/elements/services/propertiesService/IProperty'; import { PropertyType } from '../src/elements/services/propertiesService/PropertyType'; import { combineNumericStyleInputValue, getNumericStyleInputUnitLabel, normalizeNumericStyleInputOptionValues, parseNumericStyleInputValue, resolveNumericStyleInputSelectedUnit, resolveNumericStyleInputStep } from '../src/elements/controls/NumericStyleInputValueHelpers'; -import { applyCssNumericPropertyDefaults, convertNumericUnitValue, defaultCssNumericUnits, defaultCssNumericUnitSteps } from '../src/elements/services/propertiesService/propertyEditors/UnitPropertyEditorConfig'; +import { applyCssNumericPropertyDefaults, convertNumericUnitValue, defaultCssNumericUnits, defaultCssNumericUnitSteps, getCssNumericEditorConfig } from '../src/elements/services/propertiesService/propertyEditors/UnitPropertyEditorConfig'; test('parses numeric, fixed, and custom values', () => { expect(parseNumericStyleInputValue('12px')).toEqual({ kind: 'numeric', numberText: '12', value: 12, unit: 'px' }); @@ -219,4 +219,17 @@ test('abstract css properties service fills css numeric metadata', () => { delete (globalThis as any).getComputedStyle; else Object.defineProperty(globalThis, 'getComputedStyle', { configurable: true, value: originalGetComputedStyle }); -}); \ No newline at end of file +}); + +test('css numeric editor configuration retains a unit editor addon', () => { + const addon = () => ({}) as HTMLElement; + const property: IProperty = { + name: 'rotate', + type: 'css-angle', + unitEditorAddon: addon, + service: {} as any, + propertyType: PropertyType.cssValue + }; + + expect(getCssNumericEditorConfig(property)?.addon).toBe(addon); +}); From 624914f08e6be5aa2c74349550ff605b9a1536a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jochen=20K=C3=BChner?= Date: Sun, 2 Aug 2026 19:52:30 +0200 Subject: [PATCH 2/3] node-projects/web-component-designer#464: unit editor addons --- .../propertyEditors/AngleUnitEditorAddon.ts | 98 +++++++++++++++++++ .../propertyEditors/UnitPropertyEditor.ts | 14 ++- .../UnitPropertyEditorConfig.ts | 3 +- packages/web-component-designer/src/index.ts | 1 + .../tests/AngleUnitEditorAddon.test.ts | 61 ++++++++++++ .../tests/NumericStyleInput.test.ts | 6 ++ .../tests/UnitPropertyEditor.test.ts | 30 ++++++ 7 files changed, 211 insertions(+), 2 deletions(-) create mode 100644 packages/web-component-designer/src/elements/services/propertiesService/propertyEditors/AngleUnitEditorAddon.ts create mode 100644 packages/web-component-designer/tests/AngleUnitEditorAddon.test.ts create mode 100644 packages/web-component-designer/tests/UnitPropertyEditor.test.ts diff --git a/packages/web-component-designer/src/elements/services/propertiesService/propertyEditors/AngleUnitEditorAddon.ts b/packages/web-component-designer/src/elements/services/propertiesService/propertyEditors/AngleUnitEditorAddon.ts new file mode 100644 index 000000000..3d7499507 --- /dev/null +++ b/packages/web-component-designer/src/elements/services/propertiesService/propertyEditors/AngleUnitEditorAddon.ts @@ -0,0 +1,98 @@ +import type { UnitEditorAddon } from './UnitPropertyEditorConfig.js'; + +const popupSize = 108; +const angleUnitInDegrees: Record = { deg: 1, grad: 0.9, rad: 180 / Math.PI, turn: 360 }; + +function getAngle(event: PointerEvent, circle: HTMLElement) { + const rect = circle.getBoundingClientRect(); + const x = event.clientX - (rect.left + rect.width / 2); + const y = event.clientY - (rect.top + rect.height / 2); + return (Math.atan2(-y, x) * 180 / Math.PI + 360) % 360; +} + +export function getAngleInDegrees(value?: string | null) { + const match = value?.trim().match(/^([+-]?(?:\d+(?:\.\d+)?|\.\d+))\s*([a-z]+)?$/i); + if (!match) + return 0; + const angle = Number(match[1]) * (angleUnitInDegrees[match[2]?.toLowerCase() ?? 'deg'] ?? 1); + return Number.isFinite(angle) ? ((angle % 360) + 360) % 360 : 0; +} + +/** Creates the built-in circular picker used by CSS angle properties. */ +export const createAngleUnitEditorAddon: UnitEditorAddon = context => { + const button = document.createElement('button'); + button.type = 'button'; + button.title = 'Pick angle'; + button.setAttribute('aria-label', 'Pick angle'); + button.textContent = '◉'; + button.style.cssText = 'border:0;background:transparent;color:inherit;cursor:pointer;padding:0 3px;height:24px;line-height:1;'; + + let popup: HTMLDivElement | null = null; + let circle: HTMLDivElement | null = null; + let dragging = false; + let cancelOnBlur: () => void; + + const updateHand = (angle: number) => { + const hand = popup?.querySelector('[data-angle-hand]'); + if (hand) + hand.style.transform = `translateX(-50%) rotate(${angle}deg)`; + }; + const selectAngle = async (event: PointerEvent, commit: boolean) => { + if (!circle) + return; + const angle = Math.round(getAngle(event, circle)); + updateHand(angle); + const value = `${angle}deg`; + if (commit) + await context.setValue(value); + else + await context.previewValue(value); + }; + const close = async (removePreview = false) => { + if (removePreview) + await context.removePreviewValue(); + popup?.remove(); + popup = null; + circle = null; + dragging = false; + document.removeEventListener('pointerdown', outsidePointerDown, true); + window.removeEventListener('blur', cancelOnBlur); + }; + const outsidePointerDown = (event: PointerEvent) => { + if (popup && !popup.contains(event.target as Node) && event.target !== button) + void close(dragging); + }; + const open = () => { + if (popup) { + void close(dragging); + return; + } + popup = document.createElement('div'); + popup.style.cssText = `position:fixed;z-index:100000;width:${popupSize}px;height:${popupSize}px;border:2px solid currentColor;border-radius:50%;background:var(--property-editor-popup-background,#fff);color:var(--property-editor-popup-color,#111);box-sizing:border-box;`; + circle = popup; + const labels = [['0', 'right'], ['90', 'top'], ['180', 'left'], ['270', 'bottom']]; + for (const [label, position] of labels) { + const item = document.createElement('span'); + item.textContent = label; + item.style.cssText = `position:absolute;font:10px sans-serif;${position}:4px;${position === 'right' || position === 'left' ? 'top:50%;transform:translateY(-50%);' : 'left:50%;transform:translateX(-50%);'}`; + popup.appendChild(item); + } + const hand = document.createElement('span'); + hand.dataset.angleHand = ''; + hand.style.cssText = `position:absolute;left:50%;top:50%;width:42%;height:2px;background:#f22;transform-origin:0 50%;transform:rotate(${getAngleInDegrees(context.value)}deg);`; + popup.appendChild(hand); + document.body.appendChild(popup); + const rect = button.getBoundingClientRect(); + popup.style.left = `${Math.max(4, rect.right - popupSize)}px`; + popup.style.top = `${rect.bottom + 4}px`; + circle.addEventListener('pointerdown', event => { dragging = true; circle!.setPointerCapture?.(event.pointerId); void selectAngle(event, false); }); + circle.addEventListener('pointermove', event => { if (dragging) void selectAngle(event, false); }); + circle.addEventListener('pointerup', event => { if (dragging) { dragging = false; void selectAngle(event, true); } }); + circle.addEventListener('pointercancel', () => { if (dragging) void close(true); }); + cancelOnBlur = () => { if (dragging) void close(true); }; + window.addEventListener('blur', cancelOnBlur); + document.addEventListener('pointerdown', outsidePointerDown, true); + }; + button.addEventListener('click', open); + return button; +}; diff --git a/packages/web-component-designer/src/elements/services/propertiesService/propertyEditors/UnitPropertyEditor.ts b/packages/web-component-designer/src/elements/services/propertiesService/propertyEditors/UnitPropertyEditor.ts index 3196f3509..48bfd355d 100644 --- a/packages/web-component-designer/src/elements/services/propertiesService/propertyEditors/UnitPropertyEditor.ts +++ b/packages/web-component-designer/src/elements/services/propertiesService/propertyEditors/UnitPropertyEditor.ts @@ -30,6 +30,7 @@ export class UnitPropertyEditor extends BasePropertyEditor { }); if (config?.addon) { const thisEditor = this; + let previewStartValue: string | null = null; const context = { property, get value() { @@ -40,13 +41,24 @@ export class UnitPropertyEditor extends BasePropertyEditor { }, setValue: async (value: string | null) => { selector.value = value ?? ''; + previewStartValue = null; await this._valueChanged(value); }, previewValue: async (value: string | null) => { + previewStartValue ??= selector.value; selector.value = value ?? ''; await this._previewValueChanged(value); }, - removePreviewValue: async () => this._removePreviewValue() + removePreviewValue: async () => { + try { + await this._removePreviewValue(); + } finally { + if (previewStartValue !== null) { + selector.value = previewStartValue; + previewStartValue = null; + } + } + } }; selector.addon = config.addon(context); } diff --git a/packages/web-component-designer/src/elements/services/propertiesService/propertyEditors/UnitPropertyEditorConfig.ts b/packages/web-component-designer/src/elements/services/propertiesService/propertyEditors/UnitPropertyEditorConfig.ts index 7951daa56..4892df59b 100644 --- a/packages/web-component-designer/src/elements/services/propertiesService/propertyEditors/UnitPropertyEditorConfig.ts +++ b/packages/web-component-designer/src/elements/services/propertiesService/propertyEditors/UnitPropertyEditorConfig.ts @@ -1,5 +1,6 @@ import type { IDesignItem } from '../../../item/IDesignItem.js'; import type { IProperty } from '../IProperty.js'; +import { createAngleUnitEditorAddon } from './AngleUnitEditorAddon.js'; export type UnitPropertyType = 'css-length' | 'css-angle' | 'css-time' | 'css-frequency' | 'css-flex' | 'css-resolution' | 'css-scale' | 'svg-length'; @@ -563,6 +564,6 @@ export function getCssNumericEditorConfig(property: IProperty): UnitEditorConfig fixedValues: getCssNumericKeywordValues(property.values), unitSteps: { ...defaultUnitSteps, ...(property.unitSteps ?? {}) }, convertValue: context => convertNumericUnitValue({ ...context, property, numericType }), - addon: property.unitEditorAddon + addon: property.unitEditorAddon ?? (numericType === 'css-angle' ? createAngleUnitEditorAddon : undefined) }; } diff --git a/packages/web-component-designer/src/index.ts b/packages/web-component-designer/src/index.ts index 75cd749ec..5c235c39c 100644 --- a/packages/web-component-designer/src/index.ts +++ b/packages/web-component-designer/src/index.ts @@ -159,6 +159,7 @@ export * from "./elements/services/propertiesService/propertyEditors/ColorProper export * from "./elements/services/propertiesService/propertyEditors/CssPropertyEditor.js"; export * from "./elements/services/propertiesService/propertyEditors/UnitPropertyEditor.js"; export type { UnitEditorAddon, UnitEditorAddonContext } from "./elements/services/propertiesService/propertyEditors/UnitPropertyEditorConfig.js"; +export { createAngleUnitEditorAddon, getAngleInDegrees } from "./elements/services/propertiesService/propertyEditors/AngleUnitEditorAddon.js"; export * from "./elements/services/propertiesService/propertyEditors/DatePropertyEditor.js"; export * from "./elements/services/propertiesService/propertyEditors/ImageButtonListPropertyEditor.js"; export * from "./elements/services/propertiesService/propertyEditors/JsonPropertyEditor.js"; diff --git a/packages/web-component-designer/tests/AngleUnitEditorAddon.test.ts b/packages/web-component-designer/tests/AngleUnitEditorAddon.test.ts new file mode 100644 index 000000000..eb52e00db --- /dev/null +++ b/packages/web-component-designer/tests/AngleUnitEditorAddon.test.ts @@ -0,0 +1,61 @@ +/** @jest-environment jsdom */ +import { expect, jest, test } from '@jest/globals'; +import type { IProperty } from '../src/elements/services/propertiesService/IProperty'; +import { createAngleUnitEditorAddon, getAngleInDegrees } from '../src/elements/services/propertiesService/propertyEditors/AngleUnitEditorAddon'; + +test.each([ + ['90deg', 90], ['100grad', 90], ['1.5707963268rad', 90], ['0.25turn', 90] +])('normalizes %s for the angle picker hand', (value, expected) => { + expect(getAngleInDegrees(value)).toBeCloseTo(expected); +}); + +test('angle picker removes a preview when pointer interaction is cancelled', async () => { + const removePreviewValue = jest.fn<() => Promise>().mockResolvedValue(undefined); + const context = { + property: {} as IProperty, + value: '1rad', + designItems: [], + setValue: jest.fn<() => Promise>().mockResolvedValue(undefined), + previewValue: jest.fn<() => Promise>().mockResolvedValue(undefined), + removePreviewValue + }; + const button = createAngleUnitEditorAddon(context); + document.body.appendChild(button); + button.click(); + const popup = document.body.lastElementChild as HTMLElement; + popup.dispatchEvent(Object.assign(new Event('pointerdown'), { clientX: 10, clientY: 10, pointerId: 1 })); + popup.dispatchEvent(new Event('pointercancel')); + await Promise.resolve(); + expect(context.previewValue).toHaveBeenCalled(); + expect(removePreviewValue).toHaveBeenCalled(); + expect(document.body.contains(popup)).toBe(false); + button.remove(); +}); + +test.each([ + ['right', 100, 50, '0deg'], + ['top', 50, 0, '90deg'], + ['left', 0, 50, '180deg'], + ['bottom', 50, 100, '270deg'] +])('dragging to the %s of the dial selects the expected angle', async (_position, clientX, clientY, expected) => { + const previewValue = jest.fn<() => Promise>().mockResolvedValue(undefined); + const context = { + property: {} as IProperty, + value: '0deg', + designItems: [], + setValue: jest.fn<() => Promise>().mockResolvedValue(undefined), + previewValue, + removePreviewValue: jest.fn<() => Promise>().mockResolvedValue(undefined) + }; + const button = createAngleUnitEditorAddon(context); + document.body.appendChild(button); + button.click(); + const popup = document.body.lastElementChild as HTMLElement; + Object.defineProperty(popup, 'getBoundingClientRect', { value: () => ({ left: 0, top: 0, width: 100, height: 100 }) }); + popup.dispatchEvent(Object.assign(new Event('pointerdown'), { clientX, clientY, pointerId: 1 })); + popup.dispatchEvent(Object.assign(new Event('pointerup'), { clientX, clientY, pointerId: 1 })); + await Promise.resolve(); + expect(previewValue).toHaveBeenCalledWith(expected); + button.remove(); + popup.remove(); +}); diff --git a/packages/web-component-designer/tests/NumericStyleInput.test.ts b/packages/web-component-designer/tests/NumericStyleInput.test.ts index c9557ad38..59b1d2d67 100644 --- a/packages/web-component-designer/tests/NumericStyleInput.test.ts +++ b/packages/web-component-designer/tests/NumericStyleInput.test.ts @@ -3,6 +3,7 @@ import type { IProperty } from '../src/elements/services/propertiesService/IProp import { PropertyType } from '../src/elements/services/propertiesService/PropertyType'; import { combineNumericStyleInputValue, getNumericStyleInputUnitLabel, normalizeNumericStyleInputOptionValues, parseNumericStyleInputValue, resolveNumericStyleInputSelectedUnit, resolveNumericStyleInputStep } from '../src/elements/controls/NumericStyleInputValueHelpers'; import { applyCssNumericPropertyDefaults, convertNumericUnitValue, defaultCssNumericUnits, defaultCssNumericUnitSteps, getCssNumericEditorConfig } from '../src/elements/services/propertiesService/propertyEditors/UnitPropertyEditorConfig'; +import { createAngleUnitEditorAddon } from '../src/elements/services/propertiesService/propertyEditors/AngleUnitEditorAddon'; test('parses numeric, fixed, and custom values', () => { expect(parseNumericStyleInputValue('12px')).toEqual({ kind: 'numeric', numberText: '12', value: 12, unit: 'px' }); @@ -233,3 +234,8 @@ test('css numeric editor configuration retains a unit editor addon', () => { expect(getCssNumericEditorConfig(property)?.addon).toBe(addon); }); + +test('css angle editor gets the built-in angle addon', () => { + const property: IProperty = { name: 'rotate', type: 'css-angle', service: {} as any, propertyType: PropertyType.cssValue }; + expect(getCssNumericEditorConfig(property)?.addon).toBe(createAngleUnitEditorAddon); +}); diff --git a/packages/web-component-designer/tests/UnitPropertyEditor.test.ts b/packages/web-component-designer/tests/UnitPropertyEditor.test.ts new file mode 100644 index 000000000..ee7276deb --- /dev/null +++ b/packages/web-component-designer/tests/UnitPropertyEditor.test.ts @@ -0,0 +1,30 @@ +/** @jest-environment jsdom */ +import { beforeAll, expect, test } from '@jest/globals'; +import { PropertyType } from '../src/elements/services/propertiesService/PropertyType'; +import type { IProperty } from '../src/elements/services/propertiesService/IProperty'; + +let UnitPropertyEditor: typeof import('../src/elements/services/propertiesService/propertyEditors/UnitPropertyEditor').UnitPropertyEditor; + +beforeAll(async () => { + if (!CSSStyleSheet.prototype.replaceSync) + Object.defineProperty(CSSStyleSheet.prototype, 'replaceSync', { value() { } }); + ({ UnitPropertyEditor } = await import('../src/elements/services/propertiesService/propertyEditors/UnitPropertyEditor')); +}); + +test('restores the numeric editor value when an addon preview is cancelled', async () => { + const property: IProperty = { + name: 'rotate', + type: 'css-angle', + service: {} as any, + propertyType: PropertyType.cssValue + }; + const editor = new UnitPropertyEditor(property); + editor.element.value = '10deg'; + editor.element.addon.click(); + const popup = document.body.lastElementChild as HTMLElement; + popup.dispatchEvent(Object.assign(new Event('pointerdown'), { clientX: 10, clientY: 10, pointerId: 1 })); + popup.dispatchEvent(new Event('pointercancel')); + await Promise.resolve(); + expect(editor.element.value).toBe('10deg'); + editor.element.remove(); +}); From f6e52bb44b583e464388f39da2f224e308ff7bbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jochen=20K=C3=BChner?= Date: Sun, 2 Aug 2026 20:25:58 +0200 Subject: [PATCH 3/3] node-projects/web-component-designer#464: unit editor addons --- .../propertyEditors/AngleUnitEditorAddon.ts | 5 ++ .../propertyEditors/UnitPropertyEditor.ts | 2 + .../tests/UnitPropertyEditor.test.ts | 51 ++++++++++++++++++- 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/packages/web-component-designer/src/elements/services/propertiesService/propertyEditors/AngleUnitEditorAddon.ts b/packages/web-component-designer/src/elements/services/propertiesService/propertyEditors/AngleUnitEditorAddon.ts index 3d7499507..1a4e62ee5 100644 --- a/packages/web-component-designer/src/elements/services/propertiesService/propertyEditors/AngleUnitEditorAddon.ts +++ b/packages/web-component-designer/src/elements/services/propertiesService/propertyEditors/AngleUnitEditorAddon.ts @@ -26,6 +26,11 @@ export const createAngleUnitEditorAddon: UnitEditorAddon = context => { button.setAttribute('aria-label', 'Pick angle'); button.textContent = '◉'; button.style.cssText = 'border:0;background:transparent;color:inherit;cursor:pointer;padding:0 3px;height:24px;line-height:1;'; + if (context.property.readonly) { + button.disabled = true; + button.style.cursor = 'default'; + return button; + } let popup: HTMLDivElement | null = null; let circle: HTMLDivElement | null = null; diff --git a/packages/web-component-designer/src/elements/services/propertiesService/propertyEditors/UnitPropertyEditor.ts b/packages/web-component-designer/src/elements/services/propertiesService/propertyEditors/UnitPropertyEditor.ts index 48bfd355d..3b3186349 100644 --- a/packages/web-component-designer/src/elements/services/propertiesService/propertyEditors/UnitPropertyEditor.ts +++ b/packages/web-component-designer/src/elements/services/propertiesService/propertyEditors/UnitPropertyEditor.ts @@ -40,6 +40,8 @@ export class UnitPropertyEditor extends BasePropertyEditor { return thisEditor.designItems; }, setValue: async (value: string | null) => { + if (previewStartValue !== null) + await this._removePreviewValue(); selector.value = value ?? ''; previewStartValue = null; await this._valueChanged(value); diff --git a/packages/web-component-designer/tests/UnitPropertyEditor.test.ts b/packages/web-component-designer/tests/UnitPropertyEditor.test.ts index ee7276deb..60adf8b54 100644 --- a/packages/web-component-designer/tests/UnitPropertyEditor.test.ts +++ b/packages/web-component-designer/tests/UnitPropertyEditor.test.ts @@ -1,5 +1,5 @@ /** @jest-environment jsdom */ -import { beforeAll, expect, test } from '@jest/globals'; +import { beforeAll, expect, jest, test } from '@jest/globals'; import { PropertyType } from '../src/elements/services/propertiesService/PropertyType'; import type { IProperty } from '../src/elements/services/propertiesService/IProperty'; @@ -28,3 +28,52 @@ test('restores the numeric editor value when an addon preview is cancelled', asy expect(editor.element.value).toBe('10deg'); editor.element.remove(); }); + +test('clears the addon preview before committing a selected value', async () => { + const previewValue = jest.fn<() => Promise>().mockResolvedValue(undefined); + const removePreviewValue = jest.fn<() => Promise>().mockResolvedValue(undefined); + const setValue = jest.fn<() => Promise>().mockResolvedValue(undefined); + const property: IProperty = { + name: 'rotate', + type: 'css-angle', + service: { previewValue, removePreviewValue, setValue } as any, + propertyType: PropertyType.cssValue + }; + const editor = new UnitPropertyEditor(property); + const designItem = { + openGroup: () => ({ commit: jest.fn() }) + } as any; + editor.designItemsChanged([designItem]); + editor.element.value = '10deg'; + editor.element.addon.click(); + const popup = document.body.lastElementChild as HTMLElement; + Object.defineProperty(popup, 'getBoundingClientRect', { value: () => ({ left: 0, top: 0, width: 100, height: 100 }) }); + popup.dispatchEvent(Object.assign(new Event('pointerdown'), { clientX: 100, clientY: 50, pointerId: 1 })); + popup.dispatchEvent(Object.assign(new Event('pointerup'), { clientX: 100, clientY: 50, pointerId: 1 })); + await Promise.resolve(); + await Promise.resolve(); + expect(previewValue).toHaveBeenCalledWith([designItem], property, '0deg'); + expect(removePreviewValue).toHaveBeenCalledWith([designItem], property); + expect(setValue).toHaveBeenCalledWith([designItem], property, '0deg'); + editor.element.remove(); + popup.remove(); +}); + +test('does not open or change a readonly angle property', async () => { + const previewValue = jest.fn<() => Promise>().mockResolvedValue(undefined); + const setValue = jest.fn<() => Promise>().mockResolvedValue(undefined); + const property: IProperty = { + name: 'rotate', + type: 'css-angle', + readonly: true, + service: { previewValue, setValue } as any, + propertyType: PropertyType.cssValue + }; + const editor = new UnitPropertyEditor(property); + editor.element.addon.click(); + expect((editor.element.addon as HTMLButtonElement).disabled).toBe(true); + expect(document.body.querySelector('[data-angle-hand]')).toBeNull(); + expect(previewValue).not.toHaveBeenCalled(); + expect(setValue).not.toHaveBeenCalled(); + editor.element.remove(); +});