mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
Flatten workspace (#120)
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Converts a `NamedNodeMap` to a plain object.
|
||||
*/
|
||||
export function namedNodeMapToObject(namedNodeMap: NamedNodeMap): Record<string, string> {
|
||||
const obj: Record<string, string> = {};
|
||||
|
||||
for (const attr of namedNodeMap) {
|
||||
obj[attr.name] = attr.value;
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets multiple attributes on an element and handles boolean attributes appropriately.
|
||||
*
|
||||
* @param element - The element to set attributes on.
|
||||
* @param attributes - The attributes to set.
|
||||
*/
|
||||
export function setAttributes(element: HTMLElement, attributes: Record<string, string>): void {
|
||||
for (const [key, value] of Object.entries(attributes)) {
|
||||
if (typeof value === 'boolean') {
|
||||
element.toggleAttribute(key, value);
|
||||
} else {
|
||||
element.setAttribute(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Get the active element, accounting for Shadow DOM subtrees.
|
||||
*
|
||||
* @param root - The root node to search for the active element.
|
||||
*/
|
||||
export function activeElement(
|
||||
root: Document = document,
|
||||
): Element | null {
|
||||
let element = root.activeElement;
|
||||
|
||||
while (element?.shadowRoot?.activeElement != null) {
|
||||
element = element.shadowRoot.activeElement;
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
export function getDocument(node: Element | null): Document {
|
||||
return node?.ownerDocument ?? document;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export function isOutsideEvent(event: FocusEvent, container?: Element): boolean {
|
||||
const containerElement = container || (event.currentTarget as Element);
|
||||
const relatedTarget = event.relatedTarget as HTMLElement | null;
|
||||
return !relatedTarget || !containerElement.contains(relatedTarget);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './attributes';
|
||||
export * from './element';
|
||||
export * from './event';
|
||||
export * from './keyboard';
|
||||
export * from './shadow-dom';
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { FocusableElement } from 'tabbable';
|
||||
import { tabbable } from 'tabbable';
|
||||
import { activeElement, getDocument } from './element';
|
||||
|
||||
export function getTabbableOptions(): Readonly<{
|
||||
getShadowRoot: boolean;
|
||||
displayCheck: 'full' | 'none';
|
||||
}> {
|
||||
// JSDOM does not support the `tabbable` library. To solve this we can
|
||||
// check if `ResizeObserver` is a real function (not polyfilled), which
|
||||
// determines if the current environment is JSDOM-like.
|
||||
const isNativeResizeObserver: boolean = typeof ResizeObserver === 'function' && ResizeObserver.toString().includes('[native code]');
|
||||
const displayCheck: 'full' | 'none' = isNativeResizeObserver ? 'full' : 'none';
|
||||
|
||||
return ({
|
||||
getShadowRoot: true,
|
||||
displayCheck: displayCheck as 'full' | 'none',
|
||||
}) as const;
|
||||
}
|
||||
|
||||
function getTabbableIn(container: HTMLElement, dir: 1 | -1): FocusableElement | undefined {
|
||||
const list = tabbable(container, getTabbableOptions());
|
||||
const len = list.length;
|
||||
if (len === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const active = activeElement(getDocument(container)) as FocusableElement;
|
||||
const index = list.indexOf(active);
|
||||
|
||||
const nextIndex = index === -1 ? (dir === 1 ? 0 : len - 1) : index + dir;
|
||||
|
||||
return list[nextIndex];
|
||||
}
|
||||
|
||||
export function getNextTabbable(referenceElement: Element | null): FocusableElement | null {
|
||||
return (
|
||||
getTabbableIn(getDocument(referenceElement).body, 1) || (referenceElement as FocusableElement)
|
||||
);
|
||||
}
|
||||
|
||||
export function getPreviousTabbable(referenceElement: Element | null): FocusableElement | null {
|
||||
return (
|
||||
getTabbableIn(getDocument(referenceElement).body, -1) || (referenceElement as FocusableElement)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Utility function to check if a root node contains a child node across shadow DOM boundaries.
|
||||
*/
|
||||
export function containsComposedNode(rootNode: Node, childNode: Node): boolean {
|
||||
if (!rootNode || !childNode) return false;
|
||||
if (rootNode?.contains(childNode)) return true;
|
||||
const childRootNode = childNode.getRootNode();
|
||||
if (childRootNode && 'host' in childRootNode && childRootNode.host) {
|
||||
return containsComposedNode(rootNode, childRootNode.host as Node);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './shared/crypto';
|
||||
export * from './shared/state';
|
||||
export * from './shared/time';
|
||||
export * from './shared/unit';
|
||||
@@ -0,0 +1,9 @@
|
||||
let id = 0;
|
||||
|
||||
/**
|
||||
* Generates a unique ID for an element.
|
||||
*/
|
||||
export function uniqueId(): string {
|
||||
id++;
|
||||
return `:h${id}:`;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Slightly modified version of React's shallowEqual, with optimizations for Arrays
|
||||
* so we may treat them specifically as unequal if they are not a) both arrays
|
||||
* or b) don't contain the same (shallowly compared) elements.
|
||||
*/
|
||||
export function shallowEqual(objA: any, objB: any): boolean {
|
||||
// Using Object.is as a first pass, as it covers a lot of the "simple" cases that are
|
||||
// more complex than strict equality and is a built-in. For discussion, see, e.g.:
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is#description
|
||||
if (Object.is(objA, objB)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Since we've done an Object.is() check immediately above, we can safely assume non-objects (or null-valued objects)
|
||||
// are not equal, so can early bail for those as well.
|
||||
if (typeof objA !== 'object' || objA === null || typeof objB !== 'object' || objB === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Array.isArray(objA)) {
|
||||
// Early "cheap" array compares
|
||||
if (!Array.isArray(objB) || objA.length !== objB.length) return false;
|
||||
// Shallow compare for arrays
|
||||
return objA.every((vVal, i) => objB[i] === vVal);
|
||||
}
|
||||
|
||||
const keysA = Object.keys(objA);
|
||||
const keysB = Object.keys(objB);
|
||||
|
||||
if (keysA.length !== keysB.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Test for A's keys different from B.
|
||||
for (let i = 0; i < keysA.length; i++) {
|
||||
// NOTE: Since we've already guaranteed the keys list lengths are the same, we can safely cast to string here (CJP)
|
||||
if (
|
||||
!globalThis.hasOwnProperty.call(objB, keysA[i] as string)
|
||||
|| !Object.is(objA[keysA[i] as string], objB[keysA[i] as string])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
|
||||
/**
|
||||
* Checks if a value is a valid number (not NaN, null, undefined, or Infinity)
|
||||
*/
|
||||
function isValidNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && isFinite(value);
|
||||
}
|
||||
|
||||
const UnitLabels = [
|
||||
{
|
||||
singular: 'hour',
|
||||
plural: 'hours',
|
||||
},
|
||||
{
|
||||
singular: 'minute',
|
||||
plural: 'minutes',
|
||||
},
|
||||
{
|
||||
singular: 'second',
|
||||
plural: 'seconds',
|
||||
},
|
||||
] as const;
|
||||
|
||||
function toTimeUnitPhrase(timeUnitValue: number, unitIndex: number): string {
|
||||
const unitLabel = timeUnitValue === 1 ? UnitLabels[unitIndex]?.singular : UnitLabels[unitIndex]?.plural;
|
||||
|
||||
return `${timeUnitValue} ${unitLabel}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts numeric seconds into a human-readable phrase for accessibility.
|
||||
*
|
||||
* @param seconds - A (positive or negative) time, represented as seconds
|
||||
* @returns The time, represented as a phrase of hours, minutes, and seconds
|
||||
*
|
||||
* @example
|
||||
* formatAsTimePhrase(3661) // "1 hour, 1 minute, 1 second"
|
||||
* formatAsTimePhrase(90) // "1 minute, 30 seconds"
|
||||
* formatAsTimePhrase(-30) // "30 seconds remaining"
|
||||
*/
|
||||
export function formatAsTimePhrase(seconds: number): string {
|
||||
if (!isValidNumber(seconds)) return '';
|
||||
|
||||
const positiveSeconds = Math.abs(seconds);
|
||||
const negative = positiveSeconds !== seconds;
|
||||
const secondsDateTime = new Date(0, 0, 0, 0, 0, positiveSeconds, 0);
|
||||
const timeParts = [secondsDateTime.getHours(), secondsDateTime.getMinutes(), secondsDateTime.getSeconds()];
|
||||
|
||||
const timeString = timeParts
|
||||
// Convert non-0 values to a string of the value plus its unit
|
||||
.map((timeUnitValue, index) => timeUnitValue && toTimeUnitPhrase(timeUnitValue, index))
|
||||
// Ignore/exclude any 0 values
|
||||
.filter(x => x)
|
||||
// join into a single comma-separated string phrase
|
||||
.join(', ');
|
||||
|
||||
// If the time was negative, assume it represents some remaining amount of time/"count down".
|
||||
const negativeSuffix = negative ? ' remaining' : '';
|
||||
|
||||
return `${timeString}${negativeSuffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a time, in numeric seconds, to a formatted string representation
|
||||
* of the form [HH:[MM:]]SS, where hours and minutes are optional, either
|
||||
* based on the value of `seconds` or (optionally) based on the value of `guide`.
|
||||
*
|
||||
* @param seconds - The total time you'd like formatted, in seconds
|
||||
* @param guide - A number in seconds that represents how many units you'd want
|
||||
* to show. This ensures consistent formatting between e.g. 35s and 4834s.
|
||||
* @returns A string representation of the time, with expected units
|
||||
*
|
||||
* @example
|
||||
* formatTime(90) // "1:30"
|
||||
* formatTime(3661) // "1:01:01"
|
||||
* formatTime(35, 3600) // "0:35" (guided by 1-hour duration)
|
||||
* formatTime(NaN) // "0:00"
|
||||
* formatTime(Infinity) // "0:00"
|
||||
*/
|
||||
export function formatTime(seconds: number, guide?: number): string {
|
||||
// Handle negative values
|
||||
let negative = false;
|
||||
|
||||
if (seconds < 0) {
|
||||
negative = true;
|
||||
seconds = 0 - seconds;
|
||||
}
|
||||
|
||||
seconds = seconds < 0 ? 0 : seconds;
|
||||
|
||||
let s: number | string = Math.floor(seconds % 60);
|
||||
let m: number | string = Math.floor((seconds / 60) % 60);
|
||||
let h: number | string = Math.floor(seconds / 3600);
|
||||
|
||||
const gm = guide ? Math.floor((guide / 60) % 60) : 0;
|
||||
const gh = guide ? Math.floor(guide / 3600) : 0;
|
||||
|
||||
// Handle invalid times
|
||||
if (isNaN(seconds) || seconds === Infinity) {
|
||||
// '-' is false for all relational operators (e.g. <, >=) so this setting
|
||||
// will add the minimum number of fields specified by the guide
|
||||
h = m = s = '0';
|
||||
}
|
||||
|
||||
// Check if we need to show hours
|
||||
const showHours = (h as number) > 0 || gh > 0;
|
||||
const hoursString = showHours ? `${h}:` : '';
|
||||
|
||||
// If hours are showing, we may need to add a leading zero.
|
||||
// Always show at least one digit of minutes.
|
||||
const minutesString = `${(showHours || gm >= 10) && (m as number) < 10 ? `0${m}` : m}:`;
|
||||
|
||||
// Check if leading zero is needed for seconds
|
||||
const secondsString = (s as number) < 10 ? `0${s}` : s;
|
||||
|
||||
return (negative ? '-' : '') + hoursString + minutesString + secondsString;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a time value with fallback handling for invalid values.
|
||||
*
|
||||
* @param time - The time value to format in seconds (duration, currentTime, etc.)
|
||||
* @param guide - Optional guide time for consistent formatting
|
||||
* @param fallback - Fallback text when time is invalid (default: "--:--")
|
||||
* @returns Formatted time string or fallback
|
||||
*/
|
||||
export function formatDisplayTime(time: unknown, guide?: number, fallback: string = '--:--'): string {
|
||||
if (!isValidNumber(time)) {
|
||||
return fallback;
|
||||
}
|
||||
return formatTime(time, guide);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function isValidNumber(value: any): value is number {
|
||||
return typeof value === 'number' && !Number.isNaN(value) && Number.isFinite(value);
|
||||
}
|
||||
Reference in New Issue
Block a user