Skip to content

Commit c57d308

Browse files
authored
feat: Auto scroll CSS Value inputs when value is too long just by hovering with the mouse (#4502)
## Description 1. When not focused will scroll 2. Only active on fields with scroll potential https://share.descript.com/view/x0E72z2aFlS ## 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
1 parent b2137c1 commit c57d308

File tree

2 files changed

+140
-0
lines changed

2 files changed

+140
-0
lines changed

apps/builder/app/builder/features/style-panel/shared/css-value-input/css-value-input.stories.tsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,3 +157,43 @@ export const WithUnits = () => {
157157
</Flex>
158158
);
159159
};
160+
161+
export const AutoScroll = () => {
162+
const [value, setValue] = React.useState<StyleValue>({
163+
type: "var",
164+
value: "start-test-test-test-test-test-test-test-end",
165+
});
166+
167+
const [intermediateValue, setIntermediateValue] = React.useState<
168+
StyleValue | IntermediateStyleValue
169+
>();
170+
171+
return (
172+
<Flex css={{ width: 100 }}>
173+
<CssValueInput
174+
styleSource="preset"
175+
property="alignItems"
176+
value={value}
177+
intermediateValue={intermediateValue}
178+
onChange={(newValue) => {
179+
setIntermediateValue(newValue);
180+
}}
181+
onHighlight={(value) => {
182+
action("onHighlight")(value);
183+
}}
184+
onChangeComplete={({ value }) => {
185+
// on blur, select, enter etc.
186+
setValue(value);
187+
setIntermediateValue(undefined);
188+
action("onChangeComplete")(value);
189+
}}
190+
onAbort={() => {
191+
action("onAbort")();
192+
}}
193+
onReset={() => {
194+
action("onReset")();
195+
}}
196+
/>
197+
</Flex>
198+
);
199+
};

apps/builder/app/builder/features/style-panel/shared/css-value-input/css-value-input.tsx

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
useEffect,
3434
useRef,
3535
useState,
36+
useMemo,
3637
type ComponentProps,
3738
} from "react";
3839
import { useUnitSelect } from "./unit-select";
@@ -294,6 +295,97 @@ const itemToString = (item: CssValueInputValue | null) => {
294295
return toValue(item);
295296
};
296297

298+
const scrollAhead = ({ target, clientX }: MouseEvent) => {
299+
const element = target as HTMLInputElement;
300+
// Get the scrollable width of the input element
301+
const scrollWidth = element.scrollWidth;
302+
const visibleWidth = element.clientWidth;
303+
304+
if (scrollWidth === visibleWidth) {
305+
// Nothing to scroll.
306+
return false;
307+
}
308+
const inputRect = element.getBoundingClientRect();
309+
310+
// Calculate the relative x position of the mouse within the input element
311+
const relativeMouseX = clientX - inputRect.x;
312+
313+
// Calculate the percentage position (0% at the beginning, 100% at the end)
314+
const inputWidth = inputRect.width;
315+
const mousePercentageX = Math.ceil((relativeMouseX / inputWidth) * 100);
316+
317+
// Apply acceleration based on the relative position of the mouse
318+
// Closer to the beginning (-20%), closer to the end (+20%)
319+
const accelerationFactor = (mousePercentageX - 50) / 50;
320+
const adjustedMousePercentageX = Math.min(
321+
Math.max(mousePercentageX + accelerationFactor * 20, 0),
322+
100
323+
);
324+
325+
// Calculate the scroll position corresponding to the adjusted percentage
326+
const scrollPosition =
327+
(adjustedMousePercentageX / 100) * (scrollWidth - visibleWidth);
328+
329+
// Scroll the input element
330+
element.scroll({ left: scrollPosition });
331+
return true;
332+
};
333+
334+
const getAutoScrollProps = () => {
335+
let abortController = new AbortController();
336+
337+
const abort = (reason: string) => {
338+
abortController.abort(reason);
339+
};
340+
341+
return {
342+
abort,
343+
onMouseOver(event: MouseEvent) {
344+
if (event.target === document.activeElement) {
345+
abort("focused");
346+
return;
347+
}
348+
349+
if (scrollAhead(event) === false) {
350+
// Nothing to scroll.
351+
return;
352+
}
353+
354+
abortController = new AbortController();
355+
event.target?.addEventListener(
356+
"mousemove",
357+
(event) => {
358+
if (event.target === document.activeElement) {
359+
abort("focused");
360+
return;
361+
}
362+
requestAnimationFrame(() => {
363+
scrollAhead(event as MouseEvent);
364+
});
365+
},
366+
{
367+
signal: abortController.signal,
368+
passive: true,
369+
}
370+
);
371+
},
372+
onMouseOut(event: MouseEvent) {
373+
if (event.target === document.activeElement) {
374+
abort("focused");
375+
return;
376+
}
377+
(event.target as HTMLInputElement).scroll({
378+
left: 0,
379+
behavior: "smooth",
380+
});
381+
abort("mouseout");
382+
},
383+
onFocus() {
384+
abort("focus");
385+
},
386+
};
387+
};
388+
297389
const Description = styled(Box, { width: theme.spacing[27] });
298390

299391
/**
@@ -724,6 +816,13 @@ export const CssValueInput = ({
724816
handleMetaEnter
725817
);
726818

819+
const { abort, ...autoScrollProps } = useMemo(() => {
820+
return getAutoScrollProps();
821+
}, []);
822+
useEffect(() => {
823+
return () => abort("unmount");
824+
}, [abort]);
825+
727826
return (
728827
<ComboboxRoot open={isOpen}>
729828
<Box {...getComboboxProps()}>
@@ -734,6 +833,7 @@ export const CssValueInput = ({
734833
aria-disabled={ariaDisabled}
735834
fieldSizing={fieldSizing}
736835
{...inputProps}
836+
{...autoScrollProps}
737837
onFocus={() => {
738838
const isFocused = document.activeElement === inputRef.current;
739839
if (isFocused) {

0 commit comments

Comments
 (0)