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
167 changes: 167 additions & 0 deletions labs/gb/components/chip/_chip-tokens.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
//
// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0
//

// Auto-generated by cascade_tokens
// Add `// EDIT:` comments for manual adjustments.
//
// --prefix=md.comp.chip
// --states="hovered|focused|pressed|dragged,:unselected|selected,:filter|input,with-leading-icon|with-avatar,with-trailing-icon,disabled"
// --exclude=motion,state-layer,indicator

@mixin root {
// LINT.IfChange
--avatar-opacity: unset;
// md.comp.chip.avatar.shape
--avatar-shape: var(--md-sys-shape-corner-full);
// md.comp.chip.avatar.size
--avatar-size: 24px;
--container-color: unset;
// md.comp.chip.container.elevation
--container-elevation: var(--md-sys-elevation-shadow-0);
--container-opacity: unset;
// md.comp.chip.gap.horizontal
--gap-horizontal: var(--md-sys-space-50);
// md.comp.chip.height
--height: 32px;
// md.comp.chip.label-text
--label-text: var(--md-sys-typescale-label-lg);
// ┌┄ md.comp.chip.label-text
--label-text-axes: var(--md-sys-typescale-label-lg-axes);
// md.comp.chip.unselected.label-text.color
--label-text-color: var(--md-sys-color-on-surface-variant);
--label-text-opacity: unset;
// ┌┄ md.comp.chip.label-text
--label-text-tracking: var(--md-sys-typescale-label-lg-tracking);
// md.comp.chip.unselected.leading-icon.color
--leading-icon-color: var(--md-sys-color-on-surface-variant);
--leading-icon-opacity: unset;
// md.comp.chip.leading-icon.size
--leading-icon-size: 18px;
// md.comp.chip.unselected.outline.color
--outline-color: var(--md-sys-color-outline-variant);
--outline-opacity: unset;
// md.comp.chip.unselected.outline.width
--outline-width: 1px;
// md.comp.chip.padding.bottom
--padding-bottom: var(--md-sys-space-75);
// ┌ md.comp.chip.filter.padding.leading
--padding-leading: var(--md-sys-space-200);
// md.comp.chip.padding.top
--padding-top: var(--md-sys-space-75);
// ┌ md.comp.chip.filter.padding.trailing
--padding-trailing: var(--md-sys-space-200);
// md.comp.chip.unselected.shape
--shape: var(--md-sys-shape-corner-md);
// md.comp.chip.unselected.trailing-icon.color
--trailing-icon-color: var(--md-sys-color-on-surface-variant);
--trailing-icon-opacity: unset;
// md.comp.chip.trailing-icon.size
--trailing-icon-size: 18px;
// LINT.ThenChange(md-gb-chip.ts) sync with `@cssprop` jsdoc tags
}

@mixin pressed {
// md.comp.chip.pressed.shape
--shape: var(--md-sys-shape-corner-sm);
}

@mixin dragged {
// md.comp.chip.dragged.container.elevation
--container-elevation: var(--md-sys-elevation-shadow-4);
}

@mixin selected {
// md.comp.chip.selected.container.color
--container-color: var(--md-sys-color-secondary-container);
// md.comp.chip.selected.label-text.color
--label-text-color: var(--md-sys-color-on-secondary-container);
// md.comp.chip.selected.leading-icon.color
--leading-icon-color: var(--md-sys-color-on-secondary-container);
// md.comp.chip.selected.outline.width
--outline-width: 0px;
// md.comp.chip.selected.shape
--shape: var(--md-sys-shape-corner-full);
// md.comp.chip.selected.trailing-icon.color
--trailing-icon-color: var(--md-sys-color-on-secondary-container);
}

@mixin input {
// md.comp.chip.input.padding.leading
--padding-leading: var(--md-sys-space-150);
// md.comp.chip.input.padding.trailing
--padding-trailing: var(--md-sys-space-150);
}

@mixin with-leading-icon {
// md.comp.chip.with-leading-icon.padding.leading
--padding-leading: var(--md-sys-space-100);
}

@mixin with-avatar {
// md.comp.chip.with-avatar.padding.leading
--padding-leading: var(--md-sys-space-50);
}

@mixin with-trailing-icon {
// md.comp.chip.with-trailing-icon.padding.trailing
--padding-trailing: var(--md-sys-space-100);
}

@mixin disabled {
// md.comp.chip.disabled.avatar.opacity
--avatar-opacity: 0.38;
// ┌ md.comp.chip.disabled.label-text.opacity
// md.comp.chip.disabled.label-text.color
--label-text-color: hsl(from var(--md-sys-color-on-surface) h s l / 0.38);
// ┌ md.comp.chip.disabled.leading-icon.opacity
// md.comp.chip.disabled.leading-icon.color
--leading-icon-color: hsl(from var(--md-sys-color-on-surface) h s l / 0.38);
// ┌ md.comp.chip.unselected.disabled.outline.opacity
// md.comp.chip.unselected.disabled.outline.color
--outline-color: hsl(from var(--md-sys-color-on-surface) h s l / 0.1);
// ┌ md.comp.chip.disabled.trailing-icon.opacity
// md.comp.chip.disabled.trailing-icon.color
--trailing-icon-color: hsl(from var(--md-sys-color-on-surface) h s l / 0.38);
}

@mixin disabled-selected {
// ┌ md.comp.chip.selected.disabled.container.opacity
// md.comp.chip.selected.disabled.container.color
--container-color: hsl(from var(--md-sys-color-on-surface) h s l / 0.12);
}

// EDIT: Manual adjustments
@mixin filter {
// Hoisted to root, so empty here to satisfy chip.scss mapping
}

@mixin elevated {
--container-color: var(--md-sys-color-surface-container-low);
--container-elevation: var(--md-sys-elevation-shadow-1);
--label-text-color: var(--md-sys-color-on-surface);
--outline-width: 0;
}

@mixin filled {
--container-color: var(--md-sys-color-surface-container);
--container-elevation: var(--md-sys-elevation-shadow-0);
--label-text-color: var(--md-sys-color-on-surface);
--outline-width: 0;
}

@mixin tonal {
--container-color: var(--md-sys-color-secondary-container);
--container-elevation: var(--md-sys-elevation-shadow-0);
--label-text-color: var(--md-sys-color-on-secondary-container);
--outline-width: 0;
}

@mixin outlined {
--container-color: transparent;
--container-elevation: var(--md-sys-elevation-shadow-0);
--label-text-color: var(--md-sys-color-on-surface);
--outline-color: var(--md-sys-color-outline);
--outline-width: 1px;
}
222 changes: 222 additions & 0 deletions labs/gb/components/chip/chip-element.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {css, CSSResultOrNative, html, LitElement, nothing} from 'lit';
import {property, state} from 'lit/decorators.js';
import {redispatchEvent} from '../../../../internal/events/redispatch-event.js';
import focusRingStyles from '../focus/focus-ring.cssresult.js';
import rippleStyles from '../ripple/ripple.cssresult.js';
import {chip, type ChipColor, type ChipType} from './chip.js';
import {styles as chipStyles} from './chip.cssresult.js';

/**
* A Material Design Expressive Chip component (`md-gb-chip`).
*
* @slot - Used to display the chip label text.
* @slot icon - Used to display an optional leading icon, checkmark, or avatar.
* @slot remove-icon - Used to display the trailing remove button icon when `removable="true"`.
* @fires {Event} remove - Fired when the remove action is triggered on a removable chip. --bubbles --composed
* @fires {Event} change - Fired when a filter/toggle chip's selection state changes. --bubbles
* @fires {InputEvent} input - Fired when a filter/toggle chip's selection state changes. --bubbles --composed
* @csspart chip - The root container element.
* @cssprop --avatar-opacity
* @cssprop --avatar-shape
* @cssprop --avatar-size
* @cssprop --container-color
* @cssprop --container-elevation
* @cssprop --container-opacity
* @cssprop --gap-horizontal
* @cssprop --height
* @cssprop --label-text
* @cssprop --label-text-color
* @cssprop --label-text-opacity
* @cssprop --leading-icon-color
* @cssprop --leading-icon-opacity
* @cssprop --leading-icon-size
* @cssprop --outline-color
* @cssprop --outline-opacity
* @cssprop --outline-width
* @cssprop --padding-bottom
* @cssprop --padding-leading
* @cssprop --padding-top
* @cssprop --padding-trailing
* @cssprop --shape
* @cssprop --trailing-icon-color
* @cssprop --trailing-icon-opacity
* @cssprop --trailing-icon-size
*/
export class ChipElement extends LitElement {
static override styles: CSSResultOrNative[] = [
focusRingStyles,
rippleStyles,
chipStyles,
css`
:host {
display: inline-flex;
vertical-align: middle;
}
.chip {
flex: 1;
}
.chip-icon {
display: inline-flex;
align-items: center;
}
`,
];

/** The color emphasis of the chip. Defaults to `outlined`. */
@property() color: ChipColor = 'outlined';

/** The functional behavior type of the chip (`action`, `filter`, `toggle`, `link`). */
@property() type: ChipType = 'action';

/** Whether the filter or toggle chip is selected. */
@property({type: Boolean, reflect: true}) selected = false;

/** Whether the chip is removable. */
@property({type: Boolean, reflect: true}) removable = false;

/** Whether the chip is disabled. */
@property({type: Boolean, reflect: true}) disabled = false;

/** Whether the chip is soft-disabled (`aria-disabled="true"` while preserving focusability). */
@property({type: Boolean, attribute: 'soft-disabled', reflect: true})
softDisabled = false;

/** The URL destination if `type="link"` or if `href` is set. */
@property() href = '';

/** Where to display the linked `href` URL for a link chip. */
@property() target: '_blank' | '_parent' | '_self' | '_top' | '' = '';

@state() private hasLeadingIcon = false;
@state() private hasAvatar = false;
@state() private hasRemoveIcon = false;

protected override render() {
const isFilterSelected =
(this.type === 'filter' || this.type === 'toggle') && this.selected;
const withLeadingIcon = this.hasLeadingIcon || isFilterSelected;
const withAvatar = this.hasAvatar;

const chipClasses = chip({
color: this.color,
type: this.href ? 'link' : this.type,
selected: this.selected,
removable: this.removable,
disabled: this.softDisabled || this.disabled,
withLeadingIcon: withLeadingIcon && !withAvatar,
withAvatar,
withTrailingIcon: this.removable || this.hasRemoveIcon,
});

if (this.href || this.type === 'link') {
return html`<a
part="chip"
class="${chipClasses}"
href=${this.href}
target=${(this.target as '_blank' | '_parent' | '_self' | '_top') ||
nothing}
aria-disabled=${this.disabled || this.softDisabled ? 'true' : nothing}
tabindex=${this.disabled && !this.softDisabled ? -1 : nothing}>
${this.renderContent(withLeadingIcon, isFilterSelected)}
</a>`;
}

if (this.removable) {
return html`<div
part="chip"
class="${chipClasses}"
role="row"
aria-pressed=${this.type === 'filter' || this.type === 'toggle'
? (this.selected ? 'true' : 'false')
: nothing}
@change=${this.handleChange}>
<button
class="chip-action"
type="button"
?disabled=${this.disabled}
aria-disabled=${this.softDisabled ? 'true' : nothing}>
${this.renderContent(withLeadingIcon, isFilterSelected)}
</button>
${this.renderRemoveButton()}
</div>`;
}

return html`<button
part="chip"
class="${chipClasses}"
type="button"
?disabled=${this.disabled}
aria-disabled=${this.softDisabled ? 'true' : nothing}
aria-pressed=${this.type === 'filter' || this.type === 'toggle'
? (this.selected ? 'true' : 'false')
: nothing}
@change=${this.handleChange}>
${this.renderContent(withLeadingIcon, isFilterSelected)}
</button>`;
}

private renderContent(withLeadingIcon: boolean, isFilterSelected: boolean) {
return html`
${this.renderLeadingIcon(withLeadingIcon, isFilterSelected)}
<slot></slot>
`;
}

private renderLeadingIcon(
withLeadingIcon: boolean,
isFilterSelected: boolean,
) {
return html`<span
class="chip-icon"
style="${withLeadingIcon ? '' : 'display: none;'}">
<slot name="icon" @slotchange=${this.handleIconSlotChange}>
${isFilterSelected ? html`✓` : nothing}
</slot>
</span>`;
}

private renderRemoveButton() {
return html`<button
class="chip-remove"
type="button"
aria-label="Remove"
?disabled=${this.disabled}
aria-disabled=${this.softDisabled ? 'true' : nothing}
@click=${this.handleRemove}>
<slot
name="remove-icon"
@slotchange=${this.handleRemoveIconSlotChange}
>✕</slot
>
</button>`;
}

private handleIconSlotChange(event: Event) {
const slot = event.target as HTMLSlotElement;
const elements = slot.assignedElements({flatten: true});
this.hasLeadingIcon = elements.length > 0;
this.hasAvatar = elements.some((el) => el.classList.contains('chip-avatar'));
}

private handleRemoveIconSlotChange(event: Event) {
const slot = event.target as HTMLSlotElement;
this.hasRemoveIcon = slot.assignedNodes({flatten: true}).length > 0;
}

private handleChange(event: Event) {
this.selected = (event.target as HTMLElement).ariaPressed === 'true';
redispatchEvent(this, event);
}

private handleRemove(event: Event) {
event.stopPropagation();
if (this.disabled || this.softDisabled) return;
this.dispatchEvent(new Event('remove', {bubbles: true, composed: true}));
}
}
Loading
Loading