From ffbfdbf73584189e145188d43cc0f82f2716a157 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Mon, 22 Jun 2026 16:14:04 +1000 Subject: [PATCH] fix: handle hover styling for field containers --- .../developers/embedding/css-variables.mdx | 15 +++ .../field-renderer/field-canvas-style.ts | 41 ++++-- .../field-renderer/field-generic-items.ts | 118 +++++++++++------- .../field-renderer/field-renderer.ts | 12 +- .../ui/lib/field-root-container-classes.ts | 12 ++ 5 files changed, 140 insertions(+), 58 deletions(-) diff --git a/apps/docs/content/docs/developers/embedding/css-variables.mdx b/apps/docs/content/docs/developers/embedding/css-variables.mdx index ae7185964..69836b737 100644 --- a/apps/docs/content/docs/developers/embedding/css-variables.mdx +++ b/apps/docs/content/docs/developers/embedding/css-variables.mdx @@ -134,6 +134,15 @@ Specific parts of the embed can be targeted with CSS classes for granular stylin | `.embed--WaitingForTurn` | Waiting screen when it is not the user's turn | | `.embed--DocumentCompleted` | Completion screen after signing | | `.field--FieldRootContainer` | Base container for document fields | +| `.field--FieldRootContainerHover` | Hover state for document fields | + + + Fields are rendered onto a canvas, which has no real `:hover` pseudo-class. To + style a field's hover state, target the `.field--FieldRootContainerHover` class + instead of `:hover`. The properties that take effect on hover are + `background-color`, `border-color`, `border-width`, `border-radius` and + `opacity`. Any of these you leave unset keep their resting value. + ### Field Data Attributes @@ -159,6 +168,12 @@ Fields expose data attributes for state-based styling: opacity: 0.2; } +/* Style the field hover state */ +.field--FieldRootContainerHover { + background-color: rgba(0, 0, 0, 0.04); + border-color: var(--primary); +} + /* Custom widget styling */ .embed--DocumentWidget { background-color: #ffffff; diff --git a/packages/lib/universal/field-renderer/field-canvas-style.ts b/packages/lib/universal/field-renderer/field-canvas-style.ts index 316185607..612eab22c 100644 --- a/packages/lib/universal/field-renderer/field-canvas-style.ts +++ b/packages/lib/universal/field-renderer/field-canvas-style.ts @@ -1,10 +1,11 @@ import { FIELD_PROBE_ANCHOR_SELECTOR, + FIELD_ROOT_CONTAINER_HOVER_CLASS_NAME, FIELD_ROOT_CONTAINER_PROBE_CLASS_NAME, } from '@documenso/ui/lib/field-root-container-classes'; import { colord } from 'colord'; -import type { FieldCanvasStyle, FieldRenderMode, FieldToRender } from './field-renderer'; +import type { FieldCanvasStyle, FieldCanvasStyleProps, FieldRenderMode, FieldToRender } from './field-renderer'; export type FieldCanvasStyleCache = Map; @@ -98,6 +99,25 @@ const createFieldProbeElement = (field: FieldToRender): HTMLElement => { return $probe; }; +/** + * Map a probe's computed style onto the canvas-handled prop set. Shared by the + * resting and hover reads so both honor every property the renderer can apply + * (see `field-generic-items.ts`): fill, stroke, stroke width, corner radius and + * group opacity. + */ +const extractCanvasStyleProps = (computedStyle: CSSStyleDeclaration): FieldCanvasStyleProps => { + const borderWidth = getPixelValue(computedStyle.borderTopWidth); + const hasBorderStyle = computedStyle.borderTopStyle !== 'none' && Boolean(borderWidth); + + return { + backgroundColor: getRenderableColor(computedStyle.backgroundColor), + borderColor: hasBorderStyle ? getRenderableColor(computedStyle.borderTopColor) : undefined, + borderRadius: getPixelValue(computedStyle.borderTopLeftRadius), + borderWidth: hasBorderStyle ? borderWidth : undefined, + opacity: getOpacityValue(computedStyle.opacity), + }; +}; + const computeFieldCanvasStyleFromProbe = (field: FieldToRender): FieldCanvasStyle | undefined => { if (typeof document === 'undefined' || typeof window === 'undefined') { return undefined; @@ -119,18 +139,17 @@ const computeFieldCanvasStyleFromProbe = (field: FieldToRender): FieldCanvasStyl $anchor.appendChild($probe); try { - const computedStyle = window.getComputedStyle($probe); - const borderWidth = getPixelValue(computedStyle.borderTopWidth); - const borderColor = getRenderableColor(computedStyle.borderTopColor); - const hasBorderStyle = computedStyle.borderTopStyle !== 'none' && Boolean(borderWidth); - const borderRadius = getPixelValue(computedStyle.borderTopLeftRadius); + const restingStyle = extractCanvasStyleProps(window.getComputedStyle($probe)); + + // Reuse the same element for the hover read: adding the hover class makes + // `getComputedStyle` resolve the hovered cascade synchronously — no real + // pointer or `:hover` pseudo-class needed. + $probe.classList.add(FIELD_ROOT_CONTAINER_HOVER_CLASS_NAME); + const hoverStyle = extractCanvasStyleProps(window.getComputedStyle($probe)); return { - backgroundColor: getRenderableColor(computedStyle.backgroundColor), - borderColor: hasBorderStyle ? borderColor : undefined, - borderRadius, - borderWidth: hasBorderStyle ? borderWidth : undefined, - opacity: getOpacityValue(computedStyle.opacity), + ...restingStyle, + hover: hoverStyle, }; } finally { $probe.remove(); diff --git a/packages/lib/universal/field-renderer/field-generic-items.ts b/packages/lib/universal/field-renderer/field-generic-items.ts index d37d0234b..f8533add5 100644 --- a/packages/lib/universal/field-renderer/field-generic-items.ts +++ b/packages/lib/universal/field-renderer/field-generic-items.ts @@ -8,6 +8,11 @@ export const konvaTextFontFamily = '"Noto Sans", "Noto Sans Japanese", "Noto Sans Chinese", "Noto Sans Korean", sans-serif'; export const konvaTextFill = 'black'; +// Renderer defaults, shared between `upsertFieldRect` and the hover interaction so +// hover tweens return the field to its actual idle state rather than a guess. +const DEFAULT_FIELD_STROKE_WIDTH = 2; +const DEFAULT_FIELD_CORNER_RADIUS = 2; + export const upsertFieldGroup = (field: FieldToRender, options: RenderFieldElementOptions): Konva.Group => { const { pageWidth, pageHeight, pageLayer, editable, scale } = options; @@ -59,8 +64,8 @@ export const upsertFieldRect = (field: FieldToRender, options: RenderFieldElemen height: fieldHeight, fill: fieldCanvasStyle?.backgroundColor ?? DEFAULT_RECT_BACKGROUND, stroke: fieldCanvasStyle?.borderColor ?? (color ? getRecipientColorStyles(color).baseRing : '#e5e7eb'), - strokeWidth: fieldCanvasStyle?.borderWidth ?? 2, - cornerRadius: fieldCanvasStyle?.borderRadius ?? 2, + strokeWidth: fieldCanvasStyle?.borderWidth ?? DEFAULT_FIELD_STROKE_WIDTH, + cornerRadius: fieldCanvasStyle?.borderRadius ?? DEFAULT_FIELD_CORNER_RADIUS, strokeScaleEnabled: false, visible: mode !== 'export', } satisfies Partial); @@ -119,71 +124,92 @@ type CreateFieldHoverInteractionOptions = { fieldRect: Konva.Rect; }; +type FieldHoverTweenState = { + fill: string; + stroke: string; + strokeWidth: number; + cornerRadius: number; + opacity: number; +}; + /** * Adds smooth transition-like behavior for hover effects to the field group and rectangle. + * + * Resting and hover states both derive from the resolved field canvas style (read + * from a probe, so custom embed CSS is honored — hover via the + * `field--FieldRootContainerHover` class). Every canvas-handled property is + * respected on hover: fill, stroke, stroke width, corner radius and opacity. When + * the embedder hasn't customized a hover property (its probed value matches the + * resting value), it falls back to the resting value — except the background, + * which falls back to the default recipient hover color to preserve the built-in + * hover effect for teams without custom branding. */ export const createFieldHoverInteraction = ({ options, fieldGroup, fieldRect }: CreateFieldHoverInteractionOptions) => { - const { mode } = options; + const { mode, color, fieldCanvasStyle } = options; - if (mode === 'export' || !options.color) { + if (mode === 'export' || !color) { return; } - if (options.fieldCanvasStyle?.backgroundColor) { - return; - } + const recipientStyles = getRecipientColorStyles(color); - const hoverColor = getRecipientColorStyles(options.color).baseRingHover; + const restingState: FieldHoverTweenState = { + fill: fieldCanvasStyle?.backgroundColor ?? DEFAULT_RECT_BACKGROUND, + stroke: fieldCanvasStyle?.borderColor ?? recipientStyles.baseRing, + strokeWidth: fieldCanvasStyle?.borderWidth ?? DEFAULT_FIELD_STROKE_WIDTH, + cornerRadius: fieldCanvasStyle?.borderRadius ?? DEFAULT_FIELD_CORNER_RADIUS, + opacity: fieldCanvasStyle?.opacity ?? 1, + }; - fieldGroup.on('mouseover', () => { - const layer = fieldRect.getLayer(); - if (!layer) { + const hover = fieldCanvasStyle?.hover; + + // A hover prop is "customized" only when it differs from the resting value; + // otherwise the hover class resolved to nothing and we keep the resting value. + const pickHover = (hoverValue: T | undefined, restingValue: T, customizedFallback: T): T => { + if (hoverValue === undefined || hoverValue === restingValue) { + return customizedFallback; + } + + return hoverValue; + }; + + const hoverState: FieldHoverTweenState = { + // Background is special: when uncustomized, fall back to the recipient hover + // color rather than the resting fill so the default hover effect still shows. + fill: pickHover(hover?.backgroundColor, restingState.fill, recipientStyles.baseRingHover), + stroke: pickHover(hover?.borderColor, restingState.stroke, restingState.stroke), + strokeWidth: pickHover(hover?.borderWidth, restingState.strokeWidth, restingState.strokeWidth), + cornerRadius: pickHover(hover?.borderRadius, restingState.cornerRadius, restingState.cornerRadius), + opacity: pickHover(hover?.opacity, restingState.opacity, restingState.opacity), + }; + + const tweenTo = (state: FieldHoverTweenState) => { + if (!fieldRect.getLayer()) { return; } new Konva.Tween({ node: fieldRect, duration: 0.3, - fill: hoverColor, + fill: state.fill, + stroke: state.stroke, + strokeWidth: state.strokeWidth, + cornerRadius: state.cornerRadius, }).play(); - }); - - fieldGroup.on('mouseout', () => { - const layer = fieldRect.getLayer(); - if (!layer) { - return; - } new Konva.Tween({ - node: fieldRect, + node: fieldGroup, duration: 0.3, - fill: DEFAULT_RECT_BACKGROUND, + opacity: state.opacity, }).play(); - }); + }; - fieldGroup.on('transformstart', () => { - const layer = fieldRect.getLayer(); - if (!layer) { - return; - } + // Field groups are reused across re-renders (upserted via `findOne`), so clear + // any previously-bound hover listeners before re-registering to avoid stacking. + fieldGroup.off('mouseover.fieldHover mouseout.fieldHover transformstart.fieldHover transformend.fieldHover'); - new Konva.Tween({ - node: fieldRect, - duration: 0.3, - fill: hoverColor, - }).play(); - }); - - fieldGroup.on('transformend', () => { - const layer = fieldRect.getLayer(); - if (!layer) { - return; - } - - new Konva.Tween({ - node: fieldRect, - duration: 0.3, - fill: DEFAULT_RECT_BACKGROUND, - }).play(); - }); + fieldGroup.on('mouseover.fieldHover', () => tweenTo(hoverState)); + fieldGroup.on('mouseout.fieldHover', () => tweenTo(restingState)); + fieldGroup.on('transformstart.fieldHover', () => tweenTo(hoverState)); + fieldGroup.on('transformend.fieldHover', () => tweenTo(restingState)); }; diff --git a/packages/lib/universal/field-renderer/field-renderer.ts b/packages/lib/universal/field-renderer/field-renderer.ts index 5b8169e8c..73bb573a5 100644 --- a/packages/lib/universal/field-renderer/field-renderer.ts +++ b/packages/lib/universal/field-renderer/field-renderer.ts @@ -44,7 +44,7 @@ export type RenderFieldElementOptions = { translations: Record | null; }; -export type FieldCanvasStyle = { +export type FieldCanvasStyleProps = { backgroundColor?: string; borderColor?: string; borderRadius?: number; @@ -52,6 +52,16 @@ export type FieldCanvasStyle = { opacity?: number; }; +export type FieldCanvasStyle = FieldCanvasStyleProps & { + /** + * The custom hover state, resolved from the `field--FieldRootContainerHover` + * class. Mirrors the resting prop set so any canvas-handled property can change + * on hover. Properties left unset fall back to the resting value (and the + * background falls back to the default recipient hover color). + */ + hover?: FieldCanvasStyleProps; +}; + /** * Converts a fields percentage based values to pixel based values. */ diff --git a/packages/ui/lib/field-root-container-classes.ts b/packages/ui/lib/field-root-container-classes.ts index 8eabc695f..e4e6bf8d2 100644 --- a/packages/ui/lib/field-root-container-classes.ts +++ b/packages/ui/lib/field-root-container-classes.ts @@ -5,6 +5,18 @@ export const FIELD_ROOT_CONTAINER_CLASS_NAME = `${FIELD_ROOT_CONTAINER_SHARED_CL export const FIELD_ROOT_CONTAINER_PROBE_CLASS_NAME = `field--FieldRootContainerProbe ${FIELD_ROOT_CONTAINER_SHARED_CLASS_NAME}`; +/** + * Class embedders target to style a field's hover state, e.g. + * `.field--FieldRootContainerHover { background-color: ... }`. + * + * Canvas-rendered fields can't use the `:hover` pseudo-class (there's no real + * DOM element to hover), so the hover probe applies this class permanently to + * read the hovered cascade synchronously via `getComputedStyle`. Using a real + * class instead of `:hover` keeps resolution deterministic — no stylesheet + * parsing, pseudo-class synthesis, or source-order/specificity guesswork. + */ +export const FIELD_ROOT_CONTAINER_HOVER_CLASS_NAME = 'field--FieldRootContainerHover'; + /** * Selector for the element the probe is appended into when reading computed * field styles. It must be an ancestor of where real fields render so the probe