npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@lautarodev/playwright-components

v0.0.4

Published

Declare a UI component once — its locator, typed elements, and actions — then `mount()` it into a fully-typed Playwright test handle.

Downloads

787

Readme

playwright-components

Declare a UI component once — its locator, typed elements, and actions — then mount() it into a fully-typed Playwright test handle.

const loginPage = mount(page, LoginPage);

await loginPage.form.login('[email protected]', 'secret');
await expect(page).toHaveURL('/dashboard');
await expect(loginPage.form.email).toBeVisible(); // primitives are real Locators

That LoginPage is the end of the road — a page composed of nested components. This README builds up to it from the smallest possible component.

Install

npm install -D @lautarodev/playwright-components

@playwright/test >= 1.61.0 is a declared peer dependency. npm 7+ installs it automatically. With yarn or npm 6, install it explicitly:

yarn add -D @lautarodev/playwright-components @playwright/test

Mental model

Three ideas carry the whole library:

  1. Declare a component with Component({ locator, template, actions })template names the elements, actions are high-level methods over them.
  2. Mount it with mount(page, decl) to get a typed handle.
  3. On that handle, primitives are real Locatorsexpect(handle.field) works with no unwrapping, and actions are callable methods.

The rest is composition: components nest inside components, all the way up to a full page.

Your first component

The smallest useful component: one element, no custom actions. A primitive wraps a Locator and adds semantic methods (Button gets .click()), but the result still is a Locator.

import { test, expect } from '@playwright/test';
import { Component, mount, Button } from '@lautarodev/playwright-components';

const SubmitBar = Component({
  locator: (p) => p.locator('.submit-bar'),
  template: (root) => ({
    submit: Button(root.locator('button[type=submit]')),
  }),
});

test('submit button works', async ({ page }) => {
  await page.goto('/');
  const bar = mount(page, SubmitBar);

  await expect(bar.submit).toBeVisible(); // submit is a real Locator
  await bar.submit.click();               // primitive action method
});

Components with actions

Add an actions map to bundle several element interactions behind one method. actions receives the template, so each action drives the named elements.

import { test, expect } from '@playwright/test';
import { Component, mount, Input, Button } from '@lautarodev/playwright-components';

const SearchBox = Component({
  locator: (p) => p.locator('.search'),
  template: (root) => ({
    input: Input(root.locator('input')),
    submit: Button(root.locator('button[type=submit]')),
  }),
  actions: (template) => ({
    search: async (query: string) => {
      await template.input.fill(query);
      await template.submit.click();
    },
  }),
});

test('search returns results', async ({ page }) => {
  await page.goto('/');
  const box = mount(page, SearchBox);

  await box.search('playwright');

  await expect(page).toHaveURL(/\?q=playwright/);
  await expect(box.input).toBeVisible(); // input is still a Locator
});

Effects & preconditions

An action can declare a precondition (runs before the action) and an effects entry (runs after it). Use effects to wait for the outcome — navigation, a toast, a state change — so tests don't sprinkle waitFor* calls.

import { Component, Input, Button } from '@lautarodev/playwright-components';

const LoginForm = Component({
  locator: (p) => p.locator('.login-form'),
  template: (root) => ({
    email: Input(root.locator('input[name=email]')),
    password: Input(root.locator('input[name=password]')),
    submit: Button(root.locator('button[type=submit]')),
  }),
  actions: (template) => ({
    login: async (email: string, password: string) => {
      await template.email.fill(email);
      await template.password.fill(password);
      await template.submit.click();
    },
  }),
  precondition: {
    login: (template) => {
      // runs before login() — assert the form is ready
      void template.submit;
    },
  },
  effects: {
    login: (page) => page.waitForURL('**/dashboard'),
  },
});

The precondition is template-based (it sees the elements); the effect is page-based (it sees the whole page). Calling form.login(...) now waits for /dashboard automatically.

Skipping the effect

Pass { wait: false } as a trailing argument to skip the post-effect only — the precondition always runs. Useful when a parent action already handles the navigation, or when you need to assert mid-flight.

await form.login('[email protected]', 'secret', { wait: false }); // effect skipped

This is the WaitOpts pattern: every leaf action accepts an optional trailing { wait?: boolean }.

Composition — mirror pattern

Components nest by mirroring a child into both template and actions of the parent under the same key. The mounted handle merges the two sides, so handle.child.someElement (a Locator) and handle.child.someAction() are both available.

Extract the password field from the form above into its own component, then compose it back:

import { Component, mount, Input, Button, Toggle } from '@lautarodev/playwright-components';

// Child — a reusable password field with show/hide
const PasswordField = Component({
  locator: (p) => p.locator('.password-field'),
  template: (root) => ({
    input: Input(root.locator('input')),
    toggle: Toggle(root.locator('.show-password')),
  }),
  actions: (template) => ({
    type: async (value: string) => {
      await template.input.fill(value);
    },
    reveal: async () => {
      await template.toggle.check();
    },
  }),
});

// Parent — composes PasswordField under the key `password`
const LoginForm = Component({
  locator: (p) => p.locator('.login-form'),
  template: (root) => ({
    email: Input(root.locator('input[name=email]')),
    password: PasswordField.template!(root.locator('.password-field')), // mirror: element side
    submit: Button(root.locator('button[type=submit]')),
  }),
  actions: (template) => ({
    password: PasswordField.actions!(template.password),                // mirror: action side
    login: async (email: string, password: string) => {
      await template.email.fill(email);
      await template.password.input.fill(password); // child elements reachable in actions
      await template.submit.click();
    },
  }),
});

After mounting, the password key exposes both sides:

const form = mount(page, LoginForm);

// element side — password.input is a real Locator
await expect(form.password.input).toBeVisible();

// action side — password.type() and password.reveal() are callable
await form.password.type('secret');
await form.password.reveal();

// parent action — login() uses both email and password internally
await form.login('[email protected]', 'secret');

Page objects

A page is just the top-level component: its locator resolves the page root, and it mirrors one or more child components. This is the deepest nesting — a page composing a Navbar and the LoginForm from above.

import { Component, mount, Link, Button } from '@lautarodev/playwright-components';

const Navbar = Component({
  locator: (p) => p.locator('nav'),
  template: (root) => ({
    home: Link(root.locator('a[href="/"]')),
    logout: Button(root.locator('.logout')),
  }),
  actions: (template) => ({
    goHome: async () => {
      await template.home.click();
    },
  }),
});

const LoginPage = Component({
  locator: (p) => p.locator('body'), // page root
  template: (root) => ({
    navbar: Navbar.template!(root.locator('nav')),
    form: LoginForm.template!(root.locator('.login-form')),
  }),
  actions: (template) => ({
    navbar: Navbar.actions!(template.navbar),
    form: LoginForm.actions!(template.form),
  }),
});

The mounted page reads exactly like the teaser at the top:

const loginPage = mount(page, LoginPage);

await loginPage.form.login('[email protected]', 'secret');
await loginPage.navbar.goHome();
await expect(loginPage.form.email).toBeVisible();

Page objects with navigation

Page() is the top-level counterpart to Component(). It accepts the same fields plus a url string — and the mounted handle gains an explicit .goto() method that navigates to that URL.

import { Page, Component, mount, Link, Button } from '@lautarodev/playwright-components';

const LoginPage = Page({
  name: 'LoginPage',
  url: '/login',
  locator: (p) => p.locator('body'),
  template: (root) => ({
    navbar: Navbar.template!(root.locator('nav')),
    form: LoginForm.template!(root.locator('.login-form')),
  }),
  actions: (template) => ({
    navbar: Navbar.actions!(template.navbar),
    form: LoginForm.actions!(template.form),
  }),
});

Mounting works identically, but the handle gains .goto():

const loginPage = mount(page, LoginPage);

await loginPage.goto();                              // navigates to '/login'
await loginPage.form.login('[email protected]', 'secret');
await loginPage.navbar.goHome();
await expect(loginPage.form.email).toBeVisible();

.goto() is explicit — it fires only when you call it, never automatically on mount(). Call it once in your fixture setup; the test body stays navigation-free.

Pages cannot be children

Page declarations do not expose .template or .actions, so they cannot be composed as mirror children inside another component's template. Only Component declarations can be nested. TypeScript will not surface a method to embed a Page as a child — the constraint is enforced at the type level.

Fixture wiring with Page

Because the URL lives on the declaration, fixture setup shrinks to a one-liner:

// fixtures.ts
import { test as base, expect } from '@playwright/test';
import { mount } from '@lautarodev/playwright-components';
import type { MountedPage } from '@lautarodev/playwright-components';
import { LoginPage } from './declarations';

export const test = base.extend<{ loginPage: MountedPage<typeof LoginPage> }>({
  loginPage: async ({ page }, use) => {
    const lp = mount(page, LoginPage);
    await lp.goto();              // URL is on the declaration, not the fixture
    await use(lp);
  },
});

export { expect };
// login.spec.ts
test('successful login redirects to dashboard', async ({ loginPage, page }) => {
  await loginPage.form.login('[email protected]', 'secret');
  await expect(page).toHaveURL('/dashboard');
});

Step tracing for goto

.goto() emits a test.step like every other action method. With name: 'LoginPage' the trace label is LoginPage.goto.

Writing tests

Fixture wiring stays in your test code — the library only declares and mounts components. Use Playwright's own test.extend, then mount(page, decl) and pass the handle to use.

When the declaration is a Page, call .goto() inside the fixture to navigate. When it is a plain Component, handle navigation yourself before calling use.

// fixtures.ts
import { test as base, expect } from '@playwright/test';
import { mount } from '@lautarodev/playwright-components';
import type { MountedPage, Mounted } from '@lautarodev/playwright-components';
import { LoginPage } from './declarations';      // declared with Page()

export const test = base.extend<{ loginPage: MountedPage<typeof LoginPage> }>({
  loginPage: async ({ page }, use) => {
    const lp = mount(page, LoginPage);
    await lp.goto();                             // URL lives on the declaration
    await use(lp);
  },
});

export { expect };
// login.spec.ts
import { test, expect } from './fixtures';

test('successful login redirects to dashboard', async ({ loginPage, page }) => {
  await loginPage.form.login('[email protected]', 'secret');
  await expect(page).toHaveURL('/dashboard');
});

test('password reveal toggle works', async ({ loginPage }) => {
  await loginPage.form.password.reveal();
  await expect(loginPage.form.password.input).toHaveAttribute('type', 'text');
});

page stays in the fixture closure — it is NOT on the mounted handle. Need page in a test? Pull it from Playwright's own fixtures, as above: async ({ loginPage, page }) => ….


Reference

Primitives

Each primitive wraps a Locator with semantic action methods. The result is a real Locator, so expect(handle.field) works with no unwrapping.

| Primitive | Method(s) | Notes | |-----------|-----------|-------| | Button | .click(options?) | — | | Link | .click(options?) | — | | Input | .fill(value, options?) .clear(options?) | Covers <input> and <textarea> | | Dropdown | .select(values, options?) | Native <select> — wraps selectOption | | Combobox | .choose(label, options?) | Custom/portaled dropdown — page-wide option match | | RadioButton | .check(options?) | One-way only — no uncheck | | Toggle | .check(options?) .uncheck(options?) | Checkbox or switch | | Image | — | Assert-only; the Locator itself is the surface | | Label | — | Assert-only; the Locator itself is the surface |

options on each method passes through to the underlying Playwright method.

Step tracing

Every leaf action is automatically wrapped in test.step(label, fn) and appears in the Playwright trace viewer and HTML report as a named, collapsible step. No configuration is required.

Step label format

  • With a name on the declaration: ${name}.${key-path} — e.g. LoginForm.password.type
  • Without name: dot-joined key path — e.g. password.type
  • For .goto() on a Page: ${name}.goto — e.g. LoginPage.goto
const LoginForm = Component({
  name: 'LoginForm',        // optional — improves label readability in traces
  locator: (p) => p.locator('.login-form'),
  // ...
});

// Trace label: "LoginForm.login"
await form.login('[email protected]', 'secret');

// Trace label: "LoginForm.password.type" (nested mirror key)
await form.password.type('secret');

A named child component embedded directly in a parent template nests under the parent: a child with name: 'Nav' at key header under App labels its actions App.Nav.* (the name replaces the key as the segment, the parent prefix is kept).

Opt out per mount

Pass { steps: false } as the third argument to disable step wrapping for a specific mount. Actions continue to execute identically.

import type { MountOptions } from '@lautarodev/playwright-components';

const opts: MountOptions = { steps: false };
const form = mount(page, LoginForm, opts);

await form.login('[email protected]', 'secret'); // no test.step emitted

Step tracing has no effect outside a Playwright runner (build scripts, type-only imports) — the library degrades silently to a direct call.

Collision rule

A key cannot be both a leaf template element and an action function at the same level. This is enforced at compile time (NoCollision resolves to never) and thrown at mount time.

// Bad — `submit` is both a Button element and an action function
const Bad = Component({
  locator: (p) => p.locator('.x'),
  template: (root) => ({
    submit: Button(root.locator('button')), // leaf element key
  }),
  actions: () => ({
    submit: async () => {},                 // same key → collision
  }),
});
// TypeScript: typeof Bad is `never`
// Runtime: mount(page, Bad) throws

// Good — action key differs from the element key
const Good = Component({
  locator: (p) => p.locator('.x'),
  template: (root) => ({
    submitBtn: Button(root.locator('button')),
  }),
  actions: (template) => ({
    submit: async () => { await template.submitBtn.click(); },
  }),
});

A mirror child (whose action value is a bag of functions, not a bare function) is never a collision — that is how composition shares a key by design.

Type safety

mount(page, decl) is overloaded by declaration kind:

| Declaration | mount() return type | Has .goto()? | |-------------|-----------------------|----------------| | Component(...) | Mounted<typeof decl> | No | | Page(...) | MountedPage<typeof decl> | Yes |

Both types are fully derived from the declaration — no casting needed. If the declaration changes, the handle type updates automatically.

import type { Mounted, MountedPage } from '@lautarodev/playwright-components';

// Component handle
type LoginFormHandle = Mounted<typeof LoginForm>;
// loginForm.email           → InputLocator (Locator + .fill() + .clear())
// loginForm.password.input  → InputLocator
// loginForm.password.type   → (value: string, opts?: WaitOpts) => Promise<void>
// loginForm.login           → (email: string, password: string, opts?: WaitOpts) => Promise<void>

// Page handle
type LoginPageHandle = MountedPage<typeof LoginPage>;
// loginPage.goto            → () => Promise<void>
// loginPage.form.login      → (email: string, password: string, opts?: WaitOpts) => Promise<void>
// loginPage.navbar.goHome   → (opts?: WaitOpts) => Promise<void>

Page factory

| Export | Kind | Description | |--------|------|-------------| | Page | function | Declares a top-level page component with a url | | PageDecl | type | The branded type returned by Page() | | MountedPage | type | The handle type for a mounted Page — includes .goto() | | isPageDecl | function | Runtime guard: true when value is a PageDecl |