diff --git a/dev/vscode-list/default-active-item.html b/dev/vscode-list/default-active-item.html new file mode 100644 index 000000000..830be84d8 --- /dev/null +++ b/dev/vscode-list/default-active-item.html @@ -0,0 +1,239 @@ + + + + + + VSCode Elements + + + + + + + +

Basic example

+
+ +
+ + + + + + + lorem + + + + + + ipsum + + + + + lorem + + + + + + ipsum + + + + + + dolor + + + + + lorem + + + + + + ipsum + + + + + lorem + + + + + + lorem + + + + + + + + dolor + + + + + lorem + + + + + + ipsum + + + + + lorem + + + + + + lorem + + + + + + lorem + + + + + + lorem + + + + + + lorem + + + + + + lorem + + + + + + lorem + + + + + + + + lorem + + + + + + + dolor + + + + + +
+
+
+ + diff --git a/dev/vscode-list/double-click-expand-mode.html b/dev/vscode-list/double-click-expand-mode.html new file mode 100644 index 000000000..116536863 --- /dev/null +++ b/dev/vscode-list/double-click-expand-mode.html @@ -0,0 +1,235 @@ + + + + + + VSCode Elements + + + + + + + +

Expand mode: doubleClick

+
+ +
+ + + + + + + lorem + + + + + + ipsum + + + + + lorem + + + + + + ipsum + + + + + + dolor + + + + + lorem + + + + + + ipsum + + + + + lorem + + + + + + lorem + + + + + + + + dolor + + + + + lorem + + + + + + ipsum + + + + + lorem + + + + + + lorem + + + + + + lorem + + + + + + lorem + + + + + + lorem + + + + + + lorem + + + + + + lorem + + + + + + + + lorem + + + + + + + dolor + + + +
+
+
+ + diff --git a/dev/vscode-list/events.html b/dev/vscode-list/events.html new file mode 100644 index 000000000..2f000fa69 --- /dev/null +++ b/dev/vscode-list/events.html @@ -0,0 +1,237 @@ + + + + + + VSCode Elements + + + + + + + +

Events

+
+ +
+ + + + + + + lorem + + + + + + ipsum + + + + + lorem + + + + + + ipsum + + + + + + dolor + + + + + lorem + + + + + + ipsum + + + + + lorem + + + + + + lorem + + + + + + + + dolor + + + + + lorem + + + + + + ipsum + + + + + lorem + + + + + + lorem + + + + + + lorem + + + + + + lorem + + + + + + lorem + + + + + + lorem + + + + + + lorem + + + + + + + + lorem + + + + + + + dolor + + + + +
+
+
+ + diff --git a/dev/vscode-list/expand-all-collapse-all.html b/dev/vscode-list/expand-all-collapse-all.html new file mode 100644 index 000000000..97d5e619e --- /dev/null +++ b/dev/vscode-list/expand-all-collapse-all.html @@ -0,0 +1,249 @@ + + + + + + VSCode Elements + + + + + + + +

Expand/Collapse all

+
+ +

+ + +

+
+ + + + + + lorem + + + + + + ipsum + + + + + lorem + + + + + + ipsum + + + + + + dolor + + + + + lorem + + + + + + ipsum + + + + + lorem + + + + + + lorem + + + + + + + + dolor + + + + + lorem + + + + + + ipsum + + + + + lorem + + + + + + lorem + + + + + + lorem + + + + + + lorem + + + + + + lorem + + + + + + lorem + + + + + + lorem + + + + + + + + lorem + + + + + + + dolor + + + +
+
+
+ + diff --git a/dev/vscode-list/guides-has-arrows.html b/dev/vscode-list/guides-has-arrows.html new file mode 100644 index 000000000..b57044c48 --- /dev/null +++ b/dev/vscode-list/guides-has-arrows.html @@ -0,0 +1,239 @@ + + + + + + VSCode Elements + + + + + + + +

Basic example

+
+ +
+ + + + + + + lorem + + + + + + ipsum + + + + + lorem + + + + + + ipsum + + + + + + dolor + + + + + lorem + + + + + + ipsum + + + + + lorem + + + + + + lorem + + + + + + + + dolor + + + + + lorem + + + + + + ipsum + + + + + lorem + + + + + + lorem + + + + + + lorem + + + + + + lorem + + + + + + lorem + + + + + + lorem + + + + + + lorem + + + + + + + + lorem + + + + + + + dolor + + + + + +
+
+
+ + diff --git a/dev/vscode-list/guides-wo-arrows.html b/dev/vscode-list/guides-wo-arrows.html new file mode 100644 index 000000000..9c6a4cb21 --- /dev/null +++ b/dev/vscode-list/guides-wo-arrows.html @@ -0,0 +1,239 @@ + + + + + + VSCode Elements + + + + + + + +

Basic example

+
+ +
+ + + + + + + lorem + + + + + + ipsum + + + + + lorem + + + + + + ipsum + + + + + + dolor + + + + + lorem + + + + + + ipsum + + + + + lorem + + + + + + lorem + + + + + + + + dolor + + + + + lorem + + + + + + ipsum + + + + + lorem + + + + + + lorem + + + + + + lorem + + + + + + lorem + + + + + + lorem + + + + + + lorem + + + + + + lorem + + + + + + + + lorem + + + + + + + dolor + + + + + +
+
+
+ + diff --git a/dev/vscode-list/multi-select.html b/dev/vscode-list/multi-select.html new file mode 100644 index 000000000..b0ecc1873 --- /dev/null +++ b/dev/vscode-list/multi-select.html @@ -0,0 +1,245 @@ + + + + + + VSCode Elements + + + + + + + +

Basic example

+
+ +
+ + + + + + + lorem + + + + + + ipsum + + + + + lorem + + + + + + ipsum + + + + + + dolor + + + + + lorem + + + + + + ipsum + + + + + lorem + + + + + + lorem + + + + + + + + dolor + + + + + lorem + + + + + + ipsum + + + + + lorem + + + + + + lorem + + + + + + lorem + + + + + + lorem + + + + + + lorem + + + + + + lorem + + + + + + lorem + + + + + + + + lorem + + + + + + + dolor + + + + + +
+
+
+ + diff --git a/dev/vscode-list/set-active-item.html b/dev/vscode-list/set-active-item.html new file mode 100644 index 000000000..6f9f2217e --- /dev/null +++ b/dev/vscode-list/set-active-item.html @@ -0,0 +1,239 @@ + + + + + + VSCode Elements + + + + + + + +

Basic example

+
+ +
+ + + + + + + lorem + + + + + + ipsum + + + + + lorem + + + + + + ipsum + + + + + + dolor + + + + + lorem + + + + + + ipsum + + + + + lorem + + + + + + lorem + + + + + + + + dolor + + + + + lorem + + + + + + ipsum + + + + + lorem + + + + + + lorem + + + + + + lorem + + + + + + lorem + + + + + + lorem + + + + + + lorem + + + + + + lorem + + + + + + + + lorem + + + + + + + dolor + + + + + +
+
+
+ + diff --git a/package-lock.json b/package-lock.json index c99a68321..a8c0eada0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.17.0", "license": "MIT", "dependencies": { + "@lit/context": "^1.1.3", "lit": "^3.2.1" }, "devDependencies": { @@ -2666,6 +2667,15 @@ "integrity": "sha512-wx4aBmgeGvFmOKucFKY+8VFJSYZxs9poN3SDNQFF6lT6NrQUnHiPB2PWz2sc4ieEcAaYYzN+1uWahEeTq2aRIQ==", "license": "BSD-3-Clause" }, + "node_modules/@lit/context": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@lit/context/-/context-1.1.3.tgz", + "integrity": "sha512-Auh37F4S0PZM93HTDfZWs97mmzaQ7M3vnTc9YvxAGyP3UItSK/8Fs0vTOGT+njuvOwbKio/l8Cx/zWL4vkutpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit/reactive-element": "^1.6.2 || ^2.0.0" + } + }, "node_modules/@lit/reactive-element": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.0.tgz", @@ -13538,6 +13548,14 @@ "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.2.1.tgz", "integrity": "sha512-wx4aBmgeGvFmOKucFKY+8VFJSYZxs9poN3SDNQFF6lT6NrQUnHiPB2PWz2sc4ieEcAaYYzN+1uWahEeTq2aRIQ==" }, + "@lit/context": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@lit/context/-/context-1.1.3.tgz", + "integrity": "sha512-Auh37F4S0PZM93HTDfZWs97mmzaQ7M3vnTc9YvxAGyP3UItSK/8Fs0vTOGT+njuvOwbKio/l8Cx/zWL4vkutpQ==", + "requires": { + "@lit/reactive-element": "^1.6.2 || ^2.0.0" + } + }, "@lit/reactive-element": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.0.tgz", diff --git a/package.json b/package.json index 0e4daa65d..78195d267 100644 --- a/package.json +++ b/package.json @@ -132,6 +132,7 @@ }, "homepage": "https://vscode-elements.github.io", "dependencies": { + "@lit/context": "^1.1.3", "lit": "^3.2.1" }, "devDependencies": { diff --git a/src/main.ts b/src/main.ts index 880d8a69f..4a8b6afe0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,6 +12,8 @@ export {VscodeFormGroup} from './vscode-form-group/index.js'; export {VscodeFormHelper} from './vscode-form-helper/index.js'; export {VscodeIcon} from './vscode-icon/index.js'; export {VscodeLabel} from './vscode-label/index.js'; +export {VscodeList} from './vscode-list/index.js'; +export {VscodeListItem} from './vscode-list-item/index.js'; export {VscodeMultiSelect} from './vscode-multi-select/index.js'; export {VscodeOption} from './vscode-option/index.js'; export {VscodeProgressRing} from './vscode-progress-ring/index.js'; diff --git a/src/vscode-list-item/helpers.ts b/src/vscode-list-item/helpers.ts new file mode 100644 index 000000000..6886cb96a --- /dev/null +++ b/src/vscode-list-item/helpers.ts @@ -0,0 +1,13 @@ +import {VscodeListItem} from './vscode-list-item'; + +export function getParentItem(childItem: VscodeListItem) { + if (!childItem.parentElement) { + return null; + } + + if (!(childItem.parentElement instanceof VscodeListItem)) { + return null; + } + + return childItem.parentElement; +} diff --git a/src/vscode-list-item/index.ts b/src/vscode-list-item/index.ts new file mode 100644 index 000000000..15686013e --- /dev/null +++ b/src/vscode-list-item/index.ts @@ -0,0 +1 @@ +export {VscodeListItem} from './vscode-list-item.js'; diff --git a/src/vscode-list-item/vscode-list-item.styles.ts b/src/vscode-list-item/vscode-list-item.styles.ts new file mode 100644 index 000000000..8d7b6c261 --- /dev/null +++ b/src/vscode-list-item/vscode-list-item.styles.ts @@ -0,0 +1,166 @@ +import {CSSResultGroup, css} from 'lit'; +import defaultStyles from '../includes/default.styles'; + +const styles: CSSResultGroup = [ + defaultStyles, + css` + :host { + --hover-outline-color: transparent; + --hover-outline-style: solid; + --hover-outline-width: 0; + + --selected-outline-color: transparent; + --selected-outline-style: solid; + --selected-outline-width: 0; + + cursor: pointer; + display: block; + user-select: none; + } + + .wrapper { + display: block; + } + + .content { + align-items: flex-start; + display: flex; + font-family: var(--vscode-font-family); + font-size: var(--vscode-font-size); + font-weight: var(--vscode-font-weight); + outline-offset: -1px; + padding-right: 12px; + } + + .content:hover { + background-color: var(--vscode-list-hoverBackground); + color: var(--vscode-list-hoverForeground); + } + + :host([selected]) .content { + color: var(--vscode-list-activeSelectionForeground); + background-color: var(--vscode-list-activeSelectionBackground); + } + + :host([selected]) ::slotted(vscode-icon) { + color: var(--vscode-list-activeSelectionForeground); + } + + :host(:focus) { + outline: none; + } + + :host(:focus) .content.active { + outline-color: var( + --vscode-list-focusAndSelectionOutline, + var(--vscode-list-focusOutline) + ); + outline-style: solid; + outline-width: 1px; + } + + .arrow-container { + align-items: center; + display: var(--vsc-list-item-arrow-display); + height: 22px; + justify-content: center; + padding-left: 8px; + padding-right: 6px; + width: 16px; + } + + .arrow-container svg { + display: block; + fill: var(--vscode-icon-foreground); + } + + .arrow-container.icon-rotated svg { + transform: rotate(90deg); + } + + :host([selected]) .arrow-container svg { + fill: var(--vscode-list-activeSelectionForeground); + } + + .icon-container { + align-items: center; + display: flex; + height: 22px; + margin-right: 6px; + } + + .children { + position: relative; + } + + .children.guides:before { + background-color: var(--vscode-tree-inactiveIndentGuidesStroke); + content: ''; + display: block; + height: 100%; + left: var(--indentation-guide-left); + pointer-events: none; + position: absolute; + width: 1px; + z-index: 1; + } + + .children.guides.highlighted-guides:before { + background-color: var(--vscode-tree-indentGuidesStroke); + } + + .text-content { + line-height: 22px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .description { + font-size: 0.9em; + margin-left: 0.5em; + opacity: 0.95; + } + + .additional-content { + display: flex; + margin-left: auto; + } + + .decorations { + align-items: center; + display: flex; + height: 22px; + } + + .actions { + align-items: center; + display: none; + height: 22px; + } + + .content:hover .actions { + display: flex; + } + + .actions ::slotted(vscode-icon), + .actions ::slotted(vscode-badge) { + margin-left: 4px; + } + + .decorations ::slotted(vscode-icon), + .decorations ::slotted(vscode-badge) { + margin-left: 4px; + } + + :host([branch]) ::slotted(vscode-list-item) { + display: none; + } + + :host([branch][open]) ::slotted(vscode-list-item) { + display: block; + } + `, +]; + +export default styles; diff --git a/src/vscode-list-item/vscode-list-item.test.ts b/src/vscode-list-item/vscode-list-item.test.ts new file mode 100644 index 000000000..599bfd311 --- /dev/null +++ b/src/vscode-list-item/vscode-list-item.test.ts @@ -0,0 +1,9 @@ +import {expect} from '@open-wc/testing'; +import {VscodeListItem} from './index.js'; + +describe('vscode-list', () => { + it('is defined', () => { + const el = document.createElement('vscode-list-item'); + expect(el).to.instanceOf(VscodeListItem); + }); +}); diff --git a/src/vscode-list-item/vscode-list-item.ts b/src/vscode-list-item/vscode-list-item.ts new file mode 100644 index 000000000..ca33c4006 --- /dev/null +++ b/src/vscode-list-item/vscode-list-item.ts @@ -0,0 +1,429 @@ +import {PropertyValues, TemplateResult, html, nothing} from 'lit'; +import {consume} from '@lit/context'; +import { + customElement, + property, + queryAssignedElements, +} from 'lit/decorators.js'; +import {classMap} from 'lit/directives/class-map.js'; +import {VscElement} from '../includes/VscElement'; +import {stylePropertyMap} from '../includes/style-property-map'; +import { + ConfigContext, + configContext, + listContext, + type ListContext, +} from '../vscode-list/list-context'; +import {initPathTrackerProps} from '../vscode-list/helpers'; +import styles from './vscode-list-item.styles'; +import {getParentItem} from './helpers'; +import {EXPAND_MODE} from '../vscode-list/vscode-list'; + +const BASE_INDENT = 3; +const ARROW_CONTAINER_WIDTH = 30; + +const arrowIcon = html` + +`; + +@customElement('vscode-list-item') +export class VscodeListItem extends VscElement { + static override styles = styles; + + //#region properties + + @property({type: Boolean}) + active = false; + + @property({type: Boolean, reflect: true}) + branch = false; + + @property({type: Boolean}) + hasActiveItem = false; + + @property({type: Boolean}) + hasSelectedItem = false; + + /** @internal */ + @property({type: Boolean}) + highlightedGuides = false; + + @property({type: Boolean, reflect: true}) + open = false; + + @property({type: Number, reflect: true}) + level = 0; + + @property({type: Boolean, reflect: true}) + set selected(selected: boolean) { + this._selected = selected; + this._listContextState.selectedItems.add(this); + this.ariaSelected = selected ? 'true' : 'false'; + } + get selected(): boolean { + return this._selected; + } + private _selected = false; + + set path(newPath: number[]) { + this._path = newPath; + } + get path(): number[] { + return this._path; + } + + //#endregion + + //#region private variables + + private _path: number[] = []; + + @consume({context: listContext, subscribe: true}) + private _listContextState: ListContext = { + isShiftPressed: false, + selectedItems: new Set(), + allItems: null, + itemListUpToDate: false, + focusedItem: null, + prevFocusedItem: null, + hasBranchItem: false, + rootElement: null, + activeItem: null, + highlightedItems: [], + }; + + @consume({context: configContext, subscribe: true}) + private _configContext!: ConfigContext; + + @queryAssignedElements({selector: 'vscode-list-item'}) + private _initiallyAssignedListItems!: VscodeListItem[]; + + @queryAssignedElements({selector: 'vscode-list-item', slot: 'children'}) + private _childrenListItems!: VscodeListItem[]; + + //#endregion + + //#region lifecycle methods + + constructor() { + super(); + + this.addEventListener('focus', this._handleComponentFocus); + } + + override connectedCallback(): void { + super.connectedCallback(); + this._mainSlotChange(); + this.role = 'treeitem'; + this.ariaDisabled = 'false'; + } + + protected override willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has('active')) { + this._toggleActiveState(); + } + + if (changedProperties.has('open') || changedProperties.has('branch')) { + this._setAriaExpanded(); + } + } + + //#endregion + + //#region private methods + + private _setAriaExpanded() { + if (!this.branch) { + this.ariaExpanded = null; + } else { + this.ariaExpanded = this.open ? 'true' : 'false'; + } + } + + private _setHasActiveItemFlagOnParent( + childItem: VscodeListItem, + value: boolean + ) { + const parent = getParentItem(childItem); + + if (parent) { + parent.hasActiveItem = value; + } + } + + private _toggleActiveState() { + if (this.active) { + if (this._listContextState.activeItem) { + this._listContextState.activeItem.active = false; + this._setHasActiveItemFlagOnParent( + this._listContextState.activeItem, + false + ); + } + + this._listContextState.activeItem = this; + this._setHasActiveItemFlagOnParent(this, true); + this.tabIndex = 0; + } else { + if (this._listContextState.activeItem === this) { + this._listContextState.activeItem = null; + this._setHasActiveItemFlagOnParent(this, false); + } + + this.tabIndex = -1; + } + } + + private _selectItem(isCtrlDown: boolean) { + const {selectedItems} = this._listContextState; + const {multiSelect} = this._configContext; + + if (multiSelect && isCtrlDown) { + if (this.selected) { + this.selected = false; + selectedItems.delete(this); + } else { + this.selected = true; + selectedItems.add(this); + } + } else { + selectedItems.forEach((li) => (li.selected = false)); + selectedItems.clear(); + this.selected = true; + selectedItems.add(this); + } + } + + private _selectRange() { + const prevFocused = this._listContextState.prevFocusedItem; + + if (!prevFocused || prevFocused === this) { + return; + } + + if (!this._listContextState.itemListUpToDate) { + this._listContextState.allItems = + this._listContextState.rootElement!.querySelectorAll( + 'vscode-list-item' + ); + + if (this._listContextState.allItems) { + this._listContextState.allItems.forEach((li, i) => { + li.dataset.score = i.toString(); + }); + } + + this._listContextState.itemListUpToDate = true; + } + + let from = +(prevFocused.dataset.score ?? -1); + let to = +(this.dataset.score ?? -1); + + if (from > to) { + [from, to] = [to, from]; + } + + this._listContextState.selectedItems.forEach((li) => (li.selected = false)); + this._listContextState.selectedItems.clear(); + + this._selectItemsAndAllVisibleDescendants(from, to); + } + + private _selectItemsAndAllVisibleDescendants(from: number, to: number) { + let i = from; + + while (i <= to) { + if (this._listContextState.allItems) { + const item = this._listContextState.allItems[i]; + + if (item.branch && !item.open) { + item.selected = true; + const numChildren = item.querySelectorAll('vscode-list-item').length; + i += numChildren; + } else if (item.branch && item.open) { + item.selected = true; + i += this._selectItemsAndAllVisibleDescendants(i + 1, to); + } else { + item.selected = true; + i += 1; + } + } + } + + return i; + } + + private _mainSlotChange() { + this._initiallyAssignedListItems.forEach((li) => { + li.setAttribute('slot', 'children'); + }); + } + + //#endregion + + //#region event handlers + + private _handleChildrenSlotChange() { + initPathTrackerProps(this, this._childrenListItems); + + if (this._listContextState.rootElement) { + this._listContextState.rootElement.updateHasBranchItemFlag(); + } + } + + private _handleMainSlotChange = () => { + this._mainSlotChange(); + this._listContextState.itemListUpToDate = false; + }; + + private _handleComponentFocus = () => { + if ( + this._listContextState.focusedItem && + this._listContextState.focusedItem !== this + ) { + if (!this._listContextState.isShiftPressed) { + this._listContextState.prevFocusedItem = + this._listContextState.focusedItem; + } + + this._listContextState.focusedItem = null; + } + + this._listContextState.focusedItem = this; + }; + + private _handleContentClick(ev: MouseEvent) { + ev.stopPropagation(); + + const isCtrlDown = ev.ctrlKey; + const isShiftDown = ev.shiftKey; + + if (isShiftDown && this._configContext.multiSelect) { + this._selectRange(); + this._listContextState.emitSelectEvent?.(); + } else { + this._selectItem(isCtrlDown); + this._listContextState.emitSelectEvent?.(); + + if (this._configContext.expandMode === EXPAND_MODE.SINGLE_CLICK) { + if (this.branch && !(this._configContext.multiSelect && isCtrlDown)) { + this.open = !this.open; + } + } + } + + this.active = true; + + if (!isShiftDown) { + this._listContextState.prevFocusedItem = this; + } + + this.updateComplete.then(() => { + this._listContextState.highlightGuides?.(); + }); + } + + private _handleDoubleClick(ev: MouseEvent) { + if (this._configContext.expandMode === EXPAND_MODE.DOUBLE_CLICK) { + if (this.branch && !(this._configContext.multiSelect && ev.ctrlKey)) { + this.open = !this.open; + } + } + } + + //#endregion + + override render(): TemplateResult { + const {arrows, indent, indentGuides} = this._configContext; + const {hasBranchItem} = this._listContextState; + let indentation = BASE_INDENT + this.level * indent; + const guideOffset = arrows ? 13 : 3; + const indentGuideX = BASE_INDENT + this.level * indent + guideOffset; + + if (!this.branch && arrows && hasBranchItem) { + indentation += ARROW_CONTAINER_WIDTH; + } + + const contentClasses = { + content: true, + active: this.active, + }; + + const childrenClasses = { + children: true, + guides: this.branch && indentGuides, + 'highlighted-guides': this.highlightedGuides, + }; + + return html`
+
+ ${this.branch && arrows + ? html`
+ ${arrowIcon} +
` + : nothing} +
+ + ${this.branch && !this.open + ? html`` + : nothing} + ${this.branch && this.open + ? html`` + : nothing} + ${!this.branch ? html`` : nothing} +
+
+ + +
+
+
+ +
+
+
+
+
+ +
+
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'vscode-list-item': VscodeListItem; + } +} diff --git a/src/vscode-list/helpers.ts b/src/vscode-list/helpers.ts new file mode 100644 index 000000000..b39f4ced2 --- /dev/null +++ b/src/vscode-list/helpers.ts @@ -0,0 +1,206 @@ +import {VscodeListItem} from '../vscode-list-item'; +import type {VscodeList} from './vscode-list'; + +const isListItem = (item: HTMLElement): item is VscodeListItem => + item.tagName.toUpperCase() === 'VSCODE-LIST-ITEM'; + +const isListRoot = (item: HTMLElement): item is VscodeList => + item.tagName.toUpperCase() === 'VSCODE-LIST'; + +export const initPathTrackerProps = ( + parentElement: VscodeList | VscodeListItem, + items: VscodeListItem[] +): void => { + const numChildren = items.length; + const parentElementLevel = isListRoot(parentElement) + ? -1 + : (parentElement as VscodeListItem).level; + + if ('branch' in parentElement) { + parentElement.branch = numChildren > 0; + } + + parentElement.dataset.numChildren = numChildren.toString(); + + items.forEach((item, i) => { + const level = parentElementLevel + 1; + const index = i.toString(); + + if ('path' in parentElement) { + item.path = [...parentElement.path, i]; + } else { + item.path = [i]; + } + + item.level = parentElementLevel + 1; + item.dataset.level = (parentElementLevel + 1).toString(); + item.dataset.index = i.toString(); + item.dataset.last = i === numChildren - 1 ? 'true' : 'false'; + item.dataset.id = `${level}_${index}`; + item.dataset.path = item.path.join('.'); + }); +}; + +export const findLastChildItem = (item: VscodeListItem): VscodeListItem => { + const children = item.querySelectorAll( + ':scope > vscode-list-item' + ); + + if (children.length < 1) { + return item; + } + + const lastItem = children[children.length - 1]; + + if (lastItem.branch && lastItem.open) { + return findLastChildItem(lastItem); + } else { + return lastItem; + } +}; + +export const findClosestParentHasNextSibling = ( + item: VscodeListItem +): VscodeListItem | null => { + if (!item.parentElement) { + return null; + } + + if (!isListItem(item.parentElement)) { + return null; + } + + const isLast = item.parentElement.dataset.last === 'true' ? true : false; + + if (!isLast) { + return item.parentElement; + } else { + return findClosestParentHasNextSibling(item.parentElement); + } +}; + +export const findNextItem = (item: VscodeListItem): VscodeListItem | null => { + if (item.branch && item.open) { + return item.querySelector('vscode-list-item'); + } + + const {parentElement} = item; + + if (!parentElement) { + return null; + } + + const numSiblings = parseInt(parentElement.dataset.numChildren ?? '0', 10); + const index = parseInt(item.dataset.index ?? '-1', 10); + + let level = 0; + + if ('level' in item) { + level = item.level; + } + + if (index === numSiblings - 1) { + const closestParent = findClosestParentHasNextSibling(item); + + if (closestParent) { + const cpIndex = parseInt(closestParent.dataset.index ?? '', 10) + 1; + const cpLevel = closestParent.level; + + return ( + closestParent.parentElement?.querySelector( + `vscode-list-item[level="${cpLevel}"][data-index="${cpIndex}"]` + ) ?? null + ); + } else { + return null; + } + } + + const nextElementIndex = Math.min(numSiblings - 1, index + 1); + + return parentElement.querySelector( + `vscode-list-item[level="${level}"][data-index="${nextElementIndex}"]` + ); +}; + +export const findPrevItem = (item: VscodeListItem): VscodeListItem | null => { + const {parentElement} = item; + const index = parseInt(item.dataset.index ?? '-1', 10); + + if (!parentElement) { + return null; + } + + const prevSibling = parentElement.querySelector( + `:scope vscode-list-item[data-index="${index - 1}"]` + ); + + if (!prevSibling) { + if (parentElement.tagName.toUpperCase() === 'VSCODE-LIST-ITEM') { + return parentElement as VscodeListItem; + } + } + + if (prevSibling && prevSibling.branch && prevSibling.open) { + const lastChild = findLastChildItem(prevSibling); + + return lastChild; + } + + return prevSibling; +}; + +export const findAncestorOnSpecificLevel = ( + item: VscodeListItem, + level: number +): VscodeListItem | null => { + if ( + !item.parentElement || + item.parentElement.tagName.toUpperCase() !== 'VSCODE-LIST-ITEM' + ) { + return null; + } + + const parent = item.parentElement as VscodeListItem; + const itemLevel = +(item.dataset.level ?? ''); + + if (itemLevel > level) { + return findAncestorOnSpecificLevel(parent, level); + } + + if (itemLevel === level) { + return item; + } + + return null; +}; + +export const selectItemAndAllVisibleDescendants = (item: VscodeListItem) => { + if (!item) { + return; + } + + item.selected = true; + + if (item.branch && item.open) { + const children = item.querySelectorAll( + ':scope > vscode-list-item' + ); + + children.forEach((c) => { + selectItemAndAllVisibleDescendants(c); + }); + } +}; + +export function getParentItem(childItem: VscodeListItem) { + if (!childItem.parentElement) { + return null; + } + + if (!isListItem(childItem.parentElement)) { + return null; + } + + return childItem.parentElement; +} diff --git a/src/vscode-list/index.ts b/src/vscode-list/index.ts new file mode 100644 index 000000000..7954e891c --- /dev/null +++ b/src/vscode-list/index.ts @@ -0,0 +1 @@ +export {VscodeList} from './vscode-list.js'; diff --git a/src/vscode-list/list-context.ts b/src/vscode-list/list-context.ts new file mode 100644 index 000000000..813cb7754 --- /dev/null +++ b/src/vscode-list/list-context.ts @@ -0,0 +1,35 @@ +import {createContext} from '@lit/context'; +import type {VscodeListItem} from '../vscode-list-item'; +import type {ExpandMode, VscodeList} from './vscode-list'; + +export interface ListContext { + isShiftPressed: boolean; + selectedItems: Set; + allItems: NodeListOf | null; + itemListUpToDate: boolean; + focusedItem: VscodeListItem | null; + prevFocusedItem: VscodeListItem | null; + /** If arrows are visible and `List` component has not any branch item, the + * extra padding should be removed in the leaf elements before the content + */ + hasBranchItem: boolean; + rootElement: VscodeList | null; + activeItem: VscodeListItem | null; + highlightedItems: VscodeListItem[]; + highlightGuides?: () => void; + emitSelectEvent?: () => void; +} + +export const listContext = createContext('vscode-list'); + +export interface ConfigContext { + readonly arrows: boolean; + readonly expandMode: ExpandMode; + readonly indent: number; + readonly indentGuides: boolean; + readonly multiSelect: boolean; +} + +export const configContext = createContext( + Symbol('configContext') +); diff --git a/src/vscode-list/vscode-list.styles.ts b/src/vscode-list/vscode-list.styles.ts new file mode 100644 index 000000000..31203cacd --- /dev/null +++ b/src/vscode-list/vscode-list.styles.ts @@ -0,0 +1,19 @@ +import {CSSResultGroup, css} from 'lit'; +import defaultStyles from '../includes/default.styles'; + +const styles: CSSResultGroup = [ + defaultStyles, + css` + :host { + --vsc-list-item-arrow-display: none; + + display: block; + } + + :host([arrows]) { + --vsc-list-item-arrow-display: flex; + } + `, +]; + +export default styles; diff --git a/src/vscode-list/vscode-list.test.ts b/src/vscode-list/vscode-list.test.ts new file mode 100644 index 000000000..293f07a6e --- /dev/null +++ b/src/vscode-list/vscode-list.test.ts @@ -0,0 +1,81 @@ +import {expect, fixture, html} from '@open-wc/testing'; +import {sendKeys} from '@web/test-runner-commands'; +import '../vscode-list-item/vscode-list-item.js'; +import {VscodeListItem} from '../vscode-list-item/vscode-list-item.js'; +import {VscodeList} from './index.js'; + +describe('vscode-list', () => { + it('is defined', () => { + const el = document.createElement('vscode-list'); + expect(el).to.instanceOf(VscodeList); + }); + + it('focuses first item by default', async () => { + const el = await fixture(html` + + Item 1 + Item 2 + + `); + const firstItem = + el.querySelectorAll('vscode-list-item')[0]!; + const secondItem = + el.querySelectorAll('vscode-list-item')[1]!; + + expect(firstItem.tabIndex).to.eq(0); + expect(firstItem.active).to.be.true; + expect(secondItem.tabIndex).to.eq(-1); + expect(secondItem.active).to.be.false; + }); + + it('focuses active item on load', async () => { + const el = await fixture(html` + + Item 1 + Item 2 + + `); + const firstItem = + el.querySelectorAll('vscode-list-item')[0]!; + const secondItem = + el.querySelectorAll('vscode-list-item')[1]!; + + expect(firstItem.tabIndex).to.eq(-1); + expect(firstItem.active).to.be.false; + expect(secondItem.tabIndex).to.eq(0); + expect(secondItem.active).to.be.true; + }); + + it('focuses next item when arrow down key is pressed', async () => { + const el = await fixture(html` + + Item 1 + Item 2 + + `); + + el.querySelector('vscode-list-item')?.focus(); + await sendKeys({press: 'ArrowDown'}); + await el.updateComplete; + + const items = el.querySelectorAll('vscode-list-item')!; + + expect(items[0].tabIndex).to.eq(-1); + expect(items[0].active).to.be.false; + expect(items[1].tabIndex).to.eq(0); + expect(items[1].active).to.be.true; + }); + + it('selects item with Enter key press'); + it('selects item with click on it'); + it('opens and selects branch item with Enter key press'); + it('opens and selects branch item with click on it'); + it('selecting multiple items upwards with the mouse and the Shift key'); + it( + 'expands selection of multiple items upwards with the mouse and the Shift key' + ); + it('selecting multiple items downwards with the mouse and the Shift key'); + it( + 'expands selection of multiple items downwards with the mouse and the Shift key' + ); +}); diff --git a/src/vscode-list/vscode-list.ts b/src/vscode-list/vscode-list.ts new file mode 100644 index 000000000..94473bebd --- /dev/null +++ b/src/vscode-list/vscode-list.ts @@ -0,0 +1,445 @@ +import {PropertyValues, TemplateResult, html} from 'lit'; +import {provide} from '@lit/context'; +import { + customElement, + property, + queryAssignedElements, +} from 'lit/decorators.js'; +import {VscElement} from '../includes/VscElement'; +import type {VscodeListItem} from '../vscode-list-item'; +import styles from './vscode-list.styles'; +import { + ConfigContext, + configContext, + listContext, + type ListContext, +} from './list-context'; +import { + findNextItem, + findPrevItem, + getParentItem, + initPathTrackerProps, +} from './helpers.js'; + +export type VscListSelectEvent = CustomEvent<{selectedItems: VscodeListItem[]}>; + +export const EXPAND_MODE = { + SINGLE_CLICK: 'singleClick', + DOUBLE_CLICK: 'doubleClick', +} as const; + +export type ExpandMode = (typeof EXPAND_MODE)[keyof typeof EXPAND_MODE]; + +type ListenedKey = + | 'ArrowDown' + | 'ArrowUp' + | 'ArrowLeft' + | 'ArrowRight' + | 'Enter' + | 'Escape' + | 'Shift' + | ' '; + +const listenedKeys: ListenedKey[] = [ + ' ', + 'ArrowDown', + 'ArrowUp', + 'ArrowLeft', + 'ArrowRight', + 'Enter', + 'Escape', + 'Shift', +]; +const DEFAULT_ARROWS = false; +const DEFAULT_INDENT = 8; +const DEFAULT_INDENT_GUIDES = false; +const DEFAULT_MULTI_SELECT = false; +const DEFAULT_EXPAND_MODE = EXPAND_MODE.SINGLE_CLICK; + +@customElement('vscode-list') +export class VscodeList extends VscElement { + static override styles = styles; + + //#region properties + + @property({type: Boolean, reflect: true}) + arrows = DEFAULT_ARROWS; + + @property({type: String, attribute: 'expand-mode'}) + expandMode: ExpandMode = DEFAULT_EXPAND_MODE; + + @property({type: Number, reflect: true}) + indent = DEFAULT_INDENT; + + @property({type: Boolean, attribute: 'indent-guides', reflect: true}) + indentGuides = DEFAULT_INDENT_GUIDES; + + @property({type: Boolean, reflect: true, attribute: 'multi-select'}) + multiSelect = DEFAULT_MULTI_SELECT; + + //#endregion + + //#region private variables + + @provide({context: listContext}) + private _listContextState: ListContext = { + isShiftPressed: false, + activeItem: null, + selectedItems: new Set(), + allItems: null, + itemListUpToDate: false, + focusedItem: null, + prevFocusedItem: null, + hasBranchItem: false, + rootElement: this, + highlightedItems: [], + highlightGuides: () => { + this._highlightGuides(); + }, + emitSelectEvent: () => { + this._emitSelectEvent(); + }, + }; + + @provide({context: configContext}) + private _configContext: ConfigContext = { + arrows: DEFAULT_ARROWS, + expandMode: DEFAULT_EXPAND_MODE, + indent: DEFAULT_INDENT, + indentGuides: DEFAULT_INDENT_GUIDES, + multiSelect: DEFAULT_MULTI_SELECT, + }; + + @queryAssignedElements({selector: 'vscode-list-item'}) + private _assignedListItems!: VscodeListItem[]; + + //#region lifecycle methods + + constructor() { + super(); + + this.addEventListener('keyup', this._handleComponentKeyUp); + this.addEventListener('keydown', this._handleComponentKeyDown); + } + + override connectedCallback(): void { + super.connectedCallback(); + + this.role = 'tree'; + } + + protected override willUpdate(changedProperties: PropertyValues): void { + this._updateConfigContext(changedProperties); + + if (changedProperties.has('multiSelect')) { + this.ariaMultiSelectable = this.multiSelect ? 'true' : 'false'; + } + } + + //#endregion + + //#region public methods + + expandAll() { + const children = this.querySelectorAll('vscode-list-item'); + + children.forEach((item) => { + if (item.branch) { + item.open = true; + } + }); + } + + collapseAll() { + const children = this.querySelectorAll('vscode-list-item'); + + children.forEach((item) => { + if (item.branch) { + item.open = false; + } + }); + } + + /** + * @internal + * Updates `hasBranchItem` property in the context state in order to removing + * extra padding before the leaf elements, if it is required. + */ + updateHasBranchItemFlag() { + const hasBranchItem = this._assignedListItems.some((li) => li.branch); + this._listContextState = {...this._listContextState, hasBranchItem}; + } + + //#endregion + + //#region private methods + + private _emitSelectEvent() { + const ev = new CustomEvent('vsc-list-select', { + detail: Array.from(this._listContextState.selectedItems), + }); + + this.dispatchEvent(ev); + } + + private _highlightGuides() { + const {activeItem, highlightedItems, selectedItems} = + this._listContextState; + + highlightedItems.forEach((i) => (i.highlightedGuides = false)); + + if (activeItem) { + this._listContextState.highlightedItems = []; + + if (activeItem.branch && activeItem.open) { + activeItem.highlightedGuides = true; + this._listContextState.highlightedItems.push(activeItem); + } else { + const parent = getParentItem(activeItem); + + if (parent && parent.branch) { + parent.highlightedGuides = true; + this._listContextState.highlightedItems.push(parent); + } + } + } + + if (selectedItems) { + selectedItems.forEach((item) => { + if (item.branch && item.open) { + item.highlightedGuides = true; + this._listContextState.highlightedItems.push(item); + } else { + const parent = getParentItem(item); + + if (parent && parent.branch) { + parent.highlightedGuides = true; + this._listContextState.highlightedItems.push(item); + } + } + }); + } + } + + private _updateConfigContext(changedProperties: PropertyValues) { + const {arrows, expandMode, indent, indentGuides, multiSelect} = this; + + if (changedProperties.has('arrows')) { + this._configContext = {...this._configContext, arrows}; + } + + if (changedProperties.has('expandMode')) { + this._configContext = {...this._configContext, expandMode}; + } + + if (changedProperties.has('indent')) { + this._configContext = {...this._configContext, indent}; + } + + if (changedProperties.has('indentGuides')) { + this._configContext = {...this._configContext, indentGuides}; + } + + if (changedProperties.has('multiSelect')) { + this._configContext = {...this._configContext, multiSelect}; + } + } + + private _focusItem(item: VscodeListItem) { + item.active = true; + + item.updateComplete.then(() => { + item.focus(); + }); + } + + private _focusPrevItem() { + if (this._listContextState.focusedItem) { + const item = findPrevItem(this._listContextState.focusedItem); + + if (item) { + this._focusItem(item); + + if (this._listContextState.isShiftPressed && this.multiSelect) { + item.selected = !item.selected; + this._emitSelectEvent(); + } + } + } + } + + private _focusNextItem() { + if (this._listContextState.focusedItem) { + const item = findNextItem(this._listContextState.focusedItem); + + if (item) { + this._focusItem(item); + + if (this._listContextState.isShiftPressed && this.multiSelect) { + item.selected = !item.selected; + this._emitSelectEvent(); + } + } + } + } + + //#endregion + + //#region event handlers + + private _handleArrowRightPress() { + if (!this._listContextState.focusedItem) { + return; + } + + const {focusedItem} = this._listContextState; + + if (focusedItem.branch) { + if (focusedItem.open) { + this._focusNextItem(); + } else { + focusedItem.open = true; + } + } + } + + private _handleArrowLeftPress(ev: KeyboardEvent) { + if (ev.ctrlKey) { + this.collapseAll(); + return; + } + + if (!this._listContextState.focusedItem) { + return; + } + + const {focusedItem} = this._listContextState; + const parent = getParentItem(focusedItem); + + if (!focusedItem.branch) { + if (parent && parent.branch) { + this._focusItem(parent); + } + } else { + if (focusedItem.open) { + focusedItem.open = false; + } else { + if (parent && parent.branch) { + this._focusItem(parent); + } + } + } + } + + private _handleArrowDownPress() { + if (this._listContextState.focusedItem) { + this._focusNextItem(); + } else { + this._focusItem(this._assignedListItems[0]); + } + } + + private _handleArrowUpPress() { + if (this._listContextState.focusedItem) { + this._focusPrevItem(); + } else { + this._focusItem(this._assignedListItems[0]); + } + } + + private _handleEnterPress() { + const {focusedItem} = this._listContextState; + + if (focusedItem) { + this._listContextState.selectedItems.forEach( + (li) => (li.selected = false) + ); + + focusedItem.selected = true; + this._emitSelectEvent(); + + if (focusedItem.branch) { + focusedItem.open = !focusedItem.open; + } + } + } + + private _handleShiftPress() { + this._listContextState.isShiftPressed = true; + } + + private _handleComponentKeyDown = (ev: KeyboardEvent) => { + const key = ev.key as ListenedKey; + + if (listenedKeys.includes(key)) { + ev.stopPropagation(); + ev.preventDefault(); + } + + switch (key) { + case ' ': + case 'Enter': + this._handleEnterPress(); + break; + case 'ArrowDown': + this._handleArrowDownPress(); + break; + case 'ArrowLeft': + this._handleArrowLeftPress(ev); + break; + case 'ArrowRight': + this._handleArrowRightPress(); + break; + case 'ArrowUp': + this._handleArrowUpPress(); + break; + case 'Shift': + this._handleShiftPress(); + break; + default: + } + }; + + private _handleComponentKeyUp = (ev: KeyboardEvent) => { + if (ev.key === 'Shift') { + this._listContextState.isShiftPressed = false; + } + }; + + private _handleSlotChange = () => { + this._listContextState.itemListUpToDate = false; + initPathTrackerProps(this, this._assignedListItems); + + this.updateComplete.then(() => { + if (this._listContextState.activeItem === null) { + const firstChild = this.querySelector( + ':scope > vscode-list-item' + ); + + if (firstChild) { + firstChild.active = true; + } + } + + this._highlightGuides(); + }); + }; + + //#endregion + + override render(): TemplateResult { + return html`
+ +
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'vscode-list': VscodeList; + } + + interface GlobalEventHandlersEventMap { + 'vsc-list-select': VscListSelectEvent; + } +}