reorg and add private keyword

pull/1102/head
Cory LaViska 2023-01-03 15:04:07 -05:00
rodzic 96e41198ec
commit c8555f448c
41 zmienionych plików z 1260 dodań i 1245 usunięć

Wyświetl plik

@ -23,6 +23,8 @@ This release includes a complete rewrite of `<sl-select>` to improve accessibili
- Added Traditional Chinese translation [#1086](https://github.com/shoelace-style/shoelace/pull/1086)
- Fixed a bug in `<sl-tree-item>` where the checked/indeterminate states could get out of sync when using the `multiple` option [#1076](https://github.com/shoelace-style/shoelace/issues/1076)
- Fixed a bug in `<sl-tree>` that caused `sl-selection-change` to emit before the DOM updated [#1096](https://github.com/shoelace-style/shoelace/issues/1096)
- Reorganized all components to make class structures more consistent
- Updated non-public fields to use the `private` keyword (these were previously private only by convention, but now TypeScript will warn you)
- Updated the hover style of `<sl-menu-item>` to be consistent with `<sl-option>`
- Updated the status of `<sl-tree>` and `<sl-tree-item>` from experimental to stable
- Updated React wrappers to use the latest API from `@lit-labs/react` [#1090](https://github.com/shoelace-style/shoelace/pull/1090)

Wyświetl plik

@ -200,6 +200,23 @@ All components have a host element, which is a reference to the `<sl-*>` element
Aside from `display`, avoid setting styles on the host element when possible. The reason for this is that styles applied to the host element are not encapsulated. Instead, create a base element that wraps the component's internals and style that instead. This convention also makes it easier to use BEM in components, as the base element can serve as the "block" entity.
When authoring components, please try to follow the same structure and conventions found in other components. Classes, for example, generally follow this structure:
- Static properties/methods
- Private/public properties (that are _not_ reactive)
- `@query` decorators
- `@state` decorators
- `@property` decorators
- Lifecycle methods (`connectedCallback()`, `disconnectedCallback()`, `firstUpdated()`, etc.)
- Private methods
- `@watch` decorators
- Public methods
- The `render()` method
Please avoid using the `public` keyword for class fields. It's simply too verbose when combined with decorators, property names, and arguments. However, _please do_ add `private` in front of any property or method that is intended to be private.
?> This might seem like a lot, but it's fairly intuitive once you start working with the library. However, don't let this structure prevent you from submitting a PR. [Code can change](https://www.abeautifulsite.net/posts/code-can-change/) and nobody will chastise you for "getting it wrong." At the same time, encouraging consistency helps keep the library maintainable and easy for others to understand. (A lint rule that helps with things like this would be a very welcome PR!)
### Class Names
All components use a [shadow DOM](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_shadow_DOM), so styles are completely encapsulated from the rest of the document. As a result, class names used _inside_ a component won't conflict with class names _outside_ the component, so we're free to name them anything we want.

Wyświetl plik

@ -73,6 +73,57 @@ export default class SlAlert extends ShoelaceElement {
this.base.hidden = !this.open;
}
private restartAutoHide() {
clearTimeout(this.autoHideTimeout);
if (this.open && this.duration < Infinity) {
this.autoHideTimeout = window.setTimeout(() => this.hide(), this.duration);
}
}
private handleCloseClick() {
this.hide();
}
private handleMouseMove() {
this.restartAutoHide();
}
@watch('open', { waitUntilFirstUpdate: true })
async handleOpenChange() {
if (this.open) {
// Show
this.emit('sl-show');
if (this.duration < Infinity) {
this.restartAutoHide();
}
await stopAnimations(this.base);
this.base.hidden = false;
const { keyframes, options } = getAnimation(this, 'alert.show', { dir: this.localize.dir() });
await animateTo(this.base, keyframes, options);
this.emit('sl-after-show');
} else {
// Hide
this.emit('sl-hide');
clearTimeout(this.autoHideTimeout);
await stopAnimations(this.base);
const { keyframes, options } = getAnimation(this, 'alert.hide', { dir: this.localize.dir() });
await animateTo(this.base, keyframes, options);
this.base.hidden = true;
this.emit('sl-after-hide');
}
}
@watch('duration')
handleDurationChange() {
this.restartAutoHide();
}
/** Shows the alert. */
async show() {
if (this.open) {
@ -129,57 +180,6 @@ export default class SlAlert extends ShoelaceElement {
});
}
restartAutoHide() {
clearTimeout(this.autoHideTimeout);
if (this.open && this.duration < Infinity) {
this.autoHideTimeout = window.setTimeout(() => this.hide(), this.duration);
}
}
handleCloseClick() {
this.hide();
}
handleMouseMove() {
this.restartAutoHide();
}
@watch('open', { waitUntilFirstUpdate: true })
async handleOpenChange() {
if (this.open) {
// Show
this.emit('sl-show');
if (this.duration < Infinity) {
this.restartAutoHide();
}
await stopAnimations(this.base);
this.base.hidden = false;
const { keyframes, options } = getAnimation(this, 'alert.show', { dir: this.localize.dir() });
await animateTo(this.base, keyframes, options);
this.emit('sl-after-show');
} else {
// Hide
this.emit('sl-hide');
clearTimeout(this.autoHideTimeout);
await stopAnimations(this.base);
const { keyframes, options } = getAnimation(this, 'alert.hide', { dir: this.localize.dir() });
await animateTo(this.base, keyframes, options);
this.base.hidden = true;
this.emit('sl-after-hide');
}
}
@watch('duration')
handleDurationChange() {
this.restartAutoHide();
}
render() {
return html`
<div

Wyświetl plik

@ -29,11 +29,11 @@ import type { CSSResultGroup } from 'lit';
export default class SlAnimatedImage extends ShoelaceElement {
static styles: CSSResultGroup = styles;
@query('.animated-image__animated') animatedImage: HTMLImageElement;
@state() frozenFrame: string;
@state() isLoaded = false;
@query('.animated-image__animated') animatedImage: HTMLImageElement;
/** The path to the image to load. */
@property() src: string;
@ -43,11 +43,11 @@ export default class SlAnimatedImage extends ShoelaceElement {
/** Plays the animation. When this attribute is remove, the animation will pause. */
@property({ type: Boolean, reflect: true }) play: boolean;
handleClick() {
private handleClick() {
this.play = !this.play;
}
handleLoad() {
private handleLoad() {
const canvas = document.createElement('canvas');
const { width, height } = this.animatedImage;
canvas.width = width;
@ -61,7 +61,7 @@ export default class SlAnimatedImage extends ShoelaceElement {
}
}
handleError() {
private handleError() {
this.emit('sl-error');
}

Wyświetl plik

@ -100,68 +100,24 @@ export default class SlAnimation extends ShoelaceElement {
this.destroyAnimation();
}
@watch('name')
@watch('delay')
@watch('direction')
@watch('duration')
@watch('easing')
@watch('endDelay')
@watch('fill')
@watch('iterations')
@watch('iterationsStart')
@watch('keyframes')
handleAnimationChange() {
if (!this.hasUpdated) {
return;
}
this.createAnimation();
}
handleAnimationFinish() {
private handleAnimationFinish() {
this.play = false;
this.hasStarted = false;
this.emit('sl-finish');
}
handleAnimationCancel() {
private handleAnimationCancel() {
this.play = false;
this.hasStarted = false;
this.emit('sl-cancel');
}
@watch('play')
handlePlayChange() {
if (this.animation) {
if (this.play && !this.hasStarted) {
this.hasStarted = true;
this.emit('sl-start');
}
if (this.play) {
this.animation.play();
} else {
this.animation.pause();
}
return true;
}
return false;
}
@watch('playbackRate')
handlePlaybackRateChange() {
if (this.animation) {
this.animation.playbackRate = this.playbackRate;
}
}
handleSlotChange() {
private handleSlotChange() {
this.destroyAnimation();
this.createAnimation();
}
async createAnimation() {
private async createAnimation() {
const easing = animations.easings[this.easing] ?? this.easing;
const keyframes = this.keyframes ?? (animations as unknown as Partial<Record<string, Keyframe[]>>)[this.name];
const slot = await this.defaultSlot;
@ -196,7 +152,7 @@ export default class SlAnimation extends ShoelaceElement {
return true;
}
destroyAnimation() {
private destroyAnimation() {
if (this.animation) {
this.animation.cancel();
this.animation.removeEventListener('cancel', this.handleAnimationCancel);
@ -205,6 +161,50 @@ export default class SlAnimation extends ShoelaceElement {
}
}
@watch('name')
@watch('delay')
@watch('direction')
@watch('duration')
@watch('easing')
@watch('endDelay')
@watch('fill')
@watch('iterations')
@watch('iterationsStart')
@watch('keyframes')
handleAnimationChange() {
if (!this.hasUpdated) {
return;
}
this.createAnimation();
}
@watch('play')
handlePlayChange() {
if (this.animation) {
if (this.play && !this.hasStarted) {
this.hasStarted = true;
this.emit('sl-start');
}
if (this.play) {
this.animation.play();
} else {
this.animation.pause();
}
return true;
}
return false;
}
@watch('playbackRate')
handlePlaybackRateChange() {
if (this.animation) {
this.animation.playbackRate = this.playbackRate;
}
}
/** Clears all keyframe effects caused by this animation and aborts its playback. */
cancel() {
this.animation?.cancel();

Wyświetl plik

@ -24,12 +24,12 @@ import type { CSSResultGroup } from 'lit';
export default class SlBreadcrumb extends ShoelaceElement {
static styles: CSSResultGroup = styles;
@query('slot') defaultSlot: HTMLSlotElement;
@query('slot[name="separator"]') separatorSlot: HTMLSlotElement;
private readonly localize = new LocalizeController(this);
private separatorDir = this.localize.dir();
@query('slot') defaultSlot: HTMLSlotElement;
@query('slot[name="separator"]') separatorSlot: HTMLSlotElement;
/**
* The label to use for the breadcrumb control. This will not be shown on the screen, but it will be announced by
* screen readers and other assistive devices to provide more context for users.
@ -49,7 +49,7 @@ export default class SlBreadcrumb extends ShoelaceElement {
return clone;
}
handleSlotChange() {
private handleSlotChange() {
const items = [...this.defaultSlot.assignedElements({ flatten: true })].filter(
item => item.tagName.toLowerCase() === 'sl-breadcrumb-item'
) as SlBreadcrumbItem[];

Wyświetl plik

@ -28,27 +28,27 @@ export default class SlButtonGroup extends ShoelaceElement {
*/
@property() label = '';
handleFocus(event: CustomEvent) {
private handleFocus(event: CustomEvent) {
const button = findButton(event.target as HTMLElement);
button?.classList.add('sl-button-group__button--focus');
}
handleBlur(event: CustomEvent) {
private handleBlur(event: CustomEvent) {
const button = findButton(event.target as HTMLElement);
button?.classList.remove('sl-button-group__button--focus');
}
handleMouseOver(event: CustomEvent) {
private handleMouseOver(event: CustomEvent) {
const button = findButton(event.target as HTMLElement);
button?.classList.add('sl-button-group__button--hover');
}
handleMouseOut(event: CustomEvent) {
private handleMouseOut(event: CustomEvent) {
const button = findButton(event.target as HTMLElement);
button?.classList.remove('sl-button-group__button--hover');
}
handleSlotChange() {
private handleSlotChange() {
const slottedElements = [...this.defaultSlot.assignedElements({ flatten: true })] as HTMLElement[];
slottedElements.forEach(el => {

Wyświetl plik

@ -39,8 +39,6 @@ import type { CSSResultGroup } from 'lit';
export default class SlButton extends ShoelaceElement implements ShoelaceFormControl {
static styles: CSSResultGroup = styles;
@query('.button') button: HTMLButtonElement | HTMLLinkElement;
private readonly formSubmitController = new FormSubmitController(this, {
form: input => {
// Buttons support a form attribute that points to an arbitrary form, so if this attribute it set we need to query
@ -58,6 +56,8 @@ export default class SlButton extends ShoelaceElement implements ShoelaceFormCon
private readonly hasSlotController = new HasSlotController(this, '[default]', 'prefix', 'suffix');
private readonly localize = new LocalizeController(this);
@query('.button') button: HTMLButtonElement | HTMLLinkElement;
@state() private hasFocus = false;
@state() invalid = false;
@property() title = ''; // make reactive to pass through
@ -145,6 +145,49 @@ export default class SlButton extends ShoelaceElement implements ShoelaceFormCon
}
}
private handleBlur() {
this.hasFocus = false;
this.emit('sl-blur');
}
private handleFocus() {
this.hasFocus = true;
this.emit('sl-focus');
}
private handleClick(event: MouseEvent) {
if (this.disabled || this.loading) {
event.preventDefault();
event.stopPropagation();
return;
}
if (this.type === 'submit') {
this.formSubmitController.submit(this);
}
if (this.type === 'reset') {
this.formSubmitController.reset(this);
}
}
private isButton() {
return this.href ? false : true;
}
private isLink() {
return this.href ? true : false;
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
// Disabled form controls are always valid, so we need to recheck validity when the state changes
if (this.isButton()) {
this.button.disabled = this.disabled;
this.invalid = !(this.button as HTMLButtonElement).checkValidity();
}
}
/** Simulates a click on the button. */
click() {
this.button.click();
@ -186,49 +229,6 @@ export default class SlButton extends ShoelaceElement implements ShoelaceFormCon
}
}
handleBlur() {
this.hasFocus = false;
this.emit('sl-blur');
}
handleFocus() {
this.hasFocus = true;
this.emit('sl-focus');
}
handleClick(event: MouseEvent) {
if (this.disabled || this.loading) {
event.preventDefault();
event.stopPropagation();
return;
}
if (this.type === 'submit') {
this.formSubmitController.submit(this);
}
if (this.type === 'reset') {
this.formSubmitController.reset(this);
}
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
// Disabled form controls are always valid, so we need to recheck validity when the state changes
if (this.isButton()) {
this.button.disabled = this.disabled;
this.invalid = !(this.button as HTMLButtonElement).checkValidity();
}
}
private isButton() {
return this.href ? false : true;
}
private isLink() {
return this.href ? true : false;
}
render() {
const isLink = this.isLink();
const tag = isLink ? literal`a` : literal`button`;

Wyświetl plik

@ -39,8 +39,6 @@ import type { CSSResultGroup } from 'lit';
export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormControl {
static styles: CSSResultGroup = styles;
@query('input[type="checkbox"]') input: HTMLInputElement;
// @ts-expect-error -- Controller is currently unused
private readonly formSubmitController = new FormSubmitController(this, {
value: (control: SlCheckbox) => (control.checked ? control.value || 'on' : undefined),
@ -48,8 +46,11 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC
setValue: (control: SlCheckbox, checked: boolean) => (control.checked = checked)
});
@query('input[type="checkbox"]') input: HTMLInputElement;
@state() private hasFocus = false;
@state() invalid = false;
@property() title = ''; // make reactive to pass through
/** The name of the checkbox, submitted as a name/value pair with form data. */
@ -83,6 +84,41 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC
this.invalid = !this.input.checkValidity();
}
private handleClick() {
this.checked = !this.checked;
this.indeterminate = false;
this.emit('sl-change');
}
private handleBlur() {
this.hasFocus = false;
this.emit('sl-blur');
}
private handleInput() {
this.emit('sl-input');
}
private handleFocus() {
this.hasFocus = true;
this.emit('sl-focus');
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
// Disabled form controls are always valid, so we need to recheck validity when the state changes
this.input.disabled = this.disabled;
this.invalid = !this.input.checkValidity();
}
@watch('checked', { waitUntilFirstUpdate: true })
@watch('indeterminate', { waitUntilFirstUpdate: true })
handleStateChange() {
this.input.checked = this.checked; // force a sync update
this.input.indeterminate = this.indeterminate; // force a sync update
this.invalid = !this.input.checkValidity();
}
/** Simulates a click on the checkbox. */
click() {
this.input.click();
@ -117,41 +153,6 @@ export default class SlCheckbox extends ShoelaceElement implements ShoelaceFormC
this.invalid = !this.input.checkValidity();
}
handleClick() {
this.checked = !this.checked;
this.indeterminate = false;
this.emit('sl-change');
}
handleBlur() {
this.hasFocus = false;
this.emit('sl-blur');
}
handleInput() {
this.emit('sl-input');
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
// Disabled form controls are always valid, so we need to recheck validity when the state changes
this.input.disabled = this.disabled;
this.invalid = !this.input.checkValidity();
}
handleFocus() {
this.hasFocus = true;
this.emit('sl-focus');
}
@watch('checked', { waitUntilFirstUpdate: true })
@watch('indeterminate', { waitUntilFirstUpdate: true })
handleStateChange() {
this.input.checked = this.checked; // force a sync update
this.input.indeterminate = this.indeterminate; // force a sync update
this.invalid = !this.input.checkValidity();
}
render() {
return html`
<label

Wyświetl plik

@ -88,16 +88,16 @@ declare const EyeDropper: EyeDropperConstructor;
export default class SlColorPicker extends ShoelaceElement implements ShoelaceFormControl {
static styles: CSSResultGroup = styles;
@query('[part~="input"]') input: SlInput;
@query('[part~="preview"]') previewButton: HTMLButtonElement;
@query('.color-dropdown') dropdown: SlDropdown;
// @ts-expect-error -- Controller is currently unused
private readonly formSubmitController = new FormSubmitController(this);
private isSafeValue = false;
private lastValueEmitted: string;
private readonly localize = new LocalizeController(this);
@query('[part~="input"]') input: SlInput;
@query('[part~="preview"]') previewButton: HTMLButtonElement;
@query('.color-dropdown') dropdown: SlDropdown;
@state() private isDraggingGridHandle = false;
@state() private isEmpty = false;
@state() private inputValue = '';
@ -195,70 +195,11 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
}
}
/** Returns the current value as a string in the specified format. */
getFormattedValue(format: 'hex' | 'hexa' | 'rgb' | 'rgba' | 'hsl' | 'hsla' | 'hsv' | 'hsva' = 'hex') {
const currentColor = this.parseColor(
`hsla(${this.hue}, ${this.saturation}%, ${this.lightness}%, ${this.alpha / 100})`
);
if (currentColor === null) {
return '';
}
switch (format) {
case 'hex':
return currentColor.hex;
case 'hexa':
return currentColor.hexa;
case 'rgb':
return currentColor.rgb.string;
case 'rgba':
return currentColor.rgba.string;
case 'hsl':
return currentColor.hsl.string;
case 'hsla':
return currentColor.hsla.string;
case 'hsv':
return currentColor.hsv.string;
case 'hsva':
return currentColor.hsva.string;
default:
return '';
}
}
getBrightness(lightness: number) {
private getBrightness(lightness: number) {
return clamp(-1 * ((200 * lightness) / (this.saturation - 200)), 0, 100);
}
getLightness(brightness: number) {
return clamp(((((200 - this.saturation) * brightness) / 100) * 5) / 10, 0, 100);
}
/** Checks for validity but does not show the browser's validation message. */
checkValidity() {
return this.input.checkValidity();
}
/** Checks for validity and shows the browser's validation message if the control is invalid. */
reportValidity() {
if (!this.inline && this.input.invalid) {
// If the input is inline and invalid, show the dropdown so the browser can focus on it
this.dropdown.show();
this.addEventListener('sl-after-show', () => this.input.reportValidity(), { once: true });
return this.checkValidity();
}
return this.input.reportValidity();
}
/** Sets a custom validation message. If `message` is not empty, the field will be considered invalid. */
setCustomValidity(message: string) {
this.input.setCustomValidity(message);
this.invalid = this.input.invalid;
}
handleCopy() {
private handleCopy() {
this.input.select();
document.execCommand('copy');
this.previewButton.focus();
@ -270,7 +211,7 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
});
}
handleFormatToggle() {
private handleFormatToggle() {
const formats = ['hex', 'rgb', 'hsl', 'hsv'];
const nextIndex = (formats.indexOf(this.format) + 1) % formats.length;
this.format = formats[nextIndex] as 'hex' | 'rgb' | 'hsl' | 'hsv';
@ -279,7 +220,7 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
this.emit('sl-input');
}
handleAlphaDrag(event: PointerEvent) {
private handleAlphaDrag(event: PointerEvent) {
const container = this.shadowRoot!.querySelector<HTMLElement>('.color-picker__slider.color-picker__alpha')!;
const handle = container.querySelector<HTMLElement>('.color-picker__slider-handle')!;
const { width } = container.getBoundingClientRect();
@ -303,7 +244,7 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
});
}
handleHueDrag(event: PointerEvent) {
private handleHueDrag(event: PointerEvent) {
const container = this.shadowRoot!.querySelector<HTMLElement>('.color-picker__slider.color-picker__hue')!;
const handle = container.querySelector<HTMLElement>('.color-picker__slider-handle')!;
const { width } = container.getBoundingClientRect();
@ -327,7 +268,7 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
});
}
handleGridDrag(event: PointerEvent) {
private handleGridDrag(event: PointerEvent) {
const grid = this.shadowRoot!.querySelector<HTMLElement>('.color-picker__grid')!;
const handle = grid.querySelector<HTMLElement>('.color-picker__grid-handle')!;
const { width, height } = grid.getBoundingClientRect();
@ -356,7 +297,7 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
});
}
handleAlphaKeyDown(event: KeyboardEvent) {
private handleAlphaKeyDown(event: KeyboardEvent) {
const increment = event.shiftKey ? 10 : 1;
const oldValue = this.value;
@ -390,7 +331,7 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
}
}
handleHueKeyDown(event: KeyboardEvent) {
private handleHueKeyDown(event: KeyboardEvent) {
const increment = event.shiftKey ? 10 : 1;
const oldValue = this.value;
@ -424,7 +365,7 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
}
}
handleGridKeyDown(event: KeyboardEvent) {
private handleGridKeyDown(event: KeyboardEvent) {
const increment = event.shiftKey ? 10 : 1;
const oldValue = this.value;
@ -462,7 +403,7 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
}
}
handleInputChange(event: CustomEvent) {
private handleInputChange(event: CustomEvent) {
const target = event.target as HTMLInputElement;
const oldValue = this.value;
@ -482,12 +423,12 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
}
}
handleInputInput(event: CustomEvent) {
private handleInputInput(event: CustomEvent) {
// Prevent the <sl-input>'s sl-input event from bubbling up
event.stopPropagation();
}
handleInputKeyDown(event: KeyboardEvent) {
private handleInputKeyDown(event: KeyboardEvent) {
if (event.key === 'Enter') {
const oldValue = this.value;
@ -507,11 +448,11 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
}
}
handleTouchMove(event: TouchEvent) {
private handleTouchMove(event: TouchEvent) {
event.preventDefault();
}
parseColor(colorString: string) {
private parseColor(colorString: string) {
const color = new TinyColor(colorString);
if (!color.isValid) {
return null;
@ -591,7 +532,7 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
};
}
setColor(colorString: string) {
private setColor(colorString: string) {
const newColor = this.parseColor(colorString);
if (newColor === null) {
@ -609,14 +550,14 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
return true;
}
setLetterCase(string: string) {
private setLetterCase(string: string) {
if (typeof string !== 'string') {
return '';
}
return this.uppercase ? string.toUpperCase() : string.toLowerCase();
}
async syncValues() {
private async syncValues() {
const currentColor = this.parseColor(
`hsla(${this.hue}, ${this.saturation}%, ${this.lightness}%, ${this.alpha / 100})`
);
@ -645,11 +586,11 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
this.isSafeValue = false;
}
handleAfterHide() {
private handleAfterHide() {
this.previewButton.classList.remove('color-picker__preview-color--copied');
}
handleEyeDropper() {
private handleEyeDropper() {
if (!hasEyeDropper) {
return;
}
@ -664,7 +605,7 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
});
}
selectSwatch(color: string) {
private selectSwatch(color: string) {
const oldValue = this.value;
if (!this.disabled) {
@ -677,6 +618,10 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
}
}
private getLightness(brightness: number) {
return clamp(((((200 - this.saturation) * brightness) / 100) * 5) / 10, 0, 100);
}
@watch('format', { waitUntilFirstUpdate: true })
handleFormatChange() {
this.syncValues();
@ -718,6 +663,61 @@ export default class SlColorPicker extends ShoelaceElement implements ShoelaceFo
}
}
/** Returns the current value as a string in the specified format. */
getFormattedValue(format: 'hex' | 'hexa' | 'rgb' | 'rgba' | 'hsl' | 'hsla' | 'hsv' | 'hsva' = 'hex') {
const currentColor = this.parseColor(
`hsla(${this.hue}, ${this.saturation}%, ${this.lightness}%, ${this.alpha / 100})`
);
if (currentColor === null) {
return '';
}
switch (format) {
case 'hex':
return currentColor.hex;
case 'hexa':
return currentColor.hexa;
case 'rgb':
return currentColor.rgb.string;
case 'rgba':
return currentColor.rgba.string;
case 'hsl':
return currentColor.hsl.string;
case 'hsla':
return currentColor.hsla.string;
case 'hsv':
return currentColor.hsv.string;
case 'hsva':
return currentColor.hsva.string;
default:
return '';
}
}
/** Checks for validity but does not show the browser's validation message. */
checkValidity() {
return this.input.checkValidity();
}
/** Checks for validity and shows the browser's validation message if the control is invalid. */
reportValidity() {
if (!this.inline && this.input.invalid) {
// If the input is inline and invalid, show the dropdown so the browser can focus on it
this.dropdown.show();
this.addEventListener('sl-after-show', () => this.input.reportValidity(), { once: true });
return this.checkValidity();
}
return this.input.reportValidity();
}
/** Sets a custom validation message. If `message` is not empty, the field will be considered invalid. */
setCustomValidity(message: string) {
this.input.setCustomValidity(message);
this.invalid = this.input.invalid;
}
render() {
const gridHandleX = this.saturation;
const gridHandleY = 100 - this.brightness;

Wyświetl plik

@ -42,13 +42,13 @@ import type { CSSResultGroup } from 'lit';
export default class SlDetails extends ShoelaceElement {
static styles: CSSResultGroup = styles;
private readonly localize = new LocalizeController(this);
@query('.details') details: HTMLElement;
@query('.details__header') header: HTMLElement;
@query('.details__body') body: HTMLElement;
@query('.details__expand-icon-slot') expandIconSlot: HTMLSlotElement;
private readonly localize = new LocalizeController(this);
/**
* 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.
@ -66,27 +66,7 @@ export default class SlDetails extends ShoelaceElement {
this.body.style.height = this.open ? 'auto' : '0';
}
/** Shows the details. */
async show() {
if (this.open || this.disabled) {
return undefined;
}
this.open = true;
return waitForEvent(this, 'sl-after-show');
}
/** Hides the details */
async hide() {
if (!this.open || this.disabled) {
return undefined;
}
this.open = false;
return waitForEvent(this, 'sl-after-hide');
}
handleSummaryClick() {
private handleSummaryClick() {
if (!this.disabled) {
if (this.open) {
this.hide();
@ -98,7 +78,7 @@ export default class SlDetails extends ShoelaceElement {
}
}
handleSummaryKeyDown(event: KeyboardEvent) {
private handleSummaryKeyDown(event: KeyboardEvent) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
@ -157,6 +137,26 @@ export default class SlDetails extends ShoelaceElement {
}
}
/** Shows the details. */
async show() {
if (this.open || this.disabled) {
return undefined;
}
this.open = true;
return waitForEvent(this, 'sl-after-show');
}
/** Hides the details */
async hide() {
if (!this.open || this.disabled) {
return undefined;
}
this.open = false;
return waitForEvent(this, 'sl-after-hide');
}
render() {
const isRtl = this.localize.dir() === 'rtl';

Wyświetl plik

@ -64,15 +64,15 @@ import type { CSSResultGroup } from 'lit';
export default class SlDialog extends ShoelaceElement {
static styles: CSSResultGroup = styles;
@query('.dialog') dialog: HTMLElement;
@query('.dialog__panel') panel: HTMLElement;
@query('.dialog__overlay') overlay: HTMLElement;
private readonly hasSlotController = new HasSlotController(this, 'footer');
private readonly localize = new LocalizeController(this);
private modal: Modal;
private originalTrigger: HTMLElement | null;
@query('.dialog') dialog: HTMLElement;
@query('.dialog__panel') panel: HTMLElement;
@query('.dialog__overlay') overlay: HTMLElement;
/**
* Indicates whether or not the dialog is open. You can toggle this attribute to show and hide the dialog, or you can
* use the `show()` and `hide()` methods and this attribute will reflect the dialog's open state.
@ -112,26 +112,6 @@ export default class SlDialog extends ShoelaceElement {
unlockBodyScrolling(this);
}
/** Shows the dialog. */
async show() {
if (this.open) {
return undefined;
}
this.open = true;
return waitForEvent(this, 'sl-after-show');
}
/** Hides the dialog */
async hide() {
if (!this.open) {
return undefined;
}
this.open = false;
return waitForEvent(this, 'sl-after-hide');
}
private requestClose(source: 'close-button' | 'keyboard' | 'overlay') {
const slRequestClose = this.emit('sl-request-close', {
cancelable: true,
@ -147,15 +127,15 @@ export default class SlDialog extends ShoelaceElement {
this.hide();
}
addOpenListeners() {
private addOpenListeners() {
document.addEventListener('keydown', this.handleDocumentKeyDown);
}
removeOpenListeners() {
private removeOpenListeners() {
document.removeEventListener('keydown', this.handleDocumentKeyDown);
}
handleDocumentKeyDown(event: KeyboardEvent) {
private handleDocumentKeyDown(event: KeyboardEvent) {
if (this.open && event.key === 'Escape') {
event.stopPropagation();
this.requestClose('keyboard');
@ -254,6 +234,26 @@ export default class SlDialog extends ShoelaceElement {
}
}
/** Shows the dialog. */
async show() {
if (this.open) {
return undefined;
}
this.open = true;
return waitForEvent(this, 'sl-after-show');
}
/** Hides the dialog */
async hide() {
if (!this.open) {
return undefined;
}
this.open = false;
return waitForEvent(this, 'sl-after-hide');
}
render() {
return html`
<div

Wyświetl plik

@ -72,15 +72,15 @@ import type { CSSResultGroup } from 'lit';
export default class SlDrawer extends ShoelaceElement {
static styles: CSSResultGroup = styles;
@query('.drawer') drawer: HTMLElement;
@query('.drawer__panel') panel: HTMLElement;
@query('.drawer__overlay') overlay: HTMLElement;
private readonly hasSlotController = new HasSlotController(this, 'footer');
private readonly localize = new LocalizeController(this);
private modal: Modal;
private originalTrigger: HTMLElement | null;
@query('.drawer') drawer: HTMLElement;
@query('.drawer__panel') panel: HTMLElement;
@query('.drawer__overlay') overlay: HTMLElement;
/**
* Indicates whether or not the drawer is open. You can toggle this attribute to show and hide the drawer, or you can
* use the `show()` and `hide()` methods and this attribute will reflect the drawer's open state.
@ -132,26 +132,6 @@ export default class SlDrawer extends ShoelaceElement {
unlockBodyScrolling(this);
}
/** Shows the drawer. */
async show() {
if (this.open) {
return undefined;
}
this.open = true;
return waitForEvent(this, 'sl-after-show');
}
/** Hides the drawer */
async hide() {
if (!this.open) {
return undefined;
}
this.open = false;
return waitForEvent(this, 'sl-after-hide');
}
private requestClose(source: 'close-button' | 'keyboard' | 'overlay') {
const slRequestClose = this.emit('sl-request-close', {
cancelable: true,
@ -167,15 +147,15 @@ export default class SlDrawer extends ShoelaceElement {
this.hide();
}
addOpenListeners() {
private addOpenListeners() {
document.addEventListener('keydown', this.handleDocumentKeyDown);
}
removeOpenListeners() {
private removeOpenListeners() {
document.removeEventListener('keydown', this.handleDocumentKeyDown);
}
handleDocumentKeyDown(event: KeyboardEvent) {
private handleDocumentKeyDown(event: KeyboardEvent) {
if (this.open && !this.contained && event.key === 'Escape') {
event.stopPropagation();
this.requestClose('keyboard');
@ -296,6 +276,26 @@ export default class SlDrawer extends ShoelaceElement {
}
}
/** Shows the drawer. */
async show() {
if (this.open) {
return undefined;
}
this.open = true;
return waitForEvent(this, 'sl-after-show');
}
/** Hides the drawer */
async hide() {
if (!this.open) {
return undefined;
}
this.open = false;
return waitForEvent(this, 'sl-after-hide');
}
render() {
return html`
<div

Wyświetl plik

@ -24,10 +24,10 @@ import type { CSSResultGroup } from 'lit';
export default class SlIconButton extends ShoelaceElement {
static styles: CSSResultGroup = styles;
@state() private hasFocus = false;
@query('.icon-button') button: HTMLButtonElement | HTMLLinkElement;
@state() private hasFocus = false;
/** The name of the icon to draw. Available names depend on the icon library being used. */
@property() name?: string;
@ -58,6 +58,23 @@ export default class SlIconButton extends ShoelaceElement {
/** Disables the button. */
@property({ type: Boolean, reflect: true }) disabled = false;
private handleBlur() {
this.hasFocus = false;
this.emit('sl-blur');
}
private handleFocus() {
this.hasFocus = true;
this.emit('sl-focus');
}
private handleClick(event: MouseEvent) {
if (this.disabled) {
event.preventDefault();
event.stopPropagation();
}
}
/** Simulates a click on the icon button. */
click() {
this.button.click();
@ -73,23 +90,6 @@ export default class SlIconButton extends ShoelaceElement {
this.button.blur();
}
handleBlur() {
this.hasFocus = false;
this.emit('sl-blur');
}
handleFocus() {
this.hasFocus = true;
this.emit('sl-focus');
}
handleClick(event: MouseEvent) {
if (this.disabled) {
event.preventDefault();
event.stopPropagation();
}
}
render() {
const isLink = this.href ? true : false;
const tag = isLink ? literal`a` : literal`button`;

Wyświetl plik

@ -65,11 +65,6 @@ export default class SlIcon extends ShoelaceElement {
return this.src;
}
// Fetches the icon and redraws it. Used to handle library registrations.
redraw() {
this.setIcon();
}
@watch('label')
handleLabelChange() {
const hasLabel = typeof this.label === 'string' && this.label.length > 0;
@ -129,10 +124,6 @@ export default class SlIcon extends ShoelaceElement {
}
}
handleChange() {
this.setIcon();
}
render() {
return html` ${unsafeSVG(this.svg)} `;
}

Wyświetl plik

@ -43,7 +43,7 @@ export function registerIconLibrary(
// Redraw watched icons
watchedIcons.forEach(icon => {
if (icon.library === name) {
icon.redraw();
icon.setIcon();
}
});
}

Wyświetl plik

@ -38,15 +38,15 @@ import type { CSSResultGroup } from 'lit';
export default class SlImageComparer extends ShoelaceElement {
static styles: CSSResultGroup = styles;
private readonly localize = new LocalizeController(this);
@query('.image-comparer') base: HTMLElement;
@query('.image-comparer__handle') handle: HTMLElement;
private readonly localize = new LocalizeController(this);
/** The position of the divider as a percentage. */
@property({ type: Number, reflect: true }) position = 50;
handleDrag(event: PointerEvent) {
private handleDrag(event: PointerEvent) {
const { width } = this.base.getBoundingClientRect();
const isRtl = this.localize.dir() === 'rtl';
@ -61,7 +61,7 @@ export default class SlImageComparer extends ShoelaceElement {
});
}
handleKeyDown(event: KeyboardEvent) {
private handleKeyDown(event: KeyboardEvent) {
const isLtr = this.localize.dir() === 'ltr';
const isRtl = this.localize.dir() === 'rtl';

Wyświetl plik

@ -34,7 +34,7 @@ export default class SlInclude extends ShoelaceElement {
*/
@property({ attribute: 'allow-scripts', type: Boolean }) allowScripts = false;
executeScript(script: HTMLScriptElement) {
private executeScript(script: HTMLScriptElement) {
// Create a copy of the script and swap it out so the browser executes it
const newScript = document.createElement('script');
[...script.attributes].forEach(attr => newScript.setAttribute(attr.name, attr.value));

Wyświetl plik

@ -14,8 +14,9 @@ import styles from './input.styles';
import type { ShoelaceFormControl } from '../../internal/shoelace-element';
import type { CSSResultGroup } from 'lit';
//
// It's currently impossible to hide Firefox's built-in clear icon when using <input type="date|time">, so we need this
// check to apply a clip-path to hide it. I know, I know...user agent sniffing is nasty but, if it fails, we only see a
// check to apply a clip-path to hide it. I know, I knowuser agent sniffing is nasty but, if it fails, we only see a
// redundant clear icon so nothing important is breaking. The benefits outweigh the costs for this one. See the
// discussion at: https://github.com/shoelace-style/shoelace/pull/794
//
@ -62,12 +63,12 @@ const isFirefox = isChromium ? false : navigator.userAgent.includes('Firefox');
export default class SlInput extends ShoelaceElement implements ShoelaceFormControl {
static styles: CSSResultGroup = styles;
@query('.input__control') input: HTMLInputElement;
private readonly formSubmitController = new FormSubmitController(this);
private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
private readonly localize = new LocalizeController(this);
@query('.input__control') input: HTMLInputElement;
@state() private hasFocus = false;
@state() invalid = false;
@property() title = ''; // make reactive to pass through
@ -222,6 +223,85 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont
this.invalid = !this.input.checkValidity();
}
private handleBlur() {
this.hasFocus = false;
this.emit('sl-blur');
}
private handleChange() {
this.value = this.input.value;
this.emit('sl-change');
}
private handleClearClick(event: MouseEvent) {
this.value = '';
this.emit('sl-clear');
this.emit('sl-input');
this.emit('sl-change');
this.input.focus();
event.stopPropagation();
}
private handleFocus() {
this.hasFocus = true;
this.emit('sl-focus');
}
private handleInput() {
this.value = this.input.value;
this.emit('sl-input');
}
private handleInvalid() {
this.invalid = true;
}
private handleKeyDown(event: KeyboardEvent) {
const hasModifier = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
// Pressing enter when focused on an input should submit the form like a native input, but we wait a tick before
// submitting to allow users to cancel the keydown event if they need to
if (event.key === 'Enter' && !hasModifier) {
setTimeout(() => {
//
// When using an Input Method Editor (IME), pressing enter will cause the form to submit unexpectedly. One way
// to check for this is to look at event.isComposing, which will be true when the IME is open.
//
// See https://github.com/shoelace-style/shoelace/pull/988
//
if (!event.defaultPrevented && !event.isComposing) {
this.formSubmitController.submit();
}
});
}
}
private handlePasswordToggle() {
this.passwordVisible = !this.passwordVisible;
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
// Disabled form controls are always valid, so we need to recheck validity when the state changes
this.input.disabled = this.disabled;
this.invalid = !this.input.checkValidity();
}
@watch('step', { waitUntilFirstUpdate: true })
handleStepChange() {
// If step changes, the value may become invalid so we need to recheck after the update. We set the new step
// imperatively so we don't have to wait for the next render to report the updated validity.
this.input.step = String(this.step);
this.invalid = !this.input.checkValidity();
}
@watch('value', { waitUntilFirstUpdate: true })
handleValueChange() {
this.input.value = this.value; // force a sync update
this.invalid = !this.input.checkValidity();
}
/** Sets focus on the input. */
focus(options?: FocusOptions) {
this.input.focus(options);
@ -300,85 +380,6 @@ export default class SlInput extends ShoelaceElement implements ShoelaceFormCont
this.invalid = !this.input.checkValidity();
}
handleBlur() {
this.hasFocus = false;
this.emit('sl-blur');
}
handleChange() {
this.value = this.input.value;
this.emit('sl-change');
}
handleClearClick(event: MouseEvent) {
this.value = '';
this.emit('sl-clear');
this.emit('sl-input');
this.emit('sl-change');
this.input.focus();
event.stopPropagation();
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
// Disabled form controls are always valid, so we need to recheck validity when the state changes
this.input.disabled = this.disabled;
this.invalid = !this.input.checkValidity();
}
@watch('step', { waitUntilFirstUpdate: true })
handleStepChange() {
// If step changes, the value may become invalid so we need to recheck after the update. We set the new step
// imperatively so we don't have to wait for the next render to report the updated validity.
this.input.step = String(this.step);
this.invalid = !this.input.checkValidity();
}
handleFocus() {
this.hasFocus = true;
this.emit('sl-focus');
}
handleInput() {
this.value = this.input.value;
this.emit('sl-input');
}
handleInvalid() {
this.invalid = true;
}
handleKeyDown(event: KeyboardEvent) {
const hasModifier = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
// Pressing enter when focused on an input should submit the form like a native input, but we wait a tick before
// submitting to allow users to cancel the keydown event if they need to
if (event.key === 'Enter' && !hasModifier) {
setTimeout(() => {
//
// When using an Input Method Editor (IME), pressing enter will cause the form to submit unexpectedly. One way
// to check for this is to look at event.isComposing, which will be true when the IME is open.
//
// See https://github.com/shoelace-style/shoelace/pull/988
//
if (!event.defaultPrevented && !event.isComposing) {
this.formSubmitController.submit();
}
});
}
}
handlePasswordToggle() {
this.passwordVisible = !this.passwordVisible;
}
@watch('value', { waitUntilFirstUpdate: true })
handleValueChange() {
this.input.value = this.value; // force a sync update
this.invalid = !this.input.checkValidity();
}
render() {
const hasLabelSlot = this.hasSlotController.test('label');
const hasHelpTextSlot = this.hasSlotController.test('help-text');

Wyświetl plik

@ -48,9 +48,20 @@ export default class SlMenuItem extends ShoelaceElement {
this.setAttribute('role', 'menuitem');
}
/** Returns a text label based on the contents of the menu item's default slot. */
getTextLabel() {
return getTextContent(this.defaultSlot);
private handleDefaultSlotChange() {
const textLabel = this.getTextLabel();
// Ignore the first time the label is set
if (typeof this.cachedTextLabel === 'undefined') {
this.cachedTextLabel = textLabel;
return;
}
// When the label changes, emit a slotchange event so parent controls see it
if (textLabel !== this.cachedTextLabel) {
this.cachedTextLabel = textLabel;
this.emit('slotchange', { bubbles: true, composed: false, cancelable: false });
}
}
@watch('checked')
@ -66,20 +77,9 @@ export default class SlMenuItem extends ShoelaceElement {
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
}
handleDefaultSlotChange() {
const textLabel = this.getTextLabel();
// Ignore the first time the label is set
if (typeof this.cachedTextLabel === 'undefined') {
this.cachedTextLabel = textLabel;
return;
}
// When the label changes, emit a slotchange event so parent controls see it
if (textLabel !== this.cachedTextLabel) {
this.cachedTextLabel = textLabel;
this.emit('slotchange', { bubbles: true, composed: false, cancelable: false });
}
/** Returns a text label based on the contents of the menu item's default slot. */
getTextLabel() {
return getTextContent(this.defaultSlot);
}
render() {

Wyświetl plik

@ -23,17 +23,17 @@ export interface MenuSelectEventDetail {
export default class SlMenu extends ShoelaceElement {
static styles: CSSResultGroup = styles;
@query('slot') defaultSlot: HTMLSlotElement;
private typeToSelectString = '';
private typeToSelectTimeout: number;
@query('slot') defaultSlot: HTMLSlotElement;
connectedCallback() {
super.connectedCallback();
this.setAttribute('role', 'menu');
}
getAllItems(options: { includeDisabled: boolean } = { includeDisabled: true }) {
private getAllItems(options: { includeDisabled: boolean } = { includeDisabled: true }) {
return [...this.defaultSlot.assignedElements({ flatten: true })].filter((el: HTMLElement) => {
if (el.getAttribute('role') !== 'menuitem') {
return false;
@ -47,19 +47,15 @@ export default class SlMenu extends ShoelaceElement {
}) as SlMenuItem[];
}
/**
* @internal Gets the current menu item, which is the menu item that has `tabindex="0"` within the roving tab index.
* The menu item may or may not have focus, but for keyboard interaction purposes it's considered the "active" item.
*/
getCurrentItem() {
// Gets the current menu item, which is the menu item that has `tabindex="0"` within the roving tab index. The menu
// item may or may not have focus, but for keyboard interaction purposes it's considered the "active" item.
private getCurrentItem() {
return this.getAllItems({ includeDisabled: false }).find(i => i.getAttribute('tabindex') === '0');
}
/**
* @internal Sets the current menu item to the specified element. This sets `tabindex="0"` on the target element and
* `tabindex="-1"` to all other items. This method must be called prior to setting focus on a menu item.
*/
setCurrentItem(item: SlMenuItem) {
// Sets the current menu item to the specified element. This sets `tabindex="0"` on the target element and
// `tabindex="-1"` to all other items. This method must be called prior to setting focus on a menu item.
private setCurrentItem(item: SlMenuItem) {
const items = this.getAllItems({ includeDisabled: false });
const activeItem = item.disabled ? items[0] : item;
@ -69,41 +65,7 @@ export default class SlMenu extends ShoelaceElement {
});
}
/**
* Initiates type-to-select logic, which automatically selects an option based on what the user is currently typing.
* The event passed will be used to append the appropriate characters to the internal query and the selection will be
* updated. After a brief period, the internal query is cleared automatically. This is useful for enabling
* type-to-select behavior when the menu doesn't have focus.
*/
typeToSelect(event: KeyboardEvent) {
const items = this.getAllItems({ includeDisabled: false });
clearTimeout(this.typeToSelectTimeout);
this.typeToSelectTimeout = window.setTimeout(() => (this.typeToSelectString = ''), 1000);
if (event.key === 'Backspace') {
if (event.metaKey || event.ctrlKey) {
this.typeToSelectString = '';
} else {
this.typeToSelectString = this.typeToSelectString.slice(0, -1);
}
} else {
this.typeToSelectString += event.key.toLowerCase();
}
for (const item of items) {
const slot = item.shadowRoot?.querySelector<HTMLSlotElement>('slot:not([name])');
const label = getTextContent(slot).toLowerCase().trim();
if (label.startsWith(this.typeToSelectString)) {
this.setCurrentItem(item);
// Set focus here to force the browser to show :focus-visible styles
item.focus();
break;
}
}
}
handleClick(event: MouseEvent) {
private handleClick(event: MouseEvent) {
const target = event.target as HTMLElement;
const item = target.closest('sl-menu-item');
@ -112,7 +74,7 @@ export default class SlMenu extends ShoelaceElement {
}
}
handleKeyDown(event: KeyboardEvent) {
private handleKeyDown(event: KeyboardEvent) {
// Make a selection when pressing enter
if (event.key === 'Enter') {
const item = this.getCurrentItem();
@ -163,7 +125,7 @@ export default class SlMenu extends ShoelaceElement {
this.typeToSelect(event);
}
handleMouseDown(event: MouseEvent) {
private handleMouseDown(event: MouseEvent) {
const target = event.target as HTMLElement;
if (target.getAttribute('role') === 'menuitem') {
@ -171,7 +133,7 @@ export default class SlMenu extends ShoelaceElement {
}
}
handleSlotChange() {
private handleSlotChange() {
const items = this.getAllItems({ includeDisabled: false });
// Reset the roving tab index when the slotted items change
@ -180,6 +142,40 @@ export default class SlMenu extends ShoelaceElement {
}
}
/**
* Initiates type-to-select logic, which automatically selects an option based on what the user is currently typing.
* The event passed will be used to append the appropriate characters to the internal query and the selection will be
* updated. After a brief period, the internal query is cleared automatically. This is useful for enabling
* type-to-select behavior when the menu doesn't have focus.
*/
typeToSelect(event: KeyboardEvent) {
const items = this.getAllItems({ includeDisabled: false });
clearTimeout(this.typeToSelectTimeout);
this.typeToSelectTimeout = window.setTimeout(() => (this.typeToSelectString = ''), 1000);
if (event.key === 'Backspace') {
if (event.metaKey || event.ctrlKey) {
this.typeToSelectString = '';
} else {
this.typeToSelectString = this.typeToSelectString.slice(0, -1);
}
} else {
this.typeToSelectString += event.key.toLowerCase();
}
for (const item of items) {
const slot = item.shadowRoot?.querySelector<HTMLSlotElement>('slot:not([name])');
const label = getTextContent(slot).toLowerCase().trim();
if (label.startsWith(this.typeToSelectString)) {
this.setCurrentItem(item);
// Set focus here to force the browser to show :focus-visible styles
item.focus();
break;
}
}
}
render() {
return html`
<slot

Wyświetl plik

@ -57,32 +57,13 @@ export default class SlMutationObserver extends ShoelaceElement {
this.stopObserver();
}
@watch('disabled')
handleDisabledChange() {
if (this.disabled) {
this.stopObserver();
} else {
this.startObserver();
}
}
@watch('attr', { waitUntilFirstUpdate: true })
@watch('attr-old-value', { waitUntilFirstUpdate: true })
@watch('char-data', { waitUntilFirstUpdate: true })
@watch('char-data-old-value', { waitUntilFirstUpdate: true })
@watch('childList', { waitUntilFirstUpdate: true })
handleChange() {
this.stopObserver();
this.startObserver();
}
handleMutation(mutationList: MutationRecord[]) {
private handleMutation(mutationList: MutationRecord[]) {
this.emit('sl-mutation', {
detail: { mutationList }
});
}
startObserver() {
private startObserver() {
const observeAttributes = typeof this.attr === 'string' && this.attr.length > 0;
const attributeFilter = observeAttributes && this.attr !== '*' ? this.attr.split(' ') : undefined;
@ -105,10 +86,29 @@ export default class SlMutationObserver extends ShoelaceElement {
}
}
stopObserver() {
private stopObserver() {
this.mutationObserver.disconnect();
}
@watch('disabled')
handleDisabledChange() {
if (this.disabled) {
this.stopObserver();
} else {
this.startObserver();
}
}
@watch('attr', { waitUntilFirstUpdate: true })
@watch('attr-old-value', { waitUntilFirstUpdate: true })
@watch('char-data', { waitUntilFirstUpdate: true })
@watch('char-data-old-value', { waitUntilFirstUpdate: true })
@watch('childList', { waitUntilFirstUpdate: true })
handleChange() {
this.stopObserver();
this.startObserver();
}
render() {
return html` <slot></slot> `;
}

Wyświetl plik

@ -34,12 +34,12 @@ export default class SlOption extends ShoelaceElement {
// @ts-expect-error -- Controller is currently unused
private readonly localize = new LocalizeController(this);
@query('.option__label') defaultSlot: HTMLSlotElement;
@state() current = false; // the user has keyed into the option, but hasn't selected it yet (shows a highlight)
@state() selected = false; // the option is selected and has aria-selected="true"
@state() hasHover = false; // we need this because Safari doesn't honor :hover styles while dragging
@query('.option__label') defaultSlot: HTMLSlotElement;
/**
* The option's value. When selected, the containing form control will receive this value. The value must be unique
* from other options in the same group. Values may not contain spaces, as spaces are used as delimiters when listing
@ -56,9 +56,28 @@ export default class SlOption extends ShoelaceElement {
this.setAttribute('aria-selected', 'false');
}
/** Returns a plain text label based on the option's content. */
getTextLabel() {
return (this.textContent ?? '').trim();
private handleDefaultSlotChange() {
const textLabel = this.getTextLabel();
// Ignore the first time the label is set
if (typeof this.cachedTextLabel === 'undefined') {
this.cachedTextLabel = textLabel;
return;
}
// When the label changes, emit a slotchange event so parent controls see it
if (textLabel !== this.cachedTextLabel) {
this.cachedTextLabel = textLabel;
this.emit('slotchange', { bubbles: true, composed: false, cancelable: false });
}
}
private handleMouseEnter() {
this.hasHover = true;
}
private handleMouseLeave() {
this.hasHover = false;
}
@watch('disabled')
@ -79,28 +98,9 @@ export default class SlOption extends ShoelaceElement {
}
}
handleDefaultSlotChange() {
const textLabel = this.getTextLabel();
// Ignore the first time the label is set
if (typeof this.cachedTextLabel === 'undefined') {
this.cachedTextLabel = textLabel;
return;
}
// When the label changes, emit a slotchange event so parent controls see it
if (textLabel !== this.cachedTextLabel) {
this.cachedTextLabel = textLabel;
this.emit('slotchange', { bubbles: true, composed: false, cancelable: false });
}
}
handleMouseEnter() {
this.hasHover = true;
}
handleMouseLeave() {
this.hasHover = false;
/** Returns a plain text label based on the option's content. */
getTextLabel() {
return (this.textContent ?? '').trim();
}
render() {

Wyświetl plik

@ -38,13 +38,13 @@ import type { CSSResultGroup } from 'lit';
export default class SlPopup extends ShoelaceElement {
static styles: CSSResultGroup = styles;
/** A reference to the internal popup container. Useful for animating and styling the popup with JavaScript. */
@query('.popup') public popup: HTMLElement;
@query('.popup__arrow') private arrowEl: HTMLElement;
private anchorEl: HTMLElement | null;
private cleanup: ReturnType<typeof autoUpdate> | undefined;
/** A reference to the internal popup container. Useful for animating and styling the popup with JavaScript. */
@query('.popup') popup: HTMLElement;
@query('.popup__arrow') private arrowEl: HTMLElement;
/**
* The element the popup will be anchored to. If the anchor lives outside of the popup, you can provide its `id` or a
* reference to it here. If the anchor lives inside the popup, use the `anchor` slot instead.
@ -192,7 +192,31 @@ export default class SlPopup extends ShoelaceElement {
this.stop();
}
async handleAnchorChange() {
async updated(changedProps: Map<string, unknown>) {
super.updated(changedProps);
// Start or stop the positioner when active changes
if (changedProps.has('active')) {
if (this.active) {
this.start();
} else {
this.stop();
}
}
// Update the anchor when anchor changes
if (changedProps.has('anchor')) {
this.handleAnchorChange();
}
// All other properties will trigger a reposition when active
if (this.active) {
await this.updateComplete;
this.reposition();
}
}
private async handleAnchorChange() {
await this.stop();
if (this.anchor && typeof this.anchor === 'string') {
@ -248,30 +272,6 @@ export default class SlPopup extends ShoelaceElement {
});
}
async updated(changedProps: Map<string, unknown>) {
super.updated(changedProps);
// Start or stop the positioner when active changes
if (changedProps.has('active')) {
if (this.active) {
this.start();
} else {
this.stop();
}
}
// Update the anchor when anchor changes
if (changedProps.has('anchor')) {
this.handleAnchorChange();
}
// All other properties will trigger a reposition when active
if (this.active) {
await this.updateComplete;
this.reposition();
}
}
/** Forces the popup to recalculate and reposition itself. */
reposition() {
// Nothing to do if the popup is inactive or the anchor doesn't exist

Wyświetl plik

@ -26,6 +26,7 @@ import type { CSSResultGroup } from 'lit';
@customElement('sl-progress-ring')
export default class SlProgressRing extends ShoelaceElement {
static styles: CSSResultGroup = styles;
private readonly localize = new LocalizeController(this);
@query('.progress-ring__indicator') indicator: SVGCircleElement;

Wyświetl plik

@ -32,11 +32,11 @@ import type { CSSResultGroup } from 'lit';
export default class SlRadioButton extends ShoelaceElement {
static styles: CSSResultGroup = styles;
private readonly hasSlotController = new HasSlotController(this, '[default]', 'prefix', 'suffix');
@query('.button') input: HTMLInputElement;
@query('.hidden-input') hiddenInput: HTMLInputElement;
private readonly hasSlotController = new HasSlotController(this, '[default]', 'prefix', 'suffix');
@state() protected hasFocus = false;
@state() checked = false;
@ -57,22 +57,12 @@ export default class SlRadioButton extends ShoelaceElement {
this.setAttribute('role', 'presentation');
}
/** Sets focus on the radio button. */
focus(options?: FocusOptions) {
this.input.focus(options);
}
/** Removes focus from the radio button. */
blur() {
this.input.blur();
}
handleBlur() {
private handleBlur() {
this.hasFocus = false;
this.emit('sl-blur');
}
handleClick(e: MouseEvent) {
private handleClick(e: MouseEvent) {
if (this.disabled) {
e.preventDefault();
e.stopPropagation();
@ -82,14 +72,24 @@ export default class SlRadioButton extends ShoelaceElement {
this.checked = true;
}
private handleFocus() {
this.hasFocus = true;
this.emit('sl-focus');
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
}
handleFocus() {
this.hasFocus = true;
this.emit('sl-focus');
/** Sets focus on the radio button. */
focus(options?: FocusOptions) {
this.input.focus(options);
}
/** Removes focus from the radio button. */
blur() {
this.input.blur();
}
render() {

Wyświetl plik

@ -70,13 +70,6 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor
/** Ensures a child radio is checked before allowing the containing form to submit. */
@property({ type: Boolean, reflect: true }) required = false;
@watch('value')
handleValueChange() {
if (this.hasUpdated) {
this.updateCheckedRadio();
}
}
connectedCallback() {
super.connectedCallback();
this.defaultValue = this.value;
@ -86,6 +79,128 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor
this.invalid = !this.validity.valid;
}
private getAllRadios() {
return [...this.querySelectorAll<SlRadio | SlRadioButton>('sl-radio, sl-radio-button')];
}
private handleRadioClick(event: MouseEvent) {
const target = event.target as SlRadio | SlRadioButton;
const radios = this.getAllRadios();
const oldValue = this.value;
if (target.disabled) {
return;
}
this.value = target.value;
radios.forEach(radio => (radio.checked = radio === target));
if (this.value !== oldValue) {
this.emit('sl-change');
this.emit('sl-input');
}
}
private handleKeyDown(event: KeyboardEvent) {
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].includes(event.key)) {
return;
}
const radios = this.getAllRadios().filter(radio => !radio.disabled);
const checkedRadio = radios.find(radio => radio.checked) ?? radios[0];
const incr = event.key === ' ' ? 0 : ['ArrowUp', 'ArrowLeft'].includes(event.key) ? -1 : 1;
const oldValue = this.value;
let index = radios.indexOf(checkedRadio) + incr;
if (index < 0) {
index = radios.length - 1;
}
if (index > radios.length - 1) {
index = 0;
}
this.getAllRadios().forEach(radio => {
radio.checked = false;
if (!this.hasButtonGroup) {
radio.tabIndex = -1;
}
});
this.value = radios[index].value;
radios[index].checked = true;
if (!this.hasButtonGroup) {
radios[index].tabIndex = 0;
radios[index].focus();
} else {
radios[index].shadowRoot!.querySelector('button')!.focus();
}
if (this.value !== oldValue) {
this.emit('sl-change');
this.emit('sl-input');
}
event.preventDefault();
}
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();
}
}
private handleSlotChange() {
const radios = this.getAllRadios();
radios.forEach(radio => (radio.checked = radio.value === this.value));
this.hasButtonGroup = radios.some(radio => radio.tagName.toLowerCase() === 'sl-radio-button');
if (!radios.some(radio => radio.checked)) {
if (this.hasButtonGroup) {
const buttonRadio = radios[0].shadowRoot!.querySelector('button')!;
buttonRadio.tabIndex = 0;
} else {
radios[0].tabIndex = 0;
}
}
if (this.hasButtonGroup) {
const buttonGroup = this.shadowRoot?.querySelector('sl-button-group');
if (buttonGroup) {
buttonGroup.disableRole = true;
}
}
}
private showNativeErrorMessage() {
this.input.hidden = false;
this.input.reportValidity();
setTimeout(() => (this.input.hidden = true), 10000);
}
private updateCheckedRadio() {
const radios = this.getAllRadios();
radios.forEach(radio => (radio.checked = radio.value === this.value));
this.invalid = !this.validity.valid;
}
@watch('value')
handleValueChange() {
if (this.hasUpdated) {
this.updateCheckedRadio();
}
}
/** Checks for validity but does not show the browser's validation message. */
checkValidity() {
return this.validity.valid;
@ -137,121 +252,6 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor
return !this.invalid;
}
getAllRadios() {
return [...this.querySelectorAll<SlRadio | SlRadioButton>('sl-radio, sl-radio-button')];
}
handleRadioClick(event: MouseEvent) {
const target = event.target as SlRadio | SlRadioButton;
const radios = this.getAllRadios();
const oldValue = this.value;
if (target.disabled) {
return;
}
this.value = target.value;
radios.forEach(radio => (radio.checked = radio === target));
if (this.value !== oldValue) {
this.emit('sl-change');
this.emit('sl-input');
}
}
handleKeyDown(event: KeyboardEvent) {
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].includes(event.key)) {
return;
}
const radios = this.getAllRadios().filter(radio => !radio.disabled);
const checkedRadio = radios.find(radio => radio.checked) ?? radios[0];
const incr = event.key === ' ' ? 0 : ['ArrowUp', 'ArrowLeft'].includes(event.key) ? -1 : 1;
const oldValue = this.value;
let index = radios.indexOf(checkedRadio) + incr;
if (index < 0) {
index = radios.length - 1;
}
if (index > radios.length - 1) {
index = 0;
}
this.getAllRadios().forEach(radio => {
radio.checked = false;
if (!this.hasButtonGroup) {
radio.tabIndex = -1;
}
});
this.value = radios[index].value;
radios[index].checked = true;
if (!this.hasButtonGroup) {
radios[index].tabIndex = 0;
radios[index].focus();
} else {
radios[index].shadowRoot!.querySelector('button')!.focus();
}
if (this.value !== oldValue) {
this.emit('sl-change');
this.emit('sl-input');
}
event.preventDefault();
}
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();
}
}
handleSlotChange() {
const radios = this.getAllRadios();
radios.forEach(radio => (radio.checked = radio.value === this.value));
this.hasButtonGroup = radios.some(radio => radio.tagName.toLowerCase() === 'sl-radio-button');
if (!radios.some(radio => radio.checked)) {
if (this.hasButtonGroup) {
const buttonRadio = radios[0].shadowRoot!.querySelector('button')!;
buttonRadio.tabIndex = 0;
} else {
radios[0].tabIndex = 0;
}
}
if (this.hasButtonGroup) {
const buttonGroup = this.shadowRoot?.querySelector('sl-button-group');
if (buttonGroup) {
buttonGroup.disableRole = true;
}
}
}
showNativeErrorMessage() {
this.input.hidden = false;
this.input.reportValidity();
setTimeout(() => (this.input.hidden = true), 10000);
}
updateCheckedRadio() {
const radios = this.getAllRadios();
radios.forEach(radio => (radio.checked = radio.value === this.value));
this.invalid = !this.validity.valid;
}
render() {
const hasLabelSlot = this.hasSlotController.test('label');
const hasHelpTextSlot = this.hasSlotController.test('help-text');

Wyświetl plik

@ -44,19 +44,28 @@ export default class SlRadio extends ShoelaceElement {
connectedCallback() {
super.connectedCallback();
this.handleBlur = this.handleBlur.bind(this);
this.handleClick = this.handleClick.bind(this);
this.handleFocus = this.handleFocus.bind(this);
this.setInitialAttributes();
this.addEventListeners();
}
@watch('checked')
handleCheckedChange() {
this.setAttribute('aria-checked', this.checked ? 'true' : 'false');
this.setAttribute('tabindex', this.checked ? '0' : '-1');
disconnectedCallback() {
this.removeEventListeners();
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
private addEventListeners() {
this.addEventListener('blur', this.handleBlur);
this.addEventListener('click', this.handleClick);
this.addEventListener('focus', this.handleFocus);
}
private removeEventListeners() {
this.removeEventListener('blur', this.handleBlur);
this.removeEventListener('click', this.handleClick);
this.removeEventListener('focus', this.handleFocus);
}
private handleBlur() {
@ -75,18 +84,23 @@ export default class SlRadio extends ShoelaceElement {
this.emit('sl-focus');
}
private addEventListeners() {
this.addEventListener('blur', () => this.handleBlur());
this.addEventListener('click', () => this.handleClick());
this.addEventListener('focus', () => this.handleFocus());
}
private setInitialAttributes() {
this.setAttribute('role', 'radio');
this.setAttribute('tabindex', '-1');
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
}
@watch('checked')
handleCheckedChange() {
this.setAttribute('aria-checked', this.checked ? 'true' : 'false');
this.setAttribute('tabindex', this.checked ? '0' : '-1');
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
}
render() {
return html`
<span

Wyświetl plik

@ -46,15 +46,15 @@ import type { CSSResultGroup } from 'lit';
export default class SlRange extends ShoelaceElement implements ShoelaceFormControl {
static styles: CSSResultGroup = styles;
@query('.range__control') input: HTMLInputElement;
@query('.range__tooltip') output: HTMLOutputElement | null;
// @ts-expect-error -- Controller is currently unused
private readonly formSubmitController = new FormSubmitController(this);
private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
private readonly localize = new LocalizeController(this);
private resizeObserver: ResizeObserver;
@query('.range__control') input: HTMLInputElement;
@query('.range__tooltip') output: HTMLOutputElement | null;
@state() private hasFocus = false;
@state() private hasTooltip = false;
@state() invalid = false;
@ -118,6 +118,91 @@ export default class SlRange extends ShoelaceElement implements ShoelaceFormCont
this.resizeObserver.unobserve(this.input);
}
private handleChange() {
this.emit('sl-change');
}
private handleInput() {
this.value = parseFloat(this.input.value);
this.emit('sl-input');
this.syncRange();
}
private handleBlur() {
this.hasFocus = false;
this.hasTooltip = false;
this.emit('sl-blur');
}
private handleFocus() {
this.hasFocus = true;
this.hasTooltip = true;
this.emit('sl-focus');
}
private handleThumbDragStart() {
this.hasTooltip = true;
}
private handleThumbDragEnd() {
this.hasTooltip = false;
}
private syncProgress(percent: number) {
this.input.style.setProperty('--percent', `${percent * 100}%`);
}
private syncTooltip(percent: number) {
if (this.output !== null) {
const inputWidth = this.input.offsetWidth;
const tooltipWidth = this.output.offsetWidth;
const thumbSize = getComputedStyle(this.input).getPropertyValue('--thumb-size');
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
// under the hood, we don't have access to the thumb's true coordinates. These measurements can be a pixel or two
// off depending on the size of the control, thumb, and tooltip dimensions.
if (isRtl) {
const x = `${inputWidth - percentAsWidth}px + ${percent} * ${thumbSize}`;
this.output.style.translate = `calc((${x} - ${tooltipWidth / 2}px - ${thumbSize} / 2))`;
} else {
const x = `${percentAsWidth}px - ${percent} * ${thumbSize}`;
this.output.style.translate = `calc(${x} - ${tooltipWidth / 2}px + ${thumbSize} / 2)`;
}
}
}
@watch('value', { waitUntilFirstUpdate: true })
handleValueChange() {
this.invalid = !this.input.checkValidity();
// The value may have constraints, so we set the native control's value and sync it back to ensure it adhere's to
// min, max, and step properly
this.input.value = this.value.toString();
this.value = parseFloat(this.input.value);
this.syncRange();
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
// Disabled form controls are always valid, so we need to recheck validity when the state changes
this.input.disabled = this.disabled;
this.invalid = !this.input.checkValidity();
}
@watch('hasTooltip', { waitUntilFirstUpdate: true })
syncRange() {
const percent = Math.max(0, (this.value - this.min) / (this.max - this.min));
this.syncProgress(percent);
if (this.tooltip !== 'none') {
this.syncTooltip(percent);
}
}
/** Sets focus on the range. */
focus(options?: FocusOptions) {
this.input.focus(options);
@ -160,91 +245,6 @@ export default class SlRange extends ShoelaceElement implements ShoelaceFormCont
this.invalid = !this.input.checkValidity();
}
handleChange() {
this.emit('sl-change');
}
handleInput() {
this.value = parseFloat(this.input.value);
this.emit('sl-input');
this.syncRange();
}
handleBlur() {
this.hasFocus = false;
this.hasTooltip = false;
this.emit('sl-blur');
}
@watch('value', { waitUntilFirstUpdate: true })
handleValueChange() {
this.invalid = !this.input.checkValidity();
// The value may have constraints, so we set the native control's value and sync it back to ensure it adhere's to
// min, max, and step properly
this.input.value = this.value.toString();
this.value = parseFloat(this.input.value);
this.syncRange();
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
// Disabled form controls are always valid, so we need to recheck validity when the state changes
this.input.disabled = this.disabled;
this.invalid = !this.input.checkValidity();
}
handleFocus() {
this.hasFocus = true;
this.hasTooltip = true;
this.emit('sl-focus');
}
handleThumbDragStart() {
this.hasTooltip = true;
}
handleThumbDragEnd() {
this.hasTooltip = false;
}
@watch('hasTooltip', { waitUntilFirstUpdate: true })
syncRange() {
const percent = Math.max(0, (this.value - this.min) / (this.max - this.min));
this.syncProgress(percent);
if (this.tooltip !== 'none') {
this.syncTooltip(percent);
}
}
syncProgress(percent: number) {
this.input.style.setProperty('--percent', `${percent * 100}%`);
}
syncTooltip(percent: number) {
if (this.output !== null) {
const inputWidth = this.input.offsetWidth;
const tooltipWidth = this.output.offsetWidth;
const thumbSize = getComputedStyle(this.input).getPropertyValue('--thumb-size');
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
// under the hood, we don't have access to the thumb's true coordinates. These measurements can be a pixel or two
// off depending on the size of the control, thumb, and tooltip dimensions.
if (isRtl) {
const x = `${inputWidth - percentAsWidth}px + ${percent} * ${thumbSize}`;
this.output.style.translate = `calc((${x} - ${tooltipWidth / 2}px - ${thumbSize} / 2))`;
} else {
const x = `${percentAsWidth}px - ${percent} * ${thumbSize}`;
this.output.style.translate = `calc(${x} - ${tooltipWidth / 2}px + ${thumbSize} / 2)`;
}
}
}
render() {
const hasLabelSlot = this.hasSlotController.test('label');
const hasHelpTextSlot = this.hasSlotController.test('help-text');

Wyświetl plik

@ -31,10 +31,10 @@ import type { CSSResultGroup } from 'lit';
export default class SlRating extends ShoelaceElement {
static styles: CSSResultGroup = styles;
@query('.rating') rating: HTMLElement;
private readonly localize = new LocalizeController(this);
@query('.rating') rating: HTMLElement;
@state() private hoverValue = 0;
@state() private isHovering = false;
@ -66,25 +66,15 @@ export default class SlRating extends ShoelaceElement {
*/
@property() getSymbol: (value: number) => string = () => '<sl-icon name="star-fill" library="system"></sl-icon>';
/** Sets focus on the rating. */
focus(options?: FocusOptions) {
this.rating.focus(options);
}
/** Removes focus from the rating. */
blur() {
this.rating.blur();
}
getValueFromMousePosition(event: MouseEvent) {
private getValueFromMousePosition(event: MouseEvent) {
return this.getValueFromXCoordinate(event.clientX);
}
getValueFromTouchPosition(event: TouchEvent) {
private getValueFromTouchPosition(event: TouchEvent) {
return this.getValueFromXCoordinate(event.touches[0].clientX);
}
getValueFromXCoordinate(coordinate: number) {
private getValueFromXCoordinate(coordinate: number) {
const isRtl = this.localize.dir() === 'rtl';
const { left, right, width } = this.rating.getBoundingClientRect();
const value = isRtl
@ -94,12 +84,12 @@ export default class SlRating extends ShoelaceElement {
return clamp(value, 0, this.max);
}
handleClick(event: MouseEvent) {
private handleClick(event: MouseEvent) {
this.setValue(this.getValueFromMousePosition(event));
this.emit('sl-change');
}
setValue(newValue: number) {
private setValue(newValue: number) {
if (this.disabled || this.readonly) {
return;
}
@ -108,7 +98,7 @@ export default class SlRating extends ShoelaceElement {
this.isHovering = false;
}
handleKeyDown(event: KeyboardEvent) {
private handleKeyDown(event: KeyboardEvent) {
const isLtr = this.localize.dir() === 'ltr';
const isRtl = this.localize.dir() === 'rtl';
const oldValue = this.value;
@ -144,31 +134,31 @@ export default class SlRating extends ShoelaceElement {
}
}
handleMouseEnter() {
private handleMouseEnter() {
this.isHovering = true;
}
handleMouseMove(event: MouseEvent) {
private handleMouseMove(event: MouseEvent) {
this.hoverValue = this.getValueFromMousePosition(event);
}
handleMouseLeave() {
private handleMouseLeave() {
this.isHovering = false;
}
handleTouchStart(event: TouchEvent) {
private handleTouchStart(event: TouchEvent) {
this.hoverValue = this.getValueFromTouchPosition(event);
// Prevent scrolling when touch is initiated
event.preventDefault();
}
handleTouchMove(event: TouchEvent) {
private handleTouchMove(event: TouchEvent) {
this.isHovering = true;
this.hoverValue = this.getValueFromTouchPosition(event);
}
handleTouchEnd(event: TouchEvent) {
private handleTouchEnd(event: TouchEvent) {
this.isHovering = false;
this.setValue(this.hoverValue);
this.emit('sl-change');
@ -177,11 +167,21 @@ export default class SlRating extends ShoelaceElement {
event.preventDefault();
}
roundToPrecision(numberToRound: number, precision = 0.5) {
private roundToPrecision(numberToRound: number, precision = 0.5) {
const multiplier = 1 / precision;
return Math.ceil(numberToRound * multiplier) / multiplier;
}
/** Sets focus on the rating. */
focus(options?: FocusOptions) {
this.rating.focus(options);
}
/** Removes focus from the rating. */
blur() {
this.rating.blur();
}
render() {
const isRtl = this.localize.dir() === 'rtl';
const counter = Array.from(Array(this.max).keys());

Wyświetl plik

@ -41,13 +41,13 @@ export default class SlResizeObserver extends ShoelaceElement {
this.stopObserver();
}
handleSlotChange() {
private handleSlotChange() {
if (!this.disabled) {
this.startObserver();
}
}
startObserver() {
private startObserver() {
const slot = this.shadowRoot!.querySelector('slot');
if (slot !== null) {
@ -65,7 +65,7 @@ export default class SlResizeObserver extends ShoelaceElement {
}
}
stopObserver() {
private stopObserver() {
this.resizeObserver.disconnect();
}

Wyświetl plik

@ -62,12 +62,6 @@ import type { CSSResultGroup } from 'lit';
export default class SlSelect extends ShoelaceElement implements ShoelaceFormControl {
static styles: CSSResultGroup = styles;
@query('.select') popup: SlPopup;
@query('.select__combobox') combobox: HTMLSlotElement;
@query('.select__display-input') displayInput: HTMLInputElement;
@query('.select__value-input') valueInput: HTMLInputElement;
@query('.select__listbox') listbox: HTMLSlotElement;
// @ts-expect-error -- Controller is currently unused
private readonly formSubmitController = new FormSubmitController(this);
private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
@ -75,6 +69,12 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
private typeToSelectString = '';
private typeToSelectTimeout: number;
@query('.select') popup: SlPopup;
@query('.select__combobox') combobox: HTMLSlotElement;
@query('.select__display-input') displayInput: HTMLInputElement;
@query('.select__value-input') valueInput: HTMLInputElement;
@query('.select__listbox') listbox: HTMLSlotElement;
@state() private hasFocus = false;
@state() displayLabel = '';
@state() currentOption: SlOption;
@ -163,32 +163,6 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
this.open = false;
}
/** Checks for validity but does not show the browser's validation message. */
checkValidity() {
return this.valueInput.checkValidity();
}
/** Checks for validity and shows the browser's validation message if the control is invalid. */
reportValidity() {
return this.valueInput.reportValidity();
}
/** Sets a custom validation message. If `message` is not empty, the field will be considered invalid. */
setCustomValidity(message: string) {
this.valueInput.setCustomValidity(message);
this.invalid = !this.valueInput.checkValidity();
}
/** Sets focus on the control. */
focus(options?: FocusOptions) {
this.displayInput.focus(options);
}
/** Removes focus from the control. */
blur() {
this.displayInput.blur();
}
private addOpenListeners() {
document.addEventListener('focusin', this.handleDocumentFocusIn);
document.addEventListener('keydown', this.handleDocumentKeyDown);
@ -541,28 +515,6 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
this.setSelectedOptions(allOptions.filter(el => value.includes(el.value)));
}
/** Shows the listbox. */
async show() {
if (this.open || this.disabled) {
this.open = false;
return undefined;
}
this.open = true;
return waitForEvent(this, 'sl-after-show');
}
/** Hides the listbox. */
async hide() {
if (!this.open || this.disabled) {
this.open = false;
return undefined;
}
this.open = false;
return waitForEvent(this, 'sl-after-hide');
}
@watch('open', { waitUntilFirstUpdate: true })
async handleOpenChange() {
if (this.open && !this.disabled) {
@ -606,6 +558,54 @@ export default class SlSelect extends ShoelaceElement implements ShoelaceFormCon
}
}
/** Shows the listbox. */
async show() {
if (this.open || this.disabled) {
this.open = false;
return undefined;
}
this.open = true;
return waitForEvent(this, 'sl-after-show');
}
/** Hides the listbox. */
async hide() {
if (!this.open || this.disabled) {
this.open = false;
return undefined;
}
this.open = false;
return waitForEvent(this, 'sl-after-hide');
}
/** Checks for validity but does not show the browser's validation message. */
checkValidity() {
return this.valueInput.checkValidity();
}
/** Checks for validity and shows the browser's validation message if the control is invalid. */
reportValidity() {
return this.valueInput.reportValidity();
}
/** Sets a custom validation message. If `message` is not empty, the field will be considered invalid. */
setCustomValidity(message: string) {
this.valueInput.setCustomValidity(message);
this.invalid = !this.valueInput.checkValidity();
}
/** Sets focus on the control. */
focus(options?: FocusOptions) {
this.displayInput.focus(options);
}
/** Removes focus from the control. */
blur() {
this.displayInput.blur();
}
render() {
const hasLabelSlot = this.hasSlotController.test('label');
const hasHelpTextSlot = this.hasSlotController.test('help-text');

Wyświetl plik

@ -101,7 +101,7 @@ export default class SlSplitPanel extends ShoelaceElement {
return (value / this.size) * 100;
}
handleDrag(event: PointerEvent) {
private handleDrag(event: PointerEvent) {
const isRtl = this.localize.dir() === 'rtl';
if (this.disabled) {
@ -154,7 +154,7 @@ export default class SlSplitPanel extends ShoelaceElement {
});
}
handleKeyDown(event: KeyboardEvent) {
private handleKeyDown(event: KeyboardEvent) {
if (this.disabled) {
return;
}
@ -185,6 +185,16 @@ export default class SlSplitPanel extends ShoelaceElement {
}
}
private handleResize(entries: ResizeObserverEntry[]) {
const { width, height } = entries[0].contentRect;
this.size = this.vertical ? height : width;
// Resize when a primary panel is set
if (this.primary) {
this.position = this.pixelsToPercentage(this.cachedPositionInPixels);
}
}
@watch('position')
handlePositionChange() {
this.cachedPositionInPixels = this.percentageToPixels(this.position);
@ -202,16 +212,6 @@ export default class SlSplitPanel extends ShoelaceElement {
this.detectSize();
}
handleResize(entries: ResizeObserverEntry[]) {
const { width, height } = entries[0].contentRect;
this.size = this.vertical ? height : width;
// Resize when a primary panel is set
if (this.primary) {
this.position = this.pixelsToPercentage(this.cachedPositionInPixels);
}
}
render() {
const gridTemplate = this.vertical ? 'gridTemplateRows' : 'gridTemplateColumns';
const gridTemplateAlt = this.vertical ? 'gridTemplateColumns' : 'gridTemplateRows';

Wyświetl plik

@ -37,8 +37,6 @@ import type { CSSResultGroup } from 'lit';
export default class SlSwitch extends ShoelaceElement implements ShoelaceFormControl {
static styles: CSSResultGroup = styles;
@query('input[type="checkbox"]') input: HTMLInputElement;
// @ts-expect-error -- Controller is currently unused
private readonly formSubmitController = new FormSubmitController(this, {
value: (control: SlSwitch) => (control.checked ? control.value : undefined),
@ -46,6 +44,8 @@ export default class SlSwitch extends ShoelaceElement implements ShoelaceFormCon
setValue: (control: SlSwitch, checked: boolean) => (control.checked = checked)
});
@query('input[type="checkbox"]') input: HTMLInputElement;
@state() private hasFocus = false;
@state() invalid = false;
@property() title = ''; // make reactive to pass through
@ -75,6 +75,54 @@ export default class SlSwitch extends ShoelaceElement implements ShoelaceFormCon
this.invalid = !this.input.checkValidity();
}
private handleBlur() {
this.hasFocus = false;
this.emit('sl-blur');
}
private handleInput() {
this.emit('sl-input');
}
private handleClick() {
this.checked = !this.checked;
this.emit('sl-change');
}
private handleFocus() {
this.hasFocus = true;
this.emit('sl-focus');
}
private handleKeyDown(event: KeyboardEvent) {
if (event.key === 'ArrowLeft') {
event.preventDefault();
this.checked = false;
this.emit('sl-change');
this.emit('sl-input');
}
if (event.key === 'ArrowRight') {
event.preventDefault();
this.checked = true;
this.emit('sl-change');
this.emit('sl-input');
}
}
@watch('checked', { waitUntilFirstUpdate: true })
handleCheckedChange() {
this.input.checked = this.checked; // force a sync update
this.invalid = !this.input.checkValidity();
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
// Disabled form controls are always valid, so we need to recheck validity when the state changes
this.input.disabled = this.disabled;
this.invalid = !this.input.checkValidity();
}
/** Simulates a click on the switch. */
click() {
this.input.click();
@ -106,54 +154,6 @@ export default class SlSwitch extends ShoelaceElement implements ShoelaceFormCon
this.invalid = !this.input.checkValidity();
}
handleBlur() {
this.hasFocus = false;
this.emit('sl-blur');
}
handleInput() {
this.emit('sl-input');
}
@watch('checked', { waitUntilFirstUpdate: true })
handleCheckedChange() {
this.input.checked = this.checked; // force a sync update
this.invalid = !this.input.checkValidity();
}
handleClick() {
this.checked = !this.checked;
this.emit('sl-change');
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
// Disabled form controls are always valid, so we need to recheck validity when the state changes
this.input.disabled = this.disabled;
this.invalid = !this.input.checkValidity();
}
handleFocus() {
this.hasFocus = true;
this.emit('sl-focus');
}
handleKeyDown(event: KeyboardEvent) {
if (event.key === 'ArrowLeft') {
event.preventDefault();
this.checked = false;
this.emit('sl-change');
this.emit('sl-input');
}
if (event.key === 'ArrowRight') {
event.preventDefault();
this.checked = true;
this.emit('sl-change');
this.emit('sl-input');
}
}
render() {
return html`
<label

Wyświetl plik

@ -44,17 +44,17 @@ export default class SlTabGroup extends ShoelaceElement {
static styles: CSSResultGroup = styles;
private readonly localize = new LocalizeController(this);
@query('.tab-group') tabGroup: HTMLElement;
@query('.tab-group__body') body: HTMLSlotElement;
@query('.tab-group__nav') nav: HTMLElement;
@query('.tab-group__indicator') indicator: HTMLElement;
private activeTab?: SlTab;
private mutationObserver: MutationObserver;
private resizeObserver: ResizeObserver;
private tabs: SlTab[] = [];
private panels: SlTabPanel[] = [];
@query('.tab-group') tabGroup: HTMLElement;
@query('.tab-group__body') body: HTMLSlotElement;
@query('.tab-group__nav') nav: HTMLElement;
@query('.tab-group__indicator') indicator: HTMLElement;
@state() private hasScrollControls = false;
/** The placement of the tabs. */
@ -111,16 +111,7 @@ export default class SlTabGroup extends ShoelaceElement {
this.resizeObserver.unobserve(this.nav);
}
/** Shows the specified tab panel. */
show(panel: string) {
const tab = this.tabs.find(el => el.panel === panel);
if (tab) {
this.setActiveTab(tab, { scrollBehavior: 'smooth' });
}
}
getAllTabs(options: { includeDisabled: boolean } = { includeDisabled: true }) {
private getAllTabs(options: { includeDisabled: boolean } = { includeDisabled: true }) {
const slot = this.shadowRoot!.querySelector<HTMLSlotElement>('slot[name="nav"]')!;
return [...(slot.assignedElements() as SlTab[])].filter(el => {
@ -130,15 +121,15 @@ export default class SlTabGroup extends ShoelaceElement {
});
}
getAllPanels() {
private getAllPanels() {
return [...this.body.assignedElements()].filter(el => el.tagName.toLowerCase() === 'sl-tab-panel') as [SlTabPanel];
}
getActiveTab() {
private getActiveTab() {
return this.tabs.find(el => el.active);
}
handleClick(event: MouseEvent) {
private handleClick(event: MouseEvent) {
const target = event.target as HTMLElement;
const tab = target.closest('sl-tab');
const tabGroup = tab?.closest('sl-tab-group');
@ -153,7 +144,7 @@ export default class SlTabGroup extends ShoelaceElement {
}
}
handleKeyDown(event: KeyboardEvent) {
private handleKeyDown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
const tab = target.closest('sl-tab');
const tabGroup = tab?.closest('sl-tab-group');
@ -218,7 +209,7 @@ export default class SlTabGroup extends ShoelaceElement {
}
}
handleScrollToStart() {
private handleScrollToStart() {
this.nav.scroll({
left:
this.localize.dir() === 'rtl'
@ -228,7 +219,7 @@ export default class SlTabGroup extends ShoelaceElement {
});
}
handleScrollToEnd() {
private handleScrollToEnd() {
this.nav.scroll({
left:
this.localize.dir() === 'rtl'
@ -238,17 +229,7 @@ export default class SlTabGroup extends ShoelaceElement {
});
}
@watch('noScrollControls', { waitUntilFirstUpdate: true })
updateScrollControls() {
if (this.noScrollControls) {
this.hasScrollControls = false;
} else {
this.hasScrollControls =
['top', 'bottom'].includes(this.placement) && this.nav.scrollWidth > this.nav.clientWidth;
}
}
setActiveTab(tab: SlTab, options?: { emitEvents?: boolean; scrollBehavior?: 'auto' | 'smooth' }) {
private setActiveTab(tab: SlTab, options?: { emitEvents?: boolean; scrollBehavior?: 'auto' | 'smooth' }) {
options = {
emitEvents: true,
scrollBehavior: 'auto',
@ -279,7 +260,7 @@ export default class SlTabGroup extends ShoelaceElement {
}
}
setAriaLabels() {
private setAriaLabels() {
// Link each tab with its corresponding panel
this.tabs.forEach(tab => {
const panel = this.panels.find(el => el.name === tab.panel);
@ -290,19 +271,7 @@ export default class SlTabGroup extends ShoelaceElement {
});
}
@watch('placement', { waitUntilFirstUpdate: true })
syncIndicator() {
const tab = this.getActiveTab();
if (tab) {
this.indicator.style.display = 'block';
this.repositionIndicator();
} else {
this.indicator.style.display = 'none';
}
}
repositionIndicator() {
private repositionIndicator() {
const currentTab = this.getActiveTab();
if (!currentTab) {
@ -343,12 +312,43 @@ export default class SlTabGroup extends ShoelaceElement {
}
// This stores tabs and panels so we can refer to a cache instead of calling querySelectorAll() multiple times.
syncTabsAndPanels() {
private syncTabsAndPanels() {
this.tabs = this.getAllTabs({ includeDisabled: false });
this.panels = this.getAllPanels();
this.syncIndicator();
}
@watch('noScrollControls', { waitUntilFirstUpdate: true })
updateScrollControls() {
if (this.noScrollControls) {
this.hasScrollControls = false;
} else {
this.hasScrollControls =
['top', 'bottom'].includes(this.placement) && this.nav.scrollWidth > this.nav.clientWidth;
}
}
@watch('placement', { waitUntilFirstUpdate: true })
syncIndicator() {
const tab = this.getActiveTab();
if (tab) {
this.indicator.style.display = 'block';
this.repositionIndicator();
} else {
this.indicator.style.display = 'none';
}
}
/** Shows the specified tab panel. */
show(panel: string) {
const tab = this.tabs.find(el => el.panel === panel);
if (tab) {
this.setActiveTab(tab, { scrollBehavior: 'smooth' });
}
}
render() {
const isRtl = this.localize.dir() === 'rtl';

Wyświetl plik

@ -31,11 +31,11 @@ export default class SlTab extends ShoelaceElement {
static styles: CSSResultGroup = styles;
private readonly localize = new LocalizeController(this);
@query('.tab') tab: HTMLElement;
private readonly attrId = ++id;
private readonly componentId = `sl-tab-${this.attrId}`;
@query('.tab') tab: HTMLElement;
/** The name of the tab panel this tab is associated with. The panel must be located in the same tab group. */
@property({ reflect: true }) panel = '';
@ -53,17 +53,7 @@ export default class SlTab extends ShoelaceElement {
this.setAttribute('role', 'tab');
}
/** Sets focus to the tab. */
focus(options?: FocusOptions) {
this.tab.focus(options);
}
/** Removes focus from the tab. */
blur() {
this.tab.blur();
}
handleCloseClick() {
private handleCloseClick() {
this.emit('sl-close');
}
@ -77,6 +67,16 @@ export default class SlTab extends ShoelaceElement {
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
}
/** Sets focus to the tab. */
focus(options?: FocusOptions) {
this.tab.focus(options);
}
/** Removes focus from the tab. */
blur() {
this.tab.blur();
}
render() {
// If the user didn't provide an ID, we'll set one so we can link tabs and tab panels with aria labels
this.id = this.id.length > 0 ? this.id : this.componentId;

Wyświetl plik

@ -41,7 +41,7 @@ export default class SlTag extends ShoelaceElement {
/** Makes the tag removable and shows a remove button. */
@property({ type: Boolean }) removable = false;
handleRemoveClick() {
private handleRemoveClick() {
this.emit('sl-remove');
}

Wyświetl plik

@ -37,13 +37,13 @@ import type { CSSResultGroup } from 'lit';
export default class SlTextarea extends ShoelaceElement implements ShoelaceFormControl {
static styles: CSSResultGroup = styles;
@query('.textarea__control') input: HTMLTextAreaElement;
// @ts-expect-error -- Controller is currently unused
private readonly formSubmitController = new FormSubmitController(this);
private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
private resizeObserver: ResizeObserver;
@query('.textarea__control') input: HTMLTextAreaElement;
@state() private hasFocus = false;
@state() invalid = false;
@property() title = ''; // make reactive to pass through
@ -147,6 +147,55 @@ export default class SlTextarea extends ShoelaceElement implements ShoelaceFormC
this.resizeObserver.unobserve(this.input);
}
private handleBlur() {
this.hasFocus = false;
this.emit('sl-blur');
}
private handleChange() {
this.value = this.input.value;
this.setTextareaHeight();
this.emit('sl-change');
}
private handleFocus() {
this.hasFocus = true;
this.emit('sl-focus');
}
private handleInput() {
this.value = this.input.value;
this.emit('sl-input');
}
private setTextareaHeight() {
if (this.resize === 'auto') {
this.input.style.height = 'auto';
this.input.style.height = `${this.input.scrollHeight}px`;
} else {
(this.input.style.height as string | undefined) = undefined;
}
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
// Disabled form controls are always valid, so we need to recheck validity when the state changes
this.input.disabled = this.disabled;
this.invalid = !this.input.checkValidity();
}
@watch('rows', { waitUntilFirstUpdate: true })
handleRowsChange() {
this.setTextareaHeight();
}
@watch('value', { waitUntilFirstUpdate: true })
handleValueChange() {
this.input.value = this.value; // force a sync update
this.invalid = !this.input.checkValidity();
this.updateComplete.then(() => this.setTextareaHeight());
}
/** Sets focus on the textarea. */
focus(options?: FocusOptions) {
this.input.focus(options);
@ -221,55 +270,6 @@ export default class SlTextarea extends ShoelaceElement implements ShoelaceFormC
this.invalid = !this.input.checkValidity();
}
handleBlur() {
this.hasFocus = false;
this.emit('sl-blur');
}
handleChange() {
this.value = this.input.value;
this.setTextareaHeight();
this.emit('sl-change');
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
// Disabled form controls are always valid, so we need to recheck validity when the state changes
this.input.disabled = this.disabled;
this.invalid = !this.input.checkValidity();
}
handleFocus() {
this.hasFocus = true;
this.emit('sl-focus');
}
handleInput() {
this.value = this.input.value;
this.emit('sl-input');
}
@watch('rows', { waitUntilFirstUpdate: true })
handleRowsChange() {
this.setTextareaHeight();
}
@watch('value', { waitUntilFirstUpdate: true })
handleValueChange() {
this.input.value = this.value; // force a sync update
this.invalid = !this.input.checkValidity();
this.updateComplete.then(() => this.setTextareaHeight());
}
setTextareaHeight() {
if (this.resize === 'auto') {
this.input.style.height = 'auto';
this.input.style.height = `${this.input.scrollHeight}px`;
} else {
(this.input.style.height as string | undefined) = undefined;
}
}
render() {
const hasLabelSlot = this.hasSlotController.test('label');
const hasHelpTextSlot = this.hasSlotController.test('help-text');

Wyświetl plik

@ -44,13 +44,13 @@ import type { CSSResultGroup } from 'lit';
export default class SlTooltip extends ShoelaceElement {
static styles: CSSResultGroup = styles;
private hoverTimeout: number;
private readonly localize = new LocalizeController(this);
@query('slot:not([name])') defaultSlot: HTMLSlotElement;
@query('.tooltip__body') body: HTMLElement;
@query('sl-popup') popup: SlPopup;
private hoverTimeout: number;
private readonly localize = new LocalizeController(this);
/** The tooltip's content. If you need to display HTML, use the `content` slot instead. */
@property() content = '';
@ -137,46 +137,13 @@ export default class SlTooltip extends ShoelaceElement {
this.removeEventListener('mouseout', this.handleMouseOut);
}
/** Shows the tooltip. */
async show() {
if (this.open) {
return undefined;
}
this.open = true;
return waitForEvent(this, 'sl-after-show');
}
/** Hides the tooltip */
async hide() {
if (!this.open) {
return undefined;
}
this.open = false;
return waitForEvent(this, 'sl-after-hide');
}
getTarget() {
// Get the first child that isn't a <style> or content slot
const target = [...this.children].find(
el => el.tagName.toLowerCase() !== 'style' && el.getAttribute('slot') !== 'content'
);
if (!target) {
throw new Error('Invalid tooltip target: no child element was found.');
}
return target as HTMLElement;
}
handleBlur() {
private handleBlur() {
if (this.hasTrigger('focus')) {
this.hide();
}
}
handleClick() {
private handleClick() {
if (this.hasTrigger('click')) {
if (this.open) {
this.hide();
@ -186,13 +153,13 @@ export default class SlTooltip extends ShoelaceElement {
}
}
handleFocus() {
private handleFocus() {
if (this.hasTrigger('focus')) {
this.show();
}
}
handleKeyDown(event: KeyboardEvent) {
private handleKeyDown(event: KeyboardEvent) {
// Pressing escape when the target element has focus should dismiss the tooltip
if (this.open && event.key === 'Escape') {
event.stopPropagation();
@ -200,7 +167,7 @@ export default class SlTooltip extends ShoelaceElement {
}
}
handleMouseOver() {
private handleMouseOver() {
if (this.hasTrigger('hover')) {
const delay = parseDuration(getComputedStyle(this).getPropertyValue('--show-delay'));
clearTimeout(this.hoverTimeout);
@ -208,7 +175,7 @@ export default class SlTooltip extends ShoelaceElement {
}
}
handleMouseOut() {
private handleMouseOut() {
if (this.hasTrigger('hover')) {
const delay = parseDuration(getComputedStyle(this).getPropertyValue('--hide-delay'));
clearTimeout(this.hoverTimeout);
@ -216,6 +183,11 @@ export default class SlTooltip extends ShoelaceElement {
}
}
private hasTrigger(triggerType: string) {
const triggers = this.trigger.split(' ');
return triggers.includes(triggerType);
}
@watch('open', { waitUntilFirstUpdate: true })
async handleOpenChange() {
if (this.open) {
@ -266,9 +238,24 @@ export default class SlTooltip extends ShoelaceElement {
}
}
hasTrigger(triggerType: string) {
const triggers = this.trigger.split(' ');
return triggers.includes(triggerType);
/** Shows the tooltip. */
async show() {
if (this.open) {
return undefined;
}
this.open = true;
return waitForEvent(this, 'sl-after-show');
}
/** Hides the tooltip */
async hide() {
if (!this.open) {
return undefined;
}
this.open = false;
return waitForEvent(this, 'sl-after-hide');
}
render() {

Wyświetl plik

@ -100,6 +100,56 @@ export default class SlTreeItem extends ShoelaceElement {
this.handleExpandedChange();
}
private async animateCollapse() {
this.emit('sl-collapse');
await stopAnimations(this.childrenContainer);
const { keyframes, options } = getAnimation(this, 'tree-item.collapse', { dir: this.localize.dir() });
await animateTo(
this.childrenContainer,
shimKeyframesHeightAuto(keyframes, this.childrenContainer.scrollHeight),
options
);
this.childrenContainer.hidden = true;
this.emit('sl-after-collapse');
}
// Checks whether the item is nested into an item
private isNestedItem(): boolean {
const parent = this.parentElement;
return !!parent && SlTreeItem.isTreeItem(parent);
}
private handleChildrenSlotChange() {
this.loading = false;
this.isLeaf = !this.lazy && this.getChildrenItems().length === 0;
}
protected willUpdate(changedProperties: PropertyValueMap<SlTreeItem> | Map<PropertyKey, unknown>) {
if (changedProperties.has('selected') && !changedProperties.has('indeterminate')) {
this.indeterminate = false;
}
}
private async animateExpand() {
this.emit('sl-expand');
await stopAnimations(this.childrenContainer);
this.childrenContainer.hidden = false;
const { keyframes, options } = getAnimation(this, 'tree-item.expand', { dir: this.localize.dir() });
await animateTo(
this.childrenContainer,
shimKeyframesHeightAuto(keyframes, this.childrenContainer.scrollHeight),
options
);
this.childrenContainer.style.height = 'auto';
this.emit('sl-after-expand');
}
@watch('loading', { waitUntilFirstUpdate: true })
handleLoadingChange() {
this.setAttribute('aria-busy', this.loading ? 'true' : 'false');
@ -148,40 +198,7 @@ export default class SlTreeItem extends ShoelaceElement {
this.emit('sl-lazy-change');
}
private async animateExpand() {
this.emit('sl-expand');
await stopAnimations(this.childrenContainer);
this.childrenContainer.hidden = false;
const { keyframes, options } = getAnimation(this, 'tree-item.expand', { dir: this.localize.dir() });
await animateTo(
this.childrenContainer,
shimKeyframesHeightAuto(keyframes, this.childrenContainer.scrollHeight),
options
);
this.childrenContainer.style.height = 'auto';
this.emit('sl-after-expand');
}
private async animateCollapse() {
this.emit('sl-collapse');
await stopAnimations(this.childrenContainer);
const { keyframes, options } = getAnimation(this, 'tree-item.collapse', { dir: this.localize.dir() });
await animateTo(
this.childrenContainer,
shimKeyframesHeightAuto(keyframes, this.childrenContainer.scrollHeight),
options
);
this.childrenContainer.hidden = true;
this.emit('sl-after-collapse');
}
// Gets all the nested tree items
/** Gets all the nested tree items in this node. */
getChildrenItems({ includeDisabled = true }: { includeDisabled?: boolean } = {}): SlTreeItem[] {
return this.childrenSlot
? ([...this.childrenSlot.assignedElements({ flatten: true })].filter(
@ -190,23 +207,6 @@ export default class SlTreeItem extends ShoelaceElement {
: [];
}
// Checks whether the item is nested into an item
private isNestedItem(): boolean {
const parent = this.parentElement;
return !!parent && SlTreeItem.isTreeItem(parent);
}
handleChildrenSlotChange() {
this.loading = false;
this.isLeaf = !this.lazy && this.getChildrenItems().length === 0;
}
protected willUpdate(changedProperties: PropertyValueMap<SlTreeItem> | Map<PropertyKey, unknown>) {
if (changedProperties.has('selected') && !changedProperties.has('indeterminate')) {
this.indeterminate = false;
}
}
render() {
const isRtl = this.localize.dir() === 'rtl';
const showExpandButton = !this.loading && (!this.isLeaf || this.lazy);

Wyświetl plik

@ -93,6 +93,10 @@ export default class SlTree extends ShoelaceElement {
async connectedCallback() {
super.connectedCallback();
this.handleTreeChanged = this.handleTreeChanged.bind(this);
this.handleFocusIn = this.handleFocusIn.bind(this);
this.handleFocusOut = this.handleFocusOut.bind(this);
this.setAttribute('role', 'tree');
this.setAttribute('tabindex', '0');
@ -110,6 +114,7 @@ export default class SlTree extends ShoelaceElement {
super.disconnectedCallback();
this.mutationObserver.disconnect();
this.removeEventListener('focusin', this.handleFocusIn);
this.removeEventListener('focusout', this.handleFocusOut);
this.removeEventListener('sl-lazy-change', this.handleSlotChange);
@ -154,7 +159,7 @@ export default class SlTree extends ShoelaceElement {
});
};
handleTreeChanged = (mutations: MutationRecord[]) => {
private handleTreeChanged(mutations: MutationRecord[]) {
for (const mutation of mutations) {
const addedNodes: SlTreeItem[] = [...mutation.addedNodes].filter(SlTreeItem.isTreeItem) as SlTreeItem[];
const removedNodes = [...mutation.removedNodes].filter(SlTreeItem.isTreeItem) as SlTreeItem[];
@ -166,29 +171,9 @@ export default class SlTree extends ShoelaceElement {
this.focusItem(this.getFocusableItems()[0]);
}
}
};
@watch('selection')
async handleSelectionChange() {
const isSelectionMultiple = this.selection === 'multiple';
const items = this.getAllTreeItems();
this.setAttribute('aria-multiselectable', isSelectionMultiple ? 'true' : 'false');
for (const item of items) {
item.selectable = isSelectionMultiple;
}
if (isSelectionMultiple) {
await this.updateComplete;
[...this.querySelectorAll(':scope > sl-tree-item')].forEach((treeItem: SlTreeItem) =>
syncCheckboxes(treeItem, true)
);
}
}
syncTreeItems(selectedItem: SlTreeItem) {
private syncTreeItems(selectedItem: SlTreeItem) {
const items = this.getAllTreeItems();
if (this.selection === 'multiple') {
@ -202,7 +187,7 @@ export default class SlTree extends ShoelaceElement {
}
}
selectItem(selectedItem: SlTreeItem) {
private selectItem(selectedItem: SlTreeItem) {
const previousSelection = [...this.selectedItems];
if (this.selection === 'multiple') {
@ -234,18 +219,18 @@ export default class SlTree extends ShoelaceElement {
}
// Returns the list of tree items that are selected in the tree.
get selectedItems(): SlTreeItem[] {
private get selectedItems(): SlTreeItem[] {
const items = this.getAllTreeItems();
const isSelected = (item: SlTreeItem) => item.selected;
return items.filter(isSelected);
}
getAllTreeItems() {
private getAllTreeItems() {
return [...this.querySelectorAll<SlTreeItem>('sl-tree-item')];
}
getFocusableItems() {
private getFocusableItems() {
const items = this.getAllTreeItems();
const collapsedItems = new Set();
@ -263,11 +248,11 @@ export default class SlTree extends ShoelaceElement {
});
}
focusItem(item?: SlTreeItem | null) {
private focusItem(item?: SlTreeItem | null) {
item?.focus();
}
handleKeyDown(event: KeyboardEvent) {
private handleKeyDown(event: KeyboardEvent) {
if (!['ArrowDown', 'ArrowUp', 'ArrowRight', 'ArrowLeft', 'Home', 'End', 'Enter', ' '].includes(event.key)) {
return;
}
@ -332,7 +317,7 @@ export default class SlTree extends ShoelaceElement {
}
}
handleClick(event: Event) {
private handleClick(event: Event) {
const target = event.target as HTMLElement;
const treeItem = target.closest('sl-tree-item')!;
const isExpandButton = event
@ -350,16 +335,16 @@ export default class SlTree extends ShoelaceElement {
}
}
handleFocusOut = (event: FocusEvent) => {
private handleFocusOut(event: FocusEvent) {
const relatedTarget = event.relatedTarget as HTMLElement;
// If the element that got the focus is not in the tree
if (!relatedTarget || !this.contains(relatedTarget)) {
this.tabIndex = 0;
}
};
}
handleFocusIn = (event: FocusEvent) => {
private handleFocusIn(event: FocusEvent) {
const target = event.target as SlTreeItem;
// If the tree has been focused, move the focus to the last focused item
@ -377,13 +362,33 @@ export default class SlTree extends ShoelaceElement {
target.tabIndex = 0;
}
};
}
handleSlotChange() {
private handleSlotChange() {
const items = this.getAllTreeItems();
items.forEach(this.initTreeItem);
}
@watch('selection')
async handleSelectionChange() {
const isSelectionMultiple = this.selection === 'multiple';
const items = this.getAllTreeItems();
this.setAttribute('aria-multiselectable', isSelectionMultiple ? 'true' : 'false');
for (const item of items) {
item.selectable = isSelectionMultiple;
}
if (isSelectionMultiple) {
await this.updateComplete;
[...this.querySelectorAll(':scope > sl-tree-item')].forEach((treeItem: SlTreeItem) =>
syncCheckboxes(treeItem, true)
);
}
}
render() {
return html`
<div part="base" class="tree" @click=${this.handleClick} @keydown=${this.handleKeyDown}>