From 7a07171fbb865667147649be7315ecc78a2837f2 Mon Sep 17 00:00:00 2001 From: Viktor Podzigun Date: Tue, 13 Feb 2024 21:39:15 +0100 Subject: [PATCH] Added ScrollBar component --- src/ScrollBar.d.ts | 15 ++ src/ScrollBar.mjs | 112 ++++++++++++++ test/ScrollBar.test.mjs | 332 ++++++++++++++++++++++++++++++++++++++++ test/all.mjs | 1 + 4 files changed, 460 insertions(+) create mode 100644 src/ScrollBar.d.ts create mode 100644 src/ScrollBar.mjs create mode 100644 test/ScrollBar.test.mjs diff --git a/src/ScrollBar.d.ts b/src/ScrollBar.d.ts new file mode 100644 index 0000000..d7abc0a --- /dev/null +++ b/src/ScrollBar.d.ts @@ -0,0 +1,15 @@ +/// + +import { Widgets } from "@farjs/blessed"; + +export interface ScrollBarProps { + readonly left: number; + readonly top: number; + readonly length: number; + readonly style: Widgets.Types.TStyle; + readonly value: number; + readonly extent: number; + readonly min: number; + readonly max: number; + onChange(value: number): void; +} diff --git a/src/ScrollBar.mjs b/src/ScrollBar.mjs new file mode 100644 index 0000000..b47729a --- /dev/null +++ b/src/ScrollBar.mjs @@ -0,0 +1,112 @@ +/** + * @typedef {import("./ScrollBar").ScrollBarProps} ScrollBarProps + */ +import React from "react"; + +const h = React.createElement; + +/** + * @param {ScrollBarProps} props + */ +const ScrollBar = (props) => { + const unitIncrement = 1; + const blockIncrement = Math.max(props.extent, 1); + const barLength = Math.max(props.length, 2) - 2; + const min = Math.max(props.min, 0); + const max = Math.max(props.max, 0); + const value = Math.min(Math.max(props.value, min), max); + const markerLength = 1; + const upLength = + value === min + ? 0 + : value === max + ? barLength - markerLength + : Math.max( + Math.min( + Math.trunc((value * barLength) / Math.max(max - min, 1)), + barLength - markerLength - 1 + ), + 1 + ); + const downLength = barLength - upLength - markerLength; + + return h( + React.Fragment, + null, + h("text", { + width: 1, + height: 1, + left: props.left, + top: props.top, + clickable: true, + mouse: true, + autoFocus: false, + style: props.style, + onClick: () => { + props.onChange(Math.max(props.value - unitIncrement, min)); + }, + content: ScrollBar.upArrowCh, + }), + h("text", { + width: 1, + height: upLength, + left: props.left, + top: props.top + 1, + clickable: true, + mouse: true, + autoFocus: false, + style: props.style, + onClick: () => { + props.onChange(Math.max(props.value - blockIncrement, min)); + }, + content: ScrollBar.scrollCh.repeat(upLength), + }), + h("text", { + width: 1, + height: markerLength, + left: props.left, + top: props.top + 1 + upLength, + autoFocus: false, + style: props.style, + content: ScrollBar.markerCh, + }), + h("text", { + width: 1, + height: downLength, + left: props.left, + top: props.top + 1 + upLength + markerLength, + clickable: true, + mouse: true, + autoFocus: false, + style: props.style, + onClick: () => { + props.onChange(Math.min(props.value + blockIncrement, max)); + }, + content: ScrollBar.scrollCh.repeat(downLength), + }), + h("text", { + width: 1, + height: 1, + left: props.left, + top: props.top + 1 + upLength + markerLength + downLength, + clickable: true, + mouse: true, + autoFocus: false, + style: props.style, + onClick: () => { + props.onChange(Math.min(props.value + unitIncrement, max)); + }, + content: ScrollBar.downArrowCh, + }) + ); +}; + +ScrollBar.displayName = "ScrollBar"; + +ScrollBar.markerCh = "\u2588"; // █ +ScrollBar.scrollCh = "\u2591"; // ░ + +ScrollBar.upArrowCh = "\u25b2"; // ▲ +ScrollBar.downArrowCh = "\u25bc"; // ▼ + +export default ScrollBar; diff --git a/test/ScrollBar.test.mjs b/test/ScrollBar.test.mjs new file mode 100644 index 0000000..bfadf49 --- /dev/null +++ b/test/ScrollBar.test.mjs @@ -0,0 +1,332 @@ +/** + * @typedef {import('../src/ScrollBar').ScrollBarProps} ScrollBarProps + */ +import React from "react"; +import TestRenderer from "react-test-renderer"; +import { assertComponents } from "react-assert"; +import assert from "node:assert/strict"; +import mockFunction from "mock-fn"; +import ScrollBar from "../src/ScrollBar.mjs"; + +const h = React.createElement; + +const { describe, it } = await (async () => { + // @ts-ignore + const module = process.isBun ? "bun:test" : "node:test"; + // @ts-ignore + return process.isBun // @ts-ignore + ? Promise.resolve({ describe: (_, fn) => fn(), it: test }) + : import(module); +})(); + +describe("ScrollBar.test.mjs", () => { + it("should call onChange(min) if value = min when onClick up arrow", () => { + //given + const onChange = mockFunction((value) => { + //then + assert.deepEqual(value, props.min); + }); + const props = getScrollBarProps({ ...defaultProps, onChange }); + assert.deepEqual(props.value, props.min); + const renderer = TestRenderer.create(h(ScrollBar, props)); + const text = renderer.root.findAllByType("text")[0]; + + //when + text.props.onClick(null); + + //then + assert.deepEqual(onChange.times, 1); + }); + + it("should call onChange(value-1) if value > min when onClick up arrow", () => { + //given + const onChange = mockFunction((value) => { + //then + assert.deepEqual(value, props.value - 1); + }); + const props = getScrollBarProps({ ...defaultProps, value: 2, onChange }); + assert.deepEqual(props.value > props.min, true); + const renderer = TestRenderer.create(h(ScrollBar, props)); + const text = renderer.root.findAllByType("text")[0]; + + //when + text.props.onClick(null); + + //then + assert.deepEqual(onChange.times, 1); + }); + + it("should call onChange(min) if value < extent when onClick up block", () => { + //given + const onChange = mockFunction((value) => { + //then + assert.deepEqual(value, props.min); + }); + const props = getScrollBarProps({ ...defaultProps, value: 5, onChange }); + assert.deepEqual(props.value < props.extent, true); + const renderer = TestRenderer.create(h(ScrollBar, props)); + const text = renderer.root.findAllByType("text")[1]; + + //when + text.props.onClick(null); + + //then + assert.deepEqual(onChange.times, 1); + }); + + it("should call onChange(value-extent) if value > extent when onClick up block", () => { + //given + const onChange = mockFunction((value) => { + //then + assert.deepEqual(value, props.value - props.extent); + }); + const props = getScrollBarProps({ ...defaultProps, value: 10, onChange }); + assert.deepEqual(props.value > props.extent, true); + const renderer = TestRenderer.create(h(ScrollBar, props)); + const text = renderer.root.findAllByType("text")[1]; + + //when + text.props.onClick(null); + + //then + assert.deepEqual(onChange.times, 1); + }); + + it("should call onChange(value+extent) if value < extent when onClick down block", () => { + //given + const onChange = mockFunction((value) => { + //then + assert.deepEqual(value, props.value + props.extent); + }); + const props = getScrollBarProps({ ...defaultProps, value: 10, onChange }); + assert.deepEqual(props.value < props.max - props.extent, true); + const renderer = TestRenderer.create(h(ScrollBar, props)); + const text = renderer.root.findAllByType("text")[3]; + + //when + text.props.onClick(null); + + //then + assert.deepEqual(onChange.times, 1); + }); + + it("should call onChange(max) if value > extent when onClick down block", () => { + //given + const onChange = mockFunction((value) => { + //then + assert.deepEqual(value, props.max); + }); + const props = getScrollBarProps({ ...defaultProps, value: 15, onChange }); + assert.deepEqual(props.value > props.max - props.extent, true); + const renderer = TestRenderer.create(h(ScrollBar, props)); + const text = renderer.root.findAllByType("text")[3]; + + //when + text.props.onClick(null); + + //then + assert.deepEqual(onChange.times, 1); + }); + + it("should call onChange(value+1) if value < max when onClick down arrow", () => { + //given + const onChange = mockFunction((value) => { + //then + assert.deepEqual(value, props.value + 1); + }); + const props = getScrollBarProps({ ...defaultProps, value: 15, onChange }); + assert.deepEqual(props.value < props.max, true); + const renderer = TestRenderer.create(h(ScrollBar, props)); + const text = renderer.root.findAllByType("text")[4]; + + //when + text.props.onClick(null); + + //then + assert.deepEqual(onChange.times, 1); + }); + + it("should call onChange(max) if value = max when onClick down arrow", () => { + //given + const onChange = mockFunction((value) => { + //then + assert.deepEqual(value, props.max); + }); + const props = getScrollBarProps({ ...defaultProps, value: 20, onChange }); + assert.deepEqual(props.value, props.max); + const renderer = TestRenderer.create(h(ScrollBar, props)); + const text = renderer.root.findAllByType("text")[4]; + + //when + text.props.onClick(null); + + //then + assert.deepEqual(onChange.times, 1); + }); + + it("should render component at min position", () => { + //given + const props = getScrollBarProps({ ...defaultProps }); + assert.deepEqual(props.value, props.min); + + //when + const result = TestRenderer.create(h(ScrollBar, props)).root; + + //then + assertScrollBar(result, props, 0); + }); + + it("should render component at min+1 position", () => { + //given + const props = getScrollBarProps({ ...defaultProps, value: 1 }); + assert.deepEqual(props.value, props.min + 1); + + //when + const result = TestRenderer.create(h(ScrollBar, props)).root; + + //then + assertScrollBar(result, props, 1); + }); + + it("should render component between min and max", () => { + //given + const props = getScrollBarProps({ ...defaultProps, value: 10 }); + assert.deepEqual(props.value > props.min, true); + assert.deepEqual(props.value < props.max, true); + + //when + const result = TestRenderer.create(h(ScrollBar, props)).root; + + //then + assertScrollBar(result, props, 3); + }); + + it("should render component at max-1 position", () => { + //given + const props = getScrollBarProps({ ...defaultProps, value: 19 }); + assert.deepEqual(props.value, props.max - 1); + + //when + const result = TestRenderer.create(h(ScrollBar, props)).root; + + //then + assertScrollBar(result, props, 4); + }); + + it("should render component at max position", () => { + //given + const props = getScrollBarProps({ ...defaultProps, value: 20 }); + assert.deepEqual(props.value, props.max); + + //when + const result = TestRenderer.create(h(ScrollBar, props)).root; + + //then + assertScrollBar(result, props, 5); + }); +}); + +/** + * @typedef {{ + * value: number, + * extent: number, + * min: number, + * max: number, + * onChange(value: number): void + * }} DefaultProps + * @type {DefaultProps} + */ +const defaultProps = { + value: 0, + extent: 8, + min: 0, + max: 20, + onChange: () => {}, +}; + +/** + * @param {DefaultProps} props + * @returns {ScrollBarProps} + */ +function getScrollBarProps(props = defaultProps) { + return { + left: 1, + top: 2, + length: 8, + style: {}, + value: props.value, + extent: props.extent, + min: props.min, + max: props.max, + onChange: props.onChange, + }; +} + +/** + * @param {TestRenderer.ReactTestInstance} result + * @param {ScrollBarProps} props + * @param {number} upLength + */ +function assertScrollBar(result, props, upLength) { + assert.deepEqual(ScrollBar.displayName, "ScrollBar"); + + const markerLength = 1; + const downLength = props.length - 2 - upLength - markerLength; + + assertComponents( + result.children, + h("text", { + width: 1, + height: 1, + left: props.left, + top: props.top, + clickable: true, + mouse: true, + autoFocus: false, + style: props.style, + content: ScrollBar.upArrowCh, + }), + h("text", { + width: 1, + height: upLength, + left: props.left, + top: props.top + 1, + clickable: true, + mouse: true, + autoFocus: false, + style: props.style, + content: ScrollBar.scrollCh.repeat(upLength), + }), + h("text", { + width: 1, + height: markerLength, + left: props.left, + top: props.top + 1 + upLength, + autoFocus: false, + style: props.style, + content: ScrollBar.markerCh, + }), + h("text", { + width: 1, + height: downLength, + left: props.left, + top: props.top + 1 + upLength + markerLength, + clickable: true, + mouse: true, + autoFocus: false, + style: props.style, + content: ScrollBar.scrollCh.repeat(downLength), + }), + h("text", { + width: 1, + height: 1, + left: props.left, + top: props.top + 1 + upLength + markerLength + downLength, + clickable: true, + mouse: true, + autoFocus: false, + style: props.style, + content: ScrollBar.downArrowCh, + }) + ); +} diff --git a/test/all.mjs b/test/all.mjs index fa2a557..8387037 100644 --- a/test/all.mjs +++ b/test/all.mjs @@ -2,6 +2,7 @@ await import("./Button.test.mjs"); await import("./ButtonsPanel.test.mjs"); await import("./ListView.test.mjs"); await import("./ListViewport.test.mjs"); +await import("./ScrollBar.test.mjs"); await import("./TextLine.test.mjs"); await import("./UI.test.mjs"); await import("./UiString.test.mjs");