diff --git a/bau-ui/examples/bau-storybook/src/components/navBarMenu.js b/bau-ui/examples/bau-storybook/src/components/navBarMenu.js index 53ea671e..f3829130 100644 --- a/bau-ui/examples/bau-storybook/src/components/navBarMenu.js +++ b/bau-ui/examples/bau-storybook/src/components/navBarMenu.js @@ -141,6 +141,8 @@ export default function (context) { href: "/components/radioButtonGroup", }, }, + { data: { name: "Resizable", href: "/components/resizable" } }, + { data: { name: "Select", href: "/components/select" } }, { data: { name: "Select Native", href: "/components/selectNative" } }, { data: { name: "Skeleton", href: "/components/skeleton" } }, diff --git a/bau-ui/examples/bau-storybook/src/landingPage.js b/bau-ui/examples/bau-storybook/src/landingPage.js index eea23df1..628aff56 100644 --- a/bau-ui/examples/bau-storybook/src/landingPage.js +++ b/bau-ui/examples/bau-storybook/src/landingPage.js @@ -60,7 +60,7 @@ export default function (context) { title: "UI components for the web", Content: () => [ p( - "Over 45 components such as button, input, tabs, autocomplete etc ..." + "Over 50 components such as button, input, tabs, autocomplete etc ..." ), Button( { diff --git a/bau-ui/examples/bau-storybook/src/pages/resizable/resizable-horizontal-1.ts b/bau-ui/examples/bau-storybook/src/pages/resizable/resizable-horizontal-1.ts new file mode 100644 index 00000000..f8fb27a7 --- /dev/null +++ b/bau-ui/examples/bau-storybook/src/pages/resizable/resizable-horizontal-1.ts @@ -0,0 +1,49 @@ +import resizable from "@grucloud/bau-ui/resizable"; +import { Context } from "@grucloud/bau-ui/context"; + +export default (context: Context) => { + const { bau, css } = context; + const { section, div } = bau.tags; + + const { PanelGroup, Panel, Handle } = resizable(context, { + class: css` + display: inline-flex; + border: 1px var(--color-emphasis-100) solid; + width: 400px; + `, + }); + + const Panel1 = () => + Panel( + { + class: css` + display: flex; + justify-content: center; + align-items: center; + min-width: fit-content; + width: 100px; + height: 100px; + `, + }, + div("Resize me") + ); + + const HandleIcon = () => + div( + { + class: css` + background-color: var(--color-emphasis-100); + color: var(--color-emphasis-400); + border-radius: var(--global-radius); + font-size: large; + padding: 0.2rem; + z-index: 1; + `, + }, + "\u22EE" + ); + + return () => { + return section(PanelGroup(Panel1(), Handle(HandleIcon()))); + }; +}; diff --git a/bau-ui/examples/bau-storybook/src/pages/resizable/resizable-horizontal-2.ts b/bau-ui/examples/bau-storybook/src/pages/resizable/resizable-horizontal-2.ts new file mode 100644 index 00000000..2a2a0561 --- /dev/null +++ b/bau-ui/examples/bau-storybook/src/pages/resizable/resizable-horizontal-2.ts @@ -0,0 +1,49 @@ +import resizable from "@grucloud/bau-ui/resizable"; +import { Context } from "@grucloud/bau-ui/context"; + +export default (context: Context) => { + const { bau, css } = context; + const { section, div } = bau.tags; + + const { PanelGroup, Panel, Handle } = resizable(context, { + class: css` + display: inline-flex; + border: 1px var(--color-emphasis-100) solid; + width: 600px; + `, + }); + + const Panel1 = () => + Panel( + { + class: css` + display: flex; + justify-content: center; + align-items: center; + min-width: fit-content; + width: 100px; + height: 100px; + `, + }, + div("Panel1") + ); + + const Panel2 = () => + Panel( + { + class: css` + display: flex; + justify-content: center; + align-items: center; + min-width: fit-content; + width: 100px; + height: 100px; + `, + }, + div("Panel2") + ); + + return () => { + return section(PanelGroup(Panel1(), Handle(), Panel2())); + }; +}; diff --git a/bau-ui/examples/bau-storybook/src/pages/resizable/resizable-nested.ts b/bau-ui/examples/bau-storybook/src/pages/resizable/resizable-nested.ts new file mode 100644 index 00000000..9995c58c --- /dev/null +++ b/bau-ui/examples/bau-storybook/src/pages/resizable/resizable-nested.ts @@ -0,0 +1,90 @@ +import resizable from "@grucloud/bau-ui/resizable"; +import { Context } from "@grucloud/bau-ui/context"; + +export default (context: Context) => { + const { bau, css } = context; + const { section, div } = bau.tags; + + const { PanelGroup, Panel, Handle } = resizable(context, { + class: css` + display: inline-flex; + border: 1px var(--color-emphasis-500) solid; + width: 600px; + height: 300px; + + & > div.handle { + width: 0.1rem; + &::after { + width: 0.1rem; + } + } + `, + }); + + const vertical = resizable(context, { + direction: "vertical", + class: css` + flex-grow: 1; + display: inline-flex; + flex-direction: column; + min-width: fit-content; + & > div.handle { + height: 0.1rem; + &::after { + height: 0.1rem; + } + } + `, + }); + + const NavBar = () => + Panel( + { + class: css` + display: flex; + justify-content: center; + align-items: center; + min-width: fit-content; + width: 100px; + `, + }, + div("NavBar") + ); + + const Main = () => + Panel( + { + class: css` + display: flex; + justify-content: center; + align-items: center; + min-height: 2rem; + height: 70%; + `, + }, + div("Main") + ); + + const Footer = () => + Panel( + { + class: css` + display: flex; + justify-content: center; + align-items: center; + min-height: 2rem; + `, + }, + div("Footer") + ); + + return () => { + return section( + PanelGroup( + NavBar(), + Handle(), + vertical.PanelGroup(Main(), vertical.Handle(), Footer()) + ) + ); + }; +}; diff --git a/bau-ui/examples/bau-storybook/src/pages/resizable/resizable-vertical-2.ts b/bau-ui/examples/bau-storybook/src/pages/resizable/resizable-vertical-2.ts new file mode 100644 index 00000000..641d9969 --- /dev/null +++ b/bau-ui/examples/bau-storybook/src/pages/resizable/resizable-vertical-2.ts @@ -0,0 +1,66 @@ +import resizable from "@grucloud/bau-ui/resizable"; +import { Context } from "@grucloud/bau-ui/context"; + +export default (context: Context) => { + const { bau, css } = context; + const { section, div } = bau.tags; + + const { PanelGroup, Panel, Handle } = resizable(context, { + direction: "vertical", + class: css` + display: inline-flex; + flex-direction: column; + border: 1px grey dotted; + height: 300px; + `, + }); + + const Panel1 = () => + Panel( + { + class: css` + display: flex; + justify-content: center; + align-items: center; + min-height: 50px; + width: 100px; + height: 100px; + `, + }, + div("Panel1") + ); + + const Panel2 = () => + Panel( + { + class: css` + display: flex; + justify-content: center; + align-items: center; + min-height: 50px; + width: 100px; + height: 100px; + `, + }, + div("Panel2") + ); + + const HandleIcon = () => + div( + { + class: css` + background-color: var(--color-emphasis-100); + color: var(--color-emphasis-400); + border-radius: var(--global-radius); + font-size: large; + z-index: 1; + line-height: 0.5; + `, + }, + "\u22EF" + ); + + return () => { + return section(PanelGroup(Panel1(), Handle(HandleIcon()), Panel2())); + }; +}; diff --git a/bau-ui/examples/bau-storybook/src/pages/resizable/resizable.examples.ts b/bau-ui/examples/bau-storybook/src/pages/resizable/resizable.examples.ts new file mode 100644 index 00000000..09d373cd --- /dev/null +++ b/bau-ui/examples/bau-storybook/src/pages/resizable/resizable.examples.ts @@ -0,0 +1,59 @@ +import { Context } from "@grucloud/bau-ui/context"; + +import pageExample from "../pageExample.ts"; + +import resizableHorizontal1 from "./resizable-horizontal-1.ts"; +// @ts-ignore +import codeHorizontal1 from "./resizable-horizontal-1.ts?raw"; + +import resizableHorizontal2 from "./resizable-horizontal-2.ts"; +// @ts-ignore +import codeHorizontal2 from "./resizable-horizontal-2.ts?raw"; + +import resizableVertical2 from "./resizable-vertical-2.ts"; +// @ts-ignore +import codeVertical2 from "./resizable-vertical-2.ts?raw"; + +import resizableNested from "./resizable-nested.ts"; +// @ts-ignore +import codeNested from "./resizable-nested.ts?raw"; + +export const resizableSpec = { + title: "Resizable", + package: "resizable", + description: "The resizable component allows to resize panels", + sourceCodeUrl: + "https://github.com/grucloud/bau/blob/main/bau-ui/resizable/resizable.js", + importStatement: `import resizable from "@grucloud/bau-ui/resizable";`, + examples: [ + { + title: "Horizontal 1 Panel", + description: "A resizable horizontal panel.", + code: codeHorizontal1, + createComponent: resizableHorizontal1, + }, + { + title: "Horizontal 2 Panels", + description: "A resizable 2 side horizontal panel.", + code: codeHorizontal2, + createComponent: resizableHorizontal2, + }, + { + title: "Vertical 2 Panels", + description: "A resizable 2 side vertical panel.", + code: codeVertical2, + createComponent: resizableVertical2, + }, + { + title: "Nested", + description: "Nested panels.", + code: codeNested, + createComponent: resizableNested, + }, + ], +}; + +export default (context: Context) => { + const PageExample = pageExample(context); + return () => PageExample(resizableSpec); +}; diff --git a/bau-ui/examples/bau-storybook/src/routes.js b/bau-ui/examples/bau-storybook/src/routes.js index 4218fdf5..d2642c5d 100644 --- a/bau-ui/examples/bau-storybook/src/routes.js +++ b/bau-ui/examples/bau-storybook/src/routes.js @@ -38,6 +38,7 @@ import paginationNavigationExamples from "./pages/paginationNavigation/paginatio import paperExamples from "./pages/paper/paper.examples"; import radioButtonExamples from "./pages/radioButton/radioButton.examples"; import radioButtonGroupExamples from "./pages/radioButtonGroup/radioButtonGroup.examples"; +import resizableExamples from "./pages/resizable/resizable.examples"; import selectExamples from "./pages/select/select.examples"; import selectNativeExamples from "./pages/selectNative/select-native.examples"; import skeletonExamples from "./pages/skeleton/skeleton.examples"; @@ -336,6 +337,13 @@ export const createRoutes = ({ context }) => { component: selectExamples(context), }), }, + { + path: "resizable", + action: () => ({ + title: "Resizable", + component: resizableExamples(context), + }), + }, { path: "selectNative", action: () => ({ diff --git a/bau-ui/resizable/index.d.ts b/bau-ui/resizable/index.d.ts new file mode 100644 index 00000000..f0641153 --- /dev/null +++ b/bau-ui/resizable/index.d.ts @@ -0,0 +1,15 @@ +declare module "@grucloud/bau-ui/resizable" { + type DefaultDesignProps = import("../constants").DefaultDesignProps; + type ComponentOption = import("../bau-ui").ComponentOption; + + export type ResizableProps = { + direction?: "vertical" | "horizontal"; + } & DefaultDesignProps; + + type Component = import("../bau-ui").Component; + + export default function ( + context: any, + option?: ComponentOption & ResizableProps + ): { Panel: Component; PanelGroup: Component; Handle: Component }; +} diff --git a/bau-ui/resizable/index.js b/bau-ui/resizable/index.js new file mode 100644 index 00000000..2f744807 --- /dev/null +++ b/bau-ui/resizable/index.js @@ -0,0 +1 @@ +export { default } from "./resizable"; diff --git a/bau-ui/resizable/resizable.js b/bau-ui/resizable/resizable.js new file mode 100644 index 00000000..4bd50c05 --- /dev/null +++ b/bau-ui/resizable/resizable.js @@ -0,0 +1,172 @@ +import classNames from "@grucloud/bau-css/classNames.js"; +import { toPropsAndChildren } from "@grucloud/bau/bau.js"; + +export default function (context, options = {}) { + const { bau, css, window } = context; + const { section, article, div } = bau.tags; + const { document } = window; + const { direction = "horizontal" } = options; + const className = css` + & .resizablePanel { + box-sizing: border-box; + } + & .handle { + position: relative; + width: 1px; + cursor: col-resize; + display: flex; + justify-content: center; + align-items: center; + &::after { + content: ""; + position: absolute; + background-color: var(--color-emphasis-100); + } + } + & .horizontal { + width: 1rem; + cursor: col-resize; + &::after { + height: 100%; + width: 1px; + } + } + & .vertical { + height: 1rem; + width: 100%; + cursor: row-resize; + &::after { + height: 1px; + width: 100%; + } + } + `; + + function PanelGroup(...args) { + let [props, ...children] = toPropsAndChildren(args); + + return section( + { + ...props, + class: classNames( + "resizablePanelGroup", + props?.class, + options?.class, + className + ), + }, + children + ); + } + + function Panel(...args) { + let [props, ...children] = toPropsAndChildren(args); + + return article( + { + ...props, + class: classNames("resizablePanel", props?.class), + }, + children + ); + } + + function Handle(...args) { + let [props, ...children] = toPropsAndChildren(args); + + // The current position of mouse + let _x = 0; + let _y = 0; + let _panelGroup; + let _nextPanel; + let _nextPanelRect; + let _previousPanel; + let _previousPanelRect; + + const isHorizontal = () => direction === "horizontal"; + + const mouseMoveHandler = (event) => + isHorizontal() + ? mouseMoveHandlerHorizontal(event) + : mouseMoveHandlerVertical(event); + + const mouseMoveHandlerHorizontal = (event) => { + const dx = event.clientX - _x; + if (_previousPanelRect) { + _previousPanel.style.width = `${_previousPanelRect.width + dx}px`; + } + if (_nextPanelRect) { + _nextPanel.style.width = `${_nextPanelRect.width - dx}px`; + } + }; + + const mouseMoveHandlerVertical = (event) => { + const dy = event.clientY - _y; + console.log("mouseMoveHandlerHorizontal dy", dy, "width"); + if (_previousPanelRect) { + _previousPanel.style.height = `${_previousPanelRect.height + dy}px`; + } + if (_nextPanelRect) { + _nextPanel.style.height = `${_nextPanelRect.height - dy}px`; + } + }; + + const mouseUpHandler = () => { + console.log("mouseUpHandler"); + _nextPanel = null; + _previousPanel = null; + _nextPanelRect = null; + _previousPanelRect = null; + _panelGroup.style.cursor = null; + _panelGroup.style["user-select"] = "auto"; + document.removeEventListener("mousemove", mouseMoveHandler); + document.removeEventListener("mouseup", mouseUpHandler); + }; + + const mouseDownHandler = (event) => { + // Get the current mouse position + _x = event.clientX; + _y = event.clientY; + + const { target } = event; + const handleEl = target.closest(".handle"); + _panelGroup = target.closest(".resizablePanelGroup"); + console.log("mouseDownHandler", _x, _y, _panelGroup); + _panelGroup.style.cursor = isHorizontal() ? "col-resize" : "row-resize"; + _panelGroup.style["user-select"] = "none"; + + _nextPanel = handleEl.nextSibling; + _previousPanel = handleEl.previousSibling; + + if (_previousPanel) { + _previousPanelRect = _previousPanel.getBoundingClientRect(); + } + if (_nextPanel) { + _nextPanelRect = _nextPanel.getBoundingClientRect(); + } + document.addEventListener("mousemove", mouseMoveHandler); + document.addEventListener("mouseup", mouseUpHandler); + }; + + return div( + { + ...props, + class: classNames("handle", direction), + role: "separator", + bauMounted: ({ element }) => { + element.addEventListener("mousedown", mouseDownHandler); + }, + bauUnmounted: ({ element }) => { + element.removeEventListener("mousedown", mouseDownHandler); + }, + }, + children + ); + } + + return { + PanelGroup, + Panel, + Handle, + }; +}