Merge branch 'next' into current

This commit is contained in:
Cory LaViska
2024-10-24 16:29:23 -04:00
28 changed files with 546 additions and 76 deletions

View File

@@ -502,6 +502,109 @@ Remember that custom tags are rendered in a shadow root. To style them, you can
</script>
```
### Lazy loading options
Lazy loading options is very hard to get right. `<wa-select>` largely follows how a native `<select>` works.
Here are the following conditions:
- If a `<wa-select>` is created without any options, but is given a `value` attribute, its `value` will be `""`, and then when options are added, if any of the options have a value equal to the `<wa-select>` value, the value of the `<wa-select>` will equal that of the option.
EX: `<wa-select value="foo">` will have a value of `""` until `<wa-option value="foo">Foo</wa-option>` connects, at which point its value will become `"foo"` when submitting.
- If a `<wa-select multiple>` with an initial value has multiple values, but only some of the options are present, it will only respect the options that are present, and if a selected option is loaded in later, _AND_ the value of the select has not changed via user interaction or direct property assignment, it will add the selected option to the form value and to the `.value` of the select.
This can be hard to conceptualize, so heres a fairly large example showing how lazy loaded options work with `<wa-select>` and `<wa-select multiple>` when given initial value attributes. Feel free to play around with it in a codepen.
```html:preview
<form id="lazy-options-example">
<div>
<sl-select name="select-1" value="foo" label="Single select (with existing options)">
<sl-option value="bar">Bar</sl-option>
<sl-option value="baz">Baz</sl-option>
</sl-select>
<br>
<sl-button type="button">Add "foo" option</sl-button>
</div>
<br>
<div>
<sl-select name="select-2" value="foo" label="Single select (with no existing options)">
</sl-select>
<br>
<sl-button type="button">Add "foo" option</sl-button>
</div>
<br>
<div>
<sl-select name="select-3" value="foo bar baz" multiple label="Multiple Select (with existing options)">
<sl-option value="bar">Bar</sl-option>
<sl-option value="baz">Baz</sl-option>
</sl-select>
<br>
<sl-button type="button">Add "foo" option</sl-button>
</div>
<br>
<div>
<sl-select name="select-4" value="foo" multiple label="Multiple Select (with no existing options)">
</sl-select>
<br>
<sl-button type="button">Add "foo" option</sl-button>
</div>
<br><br>
<div style="display: flex; gap: 16px;">
<sl-button type="reset">Reset</sl-button>
<sl-button type="submit" variant="brand">Show FormData</sl-button>
</div>
<br>
<pre hidden><code id="lazy-options-example-form-data"></code></pre>
<br>
</form>
<script type="module">
function addFooOption(e) {
const addFooButton = e.target.closest("sl-button[type='button']")
if (!addFooButton) {
return
}
const select = addFooButton.parentElement.querySelector("sl-select")
if (select.querySelector("sl-option[value='foo']")) {
// Foo already exists. no-op.
return
}
const option = document.createElement("sl-option")
option.setAttribute("value", "foo")
option.innerText = "Foo"
select.append(option)
}
function handleLazySubmit (event) {
event.preventDefault()
const formData = new FormData(event.target)
const codeElement = document.querySelector("#lazy-options-example-form-data")
const obj = {}
for (const key of formData.keys()) {
const val = formData.getAll(key).length > 1 ? formData.getAll(key) : formData.get(key)
obj[key] = val
}
codeElement.textContent = JSON.stringify(obj, null, 2)
const preElement = codeElement.parentElement
preElement.removeAttribute("hidden")
}
const container = document.querySelector("#lazy-options-example")
container.addEventListener("click", addFooOption)
container.addEventListener("submit", handleLazySubmit)
</script>
```
:::warning
Be sure you trust the content you are outputting! Passing unsanitized user input to `getTag()` can result in XSS vulnerabilities.
:::

View File

@@ -195,7 +195,7 @@ Avoid using `setTimeout()` or `requestAnimationFrame()` in situations like this.
### VS Code
Shoelace ships with a file called `vscode.html-custom-data.json` that can be used to describe it's custom elements to Visual Studio Code. This enables code completion for Shoelace components (also known as "code hinting" or "IntelliSense"). To enable it, you need to tell VS Code where the file is.
Shoelace ships with a file called `vscode.html-custom-data.json` that can be used to describe its custom elements to Visual Studio Code. This enables code completion for Shoelace components (also known as "code hinting" or "IntelliSense"). To enable it, you need to tell VS Code where the file is.
1. [Install Shoelace locally](/getting-started/installation#local-installation)
2. If it doesn't already exist, create a folder called `.vscode` at the root of your project

View File

@@ -12,6 +12,21 @@ Components with the <sl-badge variant="warning" pill>Experimental</sl-badge> bad
New versions of Shoelace are released as-needed and generally occur when a critical mass of changes have accumulated. At any time, you can see what's coming in the next release by visiting [next.shoelace.style](https://next.shoelace.style).
## 2.18.0
- Added Finnish translations [#2211]
- Added the `.focus` function to `<sl-radio-group>` [#2192]
- Fixed a bug in `<sl-tab-group>` when removed from the DOM too quickly. [#2218]
- Fixed a bug with `<sl-select>` not respecting its initial value. [#2204]
- Fixed a bug with certain bundlers when using dynamic imports [#2210]
- Fixed a bug in `<sl-textarea>` causing scroll jumping when using `resize="auto"` [#2182]
- Fixed a bug in `<sl-relative-time>` where the title attribute would show with redundant info [#2184]
- Fixed a bug in `<sl-select>` that caused multi-selects without placeholders to have the wrong padding [#2194]
- Fixed a bug in `<sl-tooltip>` that caused a memory leak in disconnected elements [#2226]
- Fixed a bug in `<sl-select>` that caused an exception in an edge case using Edge + autofill [#2221]
- Improved the behavior of navigation dots in `<sl-carousel>` [#2220]
- Updated all checks for directionality to use `this.localize.dir()` instead of `el.matches(:dir(rtl))` so older browsers don't error out [#2188]
## 2.17.1
- Fixed a bug in `<sl-icon>` not applying the mutator when loading multiple icons of the same name from a spritesheet. [#2178]

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@shoelace-style/shoelace",
"version": "2.17.1",
"version": "2.18.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@shoelace-style/shoelace",
"version": "2.17.1",
"version": "2.18.0",
"license": "MIT",
"dependencies": {
"@ctrl/tinycolor": "^4.0.2",

View File

@@ -1,7 +1,7 @@
{
"name": "@shoelace-style/shoelace",
"description": "A forward-thinking library of web components.",
"version": "2.17.1",
"version": "2.18.0",
"homepage": "https://github.com/shoelace-style/shoelace",
"author": "Cory LaViska",
"license": "MIT",
@@ -18,11 +18,15 @@
"./dist/custom-elements.json": "./dist/custom-elements.json",
"./dist/shoelace.js": "./dist/shoelace.js",
"./dist/shoelace-autoloader.js": "./dist/shoelace-autoloader.js",
"./dist/themes": "./dist/themes",
"./dist/themes/*": "./dist/themes/*",
"./dist/components": "./dist/components",
"./dist/components/*": "./dist/components/*",
"./dist/utilities": "./dist/utilities",
"./dist/utilities/*": "./dist/utilities/*",
"./dist/react": "./dist/react/index.js",
"./dist/react/*": "./dist/react/*",
"./dist/translations": "./dist/translations",
"./dist/translations/*": "./dist/translations/*"
},
"files": [

View File

@@ -94,6 +94,7 @@ export default class SlCarousel extends ShoelaceElement {
private autoplayController = new AutoplayController(this, () => this.next());
private readonly localize = new LocalizeController(this);
private mutationObserver: MutationObserver;
private pendingSlideChange = false;
connectedCallback(): void {
super.connectedCallback();
@@ -153,7 +154,7 @@ export default class SlCarousel extends ShoelaceElement {
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');
@@ -261,6 +262,9 @@ export default class SlCarousel extends ShoelaceElement {
@eventOptions({ passive: true })
private handleScroll() {
this.scrolling = true;
if (!this.pendingSlideChange) {
this.synchronizeSlides();
}
}
/** @internal Synchronizes the slides with the IntersectionObserver API. */
@@ -277,20 +281,28 @@ export default class SlCarousel extends ShoelaceElement {
}
const firstIntersecting = entries.find(entry => entry.isIntersecting);
if (!firstIntersecting) {
return;
}
if (firstIntersecting) {
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 SlCarouselItem);
// Normalize the index to ignore clones
const normalizedIndex = this.loop ? slideIndex - this.slidesPerPage : slideIndex;
// 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');
} else {
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;
}
}
},
@@ -307,10 +319,9 @@ export default class SlCarousel extends ShoelaceElement {
private handleScrollEnd() {
if (!this.scrolling || this.dragging) return;
this.synchronizeSlides();
this.scrolling = false;
this.pendingSlideChange = false;
this.synchronizeSlides();
}
private isCarouselItem(node: Node): node is SlCarouselItem {
@@ -380,7 +391,7 @@ export default class SlCarousel extends ShoelaceElement {
}
@watch('activeSlide')
handelSlideChange() {
handleSlideChange() {
const slides = this.getSlides();
slides.forEach((slide, i) => {
slide.classList.toggle('--is-active', i === this.activeSlide);
@@ -461,7 +472,7 @@ export default class SlCarousel extends ShoelaceElement {
: 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.
@@ -485,11 +496,14 @@ export default class SlCarousel extends ShoelaceElement {
const nextLeft = nextSlideRect.left - scrollContainerRect.left;
const nextTop = nextSlideRect.top - scrollContainerRect.top;
scrollContainer.scrollTo({
left: nextLeft + scrollContainer.scrollLeft,
top: nextTop + scrollContainer.scrollTop,
behavior
});
if (nextLeft || nextTop) {
this.pendingSlideChange = true;
scrollContainer.scrollTo({
left: nextLeft + scrollContainer.scrollLeft,
top: nextTop + scrollContainer.scrollTop,
behavior
});
}
}
render() {
@@ -498,7 +512,7 @@ export default class SlCarousel extends ShoelaceElement {
const currentPage = this.getCurrentPage();
const prevEnabled = this.canScrollPrev();
const nextEnabled = this.canScrollNext();
const isLtr = this.matches(':dir(ltr)');
const isLtr = this.localize.dir() === 'rtl';
return html`
<div part="base" class="carousel">

View File

@@ -187,7 +187,7 @@ export default class SlDetails extends ShoelaceElement {
}
render() {
const isRtl = this.matches(':dir(rtl)');
const isRtl = this.localize.dir() === 'rtl';
return html`
<details

View File

@@ -2,6 +2,7 @@ import { clamp } from '../../internal/math.js';
import { classMap } from 'lit/directives/class-map.js';
import { drag } from '../../internal/drag.js';
import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize.js';
import { property, query } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import { watch } from '../../internal/watch.js';
@@ -38,6 +39,8 @@ export default class SlImageComparer extends ShoelaceElement {
static styles: CSSResultGroup = [componentStyles, styles];
static scopedElement = { 'sl-icon': SlIcon };
private readonly localize = new LocalizeController(this);
@query('.image-comparer') base: HTMLElement;
@query('.image-comparer__handle') handle: HTMLElement;
@@ -46,7 +49,7 @@ export default class SlImageComparer extends ShoelaceElement {
private handleDrag(event: PointerEvent) {
const { width } = this.base.getBoundingClientRect();
const isRtl = this.matches(':dir(rtl)');
const isRtl = this.localize.dir() === 'rtl';
event.preventDefault();
@@ -60,8 +63,8 @@ export default class SlImageComparer extends ShoelaceElement {
}
private handleKeyDown(event: KeyboardEvent) {
const isLtr = this.matches(':dir(ltr)');
const isRtl = this.matches(':dir(rtl)');
const isLtr = this.localize.dir() === 'ltr';
const isRtl = this.localize.dir() === 'rtl';
if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) {
const incr = event.shiftKey ? 10 : 1;
@@ -93,7 +96,7 @@ export default class SlImageComparer extends ShoelaceElement {
}
render() {
const isRtl = this.matches(':dir(rtl)');
const isRtl = this.localize.dir() === 'rtl';
return html`
<div

View File

@@ -1,6 +1,7 @@
import { classMap } from 'lit/directives/class-map.js';
import { getTextContent, HasSlotController } from '../../internal/slot.js';
import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize.js';
import { property, query } from 'lit/decorators.js';
import { SubmenuController } from './submenu-controller.js';
import { watch } from '../../internal/watch.js';
@@ -47,6 +48,7 @@ export default class SlMenuItem extends ShoelaceElement {
};
private cachedTextLabel: string;
private readonly localize = new LocalizeController(this);
@query('slot:not([name])') defaultSlot: HTMLSlotElement;
@query('.menu-item') menuItem: HTMLElement;
@@ -153,7 +155,7 @@ export default class SlMenuItem extends ShoelaceElement {
}
render() {
const isRtl = this.matches(':dir(rtl)');
const isRtl = this.localize.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 === 'sl-menu')[0];
const isRtl = this.host.matches(':dir(rtl)');
const isRtl = getComputedStyle(this.host).direction === 'rtl';
if (!menu) {
return;
}
@@ -259,7 +259,7 @@ export class SubmenuController implements ReactiveController {
}
renderSubmenu() {
const isRtl = this.host.matches(':dir(rtl)');
const isRtl = getComputedStyle(this.host).direction === 'rtl';
// Always render the slot, but conditionally render the outer <sl-popup>
if (!this.isConnected) {

View File

@@ -1,6 +1,7 @@
import { arrow, autoUpdate, computePosition, flip, offset, platform, shift, size } from '@floating-ui/dom';
import { classMap } from 'lit/directives/class-map.js';
import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize.js';
import { offsetParent } from 'composed-offset-position';
import { property, query } from 'lit/decorators.js';
import componentStyles from '../../styles/component.styles.js';
@@ -56,6 +57,7 @@ export default class SlPopup extends ShoelaceElement {
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;
@@ -413,7 +415,7 @@ export default class SlPopup extends ShoelaceElement {
//
// 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

@@ -192,14 +192,7 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor
}
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 handleInvalid(event: Event) {
@@ -325,6 +318,20 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor
this.formControlController.updateValidity();
}
/** 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 is not disabled
if (radioToFocus) {
radioToFocus.focus(options);
}
}
render() {
const hasLabelSlot = this.hasSlotController.test('label');
const hasHelpTextSlot = this.hasSlotController.test('help-text');

View File

@@ -300,6 +300,102 @@ describe('when a size is applied', () => {
});
});
describe('when handling focus', () => {
const doAction = async (instance: SlRadioGroup, 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<SlRadioGroup>(html`
<sl-radio-group>
<sl-radio id="radio-0" value="0" disabled></sl-radio>
<sl-radio id="radio-1" value="1" disabled></sl-radio>
<sl-radio id="radio-2" value="2" disabled></sl-radio>
<sl-radio id="radio-3" value="3" disabled></sl-radio>
</sl-radio-group>
`);
const validFocusHandler = sinon.spy();
Array.from(el.querySelectorAll<SlRadio>('sl-radio')).forEach(radio =>
radio.addEventListener('sl-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<SlRadioGroup>(html`
<sl-radio-group>
<sl-radio id="radio-0" value="0" disabled></sl-radio>
<sl-radio id="radio-1" value="1"></sl-radio>
<sl-radio id="radio-2" value="2"></sl-radio>
<sl-radio id="radio-3" value="3"></sl-radio>
</sl-radio-group>
`);
const invalidFocusHandler = sinon.spy();
const validFocusHandler = sinon.spy();
const disabledRadio = el.querySelector('#radio-0')!;
const validRadio = el.querySelector('#radio-1')!;
disabledRadio.addEventListener('sl-focus', invalidFocusHandler);
validRadio.addEventListener('sl-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<SlRadioGroup>(html`
<sl-radio-group value="2">
<sl-radio id="radio-0" value="0" disabled></sl-radio>
<sl-radio id="radio-1" value="1"></sl-radio>
<sl-radio id="radio-2" value="2" checked></sl-radio>
<sl-radio id="radio-3" value="3"></sl-radio>
</sl-radio-group>
`);
const invalidFocusHandler = sinon.spy();
const validFocusHandler = sinon.spy();
const disabledRadio = el.querySelector('#radio-0')!;
const validRadio = el.querySelector('#radio-2')!;
disabledRadio.addEventListener('sl-focus', invalidFocusHandler);
validRadio.addEventListener('sl-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 sl-change when toggled with the arrow keys', async () => {
const radioGroup = await fixture<SlRadioGroup>(html`

View File

@@ -175,7 +175,7 @@ export default class SlRange extends ShoelaceElement implements ShoelaceFormCont
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

@@ -2,6 +2,7 @@ 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 { styleMap } from 'lit/directives/style-map.js';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { watch } from '../../internal/watch.js';
@@ -35,6 +36,8 @@ export default class SlRating extends ShoelaceElement {
static styles: CSSResultGroup = [componentStyles, styles];
static dependencies = { 'sl-icon': SlIcon };
private readonly localize = new LocalizeController(this);
@query('.rating') rating: HTMLElement;
@state() private hoverValue = 0;
@@ -77,7 +80,7 @@ export default class SlRating extends ShoelaceElement {
}
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)
@@ -105,8 +108,8 @@ export default class SlRating extends ShoelaceElement {
}
private handleKeyDown(event: KeyboardEvent) {
const isLtr = this.matches(':dir(ltr)');
const isRtl = this.matches(':dir(rtl)');
const isLtr = this.localize.dir() === 'ltr';
const isRtl = this.localize.dir() === 'rtl';
const oldValue = this.value;
if (this.disabled || this.readonly) {
@@ -211,7 +214,7 @@ export default class SlRating extends ShoelaceElement {
}
render() {
const isRtl = this.matches(':dir(rtl)');
const isRtl = this.localize.dir() === 'rtl';
const counter = Array.from(Array(this.max).keys());
let displayValue = 0;

View File

@@ -99,7 +99,7 @@ export default class SlRelativeTime extends ShoelaceElement {
this.updateTimeout = window.setTimeout(() => this.requestUpdate(), nextInterval);
}
return html` <time datetime=${this.isoTime} title=${this.relativeTime}>${this.relativeTime}</time> `;
return html` <time datetime=${this.isoTime}>${this.relativeTime}</time> `;
}
}

View File

@@ -97,6 +97,7 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
@state() displayLabel = '';
@state() currentOption: SlOption;
@state() selectedOptions: SlOption[] = [];
@state() private valueHasChanged: boolean = false;
/** The name of the select, submitted as a name/value pair with form data. */
@property() name = '';
@@ -216,6 +217,10 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
connectedCallback() {
super.connectedCallback();
setTimeout(() => {
this.handleDefaultSlotChange();
});
// Because this is a form control, it shouldn't be opened initially
this.open = false;
}
@@ -310,6 +315,7 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
// If it is open, update the value based on the current selection and close it
if (this.currentOption && !this.currentOption.disabled) {
this.valueHasChanged = true;
if (this.multiple) {
this.toggleOptionSelection(this.currentOption);
} else {
@@ -367,7 +373,7 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
}
// All other "printable" keys trigger type to select
if (event.key.length === 1 || event.key === 'Backspace') {
if ((event.key && event.key.length === 1) || event.key === 'Backspace') {
const allOptions = this.getAllOptions();
// Don't block important key combos like CMD+R
@@ -470,6 +476,7 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
const oldValue = this.value;
if (option && !option.disabled) {
this.valueHasChanged = true;
if (this.multiple) {
this.toggleOptionSelection(option);
} else {
@@ -495,20 +502,20 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
}
private handleDefaultSlotChange() {
if (!customElements.get('wa-option')) {
customElements.whenDefined('wa-option').then(() => this.handleDefaultSlotChange());
}
const allOptions = this.getAllOptions();
const value = Array.isArray(this.value) ? this.value : [this.value];
const val = this.valueHasChanged ? this.value : this.defaultValue;
const value = Array.isArray(val) ? val : [val];
const values: string[] = [];
// Check for duplicate values in menu items
if (customElements.get('sl-option')) {
allOptions.forEach(option => values.push(option.value));
allOptions.forEach(option => values.push(option.value));
// Select only the options that match the new value
this.setSelectedOptions(allOptions.filter(el => value.includes(el.value)));
} else {
// Rerun this handler when <sl-option> is registered
customElements.whenDefined('sl-option').then(() => this.handleDefaultSlotChange());
}
// Select only the options that match the new value
this.setSelectedOptions(allOptions.filter(el => value.includes(el.value)));
}
private handleTagRemove(event: SlRemoveEvent, option: SlOption) {
@@ -586,8 +593,9 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
// This method must be called whenever the selection changes. It will update the selected options cache, the current
// value, and the display value
private selectionChanged() {
const options = this.getAllOptions();
// Update selected options cache
this.selectedOptions = this.getAllOptions().filter(el => el.selected);
this.selectedOptions = options.filter(el => el.selected);
// Update the value and display label
if (this.multiple) {
@@ -600,8 +608,9 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
this.displayLabel = this.localize.term('numOptionsSelected', this.selectedOptions.length);
}
} else {
this.value = this.selectedOptions[0]?.value ?? '';
this.displayLabel = this.selectedOptions[0]?.getTextLabel() ?? '';
const selectedOption = this.selectedOptions[0];
this.value = selectedOption?.value ?? '';
this.displayLabel = selectedOption?.getTextLabel?.() ?? '';
}
// Update validity
@@ -750,7 +759,7 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
const hasLabel = this.label ? true : !!hasLabelSlot;
const hasHelpText = this.helpText ? true : !!hasHelpTextSlot;
const hasClearIcon = this.clearable && !this.disabled && this.value.length > 0;
const isPlaceholderVisible = this.placeholder && this.value.length === 0;
const isPlaceholderVisible = this.placeholder && this.value && this.value.length <= 0;
return html`
<div

View File

@@ -209,7 +209,7 @@ export default css`
margin-inline-start: var(--sl-input-spacing-medium);
}
.select--medium.select--multiple:not(.select--placeholder-visible) .select__combobox {
.select--medium.select--multiple .select__combobox {
padding-inline-start: 0;
padding-block: 3px;
}
@@ -238,7 +238,7 @@ export default css`
margin-inline-start: var(--sl-input-spacing-large);
}
.select--large.select--multiple:not(.select--placeholder-visible) .select__combobox {
.select--large.select--multiple .select__combobox {
padding-inline-start: 0;
padding-block: 4px;
}

View File

@@ -206,6 +206,29 @@ describe('<sl-select>', () => {
expect(handler).to.be.calledTwice;
});
// this can happen in on ms-edge autofilling 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<SlSelect>(html`
<sl-select required>
<sl-option value="option-1">Option 1</sl-option>
</sl-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 */
el.handleDocumentKeyDown(event);
});
});
it('should open the listbox when any letter key is pressed with sl-select is on focus', async () => {
@@ -593,6 +616,129 @@ describe('<sl-select>', () => {
expect(tag.hasAttribute('pill')).to.be.true;
});
describe('With lazily loaded options', () => {
describe('With no existing options', () => {
it('Should wait to select the option when the option exists for single select', async () => {
const form = await fixture<HTMLFormElement>(
html`<form><sl-select name="select" value="option-1"></sl-select></form>`
);
const el = form.querySelector<SlSelect>('sl-select')!;
await aTimeout(10);
expect(el.value).to.equal('');
expect(new FormData(form).get('select')).equal('');
const option = document.createElement('sl-option');
option.value = 'option-1';
option.innerText = 'Option 1';
el.append(option);
await aTimeout(10);
await el.updateComplete;
expect(el.value).to.equal('option-1');
expect(new FormData(form).get('select')).equal('option-1');
});
it('Should wait to select the option when the option exists for multiple select', async () => {
const form = await fixture<HTMLFormElement>(
html`<form><sl-select name="select" value="option-1" multiple></sl-select></form>`
);
const el = form.querySelector<SlSelect>('sl-select')!;
expect(Array.isArray(el.value)).to.equal(true);
expect(el.value.length).to.equal(0);
const option = document.createElement('sl-option');
option.value = 'option-1';
option.innerText = 'Option 1';
el.append(option);
await aTimeout(10);
await el.updateComplete;
expect(el.value.length).to.equal(1);
expect(el.value).to.have.members(['option-1']);
expect(new FormData(form).getAll('select')).have.members(['option-1']);
});
});
describe('With existing options', () => {
it('Should not select the option if options already exist for single select', async () => {
const form = await fixture<HTMLFormElement>(
html` <form>
<sl-select name="select" value="foo">
<sl-option value="bar">Bar</sl-option>
<sl-option value="baz">Baz</sl-option>
</sl-select>
</form>`
);
const el = form.querySelector<SlSelect>('sl-select')!;
expect(el.value).to.equal('');
expect(new FormData(form).get('select')).to.equal('');
const option = document.createElement('sl-option');
option.value = 'foo';
option.innerText = 'Foo';
el.append(option);
await aTimeout(10);
await el.updateComplete;
expect(el.value).to.equal('foo');
expect(new FormData(form).get('select')).to.equal('foo');
});
it('Should not select the option if options already exists for multiple select', async () => {
const form = await fixture<HTMLFormElement>(
html` <form>
<sl-select name="select" value="foo" multiple>
<sl-option value="bar">Bar</sl-option>
<sl-option value="baz">Baz</sl-option>
</sl-select>
</form>`
);
const el = form.querySelector<SlSelect>('sl-select')!;
expect(el.value).to.be.an('array');
expect(el.value.length).to.equal(0);
const option = document.createElement('sl-option');
option.value = 'foo';
option.innerText = 'Foo';
el.append(option);
await aTimeout(10);
await el.updateComplete;
expect(el.value).to.have.members(['foo']);
expect(new FormData(form).getAll('select')).to.have.members(['foo']);
});
it('Should only select the existing options if options already exists for multiple select', async () => {
const form = await fixture<HTMLFormElement>(
html` <form>
<sl-select name="select" value="foo bar baz" multiple>
<sl-option value="bar">Bar</sl-option>
<sl-option value="baz">Baz</sl-option>
</sl-select>
</form>`
);
const el = form.querySelector<SlSelect>('sl-select')!;
expect(el.value).to.have.members(['bar', 'baz']);
expect(el.value.length).to.equal(2);
expect(new FormData(form).getAll('select')).to.have.members(['bar', 'baz']);
const option = document.createElement('sl-option');
option.value = 'foo';
option.innerText = 'Foo';
el.append(option);
await aTimeout(10);
await el.updateComplete;
expect(el.value).to.have.members(['foo', 'bar', 'baz']);
expect(new FormData(form).getAll('select')).to.have.members(['foo', 'bar', 'baz']);
});
});
});
runFormControlBaseTests('sl-select');
});

View File

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

View File

@@ -46,14 +46,13 @@ export default class SlTabGroup extends ShoelaceElement {
static styles: CSSResultGroup = [componentStyles, styles];
static dependencies = { 'sl-icon-button': SlIconButton, 'sl-resize-observer': SlResizeObserver };
private readonly localize = new LocalizeController(this);
private activeTab?: SlTab;
private mutationObserver: MutationObserver;
private resizeObserver: ResizeObserver;
private tabs: SlTab[] = [];
private focusableTabs: SlTab[] = [];
private panels: SlTabPanel[] = [];
private readonly localize = new LocalizeController(this);
@query('.tab-group') tabGroup: HTMLElement;
@query('.tab-group__body') body: HTMLSlotElement;
@@ -129,7 +128,10 @@ export default class SlTabGroup extends ShoelaceElement {
disconnectedCallback() {
super.disconnectedCallback();
this.mutationObserver?.disconnect();
this.resizeObserver?.unobserve(this.nav);
if (this.nav) {
this.resizeObserver?.unobserve(this.nav);
}
}
private getAllTabs() {
@@ -182,7 +184,7 @@ export default class SlTabGroup extends ShoelaceElement {
// 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 | SlTab = null;
if (activeEl?.tagName.toLowerCase() === 'sl-tab') {
@@ -302,7 +304,7 @@ export default class SlTabGroup extends ShoelaceElement {
const width = currentTab.clientWidth;
const height = currentTab.clientHeight;
const isRtl = this.matches(':dir(rtl)');
const isRtl = this.localize.dir() === 'rtl';
// We can't used offsetLeft/offsetTop here due to a shadow parent issue where neither can getBoundingClientRect
// because it provides invalid values for animating elements: https://bugs.chromium.org/p/chromium/issues/detail?id=920069
@@ -434,7 +436,7 @@ export default class SlTabGroup extends ShoelaceElement {
}
render() {
const isRtl = this.matches(':dir(rtl)');
const isRtl = this.localize.dir() === 'rtl';
return html`
<div

View File

@@ -84,6 +84,13 @@ describe('<sl-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<SlTabGroup>(html`
<sl-tab-group>

View File

@@ -46,6 +46,7 @@ export default class SlTextarea extends ShoelaceElement implements ShoelaceFormC
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
@@ -196,6 +197,8 @@ export default class SlTextarea extends ShoelaceElement implements ShoelaceFormC
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 {
@@ -370,6 +373,8 @@ export default class SlTextarea extends ShoelaceElement implements ShoelaceFormC
@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

@@ -6,7 +6,7 @@ export default css`
}
.textarea {
display: flex;
display: grid;
align-items: center;
position: relative;
width: 100%;
@@ -55,6 +55,17 @@ export default css`
cursor: not-allowed;
}
.textarea__control,
.textarea__size-adjuster {
grid-area: 1 / 1 / 2 / 2;
}
.textarea__size-adjuster {
visibility: hidden;
pointer-events: none;
opacity: 0;
}
.textarea--standard.textarea--disabled .textarea__control {
color: var(--sl-input-color-disabled);
}
@@ -87,7 +98,6 @@ export default css`
}
.textarea__control {
flex: 1 1 auto;
font-family: inherit;
font-size: inherit;
font-weight: inherit;

View File

@@ -109,6 +109,7 @@ export default class SlTooltip extends ShoelaceElement {
}
disconnectedCallback() {
super.disconnectedCallback();
// Cleanup this event in case the tooltip is removed while open
this.closeWatcher?.destroy();
document.removeEventListener('keydown', this.handleDocumentKeyDown);

View File

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

View File

@@ -1,5 +1,6 @@
import { clamp } from '../../internal/math.js';
import { html } from 'lit';
import { LocalizeController } from '../../utilities/localize.js';
import { property, query } from 'lit/decorators.js';
import { watch } from '../../internal/watch.js';
import componentStyles from '../../styles/component.styles.js';
@@ -89,6 +90,7 @@ export default class SlTree extends ShoelaceElement {
private lastFocusedItem: SlTreeItem | null;
private mutationObserver: MutationObserver;
private clickTarget: SlTreeItem | null = null;
private readonly localize = new LocalizeController(this);
constructor() {
super();
@@ -222,8 +224,8 @@ export default class SlTree extends ShoelaceElement {
}
const items = this.getFocusableItems();
const isLtr = this.matches(':dir(ltr)');
const isRtl = this.matches(':dir(rtl)');
const isLtr = this.localize.dir() === 'ltr';
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;