Merge branch 'next' into konnorrogers/fix-tabbable-select

pull/1813/head
Cory LaViska 2024-01-23 09:45:13 -06:00 zatwierdzone przez GitHub
commit 23ce8d3e7a
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
17 zmienionych plików z 351 dodań i 211 usunięć

Wyświetl plik

@ -1,6 +1,7 @@
import * as path from 'path';
import { customElementJetBrainsPlugin } from 'custom-element-jet-brains-integration';
import { customElementVsCodePlugin } from 'custom-element-vs-code-integration';
import { customElementVuejsPlugin } from 'custom-element-vuejs-integration';
import { parse } from 'comment-parser';
import { pascalCase } from 'pascal-case';
import commandLineArgs from 'command-line-args';
@ -218,6 +219,12 @@ export default {
url: `https://shoelace.style/components/${tag.replace('sl-', '')}`
};
}
}),
customElementVuejsPlugin({
outdir: './dist/types/vue',
fileName: 'index.d.ts',
componentTypePath: (_, tag) => `../../components/${tag.replace('sl-', '')}/${tag.replace('sl-', '')}.component.js`
})
]
};

Wyświetl plik

@ -417,7 +417,7 @@ const App = () => (
### Loading
Use the `loading` attribute to make a button busy. The width will remain the same as before, preventing adjacent elements from moving around. Clicks will be suppressed until the loading state is removed.
Use the `loading` attribute to make a button busy. The width will remain the same as before, preventing adjacent elements from moving around.
```html:preview
<sl-button variant="default" loading>Default</sl-button>

Wyświetl plik

@ -35,35 +35,22 @@ If you'd rather not use the CDN for assets, you can create a build task that cop
## Configuration
You'll need to tell Vue to ignore Shoelace components. This is pretty easy because they all start with `sl-`.
```js
import { fileURLToPath, URL } from 'url';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
isCustomElement: tag => tag.startsWith('sl-')
}
}
})
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
});
```
If you haven't configured your Vue.js project to work with custom elements/web components, follow [the instructions here](https://vuejs.org/guide/extras/web-components.html#using-custom-elements-in-vue) based on your project type to ensure your project will not throw an error when it encounters a custom element.
Now you can start using Shoelace components in your app!
## Types
Once you have configured your application for custom elements, you should be able to use Shoelace in your application without it causing any errors. Unfortunately, this doesn't register the custom elements to behave like components built using Vue. To provide autocomplete information and type safety for your components, you can import the Shoelace Vue types into your `tsconfig.json` to get better integration in your standard Vue and JSX templates.
```json
{
"compilerOptions": {
"types": ["@shoelace-style/shoelace/dist/types/vue"]
}
}
```
## Usage
### QR code generator example
@ -126,7 +113,7 @@ Are you using Shoelace with Vue? [Help us improve this page!](https://github.com
### Slots
To use Shoelace components with slots, follow the Vue documentation on using [slots with custom elements](https://vuejs.org/guide/extras/web-components.html#building-custom-elements-with-vue).
Slots in Shoelace/web components are functionally the same as basic slots in Vue. Slots can be assigned to elements using the `slot` attribute followed by the name of the slot it is being assigned to.
Here is an example:

Wyświetl plik

@ -14,17 +14,25 @@ New versions of Shoelace are released as-needed and generally occur when a criti
## Next
- Added the `hover-bridge` feature to `<sl-popup>` to support better tooltip accessibility [#1734]
- Added the `loading` attribute and the `spinner` and `spinner__base` part to `<sl-menu-item>` [#1700]
- Fixed files that did not have `.js` extensions. [#1770]
- Fixed `<sl-dialog>` not accounting for elements with hidden dialog controls like `<video>` [#1755]
- Added the `loading` attribute and the `spinner` and `spinner__base` part to `<sl-menu-item>` [#1700]
- Fixed focus trapping not scrolling elements into view. [#1750]
- Fixed more performance issues with focus trapping performance. [#1750]
- Added the `hover-bridge` feature to `<sl-popup>` to support better tooltip accessibility [#1734]
- Fixed a bug in `<sl-input>` and `<sl-textarea>` that made it work differently from `<input>` and `<textarea>` when using defaults [#1746]
- Fixed a bug in `<sl-select>` that prevented it from closing when tabbing to another select inside a shadow root [#1763]
- Fixed a bug in `<sl-spinner>` that caused the animation to appear strange in certain circumstances [#1787]
- Fixed a bug in `<sl-dialog>` with focus trapping [#1813]
- Fixed a bug that caused form controls to submit even after they were removed from the DOM [#1823]
- Fixed a bug that caused empty `<sl-radio-group>` elements to log an error in the console [#1795]
- Fixed a bug that caused modal scroll locking to conflict with the `scrollbar-gutter` property [#1805]
- Fixed a bug in `<sl-option>` that caused slotted content to show up when calling `getTextLabel()` [#1730]
- Fixed a bug in `<sl-color-picker>` that caused picker values to not match the preview color [#1831]
- Fixed a bug in `<sl-carousel>` where pagination dots don't update when swiping slide in iOS Safari [#1748]
- Fixed a bug in`<sl-carousel>` where trying to swipe doesn't change the slide in Firefox for Android [#1748]
- Improved the accessibility of `<sl-tooltip>` so they persist when hovering over the tooltip and dismiss when pressing [[Esc]] [#1734]
- Removed the scroll controller from the experimental `<sl-carousel>` and moved all mouse related logic into the component [#1748]
## 2.12.0

42
package-lock.json wygenerowano
Wyświetl plik

@ -39,6 +39,7 @@
"cspell": "^6.18.1",
"custom-element-jet-brains-integration": "^1.4.0",
"custom-element-vs-code-integration": "^1.2.1",
"custom-element-vuejs-integration": "^1.0.0",
"del": "^7.1.0",
"download": "^8.0.0",
"esbuild": "^0.19.4",
@ -6469,6 +6470,30 @@
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/custom-element-vuejs-integration": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/custom-element-vuejs-integration/-/custom-element-vuejs-integration-1.0.0.tgz",
"integrity": "sha512-L938mWj4c3gkBjoFKKtiBM6deVVzWf77EYSpt5622Cf1CYgeVysQdvqVWZ0HGWwoQe8t9gWLyAwBeELMMckkvA==",
"dev": true,
"dependencies": {
"prettier": "^2.7.1"
}
},
"node_modules/custom-element-vuejs-integration/node_modules/prettier": {
"version": "2.8.8",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz",
"integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==",
"dev": true,
"bin": {
"prettier": "bin-prettier.js"
},
"engines": {
"node": ">=10.13.0"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/custom-elements-manifest": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/custom-elements-manifest/-/custom-elements-manifest-1.0.0.tgz",
@ -23658,6 +23683,23 @@
}
}
},
"custom-element-vuejs-integration": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/custom-element-vuejs-integration/-/custom-element-vuejs-integration-1.0.0.tgz",
"integrity": "sha512-L938mWj4c3gkBjoFKKtiBM6deVVzWf77EYSpt5622Cf1CYgeVysQdvqVWZ0HGWwoQe8t9gWLyAwBeELMMckkvA==",
"dev": true,
"requires": {
"prettier": "^2.7.1"
},
"dependencies": {
"prettier": {
"version": "2.8.8",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz",
"integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==",
"dev": true
}
}
},
"custom-elements-manifest": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/custom-elements-manifest/-/custom-elements-manifest-1.0.0.tgz",

Wyświetl plik

@ -49,6 +49,7 @@
"start": "node scripts/build.js --serve",
"build": "node scripts/build.js",
"verify": "npm run prettier:check && npm run lint && npm run build && npm run test",
"postinstall": "npx playwright install",
"prepublishOnly": "npm run verify",
"prettier": "prettier --write --log-level=warn .",
"prettier:check": "prettier --check --log-level=warn .",
@ -96,6 +97,7 @@
"cspell": "^6.18.1",
"custom-element-jet-brains-integration": "^1.4.0",
"custom-element-vs-code-integration": "^1.2.1",
"custom-element-vuejs-integration": "^1.0.0",
"del": "^7.1.0",
"download": "^8.0.0",
"esbuild": "^0.19.4",

Wyświetl plik

@ -3,13 +3,13 @@ import '../../internal/scrollend-polyfill.js';
import { AutoplayController } from './autoplay-controller.js';
import { clamp } from '../../internal/math.js';
import { classMap } from 'lit/directives/class-map.js';
import { eventOptions, property, query, state } from 'lit/decorators.js';
import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize.js';
import { map } from 'lit/directives/map.js';
import { prefersReducedMotion } from '../../internal/animate.js';
import { property, query, state } from 'lit/decorators.js';
import { range } from 'lit/directives/range.js';
import { ScrollController } from './scroll-controller.js';
import { waitForEvent } from '../../internal/event.js';
import { watch } from '../../internal/watch.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
import SlIcon from '../icon/icon.component.js';
@ -86,8 +86,11 @@ export default class SlCarousel extends ShoelaceElement {
// The index of the active slide
@state() activeSlide = 0;
@state() scrolling = false;
@state() dragging = false;
private autoplayController = new AutoplayController(this, () => this.next());
private scrollController = new ScrollController(this);
private intersectionObserver: IntersectionObserver; // determines which slide is displayed
// A map containing the state of all the slides
private readonly intersectionObserverEntries = new Map<Element, IntersectionObserverEntry>();
@ -216,8 +219,80 @@ export default class SlCarousel extends ShoelaceElement {
}
}
private handleMouseDragStart(event: PointerEvent) {
const canDrag = this.mouseDragging && event.button === 0;
if (canDrag) {
event.preventDefault();
document.addEventListener('pointermove', this.handleMouseDrag, { capture: true, passive: true });
document.addEventListener('pointerup', this.handleMouseDragEnd, { capture: true, once: true });
}
}
private handleMouseDrag = (event: PointerEvent) => {
if (!this.dragging) {
// Start dragging if it hasn't yet
this.scrollContainer.style.setProperty('scroll-snap-type', 'none');
this.dragging = true;
}
this.scrollContainer.scrollBy({
left: -event.movementX,
top: -event.movementY,
behavior: 'instant'
});
};
private handleMouseDragEnd = () => {
const scrollContainer = this.scrollContainer;
document.removeEventListener('pointermove', this.handleMouseDrag, { capture: true });
// get the current scroll position
const startLeft = scrollContainer.scrollLeft;
const startTop = scrollContainer.scrollTop;
// remove the scroll-snap-type property so that the browser will snap the slide to the correct position
scrollContainer.style.removeProperty('scroll-snap-type');
// fix(safari): forcing a style recalculation doesn't seem to immediately update the scroll
// position in Safari. Setting "overflow" to "hidden" should force this behavior.
scrollContainer.style.setProperty('overflow', 'hidden');
// get the final scroll position to the slide snapped by the browser
const finalLeft = scrollContainer.scrollLeft;
const finalTop = scrollContainer.scrollTop;
// restore the scroll position to the original one, so that it can be smoothly animated if needed
scrollContainer.style.removeProperty('overflow');
scrollContainer.style.setProperty('scroll-snap-type', 'none');
scrollContainer.scrollTo({ left: startLeft, top: startTop, behavior: 'instant' });
requestAnimationFrame(async () => {
if (startLeft !== finalLeft || startTop !== finalTop) {
scrollContainer.scrollTo({
left: finalLeft,
top: finalTop,
behavior: prefersReducedMotion() ? 'auto' : 'smooth'
});
await waitForEvent(scrollContainer, 'scrollend');
}
scrollContainer.style.removeProperty('scroll-snap-type');
this.dragging = false;
this.handleScrollEnd();
});
};
@eventOptions({ passive: true })
private handleScroll() {
this.scrolling = true;
}
private handleScrollEnd() {
const slides = this.getSlides();
if (!this.scrolling || this.dragging) return;
const entries = [...this.intersectionObserverEntries.values()];
const firstIntersecting: IntersectionObserverEntry | undefined = entries.find(entry => entry.isIntersecting);
@ -226,13 +301,17 @@ export default class SlCarousel extends ShoelaceElement {
const clonePosition = Number(firstIntersecting.target.getAttribute('data-clone'));
// Scrolls to the original slide without animating, so the user won't notice that the position has changed
this.goToSlide(clonePosition, 'auto');
this.goToSlide(clonePosition, 'instant');
} else if (firstIntersecting) {
const slides = this.getSlides();
// Update the current index based on the first visible slide
const slideIndex = slides.indexOf(firstIntersecting.target as SlCarouselItem);
// Set the index to the first "snappable" slide
this.activeSlide = Math.ceil(slideIndex / this.slidesPerMove) * this.slidesPerMove;
}
this.scrolling = false;
}
private isCarouselItem(node: Node): node is SlCarouselItem {
@ -350,11 +429,6 @@ export default class SlCarousel extends ShoelaceElement {
}
}
@watch('mouseDragging')
handleMouseDraggingChange() {
this.scrollController.mouseDragging = this.mouseDragging;
}
/**
* Move the carousel backward by `slides-per-move` slides.
*
@ -380,7 +454,7 @@ export default class SlCarousel extends ShoelaceElement {
* @param behavior - The behavior used for scrolling.
*/
goToSlide(index: number, behavior: ScrollBehavior = 'smooth') {
const { slidesPerPage, loop, scrollContainer } = this;
const { slidesPerPage, loop } = this;
const slides = this.getSlides();
const slidesWithClones = this.getSlides({ excludeClones: false });
@ -399,18 +473,31 @@ export default class SlCarousel extends ShoelaceElement {
const nextSlideIndex = clamp(index + (loop ? slidesPerPage : 0), 0, slidesWithClones.length - 1);
const nextSlide = slidesWithClones[nextSlideIndex];
const scrollContainerRect = scrollContainer.getBoundingClientRect();
const nextSlideRect = nextSlide.getBoundingClientRect();
this.scrollToSlide(nextSlide, prefersReducedMotion() ? 'auto' : behavior);
}
scrollContainer.scrollTo({
left: nextSlideRect.left - scrollContainerRect.left + scrollContainer.scrollLeft,
top: nextSlideRect.top - scrollContainerRect.top + scrollContainer.scrollTop,
behavior: prefersReducedMotion() ? 'auto' : behavior
});
private async scrollToSlide(slide: HTMLElement, behavior: ScrollBehavior = 'smooth') {
const scrollContainer = this.scrollContainer;
const scrollContainerRect = scrollContainer.getBoundingClientRect();
const nextSlideRect = slide.getBoundingClientRect();
const nextLeft = nextSlideRect.left - scrollContainerRect.left;
const nextTop = nextSlideRect.top - scrollContainerRect.top;
// If the slide is already in view, don't need to scroll
if (nextLeft !== scrollContainer.scrollLeft || nextTop !== scrollContainer.scrollTop) {
scrollContainer.scrollTo({
left: nextLeft + scrollContainer.scrollLeft,
top: nextTop + scrollContainer.scrollTop,
behavior
});
await waitForEvent(scrollContainer, 'scrollend');
}
}
render() {
const { scrollController, slidesPerMove } = this;
const { slidesPerMove, scrolling } = this;
const pagesCount = this.getPageCount();
const currentPage = this.getCurrentPage();
const prevEnabled = this.canScrollPrev();
@ -425,13 +512,16 @@ export default class SlCarousel extends ShoelaceElement {
class="${classMap({
carousel__slides: true,
'carousel__slides--horizontal': this.orientation === 'horizontal',
'carousel__slides--vertical': this.orientation === 'vertical'
'carousel__slides--vertical': this.orientation === 'vertical',
'carousel__slides--dragging': this.dragging
})}"
style="--slides-per-page: ${this.slidesPerPage};"
aria-busy="${scrollController.scrolling ? 'true' : 'false'}"
aria-busy="${scrolling ? 'true' : 'false'}"
aria-atomic="true"
tabindex="0"
@keydown=${this.handleKeyDown}
@mousedown="${this.handleMouseDragStart}"
@scroll="${this.handleScroll}"
@scrollend=${this.handleScrollEnd}
>
<slot></slot>

Wyświetl plik

@ -79,9 +79,7 @@ export default css`
overflow-x: hidden;
}
.carousel__slides--dragging,
.carousel__slides--dropping {
scroll-snap-type: unset;
.carousel__slides--dragging {
}
:host([vertical]) ::slotted(sl-carousel-item) {

Wyświetl plik

@ -1,12 +1,17 @@
import '../../../dist/shoelace.js';
import { clickOnElement } from '../../internal/test.js';
import { clickOnElement, dragElement, moveMouseOnElement } from '../../internal/test.js';
import { expect, fixture, html, oneEvent } from '@open-wc/testing';
import { map } from 'lit/directives/map.js';
import { range } from 'lit/directives/range.js';
import { resetMouse } from '@web/test-runner-commands';
import sinon from 'sinon';
import type SlCarousel from './carousel.js';
describe('<sl-carousel>', () => {
afterEach(async () => {
await resetMouse();
});
it('should render a carousel with default configuration', async () => {
// Arrange
const el = await fixture(html`
@ -409,6 +414,53 @@ describe('<sl-carousel>', () => {
});
});
describe('when `mouse-dragging` attribute is provided', () => {
// TODO(alenaksu): skipping because failing in webkit, PointerEvent.movementX and PointerEvent.movementY seem to return incorrect values
it.skip('should be possible to drag the carousel using the mouse', async () => {
// Arrange
const el = await fixture<SlCarousel>(html`
<sl-carousel mouse-dragging>
<sl-carousel-item>Node 1</sl-carousel-item>
<sl-carousel-item>Node 2</sl-carousel-item>
<sl-carousel-item>Node 3</sl-carousel-item>
</sl-carousel>
`);
// Act
await dragElement(el, -Math.round(el.offsetWidth * 0.75));
await oneEvent(el.scrollContainer, 'scrollend');
await dragElement(el, -Math.round(el.offsetWidth * 0.75));
await oneEvent(el.scrollContainer, 'scrollend');
await el.updateComplete;
// Assert
expect(el.activeSlide).to.be.equal(2);
});
it('should be possible to interact with clickable elements', async () => {
// Arrange
const el = await fixture<SlCarousel>(html`
<sl-carousel mouse-dragging>
<sl-carousel-item><button>click me</button></sl-carousel-item>
<sl-carousel-item>Node 2</sl-carousel-item>
<sl-carousel-item>Node 3</sl-carousel-item>
</sl-carousel>
`);
const button = el.querySelector('button')!;
const clickSpy = sinon.spy();
button.addEventListener('click', clickSpy);
// Act
await moveMouseOnElement(button);
await clickOnElement(button);
// Assert
expect(clickSpy).to.have.been.called;
});
});
describe('Navigation controls', () => {
describe('when the user clicks the next button', () => {
it('should scroll to the next slide', async () => {

Wyświetl plik

@ -1,140 +0,0 @@
import { prefersReducedMotion } from '../../internal/animate.js';
import { waitForEvent } from '../../internal/event.js';
import type { ReactiveController, ReactiveElement } from 'lit';
interface ScrollHost extends ReactiveElement {
scrollContainer: HTMLElement;
}
/**
* A controller for handling scrolling and mouse dragging.
*/
export class ScrollController<T extends ScrollHost> implements ReactiveController {
private host: T;
dragging = false;
scrolling = false;
mouseDragging = false;
constructor(host: T) {
this.host = host;
host.addController(this);
}
async hostConnected() {
const host = this.host;
await host.updateComplete;
const scrollContainer = host.scrollContainer;
scrollContainer.addEventListener('scroll', this.handleScroll, { passive: true });
scrollContainer.addEventListener('scrollend', this.handleScrollEnd, true);
scrollContainer.addEventListener('pointerdown', this.handlePointerDown);
scrollContainer.addEventListener('pointerup', this.handlePointerUp);
scrollContainer.addEventListener('pointercancel', this.handlePointerUp);
}
hostDisconnected(): void {
const host = this.host;
const scrollContainer = host.scrollContainer;
scrollContainer.removeEventListener('scroll', this.handleScroll);
scrollContainer.removeEventListener('scrollend', this.handleScrollEnd, true);
scrollContainer.removeEventListener('pointerdown', this.handlePointerDown);
scrollContainer.removeEventListener('pointerup', this.handlePointerUp);
scrollContainer.removeEventListener('pointercancel', this.handlePointerUp);
}
handleScroll = () => {
if (!this.scrolling) {
this.scrolling = true;
this.host.requestUpdate();
}
};
handleScrollEnd = () => {
if (this.scrolling && !this.dragging) {
this.scrolling = false;
this.host.requestUpdate();
}
};
handlePointerDown = (event: PointerEvent) => {
// Do not handle drag for touch interactions as scroll is natively supported
if (event.pointerType === 'touch') {
return;
}
const canDrag = this.mouseDragging && event.button === 0;
if (canDrag) {
event.preventDefault();
this.host.scrollContainer.addEventListener('pointermove', this.handlePointerMove);
}
};
handlePointerMove = (event: PointerEvent) => {
const scrollContainer = this.host.scrollContainer;
const hasMoved = !!event.movementX || !!event.movementY;
if (!this.dragging && hasMoved) {
// Start dragging if it hasn't yet
scrollContainer.setPointerCapture(event.pointerId);
this.handleDragStart();
} else if (scrollContainer.hasPointerCapture(event.pointerId)) {
// Ignore pointers that we are not tracking
this.handleDrag(event);
}
};
handlePointerUp = (event: PointerEvent) => {
this.host.scrollContainer.releasePointerCapture(event.pointerId);
this.handleDragEnd();
};
handleDragStart() {
const host = this.host;
this.dragging = true;
host.scrollContainer.style.setProperty('scroll-snap-type', 'unset');
host.requestUpdate();
}
handleDrag(event: PointerEvent) {
this.host.scrollContainer.scrollBy({
left: -event.movementX,
top: -event.movementY
});
}
handleDragEnd() {
const host = this.host;
const scrollContainer = host.scrollContainer;
scrollContainer.removeEventListener('pointermove', this.handlePointerMove);
const startLeft = scrollContainer.scrollLeft;
const startTop = scrollContainer.scrollTop;
scrollContainer.style.removeProperty('scroll-snap-type');
const finalLeft = scrollContainer.scrollLeft;
const finalTop = scrollContainer.scrollTop;
scrollContainer.style.setProperty('scroll-snap-type', 'unset');
scrollContainer.scrollTo({ left: startLeft, top: startTop, behavior: 'auto' });
scrollContainer.scrollTo({ left: finalLeft, top: finalTop, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
// Wait for scroll to be applied
requestAnimationFrame(async () => {
if (startLeft !== finalLeft || startTop !== finalTop) {
await waitForEvent(scrollContainer, 'scrollend');
}
scrollContainer.style.removeProperty('scroll-snap-type');
this.dragging = false;
host.requestUpdate();
});
}
}

Wyświetl plik

@ -667,7 +667,7 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
/** Generates a hex string from HSV values. Hue must be 0-360. All other arguments must be 0-100. */
private getHexString(hue: number, saturation: number, brightness: number, alpha = 100) {
const color = new TinyColor(`hsva(${hue}, ${saturation}, ${brightness}, ${alpha / 100})`);
const color = new TinyColor(`hsva(${hue}, ${saturation}%, ${brightness}%, ${alpha / 100})`);
if (!color.isValid) {
return '';
}

Wyświetl plik

@ -106,7 +106,22 @@ export default class SlOption extends ShoelaceElement {
/** Returns a plain text label based on the option's content. */
getTextLabel() {
return (this.textContent ?? '').trim();
const nodes = this.childNodes;
let label = '';
[...nodes].forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
if (!(node as HTMLElement).hasAttribute('slot')) {
label += (node as HTMLElement).outerHTML;
}
}
if (node.nodeType === Node.TEXT_NODE) {
label += node.textContent;
}
});
return label.trim();
}
render() {

Wyświetl plik

@ -28,6 +28,7 @@ import type SlRadioButton from '../radio-button/radio-button.js';
* @slot - The default slot where `<sl-radio>` or `<sl-radio-button>` elements are placed.
* @slot label - The radio group's label. Required for proper accessibility. Alternatively, you can use the `label`
* attribute.
* @slot help-text - Text that describes how to use the radio group. Alternatively, you can use the `help-text` attribute.
*
* @event sl-change - Emitted when the radio group's selected value changes.
* @event sl-input - Emitted when the radio group receives user input.
@ -218,7 +219,7 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor
this.hasButtonGroup = radios.some(radio => radio.tagName.toLowerCase() === 'sl-radio-button');
if (!radios.some(radio => radio.checked)) {
if (radios.length > 0 && !radios.some(radio => radio.checked)) {
if (this.hasButtonGroup) {
const buttonRadio = radios[0].shadowRoot?.querySelector('button');

Wyświetl plik

@ -1,6 +1,7 @@
import '../../../dist/shoelace.js';
import sinon from 'sinon';
import { expect, fixture, html } from '@open-wc/testing';
import { expect, fixture, html, waitUntil } from '@open-wc/testing';
// Reproduction of this issue: https://github.com/shoelace-style/shoelace/issues/1703
it('Should still run form validations if an element is removed', async () => {
@ -19,3 +20,59 @@ it('Should still run form validations if an element is removed', async () => {
expect(form.checkValidity()).to.equal(false);
expect(form.reportValidity()).to.equal(false);
});
it('should submit the correct form values', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<sl-input name="a" value="1"></sl-input>
<sl-input name="b" value="2"></sl-input>
<sl-input name="c" value="3"></sl-input>
<sl-button type="submit">Submit</sl-button>
</form>
`);
const button = form.querySelector('sl-button')!;
const submitHandler = sinon.spy((event: SubmitEvent) => {
formData = new FormData(form);
event.preventDefault();
});
let formData: FormData;
form.addEventListener('submit', submitHandler);
button.click();
await waitUntil(() => submitHandler.calledOnce);
expect(formData!.get('a')).to.equal('1');
expect(formData!.get('b')).to.equal('2');
expect(formData!.get('c')).to.equal('3');
});
it('should submit the correct form values when form controls are removed from the DOM', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<sl-input name="a" value="1"></sl-input>
<sl-input name="b" value="2"></sl-input>
<sl-input name="c" value="3"></sl-input>
<sl-button type="submit">Submit</sl-button>
</form>
`);
const button = form.querySelector('sl-button')!;
const submitHandler = sinon.spy((event: SubmitEvent) => {
formData = new FormData(form);
event.preventDefault();
});
let formData: FormData;
form.addEventListener('submit', submitHandler);
form.querySelector('[name="b"]')!.remove();
button.click();
await waitUntil(() => submitHandler.calledOnce);
expect(formData!.get('a')).to.equal('1');
expect(formData!.get('b')).to.equal(null);
expect(formData!.get('c')).to.equal('3');
});

Wyświetl plik

@ -217,7 +217,14 @@ export class FormControlController implements ReactiveController {
// injecting the name/value on a temporary button, so we can just skip them here.
const isButton = this.host.tagName.toLowerCase() === 'sl-button';
if (!disabled && !isButton && typeof name === 'string' && name.length > 0 && typeof value !== 'undefined') {
if (
this.host.isConnected &&
!disabled &&
!isButton &&
typeof name === 'string' &&
name.length > 0 &&
typeof value !== 'undefined'
) {
if (Array.isArray(value)) {
(value as unknown[]).forEach(val => {
event.formData.append(name, (val as string | number | boolean).toString());

Wyświetl plik

@ -32,19 +32,24 @@ if (!isSupported) {
const pointers = new Set();
const scrollHandlers = new WeakMap<EventTarget, EventListenerOrEventListenerObject>();
const handlePointerDown = (event: PointerEvent) => {
pointers.add(event.pointerId);
const handlePointerDown = (event: TouchEvent) => {
for (const touch of event.changedTouches) {
pointers.add(touch.identifier);
}
};
const handlePointerUp = (event: PointerEvent) => {
pointers.delete(event.pointerId);
const handlePointerUp = (event: TouchEvent) => {
for (const touch of event.changedTouches) {
pointers.delete(touch.identifier);
}
};
document.addEventListener('pointerdown', handlePointerDown);
document.addEventListener('pointerup', handlePointerUp);
document.addEventListener('touchstart', handlePointerDown, true);
document.addEventListener('touchend', handlePointerUp, true);
document.addEventListener('touchcancel', handlePointerUp, true);
decorate(EventTarget.prototype, 'addEventListener', function (this: EventTarget, addEventListener, type) {
if (type !== 'scroll') return;
if (type !== 'scrollend') return;
const handleScrollEnd = debounce(() => {
if (!pointers.size) {
@ -61,7 +66,7 @@ if (!isSupported) {
});
decorate(EventTarget.prototype, 'removeEventListener', function (this: EventTarget, removeEventListener, type) {
if (type !== 'scroll') return;
if (type !== 'scrollend') return;
const scrollHandler = scrollHandlers.get(this);
if (scrollHandler) {

Wyświetl plik

@ -4,9 +4,18 @@
* to reduce the possibility of collisions.
*/
.sl-scroll-lock {
padding-right: var(--sl-scroll-lock-size) !important;
overflow: hidden !important;
@supports (scrollbar-gutter: stable) {
.sl-scroll-lock {
scrollbar-gutter: stable !important;
overflow: hidden !important;
}
}
@supports not (scrollbar-gutter: stable) {
.sl-scroll-lock {
padding-right: var(--sl-scroll-lock-size) !important;
overflow: hidden !important;
}
}
.sl-toast-stack {