diff --git a/CHANGELOG.md b/CHANGELOG.md index bc46d7458..5f1860e90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,27 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) +## [Unreleased] + +### Added + +- Added `alwaysVisible`, `scrollSensitivity`, `fastScrollSensitivity` and `minThumbSize` property to **Scrollable**. +- **Scrollable** now dispatches the `vsc-scrollable-scroll` event. +- Added nicer checkboxes to **MultiSelect**. +- Added `selectAll()` and `selectNone()` methods to **MultiSelect**. +- Added a themed scrollbar to the **SingleSelect** and **MultiSelect**. + +### Changed + +- **ButtonGroup** can now shrink inside a flex container. +- **SingleSelect** and **MultiSelect** accessibility has been improved. +- **MultiSelect**: The badge displaying the number of selected options is now more compact. +- The 'Select all', 'Select none', and 'Accept' buttons have been removed from the **MultiSelect** dropdown for improved accessibility and better maintainability. + +### Fixed + +- **Label** can now be associated with **SingleSelect** and **MultiSelect**. + ## [1.16.1] - 2025-05-22 ### Fixed diff --git a/custom-elements-manifest.config.mjs b/custom-elements-manifest.config.mjs new file mode 100644 index 000000000..2ed209ab5 --- /dev/null +++ b/custom-elements-manifest.config.mjs @@ -0,0 +1,7 @@ +import {cemDeprecatorPlugin} from 'custom-elements-manifest-deprecator'; + +const options = {}; + +export default { + plugins: [cemDeprecatorPlugin(options)], +}; diff --git a/dev/vscode-scrollable/always-visible.html b/dev/vscode-scrollable/always-visible.html new file mode 100644 index 000000000..dc09270e1 --- /dev/null +++ b/dev/vscode-scrollable/always-visible.html @@ -0,0 +1,82 @@ + + + + + + VSCode Elements + + + + + + + + + +
+ + +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam + volutpat ex sit amet venenatis auctor. Nunc semper consequat diam + sit amet eleifend. Ut a viverra nulla. Fusce vel sapien quis metus + vestibulum dignissim in sit amet orci. Sed lorem diam, scelerisque + non urna ac, egestas suscipit eros. Donec consequat tristique + lectus, quis placerat metus venenatis a. Nam fringilla lectus + finibus iaculis dictum. Maecenas in risus velit. Vestibulum ante + ipsum primis in faucibus orci luctus et ultrices posuere cubilia + curae; +

+

 

+

 

+

 

+

 

+
aaa
+
+
+
+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam volutpat + ex sit amet venenatis auctor. Nunc semper consequat diam sit amet + eleifend. Ut a viverra nulla. Fusce vel sapien quis metus vestibulum + dignissim in sit amet orci. Sed lorem diam, scelerisque non urna ac, + egestas suscipit eros. Donec consequat tristique lectus, quis placerat + metus venenatis a. Nam fringilla lectus finibus iaculis dictum. + Maecenas in risus velit. Vestibulum ante ipsum primis in faucibus orci + luctus et ultrices posuere cubilia curae; +

+

 

+

 

+

 

+

 

+
aaa
+
+
+ + diff --git a/dev/vscode-scrollable/change-event.html b/dev/vscode-scrollable/change-event.html new file mode 100644 index 000000000..a20a03897 --- /dev/null +++ b/dev/vscode-scrollable/change-event.html @@ -0,0 +1,85 @@ + + + + + + VSCode Elements + + + + + + + + + +
+ + +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam + volutpat ex sit amet venenatis auctor. Nunc semper consequat diam + sit amet eleifend. Ut a viverra nulla. Fusce vel sapien quis metus + vestibulum dignissim in sit amet orci. Sed lorem diam, scelerisque + non urna ac, egestas suscipit eros. Donec consequat tristique + lectus, quis placerat metus venenatis a. Nam fringilla lectus + finibus iaculis dictum. Maecenas in risus velit. Vestibulum ante + ipsum primis in faucibus orci luctus et ultrices posuere cubilia + curae; +

+

 

+

 

+

 

+

 

+
aaa
+
+
+
+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam volutpat + ex sit amet venenatis auctor. Nunc semper consequat diam sit amet + eleifend. Ut a viverra nulla. Fusce vel sapien quis metus vestibulum + dignissim in sit amet orci. Sed lorem diam, scelerisque non urna ac, + egestas suscipit eros. Donec consequat tristique lectus, quis placerat + metus venenatis a. Nam fringilla lectus finibus iaculis dictum. + Maecenas in risus velit. Vestibulum ante ipsum primis in faucibus orci + luctus et ultrices posuere cubilia curae; +

+

 

+

 

+

 

+

 

+
aaa
+
+
+ + + diff --git a/dev/vscode-single-select/select-mode/scroll-demo.html b/dev/vscode-single-select/select-mode/scroll-demo.html new file mode 100644 index 000000000..3cc561687 --- /dev/null +++ b/dev/vscode-single-select/select-mode/scroll-demo.html @@ -0,0 +1,54 @@ + + + + + + VSCode Elements + + + + + + + +
+ + + Option 1 + Option 2 + Option 3 + Option 4 + Option 5 + Option 6 + Option 7 + Option 8 + Option 9 + Option 10 + Option 11 + Option 12 + Option 13 + Option 14 + Option 15 + Option 16 + Option 17 + Option 18 + Option 19 + Option 20 + + +
+ + diff --git a/package-lock.json b/package-lock.json index b3e011d97..b59c63c1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "chokidar": "^4.0.3", "concurrently": "^9.1.2", "custom-element-vs-code-integration": "^1.5.0", + "custom-elements-manifest-deprecator": "^1.2.0", "deepmerge": "^4.3.1", "dotenv": "^16.4.7", "eslint-plugin-lit": "^2.0.0", @@ -5849,6 +5850,16 @@ "integrity": "sha512-j59k0ExGCKA8T6Mzaq+7axc+KVHwpEphEERU7VZ99260npu/p/9kd+Db+I3cGKxHkM5y6q5gnlXn00mzRQkX2A==", "dev": true }, + "node_modules/custom-elements-manifest-deprecator": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/custom-elements-manifest-deprecator/-/custom-elements-manifest-deprecator-1.2.0.tgz", + "integrity": "sha512-6dKX6ELfEC8BsccFuUtLQRX+X1ODXNxPNODeudyyYkWD3eBcolzehQjTivLPBx7FSHFi5BdwV2D4lwr8P7ifbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@prettier/sync": "^0.5.2" + } + }, "node_modules/data-uri-to-buffer": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", @@ -15871,6 +15882,15 @@ "integrity": "sha512-j59k0ExGCKA8T6Mzaq+7axc+KVHwpEphEERU7VZ99260npu/p/9kd+Db+I3cGKxHkM5y6q5gnlXn00mzRQkX2A==", "dev": true }, + "custom-elements-manifest-deprecator": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/custom-elements-manifest-deprecator/-/custom-elements-manifest-deprecator-1.2.0.tgz", + "integrity": "sha512-6dKX6ELfEC8BsccFuUtLQRX+X1ODXNxPNODeudyyYkWD3eBcolzehQjTivLPBx7FSHFi5BdwV2D4lwr8P7ifbw==", + "dev": true, + "requires": { + "@prettier/sync": "^0.5.2" + } + }, "data-uri-to-buffer": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", diff --git a/package.json b/package.json index 41e0465e0..c83dfb4eb 100644 --- a/package.json +++ b/package.json @@ -164,6 +164,7 @@ "chokidar": "^4.0.3", "concurrently": "^9.1.2", "custom-element-vs-code-integration": "^1.5.0", + "custom-elements-manifest-deprecator": "^1.2.0", "deepmerge": "^4.3.1", "dotenv": "^16.4.7", "eslint-plugin-lit": "^2.0.0", diff --git a/src/includes/vscode-select/styles.ts b/src/includes/vscode-select/styles.ts index 742c08a20..7976a3e1d 100644 --- a/src/includes/vscode-select/styles.ts +++ b/src/includes/vscode-select/styles.ts @@ -189,14 +189,20 @@ export default [ border-color: var(--vscode-focusBorder, #0078d4); } + .scrollable { + display: block; + max-height: 222px; + margin: 1px; + outline: none; + overflow: hidden; + } + .options { box-sizing: border-box; cursor: pointer; list-style: none; margin: 0; - max-height: 222px; - overflow: auto; - padding: 1px; + padding: 0; } .option { diff --git a/src/includes/vscode-select/vscode-select-base.ts b/src/includes/vscode-select/vscode-select-base.ts index bfb89eff0..9efd8c683 100644 --- a/src/includes/vscode-select/vscode-select-base.ts +++ b/src/includes/vscode-select/vscode-select-base.ts @@ -1,10 +1,5 @@ import {html, render, nothing, TemplateResult, PropertyValues} from 'lit'; -import { - eventOptions, - property, - queryAssignedElements, - state, -} from 'lit/decorators.js'; +import {property, queryAssignedElements, state} from 'lit/decorators.js'; import {classMap} from 'lit/directives/class-map.js'; import {ifDefined} from 'lit/directives/if-defined.js'; import {repeat} from 'lit/directives/repeat.js'; @@ -12,11 +7,13 @@ import {when} from 'lit/directives/when.js'; import '../../vscode-button/index.js'; import '../../vscode-option/index.js'; import {VscodeOption} from '../../vscode-option/index.js'; +import {stylePropertyMap} from '../style-property-map.js'; import {VscElement} from '../VscElement.js'; import {filterOptionsByPattern, highlightRanges} from './helpers.js'; import type {InternalOption, Option, FilterMethod} from './types.js'; import {OptionListController} from './OptionListController.js'; import {checkIcon} from './template-elements.js'; +import '../../vscode-scrollable/vscode-scrollable.js'; export const VISIBLE_OPTS = 10; export const OPT_HEIGHT = 22; @@ -409,10 +406,9 @@ export class VscodeSelectBase extends VscElement { } } - @eventOptions({passive: true}) - protected _onOptionListScroll(ev: Event) { - this._optionListScrollPos = (ev.target as HTMLUListElement).scrollTop; - } + private _onOptionListScroll = (ev: CustomEvent) => { + this._optionListScrollPos = ev.detail; + }; protected _onOptionMouseOver(ev: MouseEvent): void { if (this._isHoverForbidden) { @@ -634,8 +630,6 @@ export class VscodeSelectBase extends VscElement { tabindex="-1" @click=${this._onOptionClick} @mouseover=${this._onOptionMouseOver} - @scroll=${this._onOptionListScroll} - .scrollTop=${this._optionListScrollPos} > ${repeat( list, @@ -748,10 +742,32 @@ export class VscodeSelectBase extends VscElement { open: this.open, }; + const visibleOptions = + this._isSuggestedOptionVisible || this._opts.numOfVisibleOptions === 0 + ? this._opts.numOfVisibleOptions + 1 + : this._opts.numOfVisibleOptions; + + const scrollPaneHeight = Math.min( + visibleOptions * OPT_HEIGHT, + VISIBLE_OPTS * OPT_HEIGHT + ); + return html`
${this.position === 'above' ? this._renderDescription() : nothing} - ${this._renderOptions()} ${this._renderDropdownControls()} + + ${this._renderOptions()} ${this._renderDropdownControls()} + ${this.position === 'below' ? this._renderDescription() : nothing}
`; diff --git a/src/vscode-multi-select/vscode-multi-select.test.ts b/src/vscode-multi-select/vscode-multi-select.test.ts index 9d69724b9..138b027bf 100644 --- a/src/vscode-multi-select/vscode-multi-select.test.ts +++ b/src/vscode-multi-select/vscode-multi-select.test.ts @@ -1,9 +1,10 @@ -import {VscodeMultiSelect} from './index.js'; import {expect, fixture, html} from '@open-wc/testing'; +import {sendKeys} from '@web/test-runner-commands'; import sinon from 'sinon'; import '../vscode-option/index.js'; import {clickOnElement, moveMouseOnElement} from '../includes/test-helpers.js'; import {VscodeOption} from '../vscode-option/index.js'; +import {VscodeMultiSelect} from './index.js'; describe('vscode-multi-select', () => { it('is defined', () => { @@ -351,9 +352,35 @@ describe('vscode-multi-select', () => { expect(isValidAfter).to.be.false; }); + it('creates and select suggested option', async () => { + const el = await fixture( + html`Lorem` + ); + const createOptionHandlerSpy = sinon.spy(); + el.addEventListener( + 'vsc-multi-select-create-option', + createOptionHandlerSpy + ); + const changeHandlerSpy = sinon.spy(); + el.addEventListener('change', changeHandlerSpy); + + el.focus(); + await el.updateComplete; + await sendKeys({type: 'asdf'}); + await sendKeys({down: 'ArrowDown'}); + await sendKeys({down: 'Enter'}); + + expect(createOptionHandlerSpy.getCalls()[0].args[0].detail.value).to.eq( + 'asdf' + ); + expect(changeHandlerSpy.called).to.be.true; + expect(el.value).to.eql(['asdf']); + }); + it('selects multiple options with keyboard'); it('selectedIndexes sync with values'); - it('creates and select suggested option (enter key press)'); it( 'dispatch change event, set form value, manage required state (enter key press)' ); diff --git a/src/vscode-multi-select/vscode-multi-select.ts b/src/vscode-multi-select/vscode-multi-select.ts index 52813571e..cc3e0b649 100644 --- a/src/vscode-multi-select/vscode-multi-select.ts +++ b/src/vscode-multi-select/vscode-multi-select.ts @@ -237,7 +237,7 @@ export class VscodeMultiSelect this._dispatchChangeEvent(); const opCreateEvent: VscMultiSelectCreateOptionEvent = new CustomEvent( 'vsc-multi-select-create-option', - {detail: {value: this._options[nextIndex]?.value ?? ''}} + {detail: {value: this._opts.getOptionByIndex(nextIndex)?.value ?? ''}} ); this.dispatchEvent(opCreateEvent); this.open = false; diff --git a/src/vscode-scrollable/vscode-scrollable.test.ts b/src/vscode-scrollable/vscode-scrollable.test.ts index 8f9b53bcc..b42c09325 100644 --- a/src/vscode-scrollable/vscode-scrollable.test.ts +++ b/src/vscode-scrollable/vscode-scrollable.test.ts @@ -1,9 +1,64 @@ +import {resetMouse} from '@web/test-runner-commands'; +import {dragElement} from '../includes/test-helpers.js'; import {VscodeScrollable} from './index.js'; -import {expect} from '@open-wc/testing'; +import {expect, fixture, html} from '@open-wc/testing'; +import sinon from 'sinon'; describe('vscode-scrollable', () => { it('is defined', () => { const el = document.createElement('vscode-scrollable'); expect(el).to.instanceOf(VscodeScrollable); }); + + it('initializes scrollbar', async () => { + const el = await fixture( + html`
` + ); + + const thumb = + el.shadowRoot!.querySelector('.scrollbar-thumb'); + + expect(thumb?.offsetHeight).to.eq(50); + expect(thumb?.offsetTop).to.eq(0); + expect(el.scrollPos).to.eq(0); + }); + + it('drags thumb', async () => { + const el = await fixture( + html`
` + ); + const scrollEventSpy = sinon.spy(); + el.addEventListener('vsc-scrollable-scroll', scrollEventSpy); + + const thumb = el.shadowRoot!.querySelector( + '.scrollbar-thumb' + ) as HTMLDivElement; + + await dragElement(thumb, 0, 25); + await resetMouse(); + + expect(el.scrollPos).to.eq(50); + expect(scrollEventSpy.called).to.be.true; + }); + + it('scrolls content', async () => { + const el = await fixture( + html`
` + ); + const scrollEventSpy = sinon.spy(); + el.addEventListener('vsc-scrollable-scroll', scrollEventSpy); + + const wheelEvent = new WheelEvent('wheel', {deltaY: 55}); + el.dispatchEvent(wheelEvent); + await el.updateComplete; + + expect(el.scrollPos).to.eq(55); + expect(scrollEventSpy.called).to.be.true; + }); }); diff --git a/src/vscode-scrollable/vscode-scrollable.ts b/src/vscode-scrollable/vscode-scrollable.ts index b547cc4fb..5393e8a3c 100644 --- a/src/vscode-scrollable/vscode-scrollable.ts +++ b/src/vscode-scrollable/vscode-scrollable.ts @@ -1,14 +1,16 @@ -import {html, nothing, TemplateResult} from 'lit'; +import {html, nothing, PropertyValues, TemplateResult} from 'lit'; import {property, query, 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 styles from './vscode-scrollable.styles.js'; +export type VscScrollableScrollEvent = CustomEvent; + /** * @tag vscode-scrollable * - * @cssprop [--min-thumb-height=20px] - Scrollbar thumb minimum height + * @cssprop --min-thumb-height - (@deprecated) This css property is deprecated. Use the `minThumbSize` property instead. * @cssprop [--vscode-scrollbar-shadow=#000000] * @cssprop [--vscode-scrollbarSlider-background=rgba(121, 121, 121, 0.4)] * @cssprop [--vscode-scrollbarSlider-hoverBackground=rgba(100, 100, 100, 0.7)] @@ -18,24 +20,67 @@ import styles from './vscode-scrollable.styles.js'; export class VscodeScrollable extends VscElement { static override styles = styles; + /** + * By default, the scrollbar appears only when the cursor hovers over the + * component. With this option, the scrollbar will always be visible. + */ + @property({type: Boolean, reflect: true, attribute: 'always-visible'}) + alwaysVisible = false; + + /** + * Scrolling speed multiplier when pressing `Alt`. This property is designed to use the value of + * `editor.fastScrollSensitivity`, `workbench.list.fastScrollSensitivity` or + * `terminal.integrated.fastScrollSensitivity` depending on the context. + */ + @property({type: Number, attribute: 'fast-scroll-sensitivity'}) + fastScrollSensitivity = 5; + + /** + * This setting defines the scrollbar's minimum size when the component contains a large amount of content. + */ + @property({type: Number, attribute: 'min-thumb-size'}) + minThumbSize = 20; + + /** + * A multiplier to be used on the `deltaY` of the mouse wheel scroll events. This property is + * designed to use the value of `editor.mouseWheelScrollSensitivity`, + * `workbench.list.mouseWheelScrollSensitivity` or + * `terminal.integrated.mouseWheelScrollSensitivity` depending on the context. + */ + @property({type: Number, attribute: 'mouse-wheel-scroll-sensitivity'}) + mouseWheelScrollSensitivity = 1; + + /** + * Controls shadow visibility when content overflows. + */ @property({type: Boolean, reflect: true}) shadow = true; + /** + * It's true when `scrollPos` greater than 0 + */ @property({type: Boolean, reflect: true}) scrolled = false; + /** + * Scroll position. + */ @property({type: Number, attribute: 'scroll-pos'}) set scrollPos(val: number) { - this._scrollableContainer.scrollTop = val; + this._scrollPos = val; + this._updateScrollbar(); + this._updateThumbPosition(); + this.requestUpdate(); } get scrollPos(): number { - if (!this._scrollableContainer) { - return 0; - } - - return this._scrollableContainer.scrollTop; + return this._scrollPos; } + private _scrollPos = 0; + + /** + * The maximum amount of the `scrollPos`. + */ @property({type: Number, attribute: 'scroll-max'}) get scrollMax(): number { if (!this._scrollableContainer) { @@ -82,6 +127,13 @@ export class VscodeScrollable extends VscElement { private _scrollbarVisible = true; private _scrollbarTrackZ = 0; + constructor() { + super(); + this.addEventListener('mouseover', this._handleComponentMouseOver); + this.addEventListener('mouseout', this._handleComponentMouseOut); + this.addEventListener('wheel', this._handleComponentWheel); + } + override connectedCallback(): void { super.connectedCallback(); @@ -93,16 +145,10 @@ export class VscodeScrollable extends VscElement { this.requestUpdate(); this.updateComplete.then(() => { - this._scrollableContainer.addEventListener( - 'scroll', - this._onScrollableContainerScroll.bind(this) - ); this._hostResizeObserver.observe(this); this._contentResizeObserver.observe(this._contentElement); + this._updateThumbPosition(); }); - - this.addEventListener('mouseover', this._onComponentMouseOverBound); - this.addEventListener('mouseout', this._onComponentMouseOutBound); } override disconnectedCallback(): void { @@ -112,24 +158,34 @@ export class VscodeScrollable extends VscElement { this._hostResizeObserver.disconnect(); this._contentResizeObserver.unobserve(this._contentElement); this._contentResizeObserver.disconnect(); + } - this.removeEventListener('mouseover', this._onComponentMouseOverBound); - this.removeEventListener('mouseout', this._onComponentMouseOutBound); + protected override firstUpdated(_changedProperties: PropertyValues): void { + this._updateThumbPosition(); } private _resizeObserverCallback = () => { this._updateScrollbar(); + this._updateThumbPosition(); }; + private _calcThumbHeight() { + const componentHeight = this.offsetHeight; + const contentHeight = this._contentElement?.offsetHeight ?? 0; + const proposedSize = componentHeight * (componentHeight / contentHeight); + + return Math.max(this.minThumbSize, proposedSize); + } + private _updateScrollbar() { - const compCr = this.getBoundingClientRect(); - const contentCr = this._contentElement.getBoundingClientRect(); + const contentHeight = this._contentElement?.offsetHeight ?? 0; + const componentHeight = this.offsetHeight; - if (compCr.height >= contentCr.height) { + if (componentHeight >= contentHeight) { this._scrollbarVisible = false; } else { this._scrollbarVisible = true; - this._thumbHeight = compCr.height * (compCr.height / contentCr.height); + this._thumbHeight = this._calcThumbHeight(); } this.requestUpdate(); @@ -156,11 +212,65 @@ export class VscodeScrollable extends VscElement { this.requestUpdate(); } - private _onSlotChange = () => { + private _updateThumbPosition() { + if (!this._scrollableContainer) { + return; + } + + const scrollTop = this._scrollPos; + this.scrolled = scrollTop > 0; + + const componentH = this.offsetHeight; + const thumbH = this._thumbHeight; + const contentH = this._contentElement.offsetHeight; + + const overflown = contentH - componentH; + const ratio = scrollTop / overflown; + const thumbYMax = componentH - thumbH; + + this._thumbY = Math.min(ratio * (componentH - thumbH), thumbYMax); + } + + private _calculateScrollPosFromThumbPos(scrollPos: number) { + const cmpH = this.getBoundingClientRect().height; + const thumbH = this._scrollThumbElement.getBoundingClientRect().height; + const contentH = this._contentElement.getBoundingClientRect().height; + const rawScrollPos = (scrollPos / (cmpH - thumbH)) * (contentH - cmpH); + + return this._limitScrollPos(rawScrollPos); + } + + private _limitScrollPos(newPos: number) { + if (newPos < 0) { + return 0; + } else if (newPos > this.scrollMax) { + return this.scrollMax; + } else { + return newPos; + } + } + + private _limitThumbPos(newPos: number) { + const cmpH = this.getBoundingClientRect().height; + const thumbH = this._scrollThumbElement.getBoundingClientRect().height; + + if (newPos < 0) { + return 0; + } else if (newPos > cmpH - thumbH) { + return cmpH - thumbH; + } else { + return newPos; + } + } + + //#region event handlers + private _handleSlotChange = () => { + this._updateScrollbar(); + this._updateThumbPosition(); this._zIndexFix(); }; - private _onScrollThumbMouseDown(event: MouseEvent) { + private _handleScrollThumbMouseDown(event: MouseEvent) { const cmpCr = this.getBoundingClientRect(); const thCr = this._scrollThumbElement.getBoundingClientRect(); @@ -169,35 +279,24 @@ export class VscodeScrollable extends VscElement { this._isDragging = true; this._thumbActive = true; - document.addEventListener('mousemove', this._onScrollThumbMouseMoveBound); - document.addEventListener('mouseup', this._onScrollThumbMouseUpBound); + document.addEventListener('mousemove', this._handleScrollThumbMouseMove); + document.addEventListener('mouseup', this._handleScrollThumbMouseUp); } - private _onScrollThumbMouseMove(event: MouseEvent) { - const predictedPos = + private _handleScrollThumbMouseMove = (event: MouseEvent) => { + const rawThumbPos = this._scrollThumbStartY + (event.screenY - this._mouseStartY); - let nextPos = 0; - const cmpH = this.getBoundingClientRect().height; - const thumbH = this._scrollThumbElement.getBoundingClientRect().height; - const contentH = this._contentElement.getBoundingClientRect().height; + this._thumbY = this._limitThumbPos(rawThumbPos); + this.scrollPos = this._calculateScrollPosFromThumbPos(this._thumbY); - if (predictedPos < 0) { - nextPos = 0; - } else if (predictedPos > cmpH - thumbH) { - nextPos = cmpH - thumbH; - } else { - nextPos = predictedPos; - } - - this._thumbY = nextPos; - this._scrollableContainer.scrollTop = - (nextPos / (cmpH - thumbH)) * (contentH - cmpH); - } - - private _onScrollThumbMouseMoveBound = - this._onScrollThumbMouseMove.bind(this); + this.dispatchEvent( + new CustomEvent('vsc-scrollable-scroll', { + detail: this.scrollPos, + }) + ); + }; - private _onScrollThumbMouseUp(event: MouseEvent) { + private _handleScrollThumbMouseUp = (event: MouseEvent) => { this._isDragging = false; this._thumbActive = false; @@ -210,44 +309,55 @@ export class VscodeScrollable extends VscElement { this._thumbVisible = false; } - document.removeEventListener( - 'mousemove', - this._onScrollThumbMouseMoveBound - ); - document.removeEventListener('mouseup', this._onScrollThumbMouseUpBound); - } - - private _onScrollThumbMouseUpBound = this._onScrollThumbMouseUp.bind(this); - - private _onScrollableContainerScroll() { - const scrollTop = this._scrollableContainer.scrollTop; - this.scrolled = scrollTop > 0; - - const cmpH = this.getBoundingClientRect().height; - const thumbH = this._scrollThumbElement.getBoundingClientRect().height; - const contentH = this._contentElement.getBoundingClientRect().height; - - const overflown = contentH - cmpH; - const ratio = scrollTop / overflown; - - this._thumbY = ratio * (cmpH - thumbH); - } + document.removeEventListener('mousemove', this._handleScrollThumbMouseMove); + document.removeEventListener('mouseup', this._handleScrollThumbMouseUp); + }; - private _onComponentMouseOver() { + private _handleComponentMouseOver = () => { this._thumbVisible = true; this._thumbFade = false; - } - - private _onComponentMouseOverBound = this._onComponentMouseOver.bind(this); + }; - private _onComponentMouseOut() { + private _handleComponentMouseOut = () => { if (!this._thumbActive) { this._thumbVisible = false; this._thumbFade = true; } + }; + + private _handleComponentWheel = (ev: WheelEvent) => { + ev.preventDefault(); + + const multiplier = ev.altKey + ? this.mouseWheelScrollSensitivity * this.fastScrollSensitivity + : this.mouseWheelScrollSensitivity; + + this.scrollPos = this._limitScrollPos( + this.scrollPos + ev.deltaY * multiplier + ); + this.dispatchEvent( + new CustomEvent('vsc-scrollable-scroll', { + detail: this.scrollPos, + }) + ); + }; + + private _handleScrollbarTrackPress(ev: PointerEvent) { + if (ev.target !== ev.currentTarget) { + return; + } + + this._thumbY = ev.offsetY - this._thumbHeight / 2; + this.scrollPos = this._calculateScrollPosFromThumbPos(this._thumbY); } - private _onComponentMouseOutBound = this._onComponentMouseOut.bind(this); + private _handleScrollableContainerScroll = (ev: Event) => { + if (ev.currentTarget) { + this.scrollPos = (ev.currentTarget as HTMLDivElement).scrollTop; + } + }; + + //#endregion override render(): TemplateResult { return html` @@ -256,6 +366,8 @@ export class VscodeScrollable extends VscElement { .style=${stylePropertyMap({ userSelect: this._isDragging ? 'none' : 'auto', })} + .scrollTop=${this._scrollPos} + @scroll=${this._handleScrollableContainerScroll} >
- +
`; @@ -298,4 +411,8 @@ declare global { interface HTMLElementTagNameMap { 'vscode-scrollable': VscodeScrollable; } + + interface GlobalEventHandlersEventMap { + 'vsc-scrollable-scroll': VscScrollableScrollEvent; + } } diff --git a/src/vscode-single-select/vscode-single-select.test.ts b/src/vscode-single-select/vscode-single-select.test.ts index 91ebf6c69..5d569409a 100644 --- a/src/vscode-single-select/vscode-single-select.test.ts +++ b/src/vscode-single-select/vscode-single-select.test.ts @@ -4,6 +4,7 @@ import type {VscodeOption} from '../vscode-option/vscode-option.js'; import {VscodeSingleSelect} from './index.js'; import {aTimeout, expect, fixture, html} from '@open-wc/testing'; import sinon from 'sinon'; +import {VscodeScrollable} from '../main.js'; const ignoredAttributes = [ 'aria-label', @@ -266,8 +267,9 @@ describe('vscode-single-select', () => { await el.updateComplete; await sendKeys({press: 'Enter'}); - const options = el.shadowRoot?.querySelector('.options'); - expect(options?.scrollTop).to.eq(220); + const scrollable = + el.shadowRoot?.querySelector('.scrollable'); + expect(scrollable?.scrollPos).to.eq(220); }); //#region keyboard interactions @@ -418,8 +420,9 @@ describe('vscode-single-select', () => { await el.updateComplete; const dropdown = el.shadowRoot?.querySelector('.dropdown'); + const scrollable = el.shadowRoot?.querySelector('.scrollable'); - expect(dropdown).lightDom.to.eq( + expect(scrollable).lightDom.to.eq( `
  • @@ -624,9 +627,9 @@ describe('vscode-single-select', () => { await clickOnElement(el); await sendKeys({type: 'Test'}); - const dropdown = el.shadowRoot?.querySelector('.dropdown'); + const scrollable = el.shadowRoot?.querySelector('.scrollable'); - expect(dropdown).lightDom.to.eq( + expect(scrollable).lightDom.to.eq( `
    • @@ -651,9 +654,9 @@ describe('vscode-single-select', () => { await el.updateComplete; await sendKeys({type: 'lo'}); - const dropdown = el.shadowRoot?.querySelector('.dropdown'); + const scrollable = el.shadowRoot?.querySelector('.scrollable'); - expect(dropdown).lightDom.to.eq( + expect(scrollable).lightDom.to.eq( `
      • @@ -684,9 +687,9 @@ describe('vscode-single-select', () => { await el.updateComplete; await sendKeys({type: 'Sit'}); - const dropdown = el.shadowRoot?.querySelector('.dropdown'); + const scrollable = el.shadowRoot?.querySelector('.scrollable'); - expect(dropdown).lightDom.to.eq( + expect(scrollable).lightDom.to.eq( `
        • Add "Sit"
        • @@ -714,9 +717,9 @@ describe('vscode-single-select', () => { await sendKeys({down: 'ArrowDown'}); await sendKeys({down: 'ArrowDown'}); - const dropdown = el.shadowRoot?.querySelector('.dropdown'); + const scrollable = el.shadowRoot?.querySelector('.scrollable'); - expect(dropdown).lightDom.to.eq( + expect(scrollable).lightDom.to.eq( `
          • Dolor
          • @@ -1154,6 +1157,33 @@ describe('vscode-single-select', () => { it('End is pressed'); }); + it('creates and select suggested option', async () => { + const el = await fixture( + html`Lorem` + ); + const createOptionHandlerSpy = sinon.spy(); + el.addEventListener( + 'vsc-single-select-create-option', + createOptionHandlerSpy + ); + const changeHandlerSpy = sinon.spy(); + el.addEventListener('change', changeHandlerSpy); + + el.focus(); + await el.updateComplete; + await sendKeys({type: 'asdf'}); + await sendKeys({down: 'ArrowDown'}); + await sendKeys({down: 'Enter'}); + + expect(createOptionHandlerSpy.getCalls()[0].args[0].detail.value).to.eq( + 'asdf' + ); + expect(changeHandlerSpy.called).to.be.true; + expect(el.value).to.eql('asdf'); + }); + //keyboard navigation it('selects previous option with keyboard'); it('selects next option with keyboard');