Skip to content

Commit

Permalink
SlSplitPanel snap improvements. (#2340)
Browse files Browse the repository at this point in the history
* (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 <[email protected]>

---------

Co-authored-by: Cory LaViska <[email protected]>
  • Loading branch information
Aurailus and claviska authored Feb 3, 2025
1 parent e3b117d commit a7aadc9
Show file tree
Hide file tree
Showing 2 changed files with 188 additions and 32 deletions.
101 changes: 96 additions & 5 deletions docs/pages/components/split-panel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -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
Expand Down
119 changes: 92 additions & 27 deletions src/components/split-panel/split-panel.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;

/**
* 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.
*/
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;
}
}

/**
* One or more space-separated values at which the divider should snap. Values can be in pixels or percentages, e.g.
* `"100px 50%"`.
* 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() snap?: string;
@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;
Expand Down Expand Up @@ -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);
},
Expand Down

0 comments on commit a7aadc9

Please sign in to comment.