Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/aria-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@justeattakeaway/pie-webc-core": minor
---

[Added] - A shared, extensible ARIA context: `ariaContext` and the `ContextualAria` type. It lets an ancestor component supply an accessible name and description to a descendant control across the shadow boundary (where `aria-labelledby` / `aria-describedby` cannot reach) and at any depth, without prop drilling. Any control can consume it and decide how to apply each field; a control's own ARIA props take precedence.
5 changes: 5 additions & 0 deletions .changeset/parent-disabled-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@justeattakeaway/pie-webc-core": minor
---

[Added] - `parentDisabledContext`, a generic context a disabling ancestor (such as a `pie-radio-group` or `pie-checkbox-group`) uses to tell its descendants it is disabled, so they can reflect it. Crosses shadow boundaries and reaches descendants at any depth without prop drilling.
5 changes: 5 additions & 0 deletions .changeset/radio-aria-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@justeattakeaway/pie-radio": minor
---

[Added] - `pie-radio` optionally consumes the shared `ariaContext`, so an ancestor (such as a `pie-list-item`) can supply its accessible name and description. Applied to the host, which carries `role="radio"`. No effect on a standalone radio.
5 changes: 5 additions & 0 deletions .changeset/selectable-list-item.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@justeattakeaway/pie-list": minor
---

[Added] - `pie-list-item` can host an interactive control to make the whole row selectable, via a `selection-type` prop (`none` | `radio` | `checkbox` | `switch`, default `none`). With a selection type it takes a `presentation` role (for radio/checkbox), names the control from its `primaryText`, `secondaryText` and `metaText`, hides the duplicated visible text from assistive technology, and forwards row clicks to the control. A `disabled` prop applies the disabled row styling and stops row-click forwarding (set it alongside the control's own `disabled`); a disabling ancestor (a `pie-radio-group`) also cascades its disabled state to the rows.
5 changes: 5 additions & 0 deletions .changeset/selectable-radio-list.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@justeattakeaway/pie-radio-group": minor
---

[Added] - `variant="list"` on `pie-radio-group` for a divided, list-style radio group: set `variant="list"` on the group and wrap each radio (at any nesting depth) in a `pie-list-item` with `selection-type="radio"`. `variant` handles the divided layout (dividers, no inter-item gap); the group manages keyboard navigation, focus and selection as usual. `variant="default"` (the default) keeps the plain stacked-radio layout.
32 changes: 32 additions & 0 deletions apps/pie-storybook/stories/pie-list.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import '@justeattakeaway/pie-webc/components/list';
import '@justeattakeaway/pie-webc/components/list-item';
import '@justeattakeaway/pie-webc/components/thumbnail';
import '@justeattakeaway/pie-webc/components/tag';
import '@justeattakeaway/pie-webc/components/radio-group';
import '@justeattakeaway/pie-webc/components/radio';
import '@justeattakeaway/pie-webc/components/form-label';
import '@justeattakeaway/pie-icons-webc/dist/IconPlaceholder';

import { createStory, type TemplateFunction } from '../utilities';
Expand Down Expand Up @@ -238,3 +241,32 @@ export const LongText = createStory<ListPlaygroundProps>(
leadingContent: 'icon',
trailingContent: 'none',
});

// Selectable lists -----------------------------------------------------------
// A `pie-list-item` becomes a selectable row via its `selection-type` prop, slotting the control
// into its `leading`/`trailing` slot. Radio rows live inside a `pie-radio-group`. The playground
// controls above do not apply to these.

/**
* Single-select: `pie-list-item`s inside a `pie-radio-group` with `variant="list"`. The group tells
* its items they host radios (so you don't set `selection-type` per row); the whole row is a
* selectable target and the radio is named by the item's text.
*/
export const RadioSelection = createStory<ListPlaygroundProps>(() => html`
<style>pie-radio-group { min-width: 350px; }</style>
<pie-radio-group name="delivery" value="express" variant="list">
<pie-form-label slot="label">Delivery method</pie-form-label>
<pie-list-item selection-type="radio" primaryText="Standard delivery" secondaryText="3 to 5 working days" metaText="Free">
<pie-radio slot="leading" value="standard"></pie-radio>
</pie-list-item>
<pie-list-item selection-type="radio" primaryText="Express delivery" secondaryText="Next working day" metaText="£4.99">
<pie-radio slot="leading" value="express"></pie-radio>
</pie-list-item>
<pie-list-item selection-type="radio" disabled primaryText="Collection" secondaryText="Collect from a nearby store">
<pie-radio slot="leading" value="collection" disabled></pie-radio>
</pie-list-item>
<pie-list-item selection-type="radio" primaryText="Locker" secondaryText="Pick up from a parcel locker" metaText="£1.99">
<pie-radio slot="leading" value="locker"></pie-radio>
</pie-list-item>
</pie-radio-group>
`, defaultArgs)();
11 changes: 11 additions & 0 deletions apps/pie-storybook/stories/testing/pie-list.test.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,17 @@ const TextOnlyTemplate = () => withLayout(html`

export const TextOnly = createStory<ListProps>(TextOnlyTemplate, defaultArgs)();

// Test-only: exercises the `selection-type` -> role mapping in isolation (no controls needed).
const SelectionTypesTemplate = () => withLayout(html`
<pie-list>
<pie-list-item data-test-id="item-none" primaryText="None"></pie-list-item>
<pie-list-item data-test-id="item-radio" selection-type="radio" primaryText="Radio"></pie-list-item>
<pie-list-item data-test-id="item-checkbox" selection-type="checkbox" primaryText="Checkbox"></pie-list-item>
<pie-list-item data-test-id="item-switch" selection-type="switch" primaryText="Switch"></pie-list-item>
</pie-list>
`);
export const SelectionTypes = createStory<ListProps>(SelectionTypesTemplate, defaultArgs)();

/**
* `isBold` sets the primary text to a bold font-weight.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import '@justeattakeaway/pie-webc/components/radio';
import '@justeattakeaway/pie-webc/components/form-label';
import '@justeattakeaway/pie-webc/components/button';
import '@justeattakeaway/pie-webc/components/list-item';
import '@justeattakeaway/pie-icons-webc/dist/IconPlusCircle';

import { createStory, createVariantStory, type PropDisplayOptions } from '../../utilities';
Expand Down Expand Up @@ -239,7 +240,78 @@ const DynamicSlotsTemplate = () => {
`;
};

// `value` pre-selects the matching radio. The behaviour tests use the unselected variant so
// keyboard/click assertions start from a known clean state (Tab lands on the first radio, clicking
// any row is a real change); the visual snapshot uses the pre-selected variant to capture a checked
// row (see the `WithListItems` / `WithListItemsChecked` stories below).
const buildListItemsTemplate = (value?: string) => {
function onChange (event: CustomEvent) {
const selectedRadioElement = event.target as HTMLInputElement;
action('change')(selectedRadioElement.value);
console.info(EXPECTED_CHANGE_EVENT_MESSAGE);
}

return html`
<style>
pie-radio-group { min-width: 360px; }
/* Set on the item directly (a value inherited from the group cannot override the item's :host default). */
pie-list-item { --list-item-inline-padding: var(--dt-spacing-e); }
</style>
<p><pie-button size="small-productive" data-test-id="btn-1">Button 1</pie-button></p>
<pie-radio-group data-test-id="pie-radio-group" name="delivery" variant="list" value=${ifDefined(value)} @change=${onChange}>
<!-- item-1: secondary AND meta text (combined into aria-description) -->
<pie-list-item selection-type="radio" data-test-id="item-1" primaryText="Standard" secondaryText="3 to 5 days" metaText="Free">
<pie-radio slot="leading" data-test-id="radio-1" value="standard"></pie-radio>
</pie-list-item>
<!-- item-2: secondary text only -->
<pie-list-item selection-type="radio" data-test-id="item-2" primaryText="Express" secondaryText="Next day">
<pie-radio slot="leading" data-test-id="radio-2" value="express"></pie-radio>
</pie-list-item>
<!-- item-3: neither secondary nor meta (no aria-description); a disabled row (both the
item and its radio are disabled) -->
<pie-list-item selection-type="radio" disabled data-test-id="item-3" primaryText="Collection">
<pie-radio slot="leading" data-test-id="radio-3" value="collection" disabled></pie-radio>
</pie-list-item>
<!-- item-4: meta text only -->
<pie-list-item selection-type="radio" data-test-id="item-4" primaryText="Locker" metaText="£1.99">
<pie-radio slot="leading" data-test-id="radio-4" value="locker"></pie-radio>
</pie-list-item>
</pie-radio-group>
<p><pie-button size="small-productive" data-test-id="btn-2">Button 2</pie-button></p>
`;
};

// Unselected: consumed by the component behaviour tests.
const WithListItemsTemplate = () => buildListItemsTemplate();
// A non-first row (Express) pre-selected: consumed by the visual snapshot to capture the checked state.
const WithListItemsCheckedTemplate = () => buildListItemsTemplate('express');

const WithListItemsGroupDisabledTemplate = () => html`
<style>
pie-radio-group { min-width: 360px; }
/* Set on the item directly (a value inherited from the group cannot override the item's :host default). */
pie-list-item { --list-item-inline-padding: var(--dt-spacing-e); }
</style>
<pie-radio-group data-test-id="pie-radio-group" name="delivery" variant="list" value="express" disabled>
<pie-list-item selection-type="radio" data-test-id="item-1" primaryText="Standard" secondaryText="3 to 5 days" metaText="Free">
<pie-radio slot="leading" data-test-id="radio-1" value="standard"></pie-radio>
</pie-list-item>
<pie-list-item selection-type="radio" data-test-id="item-2" primaryText="Express" secondaryText="Next day">
<pie-radio slot="leading" data-test-id="radio-2" value="express"></pie-radio>
</pie-list-item>
<pie-list-item selection-type="radio" data-test-id="item-3" primaryText="Collection">
<pie-radio slot="leading" data-test-id="radio-3" value="collection"></pie-radio>
</pie-list-item>
<pie-list-item selection-type="radio" data-test-id="item-4" primaryText="Locker" metaText="£1.99">
<pie-radio slot="leading" data-test-id="radio-4" value="locker"></pie-radio>
</pie-list-item>
</pie-radio-group>
`;

export const Default = createStory<RadioGroupProps>(DefaultTemplate, defaultArgs)();
export const WithListItems = createStory<RadioGroupProps>(WithListItemsTemplate, defaultArgs)();
export const WithListItemsChecked = createStory<RadioGroupProps>(WithListItemsCheckedTemplate, defaultArgs)();
export const WithListItemsGroupDisabled = createStory<RadioGroupProps>(WithListItemsGroupDisabledTemplate, defaultArgs)();
export const DisabledRadio = createStory<RadioGroupProps>(DisabledRadioTemplate, defaultArgs)();
export const KeyboardNavigation = createStory<RadioGroupProps>(KeyboardNavigationTemplate, defaultArgs)();
export const KeyboardNavigationDisabledAndChecked = createStory<RadioGroupProps>(KeyboardNavigationDisabledAndCheckedTemplate, defaultArgs)();
Expand Down
4 changes: 2 additions & 2 deletions bundlewatch.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@
},
{
"path": "./packages/components/pie-radio/dist/index.js",
"maxSize": "3.08 KB"
"maxSize": "4 KB"
},
{
"path": "./packages/components/pie-radio/dist/react.js",
Expand Down Expand Up @@ -202,7 +202,7 @@
},
{
"path": "./packages/components/pie-list/dist/*.js",
"maxSize": "3 KB"
"maxSize": "5 KB"
},
{
"path": "./packages/components/pie-avatar/dist/*.js",
Expand Down
40 changes: 40 additions & 0 deletions packages/components/pie-list/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ Ideally, you should install the component using the **`@justeattakeaway/pie-webc
| `isCompact` | `true`, `false` | Decreases the item height to save vertical space. See the [rules](#usage-notes-and-rules) below. | `false` |
| `isBold` | `true`, `false` | Sets the primary text to a bold font-weight. | `false` |
| `hasMedia` | `true`, `false` | **Required whenever you slot a media element (e.g. `pie-thumbnail`) into the item.** Reduces the block padding so single-line media sits correctly (this padding adjustment has no effect when `secondaryText` is set, but you should still set `hasMedia`). | `false` |
| `selectionType` | `"none"`, `"radio"`, `"checkbox"`, `"switch"` | Declares that the item hosts an interactive control in its `leading`/`trailing` slot, making the **whole row** a selectable target. Set it on each selectable row. Inside a `pie-radio-group`, also set `variant="list"` on the group for the divided layout. See [Selectable lists](#selectable-lists). | `"none"` |
| `disabled` | `true`, `false` | Marks the row as disabled: it takes the disabled styling and stops forwarding row clicks to its control. Set it alongside the slotted control's own `disabled` (the control still governs its own interactivity). No visible effect on a non-selectable (static) item. | `false` |

### Slots

Expand All @@ -68,6 +70,9 @@ Slots are provided by `pie-list-item`.
The permitted slotted elements are: a PIE WEBC icon, `pie-tag`, `pie-thumbnail`, `pie-avatar`*, `pie-switch`, and native HTML radio/checkbox inputs.
Some slotted content is designed with specific properties being used. So please read the entire readme to understand correct slot usage.

> [!NOTE]
> `pie-list` is a **static** container: it has no selection state or keyboard behaviour. For a **selectable** list, do not slot the controls into `pie-list`. Place `pie-list-item`s inside a [`pie-radio-group`](https://webc.pie.design/?path=/docs/components-radio-group--overview) (single-select) or a [`pie-checkbox-group`](https://webc.pie.design/?path=/docs/components-checkbox-group--overview) (multi-select) instead. See [Selectable lists](#selectable-lists) below.

> Slotted PIE icons are always sized by `pie-list-item` (24px). Consumers cannot override this size.

> \* `pie-avatar` is a permitted slot element but is not covered by usage examples here yet, as it is not ready for use in lists.
Expand Down Expand Up @@ -241,6 +246,40 @@ import '@justeattakeaway/pie-webc/components/thumbnail.js';
</pie-list>
```

### Selectable lists

`pie-list` itself is a static container with no selection or keyboard behaviour. To build a **selectable** list, do **not** put the controls in `pie-list`. Instead, set `variant="list"` on a [`pie-radio-group`](https://webc.pie.design/?path=/docs/components-radio-group--overview) (single-select), place `pie-list-item`s inside it with `selection-type="radio"`, and slot the control into each item's `leading` (or `trailing`) slot.

Two props, two jobs: `variant="list"` on the **group** handles the divided layout; `selection-type` on each **item** makes that row selectable:

- takes the correct **role** — `presentation` for `radio`/`checkbox` (so the group owns the controls directly), `listitem` for `switch` and `none`;
- provides its `primaryText`, `secondaryText` and `metaText` as the slotted control's **accessible name and description** (and `aria-hidden`s the now-duplicated visible text);
- **forwards a click** anywhere on the row to the control, so the whole row is a hit target;
- shows **hover and active** states on the row (suppressed when the item's `disabled` is set, or the group is disabled).

Provide the label through the item's `primaryText` — not as the control's own content. The group still owns the group-level semantics (its own role, shared `name`, selection coordination and group-disable).

Single-select (radios):

```js
import '@justeattakeaway/pie-webc/components/radio-group.js';
import '@justeattakeaway/pie-webc/components/radio.js';
import '@justeattakeaway/pie-webc/components/list-item.js';
```

```html
<!-- `variant="list"` gives the group its divided layout; `selection-type="radio"` makes each row
selectable. `value` on the group selects the matching radio (here, Express). -->
<pie-radio-group name="delivery" value="express" variant="list">
<pie-list-item selection-type="radio" primaryText="Standard delivery" secondaryText="3 to 5 working days" metaText="Free">
<pie-radio slot="leading" value="standard"></pie-radio>
</pie-list-item>
<pie-list-item selection-type="radio" primaryText="Express delivery" secondaryText="Next working day" metaText="£4.99">
<pie-radio slot="leading" value="express"></pie-radio>
</pie-list-item>
</pie-radio-group>
```

**For Native JS Applications, Vue, Angular, Svelte etc.:**

```js
Expand Down Expand Up @@ -272,6 +311,7 @@ To keep lists consistent and correct, follow these rules:

- **Always give `pie-list` an accessible name** with `aria-label` or `aria-labelledby` (use `aria-labelledby` when a visible heading exists). This is required for screen reader users to understand the list. See [Accessibility](#accessibility).
- **Provide `primaryText`** on every `pie-list-item`; it is the item's main line of content.
- **For selectable lists, use `pie-list-item` inside `pie-radio-group` or `pie-checkbox-group`, not `pie-list`.** `pie-list` is a static container with no selection or keyboard behaviour. See [Selectable lists](#selectable-lists).
- **`metaText` and the `trailing` slot are mutually exclusive.** If `metaText` is set, any `trailing` slot content is ignored. Choose one.
- **Slotted `pie-thumbnail` must use `size="40"`.** This is the only size that fits the list-item layout correctly.
- **Always set `hasMedia` when slotting media** (`pie-thumbnail`, and `pie-avatar` in future), whether or not the item has `secondaryText`. This guarantees the item has the correct block padding.
Expand Down
3 changes: 2 additions & 1 deletion packages/components/pie-list/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@
"@justeattakeaway/pie-monorepo-utils": "0.9.7"
},
"dependencies": {
"@justeattakeaway/pie-webc-core": "16.0.0"
"@justeattakeaway/pie-webc-core": "16.0.0",
"@lit/context": "1.1.6"
},
"customElements": "custom-elements.json",
"sideEffects": [
Expand Down
6 changes: 1 addition & 5 deletions packages/components/pie-list/src/list.scss
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,5 @@
:host {
display: block;

::slotted(*:nth-last-child(n+2)) {
// This allows list-items following other list-items to have 1px stripped off of their padding to account for the added border.
--list-item-size-modifier: 1px;
--list-item-border-override: var(--list-item-size-modifier) solid var(--dt-color-divider-default);
}
@include p.list-item-divider;
}
Loading
Loading