diff --git a/docs/_sidebar.md b/docs/_sidebar.md index e4f128f9f..1a3527b04 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -26,6 +26,7 @@ - [Form](/components/form.md) - [Icon](/components/icon.md) - [Icon Button](/components/icon-button.md) + - [Image Comparer](/components/image-comparer.md) - [Input](/components/input.md) - [Menu](/components/menu.md) - [Menu Divider](/components/menu-divider.md) diff --git a/docs/assets/images/kittens-color.jpg b/docs/assets/images/kittens-color.jpg new file mode 100644 index 000000000..1e1b6baeb Binary files /dev/null and b/docs/assets/images/kittens-color.jpg differ diff --git a/docs/assets/images/kittens-grayscale.jpg b/docs/assets/images/kittens-grayscale.jpg new file mode 100644 index 000000000..55e8b53fb Binary files /dev/null and b/docs/assets/images/kittens-grayscale.jpg differ diff --git a/docs/components/image-comparer.md b/docs/components/image-comparer.md new file mode 100644 index 000000000..5cb37115a --- /dev/null +++ b/docs/components/image-comparer.md @@ -0,0 +1,14 @@ +# Image Comparer + +[component-header:sl-image-comparer] + +Compares two images with a sliding panel, commonly used to demonstrate visual differences between similar photos. + +```html preview + + Kittens in a basket in grayscale + Kittens in a basket in color + +``` + +[component-metadata:sl-image-comparer] diff --git a/src/components.d.ts b/src/components.d.ts index ef219e385..a5fda988a 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -433,6 +433,12 @@ export namespace Components { */ "src": string; } + interface SlImageComparer { + /** + * The position of the divider as a percentage. + */ + "position": number; + } interface SlInput { /** * The input's autocaptialize attribute. @@ -1094,6 +1100,12 @@ declare global { prototype: HTMLSlIconButtonElement; new (): HTMLSlIconButtonElement; }; + interface HTMLSlImageComparerElement extends Components.SlImageComparer, HTMLStencilElement { + } + var HTMLSlImageComparerElement: { + prototype: HTMLSlImageComparerElement; + new (): HTMLSlImageComparerElement; + }; interface HTMLSlInputElement extends Components.SlInput, HTMLStencilElement { } var HTMLSlInputElement: { @@ -1231,6 +1243,7 @@ declare global { "sl-form": HTMLSlFormElement; "sl-icon": HTMLSlIconElement; "sl-icon-button": HTMLSlIconButtonElement; + "sl-image-comparer": HTMLSlImageComparerElement; "sl-input": HTMLSlInputElement; "sl-menu": HTMLSlMenuElement; "sl-menu-divider": HTMLSlMenuDividerElement; @@ -1741,6 +1754,16 @@ declare namespace LocalJSX { */ "src"?: string; } + interface SlImageComparer { + /** + * Emitted when the slider position changes. + */ + "onSlChange"?: (event: CustomEvent) => void; + /** + * The position of the divider as a percentage. + */ + "position"?: number; + } interface SlInput { /** * The input's autocaptialize attribute. @@ -2349,6 +2372,7 @@ declare namespace LocalJSX { "sl-form": SlForm; "sl-icon": SlIcon; "sl-icon-button": SlIconButton; + "sl-image-comparer": SlImageComparer; "sl-input": SlInput; "sl-menu": SlMenu; "sl-menu-divider": SlMenuDivider; @@ -2391,6 +2415,7 @@ declare module "@stencil/core" { "sl-form": LocalJSX.SlForm & JSXBase.HTMLAttributes; "sl-icon": LocalJSX.SlIcon & JSXBase.HTMLAttributes; "sl-icon-button": LocalJSX.SlIconButton & JSXBase.HTMLAttributes; + "sl-image-comparer": LocalJSX.SlImageComparer & JSXBase.HTMLAttributes; "sl-input": LocalJSX.SlInput & JSXBase.HTMLAttributes; "sl-menu": LocalJSX.SlMenu & JSXBase.HTMLAttributes; "sl-menu-divider": LocalJSX.SlMenuDivider & JSXBase.HTMLAttributes; diff --git a/src/components/image-comparer/image-comparer.scss b/src/components/image-comparer/image-comparer.scss new file mode 100644 index 000000000..520f21bc4 --- /dev/null +++ b/src/components/image-comparer/image-comparer.scss @@ -0,0 +1,78 @@ +@import 'component'; + +/** + * @prop --handle-size: The size of the compare handle. + */ +:host { + --divider-width: 4px; + --handle-size: 2.5rem; + + display: block; + position: relative; +} + +.image-comparer { + max-width: 100%; + max-height: 100%; + overflow: hidden; +} + +.image-comparer__before { + pointer-events: none; + + ::slotted(img) { + display: block; + max-width: 100% !important; + height: auto; + } +} + +.image-comparer__after { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 100%; + pointer-events: none; + + ::slotted(img) { + display: block; + max-width: 100% !important; + height: auto; + } +} + +.image-comparer__divider { + display: flex; + align-items: center; + justify-content: center; + position: absolute; + top: 0; + width: var(--divider-width); + height: 100%; + background-color: var(--sl-color-white); + transform: translateX(calc(var(--divider-width) / -2)); + cursor: ew-resize; +} + +.image-comparer__handle { + display: flex; + align-items: center; + justify-content: center; + position: absolute; + top: calc(50% - (var(--handle-size) / 2)); + width: var(--handle-size); + height: var(--handle-size); + background-color: var(--sl-color-white); + border-radius: var(--sl-border-radius-circle); + font-size: calc(var(--handle-size) * 0.5); + color: var(--sl-color-gray-50); + cursor: inherit; + z-index: 10; + + &:focus { + outline: none; + box-shadow: 0 0 0 1px hsl(var(--sl-color-primary-hue), var(--sl-color-primary-saturation), 50%), + var(--sl-focus-ring-box-shadow); + } +} diff --git a/src/components/image-comparer/image-comparer.tsx b/src/components/image-comparer/image-comparer.tsx new file mode 100644 index 000000000..717ed3b93 --- /dev/null +++ b/src/components/image-comparer/image-comparer.tsx @@ -0,0 +1,147 @@ +import { Component, Event, EventEmitter, Prop, State, Watch, h } from '@stencil/core'; +import { clamp } from '../../utilities/math'; + +/** + * @since 2.0 + * @status experimental + * + * @part base - The component's base wrapper. + * @part before - The container that holds the "before" image. + * @part after - The container that holds the "after" image. + * @part divider - The divider that separates the images. + * @part handle - The handle that the user drags to expose the after image. + */ + +@Component({ + tag: 'sl-image-comparer', + styleUrl: 'image-comparer.scss', + shadow: true +}) +export class ImageComparer { + base: HTMLElement; + divider: HTMLElement; + handle: HTMLElement; + + @State() dividerPosition: number; + + /** The position of the divider as a percentage. */ + @Prop({ mutable: true }) position = 50; + + @Watch('position') + handlePositionChange() { + this.slChange.emit(); + } + + /** Emitted when the slider position changes. */ + @Event() slChange: EventEmitter; + + connectedCallback() { + this.dividerPosition = this.position; + + this.handleDrag = this.handleDrag.bind(this); + this.handleKeyDown = this.handleKeyDown.bind(this); + } + + handleDrag(event: any) { + const { width } = this.base.getBoundingClientRect(); + + function drag(event: any, container: HTMLElement, onMove: (x: number, y: number) => void) { + const move = (event: any) => { + const dims = container.getBoundingClientRect(); + const offsetX = dims.left + container.ownerDocument.defaultView.pageXOffset; + const offsetY = dims.top + container.ownerDocument.defaultView.pageYOffset; + const x = (event.changedTouches ? event.changedTouches[0].pageX : event.pageX) - offsetX; + const y = (event.changedTouches ? event.changedTouches[0].pageY : event.pageY) - offsetY; + + onMove(x, y); + }; + + // Move on init + move(event); + + const stop = () => { + document.removeEventListener('mousemove', move); + document.removeEventListener('touchmove', move); + document.removeEventListener('mouseup', stop); + document.removeEventListener('touchend', stop); + }; + + document.addEventListener('mousemove', move); + document.addEventListener('touchmove', move); + document.addEventListener('mouseup', stop); + document.addEventListener('touchend', stop); + } + + this.handle.focus(); + event.preventDefault(); + + drag(event, this.base, x => { + this.position = clamp((x / width) * 100, 0, 100); + this.dividerPosition = this.position; + }); + } + + handleKeyDown(event: KeyboardEvent) { + if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) { + const incr = event.shiftKey ? 10 : 1; + let newPosition = this.position; + + event.preventDefault(); + + if (event.key === 'ArrowLeft') newPosition = newPosition - incr; + if (event.key === 'ArrowRight') newPosition = newPosition + incr; + if (event.key === 'Home') newPosition = 0; + if (event.key === 'End') newPosition = 100; + newPosition = clamp(newPosition, 0, 100); + + this.position = newPosition; + this.dividerPosition = newPosition; + } + } + + render() { + return ( +
(this.base = el)} part="base" class="image-comparer" onKeyDown={this.handleKeyDown}> +
+
+ +
+ +
+ +
+
+ +
(this.divider = el)} + part="divider" + class="image-comparer__divider" + style={{ + left: `${this.dividerPosition}%` + }} + onMouseDown={this.handleDrag} + onTouchStart={this.handleDrag} + > +
(this.handle = el)} + part="handle" + class="image-comparer__handle" + role="slider" + aria-valuenow={this.dividerPosition} + aria-valuemin="0" + aria-valuemax="100" + tabIndex={0} + > + +
+
+
+ ); + } +} diff --git a/themes/dark.css b/themes/dark.css index 02280f0ca..c3c82eec7 100644 --- a/themes/dark.css +++ b/themes/dark.css @@ -314,6 +314,13 @@ color: var(--sl-color-primary-60); } +/* Image comparer */ +.sl-theme-dark sl-image-comparer::part(divider), +.sl-theme-dark sl-image-comparer::part(handle) { + background-color: var(--sl-color-gray-10); + color: var(--sl-color-gray-60); +} + /* Menu item */ .sl-theme-dark sl-menu-item[active]::part(base) { background-color: var(--sl-color-primary-15);