fix: handle hover styling for field containers

This commit is contained in:
Lucas Smith
2026-06-22 16:14:04 +10:00
parent e2754ac314
commit ffbfdbf735
5 changed files with 140 additions and 58 deletions
@@ -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 |
<Callout type="info">
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.
</Callout>
### 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;
@@ -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<string, FieldCanvasStyle | undefined>;
@@ -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();
@@ -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<Konva.RectConfig>);
@@ -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 = <T>(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));
};
@@ -44,7 +44,7 @@ export type RenderFieldElementOptions = {
translations: Record<FieldType, string> | 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.
*/
@@ -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