# Handling Complex Page Elements

Course: [Playwright](https://www.devtutorials.ai/learn/playwright) · Section: Browser Control
Source: https://www.devtutorials.ai/learn/playwright/browser-control/handling-complex-page-elements

> Frames and nested frames, Shadow DOM and its limits, alert, confirm, prompt and beforeunload dialogs, unexpected overlays with addLocatorHandler, file uploads and file choosers, downloads, the keyboard and mouse APIs, drag and drop, working with lists, and the clipboard.

## Handling Complex Page Elements

- Real pages are not just buttons and text boxes.
- They have payment forms inside frames, widgets inside Shadow DOM, pop-up dialogs, cookie banners, file uploads and downloads.
- Playwright has a tool for each. In this lesson, you will use them all on the Practice Shop's **Widgets** page.

**Simple lesson:** frames, dialogs, overlays, files and drag and drop each have their own Playwright tool.

### A Real-World Example

- The Practice Shop takes card payments in a form from a payment company.
- That form sits in an **iframe**: a separate page inside the page.
- Normal locators cannot see inside it. `frameLocator()` can.

### What You Will Learn

1. Frames and nested frames.
2. Shadow DOM.
3. Alert, confirm, prompt and beforeunload dialogs.
4. Surprise overlays such as cookie banners.
5. File uploads and downloads.
6. The low-level keyboard and mouse.
7. Drag and drop, lists, and the clipboard.

## Frames

### frameLocator()

- An **iframe** shows another page inside the current one. It has its own document.
- `page.frameLocator('iframe[name="payment"]')` points into it. Then use `getByRole()`, `getByLabel()` and the rest as usual.
- You can also start from a locator: `page.getByTitle('Payment form').contentFrame()`.

### Nested Frames

- A frame can contain another frame. Chain `frameLocator()` to go deeper.
- Since Playwright 1.63, `page.frameLocator()` with no selector searches every frame on the page. It is handy when you do not care which frame holds the element.

### page.frames()

- `page.frames()` lists every frame, starting with the main one. `page.frame({ name: 'secure' })` finds one by name.

<figure className="plw-fig">
<svg viewBox="0 0 640 200" role="img" aria-labelledby="fig-plw-frames" style={{ width: "100%", maxWidth: 640, height: "auto" }}>
  <title id="fig-plw-frames">Animation: a locator steps from the main page into the payment frame, then into the nested 3-D Secure frame, to reach the one-time password box</title>
  <rect x="15" y="15" width="610" height="170" rx="12" style={{ fill: "var(--plw-win)", stroke: "var(--plw-slate)" }} />
  <text x="30" y="38" style={{ fill: "var(--ink)", fontFamily: "var(--sans)", fontSize: 13, fontWeight: 700 }}>Main page: /widgets</text>
  <rect x="150" y="50" width="460" height="125" rx="10" style={{ fill: "var(--plw-blue-soft)", stroke: "var(--plw-blue)" }} />
  <text x="165" y="72" style={{ fill: "var(--plw-blue)", fontFamily: "var(--sans)", fontSize: 12, fontWeight: 700 }}>iframe name="payment"</text>
  <rect x="330" y="85" width="265" height="78" rx="8" style={{ fill: "var(--plw-purple-soft)", stroke: "var(--plw-purple)" }} />
  <text x="345" y="105" style={{ fill: "var(--plw-purple)", fontFamily: "var(--sans)", fontSize: 12, fontWeight: 700 }}>iframe name="secure"</text>
  <rect x="345" y="118" width="150" height="26" rx="5" style={{ fill: "#fff", stroke: "var(--plw-slate)" }} />
  <text x="355" y="136" style={{ fill: "var(--muted)", fontFamily: "var(--sans)", fontSize: 11 }}>One-time password</text>
  <circle r="8" style={{ fill: "var(--plw-orange)" }}>
    <animate attributeName="cx" values="60;60;200;200;420;420" keyTimes="0;0.15;0.4;0.55;0.8;1" dur="6s" repeatCount="indefinite" />
    <animate attributeName="cy" values="110;110;110;110;131;131" keyTimes="0;0.15;0.4;0.55;0.8;1" dur="6s" repeatCount="indefinite" />
  </circle>
  <g style={{ fontFamily: "var(--mono)", fontSize: 10, fill: "var(--plw-orange)" }}>
    <text x="30" y="160" opacity="0">page<animate attributeName="opacity" values="0;1;1;0" keyTimes="0;0.05;0.35;0.4" dur="6s" repeatCount="indefinite" /></text>
    <text x="165" y="160" opacity="0">.frameLocator('iframe[name="payment"]')<animate attributeName="opacity" values="0;0;1;1;0" keyTimes="0;0.4;0.45;0.75;0.8" dur="6s" repeatCount="indefinite" /></text>
    <text x="165" y="160" opacity="0">.frameLocator('iframe[name="secure"]').getByLabel('One-time password')<animate attributeName="opacity" values="0;0;1;1" keyTimes="0;0.8;0.82;1" dur="6s" repeatCount="indefinite" /></text>
  </g>
</svg>
<figcaption>Each frameLocator() steps one frame deeper.</figcaption>
</figure>

**Simple lesson:** `frameLocator()` steps into a frame. Chain it for frames inside frames.

## Shadow DOM

### How Locators Work with Shadow DOM

- **Shadow DOM** hides a widget's inner HTML from the rest of the page. Custom elements such as `<rating-widget>` use it.
- Playwright's locators go into **open** shadow roots by themselves. You write them as if the shadow root were not there.

<figure className="plw-fig">
<svg viewBox="0 0 640 190" role="img" aria-labelledby="fig-plw-shadow" style={{ width: "100%", maxWidth: 640, height: "auto" }}>
  <title id="fig-plw-shadow">Animation: getByRole button Rate 4 stars passes through the open shadow root of rating-widget and finds the button. The same kind of locator stops at the closed shadow root of secret-note and finds nothing</title>
  <rect x="15" y="15" width="290" height="160" rx="12" style={{ fill: "var(--plw-green-soft)", stroke: "var(--plw-green)" }} />
  <text x="30" y="38" style={{ fill: "var(--plw-green)", fontFamily: "var(--mono)", fontSize: 12, fontWeight: 700 }}>&lt;rating-widget&gt;</text>
  <rect x="35" y="50" width="250" height="110" rx="8" style={{ fill: "#fff", stroke: "var(--plw-green)", strokeDasharray: "5 4" }} />
  <text x="50" y="70" style={{ fill: "var(--ink2)", fontFamily: "var(--sans)", fontSize: 11 }}>#shadow-root (open)</text>
  <rect x="60" y="85" width="200" height="30" rx="6" style={{ fill: "var(--plw-blue)" }} />
  <text x="160" y="105" textAnchor="middle" style={{ fill: "#fff", fontFamily: "var(--sans)", fontSize: 12, fontWeight: 700 }}>Rate 4 stars</text>
  <text x="160" y="148" textAnchor="middle" opacity="0" style={{ fill: "var(--plw-green)", fontFamily: "var(--sans)", fontSize: 13, fontWeight: 800 }}>✓ found<animate attributeName="opacity" values="0;0;1;1;0" keyTimes="0;0.3;0.35;0.95;1" dur="8s" repeatCount="indefinite" /></text>
  <rect x="335" y="15" width="290" height="160" rx="12" style={{ fill: "var(--plw-red-soft, #fde8ec)", stroke: "var(--plw-red)" }} />
  <text x="350" y="38" style={{ fill: "var(--plw-red)", fontFamily: "var(--mono)", fontSize: 12, fontWeight: 700 }}>&lt;secret-note&gt;</text>
  <rect x="355" y="50" width="250" height="110" rx="8" style={{ fill: "#e5e7eb", stroke: "var(--plw-red)" }} />
  <text x="370" y="70" style={{ fill: "var(--ink2)", fontFamily: "var(--sans)", fontSize: 11 }}>#shadow-root (closed)</text>
  <text x="480" y="110" textAnchor="middle" style={{ fill: "var(--ink2)", fontFamily: "var(--sans)", fontSize: 12 }}>🔒 locators cannot enter</text>
  <text x="480" y="148" textAnchor="middle" opacity="0" style={{ fill: "var(--plw-red)", fontFamily: "var(--sans)", fontSize: 13, fontWeight: 800 }}>✘ not found<animate attributeName="opacity" values="0;0;1;1;0" keyTimes="0;0.6;0.65;0.95;1" dur="8s" repeatCount="indefinite" /></text>
  <circle r="7" style={{ fill: "var(--plw-orange)" }}>
    <animate attributeName="cx" values="160;160;160;480;480;480" keyTimes="0;0.05;0.3;0.45;0.6;1" dur="8s" repeatCount="indefinite" />
    <animate attributeName="cy" values="0;0;100;0;60;60" keyTimes="0;0.05;0.3;0.45;0.6;1" dur="8s" repeatCount="indefinite" />
  </circle>
</svg>
<figcaption>Locators pass through open shadow roots on their own. Closed shadow roots stay closed.</figcaption>
</figure>

### Limitations

- **Closed** shadow roots cannot be entered by locators.
- **XPath** does not enter shadow roots either. CSS and `getBy...` locators do.

**Simple lesson:** locators enter open shadow roots for you. Closed ones and XPath stay outside.

## Dialogs

### Alert, Confirm and Prompt Dialogs

- `alert()`, `confirm()` and `prompt()` are the browser's built-in pop-up boxes.
- By default, Playwright **dismisses** them: `confirm()` returns false, `prompt()` returns null.

### page.on('dialog'), Accepting and Dismissing

- To answer a dialog, listen **before** the action that opens it:

```ts
page.once('dialog', (dialog) => dialog.accept());
```

- `dialog.accept('text')` answers a prompt with text. `dialog.dismiss()` presses Cancel.
- `dialog.type()` is `'alert'`, `'confirm'`, `'prompt'` or `'beforeunload'`. `dialog.message()` is its text.

### beforeunload Dialogs

- Some pages ask "Leave site?" when you close them. Playwright shows it only if you close with `page.close({ runBeforeUnload: true })`, then handle it like any dialog.

If you add a `dialog` listener, it **must** accept or dismiss the dialog. A real page stalls while a dialog waits for an answer.

**Simple lesson:** listen with `page.once('dialog')` before the click, then `accept()` or `dismiss()`.

## Unexpected Overlays

### addLocatorHandler()

- Cookie banners and newsletter pop-ups appear at random times and cover the page.
- `page.addLocatorHandler(locator, handler)` says: "whenever this appears, run this handler, then carry on".
- Playwright checks for it before each action and assertion.

### Handling Cookie Banners and Popups

- Without the handlers, the clicks would wait, then time out with "intercepts pointer events".

**Simple lesson:** `addLocatorHandler()` closes surprise overlays for you, every time they appear.

## File Uploads

### setInputFiles()

- `locator.setInputFiles('tests/fixtures/avatar.png')` chooses a file for an `<input type="file">`.
- Paths are relative to where you run the tests (the project folder).

### Uploading Multiple Files and Clearing

- Pass a list for multi-file inputs: `setInputFiles(['a.pdf', 'b.pdf'])`.
- Pass a file made in memory: `{ name: 'note.txt', mimeType: 'text/plain', buffer: Buffer.from('hello') }`.
- Pass `[]` to clear the selection.

### File Chooser Events

- Some pages hide the file input behind a button. Wait for the **file chooser** instead:

**Simple lesson:** `setInputFiles()` uploads. Use the `filechooser` event when the input is hidden behind a button.

## File Downloads

### Waiting for the download Event

- Start waiting for the download **before** the click, like a popup:

```ts
const downloadPromise = page.waitForEvent('download');
await page.getByRole('link', { name: 'Export orders (CSV)' }).click();
const download = await downloadPromise;
```

### suggestedFilename() and saveAs()

- `download.suggestedFilename()` is the name the site proposed.
- `download.saveAs(path)` saves it where you want. `download.path()` is where Playwright saved it.

### Verifying Downloaded Content

**Simple lesson:** wait for the `download` event, check the file name, then save and read the file.

## Low-Level Keyboard and Mouse

### page.keyboard

- `page.keyboard` types into whatever has focus: `press('Escape')`, `type('hello')`, `down('Shift')` and `up('Shift')`.

### Keyboard Shortcuts and Modifier Keys

- Press combinations with `+`: `page.keyboard.press('Control+K')`.
- `ControlOrMeta+K` is Control on Windows and Linux, Command on macOS.

### page.mouse and Mouse Wheel Scrolling

- `page.mouse.move(x, y)`, `down()`, `up()` and `click(x, y)` work with screen positions.
- `page.mouse.wheel(0, 600)` scrolls down by 600 pixels.
- Prefer locator actions. Use the mouse only for canvases, maps and drawing tools.

**Simple lesson:** `page.keyboard` types and presses shortcuts. `page.mouse` works by position.

## Drag and Drop

### dragTo()

- `source.dragTo(target)` drags one element and drops it on another.

### Manual Drag with the Mouse API

- For custom drag code, use the mouse: hover the source, `mouse.down()`, hover the target, `mouse.up()`.

```ts
// Manual drag, for custom drag-and-drop code
await page.getByText('Fix login bug').hover();
await page.mouse.down();
await page.getByLabel('Done').hover();
await page.mouse.up();
```

**Simple lesson:** `dragTo()` handles most drag and drop. The mouse API handles the rest.

## Working with Lists

### count() and all()

- `await locator.count()` returns how many elements match. For checks, prefer `toHaveCount()`.
- `await locator.all()` returns one locator per match, to loop over.

### allTextContents()

- `allTextContents()` and `allInnerTexts()` return the texts of all matches at once.

`all()` does not wait for the list to load. Assert first, for example with `toHaveCount()`, then loop.

**Simple lesson:** `toHaveCount()` to check, `all()` to loop, `allTextContents()` to read every text.

## Clipboard (Advanced)

### Reading and Writing the Clipboard

- Reading the clipboard needs permission: `context.grantPermissions(['clipboard-read', 'clipboard-write'])`.
- Then the page, or `page.evaluate()`, can use `navigator.clipboard`.

Clipboard support differs between browsers. Keep clipboard tests in the projects that support them, and skip the rest with a condition on `browserName`.

**Simple lesson:** grant clipboard permissions first, then read or write the clipboard.

## Try It in Your Project

- These steps take the lesson from the lab to your own app.
- List the iframes, dialogs and file uploads in your app. Each needs its own pattern.
- Add `addLocatorHandler()` for your cookie banner or promo popup.
- Write one download test that checks the file's content, not just its name.

**Simple lesson:** use one idea from this lesson on your own app this week.

## TL;DR

- `frameLocator()` goes into frames. Chain it for nested frames.
- Locators enter open shadow roots, not closed ones, and XPath never does.
- `page.once('dialog')` before the click. Then `accept()` or `dismiss()`.
- `addLocatorHandler()` closes surprise overlays.
- `setInputFiles()` uploads. `waitForEvent('download')` catches downloads.
- `page.keyboard`, `page.mouse`, `dragTo()`, `all()` and the clipboard cover the rest.

## Common mistakes

| Mistake | What happens | Fix |
|---|---|---|
| `page.getByLabel()` for a field inside an iframe | Nothing is found | Use `page.frameLocator(...).getByLabel()` |
| Adding the dialog listener after the click | The dialog is already dismissed | Add `page.once('dialog')` first |
| A dialog listener that never answers | A real page stalls | Always `accept()` or `dismiss()` |
| `click({ force: true })` through a cookie banner | The banner gets the click | Use `addLocatorHandler()` or close the banner |
| Waiting for the download after the click | The event can be missed | Start `waitForEvent('download')` first |

## Check Yourself

## Exercises

## What to Remember

- Frames: `frameLocator()`. Shadow DOM: locators go in by themselves, if it is open.
- Dialogs: listen first, then answer.
- Overlays: `addLocatorHandler()`.
- Files: `setInputFiles()` up, `waitForEvent('download')` down.
- Keyboard, mouse, `dragTo()`, `all()` and the clipboard handle the rest.

Next: **Authentication and Storage State**
