Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 4 additions & 36 deletions src/components/accordion/BqAccordion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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<BqAccordionProps, BqAccordionState> = {
props: {
label: { type: String, default: '' },
Expand Down Expand Up @@ -164,7 +130,9 @@ const definition: ComponentDefinition<BqAccordionProps, BqAccordionState> = {
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();
Expand Down
30 changes: 29 additions & 1 deletion src/components/dialog/BqDialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -28,6 +29,7 @@ type BqDialogProps = {
dismissible: boolean;
};
type BqDialogState = { titleId: string };
const NORMAL_DURATION = '200ms';

const definition: ComponentDefinition<BqDialogProps, BqDialogState> = {
props: {
Expand Down Expand Up @@ -87,6 +89,7 @@ const definition: ComponentDefinition<BqDialogProps, BqDialogState> = {
getState<T>(k: string): T;
};
const self = this as unknown as BQEl;
const record = self as unknown as Record<string, unknown>;
if (!self.getState<string>('titleId'))
self.setState('titleId', uniqueId('bq-dlg-title'));
// Escape key handler
Expand All @@ -100,10 +103,20 @@ const definition: ComponentDefinition<BqDialogProps, BqDialogState> = {
};
const close = () => {
if (self.hasAttribute('data-closing')) return;
const clearCloseTimer = () => {
const closeTimer = record['_closeTimer'] as
| ReturnType<typeof setTimeout>
| 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(
Expand All @@ -114,7 +127,15 @@ const definition: ComponentDefinition<BqDialogProps, BqDialogState> = {
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) => {
Expand All @@ -136,6 +157,13 @@ const definition: ComponentDefinition<BqDialogProps, BqDialogState> = {
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<typeof setTimeout> | undefined;
if (closeTimer) {
clearTimeout(closeTimer);
delete s['_closeTimer'];
this.removeAttribute('data-closing');
this.removeAttribute('open');
}
},
updated() {
updateOverlayFocus(this, this as unknown as OverlayFocusState, '.dialog');
Expand Down
30 changes: 29 additions & 1 deletion src/components/drawer/BqDrawer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -28,6 +29,7 @@ type BqDrawerProps = {
size: string;
};
type BqDrawerState = { titleId: string };
const NORMAL_DURATION = '200ms';

const definition: ComponentDefinition<BqDrawerProps, BqDrawerState> = {
props: {
Expand Down Expand Up @@ -99,14 +101,25 @@ const definition: ComponentDefinition<BqDrawerProps, BqDrawerState> = {
getState<T>(k: string): T;
};
const self = this as unknown as BQEl;
const record = self as unknown as Record<string, unknown>;
if (!self.getState<string>('titleId'))
self.setState('titleId', uniqueId('bq-drawer-title'));
const close = () => {
if (self.hasAttribute('data-closing')) return;
const clearCloseTimer = () => {
const closeTimer = record['_closeTimer'] as
| ReturnType<typeof setTimeout>
| 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(
Expand All @@ -117,7 +130,15 @@ const definition: ComponentDefinition<BqDrawerProps, BqDrawerState> = {
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) => {
Expand Down Expand Up @@ -147,6 +168,13 @@ const definition: ComponentDefinition<BqDrawerProps, BqDrawerState> = {
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<typeof setTimeout> | undefined;
if (closeTimer) {
clearTimeout(closeTimer);
delete s['_closeTimer'];
this.removeAttribute('data-closing');
this.removeAttribute('open');
}
},
updated() {
updateOverlayFocus(this, this as unknown as OverlayFocusState, '.drawer');
Expand Down
29 changes: 27 additions & 2 deletions src/components/dropdown-menu/BqDropdownMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -24,6 +24,7 @@ type BqDropdownMenuProps = {
disabled: boolean;
};
type BqDropdownMenuState = { uid: string };
const FAST_DURATION = '150ms';

const definition: ComponentDefinition<
BqDropdownMenuProps,
Expand Down Expand Up @@ -89,6 +90,7 @@ const definition: ComponentDefinition<
getState<T>(k: string): T;
};
const self = this as unknown as BQEl;
const record = self as unknown as Record<string, unknown>;
if (!self.getState<string>('uid')) self.setState('uid', uniqueId('bq-dm'));

const getTrigger = (): HTMLElement | null =>
Expand Down Expand Up @@ -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<typeof setTimeout>
| 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();
Expand All @@ -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 = () => {
Expand Down Expand Up @@ -430,11 +448,18 @@ const definition: ComponentDefinition<
const clearTypeaheadBuffer = s['_clearTypeaheadBuffer'] as
| (() => void)
| undefined;
const closeTimer = s['_closeTimer'] as ReturnType<typeof setTimeout> | 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"]')
Expand Down
2 changes: 1 addition & 1 deletion src/components/pagination/BqPagination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ const definition: ComponentDefinition<BqPaginationProps> = {
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);
Expand Down
3 changes: 0 additions & 3 deletions src/components/select/BqSelect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,6 @@ const definition: ComponentDefinition<BqSelectProps, BqSelectState> = {
);
}
};
const syncValue = () => syncSelectValue(self);
const observer = new MutationObserver(() => {
syncOptions();
});
Expand All @@ -210,7 +209,6 @@ const definition: ComponentDefinition<BqSelectProps, BqSelectState> = {
});

(self as unknown as Record<string, unknown>)['_handler'] = handler;
(self as unknown as Record<string, unknown>)['_syncValue'] = syncValue;
(self as unknown as Record<string, unknown>)['_syncOptions'] = syncOptions;
(self as unknown as Record<string, unknown>)['_observer'] = observer;
self.shadowRoot?.addEventListener('change', handler);
Expand All @@ -233,7 +231,6 @@ const definition: ComponentDefinition<BqSelectProps, BqSelectState> = {
proxy.setDisabled(this.hasAttribute('disabled'));
}
(s['_syncOptions'] as (() => void) | undefined)?.();
(s['_syncValue'] as (() => void) | undefined)?.();
},
render({ props, state }) {
const hasError = Boolean(props.error);
Expand Down
4 changes: 1 addition & 3 deletions src/components/textarea/BqTextarea.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,9 +210,7 @@ const definition: ComponentDefinition<BqTextareaProps, BqTextareaState> = {
aria-invalid="${hasError ? 'true' : 'false'}"
${props.required ? 'aria-required="true"' : ''}
${describedBy ? `aria-describedby="${describedBy}"` : ''}
>
${escapeHtml(props.value)}</textarea
>
>${escapeHtml(props.value)}</textarea>
${hasFooter
? `<div class="footer" part="footer">
<span>
Expand Down
1 change: 1 addition & 0 deletions src/components/tooltip/BqTooltip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ const definition: ComponentDefinition<BqTooltipProps, BqTooltipState> = {
self.addEventListener('focusout', hide);
document.addEventListener('keydown', kh);
queueMicrotask(() => {
if (!self.isConnected) return;
const slot = self.shadowRoot?.querySelector(
'slot'
) as HTMLSlotElement | null;
Expand Down
47 changes: 47 additions & 0 deletions src/utils/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,53 @@ export function uniqueId(prefix = 'bq'): string {
return `${prefix}-${++_counter}`;
}

function 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;
}

export function getAnimationTimeoutMs(
el: Element,
fallbackDuration = '0ms'
): number {
const fallbackMs = parseTimeValueToMs(fallbackDuration);
const view = el.ownerDocument.defaultView;
if (!view?.getComputedStyle) return fallbackMs;

const styles = view.getComputedStyle(el);
const animationDuration = styles.animationDuration || '';
const animationDelay = styles.animationDelay || '';
const animationName = styles.animationName || '';
const durationValues = animationDuration.split(',');
const delayValues = animationDelay.split(',');
const hasTimingValues =
durationValues.some((value) => value.trim() !== '') ||
delayValues.some((value) => value.trim() !== '');
const hasRealAnimationName = animationName.split(',').some((name) => {
const trimmed = name.trim();
return trimmed !== '' && trimmed !== 'none';
});

if (!hasTimingValues) return fallbackMs;

const durations = durationValues.map((value) => parseTimeValueToMs(value));
const delays = delayValues.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 (!hasRealAnimationName) return fallbackMs;
return longest;
}

/**
* Manages focus trapping, initial focus, and focus restoration for overlay
* components (Dialog, Drawer). Call from `updated()`.
Expand Down
Loading
Loading