feat: Make complex values in spacing UI look good (#5020)

<img width="247" alt="image"
src="https://github.com/user-attachments/assets/ed89dd8c-c45e-4bf6-9e28-b6fb8c812bbd"
/>


## Description

1. Cuts off the long values and avoids badly looking UI
2. Adds scroll on pointer over effect to see the value quickly

## Steps for reproduction

1. click button
3. expect xyz

## Code Review

- [ ] hi @kof, I need you to do
  - conceptual review (architecture, feature-correctness)
  - detailed review (read every line)
  - test it on preview

## Before requesting a review

- [ ] made a self-review
- [ ] added inline comments where things may be not obvious (the "why",
not "what")

## Before merging

- [ ] tested locally and on preview environment (preview dev login:
0000)
- [ ] updated [test
cases](https://github.com/webstudio-is/webstudio/blob/main/apps/builder/docs/test-cases.md)
document
- [ ] added tests
- [ ] if any new env variables are added, added them to `.env` file
This commit is contained in:
Oleg Isonen authored and GitHub committed 2025-03-21 10:56:42 +01:00
1 parent 910a7a26e2
commit 7ff72c335d
7 files changed
+149 -149

No files matched your search

@@ -50,15 +50,6 @@ const Cell = ({
/>
<InsetTooltip property={property} preventOpen={scrubStatus.isActive}>
<ValueText
css={{
// We want value to have `default` cursor to indicate that it's clickable,
// unlike the rest of the value area that has cursor that indicates scrubbing.
// Click and scrub works everywhere anyway, but we want cursors to be different.
//
// In order to have control over cursor we're setting pointerEvents to "all" here
// because SpaceLayout sets it to "none" for cells' content.
pointerEvents: "all",
}}
value={finalValue}
source={styleDecl.source.name}
onMouseEnter={(event) =>
@@ -141,7 +141,7 @@ export const InsetTooltip = ({
}
>
{/* @todo show tooltip on focus */}
<div>{children}</div>
<div style={{ maxWidth: "100%" }}>{children}</div>
</Tooltip>
);
};
@@ -1,20 +1,32 @@
import { styled, Text } from "@webstudio-is/design-system";
import type { StyleValue } from "@webstudio-is/css-engine";
import { useMemo, type ComponentProps } from "react";
import { useEffect, useMemo, type ComponentProps } from "react";
import { theme } from "@webstudio-is/design-system";
import { toValue } from "@webstudio-is/css-engine";
import { scrollByPointer } from "../../shared/scroll-by-pointer";
const Container = styled("button", {
// fit-content is not needed for the "button" element,
// leave it here in case of tag change
width: "fit-content",
maxWidth: "100%",
display: "flex",
flexWrap: "wrap",
flexWrap: "nowrap",
alignItems: "baseline",
justifyContent: "center",
justifyContent: "start",
border: "none",
borderRadius: theme.borderRadius[3],
padding: `${theme.spacing[2]}`,
paddingBlock: theme.spacing[2],
paddingInline: 0,
overflow: "hidden",
whiteSpace: "nowrap",
// We want value to have `default` cursor to indicate that it's clickable,
// unlike the rest of the value area that has cursor that indicates scrubbing.
// Click and scrub works everywhere anyway, but we want cursors to be different.
//
// In order to have control over cursor we're setting pointerEvents to "all" here
// because SpaceLayout sets it to "none" for cells' content.
pointerEvents: "all",
"&:focus-visible": {
outline: "none",
@@ -52,21 +64,13 @@ const Container = styled("button", {
export const ValueText = ({
value,
source,
truncate = false,
...rest
}: { value: StyleValue; truncate?: boolean } & Omit<
ComponentProps<typeof Container>,
"value"
>) => {
}: { value: StyleValue } & Omit<ComponentProps<typeof Container>, "value">) => {
const children = useMemo(() => {
if (value.type === "unit") {
// we want to show "0" rather than "0px" for default values for cleaner UI
if (source === "default" && value.unit === "px" && value.value === 0) {
return (
<Text truncate={truncate} variant="spaceSectionValueText">
{value.value}
</Text>
);
return <Text variant="spaceSectionValueText">{value.value}</Text>;
}
/**
@@ -76,7 +80,7 @@ export const ValueText = ({
return (
<>
<Text truncate={truncate} variant="spaceSectionValueText">
<Text variant="spaceSectionValueText">
{value.value}
<Text
variant="spaceSectionValueText"
@@ -93,22 +97,20 @@ export const ValueText = ({
}
if (value.type === "var") {
return (
<Text truncate={truncate} variant="spaceSectionValueText">
--{value.value}
</Text>
);
return <Text variant="spaceSectionValueText">{value.value}</Text>;
}
return (
<Text truncate={truncate} variant="spaceSectionValueText">
{toValue(value)}
</Text>
);
}, [value, source, truncate]);
return <Text variant="spaceSectionValueText">{toValue(value)}</Text>;
}, [value, source]);
const { abort, ...autoScrollProps } = useMemo(scrollByPointer, []);
useEffect(() => {
return () => abort("unmount");
}, [abort]);
return (
<Container source={source} {...rest} tabIndex={-1}>
<Container source={source} {...rest} {...autoScrollProps} tabIndex={-1}>
{children}
</Container>
);
@@ -173,7 +173,6 @@ const Cell = styled("div", {
alignItems: "center",
justifyContent: "center",
maxWidth: "100%",
padding: theme.spacing[2],
variants: {
property: {
"margin-top": { gridColumn: "2 / 5", gridRow: "1" },
@@ -1,5 +1,4 @@
import { useState, useRef } from "react";
import { theme } from "@webstudio-is/design-system";
import type { CssProperty } from "@webstudio-is/css-engine";
import { SpaceLayout } from "./layout";
import { ValueText } from "../shared/value-text";
@@ -10,7 +9,6 @@ import {
type SpaceStyleProperty,
} from "./properties";
import { InputPopover } from "../shared/input-popover";
import { SpaceTooltip } from "./tooltip";
import { StyleSection } from "../../shared/style-section";
import { useKeyboardNavigation } from "../shared/keyboard";
import { useComputedStyleDecl, useComputedStyles } from "../../shared/model";
@@ -120,27 +118,14 @@ const Cell = ({
getActiveProperties={getActiveProperties}
onClose={onPopoverClose}
/>
<SpaceTooltip property={property} preventOpen={scrubStatus.isActive}>
<ValueText
truncate
css={{
// We want value to have `default` cursor to indicate that it's clickable,
// unlike the rest of the value area that has cursor that indicates scrubbing.
// Click and scrub works everywhere anyway, but we want cursors to be different.
//
// In order to have control over cursor we're setting pointerEvents to "all" here
// because SpaceLayout sets it to "none" for cells' content.
pointerEvents: "all",
maxWidth: theme.spacing[18],
}}
value={finalValue}
source={styleDecl.source.name}
onMouseEnter={(event) =>
onHover({ property, element: event.currentTarget })
}
onMouseLeave={() => onHover(undefined)}
/>
</SpaceTooltip>
<ValueText
value={finalValue}
source={styleDecl.source.name}
onMouseEnter={(event) =>
onHover({ property, element: event.currentTarget })
}
onMouseLeave={() => onHover(undefined)}
/>
</>
);
};
@@ -58,6 +58,7 @@ import {
ValueEditorDialog,
} from "./value-editor-dialog";
import { useEffectEvent } from "~/shared/hook-utils/effect-event";
import { scrollByPointer } from "../scroll-by-pointer";
// We need to enable scrub on properties that can have numeric value.
const canBeNumber = (property: CssProperty, value: CssValueInputValue) => {
@@ -345,93 +346,6 @@ const itemToString = (item: CssValueInputValue | null) => {
return toValue(item);
};
const scrollAhead = ({ target, clientX }: MouseEvent) => {
const element = target as HTMLInputElement;
if (element.scrollWidth === element.clientWidth) {
// Nothing to scroll.
return false;
}
const inputRect = element.getBoundingClientRect();
// Calculate the relative x position of the mouse within the input element
const relativeMouseX = clientX - inputRect.x;
// Calculate the percentage position (0% at the beginning, 100% at the end)
const inputWidth = inputRect.width;
const mousePercentageX = Math.ceil((relativeMouseX / inputWidth) * 100);
// Apply acceleration based on the relative position of the mouse
// Closer to the beginning (-20%), closer to the end (+20%)
const accelerationFactor = (mousePercentageX - 50) / 50;
const adjustedMousePercentageX = Math.min(
Math.max(mousePercentageX + accelerationFactor * 20, 0),
100
);
// Calculate the scroll position corresponding to the adjusted percentage
const scrollPosition =
(adjustedMousePercentageX / 100) *
(element.scrollWidth - element.clientWidth);
// Scroll the input element
element.scroll({ left: scrollPosition });
return true;
};
const getAutoScrollProps = () => {
let abortController = new AbortController();
const abort = (reason: string) => {
abortController.abort(reason);
};
return {
abort,
onMouseOver(event: MouseEvent) {
if (event.target === document.activeElement) {
abort("focused");
return;
}
if (scrollAhead(event) === false) {
return;
}
abortController = new AbortController();
event.target?.addEventListener(
"mousemove",
(event) => {
if (event.target === document.activeElement) {
abort("focused");
return;
}
requestAnimationFrame(() => {
scrollAhead(event as MouseEvent);
});
},
{
signal: abortController.signal,
passive: true,
}
);
},
onMouseOut(event: MouseEvent) {
if (event.target === document.activeElement) {
abort("focused");
return;
}
(event.target as HTMLInputElement).scroll({
left: 0,
behavior: "smooth",
});
abort("mouseout");
},
onFocus() {
abort("focus");
},
};
};
const Description = styled(Box, { width: theme.spacing[27] });
/**
@@ -880,7 +794,7 @@ export const CssValueInput = ({
};
const { abort, ...autoScrollProps } = useMemo(() => {
return getAutoScrollProps();
return scrollByPointer();
}, []);
useEffect(() => {
@@ -0,0 +1,109 @@
const scrollAhead = (element: HTMLElement, clientX: number) => {
if (element.scrollWidth === element.clientWidth) {
// Nothing to scroll.
return false;
}
const inputRect = element.getBoundingClientRect();
// Calculate the relative x position of the mouse within the input element
const relativeMouseX = clientX - inputRect.x;
// Calculate the percentage position (0% at the beginning, 100% at the end)
const inputWidth = inputRect.width;
const mousePercentageX = Math.ceil((relativeMouseX / inputWidth) * 100);
// Apply acceleration based on the relative position of the mouse
// Closer to the beginning (-20%), closer to the end (+20%)
const accelerationFactor = (mousePercentageX - 50) / 50;
const adjustedMousePercentageX = Math.min(
Math.max(mousePercentageX + accelerationFactor * 20, 0),
100
);
// Calculate the scroll position corresponding to the adjusted percentage
const scrollPosition =
(adjustedMousePercentageX / 100) *
(element.scrollWidth - element.clientWidth);
// Scroll the input element
element.scroll({ left: scrollPosition });
return true;
};
// We don't want to scroll if the element is focused.
// E.g. this is important for inputs, where the user might be interacting with text.
const isFocused = (element: HTMLElement) => element === document.activeElement;
/**
* Scroll any element horizontally based on pointer position.
* Used in CSSValueInput and Spacing UI to show a string that is too long.
*/
export const scrollByPointer = () => {
let abortController = new AbortController();
const abort = (reason: string) => {
abortController.abort(reason);
};
const onMouseOver = (event: React.MouseEvent) => {
const element = event.currentTarget;
if (element instanceof HTMLElement === false) {
return;
}
if (isFocused(element)) {
abort("focused");
return;
}
if (scrollAhead(element, event.clientX) === false) {
return;
}
abortController = new AbortController();
element?.addEventListener(
"mousemove",
(event: MouseEvent) => {
const element = event.currentTarget;
if (element instanceof HTMLElement === false) {
return;
}
if (isFocused(element)) {
abort("focused");
return;
}
requestAnimationFrame(() => {
scrollAhead(element, event.clientX);
});
},
{
signal: abortController.signal,
passive: true,
}
);
};
const onMouseOut = (event: React.MouseEvent) => {
abort("mouseout");
const element = event.currentTarget;
if (element instanceof HTMLElement === false) {
return;
}
if (isFocused(element)) {
abort("focused");
return;
}
element.scroll({
left: 0,
behavior: "smooth",
});
};
return {
abort,
onMouseOver,
onMouseOut,
onFocus() {
abort("focus");
},
};
};