web-components icon indicating copy to clipboard operation
web-components copied to clipboard

SplitLayout: non-proportional splitter position

Open rolfsmeds opened this issue 1 year ago • 11 comments

Describe your motivation

The splitter position is currently always proportional to the width of the SplitLayout, which means that, as the SplitLayout shrinks and grows along the split's axis, both sides of the split scale proportionately.

Screen Recording 2024-05-14 at 14 35 23

This is fine in many cases, but sometimes you would need one side to stay unchanged, so that only the other side of the split scales (until the SplitLayout's total size is smaller than the "fixed" side of course).

Describe the solution you'd like

A way to set the splitter position to be "fixed" in relation to one of the sides, e.g. through an API that lets you define either side as the "split position anchor".

(In order to really make this work properly in the Flow component, it would also need a split position setter overload that takes a non-percentual size as argument, e.g. setSplitterPosition("300px")).

Describe alternatives you've considered

No response

Additional context

No response

rolfsmeds avatar May 14 '24 11:05 rolfsmeds

Some inspiration: there is a component for the angular framework that offers some advanced options for nesting layouts (https://angular-split.github.io/). Using that split-component you can easily do something like this:

split-panel

Note: the middle (yellow) area has a minimum width, moving the left section will respect this one...

JayKayDeon avatar May 17 '24 09:05 JayKayDeon

A prototype that implements the feature can be found on this branch: https://github.com/vaadin/web-components/tree/proto/split-layout/split-position-anchor.

The main difference is to set the flex-basis of the element to be used as the anchor with its initial size (width or height) alongside changing its flex-grow and flex-shrink to 0, to make it always have the defined size. That can be verified working in the example below:

https://github.com/vaadin/web-components/assets/262432/f4ba0656-670d-4bf8-8e77-a7c80803d77b

Some findings:

  • It needs to be verified that having different flex-grow/flex-shrink values for the elements might cause some undesirable effects. Just as a reference, the angular-split component shared by @JayKayDeon here, defines flex-grow/flex-shrink as 0 for elements with defined size and 1, otherwise)
  • As it can be seen in the example if the SplitLayout size shrinks more than the anchor element size, it won't shrink with it, maintaining its size, which will cause content to be hidden by the overflow
  • In some scenarios (like the one on the example, with a nested SplitLayout), the calculation of the anchor initial size might be off, so it needs to be defined how to calculate it properly in cases where the size is not explicitly defined.

DiegoCardoso avatar May 20 '24 11:05 DiegoCardoso

Hi @JayKayDeon, a somewhat similar-looking layout can be produced with two split layouts using the following setup. Not sure if this fully covers your use case:

<vaadin-split-layout style="width: 900px; height: 400px; outline: 1px solid black">
  <div>Left</div>
  <vaadin-split-layout style="height: 100%">
    <div style="min-width: 300px; background: lightyellow">Middle</div>
    <div style="max-width: 300px">Right</div>
  </vaadin-split-layout>
</vaadin-split-layout>

https://github.com/vaadin/web-components/assets/1222264/cb9d9dbe-3d68-40d2-b045-55d46f7f19e7


Update by @DiegoCardoso

The same example, but with Flow:

SplitLayout outerLayout = new SplitLayout();
SplitLayout innerLayout = new SplitLayout();
outerLayout.addToPrimary(new Span("left"));
outerLayout.addToSecondary(innerLayout);
innerLayout.addToPrimary(new Span("middle"));
innerLayout.addToSecondary(new Span("right"));

outerLayout.getStyle().setOutline("1px solid black");
outerLayout.setHeight("400px");
outerLayout.setWidth("900px");

innerLayout.setPrimaryStyle("background", "lightyellow");

// This is the important part
innerLayout.setPrimaryStyle("min-width", "300px");
innerLayout.setSecondaryStyle("max-width", "300px");

add(outerLayout);

tomivirkki avatar May 21 '24 09:05 tomivirkki

Circling back to this now after 7 months, and having tinkered with it a bit, it seems to me that the following approach would make sense:

As today, the default for both elements is flex-basis:auto (i.e. based on size), flex-shrink:1, flex-grow:1 through styles set on slotted children.

If only one of the elements has fixed size, and setSplitPosition is not used:

  • Set the fixed-size element to haveflex-grow:0 so that it doesn't grow beyond that
  • Set the unsized element to flex-basis:0 (so that it doesn't force the fixed-sized element to shrink)
  • As the user moves the handle
    • set the fixed-size element's flex-basis to the new size, but keep flex-grow:0
    • keep the unsized element unchanged

If both or neither elements have fixed size, or setSplitPosition is used to set the split, use the current behavior.

WIth this approach, fixed-size elements can still shrink if needed to prevent overflow, but min-width/min-height can be used to prevent that if needed.

Also, in order to prevent either side from pushing the handle out of the layout bounds, we could consider setting max-width/max-height of both children to be 100% - split-handle-size.

  • The default handle size is var(--lumo-size-s), while the small variant has 1px and the minimal variant has 0px, so I think the best way to handle this would be to set that size to an internal css property (e.g. --_handle-size) which can then be used to set max-width: calc(100% - var(--_handle-size)
  • I think this won't prevent setting max-width/max-height on the elements since setting that directly on the elements should override whatever's coming through ::slotted() styles from the SplitLayout.

(I've done some quick tinkering with this just through css, but haven't tried changing the handle-dragging behavior yet, so I might be missing something.)

rolfsmeds avatar Dec 03 '24 16:12 rolfsmeds

Overall the approach from the above comment seems doable but might require some changes.

In particular, we need to remove hardcoded flex-grow from the web component which would override flex-grow: 0. I'm actually not sure if we really have to set flex-grow: 1 and flex-shrink: 1 via style attribute as they can in theory use fallback values from the ::slotted() styles, maybe it's enough to only modify flex-basis dynamically?

Also, need to update layout when switching e.g. "one element with fixed size" -> "both elements with fixed size". This seems to be a bit tricky unless we require users to explicitly call the APIs e.g. getPrimaryComponent(). So we might want to deprecated public setPrimaryStyle() and `setSecondaryStyle() as suggested in #6486.

Here's the approach that I was able to come up with, not sure if it's good as it modifies public API:

  • Always wrap components passed to addToPrimary() and addToSecondary() into custom component, e.g. could be a plain Div that implements HasSize and has overrides for some methods like setWidth() and setHeight()
  • When either of the overridden methods is called, check whether there's only one component has fixed size or both, and then call getElement().getStyle().set("flex-grow", "0") as necessary,
  • On splitter dragend listener, ensure the flex-grow set above isn't overridden by 1. Alternatively, consider modifying the low counterpart logic to only set flex-basis as proposed above for the web component.

Here's a partial prototype: https://github.com/vaadin/flow-components/tree/proto/split-layout-fixed-size

web-padawan avatar Dec 18 '24 14:12 web-padawan

Man, the existing API and implementation are so messed up that I don't even know where to start. Apparently it already does wrap into a div if you pass multiple components to either side (and I guess that's why there's public API for setting each side's styles?)

Always wrapping the contents in divs will also be a much bigger breaking change, as it will affect custom styling with selectors that assume no wrappers, and probably also affect the default splitter position that is currently based on the size of the contents. Having the wrappers in shadow DOM would avoid the styling break, but then we need some API on the clientside for setting the sizes.

Maybe, instead of wrapping, we should instead just introduce new API for setting the size of primary and secondary?

  • setPrimarySize(String size) / setSecondarySize(String size)

    • sets the specified side's width/height
    • sets the specified side's flex-grow:0
    • sets the other content's flex: 1 1 0
  • If both are set, or setSplitPosition is called, removes all of that

WC/React could have corresponding primary-size / secondary-size properties.

rolfsmeds avatar Dec 19 '24 11:12 rolfsmeds

I built a quick prototype with a slightly different API.

Prototype:

  • https://github.com/vaadin/web-components/compare/proto/non-proportional-splitter
  • https://github.com/vaadin/flow-components/compare/proto/non-proportional-splitter

Flow component API:

  • void setFlexArea(FlexArea.BOTH) – default
  • void setFlexArea(FlexArea.PRIMARY)
  • void setFlexArea(FlexArea.SECONDARY)
  • FlexArea getFlexArea()
  • void setPrimarySize(String size) – sets the primary element’s flex-basis to the given size, the secondary element’s flex-basis to auto, and resets splitterPosition to null
  • void setSecondarySize(String size) – same as setPrimarySize() but applies the size to the secondary element instead

Web component API:

  • flex-area='both' – default
  • flex-area='primary'
  • flex-area='secondary'
  • setPrimarySize() – similar to the Flow component
  • setSecondarySize() – similar to the Flow component

There are intentionally no getters for primary and secondary size, as they can be misleading when using FlexArea.BOTH: they would return the flex-basis value in pixels, which doesn't necessarily reflect the actual size of the elements.

vursen avatar Apr 02 '25 13:04 vursen

I guess I'm just not sure I see the need for the FlexArea API. It seems to me that the side you set a specified size to is the side whose size you "care" about, and the other one is the flexible area.

Would @SebastianKuehnau or @JayKayDeon have opinions?

rolfsmeds avatar Apr 02 '25 19:04 rolfsmeds

My two cents: API wise I'd like to just set "width", "minWidth" and "maxWidth" to control this behavior, everything else already feels like an "implementation detail".... See https://angular-split.github.io/, having exactly this "width-restrcitions" API... Feels most intuitive and behaves as expected (for me at least)...

JayKayDeon avatar Apr 04 '25 05:04 JayKayDeon

Yeah, I'm now inclined to agree that FlexArea isn't a very intuitive API. Rolf's proposal seems to be closer to the angular-split API. The question is how the primary-width and secondary-width properties should behave when they are defined and the splitter position changes: would you expect them to reflect the new value or would you instead rely on the getSplitterPosition value? Even if we don't consider Java getters, the question is still relevant for the web component's properties because they act as both setters and getters by default.

vursen avatar Apr 04 '25 06:04 vursen

When the splitter is moved, I'd assume that the undefined size remains undefined and the defined size is updated to reflect the new size, because that's the one that determines the position of the splitter.

In Flow, getSplitterPosition is a getter for the value set with setSplitterPosition – if you don't set it, it's null and should remain so. SplitterDragEndEvent should be used to get the realtime position on the server side.

I'm not sure whether the Flow getPrimaryWidth / getSecondaryWidth methods should return the user-modified size of the defined-size element, but the undefined size element should return null until explicitly set through the setter.

rolfsmeds avatar Apr 04 '25 06:04 rolfsmeds