Skip to content

Commit 3a0ed4d

Browse files
tuguirazvanclaude
andcommitted
Slide a shared highlight between Add Fields buttons on hover
Each button faded in its own background, so nothing carried the eye between them. One element now travels to the hovered button, with travel time scaled to distance so a fast sweep neither lags behind nor stays dimmed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 30b6371 commit 3a0ed4d

5 files changed

Lines changed: 203 additions & 2 deletions

File tree

css/frm_admin.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

js/formidable_admin.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

js/src/admin/admin.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66
const { validateField } = require( './settings/validateField' );
77
const { getRangeSettingsDefaults, validateNumberRangeSetting, validateStepSetting, validateRangeSettings } = require( './settings/validateRangeSettings' );
8+
const { initFieldListHoverPill } = require( './fieldListHoverPill' );
89

910
window.FrmFormsConnect = window.FrmFormsConnect || ( function( document, window, $ ) {
1011
const el = {
@@ -10888,6 +10889,7 @@ window.frmAdminBuildJS = function() {
1088810889
setupSortable( 'ul.frm_sorting' );
1088910890

1089010891
document.querySelectorAll( '.field_type_list > li:not(.frm_show_upgrade):not(.frm_show_update)' ).forEach( makeDraggable );
10892+
initFieldListHoverPill();
1089110893

1089210894
jQuery( 'ul.field_type_list, .field_type_list li, ul.frm_code_list, .frm_code_list li, .frm_code_list li a, #frm_adv_info #category-tabs li, #frm_adv_info #category-tabs li a' ).disableSelection();
1089310895

js/src/admin/fieldListHoverPill.js

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/**
2+
* Field list hover pill.
3+
*
4+
* One shared element slides to whichever field button is hovered. The per-button hover in
5+
* _insert-fields.scss remains as the no-JS fallback.
6+
*
7+
* @since x.x
8+
*/
9+
10+
const PILL_CLASS = 'frm-insert-fields-hover';
11+
const ACTIVE_CLASS = 'frm-has-hover-pill';
12+
const ANIMATING_CLASS = 'frm-animating';
13+
14+
/**
15+
* Travel time bounds, in milliseconds.
16+
*/
17+
const MIN_TRAVEL = 120;
18+
const MAX_TRAVEL = 300;
19+
const TRAVEL_PER_PX = 0.9;
20+
21+
/**
22+
* Travels at or above this dim while moving. Shorter hops keep full opacity.
23+
*
24+
* @type {number}
25+
*/
26+
const DIM_ABOVE_TRAVEL = 200;
27+
28+
/**
29+
* Grace period on the fallback un-dim timer, in milliseconds.
30+
*
31+
* @type {number}
32+
*/
33+
const ANIMATING_GRACE = 60;
34+
35+
/**
36+
* Adds the sliding hover pill to the Add Fields list.
37+
*
38+
* @since x.x
39+
*
40+
* @return {void}
41+
*/
42+
export function initFieldListHoverPill() {
43+
const list = document.querySelector( '#frm-insert-fields .field_type_list' );
44+
45+
if ( ! list || list.classList.contains( ACTIVE_CLASS ) ) {
46+
return;
47+
}
48+
49+
const pill = document.createElement( 'div' );
50+
pill.classList.add( PILL_CLASS, 'frm_hidden' );
51+
list.append( pill );
52+
list.classList.add( ACTIVE_CLASS );
53+
54+
let animatingTimeout;
55+
let currentX = null;
56+
let currentY = null;
57+
58+
/**
59+
* Ends the dim on transitionend rather than a fixed delay, so a fast sweep across several
60+
* cards cannot leave the pill stuck dimmed.
61+
*
62+
* @return {void}
63+
*/
64+
const settle = () => {
65+
clearTimeout( animatingTimeout );
66+
pill.classList.remove( ANIMATING_CLASS );
67+
};
68+
69+
pill.addEventListener( 'transitionend', event => {
70+
if ( 'transform' === event.propertyName ) {
71+
settle();
72+
}
73+
} );
74+
75+
/**
76+
* Moves the pill over a field button, matching its size and position.
77+
*
78+
* @param {HTMLElement} button The hovered anchor.
79+
* @return {void}
80+
*/
81+
const moveTo = button => {
82+
const listRect = list.getBoundingClientRect();
83+
const buttonRect = button.getBoundingClientRect();
84+
const x = buttonRect.left - listRect.left;
85+
const y = buttonRect.top - listRect.top;
86+
const isFirstShow = null === currentX;
87+
const distance = isFirstShow ? 0 : Math.hypot( x - currentX, y - currentY );
88+
const travel = Math.min( MAX_TRAVEL, Math.max( MIN_TRAVEL, Math.round( distance * TRAVEL_PER_PX ) ) );
89+
90+
currentX = x;
91+
currentY = y;
92+
93+
pill.style.setProperty( '--frm-pill-travel', `${ travel }ms` );
94+
pill.style.width = `${ buttonRect.width }px`;
95+
pill.style.height = `${ buttonRect.height }px`;
96+
pill.style.transform = `translate(${ x }px, ${ y }px)`;
97+
98+
pill.classList.remove( 'frm_hidden' );
99+
100+
if ( isFirstShow || travel < DIM_ABOVE_TRAVEL ) {
101+
settle();
102+
return;
103+
}
104+
105+
pill.classList.add( ANIMATING_CLASS );
106+
107+
// Fallback for when transitionend does not fire, e.g. an interrupted or zero-length travel.
108+
clearTimeout( animatingTimeout );
109+
animatingTimeout = setTimeout( settle, travel + ANIMATING_GRACE );
110+
};
111+
112+
/**
113+
* Hides the pill.
114+
*
115+
* @return {void}
116+
*/
117+
const hide = () => {
118+
settle();
119+
pill.classList.add( 'frm_hidden' );
120+
121+
// Hidden means display:none, so the next appearance jumps. Forgetting the position
122+
// stops that jump being measured as a long travel.
123+
currentX = null;
124+
currentY = null;
125+
};
126+
127+
// Delegated so field buttons added after load are covered too.
128+
list.addEventListener( 'mouseover', event => {
129+
const button = event.target.closest( 'li.frmbutton > a' );
130+
131+
if ( ! button || button.classList.contains( 'disabled' ) ) {
132+
hide();
133+
return;
134+
}
135+
136+
moveTo( button );
137+
} );
138+
139+
list.addEventListener( 'mouseleave', hide );
140+
141+
// Dragging a field lifts it away from the cursor, leaving the pill with nothing to sit under.
142+
list.addEventListener( 'mousedown', hide );
143+
}

resources/scss/admin/components/builder/_insert-fields.scss

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,3 +241,59 @@ input[disabled] {
241241
.frmbutton.frm_at_limit {
242242
opacity: 0.5;
243243
}
244+
245+
/**
246+
* Hover pill
247+
*
248+
* Created by initFieldListHoverPill(), which also adds .frm-has-hover-pill. Without it the
249+
* per-button hover above still applies.
250+
*/
251+
.field_type_list {
252+
position: relative;
253+
}
254+
255+
.frm-insert-fields-hover {
256+
position: absolute;
257+
top: 0;
258+
left: 0;
259+
z-index: 0;
260+
border-radius: var(--small-radius);
261+
background: #fff;
262+
box-shadow: var(--box-shadow-md);
263+
pointer-events: none;
264+
will-change: transform;
265+
266+
/* Set per move by initFieldListHoverPill(), scaled to the distance. */
267+
--frm-pill-travel: 300ms;
268+
269+
transition:
270+
0.15s opacity ease,
271+
var(--frm-pill-travel) transform cubic-bezier(0.165, 0.84, 0.44, 1),
272+
var(--frm-pill-travel) width cubic-bezier(0.165, 0.84, 0.44, 1),
273+
var(--frm-pill-travel) height cubic-bezier(0.165, 0.84, 0.44, 1);
274+
}
275+
276+
.frm-insert-fields-hover.frm-animating {
277+
opacity: 0.45;
278+
}
279+
280+
.field_type_list.frm-has-hover-pill {
281+
282+
li.frmbutton a {
283+
position: relative;
284+
z-index: 1;
285+
}
286+
287+
/* The pill paints the hover state now, so the button stops painting its own. */
288+
li.frmbutton a:not(.disabled):hover {
289+
background: transparent;
290+
box-shadow: none;
291+
}
292+
}
293+
294+
@media (prefers-reduced-motion: reduce) {
295+
296+
.frm-insert-fields-hover {
297+
transition: none;
298+
}
299+
}

0 commit comments

Comments
 (0)