Skip to content
Draft
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
11 changes: 11 additions & 0 deletions .claude/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "storybook",
"runtimeExecutable": "npx",
"runtimeArgs": ["storybook", "dev", "-p", "3000", "--no-open"],
"port": 3000
}
]
}
12 changes: 6 additions & 6 deletions packages/ContextMenu/ContextMenu.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,16 @@ export const ItemContent: Story = {
<div style='padding: 16px; border: 1px solid var(--mo-color-transparent-gray-3); border-radius: var(--mo-border-radius)'
${contextMenu(() => html`
<mo-context-menu-item icon='content_cut'>
<span style='flex: 1'>Cut</span>
<span style='font-size: 13px; opacity: 0.6'>Ctrl + X</span>
Cut
<span slot='end' style='font-size: 13px; opacity: 0.6'>Ctrl + X</span>
</mo-context-menu-item>
<mo-context-menu-item icon='content_copy'>
<span style='flex: 1'>Copy</span>
<span style='font-size: 13px; opacity: 0.6'>Ctrl + C</span>
Copy
<span slot='end' style='font-size: 13px; opacity: 0.6'>Ctrl + C</span>
</mo-context-menu-item>
<mo-context-menu-item icon='content_paste'>
<span style='flex: 1'>Paste</span>
<span style='font-size: 13px; opacity: 0.6'>Ctrl + V</span>
Paste
<span slot='end' style='font-size: 13px; opacity: 0.6'>Ctrl + V</span>
</mo-context-menu-item>
<mo-line></mo-line>
<mo-context-menu-item>Dictionary</mo-context-menu-item>
Expand Down
78 changes: 51 additions & 27 deletions packages/List/List.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,31 +16,48 @@ type Story = StoryObj<Args>
export const Default: Story = {
render: () => html`
<mo-list>
<mo-list-item icon='inbox'>Inbox</mo-list-item>
<mo-list-item icon='drafts'>Drafts</mo-list-item>
<mo-list-item icon='send'>Sent</mo-list-item>
<mo-list-item icon='delete'>Trash</mo-list-item>
<mo-list-item>
<mo-icon slot='start' style='opacity: 0.66' icon='inbox'></mo-icon>
Inbox
</mo-list-item>
<mo-list-item>
<mo-icon slot='start' style='opacity: 0.66' icon='drafts'></mo-icon>
Drafts
</mo-list-item>
<mo-list-item>
<mo-icon slot='start' style='opacity: 0.66' icon='send'></mo-icon>
Sent
</mo-list-item>
<mo-list-item>
<mo-icon slot='start' style='opacity: 0.66' icon='delete'></mo-icon>
Trash
</mo-list-item>
</mo-list>
`,
}

/** 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`
<mo-list style='max-width: 360px'>
<mo-list-item icon='inbox'>
<span style='flex: 1'>Inbox</span>
<mo-key>Meta+I</mo-key>
<mo-list-item>
<mo-icon slot='start' style='opacity: 0.66' icon='inbox'></mo-icon>
Inbox
<mo-key slot='end'>Meta+I</mo-key>
</mo-list-item>
<mo-list-item icon='drafts'>
<span style='flex: 1'>Drafts</span>
<mo-key>Meta+D</mo-key>
<mo-list-item>
<mo-icon slot='start' style='opacity: 0.66' icon='drafts'></mo-icon>
Drafts
<mo-key slot='end'>Meta+D</mo-key>
</mo-list-item>
<div role='separator' style='height: 1px; background: var(--mo-color-transparent-gray-3)'></div>
<mo-list-item>Trash</mo-list-item>
<mo-list-item>Spam</mo-list-item>
<div role='separator' style='height: 1px; background: var(--mo-color-transparent-gray-3)'></div>
<mo-list-item icon='logout'>Sign out</mo-list-item>
<mo-list-item>
<mo-icon slot='start' style='opacity: 0.66' icon='logout'></mo-icon>
Sign out
</mo-list-item>
</mo-list>
`,
}
Expand All @@ -49,9 +66,16 @@ export const Content: Story = {
export const Disabled: Story = {
render: () => html`
<mo-list style='max-width: 360px'>
<mo-list-item icon='inbox'>Inbox</mo-list-item>
<mo-list-item icon='archive' disabled>Archive</mo-list-item>
<mo-list-item icon='settings_suggest' disabled style='opacity: 1'>
<mo-list-item>
<mo-icon slot='start' style='opacity: 0.66' icon='inbox'></mo-icon>
Inbox
</mo-list-item>
<mo-list-item disabled>
<mo-icon slot='start' style='opacity: 0.66' icon='archive'></mo-icon>
Archive
</mo-list-item>
<mo-list-item disabled style='opacity: 1'>
<mo-icon slot='start' style='opacity: 0.33' icon='settings_suggest'></mo-icon>
<span>
<span style='opacity: 0.5'>Personalization -</span>
<mo-anchor style='pointer-events: auto'>Upgrade to Pro</mo-anchor>
Expand All @@ -78,21 +102,21 @@ export const SubgridLayout: Story = {
}
</style>
<mo-list class='subgrid'>
<mo-list-item icon='inbox'>
<span>Inbox</span>
<mo-key>Meta+I</mo-key>
</mo-list-item>
<mo-list-item icon='drafts'>
<span>Drafts</span>
<mo-key>Meta+D</mo-key>
<mo-list-item>
<mo-icon slot='start' style='opacity: 0.66' icon='inbox'></mo-icon>
Inbox
<mo-key slot='end'>Meta+I</mo-key>
</mo-list-item>
<mo-list-item>
<span></span>
<span>Trash</span>
<mo-icon slot='start' style='opacity: 0.66' icon='drafts'></mo-icon>
Drafts
<mo-key slot='end'>Meta+D</mo-key>
</mo-list-item>
<mo-list-item icon='logout'>
<span>Sign out</span>
<mo-key>Meta+Shift+Q</mo-key>
<mo-list-item>Trash</mo-list-item>
<mo-list-item>
<mo-icon slot='start' style='opacity: 0.66' icon='logout'></mo-icon>
Sign out
<mo-key slot='end'>Meta+Shift+Q</mo-key>
</mo-list-item>
</mo-list>
`,
Expand Down
21 changes: 18 additions & 3 deletions packages/List/List.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
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'
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.
Expand Down Expand Up @@ -33,12 +33,27 @@ export class List extends Component {
static override get styles() {
return css`
:host {
display: block;
display: grid;
grid-template-columns: auto 1fr auto;
/*
* 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) {
outline: none;
}

::slotted(*) {
grid-column: -1 / 1;
}

${unsafeCSS(listItemRoles.map(role => `::slotted([role='${role}'])`).join(','))} {
grid-template-columns: subgrid;
display: grid;
}
`
}

Expand Down
54 changes: 47 additions & 7 deletions packages/List/ListItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -36,10 +38,15 @@ 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;
/*
* 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;
}

:host([disabled]) {
Expand All @@ -54,6 +61,36 @@ export class ListItem extends Component {
:host(:focus) {
outline: none;
}

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;
}

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);
}
`
}

Expand Down Expand Up @@ -96,15 +133,18 @@ export class ListItem extends Component {
return html`
${!this.keyboardFocus ? html.nothing : html`<mo-focus-ring inward visible></mo-focus-ring>`}
<mo-list-item-ripple ?disabled=${this.disabled}></mo-list-item-ripple>
${this.iconTemplate}
<slot name='start'>${this.startSlotDefaultContent}</slot>
<slot></slot>
<slot name='end'>${this.endSlotDefaultContent}</slot>
`
}

protected get iconTemplate() {
return !this.icon ? html.nothing : html`
<mo-icon part='icon' style='opacity: 0.66' icon=${this.icon}></mo-icon>
`
protected get startSlotDefaultContent() {
return html.nothing
}

protected get endSlotDefaultContent() {
return html.nothing
}
}

Expand Down
8 changes: 7 additions & 1 deletion packages/List/NavigationListItem.ts
Original file line number Diff line number Diff line change
@@ -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 */
Expand All @@ -20,6 +20,12 @@ export class NavigationListItem extends ListItem {
}
`
}

protected override get startSlotDefaultContent() {
return !this.icon ? html.nothing : html`
<mo-icon part='icon' icon=${this.icon}></mo-icon>
`
}
}

declare global {
Expand Down
18 changes: 5 additions & 13 deletions packages/List/SelectionListItemWithControl.ts
Original file line number Diff line number Diff line change
@@ -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<T = boolean> extends SelectionListItem<T> {
@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`
<div style='margin-inline-start: auto'>
${this.selectionControlTemplate}
</div>
`
protected override get endSlotDefaultContent() {
return this.selectionControlAlignment === 'end' ? this.selectionControlTemplate : super.endSlotDefaultContent
}

protected abstract get selectionControlTemplate(): HTMLTemplateResult
Expand Down
4 changes: 3 additions & 1 deletion packages/List/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ Object.defineProperty(Element.prototype, list, {
},
})

export const listItemRoles: ReadonlyArray<string> = ['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
},
})

Expand Down
12 changes: 6 additions & 6 deletions packages/Menu/Menu.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,16 +97,16 @@ export const ItemContent: Story = {
<mo-button type='outlined' endIcon='expand_more'>Edit</mo-button>
<mo-menu slot='popover'>
<mo-menu-item icon='content_cut'>
<span style='flex: 1'>Cut</span>
<span style='font-size: 13px; opacity: 0.6'>Ctrl + X</span>
Cut
<span slot='end' style='font-size: 13px; opacity: 0.6'>Ctrl + X</span>
</mo-menu-item>
<mo-menu-item icon='content_copy'>
<span style='flex: 1'>Copy</span>
<span style='font-size: 13px; opacity: 0.6'>Ctrl + C</span>
Copy
<span slot='end' style='font-size: 13px; opacity: 0.6'>Ctrl + C</span>
</mo-menu-item>
<mo-menu-item icon='content_paste'>
<span style='flex: 1'>Paste</span>
<span style='font-size: 13px; opacity: 0.6'>Ctrl + V</span>
Paste
<span slot='end' style='font-size: 13px; opacity: 0.6'>Ctrl + V</span>
</mo-menu-item>
<mo-line></mo-line>
<mo-menu-item>Dictionary</mo-menu-item>
Expand Down
17 changes: 15 additions & 2 deletions packages/Menu/MenuItem.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { component, css } from '@a11d/lit'
import { component, css, html } from '@a11d/lit'
import { ListItem } from '@3mo/list'

/**
Expand All @@ -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`
<mo-icon part='icon' style='opacity: 0.66' icon=${this.icon}></mo-icon>
`
}
}
Expand Down
Loading
Loading