diff --git a/bun.lock b/bun.lock index 8343d6ea..5e130cb9 100644 --- a/bun.lock +++ b/bun.lock @@ -1342,7 +1342,7 @@ "h3": ["h3@1.15.5", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": "^0.3.5", "defu": "^6.1.4", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-xEyq3rSl+dhGX2Lm0+eFQIAzlDN6Fs0EcC4f7BNUmzaRX/PTzeuM+Tr2lHB8FoXggsQIeXLj8EDVgs5ywxyxmg=="], - "happy-dom": ["happy-dom@18.0.1", "", { "dependencies": { "@types/node": "^20.0.0", "@types/whatwg-mimetype": "^3.0.2", "whatwg-mimetype": "^3.0.0" } }, "sha512-qn+rKOW7KWpVTtgIUi6RVmTBZJSe2k0Db0vh1f7CWrWclkkc7/Q+FrOfkZIb2eiErLyqu5AXEzE7XthO9JVxRA=="], + "happy-dom": ["happy-dom@14.12.3", "", { "dependencies": { "entities": "^4.5.0", "webidl-conversions": "^7.0.0", "whatwg-mimetype": "^3.0.0" } }, "sha512-vsYlEs3E9gLwA1Hp+w3qzu+RUDFf4VTT8cyKqVICoZ2k7WM++Qyd2LwzyTi5bqMJFiIC/vNpTDYuxdreENRK/g=="], "has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], @@ -2336,6 +2336,8 @@ "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], + "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], + "webpack": ["webpack@5.104.1", "", { "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.15.0", "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.17.4", "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", "terser-webpack-plugin": "^5.3.16", "watchpack": "^2.4.4", "webpack-sources": "^3.3.3" }, "bin": { "webpack": "bin/webpack.js" } }, "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA=="], "webpack-cli": ["webpack-cli@5.1.4", "", { "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", "@webpack-cli/info": "^2.0.2", "@webpack-cli/serve": "^2.0.5", "colorette": "^2.0.14", "commander": "^10.0.1", "cross-spawn": "^7.0.3", "envinfo": "^7.7.3", "fastest-levenshtein": "^1.0.12", "import-local": "^3.0.2", "interpret": "^3.1.1", "rechoir": "^0.8.0", "webpack-merge": "^5.7.3" }, "peerDependencies": { "webpack": "5.x.x" }, "bin": { "webpack-cli": "bin/cli.js" } }, "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg=="], @@ -2420,6 +2422,8 @@ "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@happy-dom/global-registrator/happy-dom": ["happy-dom@18.0.1", "", { "dependencies": { "@types/node": "^20.0.0", "@types/whatwg-mimetype": "^3.0.2", "whatwg-mimetype": "^3.0.0" } }, "sha512-qn+rKOW7KWpVTtgIUi6RVmTBZJSe2k0Db0vh1f7CWrWclkkc7/Q+FrOfkZIb2eiErLyqu5AXEzE7XthO9JVxRA=="], + "@mdx-js/mdx/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], "@puppeteer/browsers/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], diff --git a/packages/elements/package.json b/packages/elements/package.json index 4533b4ee..260a6396 100644 --- a/packages/elements/package.json +++ b/packages/elements/package.json @@ -138,4 +138,4 @@ "require": "./packages/tabs/dist/index.cjs" } } -} \ No newline at end of file +} diff --git a/packages/elements/packages/fluid-input/package.json b/packages/elements/packages/fluid-input/package.json new file mode 100644 index 00000000..1af68da0 --- /dev/null +++ b/packages/elements/packages/fluid-input/package.json @@ -0,0 +1,28 @@ +{ + "name": "@atrium-ui/fluid-input", + "author": "atrium", + "contributors": [], + "private": true, + "description": "", + "type": "module", + "main": "dist/index.js", + "types": "src/index.ts", + "scripts": { + "build": "# tsup", + "dev": "# tsup --watch" + }, + "tsup": { + "entry": [ + "src/index.ts" + ], + "format": [ + "esm", + "cjs" + ], + "external": [ + "lit" + ], + "sourcemap": true, + "clean": true + } +} diff --git a/packages/elements/packages/fluid-input/src/FluidInput.ts b/packages/elements/packages/fluid-input/src/FluidInput.ts new file mode 100644 index 00000000..c33283b2 --- /dev/null +++ b/packages/elements/packages/fluid-input/src/FluidInput.ts @@ -0,0 +1,472 @@ +import { html, css, LitElement } from "lit"; +import { customElement } from "lit/decorators.js"; + +function map( + value: number, + inMin: number, + inMax: number, + outMin: number, + outMax: number, +) { + return ((value - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin; +} + +@customElement("fluid-input") +export class FluidInput extends LitElement { + internalValue = 400; + + internalMin = 100; + + internalMax = 600; + + internalSteps = 10; + + input: HTMLInputElement | undefined | null; + + inputValue: HTMLInputElement | undefined | null; + + valueContainer: HTMLInputElement | undefined | null; + + leftArrow: Element | undefined | null; + + rightArrow: Element | undefined | null; + + static get properties() { + return { + value: { + type: Number, + }, + min: { + type: Number, + }, + max: { + type: Number, + }, + steps: { + type: Number, + }, + }; + } + + get value() { + return this.internalValue; + } + + set value(val) { + this.internalValue = +val; + this.updateValue(); + } + + get min() { + return this.internalMin; + } + + set min(val) { + this.internalMin = +val; + this.updateValue(); + } + + get max() { + return this.internalMax; + } + + set max(val) { + this.internalMax = +val; + this.updateValue(); + } + + get steps() { + return this.internalSteps; + } + + set steps(val) { + this.internalSteps = +val; + this.updateValue(); + } + + get suffix() { + return this.getAttribute("suffix"); + } + + get isRange() { + return this.max || this.min; + } + + connectedCallback(): void { + super.connectedCallback(); + + requestAnimationFrame(() => { + this.input = this.shadowRoot?.querySelector(".input-container") as HTMLInputElement; + this.inputValue = this.shadowRoot?.querySelector( + ".input-value", + ) as HTMLInputElement; + + this.valueContainer = this.shadowRoot?.querySelector( + ".value-container", + ) as HTMLInputElement; + + this.leftArrow = this.shadowRoot?.querySelector(".left-arrow"); + this.rightArrow = this.shadowRoot?.querySelector(".right-arrow"); + + this.registerHandlers(); + this.updateValue(); + }); + } + + updateValue() { + if (this.isRange && this.input != null) { + this.input.style.setProperty( + "--value", + map(this.value, this.min, this.max, 0, 1).toString(), + ); + } + + const getPrecision = (n: number) => { + const precParts = n.toString().split("."); + const size = precParts[1] ? precParts[1].length : 0; + + // return 0 if precision is smaller then .000 + if (precParts[1] && precParts[1].substring(0, 3) === "000") { + return 0; + } + + return size; + }; + + const valuePrecision = getPrecision(this.value); + const stepsPrecision = getPrecision(this.steps); + + const precision = valuePrecision > stepsPrecision ? stepsPrecision : valuePrecision; + + if (this.inputValue) { + this.inputValue.value = this.value.toFixed(precision); + this.inputValue.size = this.inputValue.value.length; + } + } + + setValue(value: number) { + const latValue = this.value; + + if (this.isRange) { + this.value = Math.min(Math.max(value, this.min), this.max); + } else { + this.value = value; + } + + this.dispatchEvent(new CustomEvent("change", { detail: this.value - latValue })); + } + + registerHandlers() { + let startPos: [number, number] | null = null; + let startMovePos: [number, number] | null = null; + let startValue = this.value; + let focused = false; + + const cancel = () => { + startPos = null; + startMovePos = null; + if (this.input) { + this.input.removeAttribute("active"); + } + }; + + if (this.valueContainer) { + this.valueContainer.addEventListener("click", (e) => { + if (this.inputValue) { + this.inputValue.disabled = false; + focused = true; + + this.setAttribute("active", ""); + + this.inputValue.focus(); + } + }); + } + + const up = () => { + cancel(); + }; + const start = (e: TouchEvent | MouseEvent) => { + let x = 0; + let y = 0; + + if (e instanceof MouseEvent) { + x = e.clientX; + y = e.clientY; + } else { + x = e.touches[0].clientX; + y = e.touches[0].clientY; + } + + if (!focused) { + startPos = [x, y]; + startValue = this.value; + if (this.input) { + this.input.setAttribute("active", ""); + } + e.preventDefault(); + } + }; + const move = (e: TouchEvent | MouseEvent) => { + let x = 0; + let y = 0; + + if (e instanceof MouseEvent) { + x = e.clientX; + y = e.clientY; + } else { + x = e.touches[0].clientX; + y = e.touches[0].clientY; + } + + if (startPos) { + if (Math.abs(x - startPos[0]) > 10) { + startMovePos = [x, y]; + } + } + if (startMovePos && startPos) { + // apply shift key scaler + let scale = e.shiftKey ? 0.0005 : 0.005; + // scale to min max range + if (this.max - this.min > 0) { + scale *= (this.max - this.min) / 1; + } + + // set value by absolute delta movement * scale + let absolute = startValue + (x - startPos[0]) * scale; + // apply steps + absolute -= absolute % this.steps; + + this.setValue(absolute); + e.preventDefault(); + } + }; + + const cancelInput = () => { + this.setValue(this.value); + if (!this.inputValue) return; + this.inputValue.disabled = true; + focused = false; + this.removeAttribute("active"); + }; + + const submit = () => { + if (!this.inputValue) return; + + if (Number.isNaN(this.inputValue.value)) { + try { + const evalValue = +this.inputValue.value; + this.setValue(evalValue); + } catch (err) { + console.log(err); + } + + cancelInput(); + } else { + const evalValue = eval(this.inputValue.value); + this.setValue(parseFloat(evalValue)); + this.inputValue.disabled = true; + this.removeAttribute("active"); + focused = false; + } + }; + + const input = (e: KeyboardEvent) => { + if (e.key === "Enter") { + submit(); + } else if (e.key === "Escape") { + cancelInput(); + } + }; + + if (this.inputValue && this.input && this.rightArrow && this.leftArrow) { + this.inputValue.addEventListener("blur", submit); + this.inputValue.addEventListener("keydown", input); + + // mouse + this.input.addEventListener("mousedown", start); + window.addEventListener("mousemove", move); + + // touch + this.input.addEventListener("touchstart", start); + window.addEventListener("touchmove", move); + + // touch + window.addEventListener("touchend", up); + window.addEventListener("touchcancel", up); + + // mouse + window.addEventListener("mouseup", up); + window.addEventListener("mousecancel", up); + window.addEventListener("mouseleave", up); + + this.leftArrow.addEventListener("click", (e) => { + this.setValue(this.value - this.steps); + e.preventDefault(); + }); + this.rightArrow.addEventListener("click", (e) => { + this.setValue(this.value + this.steps); + e.preventDefault(); + }); + } + + // touch + this.addEventListener("touchstart", (e) => { + if (!startPos && !focused) { + e.preventDefault(); + } + }); + + // mouse + this.addEventListener("mousedown", (e) => { + if (!startPos && !focused) { + e.preventDefault(); + } + }); + } + + static get styles() { + return css` + :host { + display: inline-block; + height: 20px; + width: 85px; + + --color-input-background: #c4c4c4; + --color-input-hover-background: #cccccc; + --color-input-active-background: #cccccc; + --value-background-color: var(--accent-color); + } + + .input-container { + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + background: var(--color-input-background); + border-radius: 4px; + cursor: ew-resize; + position: relative; + overflow: hidden; + border: 1px solid transparent; + } + + .input-container:before { + content: ""; + position: absolute; + left: 0; + top: 0; + height: 100%; + width: calc(100% * var(--value)); + pointer-events: none; + background: var(--value-background-color); + opacity: 0.75; + } + + .input-container:hover { + background: var(--color-input-hover-background); + } + + .input-container[active] { + background: var(--color-input-active-background); + border-color: grey; + } + + .value-container { + white-space: nowrap; + height: 100%; + } + + .input-value { + cursor: ew-resize; + height: 100%; + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + margin: 0; + width: auto; + padding: 0; + color: inherit; + font-family: inherit; + font-size: inherit; + text-align: center; + position: relative; + z-index: 1000; + } + + .input-value:focus { + cursor: text; + } + + .value-suffix { + opacity: 0.5; + pointer-events: none; + } + + :host([active]) .value-suffix { + display: none; + } + + .input-value:focus { + outline: none; + cursor: text; + } + + .arrow { + padding: 0 6px; + height: 100%; + display: flex; + align-items: center; + cursor: pointer; + opacity: 0.75; + position: absolute; + } + + .left-arrow { + left: 0; + } + .right-arrow { + right: 0; + } + + .arrow:hover { + background: rgba(0, 0, 0, 0.1); + } + + .arrow:active { + background: rgba(255, 255, 255, 0.25); + } + + .arrow svg { + fill: none; + stroke: #fff; + stroke-width: 1.25px; + stroke-linecap: round; + } + `; + } + + render() { + return html` +