Custom Controls
Wrap selectors in your own control classes with typed methods.
A custom control is any class whose constructor accepts a Locator. Pass it as the second argument to @Selector(...) (or any selector decorator) and the decorator constructs it with the resolved locator.
This is the recommended way to integrate an existing control library without committing to the built-in PageObject hierarchy.
Minimal example
import type { Locator, Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { RootSelector, Selector } from "playwright-page-object";
class InputControl {
constructor(readonly locator: Locator) {}
async fill(value: string) {
await this.locator.fill(value);
}
async expectValue(value: string) {
await expect(this.locator).toHaveValue(value);
}
}
@RootSelector("CheckoutPage")
class CheckoutPage {
constructor(readonly page: Page) {}
@Selector("PromoCodeInput", InputControl)
accessor PromoCode!: InputControl;
}
// Usage
await checkoutPage.PromoCode.fill("SAVE20");
await checkoutPage.PromoCode.expectValue("SAVE20");Factory function form
If your control needs extra constructor arguments, pass an arrow function instead of a class:
class TimedInputControl {
constructor(readonly locator: Locator, readonly timeout: number) {}
}
@Selector("PromoCodeInput", (locator) => new TimedInputControl(locator, 5000))
accessor PromoCode!: TimedInputControl;The decorator detects the difference: classes (with a prototype) get new-called; arrow functions get called directly.
With any selector decorator
All selector decorators accept a control class as their last argument:
@SelectorByRole("button", { name: "Apply" }, ButtonControl)
accessor ApplyButton!: ButtonControl;
@SelectorByText("Submit", ButtonControl)
accessor SubmitButton!: ButtonControl;
@SelectorByLabel("Email", InputControl)
accessor EmailField!: InputControl;Constraint: do not pass a PageObject subclass
PageObject instances are nested via the initializer form, not the factory form. Passing a PageObject subclass as the second argument throws at decoration time:
// Throws — PageObject subclasses must be initialized, not passed.
@Selector("PromoCode", PromoCodePageObject)
accessor PromoCode!: PromoCodePageObject;
// Correct — initialize the instance.
@Selector("PromoCode")
accessor PromoCode = new PromoCodePageObject();This protects the cloneWithContext nesting contract that PageObject relies on. See Built-In POM for the initializer form.
When to pick this style
- Your team already has a control library.
- Selectors repeat enough to deserve a named, typed abstraction.
- You want methods like
.fill()or.click()without the fullPageObjectsurface.
See also
- Built-In POM — for
PageObject-based controls with wait helpers. - Fragments — the same constructor shape, used for sub-trees with their own decorators.
Last updated on