blob: b424f010de1d37a07ca9ba114e7afe5f12897870 [file] [log] [blame]
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/
import {html, LitElement, PropertyValues} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';
import {assertIsDefined} from '../../../utils/common-util';
import {fire} from '../../../utils/event-util';
/**
* This is a replacement for iron-selector.
* Based on https://github.com/chromium/chromium/blob/f7322d4ecf3ee3804ce7e80e1e9d4b98f23b9295/ui/webui/resources/cr_elements/cr_selectable_mixin.ts.
* Modified for gerrit.
*/
@customElement('gr-selector')
export class GrSelector extends LitElement {
@property({type: String})
attrForSelected: string | null = null;
@property({type: String})
selected?: string | number;
@property({type: String})
selectedAttribute: string | null = null;
@property({type: String})
selectable?: string;
@state()
items: Element[] = [];
private selectedItem_: Element | null = null;
override render() {
return html`<slot></slot>`;
}
override firstUpdated(changedProperties: PropertyValues<this>) {
super.firstUpdated(changedProperties);
this.addEventListener('click', e => this.onClick(e));
this.observeItems();
}
// Override this method in client code to modify the observation logic,
// or to turn it off completely. By default it listens for any changes on
// the first <slot> node in this shadowRoot.
observeItems() {
this.getSlot().addEventListener('slotchange', () => this.itemsChanged());
}
override connectedCallback() {
super.connectedCallback();
this.updateItems();
}
override willUpdate(changedProperties: PropertyValues<this>) {
super.willUpdate(changedProperties);
if (changedProperties.has('attrForSelected')) {
if (this.selectedItem_) {
assertIsDefined(this.attrForSelected);
const value = this.selectedItem_.getAttribute(this.attrForSelected);
assertIsDefined(value);
this.selected = value;
}
}
}
override updated(changedProperties: PropertyValues<this>) {
super.updated(changedProperties);
if (changedProperties.has('selected')) {
this.updateSelectedItem();
}
}
/**
* Selects the given value.
*/
select(value: string | number) {
this.selected = value;
}
getSlot(): HTMLSlotElement {
const slot = this.shadowRoot!.querySelector('slot');
assertIsDefined(slot);
return slot;
}
// Override this method in client code to modify this logic, for example to
// grab children that don't reside in a <slot>.
queryItems(): Element[] {
const selectable = this.selectable === undefined ? '*' : this.selectable;
return Array.from(this.querySelectorAll(`:scope > ${selectable}`));
}
// If overriding queryItems(), override this method to return the list item
// element matching the CSS selector string |selector|.
queryMatchingItem(selector: string): HTMLElement | null {
const selectable = this.selectable || '*';
return this.querySelector<HTMLElement>(
`:scope > :is(${selectable})${selector}`
);
}
private updateItems() {
this.items = this.queryItems();
this.items.forEach((item, index) =>
item.setAttribute('data-selection-index', index.toString())
);
}
get selectedItem(): Element | null {
return this.selectedItem_;
}
private updateSelectedItem() {
if (!this.items) {
return;
}
const item =
this.selected === null || this.selected === undefined
? null
: this.items[this.valueToIndex_(this.selected)];
if (!!item && this.selectedItem_ !== item) {
this.setItemSelected_(this.selectedItem_, false);
this.setItemSelected_(item, true);
} else if (!item) {
this.setItemSelected_(this.selectedItem_, false);
}
}
private setItemSelected_(item: Element | null, isSelected: boolean) {
if (!item) {
return;
}
item.classList.toggle('selected', isSelected);
if (this.selectedAttribute) {
item.toggleAttribute(this.selectedAttribute, isSelected);
}
this.selectedItem_ = isSelected ? item : null;
fire(this, isSelected ? 'select' : 'deselect', {item});
}
private valueToIndex_(value: string | number): number {
if (!this.attrForSelected) {
return Number(value);
}
const match = this.queryMatchingItem(
`[${this.attrForSelected}="${value}"]`
);
return match ? Number(match.dataset['selectionIndex']) : -1;
}
private indexToValue_(index: number): string | number {
if (!this.attrForSelected) {
return index;
}
const item = this.items[index];
if (!item) {
return index;
}
return item.getAttribute(this.attrForSelected) || index;
}
itemsChanged() {
this.updateItems();
this.updateSelectedItem();
// Let other interested parties know about the change.
fire(this, 'items-changed', {});
}
private onClick(e: MouseEvent) {
let element = e.target as HTMLElement;
while (element && element !== this) {
const idx = this.items.indexOf(element);
if (idx >= 0) {
const value = this.indexToValue_(idx);
assertIsDefined(value);
fire(this, 'activate', {item: element, selected: value});
this.select(value);
return;
}
element = element.parentNode as HTMLElement;
}
}
}
declare global {
interface HTMLElementTagNameMap {
'gr-selector': GrSelector;
}
interface HTMLElementEventMap {
activate: CustomEvent<{}>;
'items-changed': CustomEvent<{}>;
select: CustomEvent<{}>;
deselect: CustomEvent<{}>;
}
}