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(
`
D o l or
@@ -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');