Merge branch 'next' into current

current
Cory LaViska 2025-02-03 16:30:23 -05:00
commit 0ac75df8fe
15 zmienionych plików z 335 dodań i 113 usunięć

Wyświetl plik

@ -11,15 +11,14 @@ on:
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
- name: Update system packages
run: |
sudo apt-get update
sudo apt-get upgrade -y
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:

Wyświetl plik

@ -155,4 +155,20 @@ const App = () => (
);
```
### Tailwind users
Using TailwindCSS with Shoelace [may override divider styles](https://github.com/shoelace-style/shoelace/issues/2088), making them invisible. As a workaround, add this to your Tailwind config file.
```css
@layer base {
sl-divider:not([vertical]) {
border-top: solid var(--width) var(--color);
}
sl-divider[vertical] {
border-left: solid var(--width) var(--color);
}
}
```
{% endraw %}

Wyświetl plik

@ -200,20 +200,22 @@ const App = () => (
### Snapping
To snap panels at specific positions while dragging, add the `snap` attribute with one or more space-separated values. Values must be in pixels or percentages. For example, to snap the panel at `100px` and `50%`, use `snap="100px 50%"`. You can also customize how close the divider must be before snapping with the `snap-threshold` attribute.
To snap panels at specific positions while dragging, you can use the `snap` attribute. You can provide one or more space-separated pixel or percentage values, either as single values or within a `repeat()` expression, which will be repeated along the length of the panel. You can also customize how close the divider must be before snapping with the `snap-threshold` attribute.
For example, to snap the panel at `100px` and `50%`, use `snap="100px 50%"`.
```html:preview
<div class="split-panel-snapping">
<sl-split-panel snap="100px 50%">
<div
slot="start"
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
style="height: 150px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
>
Start
</div>
<div
slot="end"
style="height: 200px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
style="height: 150px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
>
End
</div>
@ -239,16 +241,106 @@ To snap panels at specific positions while dragging, add the `snap` attribute wi
transform: translateX(-3px);
}
.split-panel-snapping-dots::before {
.split-panel-snapping .split-panel-snapping-dots::before {
left: 100px;
}
.split-panel-snapping-dots::after {
.split-panel-snapping .split-panel-snapping-dots::after {
left: 50%;
}
</style>
```
Or, if you want to snap the panel to every `100px` interval, as well as at 50% of the panel's size, you can use `snap="repeat(100px) 50%"`.
```html:preview
<div class="split-panel-snapping-repeat">
<sl-split-panel snap="repeat(100px) 50%">
<div
slot="start"
style="height: 150px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
>
Start
</div>
<div
slot="end"
style="height: 150px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
>
End
</div>
</sl-split-panel>
</div>
<style>
.split-panel-snapping-repeat {
position: relative;
}
</style>
```
### Using a Custom Snap Function
You can also implement a custom snap function which controls the snapping manually. To do this, you need to acquire a reference to the element in Javascript and set the `snap` property. For example, if you want to snap the divider to either `100px` from the left or `100px` from the right, you can set the `snap` property to a function encoding that logic.
```js
panel.snap = ({ pos, size }) => (pos < size / 2 ? 100 : size - 100);
```
Note that the `snap-threshold` property will not automatically be applied if `snap` is set to a function. Instead, the function itself must handle applying the threshold if desired, and is passed a `snapThreshold` member with its parameters.
```html:preview
<div class="split-panel-snapping-fn">
<sl-split-panel>
<div
slot="start"
style="height: 150px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
>
Start
</div>
<div
slot="end"
style="height: 150px; background: var(--sl-color-neutral-50); display: flex; align-items: center; justify-content: center; overflow: hidden;"
>
End
</div>
</sl-split-panel>
<div class="split-panel-snapping-dots"></div>
</div>
<style>
.split-panel-snapping-fn {
position: relative;
}
.split-panel-snapping-fn .split-panel-snapping-dots::before,
.split-panel-snapping-fn .split-panel-snapping-dots::after {
content: '';
position: absolute;
bottom: -12px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--sl-color-neutral-400);
transform: translateX(-3px);
}
.split-panel-snapping-fn .split-panel-snapping-dots::before {
left: 100px;
}
.split-panel-snapping-fn .split-panel-snapping-dots::after {
left: calc(100% - 100px);
}
</style>
<script>
const container = document.querySelector('.split-panel-snapping-fn');
const splitPanel = container.querySelector('sl-split-panel');
splitPanel.snap = ({ pos, size }) => (pos < size / 2) ? 100 : (size - 100);
</script>
```
{% raw %}
```jsx:react

Wyświetl plik

@ -57,10 +57,21 @@ If you'd rather not use the CDN for assets, you can create a build task that cop
One caveat is there's currently Svelte only supports `bind:value` directive in `<input>`, `<textarea>` and `<select>`, but you can still achieve two-way binding manually.
```jsx
// This doesn't work
// ❌ These do not work
<sl-input bind:value="name"></sl-input>
// This works, but it's a bit longer
<sl-input value={name} oninput={event => message = event.target.value}></sl-input>
<sl-select bind:value="job">
<sl-option value="designer">Designer</sl-option>
<sl-option value="developer">Developer</sl-option>
</sl-select>
// ✅ These are a bit longer, but work
<sl-input value={name} oninput={event => name = event.target.value}></sl-input>
<sl-select value={job} onsl-input={event => job = event.target.value}>
<sl-option value="designer">Designer</sl-option>
<sl-option value="developer">Developer</sl-option>
</sl-select>
```
:::tip

Wyświetl plik

@ -67,9 +67,9 @@ When binding complex data such as objects and arrays, use the `.prop` modifier t
One caveat is there's currently [no support for v-model on custom elements](https://github.com/vuejs/vue/issues/7830), but you can still achieve two-way binding manually.
```html
<!-- This doesn't work -->
<!-- This doesn't work -->
<sl-input v-model="name"></sl-input>
<!-- This works, but it's a bit longer -->
<!-- This works, but it's a bit longer -->
<sl-input :value="name" @input="name = $event.target.value"></sl-input>
```

Wyświetl plik

@ -100,9 +100,9 @@ When binding complex data such as objects and arrays, use the `.prop` modifier t
One caveat is there's currently [no support for v-model on custom elements](https://github.com/vuejs/vue/issues/7830), but you can still achieve two-way binding manually.
```html
<!-- This doesn't work -->
<!-- This doesn't work -->
<sl-input v-model="name"></sl-input>
<!-- This works, but it's a bit longer -->
<!-- This works, but it's a bit longer -->
<sl-input :value="name" @input="name = $event.target.value"></sl-input>
```

Wyświetl plik

@ -12,6 +12,15 @@ Components with the <sl-badge variant="warning" pill>Experimental</sl-badge> bad
New versions of Shoelace are released as-needed and generally occur when a critical mass of changes have accumulated. At any time, you can see what's coming in the next release by visiting [next.shoelace.style](https://next.shoelace.style).
## 2.20.0
- Added the ability to set a custom snap function and use `repeat(n)` to `<sl-split-panel>` [#2340]
- Fixed a bug with radios in `<sl-dialog>` focus trapping.
- Improved performance of `<sl-select>` when using a large number of options [#2318]
- Improved `<sl-alert>` to create the toast stack when used only, making it usable in SSR environments [#2359]
- Improved `scrollend-polyfill` so it only runs on the client to make it usable in SSR environments [#2359]
- Updated the Japanese translation [#2329]
## 2.19.1
- Fixed a bug in `<sl-tab-group>` that prevented changing tabs by setting `active` on `<sl-tab>` elements [#2298]

4
package-lock.json wygenerowano
Wyświetl plik

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

Wyświetl plik

@ -1,7 +1,7 @@
{
"name": "@shoelace-style/shoelace",
"description": "A forward-thinking library of web components.",
"version": "2.19.1",
"version": "2.20.0",
"homepage": "https://github.com/shoelace-style/shoelace",
"author": "Cory LaViska",
"license": "MIT",

Wyświetl plik

@ -13,8 +13,6 @@ import SlIconButton from '../icon-button/icon-button.component.js';
import styles from './alert.styles.js';
import type { CSSResultGroup } from 'lit';
const toastStack = Object.assign(document.createElement('div'), { className: 'sl-toast-stack' });
/**
* @summary Alerts are used to display important messages inline or as toast notifications.
* @documentation https://shoelace.style/components/alert
@ -50,6 +48,17 @@ export default class SlAlert extends ShoelaceElement {
private readonly hasSlotController = new HasSlotController(this, 'icon', 'suffix');
private readonly localize = new LocalizeController(this);
private static currentToastStack: HTMLDivElement;
private static get toastStack() {
if (!this.currentToastStack) {
this.currentToastStack = Object.assign(document.createElement('div'), {
className: 'sl-toast-stack'
});
}
return this.currentToastStack;
}
@query('[part~="base"]') base: HTMLElement;
@query('.alert__countdown-elapsed') countdownElement: HTMLElement;
@ -195,11 +204,11 @@ export default class SlAlert extends ShoelaceElement {
async toast() {
return new Promise<void>(resolve => {
this.handleCountdownChange();
if (toastStack.parentElement === null) {
document.body.append(toastStack);
if (SlAlert.toastStack.parentElement === null) {
document.body.append(SlAlert.toastStack);
}
toastStack.appendChild(this);
SlAlert.toastStack.appendChild(this);
// Wait for the toast stack to render
requestAnimationFrame(() => {
@ -211,12 +220,12 @@ export default class SlAlert extends ShoelaceElement {
this.addEventListener(
'sl-after-hide',
() => {
toastStack.removeChild(this);
SlAlert.toastStack.removeChild(this);
resolve();
// Remove the toast stack from the DOM when there are no more alerts
if (toastStack.querySelector('sl-alert') === null) {
toastStack.remove();
if (SlAlert.toastStack.querySelector('sl-alert') === null) {
SlAlert.toastStack.remove();
}
},
{ once: true }

Wyświetl plik

@ -34,6 +34,8 @@ export default class SlOption extends ShoelaceElement {
// @ts-expect-error - Controller is currently unused
private readonly localize = new LocalizeController(this);
private isInitialized = false;
@query('.option__label') defaultSlot: HTMLSlotElement;
@state() current = false; // the user has keyed into the option, but hasn't selected it yet (shows a highlight)
@ -57,13 +59,17 @@ export default class SlOption extends ShoelaceElement {
}
private handleDefaultSlotChange() {
// When the label changes, tell the controller to update
customElements.whenDefined('sl-select').then(() => {
const controller = this.closest('sl-select');
if (controller) {
controller.handleDefaultSlotChange();
}
});
if (this.isInitialized) {
// When the label changes, tell the controller to update
customElements.whenDefined('sl-select').then(() => {
const controller = this.closest('sl-select');
if (controller) {
controller.handleDefaultSlotChange();
}
});
} else {
this.isInitialized = true;
}
}
private handleMouseEnter() {

Wyświetl plik

@ -10,6 +10,25 @@ import ShoelaceElement from '../../internal/shoelace-element.js';
import styles from './split-panel.styles.js';
import type { CSSResultGroup } from 'lit';
export interface SnapFunctionParams {
/** The position the divider has been dragged to, in pixels. */
pos: number;
/** The size of the split-panel across its primary axis, in pixels. */
size: number;
/** The snap-threshold passed to the split-panel, in pixels. May be infinity. */
snapThreshold: number;
/** Whether or not the user-agent is RTL. */
isRtl: boolean;
/** Whether or not the split panel is vertical. */
vertical: boolean;
}
/** Used by sl-split-panel to convert an input position into a snapped position. */
export type SnapFunction = (opt: SnapFunctionParams) => number | null;
/** A SnapFunction which performs no snapping. */
export const SNAP_NONE = () => null;
/**
* @summary Split panels display two adjacent panels, allowing the user to reposition them.
* @documentation https://shoelace.style/components/split-panel
@ -67,11 +86,72 @@ export default class SlSplitPanel extends ShoelaceElement {
*/
@property() primary?: 'start' | 'end';
// Returned when the property is queried, so that string 'snap's are preserved.
private snapValue: string | SnapFunction = '';
// Actually used for computing snap points. All string snaps are converted via `toSnapFunction`.
private snapFunction: SnapFunction = SNAP_NONE;
/**
* One or more space-separated values at which the divider should snap. Values can be in pixels or percentages, e.g.
* `"100px 50%"`.
* Converts a string containing either a series of fixed/repeated snap points (e.g. "repeat(20%)", "100px 200px 800px", or "10% 50% repeat(10px)") into a SnapFunction. `SnapFunction`s take in a `SnapFunctionOpts` and return the position that the split panel should snap to.
*
* @param snap - The snap string.
* @returns a `SnapFunction` representing the snap string's logic.
*/
@property() snap?: string;
private toSnapFunction(snap: string): SnapFunction {
const snapPoints = snap.split(' ');
return ({ pos, size, snapThreshold, isRtl, vertical }) => {
let newPos = pos;
let minDistance = Number.POSITIVE_INFINITY;
snapPoints.forEach(value => {
let snapPoint: number;
if (value.startsWith('repeat(')) {
const repeatVal = snap.substring('repeat('.length, snap.length - 1);
const isPercent = repeatVal.endsWith('%');
const repeatNum = Number.parseFloat(repeatVal);
const snapIntervalPx = isPercent ? size * (repeatNum / 100) : repeatNum;
snapPoint = Math.round((isRtl && !vertical ? size - pos : pos) / snapIntervalPx) * snapIntervalPx;
} else if (value.endsWith('%')) {
snapPoint = size * (Number.parseFloat(value) / 100);
} else {
snapPoint = Number.parseFloat(value);
}
if (isRtl && !vertical) {
snapPoint = size - snapPoint;
}
const distance = Math.abs(pos - snapPoint);
if (distance <= snapThreshold && distance < minDistance) {
newPos = snapPoint;
minDistance = distance;
}
});
return newPos;
};
}
/**
* Either one or more space-separated values at which the divider should snap, in pixels, percentages, or repeat expressions e.g. `'100px 50% 500px' or `repeat(50%) 10px`,
* or a function which takes in a `SnapFunctionParams`, and returns a position to snap to, e.g. `({ pos }) => Math.round(pos / 8) * 8`.
*/
@property({ reflect: true })
set snap(snap: string | SnapFunction | null | undefined) {
this.snapValue = snap ?? '';
if (snap) {
this.snapFunction = typeof snap === 'string' ? this.toSnapFunction(snap) : snap;
} else {
this.snapFunction = SNAP_NONE;
}
}
get snap(): string | SnapFunction {
return this.snapValue;
}
/** How close the divider must be to a snap point until snapping occurs. */
@property({ type: Number, attribute: 'snap-threshold' }) snapThreshold = 12;
@ -125,30 +205,14 @@ export default class SlSplitPanel extends ShoelaceElement {
}
// Check snap points
if (this.snap) {
const snaps = this.snap.split(' ');
snaps.forEach(value => {
let snapPoint: number;
if (value.endsWith('%')) {
snapPoint = this.size * (parseFloat(value) / 100);
} else {
snapPoint = parseFloat(value);
}
if (isRtl && !this.vertical) {
snapPoint = this.size - snapPoint;
}
if (
newPositionInPixels >= snapPoint - this.snapThreshold &&
newPositionInPixels <= snapPoint + this.snapThreshold
) {
newPositionInPixels = snapPoint;
}
});
}
newPositionInPixels =
this.snapFunction({
pos: newPositionInPixels,
size: this.size,
snapThreshold: this.snapThreshold,
isRtl: isRtl,
vertical: this.vertical
}) ?? newPositionInPixels;
this.position = clamp(this.pixelsToPercentage(newPositionInPixels), 0, 100);
},

Wyświetl plik

@ -26,54 +26,61 @@ const decorate = <T, M extends keyof T>(
} as MethodOf<T, M>;
};
const isSupported = 'onscrollend' in window;
(() => {
// SSR environments should not apply the polyfill
if (typeof window === 'undefined') {
return;
}
if (!isSupported) {
const pointers = new Set();
const scrollHandlers = new WeakMap<EventTarget, EventListenerOrEventListenerObject>();
const isSupported = 'onscrollend' in window;
const handlePointerDown = (event: TouchEvent) => {
for (const touch of event.changedTouches) {
pointers.add(touch.identifier);
}
};
if (!isSupported) {
const pointers = new Set();
const scrollHandlers = new WeakMap<EventTarget, EventListenerOrEventListenerObject>();
const handlePointerUp = (event: TouchEvent) => {
for (const touch of event.changedTouches) {
pointers.delete(touch.identifier);
}
};
document.addEventListener('touchstart', handlePointerDown, true);
document.addEventListener('touchend', handlePointerUp, true);
document.addEventListener('touchcancel', handlePointerUp, true);
decorate(EventTarget.prototype, 'addEventListener', function (this: EventTarget, addEventListener, type) {
if (type !== 'scrollend') return;
const handleScrollEnd = debounce(() => {
if (!pointers.size) {
// If no pointer is active in the scroll area then the scroll has ended
this.dispatchEvent(new Event('scrollend'));
} else {
// otherwise let's wait a bit more
handleScrollEnd();
const handlePointerDown = (event: TouchEvent) => {
for (const touch of event.changedTouches) {
pointers.add(touch.identifier);
}
}, 100);
};
addEventListener.call(this, 'scroll', handleScrollEnd, { passive: true });
scrollHandlers.set(this, handleScrollEnd);
});
const handlePointerUp = (event: TouchEvent) => {
for (const touch of event.changedTouches) {
pointers.delete(touch.identifier);
}
};
decorate(EventTarget.prototype, 'removeEventListener', function (this: EventTarget, removeEventListener, type) {
if (type !== 'scrollend') return;
document.addEventListener('touchstart', handlePointerDown, true);
document.addEventListener('touchend', handlePointerUp, true);
document.addEventListener('touchcancel', handlePointerUp, true);
const scrollHandler = scrollHandlers.get(this);
if (scrollHandler) {
removeEventListener.call(this, 'scroll', scrollHandler, { passive: true } as unknown as EventListenerOptions);
}
});
}
decorate(EventTarget.prototype, 'addEventListener', function (this: EventTarget, addEventListener, type) {
if (type !== 'scrollend') return;
const handleScrollEnd = debounce(() => {
if (!pointers.size) {
// If no pointer is active in the scroll area then the scroll has ended
this.dispatchEvent(new Event('scrollend'));
} else {
// otherwise let's wait a bit more
handleScrollEnd();
}
}, 100);
addEventListener.call(this, 'scroll', handleScrollEnd, { passive: true });
scrollHandlers.set(this, handleScrollEnd);
});
decorate(EventTarget.prototype, 'removeEventListener', function (this: EventTarget, removeEventListener, type) {
if (type !== 'scrollend') return;
const scrollHandler = scrollHandlers.get(this);
if (scrollHandler) {
removeEventListener.call(this, 'scroll', scrollHandler, { passive: true } as unknown as EventListenerOptions);
}
});
}
})();
// Without an import or export, TypeScript sees vars in this file as global
export {};

Wyświetl plik

@ -80,9 +80,19 @@ function isTabbable(el: HTMLElement) {
return false;
}
// Radios without a checked attribute are not tabbable
if (tag === 'input' && el.getAttribute('type') === 'radio' && !el.hasAttribute('checked')) {
return false;
if (tag === 'input' && el.getAttribute('type') === 'radio') {
const rootNode = el.getRootNode() as HTMLElement;
const findRadios = `input[type='radio'][name="${el.getAttribute('name')}"]`;
const firstChecked = rootNode.querySelector(`${findRadios}:checked`);
if (firstChecked) {
return firstChecked === el;
}
const firstRadio = rootNode.querySelector(findRadios);
return firstRadio === el;
}
if (!isVisible(el)) {

Wyświetl plik

@ -7,20 +7,19 @@ const translation: Translation = {
$dir: 'ltr',
carousel: 'カルーセル',
clearEntry: 'クリアエントリ',
clearEntry: 'クリア',
close: '閉じる',
copied: 'コピーされました',
copied: 'コピーました',
copy: 'コピー',
currentValue: '現在の値',
currentValue: '現在の値',
error: 'エラー',
goToSlide: (slide, count) => `${count} 枚中 ${slide} 枚のスライドに移動`,
hidePassword: 'パスワードを隠す',
loading: '読み込み中',
nextSlide: '次のスライド',
numOptionsSelected: num => {
if (num === 0) return 'オプションが選択されていません';
if (num === 1) return '1 つのオプションが選択されました';
return `${num} つのオプションが選択されました`;
if (num === 0) return '項目が選択されていません';
return `${num} 個の項目が選択されました`;
},
previousSlide: '前のスライド',
progress: '進行',