Merge pull request #271 from shoelace-style/backports

Backports from Shoelace
This commit is contained in:
Cory LaViska
2024-12-04 14:34:41 -05:00
committed by GitHub
31 changed files with 470 additions and 70 deletions

View File

@@ -83,6 +83,7 @@
"jsfiddle",
"keydown",
"keyframes",
"Konnor",
"Kool",
"labelledby",
"Laravel",
@@ -108,6 +109,7 @@
"multiselectable",
"nextjs",
"nocheck",
"noindex",
"noopener",
"noreferrer",
"novalidate",
@@ -120,6 +122,7 @@
"peta",
"petabit",
"Preact",
"preconnect",
"prismjs",
"progressbar",
"radiogroup",

View File

@@ -17,7 +17,7 @@
<script src="/assets/scripts/hydration-errors.js"></script>
<link rel="stylesheet" href="/assets/styles/hydration-errors.css">
<link rel="preconnect" href="https://cdn.jsdelivr.net">
<script type="module" src="https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.4/+esm"></script>
<script type="module" src="https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.10/+esm"></script>
<script type="module" src="/assets/scripts/code-examples.js"></script>
<script type="module" src="/assets/scripts/color-scheme.js"></script>

View File

@@ -123,7 +123,7 @@ You can make a tab closable by adding a close button next to the tab and inside
const tabGroup = document.querySelector('.tabs-closable');
const generalTab = tabGroup.querySelectorAll('wa-tab')[0];
const closableTab = tabGroup.querySelectorAll('wa-tab')[1];
const closeButton = tabGroup.querySelector('wa-icon-button[slot="nav"]');
const closeButton = tabGroup.querySelector('wa-icon-button');
const restoreButton = tabGroup.nextElementSibling.nextElementSibling;
// Remove the tab when the close button is clicked

View File

@@ -29,6 +29,7 @@ Available translations include:
<div style="columns: 3; gap: 1rem; margin-block-end: 1.5rem;">
- ar
- cs
- da
- de-ch
- de
@@ -36,19 +37,24 @@ Available translations include:
- en
- es
- fa
- fi
- fr
- he
- hr
- hu
- id
- it
- ja
- nb
- nl
- nn
- pl
- pt
- ru
- sl
- sv
- tr
- uk
- zh-cn
- zh-tw

View File

@@ -14,16 +14,27 @@ During the alpha period, things might break! We take breaking changes very serio
## Next
- Added support for <kbd>Enter</kbd> to `<sl-split-panel>` to align with ARIA APG's [window splitter pattern](https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/)
- Added the Finnish translation
- Added the Italian translation
- Added the Ukrainian translation
- Added support for <kbd>Enter</kbd> to `<wa-split-panel>` to align with ARIA APG's [window splitter pattern](https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/)
- Added more resilient support for lazy loaded options in `<wa-select>`
- Added support for vertical button groups
- Added the `focus()` method to `<wa-radio-group>`
- Fixed a bug in `<wa-rating>` when using `precision`
- Fixed a bug in `<wa-rating>` that allowed tabbing into the rating when readonly
- Fixed a bug in `<wa-relative-time>` where the title attribute would show with redundant info
- Fixed a bug in `<wa-select>` that caused the placeholder to display incorrectly when using placeholder and multiple
- Fixed a bug in `<wa-tooltip>` that caused a memory leak in disconnected elements
- Fixed a bug in `<wa-select>` that prevented label changes in `<wa-option>` from updating the controller
- Fixed a bug in `<wa-carousel>` that caused interactive elements to be activated when dragging
- Fixed a bug in `<wa-tab-group>` that prevented changing tabs by setting `active` on `<wa-tab>` elements
- Fixed a bug in `<wa-tab-group>` that caused an error when removed from the DOM too quickly
- Fixed a bug in `<wa-textarea>` causing scroll jumping when using `resize="auto"`
- Fixed a bug with certain bundlers when using dynamic imports
- Improved alignment of the play icon in `<wa-animated-image>`
- Improved behavior of link buttons to not set `noreferrer noopener` by default
- Updated all checks for directionality to use `this.localize.dir()` instead of `el.matches(:dir(rtl))` so older browsers don't error out
## 3.0.0-alpha.3
@@ -35,8 +46,8 @@ During the alpha period, things might break! We take breaking changes very serio
- Fixed a bug in `<wa-select>` that made the suffix slot collide with the clear button
- Fixed a bug in `<wa-checkbox>` where unchecking and then checking would "clear" its value.
- Fixed a bug where `<wa-relative-time>` would announce the full time instead of the relative time in screen readers [#22](https://github.com/shoelace-style/webawesome-alpha/issues/22)
- Fixed a bug in `<wa-tab-group>` in Firefox where the overflow container would keep focus. [#14](https://github.com/shoelace-style/webawesome-alpha/issues/14)
- Fixed a bug in `<wa-input>` where `minlength` and `maxlength` were not being properly validated. [#35](https://github.com/shoelace-style/webawesome-alpha/issues/35)
- Fixed a bug in `<wa-tab-group>` in Firefox where the overflow container would keep focus [#14](https://github.com/shoelace-style/webawesome-alpha/issues/14)
- Fixed a bug in `<wa-input>` where `minlength` and `maxlength` were not being properly validated [#35](https://github.com/shoelace-style/webawesome-alpha/issues/35)
- Fixed a bug in `<wa-carousel>` that made pagination work incorrectly
## 3.0.0-alpha.2
@@ -68,7 +79,7 @@ Here's a list of some of the things that have changed since Shoelace v2. For que
- Changed the `data-optional`, `data-required`, `data-invalid`, `data-valid`, `data-user-invalid`, and `data-user-valid` states to `data-wa-*` prefix to avoid conflicts with user provided attributes
- Changed `<wa-icon>` so icons are no longer fixed width by default to accommodate variable width icons
- Changed `<wa-radio>` from `display: block;` to `display: inline-block`
- Changed `<wa-tab-group>` to implement a "roving tabindex" and `<wa-tab>` is no longer tabbable by default. This aligns closer to the APG pattern for tabs. [#2041]
- Changed `<wa-tab-group>` to implement a "roving tabindex" and `<wa-tab>` is no longer tabbable by default. This aligns closer to the APG pattern for tabs [#2041]
- Changed `<wa-tooltip>` to no longer wrap content due to accessibility and styling issues. Tooltips are now associated using the `for` attribute + an `id` on the trigger [#123]
- Improved `<wa-spinner>` so it doesn't wobble when zooming in Safari
- Improved submenu selection by implementing the [safe triangle](https://www.smashingmagazine.com/2023/08/better-context-menus-safe-triangles/) method [#1550]

View File

@@ -18,10 +18,13 @@
"./dist/custom-elements.json": "./dist/custom-elements.json",
"./dist/webawesome.js": "./dist/webawesome.js",
"./dist/webawesome.loader.js": "./dist/webawesome.loader.js",
"./dist/themes": "./dist/themes",
"./dist/themes/*": "./dist/themes/*",
"./dist/components": "./dist/components",
"./dist/components/*": "./dist/components/*",
"./dist/react": "./dist/react/index.js",
"./dist/react/*": "./dist/react/*",
"./dist/translations": "./dist/translations",
"./dist/translations/*": "./dist/translations/*"
},
"files": [

View File

@@ -67,7 +67,8 @@ describe('<wa-carousel>', () => {
});
});
it('should scroll forwards every `autoplay-interval` milliseconds', async () => {
// TODO - this test is hanging the test runner, but autoplay was verified manually to work
it.skip('should scroll forwards every `autoplay-interval` milliseconds', async () => {
// Arrange
const el = await fixture<WaCarousel>(html`
<wa-carousel autoplay autoplay-interval="10">

View File

@@ -102,6 +102,7 @@ export default class WaCarousel extends WebAwesomeElement {
private dragStartPosition: [number, number] = [-1, -1];
private readonly localize = new LocalizeController(this);
private mutationObserver: MutationObserver;
private pendingSlideChange = false;
connectedCallback(): void {
super.connectedCallback();
@@ -175,7 +176,7 @@ export default class WaCarousel extends WebAwesomeElement {
private handleKeyDown(event: KeyboardEvent) {
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) {
const target = event.target as HTMLElement;
const isRtl = this.matches(':dir(rtl)');
const isRtl = this.localize.dir() === 'rtl';
const isFocusInPagination = target.closest('[part~="pagination-item"]') !== null;
const isNext =
event.key === 'ArrowDown' || (!isRtl && event.key === 'ArrowRight') || (isRtl && event.key === 'ArrowLeft');
@@ -285,6 +286,9 @@ export default class WaCarousel extends WebAwesomeElement {
@eventOptions({ passive: true })
private handleScroll() {
this.scrolling = true;
if (!this.pendingSlideChange) {
this.synchronizeSlides();
}
}
/** @internal Synchronizes the slides with the IntersectionObserver API. */
@@ -302,18 +306,29 @@ export default class WaCarousel extends WebAwesomeElement {
const firstIntersecting = entries.find(entry => entry.isIntersecting);
if (firstIntersecting) {
if (this.loop && firstIntersecting.target.hasAttribute('data-clone')) {
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, 'instant');
} else {
const slides = this.getSlides();
if (!firstIntersecting) {
return;
}
// Update the current index based on the first visible slide
const slideIndex = slides.indexOf(firstIntersecting.target as WaCarouselItem);
// Set the index to the first "snappable" slide
this.activeSlide = Math.ceil(slideIndex / this.slidesPerMove) * this.slidesPerMove;
const slidesWithClones = this.getSlides({ excludeClones: false });
const slidesCount = this.getSlides().length;
// Update the current index based on the first visible slide
const slideIndex = slidesWithClones.indexOf(firstIntersecting.target as WaCarouselItem);
// Normalize the index to ignore clones
const normalizedIndex = this.loop ? slideIndex - this.slidesPerPage : slideIndex;
if (firstIntersecting) {
// Set the index to the closest "snappable" slide
this.activeSlide =
(Math.ceil(normalizedIndex / this.slidesPerMove) * this.slidesPerMove + slidesCount) % slidesCount;
if (!this.scrolling) {
if (this.loop && firstIntersecting.target.hasAttribute('data-clone')) {
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, 'instant');
}
}
}
},
@@ -334,6 +349,8 @@ export default class WaCarousel extends WebAwesomeElement {
this.synchronizeSlides();
this.scrolling = false;
this.pendingSlideChange = false;
this.synchronizeSlides();
}
private isCarouselItem(node: Node): node is WaCarouselItem {
@@ -403,7 +420,7 @@ export default class WaCarousel extends WebAwesomeElement {
}
@watch('activeSlide')
handelSlideChange() {
handleSlideChange() {
const slides = this.getSlides();
slides.forEach((slide, i) => {
slide.classList.toggle('--is-active', i === this.activeSlide);
@@ -484,7 +501,7 @@ export default class WaCarousel extends WebAwesomeElement {
: clamp(index, 0, slides.length - slidesPerPage);
this.activeSlide = newActiveSlide;
const isRtl = this.matches(':dir(rtl)');
const isRtl = this.localize.dir() === 'rtl';
// Get the index of the next slide. For looping carousel it adds `slidesPerPage`
// to normalize the starting index in order to ignore the first nth clones.
@@ -501,17 +518,35 @@ export default class WaCarousel extends WebAwesomeElement {
}
private scrollToSlide(slide: HTMLElement, behavior: ScrollBehavior = 'smooth') {
const scrollContainer = this.scrollContainer;
const scrollContainerRect = scrollContainer.getBoundingClientRect();
const nextSlideRect = slide.getBoundingClientRect();
// Since the geometry doesn't happen until rAF, we don't know if we'll be scrolling or not...
// It's best to assume that we will and cleanup in the else case below if we didn't need to
this.pendingSlideChange = true;
window.requestAnimationFrame(() => {
// This can happen if goToSlide is called before the scroll container is rendered
// We will have correctly set the activeSlide in goToSlide which will get picked up when initializeSlides is called.
if (!this.scrollContainer) {
return;
}
const nextLeft = nextSlideRect.left - scrollContainerRect.left;
const nextTop = nextSlideRect.top - scrollContainerRect.top;
const scrollContainer = this.scrollContainer;
const scrollContainerRect = scrollContainer.getBoundingClientRect();
const nextSlideRect = slide.getBoundingClientRect();
scrollContainer.scrollTo({
left: nextLeft + scrollContainer.scrollLeft,
top: nextTop + scrollContainer.scrollTop,
behavior
const nextLeft = nextSlideRect.left - scrollContainerRect.left;
const nextTop = nextSlideRect.top - scrollContainerRect.top;
if (nextLeft || nextTop) {
// This is here just in case someone set it back to false
// between rAF being requested and the callback actually running
this.pendingSlideChange = true;
scrollContainer.scrollTo({
left: nextLeft + scrollContainer.scrollLeft,
top: nextTop + scrollContainer.scrollTop,
behavior
});
} else {
this.pendingSlideChange = false;
}
});
}
@@ -532,7 +567,7 @@ export default class WaCarousel extends WebAwesomeElement {
}
// We can't rely on `this.matches()` on the server.
const isRTL = isServer ? this.dir === 'rtl' : this.matches(':dir(rtl)');
const isRTL = isServer ? this.dir === 'rtl' : this.localize.dir() === 'rtl';
return html`
<div part="base" class="carousel">

View File

@@ -3,6 +3,7 @@ import { animate, parseDuration } from '../../internal/animate.js';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query } from 'lit/decorators.js';
import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize.js';
import { WaAfterHideEvent } from '../../events/after-hide.js';
import { WaAfterShowEvent } from '../../events/after-show.js';
import { WaHideEvent } from '../../events/hide.js';
@@ -52,13 +53,14 @@ import type { CSSResultGroup } from 'lit';
export default class WaDetails extends WebAwesomeElement {
static styles: CSSResultGroup = [componentStyles, styles];
private detailsObserver: MutationObserver;
private readonly localize = new LocalizeController(this);
@query('.details') details: HTMLDetailsElement;
@query('.details__header') header: HTMLElement;
@query('.details__body') body: HTMLElement;
@query('.details__expand-icon-slot') expandIconSlot: HTMLSlotElement;
detailsObserver: MutationObserver;
/**
* Indicates whether or not the details is open. You can toggle this attribute to show and hide the details, or you
* can use the `show()` and `hide()` methods and this attribute will reflect the details' open state.
@@ -208,7 +210,7 @@ export default class WaDetails extends WebAwesomeElement {
}
render() {
const isRtl = !this.hasUpdated ? this.dir === 'rtl' : this.matches(':dir(rtl)');
const isRtl = !this.hasUpdated ? this.dir === 'rtl' : this.localize.dir() === 'rtl';
return html`
<details

View File

@@ -4,6 +4,7 @@ import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query } from 'lit/decorators.js';
import { drag } from '../../internal/drag.js';
import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize.js';
import { styleMap } from 'lit/directives/style-map.js';
import { WaChangeEvent } from '../../events/change.js';
import { watch } from '../../internal/watch.js';
@@ -41,6 +42,8 @@ import type { CSSResultGroup } from 'lit';
export default class WaImageComparer extends WebAwesomeElement {
static styles: CSSResultGroup = [componentStyles, styles];
private readonly localize = new LocalizeController(this);
@query('.image-comparer') base: HTMLElement;
@query('.image-comparer__handle') handle: HTMLElement;
@@ -49,7 +52,7 @@ export default class WaImageComparer extends WebAwesomeElement {
private handleDrag(event: PointerEvent) {
const { width } = this.base.getBoundingClientRect();
const isRtl = this.matches(':dir(rtl)');
const isRtl = this.localize.dir() === 'rtl';
event.preventDefault();
@@ -64,7 +67,7 @@ export default class WaImageComparer extends WebAwesomeElement {
private handleKeyDown(event: KeyboardEvent) {
const isLtr = this.matches(':dir(ltr)');
const isRtl = this.matches(':dir(rtl)');
const isRtl = this.localize.dir() === 'rtl';
if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) {
const incr = event.shiftKey ? 10 : 1;
@@ -96,7 +99,7 @@ export default class WaImageComparer extends WebAwesomeElement {
}
render() {
const isRtl = this.hasUpdated ? this.matches(':dir(rtl)') : this.dir === 'rtl';
const isRtl = this.hasUpdated ? this.localize.dir() === 'rtl' : this.dir === 'rtl';
return html`
<div

View File

@@ -5,6 +5,7 @@ import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query } from 'lit/decorators.js';
import { getTextContent } from '../../internal/slot.js';
import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize.js';
import { SubmenuController } from './submenu-controller.js';
import { watch } from '../../internal/watch.js';
import componentStyles from '../../styles/component.styles.js';
@@ -44,6 +45,7 @@ export default class WaMenuItem extends WebAwesomeElement {
static styles: CSSResultGroup = [componentStyles, styles];
private cachedTextLabel: string;
private readonly localize = new LocalizeController(this);
@query('slot:not([name])') defaultSlot: HTMLSlotElement;
@query('.menu-item') menuItem: HTMLElement;
@@ -163,7 +165,7 @@ export default class WaMenuItem extends WebAwesomeElement {
}
render() {
const isRtl = this.hasUpdated ? this.matches(':dir(rtl)') : this.dir === 'rtl';
const isRtl = this.hasUpdated ? this.localize.dir() === 'rtl' : this.dir === 'rtl';
const isSubmenuExpanded = this.submenuController.isExpanded();
return html`

View File

@@ -195,7 +195,7 @@ export class SubmenuController implements ReactiveController {
private handlePopupReposition = () => {
const submenuSlot: HTMLSlotElement | null = this.host.renderRoot.querySelector("slot[name='submenu']");
const menu = submenuSlot?.assignedElements({ flatten: true }).filter(el => el.localName === 'wa-menu')[0];
const isRtl = this.host.hasUpdated ? this.host.matches(':dir(rtl)') : this.host.dir === 'rtl';
const isRtl = getComputedStyle(this.host).direction === 'rtl';
if (!menu) {
return;
@@ -265,7 +265,7 @@ export class SubmenuController implements ReactiveController {
return html` <slot name="submenu" hidden></slot> `;
}
const isRtl = this.host.matches(':dir(rtl)');
const isRtl = getComputedStyle(this.host).direction === 'rtl';
return html`
<wa-popup

View File

@@ -1,8 +1,11 @@
import { expect } from '@open-wc/testing';
import { fixtures } from '../../internal/test/fixture.js';
import { html } from 'lit';
import type WaPopup from './popup.js';
describe('<wa-popup>', () => {
let element: WaPopup;
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
it('should render a component', async () => {
@@ -10,6 +13,26 @@ describe('<wa-popup>', () => {
expect(el).to.exist;
});
it('should properly handle positioning when active changes', async () => {
element = await fixture(html`<wa-popup></wa-popup>`);
element.active = true;
await element.updateComplete;
// SImulate a scroll event
const event = new Event('scroll');
window.dispatchEvent(event);
element.active = false;
await element.updateComplete;
// The component should not throw an error when the window is scrolled
expect(() => {
element.active = true;
window.dispatchEvent(event);
}).not.to.throw();
});
});
}
});

View File

@@ -2,6 +2,7 @@ import { arrow, autoUpdate, computePosition, flip, offset, platform, shift, size
import { classMap } from 'lit/directives/class-map.js';
import { customElement, property, query } from 'lit/decorators.js';
import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize.js';
import { offsetParent } from 'composed-offset-position';
import { WaRepositionEvent } from '../../events/reposition.js';
import componentStyles from '../../styles/component.styles.js';
@@ -60,6 +61,7 @@ export default class WaPopup extends WebAwesomeElement {
private anchorEl: Element | VirtualElement | null;
private cleanup: ReturnType<typeof autoUpdate> | undefined;
private readonly localize = new LocalizeController(this);
/** A reference to the internal popup container. Useful for animating and styling the popup with JavaScript. */
@query('.popup') popup: HTMLElement;
@@ -276,7 +278,7 @@ export default class WaPopup extends WebAwesomeElement {
private start() {
// We can't start the positioner without an anchor
if (!this.anchorEl) {
if (!this.anchorEl || !this.active) {
return;
}
@@ -418,7 +420,7 @@ export default class WaPopup extends WebAwesomeElement {
//
// Source: https://github.com/floating-ui/floating-ui/blob/cb3b6ab07f95275730d3e6e46c702f8d4908b55c/packages/dom/src/utils/getDocumentRect.ts#L31
//
const isRtl = this.matches(':dir(rtl)');
const isRtl = this.localize.dir() === 'rtl';
const staticSide = { top: 'bottom', right: 'left', bottom: 'top', left: 'right' }[placement.split('-')[0]]!;
this.setAttribute('data-current-placement', placement);

View File

@@ -297,6 +297,102 @@ describe('<wa-radio-group>', () => {
});
});
describe('when handling focus', () => {
const doAction = async (instance: WaRadioGroup, type: string) => {
if (type === 'focus') {
instance.focus();
await instance.updateComplete;
return;
}
const label = instance.shadowRoot!.querySelector<HTMLLabelElement>('#label')!;
label.click();
await instance.updateComplete;
};
// Tests for focus and label actions with radio buttons
['focus', 'label'].forEach(actionType => {
describe(`when using ${actionType}`, () => {
it('should do nothing if all elements are disabled', async () => {
const el = await fixture<WaRadioGroup>(html`
<wa-radio-group>
<wa-radio id="radio-0" value="0" disabled></wa-radio>
<wa-radio id="radio-1" value="1" disabled></wa-radio>
<wa-radio id="radio-2" value="2" disabled></wa-radio>
<wa-radio id="radio-3" value="3" disabled></wa-radio>
</wa-radio-group>
`);
const validFocusHandler = sinon.spy();
Array.from(el.querySelectorAll<WaRadio>('wa-radio')).forEach(radio =>
radio.addEventListener('wa-focus', validFocusHandler)
);
expect(validFocusHandler).to.not.have.been.called;
await doAction(el, actionType);
expect(validFocusHandler).to.not.have.been.called;
});
it('should focus the first radio that is enabled when the group receives focus', async () => {
const el = await fixture<WaRadioGroup>(html`
<wa-radio-group>
<wa-radio id="radio-0" value="0" disabled></wa-radio>
<wa-radio id="radio-1" value="1"></wa-radio>
<wa-radio id="radio-2" value="2"></wa-radio>
<wa-radio id="radio-3" value="3"></wa-radio>
</wa-radio-group>
`);
const invalidFocusHandler = sinon.spy();
const validFocusHandler = sinon.spy();
const disabledRadio = el.querySelector('#radio-0')!;
const validRadio = el.querySelector('#radio-1')!;
disabledRadio.addEventListener('wa-focus', invalidFocusHandler);
validRadio.addEventListener('wa-focus', validFocusHandler);
expect(invalidFocusHandler).to.not.have.been.called;
expect(validFocusHandler).to.not.have.been.called;
await doAction(el, actionType);
expect(invalidFocusHandler).to.not.have.been.called;
expect(validFocusHandler).to.have.been.called;
});
it('should focus the currently enabled radio when the group receives focus', async () => {
const el = await fixture<WaRadioGroup>(html`
<wa-radio-group value="2">
<wa-radio id="radio-0" value="0" disabled></wa-radio>
<wa-radio id="radio-1" value="1"></wa-radio>
<wa-radio id="radio-2" value="2" checked></wa-radio>
<wa-radio id="radio-3" value="3"></wa-radio>
</wa-radio-group>
`);
const invalidFocusHandler = sinon.spy();
const validFocusHandler = sinon.spy();
const disabledRadio = el.querySelector('#radio-0')!;
const validRadio = el.querySelector('#radio-2')!;
disabledRadio.addEventListener('wa-focus', invalidFocusHandler);
validRadio.addEventListener('wa-focus', validFocusHandler);
expect(invalidFocusHandler).to.not.have.been.called;
expect(validFocusHandler).to.not.have.been.called;
await doAction(el, actionType);
expect(invalidFocusHandler).to.not.have.been.called;
expect(validFocusHandler).to.have.been.called;
});
});
});
});
describe('when the value changes', () => {
it('should emit wa-change when toggled with the arrow keys', async () => {
const radioGroup = await fixture<WaRadioGroup>(html`

View File

@@ -170,14 +170,7 @@ export default class WaRadioGroup extends WebAwesomeFormAssociatedElement {
}
private handleLabelClick() {
const radios = this.getAllRadios();
const checked = radios.find(radio => radio.checked);
const radioToFocus = checked || radios[0];
// Move focus to the checked radio (or the first one if none are checked) when clicking the label
if (radioToFocus) {
radioToFocus.focus();
}
this.focus();
}
private async syncRadioElements() {
@@ -305,6 +298,19 @@ export default class WaRadioGroup extends WebAwesomeFormAssociatedElement {
event.preventDefault();
}
/** Sets focus on the radio group. */
public focus(options?: FocusOptions) {
const radios = this.getAllRadios();
const checked = radios.find(radio => radio.checked);
const firstEnabledRadio = radios.find(radio => !radio.disabled);
const radioToFocus = checked || firstEnabledRadio;
// Call focus for the checked radio. If no radio is checked, focus the first one that isn't disabled.
if (radioToFocus) {
radioToFocus.focus(options);
}
}
render() {
const hasLabelSlot = this.hasUpdated ? this.hasSlotController.test('label') : this.withLabel;
const hasHelpTextSlot = this.hasUpdated ? this.hasSlotController.test('help-text') : this.withHelpText;

View File

@@ -202,7 +202,7 @@ export default class WaRange extends WebAwesomeFormAssociatedElement {
const inputWidth = this.input.offsetWidth;
const tooltipWidth = this.output.offsetWidth;
const thumbSize = getComputedStyle(this.input).getPropertyValue('--thumb-size');
const isRtl = this.matches(':dir(rtl)');
const isRtl = this.localize.dir() === 'rtl';
const percentAsWidth = inputWidth * percent;
// The calculations are used to "guess" where the thumb is located. Since we're using the native range control

View File

@@ -3,6 +3,7 @@ import { clamp } from '../../internal/math.js';
import { classMap } from 'lit/directives/class-map.js';
import { customElement, eventOptions, property, query, state } from 'lit/decorators.js';
import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize.js';
import { styleMap } from 'lit/directives/style-map.js';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { WaChangeEvent } from '../../events/change.js';
@@ -37,6 +38,8 @@ import type { CSSResultGroup } from 'lit';
export default class WaRating extends WebAwesomeElement {
static styles: CSSResultGroup = [componentStyles, styles];
private readonly localize = new LocalizeController(this);
@query('.rating') rating: HTMLElement;
@state() private hoverValue = 0;
@@ -80,7 +83,7 @@ export default class WaRating extends WebAwesomeElement {
}
private getValueFromXCoordinate(coordinate: number) {
const isRtl = this.matches(':dir(rtl)');
const isRtl = this.localize.dir() === 'rtl';
const { left, right, width } = this.rating.getBoundingClientRect();
const value = isRtl
? this.roundToPrecision(((right - coordinate) / width) * this.max, this.precision)
@@ -109,7 +112,7 @@ export default class WaRating extends WebAwesomeElement {
private handleKeyDown(event: KeyboardEvent) {
const isLtr = this.matches(':dir(ltr)');
const isRtl = this.matches(':dir(rtl)');
const isRtl = this.localize.dir() === 'rtl';
const oldValue = this.value;
if (this.disabled || this.readonly) {
@@ -214,7 +217,7 @@ export default class WaRating extends WebAwesomeElement {
}
render() {
const isRtl = this.hasUpdated ? this.matches(':dir(rtl)') : this.dir;
const isRtl = this.hasUpdated ? this.localize.dir() === 'rtl' : this.dir;
const counter = Array.from(Array(this.max).keys());
let displayValue = 0;
@@ -240,7 +243,7 @@ export default class WaRating extends WebAwesomeElement {
aria-valuenow=${this.value}
aria-valuemin=${0}
aria-valuemax=${this.max}
tabindex=${this.disabled ? '-1' : '0'}
tabindex=${this.disabled || this.readonly ? '-1' : '0'}
@click=${this.handleClick}
@keydown=${this.handleKeyDown}
@mouseenter=${this.handleMouseEnter}

View File

@@ -164,7 +164,7 @@ export default css`
margin-inline-end: var(--wa-space-s);
}
.select--small.select--multiple .select__prefix::slotted(*) {
.select--small.select--multiple:not(.select--placeholder-visible) .select__prefix::slotted(*) {
margin-inline-start: var(--wa-space-s);
}
@@ -192,7 +192,7 @@ export default css`
margin-inline-end: var(--wa-space-m);
}
.select--medium.select--multiple .select__prefix::slotted(*) {
.select--medium.select--multiple:not(.select--placeholder-visible) .select__prefix::slotted(*) {
margin-inline-start: var(--wa-space-m);
}
@@ -220,7 +220,7 @@ export default css`
margin-inline-end: var(--wa-space-l);
}
.select--large.select--multiple .select__prefix::slotted(*) {
.select--large.select--multiple:not(.select--placeholder-visible) .select__prefix::slotted(*) {
margin-inline-start: var(--wa-space-l);
}

View File

@@ -217,6 +217,29 @@ describe('<wa-select>', () => {
});
});
// This can happen in on Microsoft Edge auto-filling an associated input element in the same form
// https://github.com/shoelace-style/shoelace/issues/2117
it('should not throw on incomplete events', async () => {
const el = await fixture<WaSelect>(html`
<wa-select required>
<sl-option value="option-1">Option 1</sl-option>
</wa-select>
`);
const event = new KeyboardEvent('keydown');
Object.defineProperty(event, 'target', { writable: false, value: el });
Object.defineProperty(event, 'key', { writable: false, value: undefined });
/**
* If Edge does autofill, it creates a broken KeyboardEvent
* which is missing the key value.
* Using the normal dispatch mechanism does not allow to do this
* Thus passing the event directly to the private method for testing
*
* @ts-expect-error - private property */
el.handleDocumentKeyDown(event);
});
it('should open the listbox when any letter key is pressed with wa-select is on focus', async () => {
const el = await fixture<WaSelect>(html`
<wa-select>

View File

@@ -417,7 +417,7 @@ export default class WaSelect extends WebAwesomeFormAssociatedElement {
}
// All other "printable" keys trigger type to select
if (event.key.length === 1 || event.key === 'Backspace') {
if (event.key?.length === 1 || event.key === 'Backspace') {
const allOptions = this.getAllOptions();
// Don't block important key combos like CMD+R

View File

@@ -107,7 +107,7 @@ export default class WaSplitPanel extends WebAwesomeElement {
}
private handleDrag(event: PointerEvent) {
const isRtl = this.hasUpdated ? this.matches(':dir(rtl)') : this.dir === 'rtl';
const isRtl = this.hasUpdated ? this.localize.dir() === 'rtl' : this.dir === 'rtl';
if (this.disabled) {
return;
@@ -248,7 +248,7 @@ export default class WaSplitPanel extends WebAwesomeElement {
render() {
const gridTemplate = this.vertical ? 'gridTemplateRows' : 'gridTemplateColumns';
const gridTemplateAlt = this.vertical ? 'gridTemplateColumns' : 'gridTemplateRows';
const isRtl = this.hasUpdated ? this.matches(':dir(rtl)') : this.dir === 'rtl';
const isRtl = this.hasUpdated ? this.localize.dir() === 'rtl' : this.dir === 'rtl';
const primary = `
clamp(
0%,

View File

@@ -86,6 +86,13 @@ describe('<wa-tab-group>', () => {
expect(tabGroup).to.be.visible;
});
it('should not throw error when unmounted too fast', async () => {
const el = await fixture(html` <div></div> `);
el.innerHTML = '<sl-tab-group></sl-tab-group>';
el.innerHTML = '';
});
it('is accessible', async () => {
const tabGroup = await fixture<WaTabGroup>(html`
<wa-tab-group>
@@ -361,6 +368,31 @@ describe('<wa-tab-group>', () => {
return expectCustomTabToBeActiveAfter(tabGroup, () => clickOnElement(customHeader!));
});
it('selects a tab by changing it via active property', async () => {
const tabGroup = await fixture<WaTabGroup>(html`
<wa-tab-group>
<wa-tab slot="nav" panel="general" data-testid="general-header">General</wa-tab>
<wa-tab slot="nav" panel="custom" data-testid="custom-header">Custom</wa-tab>
<wa-tab-panel name="general">This is the general tab panel.</wa-tab-panel>
<wa-tab-panel name="custom" data-testid="custom-tab-content">This is the custom tab panel.</wa-tab-panel>
</wa-tab-group>
`);
const customHeader = queryByTestId<WaTab>(tabGroup, 'custom-header')!;
const generalHeader = await waitForHeaderToBeActive(tabGroup, 'general-header');
generalHeader.focus();
expect(customHeader).not.to.have.attribute('active');
const showEventPromise = oneEvent(tabGroup, 'wa-tab-show') as Promise<WaTabShowEvent>;
customHeader.active = true;
await tabGroup.updateComplete;
expect(customHeader).to.have.attribute('active');
await expectPromiseToHaveName(showEventPromise, 'custom');
return expectOnlyOneTabPanelToBeActive(tabGroup, 'custom-tab-content');
});
it('does not change if the active tab is reselected', async () => {
const tabGroup = await fixture<WaTabGroup>(html`
<wa-tab-group>

View File

@@ -50,14 +50,13 @@ import type WaTabPanel from '../tab-panel/tab-panel.js';
export default class WaTabGroup extends WebAwesomeElement {
static styles: CSSResultGroup = [componentStyles, styles];
private readonly localize = new LocalizeController(this);
private activeTab?: WaTab;
private mutationObserver: MutationObserver;
private resizeObserver: ResizeObserver;
private tabs: WaTab[] = [];
private focusableTabs: WaTab[] = [];
private panels: WaTabPanel[] = [];
private readonly localize = new LocalizeController(this);
@query('.tab-group') tabGroup: HTMLElement;
@query('.tab-group__body') body: HTMLSlotElement;
@@ -96,6 +95,16 @@ export default class WaTabGroup extends WebAwesomeElement {
// Sync tabs when disabled states change
if (mutations.some(m => m.attributeName === 'disabled')) {
this.syncTabsAndPanels();
// sync tabs when active state on tab changes
} else if (mutations.some(m => m.attributeName === 'active')) {
const tabs = mutations
.filter(m => m.attributeName === 'active' && (m.target as HTMLElement).tagName.toLowerCase() === 'wa-tab')
.map(m => m.target as WaTab);
const newActiveTab = tabs.find(tab => tab.active);
if (newActiveTab) {
this.setActiveTab(newActiveTab);
}
}
});
@@ -130,7 +139,10 @@ export default class WaTabGroup extends WebAwesomeElement {
disconnectedCallback() {
super.disconnectedCallback();
this.mutationObserver?.disconnect();
this.resizeObserver?.unobserve(this.nav);
if (this.nav) {
this.resizeObserver?.unobserve(this.nav);
}
}
private getAllTabs() {
@@ -186,7 +198,7 @@ export default class WaTabGroup extends WebAwesomeElement {
// Move focus left or right
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) {
const activeEl = this.tabs.find(t => t.matches(':focus'));
const isRtl = this.matches(':dir(rtl)');
const isRtl = this.localize.dir() === 'rtl';
let nextTab: null | WaTab = null;
if (activeEl?.tagName.toLowerCase() === 'wa-tab') {
@@ -361,7 +373,7 @@ export default class WaTabGroup extends WebAwesomeElement {
}
render() {
const isRtl = this.hasUpdated ? this.matches(':dir(rtl)') : this.dir === 'rtl';
const isRtl = this.hasUpdated ? this.localize.dir() === 'rtl' : this.dir === 'rtl';
return html`
<div

View File

@@ -24,7 +24,7 @@ export default css`
border-style: var(--border-style);
border-width: var(--border-width);
box-shadow: var(--box-shadow);
display: flex;
display: grid;
align-items: center;
position: relative;
width: 100%;
@@ -38,6 +38,17 @@ export default css`
cursor: text;
}
.textarea__control,
.textarea__size-adjuster {
grid-area: 1 / 1 / 2 / 2;
}
.textarea__size-adjuster {
visibility: hidden;
pointer-events: none;
opacity: 0;
}
/* Standard textareas */
.textarea--standard.textarea--focused:not(.textarea--disabled) {
outline: var(--wa-focus-ring);
@@ -62,7 +73,6 @@ export default css`
}
.textarea__control {
flex: 1 1 auto;
font: inherit;
line-height: var(--wa-line-height-expanded);
color: var(--wa-form-control-value-color);

View File

@@ -57,6 +57,7 @@ export default class WaTextarea extends WebAwesomeFormAssociatedElement {
private resizeObserver: ResizeObserver;
@query('.textarea__control') input: HTMLTextAreaElement;
@query('.textarea__size-adjuster') sizeAdjuster: HTMLTextAreaElement;
@state() private hasFocus = false;
@property() title = ''; // make reactive to pass through
@@ -225,6 +226,8 @@ export default class WaTextarea extends WebAwesomeFormAssociatedElement {
private setTextareaHeight() {
if (this.resize === 'auto') {
// This prevents layout shifts. We use `clientHeight` instead of `scrollHeight` to account for if the `<textarea>` has a max-height set on it. In my tests, this has worked fine. Im not aware of any edge cases. [Konnor]
this.sizeAdjuster.style.height = `${this.input.clientHeight}px`;
this.input.style.height = 'auto';
this.input.style.height = `${this.input.scrollHeight}px`;
} else {
@@ -377,6 +380,9 @@ export default class WaTextarea extends WebAwesomeFormAssociatedElement {
@focus=${this.handleFocus}
@blur=${this.handleBlur}
></textarea>
<!-- This "adjuster" exists to prevent layout shifting. https://github.com/shoelace-style/shoelace/issues/2180 -->
<div part="textarea-adjuster" class="textarea__size-adjuster" ?hidden=${this.resize !== 'auto'}></div>
</div>
</div>

View File

@@ -236,7 +236,7 @@ export default class WaTreeItem extends WebAwesomeElement {
}
render() {
const isRtl = this.hasUpdated ? this.matches(':dir(rtl)') : this.dir === 'rtl';
const isRtl = this.hasUpdated ? this.localize.dir() === 'rtl' : this.dir === 'rtl';
const showExpandButton = !this.loading && (!this.isLeaf || this.lazy);
return html`

View File

@@ -1,6 +1,7 @@
import { clamp } from '../../internal/math.js';
import { customElement, property, query } from 'lit/decorators.js';
import { html, isServer } from 'lit';
import { LocalizeController } from '../../utilities/localize.js';
import { WaSelectionChangeEvent } from '../../events/selection-change.js';
import { watch } from '../../internal/watch.js';
import componentStyles from '../../styles/component.styles.js';
@@ -93,6 +94,7 @@ export default class WaTree extends WebAwesomeElement {
private lastFocusedItem: WaTreeItem | null;
private mutationObserver: MutationObserver;
private clickTarget: WaTreeItem | null = null;
private readonly localize = new LocalizeController(this);
constructor() {
super();
@@ -231,7 +233,7 @@ export default class WaTree extends WebAwesomeElement {
const items = this.getFocusableItems();
const isLtr = this.matches(':dir(ltr)');
const isRtl = this.matches(':dir(rtl)');
const isRtl = this.localize.dir() === 'rtl';
if (items.length > 0) {
event.preventDefault();

39
src/translations/fi.ts Normal file
View File

@@ -0,0 +1,39 @@
import { registerTranslation } from '@shoelace-style/localize';
import type { Translation } from '../utilities/localize.js';
const translation: Translation = {
$code: 'fi',
$name: 'Suomi',
$dir: 'ltr',
carousel: 'Karuselli',
clearEntry: 'Poista merkintä',
close: 'Sulje',
copied: 'Kopioitu',
copy: 'Kopioi',
currentValue: 'Nykyinen arvo',
error: 'Virhe',
goToSlide: (slide, count) => `Siirry diaan ${slide} / ${count}`,
hidePassword: 'Piilota salasana',
loading: 'Ladataan',
nextSlide: 'Seuraava dia',
numOptionsSelected: num => {
if (num === 0) return 'Ei valittuja vaihtoehtoja';
if (num === 1) return 'Yksi vaihtoehto valittu';
return `${num} vaihtoehtoa valittu`;
},
previousSlide: 'Edellinen dia',
progress: 'Edistyminen',
remove: 'Poista',
resize: 'Muuta kokoa',
scrollToEnd: 'Vieritä loppuun',
scrollToStart: 'Vieritä alkuun',
selectAColorFromTheScreen: 'Valitse väri näytöltä',
showPassword: 'Näytä salasana',
slideNum: slide => `Dia ${slide}`,
toggleColorFormat: 'Vaihda väriformaattia'
};
registerTranslation(translation);
export default translation;

39
src/translations/it.ts Normal file
View File

@@ -0,0 +1,39 @@
import { registerTranslation } from '@shoelace-style/localize';
import type { Translation } from '../utilities/localize.js';
const translation: Translation = {
$code: 'it',
$name: 'Italian',
$dir: 'ltr',
carousel: 'Carosello',
clearEntry: 'Cancella inserimento',
close: 'Chiudi',
copied: 'Copiato',
copy: 'Copia',
currentValue: 'Valore attuale',
error: 'Errore',
goToSlide: (slide, count) => `Vai alla diapositiva ${slide} di ${count}`,
hidePassword: 'Nascondi password',
loading: 'In caricamento',
nextSlide: 'Prossima diapositiva',
numOptionsSelected: num => {
if (num === 0) return 'Nessuna opzione selezionata';
if (num === 1) return '1 opzione selezionata';
return `${num} opzioni selezionate`;
},
previousSlide: 'Diapositiva precedente',
progress: 'Avanzamento',
remove: 'Rimuovi',
resize: 'Ridimensiona',
scrollToEnd: 'Scorri alla fine',
scrollToStart: "Scorri all'inizio",
selectAColorFromTheScreen: 'Seleziona un colore dalla schermo',
showPassword: 'Mostra password',
slideNum: slide => `Diapositiva ${slide}`,
toggleColorFormat: 'Cambia formato colore'
};
registerTranslation(translation);
export default translation;

41
src/translations/uk.ts Normal file
View File

@@ -0,0 +1,41 @@
import { registerTranslation } from '../utilities/localize.js';
import type { Translation } from '../utilities/localize.js';
const translation: Translation = {
$code: 'uk',
$name: 'Українська',
$dir: 'ltr',
carousel: 'Карусель',
clearEntry: 'Очистити поле',
close: 'Закрити',
copied: 'Скопійовано',
copy: 'Скопіювати',
currentValue: 'Поточне значення',
error: 'Збій',
goToSlide: (slide, count) => `Перейти до слайда №${slide} з ${count}`,
hidePassword: 'Приховати пароль',
loading: 'Завантаження',
nextSlide: 'Наступний слайд',
numOptionsSelected: num => {
const n = num % 10;
if (n === 0) return 'не вибрано варіантів';
if (n === 1) return 'вибрано 1 варіант';
if (n === 2 || n === 3 || n === 4) return `вибрано ${num} варіанти`;
return `вибрано ${num} варіантів`;
},
previousSlide: 'Попередній слайд',
progress: 'Поступ',
remove: 'Видалити',
resize: 'Змінити розмір',
scrollToEnd: 'Прокрутити в кінець',
scrollToStart: 'Прокрутити на початок',
selectAColorFromTheScreen: 'Виберіть колір на екрані',
showPassword: 'Показати пароль',
slideNum: slide => `Слайд ${slide}`,
toggleColorFormat: 'Переключити кольорову модель'
};
registerTranslation(translation);
export default translation;