SlSplitPanel `snap` improvements. (#2340)

* (sl-split-panel) Add repeat expression and function expression to [snap].

* (sl-split-panel) Improve documentation.

* Improve split panel repeat() syntax, code cleanup.
- Move helper methods for split panel (toSnapFunction), and type definitions (SnapFunction, SnapFunctionParams) to split-panel.component.ts
- Allow repeat() to exist within other snap points in the split panel snap property. e.g. "repeat(100px) 50% 70% 90px" is now valid.

* Apply suggestions from code review

Co-authored-by: Cory LaViska <cory@abeautifulsite.net>

---------

Co-authored-by: Cory LaViska <cory@abeautifulsite.net>
pull/2370/head
Auri Collings 2025-02-03 13:14:00 -08:00 zatwierdzone przez GitHub
rodzic e3b117dc9a
commit a7aadc93f9
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
2 zmienionych plików z 188 dodań i 32 usunięć

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,105 @@ 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

@ -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,73 @@ 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 +206,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);
},