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.css b/packages/apps/src/app/dca/Form.css index be4dccad1d..11ee57c825 100644 --- a/packages/apps/src/app/dca/Form.css +++ b/packages/apps/src/app/dca/Form.css @@ -83,6 +83,14 @@ width: 100%; } +.frequency-select { + display: inline-flex; + margin-right: -8px; + height: 18px; + font-size: 14px; + cursor: pointer; +} + .adornment { white-space: nowrap; font-weight: 500; diff --git a/packages/apps/src/app/dca/Form.ts b/packages/apps/src/app/dca/Form.ts index 12e0d974b0..5287f3e278 100644 --- a/packages/apps/src/app/dca/Form.ts +++ b/packages/apps/src/app/dca/Form.ts @@ -18,12 +18,20 @@ import { baseStyles, formStyles } from 'styles'; import { formatAmount, humanizeAmount } from 'utils/amount'; import { MINUTE_MS } from 'utils/time'; -import { DcaOrder, INTERVAL_DCA, IntervalDca } from './types'; +import { DcaOrder, FrequencyUnit, INTERVAL_DCA, IntervalDca } from './types'; import { Amount, Asset } from '@galacticcouncil/sdk'; import styles from './Form.css'; +const HOUR_MIN = 60; +const DAY_MIN = 24 * HOUR_MIN; +const FREQ_UNIT_BY_INTERVAL: Record = { + hour: 'min', + day: 'hour', + week: 'day', +}; + @customElement('gc-dca-form') export class DcaForm extends BaseElement { private account = new DatabaseController(this, AccountCursor); @@ -43,7 +51,7 @@ export class DcaForm extends BaseElement { @property({ attribute: false }) order: DcaOrder = null; @property({ attribute: false }) error = {}; - @state() advanced: boolean = false; + @state() frequencyUnit: FrequencyUnit = 'hour'; static styles = [baseStyles, formStyles, styles]; @@ -79,8 +87,25 @@ export class DcaForm extends BaseElement { return null; } - private toggleAdvanced() { - this.advanced = !this.advanced; + get minFrequency() { + return this.order + ? Math.min(this.order.frequencyMin, this.order.frequencyOpt) + : 0; + } + + get maxFrequency() { + return Number.isFinite(this.order?.frequencyOpt) + ? Math.max(this.minFrequency, this.order.frequencyOpt) + : 0; + } + + get frequencyRanges(): Record { + const range = this.maxFrequency - this.minFrequency; + return { + min: range, + hour: Math.floor(range / HOUR_MIN), + day: Math.floor(range / DAY_MIN), + }; } onScheduleClick(e: any) { @@ -92,32 +117,56 @@ export class DcaForm extends BaseElement { } onIntervalChange(e: any) { + const interval = e.detail.value as IntervalDca; const options = { bubbles: true, composed: true, - detail: { value: e.detail.value }, + detail: { value: interval }, }; + + this.setFrequencyUnit(this.maxFrequency, FREQ_UNIT_BY_INTERVAL[interval]); this.dispatchEvent(new CustomEvent('interval-change', options)); } onIntervalMultiplierChange(e: any) { + const multipliplier = e.detail.value; const options = { bubbles: true, composed: true, - detail: { value: e.detail.value }, + detail: { value: multipliplier }, }; + this.dispatchEvent(new CustomEvent('interval-mul-change', options)); + + setTimeout(() => { + if (this.frequencyRanges[this.frequencyUnit] <= 1) { + this.setFrequencyUnit(this.maxFrequency, 'min'); + } + }, 0); } - onFrequencyChange(e: any) { + convertFrequencyValue(value: number, unit: FrequencyUnit = 'min') { + return unit === 'min' + ? value + : unit === 'hour' + ? value * HOUR_MIN + : value * DAY_MIN; + } + + onFrequencyChange(value: number, unit: FrequencyUnit = 'min') { const options = { bubbles: true, composed: true, - detail: { value: e.detail.value }, + detail: { value: this.convertFrequencyValue(value, unit) }, }; this.dispatchEvent(new CustomEvent('frequency-change', options)); } + setFrequencyUnit(value: number, unit: FrequencyUnit) { + this.frequencyUnit = unit; + this.onFrequencyChange(value, unit); + } + infoSummaryTemplate() { if (this.inProgress) { return html` @@ -311,41 +360,91 @@ export class DcaForm extends BaseElement { `; } - formAdvancedSwitch() { - return html` -
-
- ${i18n.t('form.advanced')} - ${i18n.t('form.advanced.desc')} -
- this.toggleAdvanced()}> -
- `; - } - formFrequencyTemplate() { - const error = this.error['frequencyOutOfRange']; - const isDisabled = - this.error['balanceTooLow'] || this.error['minBudgetTooLow']; + const min = this.minFrequency; + const max = this.maxFrequency; + 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; + + const range = max - min; + const rangeInHours = Math.floor(range / HOUR_MIN); + const rangeInDays = Math.floor(range / DAY_MIN); + + const minValues: Record = { + min: min, + hour: Math.ceil(min / HOUR_MIN), + day: Math.ceil(min / DAY_MIN), + }; + + const maxValues: Record = { + min: max, + hour: Math.floor(max / HOUR_MIN), + day: Math.floor(max / DAY_MIN), + }; + + const values: Record = { + min: value, + hour: Math.floor(value / HOUR_MIN), + day: Math.floor(value / DAY_MIN), + }; + + const frequencyRanges = { + min: range, + hour: rangeInHours, + day: rangeInDays, + }; + + const units = [ + 'min', + this.frequencyRanges.hour > 0 && 'hour', + this.frequencyRanges.day > 0 && 'day', + ].filter((u): u is FrequencyUnit => !!u); + return html` - this.onFrequencyChange(e)}> - - ${i18n.t('form.advanced.interval')} - - + + this.onFrequencyChange( + parseFloat(e.detail.value), + this.frequencyUnit, + )}> + > +
+ ${units.length > 1 + ? html` + ({ + active: this.frequencyUnit === u, + text: i18n.t(`form.frequency.${u}`), + onClick: () => this.setFrequencyUnit(values[u], u), + }))}> +
+ ${i18n.t(`form.frequency.${this.frequencyUnit}`)} + +
+
+ ` + : i18n.t(`form.frequency.${this.frequencyUnit}`)} +
+
`; } @@ -366,21 +465,16 @@ export class DcaForm extends BaseElement { info: true, show: isValid, }; - const advancedClasses = { - hidden: this.advanced == false, - advanced: true, - }; + return html`
${this.formAssetInTemplate()} ${this.formSwitch()}${this.formAssetOutTemplate()} - ${this.formIntervalTemplate()} ${this.formAdvancedSwitch()} -
- ${this.formFrequencyTemplate()} -
+ ${this.formIntervalTemplate()}
+
${this.formFrequencyTemplate()}
${this.infoSummaryTemplate()}
${this.infoEstEndDateTemplate()}
${this.infoSlippageTemplate()}
diff --git a/packages/apps/src/app/dca/translation.en.json b/packages/apps/src/app/dca/translation.en.json index eb478b0691..42799bec41 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}}", @@ -21,6 +22,10 @@ "form.info.estSchedule": "Schedule end (est.)", "form.info.slippage": "Slippage protection", + "form.frequency.min": "minutes", + "form.frequency.hour": "hours", + "form.frequency.day": "days", + "error.insufficientBalance": "Your trade is bigger than your balance.", "error.minBudgetTooLow": "The minimum budget is {{amount}} {{asset}}.", "error.frequencyOutOfRange": "The valid frequency is between {{min}} and {{max}} minutes.", diff --git a/packages/apps/src/app/dca/types.ts b/packages/apps/src/app/dca/types.ts index 722bd92c24..a8ec3d0fa5 100644 --- a/packages/apps/src/app/dca/types.ts +++ b/packages/apps/src/app/dca/types.ts @@ -54,6 +54,7 @@ export const INTERVAL_DCA_MS: Record = { }; export type IntervalDca = (typeof INTERVAL_DCA)[number]; +export type FrequencyUnit = 'min' | 'hour' | 'day'; export interface DcaOrder extends Humanizer { amountIn: BigNumber; diff --git a/packages/apps/src/styles/form.css b/packages/apps/src/styles/form.css index 2a56840d23..d15e711a9f 100644 --- a/packages/apps/src/styles/form.css +++ b/packages/apps/src/styles/form.css @@ -107,6 +107,7 @@ @media (max-width: 480px) { .form-switch, + .advanced, .info { padding: 0 14px; } diff --git a/packages/ui/src/component/Dropdown.css b/packages/ui/src/component/Dropdown.css new file mode 100644 index 0000000000..80e230b2fb --- /dev/null +++ b/packages/ui/src/component/Dropdown.css @@ -0,0 +1,41 @@ +.tooltip { + display: none; + width: max-content; + min-width: 120px; + text-align: left; + position: fixed; + top: 0; + left: 0; + background: var(--hex-dark-blue-700); + border: 1px solid var(--hex-dark-blue-400); + color: white; + padding: 10px; + border-radius: 8px; + z-index: 1000; + box-shadow: 0px 40px 40px 0px rgba(0, 0, 0, 0.8); +} + +.tooltip.show { + display: block; +} + +.tooltip > button { + display: block; + width: 100%; + text-align: left; + border: none; + padding: 4px 8px; + background: transparent; + border-radius: 4px; + cursor: pointer; + color: var(--hex-basic-500); +} + +.tooltip > button.active { + background: rgba(255, 255, 255, 0.06); + color: white; +} + +.tooltip > button:hover { + color: white; +} diff --git a/packages/ui/src/component/Dropdown.ts b/packages/ui/src/component/Dropdown.ts new file mode 100644 index 0000000000..ac047a29a7 --- /dev/null +++ b/packages/ui/src/component/Dropdown.ts @@ -0,0 +1,47 @@ +import { html } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { UIGCElement } from 'component/base/UIGCElement'; + +import { Popper } from 'component/Popper'; + +import styles from './Dropdown.css'; + +type ItemProps = { + text: string; + active?: boolean; + onClick: () => void; +}; + +@customElement('uigc-dropdown') +export class Dropdown extends Popper { + static styles = [UIGCElement.styles, styles]; + + get buttonElement() { + return this.shadowRoot.querySelector('.tooltip > button'); + } + + @property({ type: String }) text = null; + @property({ type: Array }) items: ItemProps[] = []; + + handleItemClick(item: ItemProps) { + this.tooltipElement.classList.remove('show'); + item.onClick(); + } + + render() { + return html` + +
+ ${this.items.map( + (item) => html` + + `, + )} +
+ `; + } +} diff --git a/packages/ui/src/component/Popper.css b/packages/ui/src/component/Popper.css index 3d015d3014..33a6915b14 100644 --- a/packages/ui/src/component/Popper.css +++ b/packages/ui/src/component/Popper.css @@ -13,3 +13,7 @@ font-size: 90%; z-index: 1000; } + +.tooltip.show { + display: block; +} diff --git a/packages/ui/src/component/Popper.ts b/packages/ui/src/component/Popper.ts index 052437f361..4bc1b5e13a 100644 --- a/packages/ui/src/component/Popper.ts +++ b/packages/ui/src/component/Popper.ts @@ -3,13 +3,17 @@ import { customElement, property } from 'lit/decorators.js'; import { UIGCElement } from './base/UIGCElement'; -import { computePosition } from '@floating-ui/dom'; +import { computePosition, flip, Placement } from '@floating-ui/dom'; import styles from './Popper.css'; +type TriggerMethod = 'click' | 'hover'; + @customElement('uigc-popper') export class Popper extends UIGCElement { @property({ type: String }) text = null; + @property({ type: String }) placement: Placement = 'right-start'; + @property({ type: String }) triggerMethod: TriggerMethod = 'hover'; static styles = [UIGCElement.styles, styles]; @@ -22,35 +26,49 @@ export class Popper extends UIGCElement { return this.shadowRoot.querySelector('.tooltip') as HTMLElement; } - private mouseOverListener = () => { + private updatePosition = () => { computePosition(this.triggerElement, this.tooltipElement, { - placement: 'right-start', + placement: this.placement, + strategy: 'fixed', + middleware: [flip()], }).then(({ x, y }) => { Object.assign(this.tooltipElement.style, { - display: 'block', left: `${x}px`, top: `${y}px`, }); }); }; - private mouseOutListener = () => { - Object.assign(this.tooltipElement.style, { - display: 'none', - }); + private onShow = () => { + this.tooltipElement.classList.add('show'); + this.updatePosition(); + }; + + private onHide = () => { + this.tooltipElement.classList.remove('show'); + }; + + private onToggle = () => { + this.tooltipElement.classList.toggle('show'); + this.updatePosition(); }; override async firstUpdated() { - this.triggerElement.addEventListener('mouseover', this.mouseOverListener); - this.triggerElement.addEventListener('mouseout', this.mouseOutListener); + if (this.triggerMethod === 'click') { + this.triggerElement.addEventListener('mousedown', this.onToggle); + } else { + this.triggerElement.addEventListener('mouseover', this.onShow); + this.triggerElement.addEventListener('mouseout', this.onHide); + } } override disconnectedCallback() { - this.triggerElement.removeEventListener( - 'mouseover', - this.mouseOverListener, - ); - this.triggerElement.removeEventListener('mouseout', this.mouseOutListener); + if (this.triggerMethod === 'click') { + this.triggerElement.removeEventListener('mousedown', this.onToggle); + } else { + this.triggerElement.removeEventListener('mouseover', this.onShow); + this.triggerElement.removeEventListener('mouseout', this.onHide); + } super.disconnectedCallback(); } diff --git a/packages/ui/src/component/Slider.css b/packages/ui/src/component/Slider.css new file mode 100644 index 0000000000..abd75a55bb --- /dev/null +++ b/packages/ui/src/component/Slider.css @@ -0,0 +1,134 @@ +:host { + width: 100%; +} + +.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; + margin-left: auto; +} +.slider-root .hint { + margin-left: 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..73f09cd371 --- /dev/null +++ b/packages/ui/src/component/Slider.ts @@ -0,0 +1,146 @@ +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..abddfceca9 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -23,6 +23,7 @@ export { Dialog } from './component/Dialog'; export { DialogCountdown } from './component/DialogCountdown'; export { Divider } from './component/Divider'; export { Drawer } from './component/Drawer'; +export { Dropdown } from './component/Dropdown'; export { IconButton } from './component/IconButton'; export { Input } from './component/Input'; export { List } from './component/List'; @@ -33,6 +34,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';