diff --git a/dev/vscode-breadcrumbs/basic-example.html b/dev/vscode-breadcrumbs/basic-example.html new file mode 100644 index 000000000..9651f785e --- /dev/null +++ b/dev/vscode-breadcrumbs/basic-example.html @@ -0,0 +1,51 @@ + + + + + + VSCode Elements + + + + + + + +

Basic example

+
+ + + + + workspace + + + + src + + + + components + + + + vscode-breadcrumbs.ts + + + +
+ + diff --git a/src/includes/vscode-select/template-elements.ts b/src/includes/icons.ts similarity index 64% rename from src/includes/vscode-select/template-elements.ts rename to src/includes/icons.ts index bbe3525af..a1f6660f1 100644 --- a/src/includes/vscode-select/template-elements.ts +++ b/src/includes/icons.ts @@ -1,6 +1,6 @@ -import {html, svg} from 'lit'; +import {svg} from 'lit'; -export const chevronDownIcon = html` +export const chevronDownIcon = svg` `; +export const chevronRightIcon = svg` + +`; + export const checkIcon = svg` 0; + } + + override render(): TemplateResult { + return html` +
+ +
+ +
+
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'vscode-breadcrumb-item': VscodeBreadcrumbItem; + } +} diff --git a/src/vscode-breadcrumbs/index.ts b/src/vscode-breadcrumbs/index.ts new file mode 100644 index 000000000..e7ae284f3 --- /dev/null +++ b/src/vscode-breadcrumbs/index.ts @@ -0,0 +1 @@ +export {VscodeBreadcrumbs} from './vscode-breadcrumbs.js'; diff --git a/src/vscode-breadcrumbs/vscode-breadcrumbs.styles.ts b/src/vscode-breadcrumbs/vscode-breadcrumbs.styles.ts new file mode 100644 index 000000000..681eac2a5 --- /dev/null +++ b/src/vscode-breadcrumbs/vscode-breadcrumbs.styles.ts @@ -0,0 +1,35 @@ +import {css, CSSResultGroup} from 'lit'; +import defaultStyles from '../includes/default.styles.js'; + +const styles: CSSResultGroup = [ + defaultStyles, + css` + :host { + display: block; + width: 100%; + outline: none; + } + + .container { + -ms-overflow-style: none; /* IE 10+ */ + align-items: center; + background: var(--vscode-breadcrumb-background, transparent); + display: flex; + height: 22px; + overflow-x: auto; + overflow-y: hidden; + position: relative; + scrollbar-width: none; /* Firefox */ + } + + .container::-webkit-scrollbar { + display: none; /* Chrome/Safari */ + } + + ::slotted(vscode-breadcrumb-item) { + flex: 0 0 auto; + } + `, +]; + +export default styles; diff --git a/src/vscode-breadcrumbs/vscode-breadcrumbs.test.ts b/src/vscode-breadcrumbs/vscode-breadcrumbs.test.ts new file mode 100644 index 000000000..a55f32aef --- /dev/null +++ b/src/vscode-breadcrumbs/vscode-breadcrumbs.test.ts @@ -0,0 +1,97 @@ +import {expect, fixture, html} from '@open-wc/testing'; +import './vscode-breadcrumbs.js'; +import '../vscode-breadcrumb-item/vscode-breadcrumb-item.js'; +import {VscodeBreadcrumbs} from './vscode-breadcrumbs.js'; + +describe('vscode-breadcrumbs', () => { + it('is defined', () => { + const el = document.createElement('vscode-breadcrumbs'); + expect(el).to.be.instanceOf(VscodeBreadcrumbs); + }); + + it('focuses and selects items on click', async () => { + const el = (await fixture(html` + + Root + src + index.ts + + `)) as VscodeBreadcrumbs; + + const items = el.querySelectorAll('vscode-breadcrumb-item'); + + const selectPromise = new Promise((resolve) => { + const handler = (e: Event) => { + el.removeEventListener('vsc-select', handler); + resolve(e as CustomEvent); + }; + el.addEventListener('vsc-select', handler, {once: true}); + }); + (items[1] as HTMLElement).click(); + const ev = await selectPromise; + + expect((items[1] as HTMLElement).classList.contains('selected')).to.be.true; + expect(ev.detail.index).to.equal(1); + }); + + it('supports keyboard navigation and selection', async () => { + const el = (await fixture(html` + + Root + src + index.ts + + `)) as VscodeBreadcrumbs; + + // Focus the last item by default + const items = el.querySelectorAll('vscode-breadcrumb-item'); + await el.updateComplete; + + // Move left, then select + el.dispatchEvent( + new KeyboardEvent('keydown', {key: 'ArrowLeft', bubbles: true}) + ); + el.dispatchEvent( + new KeyboardEvent('keydown', {key: 'Enter', bubbles: true}) + ); + + await el.updateComplete; + + expect((items[1] as HTMLElement).classList.contains('selected')).to.be.true; + }); + + it('sets aria-current on the last item and aria-label on the host by default', async () => { + const el = (await fixture(html` + + Root + src + index.ts + + `)) as VscodeBreadcrumbs; + + const items = el.querySelectorAll('vscode-breadcrumb-item'); + await el.updateComplete; + + // Host should have an aria-label + expect(el.getAttribute('aria-label')).to.equal('Breadcrumb'); + + // Last item should have aria-current="page" + expect((items[2] as HTMLElement).getAttribute('aria-current')).to.equal( + 'page' + ); + // Other items should not have aria-current + expect((items[0] as HTMLElement).hasAttribute('aria-current')).to.be.false; + }); + + it('does not override an existing aria-label on the host', async () => { + const el = (await fixture(html` + + Root + src + + `)) as VscodeBreadcrumbs; + + await el.updateComplete; + expect(el.getAttribute('aria-label')).to.equal('Custom label'); + }); +}); diff --git a/src/vscode-breadcrumbs/vscode-breadcrumbs.ts b/src/vscode-breadcrumbs/vscode-breadcrumbs.ts new file mode 100644 index 000000000..f7fa34e82 --- /dev/null +++ b/src/vscode-breadcrumbs/vscode-breadcrumbs.ts @@ -0,0 +1,196 @@ +import {TemplateResult, html} from 'lit'; +import {customElement, VscElement} from '../includes/VscElement.js'; +import {property} from 'lit/decorators.js'; +import styles from './vscode-breadcrumbs.styles.js'; +import '../vscode-breadcrumb-item/vscode-breadcrumb-item.js'; + +export type VscodeBreadcrumbsEventDetail = { + index: number; + item: HTMLElement; + payload?: Event; +}; + +export type VscodeBreadcrumbsFocusEvent = + CustomEvent; +export type VscodeBreadcrumbsSelectEvent = + CustomEvent; + +/** + * @tag vscode-breadcrumbs + * + * Container for breadcrumb items. + * + * @fires vsc-focus Fired when a breadcrumb item receives focus. Detail: { index, item, payload } + * @fires vsc-select Fired when a breadcrumb item is selected. Detail: { index, item, payload } + * + * @cssprop [--vscode-breadcrumb-background=transparent] - Breadcrumbs container background + * + * @prop {number} selectedIndex - Index of the selected breadcrumb item (-1 for none) + * @attr {number} selected-index - Index of the selected breadcrumb item (-1 for none) + */ +@customElement('vscode-breadcrumbs') +export class VscodeBreadcrumbs extends VscElement { + static override styles = styles; + + @property({type: Number, reflect: true, attribute: 'selected-index'}) + selectedIndex = -1; + private _focusedIndex = -1; + + override connectedCallback(): void { + super.connectedCallback(); + this.setAttribute('role', 'list'); + if (!this.hasAttribute('aria-label')) { + this.setAttribute('aria-label', 'Breadcrumb'); + } + this.tabIndex = -1; // focus individual items + this.addEventListener('click', this.onClick as EventListener); + this.addEventListener('keydown', this.onKeydown as EventListener); + } + + override disconnectedCallback(): void { + this.removeEventListener('click', this.onClick as EventListener); + this.removeEventListener('keydown', this.onKeydown as EventListener); + super.disconnectedCallback(); + } + + override firstUpdated(): void { + this.refreshItems(); + } + + private getItems(): HTMLElement[] { + return Array.from( + this.querySelectorAll('vscode-breadcrumb-item') + ) as HTMLElement[]; + } + + private refreshItems(_event?: Event): void { + const items = this.getItems(); + // Make one item tabbable if none yet + if (this._focusedIndex < 0 && items.length) { + this._focusedIndex = items.length - 1; // default to last + } + this.applyItemStates(); + } + + private applyItemStates(): void { + const items = this.getItems(); + items.forEach((el, idx) => { + el.setAttribute('tabindex', idx === this._focusedIndex ? '0' : '-1'); + el.classList.toggle('selected', idx === this.selectedIndex); + // Mark the last breadcrumb as the current page for accessibility + if (idx === items.length - 1) { + el.setAttribute('aria-current', 'page'); + } else { + el.removeAttribute('aria-current'); + } + }); + // Ensure focused is scrolled into view + items[this._focusedIndex]?.scrollIntoView({ + block: 'nearest', + inline: 'nearest', + }); + } + + private onClick(ev: MouseEvent) { + const items = this.getItems(); + for ( + let el = ev.target as HTMLElement | null; + el && this.contains(el); + el = el.parentElement + ) { + const idx = items.indexOf(el); + if (idx >= 0) { + this.focusItem(idx, ev); + this.selectItem(idx, ev); + break; + } + } + } + + private onKeydown(ev: KeyboardEvent): void { + const items = this.getItems(); + if (!items.length) return; + if (ev.key === 'ArrowLeft') { + ev.preventDefault(); + const next = Math.max(0, this._focusedIndex - 1); + this.focusItem(next, ev); + } else if (ev.key === 'ArrowRight') { + ev.preventDefault(); + const next = Math.min(items.length - 1, this._focusedIndex + 1); + this.focusItem(next, ev); + } else if (ev.key === 'Enter' || ev.key === ' ') { + ev.preventDefault(); + this.selectItem(this._focusedIndex, ev); + } + } + + private focusItem(idx: number, payload?: Event): void { + const items = this.getItems(); + if (idx < 0 || idx >= items.length) return; + this._focusedIndex = idx; + this.applyItemStates(); + items[idx].focus(); + this.dispatchEvent( + this.createFocusEvent({index: idx, item: items[idx], payload}) + ); + } + + private selectItem(idx: number, payload?: Event): void { + const items = this.getItems(); + if (idx < 0 || idx >= items.length) return; + this.selectedIndex = idx; + this.applyItemStates(); + this.dispatchEvent( + this.createSelectEvent({index: idx, item: items[idx], payload}) + ); + } + + override render(): TemplateResult { + return html`
+ +
`; + } + + override attributeChangedCallback( + name: string, + old: string | null, + value: string | null + ): void { + super.attributeChangedCallback(name, old, value); + if (name === 'selected-index') { + // When selected-index changes via attribute/property, update item states + this.applyItemStates(); + } + } + + protected createFocusEvent( + detail: VscodeBreadcrumbsEventDetail + ): VscodeBreadcrumbsFocusEvent { + return new CustomEvent('vsc-focus', { + detail, + bubbles: true, + composed: true, + }); + } + + protected createSelectEvent( + detail: VscodeBreadcrumbsEventDetail + ): VscodeBreadcrumbsSelectEvent { + return new CustomEvent('vsc-select', { + detail, + bubbles: true, + composed: true, + }); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'vscode-breadcrumbs': VscodeBreadcrumbs; + } + + interface GlobalEventHandlersEventMap { + 'vsc-focus': VscodeBreadcrumbsFocusEvent; + 'vsc-select': VscodeBreadcrumbsSelectEvent; + } +} diff --git a/src/vscode-checkbox/vscode-checkbox.test.ts b/src/vscode-checkbox/vscode-checkbox.test.ts index 7653d5333..c44fb0f27 100644 --- a/src/vscode-checkbox/vscode-checkbox.test.ts +++ b/src/vscode-checkbox/vscode-checkbox.test.ts @@ -188,7 +188,7 @@ describe('vscode-checkbox', () => { const input = el.shadowRoot?.querySelector('input'); expect(input?.checked).to.be.true; - expect(el.shadowRoot?.querySelector('.check-icon')).to.be.ok; + expect(el.shadowRoot?.querySelector('.icon.checked')).to.be.ok; }); it('indeterminate state should be applied', async () => { diff --git a/src/vscode-checkbox/vscode-checkbox.ts b/src/vscode-checkbox/vscode-checkbox.ts index d9a414f52..044e94934 100644 --- a/src/vscode-checkbox/vscode-checkbox.ts +++ b/src/vscode-checkbox/vscode-checkbox.ts @@ -3,6 +3,7 @@ import {ifDefined} from 'lit/directives/if-defined.js'; import {property, query} from 'lit/decorators.js'; import {classMap} from 'lit/directives/class-map.js'; import {customElement} from '../includes/VscElement.js'; +import {checkIcon} from '../includes/icons.js'; import {FormButtonWidgetBase} from '../includes/form-button-widget/FormButtonWidgetBase.js'; import {LabelledCheckboxOrRadioMixin} from '../includes/form-button-widget/LabelledCheckboxOrRadio.js'; import styles from './vscode-checkbox.styles.js'; @@ -270,21 +271,7 @@ export class VscodeCheckbox 'label-inner': true, }); - const icon = html` - - `; - const check = this.checked && !this.indeterminate ? icon : nothing; + const check = this.checked && !this.indeterminate ? checkIcon : nothing; const indeterminate = this.indeterminate ? html`` : nothing; diff --git a/src/vscode-collapsible/vscode-collapsible.styles.ts b/src/vscode-collapsible/vscode-collapsible.styles.ts index 82af0f747..8fe257001 100644 --- a/src/vscode-collapsible/vscode-collapsible.styles.ts +++ b/src/vscode-collapsible/vscode-collapsible.styles.ts @@ -47,13 +47,21 @@ const styles: CSSResultGroup = [ } .header-icon { + display: block; + height: 16px; + margin: 0 3px; + width: 16px; + } + + .header-icon svg { color: var(--vscode-icon-foreground, #cccccc); display: block; flex-shrink: 0; - margin: 0 3px; + height: 100%; + width: 100%; } - .collapsible.open .header-icon { + .collapsible.open .header-icon svg { transform: rotate(90deg); } diff --git a/src/vscode-collapsible/vscode-collapsible.ts b/src/vscode-collapsible/vscode-collapsible.ts index 3363f2ce3..5f5ec61ec 100644 --- a/src/vscode-collapsible/vscode-collapsible.ts +++ b/src/vscode-collapsible/vscode-collapsible.ts @@ -1,6 +1,7 @@ import {html, nothing, TemplateResult} from 'lit'; import {property} from 'lit/decorators.js'; import {classMap} from 'lit/directives/class-map.js'; +import {chevronRightIcon} from '../includes/icons.js'; import {customElement, VscElement} from '../includes/VscElement.js'; import styles from './vscode-collapsible.styles.js'; @@ -92,21 +93,6 @@ export class VscodeCollapsible extends VscElement { }; const heading = this.heading ? this.heading : this.title; - const icon = html` - - `; - const descriptionMarkup = this.description ? html`${this.description}` : nothing; @@ -119,7 +105,7 @@ export class VscodeCollapsible extends VscElement { @click=${this._onHeaderClick} @keydown=${this._onHeaderKeyDown} > - ${icon} +
${chevronRightIcon}

${heading}${descriptionMarkup}

diff --git a/src/vscode-multi-select/vscode-multi-select.ts b/src/vscode-multi-select/vscode-multi-select.ts index 5ac841860..e20379277 100644 --- a/src/vscode-multi-select/vscode-multi-select.ts +++ b/src/vscode-multi-select/vscode-multi-select.ts @@ -2,7 +2,7 @@ import {html, LitElement, nothing, TemplateResult} from 'lit'; import {property, query} from 'lit/decorators.js'; import {ifDefined} from 'lit/directives/if-defined.js'; import {customElement} from '../includes/VscElement.js'; -import {chevronDownIcon} from '../includes/vscode-select/template-elements.js'; +import {chevronDownIcon} from '../includes/icons.js'; import {VscodeSelectBase} from '../includes/vscode-select/vscode-select-base.js'; import styles from './vscode-multi-select.styles.js'; import {AssociatedFormControl} from '../includes/AssociatedFormControl.js'; diff --git a/src/vscode-single-select/vscode-single-select.ts b/src/vscode-single-select/vscode-single-select.ts index 39a7f5dac..64b3511ae 100644 --- a/src/vscode-single-select/vscode-single-select.ts +++ b/src/vscode-single-select/vscode-single-select.ts @@ -2,7 +2,7 @@ import {html, LitElement, TemplateResult} from 'lit'; import {property, query} from 'lit/decorators.js'; import {ifDefined} from 'lit/directives/if-defined.js'; import {customElement} from '../includes/VscElement.js'; -import {chevronDownIcon} from '../includes/vscode-select/template-elements.js'; +import {chevronDownIcon} from '../includes/icons.js'; import {VscodeSelectBase} from '../includes/vscode-select/vscode-select-base.js'; import {AssociatedFormControl} from '../includes/AssociatedFormControl.js'; import styles from './vscode-single-select.styles.js'; diff --git a/src/vscode-tree-item/vscode-tree-item.ts b/src/vscode-tree-item/vscode-tree-item.ts index e13892f76..e32dc0cda 100644 --- a/src/vscode-tree-item/vscode-tree-item.ts +++ b/src/vscode-tree-item/vscode-tree-item.ts @@ -4,6 +4,7 @@ import {property, queryAssignedElements, state} from 'lit/decorators.js'; import {classMap} from 'lit/directives/class-map.js'; import {customElement, VscElement} from '../includes/VscElement.js'; import {stylePropertyMap} from '../includes/style-property-map.js'; +import {chevronRightIcon} from '../includes/icons.js'; import { ConfigContext, configContext, @@ -17,19 +18,6 @@ import {ExpandMode, IndentGuides} from '../vscode-tree/vscode-tree.js'; const BASE_INDENT = 3; const ARROW_CONTAINER_WIDTH = 30; -const arrowIcon = html` - -`; - function getParentItem(childItem: VscodeTreeItem) { if (!childItem.parentElement) { return null; @@ -432,7 +420,7 @@ export class VscodeTreeItem extends VscElement { 'icon-rotated': this.open, })} > - ${arrowIcon} + ${chevronRightIcon}
` : nothing}