diff --git a/src/components/accordion/BqAccordion.ts b/src/components/accordion/BqAccordion.ts index 92417fc..a717ac4 100644 --- a/src/components/accordion/BqAccordion.ts +++ b/src/components/accordion/BqAccordion.ts @@ -11,7 +11,7 @@ import type { ComponentDefinition } from '@bquery/bquery/component'; import { component, html } from '@bquery/bquery/component'; import { escapeHtml } from '@bquery/bquery/security'; -import { uniqueId } from '../../utils/dom.js'; +import { getAnimationTimeoutMs, uniqueId } from '../../utils/dom.js'; import { getBaseStyles } from '../../utils/styles.js'; type BqAccordionProps = { @@ -24,40 +24,6 @@ type BqAccordionState = { uid: string }; const DEFAULT_SLOW_DURATION = '300ms'; -const parseTimeValueToMs = (value: string): number => { - const trimmed = value.trim(); - if (!trimmed) return 0; - if (trimmed.endsWith('ms')) return Number.parseFloat(trimmed); - if (trimmed.endsWith('s')) return Number.parseFloat(trimmed) * 1000; - return Number.parseFloat(trimmed) || 0; -}; - -const getAnimationTimeoutMs = (el: Element): number => { - const view = el.ownerDocument.defaultView; - if (!view?.getComputedStyle) return parseTimeValueToMs(DEFAULT_SLOW_DURATION); - - const styles = view.getComputedStyle(el); - const durations = styles.animationDuration - .split(',') - .map((value) => parseTimeValueToMs(value)); - const delays = styles.animationDelay - .split(',') - .map((value) => parseTimeValueToMs(value)); - const count = Math.max(durations.length, delays.length); - - let longest = 0; - for (let i = 0; i < count; i += 1) { - const duration = durations[i] ?? durations[durations.length - 1] ?? 0; - const delay = delays[i] ?? delays[delays.length - 1] ?? 0; - longest = Math.max(longest, duration + delay); - } - - if (longest > 0) return longest; - - const variableDuration = styles.getPropertyValue('--bq-duration-slow'); - return parseTimeValueToMs(variableDuration || DEFAULT_SLOW_DURATION); -}; - const definition: ComponentDefinition = { props: { label: { type: String, default: '' }, @@ -164,7 +130,9 @@ const definition: ComponentDefinition = { clearCloseFallback(); const panel = self.shadowRoot?.querySelector('.panel'); - const timeoutMs = panel ? getAnimationTimeoutMs(panel) : 0; + const timeoutMs = panel + ? getAnimationTimeoutMs(panel, DEFAULT_SLOW_DURATION) + : 0; if (timeoutMs <= 0) { finishClose(); diff --git a/src/components/dialog/BqDialog.ts b/src/components/dialog/BqDialog.ts index 3488b5f..5bbf2bd 100644 --- a/src/components/dialog/BqDialog.ts +++ b/src/components/dialog/BqDialog.ts @@ -16,6 +16,7 @@ import { t } from '../../i18n/index.js'; import type { OverlayFocusState } from '../../utils/dom.js'; import { cleanupOverlayFocus, + getAnimationTimeoutMs, uniqueId, updateOverlayFocus, } from '../../utils/dom.js'; @@ -28,6 +29,7 @@ type BqDialogProps = { dismissible: boolean; }; type BqDialogState = { titleId: string }; +const NORMAL_DURATION = '200ms'; const definition: ComponentDefinition = { props: { @@ -87,6 +89,7 @@ const definition: ComponentDefinition = { getState(k: string): T; }; const self = this as unknown as BQEl; + const record = self as unknown as Record; if (!self.getState('titleId')) self.setState('titleId', uniqueId('bq-dlg-title')); // Escape key handler @@ -100,10 +103,20 @@ const definition: ComponentDefinition = { }; const close = () => { if (self.hasAttribute('data-closing')) return; + const clearCloseTimer = () => { + const closeTimer = record['_closeTimer'] as + | ReturnType + | undefined; + if (closeTimer) { + clearTimeout(closeTimer); + delete record['_closeTimer']; + } + }; const reducedMotion = self.ownerDocument.defaultView?.matchMedia?.( '(prefers-reduced-motion: reduce)' )?.matches; const finalize = () => { + clearCloseTimer(); self.removeAttribute('data-closing'); self.removeAttribute('open'); self.dispatchEvent( @@ -114,7 +127,15 @@ const definition: ComponentDefinition = { finalize(); } else { self.setAttribute('data-closing', ''); - setTimeout(finalize, 200); + const overlay = self.shadowRoot?.querySelector('.overlay'); + const timeoutMs = overlay + ? getAnimationTimeoutMs(overlay, NORMAL_DURATION) + : 0; + if (timeoutMs <= 0) { + finalize(); + } else { + record['_closeTimer'] = setTimeout(finalize, Math.ceil(timeoutMs) + 20); + } } }; const ch = (e: Event) => { @@ -136,6 +157,13 @@ const definition: ComponentDefinition = { if (oh) this.shadowRoot?.removeEventListener('click', oh); const ch = s['_ch'] as EventListener | undefined; if (ch) this.shadowRoot?.removeEventListener('click', ch); + const closeTimer = s['_closeTimer'] as ReturnType | undefined; + if (closeTimer) { + clearTimeout(closeTimer); + delete s['_closeTimer']; + this.removeAttribute('data-closing'); + this.removeAttribute('open'); + } }, updated() { updateOverlayFocus(this, this as unknown as OverlayFocusState, '.dialog'); diff --git a/src/components/drawer/BqDrawer.ts b/src/components/drawer/BqDrawer.ts index c73318f..b785d0f 100644 --- a/src/components/drawer/BqDrawer.ts +++ b/src/components/drawer/BqDrawer.ts @@ -16,6 +16,7 @@ import { t } from '../../i18n/index.js'; import type { OverlayFocusState } from '../../utils/dom.js'; import { cleanupOverlayFocus, + getAnimationTimeoutMs, uniqueId, updateOverlayFocus, } from '../../utils/dom.js'; @@ -28,6 +29,7 @@ type BqDrawerProps = { size: string; }; type BqDrawerState = { titleId: string }; +const NORMAL_DURATION = '200ms'; const definition: ComponentDefinition = { props: { @@ -99,14 +101,25 @@ const definition: ComponentDefinition = { getState(k: string): T; }; const self = this as unknown as BQEl; + const record = self as unknown as Record; if (!self.getState('titleId')) self.setState('titleId', uniqueId('bq-drawer-title')); const close = () => { if (self.hasAttribute('data-closing')) return; + const clearCloseTimer = () => { + const closeTimer = record['_closeTimer'] as + | ReturnType + | undefined; + if (closeTimer) { + clearTimeout(closeTimer); + delete record['_closeTimer']; + } + }; const reducedMotion = self.ownerDocument.defaultView?.matchMedia?.( '(prefers-reduced-motion: reduce)' )?.matches; const finalize = () => { + clearCloseTimer(); self.removeAttribute('data-closing'); self.removeAttribute('open'); self.dispatchEvent( @@ -117,7 +130,15 @@ const definition: ComponentDefinition = { finalize(); } else { self.setAttribute('data-closing', ''); - setTimeout(finalize, 200); + const drawer = self.shadowRoot?.querySelector('.drawer'); + const timeoutMs = drawer + ? getAnimationTimeoutMs(drawer, NORMAL_DURATION) + : 0; + if (timeoutMs <= 0) { + finalize(); + } else { + record['_closeTimer'] = setTimeout(finalize, Math.ceil(timeoutMs) + 20); + } } }; const kh = (e: Event) => { @@ -147,6 +168,13 @@ const definition: ComponentDefinition = { if (bh) this.shadowRoot?.removeEventListener('click', bh); const ch = s['_ch'] as EventListener | undefined; if (ch) this.shadowRoot?.removeEventListener('click', ch); + const closeTimer = s['_closeTimer'] as ReturnType | undefined; + if (closeTimer) { + clearTimeout(closeTimer); + delete s['_closeTimer']; + this.removeAttribute('data-closing'); + this.removeAttribute('open'); + } }, updated() { updateOverlayFocus(this, this as unknown as OverlayFocusState, '.drawer'); diff --git a/src/components/dropdown-menu/BqDropdownMenu.ts b/src/components/dropdown-menu/BqDropdownMenu.ts index 221e8d6..dcca995 100644 --- a/src/components/dropdown-menu/BqDropdownMenu.ts +++ b/src/components/dropdown-menu/BqDropdownMenu.ts @@ -14,7 +14,7 @@ import type { ComponentDefinition } from '@bquery/bquery/component'; import { component, html } from '@bquery/bquery/component'; import { escapeHtml } from '@bquery/bquery/security'; -import { uniqueId } from '../../utils/dom.js'; +import { getAnimationTimeoutMs, uniqueId } from '../../utils/dom.js'; import { getBaseStyles } from '../../utils/styles.js'; type BqDropdownMenuProps = { @@ -24,6 +24,7 @@ type BqDropdownMenuProps = { disabled: boolean; }; type BqDropdownMenuState = { uid: string }; +const FAST_DURATION = '150ms'; const definition: ComponentDefinition< BqDropdownMenuProps, @@ -89,6 +90,7 @@ const definition: ComponentDefinition< getState(k: string): T; }; const self = this as unknown as BQEl; + const record = self as unknown as Record; if (!self.getState('uid')) self.setState('uid', uniqueId('bq-dm')); const getTrigger = (): HTMLElement | null => @@ -172,10 +174,20 @@ const definition: ComponentDefinition< if (!self.hasAttribute('open') || self.hasAttribute('data-closing')) return; clearTypeaheadBuffer(); + const clearCloseTimer = () => { + const closeTimer = record['_closeTimer'] as + | ReturnType + | undefined; + if (closeTimer) { + clearTimeout(closeTimer); + delete record['_closeTimer']; + } + }; const reducedMotion = self.ownerDocument.defaultView?.matchMedia?.( '(prefers-reduced-motion: reduce)' )?.matches; const finalize = () => { + clearCloseTimer(); self.removeAttribute('data-closing'); self.removeAttribute('open'); syncTriggerA11y(); @@ -188,7 +200,13 @@ const definition: ComponentDefinition< finalize(); } else { self.setAttribute('data-closing', ''); - setTimeout(finalize, 150); + const menu = self.shadowRoot?.querySelector('.menu'); + const timeoutMs = menu ? getAnimationTimeoutMs(menu, FAST_DURATION) : 0; + if (timeoutMs <= 0) { + finalize(); + } else { + record['_closeTimer'] = setTimeout(finalize, Math.ceil(timeoutMs) + 20); + } } }; const toggle = () => { @@ -430,11 +448,18 @@ const definition: ComponentDefinition< const clearTypeaheadBuffer = s['_clearTypeaheadBuffer'] as | (() => void) | undefined; + const closeTimer = s['_closeTimer'] as ReturnType | undefined; if (triggerHandler) this.removeEventListener('click', triggerHandler); if (menuClickHandler) this.removeEventListener('click', menuClickHandler); if (keyHandler) this.removeEventListener('keydown', keyHandler); if (outsideHandler) document.removeEventListener('click', outsideHandler); clearTypeaheadBuffer?.(); + if (closeTimer) { + clearTimeout(closeTimer); + delete s['_closeTimer']; + this.removeAttribute('data-closing'); + this.removeAttribute('open'); + } if (slotChangeHandler) { this.shadowRoot ?.querySelector('slot[name="trigger"]') diff --git a/src/components/pagination/BqPagination.ts b/src/components/pagination/BqPagination.ts index 6b67884..af67811 100644 --- a/src/components/pagination/BqPagination.ts +++ b/src/components/pagination/BqPagination.ts @@ -101,7 +101,7 @@ const definition: ComponentDefinition = { if (h) this.shadowRoot?.removeEventListener('click', h); }, render({ props }) { - const page = props.page; + const page = Math.max(1, Math.min(props.page, props.total || 1)); const total = props.total; const siblings = props['sibling-count']; const pages = buildPages(page, total, siblings); diff --git a/src/components/select/BqSelect.ts b/src/components/select/BqSelect.ts index 606116c..229301c 100644 --- a/src/components/select/BqSelect.ts +++ b/src/components/select/BqSelect.ts @@ -197,7 +197,6 @@ const definition: ComponentDefinition = { ); } }; - const syncValue = () => syncSelectValue(self); const observer = new MutationObserver(() => { syncOptions(); }); @@ -210,7 +209,6 @@ const definition: ComponentDefinition = { }); (self as unknown as Record)['_handler'] = handler; - (self as unknown as Record)['_syncValue'] = syncValue; (self as unknown as Record)['_syncOptions'] = syncOptions; (self as unknown as Record)['_observer'] = observer; self.shadowRoot?.addEventListener('change', handler); @@ -233,7 +231,6 @@ const definition: ComponentDefinition = { proxy.setDisabled(this.hasAttribute('disabled')); } (s['_syncOptions'] as (() => void) | undefined)?.(); - (s['_syncValue'] as (() => void) | undefined)?.(); }, render({ props, state }) { const hasError = Boolean(props.error); diff --git a/src/components/textarea/BqTextarea.ts b/src/components/textarea/BqTextarea.ts index d98aa2b..f7988ac 100644 --- a/src/components/textarea/BqTextarea.ts +++ b/src/components/textarea/BqTextarea.ts @@ -210,9 +210,7 @@ const definition: ComponentDefinition = { aria-invalid="${hasError ? 'true' : 'false'}" ${props.required ? 'aria-required="true"' : ''} ${describedBy ? `aria-describedby="${describedBy}"` : ''} - > -${escapeHtml(props.value)} + >${escapeHtml(props.value)} ${hasFooter ? `