From c21a297f82c3d3d9f4a7ef0531f34128caf634f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Von=C3=A1=C5=A1ek?= Date: Mon, 16 Sep 2024 11:39:20 +0200 Subject: [PATCH] DCA custom interval slider --- .changeset/tasty-rats-hammer.md | 6 + packages/apps/src/app/dca/Form.ts | 52 ++++--- packages/apps/src/app/dca/translation.en.json | 3 +- packages/ui/src/component/Slider.css | 126 ++++++++++++++++ packages/ui/src/component/Slider.ts | 142 ++++++++++++++++++ packages/ui/src/index.ts | 1 + 6 files changed, 311 insertions(+), 19 deletions(-) create mode 100644 .changeset/tasty-rats-hammer.md create mode 100644 packages/ui/src/component/Slider.css create mode 100644 packages/ui/src/component/Slider.ts diff --git a/.changeset/tasty-rats-hammer.md b/.changeset/tasty-rats-hammer.md new file mode 100644 index 0000000000..8f245e37d5 --- /dev/null +++ b/.changeset/tasty-rats-hammer.md @@ -0,0 +1,6 @@ +--- +'@galacticcouncil/apps': minor +'@galacticcouncil/ui': minor +--- + +Added range slider component for DCA diff --git a/packages/apps/src/app/dca/Form.ts b/packages/apps/src/app/dca/Form.ts index 12e0d974b0..4ad4fd4dee 100644 --- a/packages/apps/src/app/dca/Form.ts +++ b/packages/apps/src/app/dca/Form.ts @@ -327,25 +327,41 @@ export class DcaForm extends BaseElement { } formFrequencyTemplate() { - const error = this.error['frequencyOutOfRange']; - const isDisabled = - this.error['balanceTooLow'] || this.error['minBudgetTooLow']; + const min = this.order + ? Math.min(this.order.frequencyMin, this.order.frequencyOpt) + : 0; + + const max = Number.isFinite(this.order?.frequencyOpt) + ? Math.max(min, this.order.frequencyOpt) + : 0; + + const value = this.frequency ?? max; + + const valueMsec = value * 60 * 1000; + const blockTime = 12_000; + const blockCount = Math.floor(valueMsec / blockTime); + const blockHint = + blockCount > 0 + ? i18n.t('form.advanced.intervalBlocks', { + minutes: value, + blocks: blockCount, + }) + : undefined; + return html` - this.onFrequencyChange(e)}> - - ${i18n.t('form.advanced.interval')} - - +
+ this.onFrequencyChange(e)}> + > + +
`; } diff --git a/packages/apps/src/app/dca/translation.en.json b/packages/apps/src/app/dca/translation.en.json index eb478b0691..21552cdf82 100644 --- a/packages/apps/src/app/dca/translation.en.json +++ b/packages/apps/src/app/dca/translation.en.json @@ -13,7 +13,8 @@ "form.advanced": "Advanced settings", "form.advanced.desc": "Customize your trades to an even greater extent.", - "form.advanced.interval": "Interval (minutes)", + "form.advanced.interval": "Custom interval", + "form.advanced.intervalBlocks": "{{minutes}} minutes = {{blocks}} blocks", "form.summary": "Summary", "form.summary.message": "Swap <1>{{amountIn}} {{assetIn}} for <1>{{assetOut}} every <1>~{{frequency}} with a total budget of <1>{{amountInBudget}} {{assetIn}} over the period of <1>~{{time}}", diff --git a/packages/ui/src/component/Slider.css b/packages/ui/src/component/Slider.css new file mode 100644 index 0000000000..2dff5e0f86 --- /dev/null +++ b/packages/ui/src/component/Slider.css @@ -0,0 +1,126 @@ +.slider-root { + position: relative; + width: 100%; +} + +.slider-root:has(input:disabled) .progress, +.slider-root:has(input:disabled) .thumb::before { + background: var(--hex-basic-600); +} + +.slider-root .slider { + position: relative; + display: flex; + align-items: center; + + width: 100%; + height: var(--thumb-size); +} + +.slider-root .slider > input { + appearance: none; + width: 100%; + height: var(--track-size); + margin: 0; + + border-radius: var(--track-size); + background: rgba(84, 99, 128, 0.35); + + outline: none; + padding: 0; +} + +.slider-root input:disabled::-webkit-slider-thumb { + cursor: not-allowed; +} + +.slider-root input::-webkit-slider-thumb { + touch-action: auto; + appearance: none; + opacity: 0; + + width: var(--thumb-size); + height: var(--thumb-size); + + cursor: grab; +} + +.slider-root input::-moz-range-thumb { + touch-action: auto; + opacity: 0; + cursor: grab; +} + +.slider-root .progress { + position: absolute; + top: 50%; + margin-top: calc(var(--track-size) / 2 * -1); + width: calc( + var(--percentage) + var(--thumb-offset) - (var(--thumb-size) / 2) + ); + height: var(--track-size); + border-radius: var(--track-size); + background: var(--hex-bright-blue-300); + pointer-events: none; +} + +.slider-root .thumb { + position: absolute; + top: 50%; + left: calc(var(--percentage) + var(--thumb-offset)); + + width: var(--thumb-size); + height: var(--thumb-size); + border-radius: 50%; + + background: rgba(146, 209, 247, 0.2); + backdrop-filter: blur(3px); + + transform: translate(-50%, -50%); + + pointer-events: none; +} + +.slider-root .thumb::before { + content: ''; + position: absolute; + width: calc(var(--thumb-size) / 2); + height: calc(var(--thumb-size) / 2); + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + + border-radius: 50%; + box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.5); + background: var(--hex-bright-blue-300); +} + +.slider-root .top, +.slider-root .bottom { + display: flex; + align-items: center; + justify-content: space-between; + + font-size: 14px; + font-weight: 500; + color: #fff; +} + +.slider-root .value { + display: flex; + align-items: center; + gap: 4px; +} + +.slider-root .bottom { + font-size: 11px; + color: var(--hex-basic-600); +} + +.slider-root .dash { + position: absolute; + height: 4px; + width: 1px; + + background-color: rgba(150, 138, 158, 0.24); +} diff --git a/packages/ui/src/component/Slider.ts b/packages/ui/src/component/Slider.ts new file mode 100644 index 0000000000..b59ff09914 --- /dev/null +++ b/packages/ui/src/component/Slider.ts @@ -0,0 +1,142 @@ +import { html } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { debounce } from 'ts-debounce'; + +import { UIGCElement } from './base/UIGCElement'; +import styles from './Slider.css'; + +import './Popper'; +import './icons/Info'; + +@customElement('uigc-slider') +export class Slider extends UIGCElement { + _inputHandler = null; + + @property({ type: Number }) min: number = 0; + @property({ type: Number }) max: number = 100; + @property({ type: Number }) step: number = 1; + @property({ type: Number }) value: number; + @property({ type: Number }) thumbSize: number = 26; + @property({ type: Number }) trackSize: number = 5; + @property({ type: Number }) dashCount: number = 20; + @property({ type: String }) label: string = ''; + @property({ type: String }) unit: string = ''; + @property({ type: String }) hint: string = ''; + @property({ type: Boolean }) disabled: boolean; + + constructor() { + super(); + this._inputHandler = debounce(this.onInputChange, 300); + } + + static get styles() { + return [UIGCElement.styles, styles]; + } + + connectedCallback(): void { + super.connectedCallback(); + this.value = Math.floor((this.min + this.max) / 2); + this.style.setProperty('--thumb-size', this.thumbSize + 'px'); + this.style.setProperty('--track-size', this.trackSize + 'px'); + + this.updateValue(); + } + + updateValue() { + const min = Number(this.min); + const max = Number(this.max); + const value = Number(this.value); + const percent = max > min ? (100 * (value - min)) / (max - min) : 0; + const thumbOffset = calculateThumbOffset(percent, this.thumbSize); + + this.style.setProperty('--thumb-offset', thumbOffset + 'px'); + this.style.setProperty('--percentage', percent + '%'); + } + + onInputChange() { + const options = { + bubbles: true, + composed: true, + detail: { value: this.value }, + }; + this.dispatchEvent(new CustomEvent('input-change', options)); + } + + handleInput(event: Event) { + const input = event.target as HTMLInputElement; + this.value = Number(input.value); + this.updateValue(); + this._inputHandler(); + } + + updated(changedProperties: Map) { + if ( + changedProperties.has('min') || + changedProperties.has('max') || + changedProperties.has('step') + ) { + this.updateValue(); + } + } + + dashTemplate() { + return Array.from({ length: this.dashCount + 1 }, (_, index) => { + const position = index * 5; + return html` +
+
+ `; + }); + } + + hintTemplate() { + if (!this.hint) return ''; + return html` + + + + `; + } + + render() { + return html` +
+
+

${this.label}

+

${this.value} ${this.unit} ${this.hintTemplate()}

+
+
+ +
+
+ ${this.dashTemplate()} +
+
+

${this.min} ${this.unit}

+

${this.max} ${this.unit}

+
+
+ `; + } +} + +function linearScale(input: [number, number], output: [number, number]) { + return (value: number) => { + if (input[0] === input[1] || output[0] === output[1]) return output[0]; + const ratio = (output[1] - output[0]) / (input[1] - input[0]); + return output[0] + ratio * (value - input[0]); + }; +} + +function calculateThumbOffset(percent: number, thumbSize: number) { + const halfWidth = thumbSize / 2; + const offset = linearScale([0, 50], [0, halfWidth])(percent); + return halfWidth - offset; +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 474bf6c3f2..47ebbb7a9c 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -33,6 +33,7 @@ export { Progress } from './component/Progress'; export { SearchBar } from './component/SearchBar'; export { Selector } from './component/Selector'; export { Skeleton } from './component/Skeleton'; +export { Slider } from './component/Slider'; export { Switch } from './component/Switch'; export { Textfield } from './component/Textfield'; export { Toast } from './component/Toast';