feat(item): add inner and container parts (#30927)

Issue number: N/A

---------

## What is the current behavior?
The inner structural elements of item are not exposed as shadow parts, preventing users from being able to customize their styles directly.

## What is the new behavior?
- Exposes `inner` and `container` shadow parts
- Adds e2e test coverage for customizing the shadow parts
- Removes css variables test, combining it with the shadow parts test

## Does this introduce a breaking change?
- [ ] Yes
- [x] No

---------

Co-authored-by: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
This commit is contained in:
Brandy Smith
2026-02-27 11:18:27 -05:00
committed by GitHub
parent 1d7b28694e
commit a2c655923b
11 changed files with 180 additions and 68 deletions

View File

@@ -939,7 +939,9 @@ ion-item,css-prop,--ripple-color,ios
ion-item,css-prop,--ripple-color,md
ion-item,css-prop,--transition,ios
ion-item,css-prop,--transition,md
ion-item,part,container
ion-item,part,detail-icon
ion-item,part,inner
ion-item,part,native
ion-item-divider,shadow

View File

@@ -18,6 +18,8 @@ import type { RouterDirection } from '../router/utils/interface';
* @slot end - Content is placed to the right of the item text in LTR, and to the left in RTL.
*
* @part native - The native HTML button, anchor or div element that wraps all child elements.
* @part inner - The inner wrapper element that arranges the item content.
* @part container - The wrapper element that contains the default slot.
* @part detail-icon - The chevron icon for the item. Only applies when `detail="true"`.
*/
@Component({
@@ -390,8 +392,8 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac
{...clickFn}
>
<slot name="start" onSlotchange={this.updateInteractivityOnSlotChange}></slot>
<div class="item-inner">
<div class="input-wrapper">
<div class="item-inner" part="inner">
<div class="input-wrapper" part="container">
<slot onSlotchange={this.updateInteractivityOnSlotChange}></slot>
</div>
<slot name="end" onSlotchange={this.updateInteractivityOnSlotChange}></slot>

View File

@@ -1,49 +0,0 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Item - CSS Variables</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
</head>
<style>
ion-item {
--padding-top: 20px;
--background: #eee;
}
</style>
<body>
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>Item CSS variables</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding-vertical">
<ion-list class="basic">
<ion-item>
<ion-label>Item 1</ion-label>
</ion-item>
<ion-item>
<ion-label>Item 2</ion-label>
</ion-item>
<ion-item>
<ion-label>Item 3</ion-label>
</ion-item>
</ion-list>
</ion-content>
</ion-app>
</body>
</html>

View File

@@ -1,17 +0,0 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
/**
* This behavior does not vary across directions
*/
configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('item: CSS variables'), () => {
test('should not have visual regressions', async ({ page }) => {
await page.goto(`/src/components/item/test/css-variables`, config);
await page.setIonViewport();
await expect(page).toHaveScreenshot(screenshot(`item-css-vars-diff`));
});
});
});

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

View File

@@ -0,0 +1,174 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
/**
* This behavior does not vary across modes/directions
*/
configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ title, config }) => {
test.describe(title('item: custom'), () => {
test.describe('CSS shadow parts', () => {
test('should be able to customize native part', async ({ page }) => {
await page.setContent(
`
<style>
ion-item::part(native) {
background-color: red;
}
</style>
<ion-item>
<ion-label>Item</ion-label>
</ion-item>
`,
config
);
const item = page.locator('ion-item');
const backgroundColor = await item.evaluate((el) => {
const shadowRoot = el.shadowRoot;
const native = shadowRoot?.querySelector('.item-native');
return native ? window.getComputedStyle(native).backgroundColor : '';
});
expect(backgroundColor).toBe('rgb(255, 0, 0)');
});
test('should be able to customize inner part', async ({ page }) => {
await page.setContent(
`
<style>
ion-item::part(inner) {
background-color: green;
}
</style>
<ion-item>
<ion-label>Item</ion-label>
</ion-item>
`,
config
);
const item = page.locator('ion-item');
const backgroundColor = await item.evaluate((el) => {
const shadowRoot = el.shadowRoot;
const inner = shadowRoot?.querySelector('.item-inner');
return inner ? window.getComputedStyle(inner).backgroundColor : '';
});
expect(backgroundColor).toBe('rgb(0, 128, 0)');
});
test('should be able to customize container part', async ({ page }) => {
await page.setContent(
`
<style>
ion-item::part(container) {
background-color: blue;
}
</style>
<ion-item>
<ion-label>Item</ion-label>
</ion-item>
`,
config
);
const item = page.locator('ion-item');
const backgroundColor = await item.evaluate((el) => {
const shadowRoot = el.shadowRoot;
const container = shadowRoot?.querySelector('.input-wrapper');
return container ? window.getComputedStyle(container).backgroundColor : '';
});
expect(backgroundColor).toBe('rgb(0, 0, 255)');
});
test('should be able to customize detail-icon part', async ({ page }) => {
await page.setContent(
`
<style>
ion-item::part(detail-icon) {
background-color: red;
}
</style>
<ion-item detail="true">
<ion-label>Item</ion-label>
</ion-item>
`,
config
);
const item = page.locator('ion-item');
const backgroundColor = await item.evaluate((el) => {
const shadowRoot = el.shadowRoot;
const detailIcon = shadowRoot?.querySelector('.item-detail-icon');
return detailIcon ? window.getComputedStyle(detailIcon).backgroundColor : '';
});
expect(backgroundColor).toBe('rgb(255, 0, 0)');
});
});
test.describe('CSS variables', () => {
test('should be able to customize background using css variables', async ({ page }) => {
await page.setContent(
`
<style>
ion-item {
--background: red;
}
</style>
<ion-item>
<ion-label>Item</ion-label>
</ion-item>
`,
config
);
const item = page.locator('ion-item');
const backgroundColor = await item.evaluate((el) => {
const shadowRoot = el.shadowRoot;
const native = shadowRoot?.querySelector('.item-native');
return native ? window.getComputedStyle(native).backgroundColor : '';
});
expect(backgroundColor).toBe('rgb(255, 0, 0)');
});
test('should be able to customize padding using css variables', async ({ page }) => {
await page.setContent(
`
<style>
ion-item {
--padding-top: 20px;
--padding-bottom: 20px;
--padding-start: 10px;
--padding-end: 10px;
}
</style>
<ion-item>
<ion-label>Item</ion-label>
</ion-item>
`,
config
);
const item = page.locator('ion-item');
const paddingValues = await item.evaluate((el) => {
const shadowRoot = el.shadowRoot;
const native = shadowRoot?.querySelector('.item-native');
return {
paddingTop: native ? window.getComputedStyle(native).paddingTop : '',
paddingBottom: native ? window.getComputedStyle(native).paddingBottom : '',
paddingStart: native ? window.getComputedStyle(native).paddingLeft : '',
paddingEnd: native ? window.getComputedStyle(native).paddingRight : '',
};
});
expect(paddingValues.paddingTop).toBe('20px');
expect(paddingValues.paddingBottom).toBe('20px');
expect(paddingValues.paddingStart).toBe('10px');
expect(paddingValues.paddingEnd).toBe('10px');
});
});
});
});