Web Component Implementation

This document describes the <vn-resize-box> custom element architecture — its lifecycle, observed attributes, public API, and internal DOM structure.


1. Overview

VanillaResizeBoxElement is a standard Custom Element v1 registered as <vn-resize-box>. It wraps arbitrary light-DOM content inside a resizable container with a bottom-right drag handle. The component does not use Shadow DOM, which means host-application CSS applies normally to slotted content.


2. Registration

if (!window.customElements.get('vn-resize-box')) {
    window.customElements.define('vn-resize-box', VanillaResizeBoxElement);
}
window.VanillaResizeBoxElement = VanillaResizeBoxElement;

The element is registered once. Subsequent script includes are harmless. The constructor is also exposed on window for programmatic use.


3. Observed Attributes

static get observedAttributes() {
    return [
        'width', 'height', 'min-width', 'min-height', 'max-width', 'max-height',
    ];
}
Attribute Type Default Description
width CSS length Initial width. A bare number is px; any CSS length is accepted (600, 600px, 100%, 60vw)
height CSS length Initial height. Same syntax as width
min-width px 100 Minimum width. Bare number or explicit px only
min-height px 100 Minimum height. Bare number or explicit px only
max-width CSS length Maximum width. Same syntax as width
max-height CSS length Maximum height. Same syntax as width

width, height, max-width and max-height are written straight into the inline style, so any CSS length works. The two min-* attributes are different: they feed the drag-clamping arithmetic, which needs an absolute pixel number, so only a bare number or an explicit px value is honoured — a relative value (50%) is rejected and the default applies.

When min-width or min-height is not explicitly set, the component falls back to VanillaResizeBoxElement.DEFAULT_MIN_WIDTH (100) and VanillaResizeBoxElement.DEFAULT_MIN_HEIGHT (100) respectively.

When max-width or max-height is not set, the component computes parent-relative bounds at drag-start to prevent the box from exceeding its parent container.


4. Lifecycle Callbacks

4.1 connectedCallback()

  1. Injects the component stylesheet into <head> once (shared across all instances).
  2. Builds internal markup: wraps existing light-DOM children in a .vn-resize-box div and appends a .vn-resize-handle element. A MutationObserver on the host then adopts any direct child added after connect into the same wrapper (inserted before the handle) — frameworks that build DOM imperatively (e.g. Angular) attach the element to the document before rendering its children, and without adoption those late children would land outside the sized wrapper.
  3. Applies initial dimensions from attributes to inline styles.

4.2 disconnectedCallback()

Runs the same end-of-drag cleanup as a pointer release (_endDrag(false)): releases the pointer capture, cancels a queued move frame, and clears the page-wide drag state (body user-select, the --dragging class). It fires no event — the element is leaving the document. Removing a box mid-drag must not leave the page unselectable.

4.3 attributeChangedCallback(name, oldValue, newValue)

Routes each observed attribute to the narrowest possible update. It deliberately does not re-run the connect-time pass, because that pass rewrites the authored width/height over the inline style — which is exactly where a dragged size lives.

Attribute changed Effect
width / height Rewrites only that axis (_applySizeAttribute). An explicit change is the author overruling the current size; removing the attribute clears the inline style
min-* / max-* Re-applies the constraint styles only (_applyConstraints), never the size. Skipped while a drag is in flight — bounds are frozen for the gesture and restored when it ends

The size is therefore stable across every attribute change that is not a width/height change, and across a disconnect → reconnect cycle: connectedCallback applies an authored size only when the attribute is actually present.

4.4 The dimension pipeline

Three separate writers, so that no update does more than it was asked to:

Method Writes Called by
_applyInitialDimensions() authored width/height if present, then the constraints connectedCallback
_applySizeAttribute(name) one axis, symmetric — an absent attribute clears the inline style attributeChangedCallback
_applyConstraints() minWidth / minHeight / maxWidth / maxHeight the two above, attributeChangedCallback, and the end of a drag (to restore the attribute bounds over the frozen ones)

5. Internal DOM Structure

After connectedCallback(), the element's light DOM looks like:

<vn-resize-box aria-label="Resizable orders grid">
    <div class="vn-resize-box">
        <!-- original children moved here -->
        <div class="vn-resize-handle" role="separator"
             aria-label="Resizable orders grid — resize handle"></div>
    </div>
</vn-resize-box>

6. Public API

6.1 Properties

Property Type Access Description
width string | null read-only The width attribute, verbatim — width="600" reads "600", not "600px"
height string | null read-only The height attribute, verbatim
maxWidth string | null read-only The max-width attribute, verbatim
maxHeight string | null read-only The max-height attribute, verbatim
minWidth number read-only The minimum width actually enforced, in px — resolved, so a rejected min-width reads back as the default
minHeight number read-only The minimum height actually enforced, in px
currentWidth number read-only Current rendered width in px
currentHeight number read-only Current rendered height in px

The first four are raw attribute reflections and the next two are resolved values, on purpose. width answers "what did the author write"; minWidth answers "what bound will a drag actually respect" — the question a number-typed minimum is useful for. None of the six has a setter: attributes are the configuration channel and resize() is the write path for size.

6.2 Methods

Method Signature Description
resize resize(width?, height?) Programmatically set dimensions (see §6.3)

6.3 resize() semantics

resize() is not a synthetic drag, and differs from one in three stated ways:

  1. Clamping — numeric arguments are clamped to the min-* / max-* attributes only. The parent-relative ceiling a drag enforces is not applied: the host may be sizing before its own layout has settled, so the parent rect would be a bound measured against a page that is about to change. A resize() can therefore produce a size the user could not have dragged to.
  2. Flex neutralisation — it sets flex: 0 0 auto, exactly as _onDown does. Without it the flex algorithm overrides the inline size and the call renders no visible change inside a flex container.
  3. Events — it fires a single vn-resize-end and no vn-resize-start / vn-resize-move. Hosts persisting on vn-resize-end (the recommended pattern) therefore see programmatic changes as well as dragged ones. Avoiding a feedback loop is the host's responsibility; the detail already carries the resulting size.

CSS-string arguments ("100%", "60vw") are applied as-is with no clamping — the clamp is pixel arithmetic and has no meaning for a relative length.

6.4 Static Constants

Name Value Description
DEFAULT_MIN_WIDTH 100 Fallback minimum width when min-width attribute is absent
DEFAULT_MIN_HEIGHT 100 Fallback minimum height when min-height attribute is absent

Three more constants are module-private, deliberately: they are part of the component's feel rather than a configuration surface, and nothing has asked for them to be tunable.

Name Value Description
HANDLE_BOUND_MARGIN 12 Gap kept between the box and its parent's far edge when the parent supplies the ceiling
HANDLE_LABEL_SUFFIX " — resize handle" Appended to the host's aria-label to name the handle
HANDLE_DEFAULT_LABEL "Resize" Handle name when the host supplies no aria-label

7. Events

Event Bubbles Detail When
vn-resize-start yes { width, height } — the size when the drag began User presses the handle
vn-resize-move yes { width, height } Each frame during a drag
vn-resize-end yes { width, height } User releases the handle, or resize() is called

All three carry the same detail shape, so a listener can read the size without a getBoundingClientRect() of its own.


8. Stylesheet Auto-Injection

On first connectedCallback(), the component inserts a <link> tag into <head>:

<link id="vn-resize-box-css" rel="stylesheet" href="{baseUrl}vanilla-resize-box.css">

The base URL is resolved at script parse time from document.currentScript.src. Multiple <vn-resize-box> elements share a single stylesheet link.


9. Accessibility

9.1 Handle semantics

Value
role separator
tabindex not set — the handle is not focusable
aria-label derived from the host's, else "Resize"
aria-hidden not set

role="separator" describes what a resize grip is — a window splitter. It is not the focusable-separator pattern: resizing is pointer-driven, so the handle takes no tabindex. A focusable handle would be a tab stop that answers to no key, and (since the handle is the box's last child) one sitting between the page and the box's own content. role="slider" was rejected for a different reason: a slider is single-valued and this handle drives two axes at once, so aria-valuenow could only ever describe one of them honestly. For the same reason the handle carries no aria-valuenow / aria-valuemin / aria-valuemax and no aria-orientation — a two-axis separator has neither a single value nor a single orientation.

The stylesheet accordingly ships no :focus-visible rule for the handle; the triangle is a pointer affordance.

9.2 Labelling

The handle used to carry aria-hidden="true", while hosts put aria-label="Resizable … grid" on the <vn-resize-box> — an element with no role, where assistive technology generally ignores the label outright. The component now consumes that label instead: the host's aria-label, when present, is copied onto the handle and suffixed, so aria-label="Resizable orders grid" on the host yields "Resizable orders grid — resize handle" on the handle. With no host label the handle falls back to "Resize".

Hosts therefore need no markup change — the label they already write now lands somewhere it is observable.

Two limits worth knowing:

9.3 No keyboard path

The handle has no keydown listener and the component observes no key. A host that needs a keyboard-operable size drives resize() from its own control, which fires vn-resize-end exactly as a drag does.


10. No Shadow DOM

The component deliberately avoids Shadow DOM. This means: