mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-09 05:26:24 +09:00
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:
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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+2
-88
@@ -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");
|
||||
},
|
||||
};
|
||||
};
|
||||
Reference in new issue
Block a user