From 522e455950bb9fd842a13b772f977aa6e3acce29 Mon Sep 17 00:00:00 2001 From: a11delavar Date: Mon, 4 Nov 2024 23:44:54 +0100 Subject: [PATCH 1/3] Proof of concept --- packages/List/List.ts | 15 +++++++-- packages/List/ListItem.ts | 33 +++++++++++++++---- packages/List/NavigationListItem.ts | 6 ++++ packages/List/SelectionListItemWithControl.ts | 18 +++------- packages/Menu/MenuItem.ts | 15 ++++++++- packages/Menu/NestedMenuItem.ts | 7 +++- packages/SelectField/Option.ts | 7 ++-- 7 files changed, 72 insertions(+), 29 deletions(-) diff --git a/packages/List/List.ts b/packages/List/List.ts index f841a51bd..2bd34bbbe 100644 --- a/packages/List/List.ts +++ b/packages/List/List.ts @@ -1,4 +1,4 @@ -import { Component, component, css, event, html } from '@a11d/lit' +import { Component, component, css, event, html, unsafeCSS } from '@a11d/lit' import { SlotController } from '@3mo/slot-controller' import { listItems } from './extensions.js' @@ -33,12 +33,23 @@ export class List extends Component { static override get styles() { return css` :host { - display: block; + display: grid; + grid-template-columns: auto 1fr auto; + column-gap: 16px; } :host(:focus) { outline: none; } + + ::slotted(*) { + grid-column: -1 / 1; + } + + ${unsafeCSS(List.itemRoles.map(role => `::slotted([role='${role}'])`).join(','))} { + grid-template-columns: subgrid; + display: grid; + } ` } diff --git a/packages/List/ListItem.ts b/packages/List/ListItem.ts index 9ee71b092..8d48071f6 100644 --- a/packages/List/ListItem.ts +++ b/packages/List/ListItem.ts @@ -13,6 +13,8 @@ import './ListItemRipple.js' * @attr preventClickOnSpace - Whether the list item should prevent click on space * * @slot - Default slot for content + * @slot start - Slot for content at the start + * @slot end - Slot for content at the end * * @csspart icon - The icon before the content. */ @@ -36,10 +38,11 @@ export class ListItem extends Component { user-select: none; padding-inline: 1rem; padding-block: 0.48em; - display: flex; - gap: 1rem; align-items: center; min-height: 3rem; + /* For list-items without a list */ + display: flex; + gap: 16px; } :host([disabled]) { @@ -54,6 +57,19 @@ export class ListItem extends Component { :host(:focus) { outline: none; } + + slot[name=start], slot:not([name]), slot[name=end] { + display: inline-flex; + } + + slot:not([name]) { + /* For list-items without a list */ + flex: 1; + } + + slot[name=end] { + justify-content: end; + } ` } @@ -96,15 +112,18 @@ export class ListItem extends Component { return html` ${!this.keyboardFocus ? html.nothing : html``} - ${this.iconTemplate} + ${this.startSlotDefaultContent} + ${this.endSlotDefaultContent} ` } - protected get iconTemplate() { - return !this.icon ? html.nothing : html` - - ` + protected get startSlotDefaultContent() { + return html.nothing + } + + protected get endSlotDefaultContent() { + return html.nothing } } diff --git a/packages/List/NavigationListItem.ts b/packages/List/NavigationListItem.ts index 3724e5183..237b1d49a 100644 --- a/packages/List/NavigationListItem.ts +++ b/packages/List/NavigationListItem.ts @@ -20,6 +20,12 @@ export class NavigationListItem extends ListItem { } ` } + + protected override get startSlotDefaultContent() { + return !this.icon ? html.nothing : html` + + ` + } } declare global { diff --git a/packages/List/SelectionListItemWithControl.ts b/packages/List/SelectionListItemWithControl.ts index 13805e5e2..2618ebd5a 100644 --- a/packages/List/SelectionListItemWithControl.ts +++ b/packages/List/SelectionListItemWithControl.ts @@ -1,23 +1,15 @@ -import { html, type HTMLTemplateResult, property } from '@a11d/lit' +import { type HTMLTemplateResult, property } from '@a11d/lit' import { SelectionListItem } from './SelectionListItem.js' export abstract class SelectionListItemWithControl extends SelectionListItem { @property() selectionControlAlignment: 'start' | 'end' = 'end' - protected override get template() { - return html` - ${this.selectionControlAlignment === 'start' ? this.selectionControlContainerTemplate : html.nothing} - ${super.template} - ${this.selectionControlAlignment === 'end' ? this.selectionControlContainerTemplate : html.nothing} - ` + protected override get startSlotDefaultContent() { + return this.selectionControlAlignment === 'start' ? this.selectionControlTemplate : super.startSlotDefaultContent } - private get selectionControlContainerTemplate() { - return html` -
- ${this.selectionControlTemplate} -
- ` + protected override get endSlotDefaultContent() { + return this.selectionControlAlignment === 'end' ? this.selectionControlTemplate : super.endSlotDefaultContent } protected abstract get selectionControlTemplate(): HTMLTemplateResult diff --git a/packages/Menu/MenuItem.ts b/packages/Menu/MenuItem.ts index f850b08a5..2da6dbddc 100644 --- a/packages/Menu/MenuItem.ts +++ b/packages/Menu/MenuItem.ts @@ -13,7 +13,20 @@ export class MenuItem extends ListItem { static override get styles() { return css` ${super.styles} - :host { min-height: 2.25rem; } + + :host { + min-height: 2.25rem; + } + ` + } + + protected override get startSlotDefaultContent() { + return this.iconTemplate + } + + protected get iconTemplate() { + return !this.icon ? html.nothing : html` + ` } } diff --git a/packages/Menu/NestedMenuItem.ts b/packages/Menu/NestedMenuItem.ts index 26fb9d227..f9e873f2c 100644 --- a/packages/Menu/NestedMenuItem.ts +++ b/packages/Menu/NestedMenuItem.ts @@ -68,13 +68,18 @@ export class NestedMenuItem extends MenuItem { ` } + protected override get endSlotDefaultContent() { + return !this.hasSubMenu ? html.nothing : html` + + ` + } + protected get hasSubMenu() { return this.slotController.hasAssignedContent('submenu') } protected get subMenuTemplate() { return !this.hasSubMenu ? html.nothing : html` - { diff --git a/packages/SelectField/Option.ts b/packages/SelectField/Option.ts index 94f00ed15..81c034dd7 100644 --- a/packages/SelectField/Option.ts +++ b/packages/SelectField/Option.ts @@ -91,11 +91,8 @@ export class Option extends SelectionListItem { ` } - protected override get template() { - return html` - ${super.template} - ${this.checkboxTemplate} - ` + protected override get endSlotDefaultContent() { + return this.checkboxTemplate } protected get checkboxTemplate() { From 349949feb858308364c7059a46a55ba1e1250691 Mon Sep 17 00:00:00 2001 From: a11delavar Date: Mon, 4 Nov 2024 23:46:11 +0100 Subject: [PATCH 2/3] Migrate stories --- packages/ContextMenu/ContextMenu.stories.ts | 12 ++-- packages/List/List.stories.ts | 78 ++++++++++++++------- packages/Menu/Menu.stories.ts | 12 ++-- packages/SelectField/FieldSelect.stories.ts | 9 ++- 4 files changed, 69 insertions(+), 42 deletions(-) diff --git a/packages/ContextMenu/ContextMenu.stories.ts b/packages/ContextMenu/ContextMenu.stories.ts index a9c677f62..eb4eaf8ba 100644 --- a/packages/ContextMenu/ContextMenu.stories.ts +++ b/packages/ContextMenu/ContextMenu.stories.ts @@ -30,16 +30,16 @@ export const ItemContent: Story = {
html` - Cut - Ctrl + X + Cut + Ctrl + X - Copy - Ctrl + C + Copy + Ctrl + C - Paste - Ctrl + V + Paste + Ctrl + V Dictionary diff --git a/packages/List/List.stories.ts b/packages/List/List.stories.ts index 39381d9d7..deb6b4f4d 100644 --- a/packages/List/List.stories.ts +++ b/packages/List/List.stories.ts @@ -16,31 +16,48 @@ type Story = StoryObj export const Default: Story = { render: () => html` - Inbox - Drafts - Sent - Trash + + + Inbox + + + + Drafts + + + + Sent + + + + Trash + `, } -/** An item holds any content after its `icon`, such as a shortcut at its end, and an element with `role='separator'` divides the groups. */ +/** An item holds any content after the icon in its `start` slot, such as a shortcut in its `end` slot, and an element with `role='separator'` divides the groups. */ export const Content: Story = { render: () => html` - - Inbox - Meta+I + + + Inbox + Meta+I - - Drafts - Meta+D + + + Drafts + Meta+D
Trash Spam
- Sign out + + + Sign out +
`, } @@ -49,9 +66,16 @@ export const Content: Story = { export const Disabled: Story = { render: () => html` - Inbox - Archive - + + + Inbox + + + + Archive + + + Personalization - Upgrade to Pro @@ -78,21 +102,21 @@ export const SubgridLayout: Story = { } - - Inbox - Meta+I - - - Drafts - Meta+D + + + Inbox + Meta+I - - Trash + + Drafts + Meta+D - - Sign out - Meta+Shift+Q + Trash + + + Sign out + Meta+Shift+Q `, diff --git a/packages/Menu/Menu.stories.ts b/packages/Menu/Menu.stories.ts index cee4d71a1..0444d8caf 100644 --- a/packages/Menu/Menu.stories.ts +++ b/packages/Menu/Menu.stories.ts @@ -97,16 +97,16 @@ export const ItemContent: Story = { Edit - Cut - Ctrl + X + Cut + Ctrl + X - Copy - Ctrl + C + Copy + Ctrl + C - Paste - Ctrl + V + Paste + Ctrl + V Dictionary diff --git a/packages/SelectField/FieldSelect.stories.ts b/packages/SelectField/FieldSelect.stories.ts index b55a79acd..a89941592 100644 --- a/packages/SelectField/FieldSelect.stories.ts +++ b/packages/SelectField/FieldSelect.stories.ts @@ -78,7 +78,7 @@ export const OptionContent: Story = { ${countries.map(country => html` - + ${country.label} `)} @@ -151,7 +151,10 @@ export const Actions: Story = { }, render: ({ onAddCountry }) => html` - Add a country + + + Add a country + Germany France Italy @@ -198,7 +201,7 @@ export const SubgridLayout: Story = { ${countries.map(country => html` - + +${country.phone} ${country.label} From 2362ce1ced4319d2b07ea3d00a55e2c05fbfc897 Mon Sep 17 00:00:00 2001 From: a11delavar Date: Tue, 18 Aug 2026 20:08:20 +0200 Subject: [PATCH 3/3] WiP --- .claude/launch.json | 11 +++++++++ packages/List/List.ts | 10 ++++++--- packages/List/ListItem.ts | 25 +++++++++++++++++++-- packages/List/NavigationListItem.ts | 2 +- packages/List/extensions.ts | 4 +++- packages/Menu/MenuItem.ts | 2 +- packages/Menu/NestedMenuItem.ts | 2 +- packages/SelectField/Option.ts | 1 - packages/VirtualizedList/VirtualizedList.ts | 17 +++++++++++++- 9 files changed, 63 insertions(+), 11 deletions(-) create mode 100644 .claude/launch.json diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 000000000..f40297670 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "storybook", + "runtimeExecutable": "npx", + "runtimeArgs": ["storybook", "dev", "-p", "3000", "--no-open"], + "port": 3000 + } + ] +} diff --git a/packages/List/List.ts b/packages/List/List.ts index 2bd34bbbe..3ea49f818 100644 --- a/packages/List/List.ts +++ b/packages/List/List.ts @@ -1,6 +1,6 @@ import { Component, component, css, event, html, unsafeCSS } from '@a11d/lit' import { SlotController } from '@3mo/slot-controller' -import { listItems } from './extensions.js' +import { listItemRoles, listItems } from './extensions.js' /** * A list of items, such as `mo-list-item`s and the ones with a checkbox, switch or radio button. @@ -35,7 +35,11 @@ export class List extends Component { :host { display: grid; grid-template-columns: auto 1fr auto; - column-gap: 16px; + /* + * Deliberately no column-gap. Gutters are painted even when the + * start/end tracks are empty, which would indent every icon-less + * list. The spacing lives on the content instead, see ListItem. + */ } :host(:focus) { @@ -46,7 +50,7 @@ export class List extends Component { grid-column: -1 / 1; } - ${unsafeCSS(List.itemRoles.map(role => `::slotted([role='${role}'])`).join(','))} { + ${unsafeCSS(listItemRoles.map(role => `::slotted([role='${role}'])`).join(','))} { grid-template-columns: subgrid; display: grid; } diff --git a/packages/List/ListItem.ts b/packages/List/ListItem.ts index 8d48071f6..a769db368 100644 --- a/packages/List/ListItem.ts +++ b/packages/List/ListItem.ts @@ -40,9 +40,13 @@ export class ListItem extends Component { padding-block: 0.48em; align-items: center; min-height: 3rem; - /* For list-items without a list */ + /* + * Only takes effect for list-items used without a list; inside one, + * the list turns the item into a subgrid. No gap here on purpose: + * a subgrid's own gap overrides the one inherited from the list, which + * would reintroduce gutters around empty start/end columns. + */ display: flex; - gap: 16px; } :host([disabled]) { @@ -60,9 +64,12 @@ export class ListItem extends Component { slot[name=start], slot:not([name]), slot[name=end] { display: inline-flex; + align-items: center; } slot:not([name]) { + /* Preserves the spacing of content that is not slotted into start/end */ + gap: var(--mo-list-item-spacing, 1rem); /* For list-items without a list */ flex: 1; } @@ -70,6 +77,20 @@ export class ListItem extends Component { slot[name=end] { justify-content: end; } + + /* + * The spacing between the columns is carried by whatever occupies them + * rather than by a column-gap on the list, so that a start/end column + * nobody uses takes up exactly no space. Both selectors are needed: + * "::slotted" for consumer content, "> *" for the item's own default content. + */ + slot[name=start]::slotted(*), slot[name=start] > * { + margin-inline-end: var(--mo-list-item-spacing, 1rem); + } + + slot[name=end]::slotted(*), slot[name=end] > * { + margin-inline-start: var(--mo-list-item-spacing, 1rem); + } ` } diff --git a/packages/List/NavigationListItem.ts b/packages/List/NavigationListItem.ts index 237b1d49a..5149188dc 100644 --- a/packages/List/NavigationListItem.ts +++ b/packages/List/NavigationListItem.ts @@ -1,4 +1,4 @@ -import { component, css } from '@a11d/lit' +import { component, css, html } from '@a11d/lit' import { ListItem } from '@3mo/list' /** @element mo-navigation-list-item */ diff --git a/packages/List/extensions.ts b/packages/List/extensions.ts index 9cfa19825..81201eaba 100644 --- a/packages/List/extensions.ts +++ b/packages/List/extensions.ts @@ -8,13 +8,15 @@ Object.defineProperty(Element.prototype, list, { }, }) +export const listItemRoles: ReadonlyArray = ['listitem', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option'] + export const listItem = Symbol('listItem') Object.defineProperty(Element.prototype, listItem, { enumerable: false, configurable: true, get(this: Element) { - return this.tagName === 'LI' || (!!this.role && ['listitem', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option'].includes(this.role)) ? this : undefined + return this.tagName === 'LI' || (!!this.role && listItemRoles.includes(this.role)) ? this : undefined }, }) diff --git a/packages/Menu/MenuItem.ts b/packages/Menu/MenuItem.ts index 2da6dbddc..00ced187b 100644 --- a/packages/Menu/MenuItem.ts +++ b/packages/Menu/MenuItem.ts @@ -1,4 +1,4 @@ -import { component, css } from '@a11d/lit' +import { component, css, html } from '@a11d/lit' import { ListItem } from '@3mo/list' /** diff --git a/packages/Menu/NestedMenuItem.ts b/packages/Menu/NestedMenuItem.ts index f9e873f2c..45c2505f4 100644 --- a/packages/Menu/NestedMenuItem.ts +++ b/packages/Menu/NestedMenuItem.ts @@ -50,7 +50,7 @@ export class NestedMenuItem extends MenuItem { } mo-icon[icon=chevron_right] { - margin-inline-start: auto; + /* The end column already pushes it to the end, no auto margin needed */ margin-inline-end: -8px; } diff --git a/packages/SelectField/Option.ts b/packages/SelectField/Option.ts index 81c034dd7..1fdf2c088 100644 --- a/packages/SelectField/Option.ts +++ b/packages/SelectField/Option.ts @@ -86,7 +86,6 @@ export class Option extends SelectionListItem { mo-checkbox { height: fit-content; - margin-inline-start: auto; } ` } diff --git a/packages/VirtualizedList/VirtualizedList.ts b/packages/VirtualizedList/VirtualizedList.ts index 5b2993c64..56e614344 100644 --- a/packages/VirtualizedList/VirtualizedList.ts +++ b/packages/VirtualizedList/VirtualizedList.ts @@ -1,4 +1,4 @@ -import { component, html, property, query } from '@a11d/lit' +import { component, css, html, property, query } from '@a11d/lit' import { type GetItemTemplate, type VirtualizedScroller } from '@3mo/virtualized-scroller' import { List, listItem } from '@3mo/list' @@ -17,6 +17,21 @@ export class VirtualizedList extends List { @query('mo-virtualized-scroller') protected readonly virtualizedScroller!: VirtualizedScroller + static override get styles() { + return css` + ${super.styles} + + :host { + /* + * The scroller renders the items inside its own shadow root, so they can + * never take part in the column grid List sets up. Opt out of it, otherwise + * the scroller is placed into the zero-width start track and nothing renders. + */ + display: block; + } + ` + } + override get items() { return (this.virtualizedScroller?.renderedItems ?? []).filter(e => !!e[listItem]) as Array }