Implement new separator design (#33599)
* Remove `onFocus` and `onBlur` handlers react-resizable-panels fixed the issue where focus-visible was not working as expected, so we no longer need this bit of code. * Implement new hover/focus separator * Collapse the panel when clicking on the new focus separator * Remove `isFocusedViaKeyboard` from snapshot * Update tests and storybook * Expand panel only on double click * Add animation * Fix tests * Don't render border when type is bar * Single click to expand, double click to collapse * Change focus separator color
@@ -15,7 +15,6 @@ import {
|
|||||||
type ResizerViewSnapshot,
|
type ResizerViewSnapshot,
|
||||||
} from "@element-hq/web-shared-components";
|
} from "@element-hq/web-shared-components";
|
||||||
import { debounce } from "lodash";
|
import { debounce } from "lodash";
|
||||||
import whatInput from "what-input";
|
|
||||||
|
|
||||||
import SettingsStore from "../../settings/SettingsStore";
|
import SettingsStore from "../../settings/SettingsStore";
|
||||||
import { SettingLevel } from "../../settings/SettingLevel";
|
import { SettingLevel } from "../../settings/SettingLevel";
|
||||||
@@ -25,13 +24,11 @@ function getInitialState(): ResizerViewSnapshot {
|
|||||||
return {
|
return {
|
||||||
isCollapsed: true,
|
isCollapsed: true,
|
||||||
initialSize: 0,
|
initialSize: 0,
|
||||||
isFocusedViaKeyboard: false,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
isCollapsed: false,
|
isCollapsed: false,
|
||||||
initialSize: SettingsStore.getValue("RoomList.panelSize") ?? undefined,
|
initialSize: SettingsStore.getValue("RoomList.panelSize") ?? undefined,
|
||||||
isFocusedViaKeyboard: false,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,12 +84,18 @@ export class ResizerViewModel
|
|||||||
};
|
};
|
||||||
|
|
||||||
private onSeparatorClick = (): void => {
|
private onSeparatorClick = (): void => {
|
||||||
|
// When panel is collapsed, single click should expand the panel.
|
||||||
if (this.panelHandle?.isCollapsed()) {
|
if (this.panelHandle?.isCollapsed()) {
|
||||||
const lastSize = SettingsStore.getValue("RoomList.panelSize");
|
const lastSize = SettingsStore.getValue("RoomList.panelSize");
|
||||||
this.panelHandle.resize(`${lastSize ?? 100}%`);
|
this.panelHandle.resize(`${lastSize ?? 100}%`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public onDoubleClick = (): void => {
|
||||||
|
// When the panel is expanded, double click should collapse.
|
||||||
|
if (!this.panelHandle?.isCollapsed()) this.panelHandle?.collapse();
|
||||||
|
};
|
||||||
|
|
||||||
public onPointerUp = (): void => {
|
public onPointerUp = (): void => {
|
||||||
this.mouseClickHandler.onPointerUp();
|
this.mouseClickHandler.onPointerUp();
|
||||||
};
|
};
|
||||||
@@ -104,28 +107,6 @@ export class ResizerViewModel
|
|||||||
public onPointerDown = (): void => {
|
public onPointerDown = (): void => {
|
||||||
this.mouseClickHandler.onPointerDown();
|
this.mouseClickHandler.onPointerDown();
|
||||||
};
|
};
|
||||||
|
|
||||||
public onFocus = (): void => {
|
|
||||||
/**
|
|
||||||
* The intention here is to make the separator visible when it is focused by keyboard
|
|
||||||
* navigation i.e tabbing through the app.
|
|
||||||
*
|
|
||||||
* There's a good reason to take this approach instead of just relying on the focus-visible
|
|
||||||
* selector:
|
|
||||||
* When exactly an element gets focus-visible is determined by browser heuristics and usually
|
|
||||||
* interacting with the mouse will not give an element focus-visible.
|
|
||||||
* However with this separator on chrome, mouse interaction occasionally gives it focus-visible.
|
|
||||||
* The leads to flakey separator behaviour.
|
|
||||||
*/
|
|
||||||
const currentNavigation = whatInput.ask();
|
|
||||||
if (currentNavigation === "keyboard") {
|
|
||||||
this.snapshot.merge({ isFocusedViaKeyboard: true });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
public onBlur = (): void => {
|
|
||||||
this.snapshot.merge({ isFocusedViaKeyboard: false });
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
|
|
||||||
import { waitFor } from "jest-matrix-react";
|
import { waitFor } from "jest-matrix-react";
|
||||||
import { type PanelImperativeHandle } from "@element-hq/web-shared-components";
|
import { type PanelImperativeHandle } from "@element-hq/web-shared-components";
|
||||||
import whatInput from "what-input";
|
|
||||||
|
|
||||||
import { ResizerViewModel } from "../../../src/viewmodels/structures/ResizerViewModel";
|
import { ResizerViewModel } from "../../../src/viewmodels/structures/ResizerViewModel";
|
||||||
import SettingsStore from "../../../src/settings/SettingsStore";
|
import SettingsStore from "../../../src/settings/SettingsStore";
|
||||||
@@ -27,7 +26,6 @@ describe("LeftPanelResizerViewModel", () => {
|
|||||||
expect(vm.getSnapshot()).toStrictEqual({
|
expect(vm.getSnapshot()).toStrictEqual({
|
||||||
isCollapsed: true,
|
isCollapsed: true,
|
||||||
initialSize: 0,
|
initialSize: 0,
|
||||||
isFocusedViaKeyboard: false,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -37,7 +35,6 @@ describe("LeftPanelResizerViewModel", () => {
|
|||||||
expect(vm.getSnapshot()).toStrictEqual({
|
expect(vm.getSnapshot()).toStrictEqual({
|
||||||
isCollapsed: false,
|
isCollapsed: false,
|
||||||
initialSize: 34,
|
initialSize: 34,
|
||||||
isFocusedViaKeyboard: false,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,7 +43,6 @@ describe("LeftPanelResizerViewModel", () => {
|
|||||||
expect(vm.getSnapshot()).toStrictEqual({
|
expect(vm.getSnapshot()).toStrictEqual({
|
||||||
isCollapsed: false,
|
isCollapsed: false,
|
||||||
initialSize: undefined,
|
initialSize: undefined,
|
||||||
isFocusedViaKeyboard: false,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -89,7 +85,7 @@ describe("LeftPanelResizerViewModel", () => {
|
|||||||
expect(mockHandle.resize).not.toHaveBeenCalledWith("34%");
|
expect(mockHandle.resize).not.toHaveBeenCalledWith("34%");
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("should expand panel on click()", () => {
|
describe("should expand panel on double click when panel is collapsed", () => {
|
||||||
it("to last non-zero width that the user set", () => {
|
it("to last non-zero width that the user set", () => {
|
||||||
const vm = new ResizerViewModel();
|
const vm = new ResizerViewModel();
|
||||||
SettingsStore.setValue("RoomList.panelSize", null, SettingLevel.DEVICE, 34);
|
SettingsStore.setValue("RoomList.panelSize", null, SettingLevel.DEVICE, 34);
|
||||||
@@ -98,11 +94,9 @@ describe("LeftPanelResizerViewModel", () => {
|
|||||||
isCollapsed: jest.fn().mockReturnValue(true),
|
isCollapsed: jest.fn().mockReturnValue(true),
|
||||||
} as unknown as PanelImperativeHandle;
|
} as unknown as PanelImperativeHandle;
|
||||||
vm.setPanelHandle(mockHandle);
|
vm.setPanelHandle(mockHandle);
|
||||||
|
|
||||||
// Simulate click
|
// Simulate click
|
||||||
vm.onPointerDown();
|
vm.onPointerDown();
|
||||||
vm.onPointerUp();
|
vm.onPointerUp();
|
||||||
|
|
||||||
expect(mockHandle.resize).toHaveBeenCalledWith("34%");
|
expect(mockHandle.resize).toHaveBeenCalledWith("34%");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -113,22 +107,23 @@ describe("LeftPanelResizerViewModel", () => {
|
|||||||
isCollapsed: jest.fn().mockReturnValue(true),
|
isCollapsed: jest.fn().mockReturnValue(true),
|
||||||
} as unknown as PanelImperativeHandle;
|
} as unknown as PanelImperativeHandle;
|
||||||
vm.setPanelHandle(mockHandle);
|
vm.setPanelHandle(mockHandle);
|
||||||
|
|
||||||
// Simulate click
|
// Simulate click
|
||||||
vm.onPointerDown();
|
vm.onPointerDown();
|
||||||
vm.onPointerUp();
|
vm.onPointerUp();
|
||||||
|
|
||||||
expect(mockHandle.resize).toHaveBeenCalledWith("100%");
|
expect(mockHandle.resize).toHaveBeenCalledWith("100%");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should set isFocusedViaKeyboard state correctly", () => {
|
it("should collapse panel on click when panel is expanded", () => {
|
||||||
whatInput.ask = jest.fn().mockReturnValue("keyboard");
|
|
||||||
const vm = new ResizerViewModel();
|
const vm = new ResizerViewModel();
|
||||||
vm.onFocus();
|
const mockHandle = {
|
||||||
expect(vm.getSnapshot().isFocusedViaKeyboard).toStrictEqual(true);
|
collapse: jest.fn(),
|
||||||
vm.onBlur();
|
isCollapsed: jest.fn().mockReturnValue(false),
|
||||||
expect(vm.getSnapshot().isFocusedViaKeyboard).toStrictEqual(false);
|
} as unknown as PanelImperativeHandle;
|
||||||
|
vm.setPanelHandle(mockHandle);
|
||||||
|
|
||||||
|
vm.onDoubleClick();
|
||||||
|
expect(mockHandle.collapse).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should resize to nearest whole number", () => {
|
it("should resize to nearest whole number", () => {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
@@ -27,11 +27,6 @@ export interface ResizerViewSnapshot {
|
|||||||
* This is the initial size of the panel if available; should be interpreted as percentage.
|
* This is the initial size of the panel if available; should be interpreted as percentage.
|
||||||
*/
|
*/
|
||||||
initialSize?: number;
|
initialSize?: number;
|
||||||
/**
|
|
||||||
* Whether the separator is currently focused by navigating
|
|
||||||
* to it using keyboard input.
|
|
||||||
*/
|
|
||||||
isFocusedViaKeyboard: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -41,14 +41,9 @@ const meta = {
|
|||||||
component: LeftResizablePanelViewWrapper,
|
component: LeftResizablePanelViewWrapper,
|
||||||
// This is a structural component, so nothing to visually test.
|
// This is a structural component, so nothing to visually test.
|
||||||
tags: ["autodocs", "!snapshot"],
|
tags: ["autodocs", "!snapshot"],
|
||||||
argTypes: {
|
|
||||||
// This snapshot state is not relevant for this View.
|
|
||||||
isFocusedViaKeyboard: { table: { disable: true } },
|
|
||||||
},
|
|
||||||
args: {
|
args: {
|
||||||
initialSize: 20,
|
initialSize: 20,
|
||||||
isCollapsed: false,
|
isCollapsed: false,
|
||||||
isFocusedViaKeyboard: false,
|
|
||||||
onLeftPanelResize: fn(),
|
onLeftPanelResize: fn(),
|
||||||
setPanelHandle: fn(),
|
setPanelHandle: fn(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,12 +8,33 @@
|
|||||||
.separator {
|
.separator {
|
||||||
/* Necessary to avoid weird focus outlines (doubled on one side, absent on one side etc...) */
|
/* Necessary to avoid weird focus outlines (doubled on one side, absent on one side etc...) */
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
|
|
||||||
|
/* We set the separator container to relative so that the child container (activeSeparatorContainer)
|
||||||
|
can be absolutely positioned */
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* When type=border, we just render a 1px border */
|
/* When type=border, we just render a 1px border */
|
||||||
.separator[data-separator-type="border"] {
|
.separator[data-separator-type="border"] {
|
||||||
width: 0px;
|
/*
|
||||||
border-right: 1px solid var(--cpd-color-bg-subtle-primary);
|
We reserve 1px of space even though the actual separator is absolute positioned.
|
||||||
|
This keeps the panel looking the same as when the border was part of the panel.
|
||||||
|
*/
|
||||||
|
width: 1px;
|
||||||
|
|
||||||
|
/* We can disable the browser focus ring since we show a thicker separator on focus */
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
.activeSeparatorContainer {
|
||||||
|
.activeSeparator {
|
||||||
|
width: 0px;
|
||||||
|
border-right: 1px solid var(--cpd-color-bg-subtle-primary);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* When type=bar, we render the 12px separator */
|
/* When type=bar, we render the 12px separator */
|
||||||
@@ -28,3 +49,36 @@
|
|||||||
.separator:focus-visible {
|
.separator:focus-visible {
|
||||||
background: var(--cpd-color-bg-action-tertiary-hovered);
|
background: var(--cpd-color-bg-action-tertiary-hovered);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* When type=border and the user hovers/focuses the separator, we make the separator thicker */
|
||||||
|
.separator[data-separator-type="border"]:is([data-separator="hover"], [data-separator="active"]),
|
||||||
|
.separator[data-separator-type="border"]:focus-visible {
|
||||||
|
.activeSeparatorContainer {
|
||||||
|
.activeSeparator {
|
||||||
|
width: 0px;
|
||||||
|
border-right: 2px solid var(--cpd-color-border-interactive-secondary);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Container for the separator when the left panel is expanded */
|
||||||
|
.activeSeparatorContainer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
/*
|
||||||
|
By making this container absolutely positioned an wide enough to cover the entire hit region,
|
||||||
|
we're able to intercept all the pointer events.
|
||||||
|
Without this, we'd only capture the events within a narrow 2px/1px border.
|
||||||
|
*/
|
||||||
|
position: absolute;
|
||||||
|
width: 20px;
|
||||||
|
z-index: 100;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
.activeSeparator {
|
||||||
|
/* Fade in/ fade out the border */
|
||||||
|
transition: border 0.15s ease-in-out 0.3s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,14 +17,13 @@ import { Flex } from "../../core/utils/Flex";
|
|||||||
type SeparatorViewProps = ResizerViewSnapshot & SeparatorViewActions;
|
type SeparatorViewProps = ResizerViewSnapshot & SeparatorViewActions;
|
||||||
|
|
||||||
const Wrapper = ({
|
const Wrapper = ({
|
||||||
onFocus,
|
|
||||||
onBlur,
|
|
||||||
onPointerDown,
|
onPointerDown,
|
||||||
onPointerMove,
|
onPointerMove,
|
||||||
onPointerUp,
|
onPointerUp,
|
||||||
|
onDoubleClick,
|
||||||
...snapshot
|
...snapshot
|
||||||
}: SeparatorViewProps): JSX.Element => {
|
}: SeparatorViewProps): JSX.Element => {
|
||||||
const vm = useMockedViewModel(snapshot, { onFocus, onBlur, onPointerDown, onPointerMove, onPointerUp });
|
const vm = useMockedViewModel(snapshot, { onPointerDown, onPointerMove, onPointerUp, onDoubleClick });
|
||||||
return <SeparatorView className="Separator" vm={vm} />;
|
return <SeparatorView className="Separator" vm={vm} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -35,13 +34,11 @@ const meta = {
|
|||||||
component: SeparatorViewWrapper,
|
component: SeparatorViewWrapper,
|
||||||
tags: ["autodocs"],
|
tags: ["autodocs"],
|
||||||
args: {
|
args: {
|
||||||
onFocus: fn(),
|
|
||||||
onBlur: fn(),
|
|
||||||
onPointerUp: fn(),
|
onPointerUp: fn(),
|
||||||
onPointerMove: fn(),
|
onPointerMove: fn(),
|
||||||
onPointerDown: fn(),
|
onPointerDown: fn(),
|
||||||
|
onDoubleClick: fn(),
|
||||||
isCollapsed: true,
|
isCollapsed: true,
|
||||||
isFocusedViaKeyboard: false,
|
|
||||||
},
|
},
|
||||||
parameters: {
|
parameters: {
|
||||||
design: {
|
design: {
|
||||||
@@ -118,16 +115,17 @@ export const KeyboardFocused: Story = {
|
|||||||
decorators: [(Story) => commonDecorator(Story)],
|
decorators: [(Story) => commonDecorator(Story)],
|
||||||
args: {
|
args: {
|
||||||
isCollapsed: false,
|
isCollapsed: false,
|
||||||
isFocusedViaKeyboard: true,
|
|
||||||
},
|
},
|
||||||
play: async ({ canvas, canvasElement }) => {
|
play: async ({ canvas, canvasElement }) => {
|
||||||
const separator = canvas.getByRole("separator");
|
const separator = canvas.getByRole("separator");
|
||||||
separator.focus();
|
separator.focus();
|
||||||
|
// Wait for animations to finish
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
await expect(canvasElement).toMatchImageSnapshot();
|
await expect(canvasElement).toMatchImageSnapshot();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Hover: Story = {
|
export const HoverWhenCollapsed: Story = {
|
||||||
// We'll manually take a screenshot for this story
|
// We'll manually take a screenshot for this story
|
||||||
tags: ["autodocs", "!snapshot"],
|
tags: ["autodocs", "!snapshot"],
|
||||||
decorators: [
|
decorators: [
|
||||||
@@ -159,3 +157,47 @@ export const Hover: Story = {
|
|||||||
await expect(canvasElement).toMatchImageSnapshot();
|
await expect(canvasElement).toMatchImageSnapshot();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const HoverWhenExpanded: Story = {
|
||||||
|
// We'll manually take a screenshot for this story
|
||||||
|
tags: ["autodocs", "!snapshot"],
|
||||||
|
args: {
|
||||||
|
isCollapsed: false,
|
||||||
|
},
|
||||||
|
decorators: [
|
||||||
|
(Story) => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "600px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ResizableGroup>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "600px",
|
||||||
|
width: "200px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Panel collapsible defaultSize="0" minSize="200px" maxSize="400px">
|
||||||
|
<Flex tabIndex={0} align="center" justify="center">
|
||||||
|
LEFT CONTENT
|
||||||
|
</Flex>
|
||||||
|
</Panel>
|
||||||
|
<Story />
|
||||||
|
<Panel>
|
||||||
|
<Flex align="center" justify="center">
|
||||||
|
MAIN CONTENT
|
||||||
|
</Flex>
|
||||||
|
</Panel>
|
||||||
|
</ResizableGroup>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
play: async ({ canvas, canvasElement }) => {
|
||||||
|
const separator = canvas.getByRole("separator");
|
||||||
|
separator.dataset.separator = "hover";
|
||||||
|
// Wait for animations to finish
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
await expect(canvasElement).toMatchImageSnapshot();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -15,17 +15,16 @@ import * as stories from "./SeparatorView.stories";
|
|||||||
import { BaseViewModel } from "../../core/viewmodel";
|
import { BaseViewModel } from "../../core/viewmodel";
|
||||||
import { ResizableGroup, Panel, type ResizerViewSnapshot, SeparatorView, type SeparatorViewActions } from "..";
|
import { ResizableGroup, Panel, type ResizerViewSnapshot, SeparatorView, type SeparatorViewActions } from "..";
|
||||||
|
|
||||||
const { Default, LeftPanelExpanded, KeyboardFocused } = composeStories(stories);
|
const { Default, LeftPanelExpanded, KeyboardFocused, HoverWhenCollapsed, HoverWhenExpanded } = composeStories(stories);
|
||||||
|
|
||||||
class MockViewModel extends BaseViewModel<ResizerViewSnapshot, unknown> implements SeparatorViewActions {
|
class MockViewModel extends BaseViewModel<ResizerViewSnapshot, unknown> implements SeparatorViewActions {
|
||||||
public constructor(snapshot: ResizerViewSnapshot) {
|
public constructor(snapshot: ResizerViewSnapshot) {
|
||||||
super(undefined, snapshot);
|
super(undefined, snapshot);
|
||||||
}
|
}
|
||||||
public onBlur: () => void = vi.fn();
|
|
||||||
public onFocus: () => void = vi.fn();
|
|
||||||
public onPointerUp: () => void = vi.fn();
|
public onPointerUp: () => void = vi.fn();
|
||||||
public onPointerMove: () => void = vi.fn();
|
public onPointerMove: () => void = vi.fn();
|
||||||
public onPointerDown: () => void = vi.fn();
|
public onPointerDown: () => void = vi.fn();
|
||||||
|
public onDoubleClick: () => void = vi.fn();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPanel(initialSnapshot?: Partial<ResizerViewSnapshot>): MockViewModel {
|
function renderPanel(initialSnapshot?: Partial<ResizerViewSnapshot>): MockViewModel {
|
||||||
@@ -57,6 +56,16 @@ describe("<SeparatorView />", () => {
|
|||||||
expect(container).toMatchSnapshot();
|
expect(container).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders HoverWhenCollapsed story", () => {
|
||||||
|
const { container } = render(<HoverWhenCollapsed />);
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders HoverWhenExpanded story", () => {
|
||||||
|
const { container } = render(<HoverWhenExpanded />);
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
it("should call onPointerDown and onPointerUp on pointer events", async () => {
|
it("should call onPointerDown and onPointerUp on pointer events", async () => {
|
||||||
const vm = renderPanel();
|
const vm = renderPanel();
|
||||||
const separator = screen.getByRole("separator");
|
const separator = screen.getByRole("separator");
|
||||||
@@ -65,12 +74,10 @@ describe("<SeparatorView />", () => {
|
|||||||
expect(vm.onPointerUp).toHaveBeenCalledOnce();
|
expect(vm.onPointerUp).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should call onFocus and onBlur when receiving/loosing focus", async () => {
|
it("should call onDoubleClick on double click", async () => {
|
||||||
const vm = renderPanel();
|
const vm = renderPanel();
|
||||||
const separator = screen.getByRole("separator");
|
const separator = screen.getByRole("separator");
|
||||||
separator.focus();
|
await userEvent.dblClick(separator);
|
||||||
expect(vm.onFocus).toHaveBeenCalled();
|
expect(vm.onDoubleClick).toHaveBeenCalledOnce();
|
||||||
separator.blur();
|
|
||||||
expect(vm.onBlur).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,14 +33,9 @@ export interface SeparatorViewActions {
|
|||||||
onPointerDown: () => void;
|
onPointerDown: () => void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* onFocus handler for the separator.
|
* onDoubleClick handler for the separator.
|
||||||
*/
|
*/
|
||||||
onFocus: () => void;
|
onDoubleClick: () => void;
|
||||||
|
|
||||||
/**
|
|
||||||
* onBlur handler for the separator.
|
|
||||||
*/
|
|
||||||
onBlur: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -53,14 +48,14 @@ interface Props {
|
|||||||
*/
|
*/
|
||||||
export function SeparatorView({ vm, className }: Props): React.ReactNode {
|
export function SeparatorView({ vm, className }: Props): React.ReactNode {
|
||||||
const { translate: _t } = useI18n();
|
const { translate: _t } = useI18n();
|
||||||
const { isCollapsed, isFocusedViaKeyboard } = useViewModel(vm);
|
const { isCollapsed } = useViewModel(vm);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* There are two types of separator:
|
* There are two types of separator:
|
||||||
* - bar: This shows a thick bar separator with a resize icon in the middle; shown when the panel is collapsed.
|
* - bar: This shows a thick bar separator with a resize icon in the middle; shown when the panel is collapsed.
|
||||||
* - border: This is just a 1px wide separator; shown when the panel is expanded.
|
* - border: This is just a thin separator; shown when the panel is expanded.
|
||||||
*/
|
*/
|
||||||
const type = isCollapsed || isFocusedViaKeyboard ? "bar" : "border";
|
const type = isCollapsed ? "bar" : "border";
|
||||||
|
|
||||||
const barContent = (
|
const barContent = (
|
||||||
<Tooltip description={_t("left_panel|separator_label")} placement="right">
|
<Tooltip description={_t("left_panel|separator_label")} placement="right">
|
||||||
@@ -75,19 +70,29 @@ export function SeparatorView({ vm, className }: Props): React.ReactNode {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This border is:
|
||||||
|
* - a 1px border that separates the left panel and main content.
|
||||||
|
* - a 2px border when the panel is expanded and the user is interacting with the separator.
|
||||||
|
*/
|
||||||
|
const border = (
|
||||||
|
<div className={styles.activeSeparatorContainer}>
|
||||||
|
<div className={styles.activeSeparator} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Separator
|
<Separator
|
||||||
className={classNames(styles.separator, className)}
|
className={classNames(styles.separator, className)}
|
||||||
onPointerUp={vm.onPointerUp}
|
onPointerUp={vm.onPointerUp}
|
||||||
onPointerMove={vm.onPointerMove}
|
onPointerMove={vm.onPointerMove}
|
||||||
onPointerDown={vm.onPointerDown}
|
onPointerDown={vm.onPointerDown}
|
||||||
onFocus={vm.onFocus}
|
|
||||||
onBlur={vm.onBlur}
|
|
||||||
aria-label={_t("left_panel|separator_label")}
|
aria-label={_t("left_panel|separator_label")}
|
||||||
data-separator-type={type}
|
data-separator-type={type}
|
||||||
|
onDoubleClick={vm.onDoubleClick}
|
||||||
disableDoubleClick
|
disableDoubleClick
|
||||||
>
|
>
|
||||||
{type === "bar" ? barContent : null}
|
{type === "bar" ? barContent : border}
|
||||||
</Separator>
|
</Separator>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ exports[`<SeparatorView /> > renders Default story 1`] = `
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`<SeparatorView /> > renders KeyboardFocused story 1`] = `
|
exports[`<SeparatorView /> > renders HoverWhenCollapsed story 1`] = `
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
style="height: 600px;"
|
style="height: 600px;"
|
||||||
@@ -95,7 +95,7 @@ exports[`<SeparatorView /> > renders KeyboardFocused story 1`] = `
|
|||||||
data-panel="true"
|
data-panel="true"
|
||||||
data-testid="react-use-id-2"
|
data-testid="react-use-id-2"
|
||||||
id="react-use-id-2"
|
id="react-use-id-2"
|
||||||
style="min-height: 0px; max-height: 100%; height: auto; min-width: 0px; max-width: 100%; width: auto; border: 0px; padding: 0px; margin: 0px; display: flex; flex: 49.751 1 0px; overflow: visible;"
|
style="min-height: 0px; max-height: 100%; height: auto; min-width: 0px; max-width: 100%; width: auto; border: 0px; padding: 0px; margin: 0px; display: flex; flex: 0 1 0px; overflow: visible;"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style="max-height: 100%; max-width: 100%; flex-grow: 1; overflow: auto; touch-action: pan-y;"
|
style="max-height: 100%; max-width: 100%; flex-grow: 1; overflow: auto; touch-action: pan-y;"
|
||||||
@@ -115,7 +115,7 @@ exports[`<SeparatorView /> > renders KeyboardFocused story 1`] = `
|
|||||||
aria-orientation="vertical"
|
aria-orientation="vertical"
|
||||||
aria-valuemax="99.502"
|
aria-valuemax="99.502"
|
||||||
aria-valuemin="0"
|
aria-valuemin="0"
|
||||||
aria-valuenow="49.751"
|
aria-valuenow="0"
|
||||||
class="SeparatorView-module_separator Separator"
|
class="SeparatorView-module_separator Separator"
|
||||||
data-separator="inactive"
|
data-separator="inactive"
|
||||||
data-separator-type="bar"
|
data-separator-type="bar"
|
||||||
@@ -142,7 +142,160 @@ exports[`<SeparatorView /> > renders KeyboardFocused story 1`] = `
|
|||||||
data-panel="true"
|
data-panel="true"
|
||||||
data-testid="react-use-id-4"
|
data-testid="react-use-id-4"
|
||||||
id="react-use-id-4"
|
id="react-use-id-4"
|
||||||
style="min-height: 0px; max-height: 100%; height: auto; min-width: 0px; max-width: 100%; width: auto; border: 0px; padding: 0px; margin: 0px; display: flex; flex: 50.249 1 0px; overflow: visible;"
|
style="min-height: 0px; max-height: 100%; height: auto; min-width: 0px; max-width: 100%; width: auto; border: 0px; padding: 0px; margin: 0px; display: flex; flex: 100 1 0px; overflow: visible;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="max-height: 100%; max-width: 100%; flex-grow: 1; overflow: auto; touch-action: pan-y;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="Flex-module_flex"
|
||||||
|
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||||
|
>
|
||||||
|
MAIN CONTENT
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`<SeparatorView /> > renders HoverWhenExpanded story 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style="height: 600px;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-group="true"
|
||||||
|
data-testid="react-use-id-1"
|
||||||
|
id="react-use-id-1"
|
||||||
|
style="height: 100%; width: 100%; overflow: hidden; display: flex; flex-flow: row; touch-action: pan-y;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="height: 600px; width: 200px;"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
data-panel="true"
|
||||||
|
data-testid="react-use-id-2"
|
||||||
|
id="react-use-id-2"
|
||||||
|
style="min-height: 0px; max-height: 100%; height: auto; min-width: 0px; max-width: 100%; width: auto; border: 0px; padding: 0px; margin: 0px; display: flex; flex: 0 1 0px; overflow: visible;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="max-height: 100%; max-width: 100%; flex-grow: 1; overflow: auto; touch-action: pan-y;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="Flex-module_flex"
|
||||||
|
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
LEFT CONTENT
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
aria-controls="react-use-id-2"
|
||||||
|
aria-label="Click or drag to expand"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-valuemax="100"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuenow="0"
|
||||||
|
class="SeparatorView-module_separator Separator"
|
||||||
|
data-separator="inactive"
|
||||||
|
data-separator-type="border"
|
||||||
|
data-testid="react-use-id-3"
|
||||||
|
id="react-use-id-3"
|
||||||
|
role="separator"
|
||||||
|
style="flex: 0 0 auto; touch-action: none;"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="SeparatorView-module_activeSeparatorContainer"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="SeparatorView-module_activeSeparator"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
data-panel="true"
|
||||||
|
data-testid="react-use-id-4"
|
||||||
|
id="react-use-id-4"
|
||||||
|
style="min-height: 0px; max-height: 100%; height: auto; min-width: 0px; max-width: 100%; width: auto; border: 0px; padding: 0px; margin: 0px; display: flex; flex: 100 1 0px; overflow: visible;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="max-height: 100%; max-width: 100%; flex-grow: 1; overflow: auto; touch-action: pan-y;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="Flex-module_flex"
|
||||||
|
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||||
|
>
|
||||||
|
MAIN CONTENT
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`<SeparatorView /> > renders KeyboardFocused story 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style="height: 600px;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-group="true"
|
||||||
|
data-testid="react-use-id-1"
|
||||||
|
id="react-use-id-1"
|
||||||
|
style="height: 100%; width: 100%; overflow: hidden; display: flex; flex-flow: row; touch-action: pan-y;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-panel="true"
|
||||||
|
data-testid="react-use-id-2"
|
||||||
|
id="react-use-id-2"
|
||||||
|
style="min-height: 0px; max-height: 100%; height: auto; min-width: 0px; max-width: 100%; width: auto; border: 0px; padding: 0px; margin: 0px; display: flex; flex: 48.426 1 0px; overflow: visible;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="max-height: 100%; max-width: 100%; flex-grow: 1; overflow: auto; touch-action: pan-y;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="Flex-module_flex"
|
||||||
|
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
LEFT CONTENT
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
aria-controls="react-use-id-2"
|
||||||
|
aria-label="Click or drag to expand"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-valuemax="96.852"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuenow="48.426"
|
||||||
|
class="SeparatorView-module_separator Separator"
|
||||||
|
data-separator="inactive"
|
||||||
|
data-separator-type="border"
|
||||||
|
data-testid="react-use-id-3"
|
||||||
|
id="react-use-id-3"
|
||||||
|
role="separator"
|
||||||
|
style="flex: 0 0 auto; touch-action: none;"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="SeparatorView-module_activeSeparatorContainer"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="SeparatorView-module_activeSeparator"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
data-panel="true"
|
||||||
|
data-testid="react-use-id-4"
|
||||||
|
id="react-use-id-4"
|
||||||
|
style="min-height: 0px; max-height: 100%; height: auto; min-width: 0px; max-width: 100%; width: auto; border: 0px; padding: 0px; margin: 0px; display: flex; flex: 51.574 1 0px; overflow: visible;"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style="max-height: 100%; max-width: 100%; flex-grow: 1; overflow: auto; touch-action: pan-y;"
|
style="max-height: 100%; max-width: 100%; flex-grow: 1; overflow: auto; touch-action: pan-y;"
|
||||||
@@ -175,7 +328,7 @@ exports[`<SeparatorView /> > renders LeftPanelExpanded story 1`] = `
|
|||||||
data-panel="true"
|
data-panel="true"
|
||||||
data-testid="react-use-id-2"
|
data-testid="react-use-id-2"
|
||||||
id="react-use-id-2"
|
id="react-use-id-2"
|
||||||
style="min-height: 0px; max-height: 100%; height: auto; min-width: 0px; max-width: 100%; width: auto; border: 0px; padding: 0px; margin: 0px; display: flex; flex: 48.309 1 0px; overflow: visible;"
|
style="min-height: 0px; max-height: 100%; height: auto; min-width: 0px; max-width: 100%; width: auto; border: 0px; padding: 0px; margin: 0px; display: flex; flex: 48.426 1 0px; overflow: visible;"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style="max-height: 100%; max-width: 100%; flex-grow: 1; overflow: auto; touch-action: pan-y;"
|
style="max-height: 100%; max-width: 100%; flex-grow: 1; overflow: auto; touch-action: pan-y;"
|
||||||
@@ -193,9 +346,9 @@ exports[`<SeparatorView /> > renders LeftPanelExpanded story 1`] = `
|
|||||||
aria-controls="react-use-id-2"
|
aria-controls="react-use-id-2"
|
||||||
aria-label="Click or drag to expand"
|
aria-label="Click or drag to expand"
|
||||||
aria-orientation="vertical"
|
aria-orientation="vertical"
|
||||||
aria-valuemax="96.618"
|
aria-valuemax="96.852"
|
||||||
aria-valuemin="0"
|
aria-valuemin="0"
|
||||||
aria-valuenow="48.309"
|
aria-valuenow="48.426"
|
||||||
class="SeparatorView-module_separator Separator"
|
class="SeparatorView-module_separator Separator"
|
||||||
data-separator="inactive"
|
data-separator="inactive"
|
||||||
data-separator-type="border"
|
data-separator-type="border"
|
||||||
@@ -204,12 +357,20 @@ exports[`<SeparatorView /> > renders LeftPanelExpanded story 1`] = `
|
|||||||
role="separator"
|
role="separator"
|
||||||
style="flex: 0 0 auto; touch-action: none;"
|
style="flex: 0 0 auto; touch-action: none;"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
/>
|
>
|
||||||
|
<div
|
||||||
|
class="SeparatorView-module_activeSeparatorContainer"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="SeparatorView-module_activeSeparator"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
data-panel="true"
|
data-panel="true"
|
||||||
data-testid="react-use-id-4"
|
data-testid="react-use-id-4"
|
||||||
id="react-use-id-4"
|
id="react-use-id-4"
|
||||||
style="min-height: 0px; max-height: 100%; height: auto; min-width: 0px; max-width: 100%; width: auto; border: 0px; padding: 0px; margin: 0px; display: flex; flex: 51.691 1 0px; overflow: visible;"
|
style="min-height: 0px; max-height: 100%; height: auto; min-width: 0px; max-width: 100%; width: auto; border: 0px; padding: 0px; margin: 0px; display: flex; flex: 51.574 1 0px; overflow: visible;"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style="max-height: 100%; max-width: 100%; flex-grow: 1; overflow: auto; touch-action: pan-y;"
|
style="max-height: 100%; max-width: 100%; flex-grow: 1; overflow: auto; touch-action: pan-y;"
|
||||||
|
|||||||