66
77let initialized = false ;
88
9+ /** Avoid rAF loops: if a group has 0-size layout, wait for resize instead. */
10+ const pendingResizeObservers = new WeakMap < HTMLElement , ResizeObserver > ( ) ;
11+
12+ function getCheckedLabel ( group : HTMLElement ) : HTMLElement | null {
13+ const checkedInput = group . querySelector < HTMLInputElement > ( 'input[type="radio"]:checked' ) ;
14+ return checkedInput ?. closest < HTMLElement > ( '.toggle-option' ) ?. querySelector < HTMLElement > ( '.toggle-label' ) ?? null ;
15+ }
16+
17+ function ensureResizeObserver ( group : HTMLElement ) : void {
18+ if ( pendingResizeObservers . has ( group ) ) return ;
19+
20+ const observer = new ResizeObserver ( ( ) => {
21+ // Once the group has measurable size, update to the currently checked label and stop observing.
22+ const rect = group . getBoundingClientRect ( ) ;
23+ if ( rect . width === 0 ) return ;
24+
25+ const label = getCheckedLabel ( group ) ;
26+ if ( ! label ) return ;
27+
28+ observer . disconnect ( ) ;
29+ pendingResizeObservers . delete ( group ) ;
30+ updateSliderPosition ( group , label ) ;
31+ } ) ;
32+
33+ pendingResizeObservers . set ( group , observer ) ;
34+ observer . observe ( group ) ;
35+ }
36+
937/**
10- * Updates the sliding indicator position and size for a toggle group
38+ * Updates the sliding indicator position and size for a toggle group.
39+ * Handles hidden containers and steady-state CSS scale transforms.
1140 */
1241function updateSliderPosition ( group : HTMLElement , targetLabel : HTMLElement ) : void {
1342 const slider = group . querySelector < HTMLElement > ( '.toggle-slider' ) ;
@@ -16,10 +45,33 @@ function updateSliderPosition(group: HTMLElement, targetLabel: HTMLElement): voi
1645 const groupRect = group . getBoundingClientRect ( ) ;
1746 const labelRect = targetLabel . getBoundingClientRect ( ) ;
1847
19- // Calculate position relative to the group's padding (0.25rem = 4px at default scale)
20- // The slider starts at left: 0.25rem, so we need to offset from there
21- const left = labelRect . left - groupRect . left - 4 ; // Subtract the initial 0.25rem offset
22- const width = labelRect . width ;
48+ // If completely unmeasurable (e.g. display:none), wait for layout to exist (no rAF loop).
49+ if ( labelRect . width === 0 || groupRect . width === 0 ) {
50+ ensureResizeObserver ( group ) ;
51+ return ;
52+ }
53+
54+ // Detect X scale from transforms (e.g. dialog open/close scale animation).
55+ // Important: transforms affect getBoundingClientRect() results but NOT computed styles.
56+ // We convert measured (scaled) distances back into the element’s unscaled coordinate space.
57+ const scaleXRaw = group . offsetWidth > 0 ? groupRect . width / group . offsetWidth : 1 ;
58+ const scaleX = Number . isFinite ( scaleXRaw ) && scaleXRaw > 0 ? scaleXRaw : 1 ;
59+
60+ // Calculate position relative to the slider's inline start (respects padding/RTL)
61+ const groupStyles = getComputedStyle ( group ) ;
62+ const sliderStyles = getComputedStyle ( slider ) ;
63+ const offsetCandidates = [
64+ parseFloat ( sliderStyles . insetInlineStart || '' ) ,
65+ parseFloat ( sliderStyles . left || '' ) ,
66+ parseFloat ( groupStyles . paddingInlineStart || '' ) ,
67+ parseFloat ( groupStyles . paddingLeft || '' ) ,
68+ ] ;
69+ const startOffset = offsetCandidates . find ( ( value ) => Number . isFinite ( value ) ) ?? 0 ;
70+
71+ // Convert scaled viewport-space distances back to unscaled local px.
72+ const deltaXScaled = labelRect . left - groupRect . left ;
73+ const left = deltaXScaled / scaleX - startOffset ;
74+ const width = labelRect . width / scaleX ;
2375
2476 slider . style . transform = `translateX(${ left } px)` ;
2577 slider . style . width = `${ width } px` ;
@@ -33,20 +85,17 @@ function initToggleGroup(group: HTMLElement): void {
3385 if ( ! slider ) return ;
3486
3587 // Position slider on the initially checked option
36- const checkedInput = group . querySelector < HTMLInputElement > ( 'input[type="radio"]:checked' ) ;
37- if ( checkedInput ) {
38- const label = checkedInput . closest < HTMLElement > ( '.toggle-option' ) ?. querySelector < HTMLElement > ( '.toggle-label' ) ;
39- if ( label ) {
40- // Disable transitions temporarily for initial positioning
41- slider . style . transition = 'none' ;
42- updateSliderPosition ( group , label ) ;
43- // Mark as initialized to trigger opacity transition
44- slider . setAttribute ( 'data-initialized' , 'true' ) ;
45- // Force reflow
46- void slider . offsetHeight ;
47- // Re-enable transitions
48- slider . style . transition = '' ;
49- }
88+ const label = getCheckedLabel ( group ) ;
89+ if ( label ) {
90+ // Disable transitions temporarily for initial positioning
91+ slider . style . transition = 'none' ;
92+ updateSliderPosition ( group , label ) ;
93+ // Mark as initialized to trigger opacity transition
94+ slider . setAttribute ( 'data-initialized' , 'true' ) ;
95+ // Force reflow
96+ void slider . offsetHeight ;
97+ // Re-enable transitions
98+ slider . style . transition = '' ;
5099 }
51100}
52101
@@ -58,6 +107,19 @@ export function initToggleGroups(): void {
58107 const groups = document . querySelectorAll < HTMLElement > ( '[data-toggle-group]' ) ;
59108 groups . forEach ( initToggleGroup ) ;
60109
110+ // Listen for programmatic value changes via custom event
111+ // Usage: document.dispatchEvent(new CustomEvent('toggle-set-value', { detail: { name: 'group-name', value: 'option-value' } }))
112+ document . addEventListener ( 'toggle-set-value' , ( ( e : CustomEvent < { name : string ; value : string } > ) => {
113+ const { name, value } = e . detail ;
114+ const radio = document . querySelector < HTMLInputElement > (
115+ `[data-toggle-group="${ name } "] input[value="${ value } "]`
116+ ) ;
117+ if ( radio ) {
118+ radio . checked = true ;
119+ radio . dispatchEvent ( new Event ( 'change' , { bubbles : true } ) ) ;
120+ }
121+ } ) as EventListener ) ;
122+
61123 // Handle toggle changes
62124 document . addEventListener ( 'change' , ( e ) => {
63125 const target = e . target ;
0 commit comments