INIT
This commit is contained in:
+12
@@ -0,0 +1,12 @@
|
||||
import { type ReadableBoxedValues } from "svelte-toolbelt";
|
||||
interface AnimationsCompleteOpts extends ReadableBoxedValues<{
|
||||
ref: HTMLElement | null;
|
||||
afterTick: boolean;
|
||||
}> {
|
||||
}
|
||||
export declare class AnimationsComplete {
|
||||
#private;
|
||||
constructor(opts: AnimationsCompleteOpts);
|
||||
run(fn: () => void | Promise<void>): void;
|
||||
}
|
||||
export {};
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { afterTick, onDestroyEffect } from "svelte-toolbelt";
|
||||
export class AnimationsComplete {
|
||||
#opts;
|
||||
#currentFrame = null;
|
||||
constructor(opts) {
|
||||
this.#opts = opts;
|
||||
onDestroyEffect(() => this.#cleanup());
|
||||
}
|
||||
#cleanup() {
|
||||
if (!this.#currentFrame)
|
||||
return;
|
||||
window.cancelAnimationFrame(this.#currentFrame);
|
||||
this.#currentFrame = null;
|
||||
}
|
||||
run(fn) {
|
||||
// if already running, cleanup and restart
|
||||
this.#cleanup();
|
||||
const node = this.#opts.ref.current;
|
||||
if (!node)
|
||||
return;
|
||||
if (typeof node.getAnimations !== "function") {
|
||||
this.#executeCallback(fn);
|
||||
return;
|
||||
}
|
||||
this.#currentFrame = window.requestAnimationFrame(() => {
|
||||
const animations = node.getAnimations();
|
||||
if (animations.length === 0) {
|
||||
this.#executeCallback(fn);
|
||||
return;
|
||||
}
|
||||
Promise.allSettled(animations.map((animation) => animation.finished)).then(() => {
|
||||
this.#executeCallback(fn);
|
||||
});
|
||||
});
|
||||
}
|
||||
#executeCallback(fn) {
|
||||
const execute = () => {
|
||||
fn();
|
||||
};
|
||||
if (this.#opts.afterTick) {
|
||||
afterTick(execute);
|
||||
}
|
||||
else {
|
||||
execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Checks if two arrays are equal by comparing their values.
|
||||
*/
|
||||
export declare function arraysAreEqual<T extends Array<unknown>>(arr1: T, arr2: T): boolean;
|
||||
/**
|
||||
* Splits an array into chunks of a given size.
|
||||
* @param arr The array to split.
|
||||
* @param size The size of each chunk.
|
||||
* @returns An array of arrays, where each sub-array has `size` elements from the original array.
|
||||
* @example ```ts
|
||||
* const arr = [1, 2, 3, 4, 5, 6, 7, 8];
|
||||
* const chunks = chunk(arr, 3);
|
||||
* // chunks = [[1, 2, 3], [4, 5, 6], [7, 8]]
|
||||
* ```
|
||||
*/
|
||||
export declare function chunk<T>(arr: T[], size: number): T[][];
|
||||
/**
|
||||
* Checks if the given index is valid for the given array.
|
||||
*
|
||||
* @param index - The index to check
|
||||
* @param arr - The array to check
|
||||
*/
|
||||
export declare function isValidIndex(index: number, arr: unknown[]): boolean;
|
||||
/**
|
||||
* Returns the array element after the given index, or undefined for out-of-bounds or empty arrays.
|
||||
* @param array the array.
|
||||
* @param index the index of the current element.
|
||||
* @param loop loop to the beginning of the array if the next index is out of bounds?
|
||||
*/
|
||||
/**
|
||||
* Returns the array element after the given index, or undefined for out-of-bounds or empty arrays.
|
||||
* For single-element arrays, returns the element if the index is 0.
|
||||
* @param array the array.
|
||||
* @param index the index of the current element.
|
||||
* @param loop loop to the beginning of the array if the next index is out of bounds?
|
||||
*/
|
||||
export declare function next<T>(array: T[], index: number, loop?: boolean): T | undefined;
|
||||
/**
|
||||
* Returns the array element prior to the given index, or undefined for out-of-bounds or empty arrays.
|
||||
* For single-element arrays, returns the element if the index is 0.
|
||||
* @param array the array.
|
||||
* @param index the index of the current element.
|
||||
* @param loop loop to the end of the array if the previous index is out of bounds?
|
||||
*/
|
||||
export declare function prev<T>(array: T[], index: number, loop?: boolean): T | undefined;
|
||||
/**
|
||||
* Returns the element some number after the given index. If the target index is out of bounds:
|
||||
* - If looping is disabled, the first or last element will be returned.
|
||||
* - If looping is enabled, it will wrap around the array.
|
||||
* Returns undefined for empty arrays or out-of-bounds initial indices.
|
||||
* @param array the array.
|
||||
* @param index the index of the current element.
|
||||
* @param increment the number of elements to move forward (can be negative).
|
||||
* @param loop loop around the array if the target index is out of bounds?
|
||||
*/
|
||||
export declare function forward<T>(array: T[], index: number, increment: number, loop?: boolean): T | undefined;
|
||||
/**
|
||||
* Returns the element some number before the given index. If the target index is out of bounds:
|
||||
* - If looping is disabled, the first or last element will be returned.
|
||||
* - If looping is enabled, it will wrap around the array.
|
||||
* Returns undefined for empty arrays or out-of-bounds initial indices.
|
||||
* @param array the array.
|
||||
* @param index the index of the current element.
|
||||
* @param decrement the number of elements to move backward (can be negative).
|
||||
* @param loop loop around the array if the target index is out of bounds?
|
||||
*/
|
||||
export declare function backward<T>(array: T[], index: number, decrement: number, loop?: boolean): T | undefined;
|
||||
/**
|
||||
* Finds the next matching item from a list of values based on a search string.
|
||||
*
|
||||
* This function handles several special cases in typeahead behavior:
|
||||
*
|
||||
* 1. Space handling: When a search string ends with a space, it handles it specially:
|
||||
* - If there's only one match for the text before the space, it ignores the space
|
||||
* - If there are multiple matches and the current match already starts with the search prefix
|
||||
* followed by a space, it keeps the current match (doesn't change selection on space)
|
||||
* - Only after typing characters beyond the space will it move to a more specific match
|
||||
*
|
||||
* 2. Repeated character handling: If a search consists of repeated characters (e.g., "aaa"),
|
||||
* it treats it as a single character for matching purposes
|
||||
*
|
||||
* 3. Cycling behavior: The function wraps around the values array starting from the current match
|
||||
* to find the next appropriate match, creating a cycling selection behavior
|
||||
*
|
||||
* @param values - Array of string values to search through (e.g., the text content of menu items)
|
||||
* @param search - The current search string typed by the user
|
||||
* @param currentMatch - The currently selected/matched item, if any
|
||||
* @returns The next matching value that should be selected, or undefined if no match is found
|
||||
*/
|
||||
export declare function getNextMatch(values: string[], search: string, currentMatch?: string): string | undefined;
|
||||
/**
|
||||
* Wraps an array around itself at a given start index
|
||||
* Example: `wrapArray(['a', 'b', 'c', 'd'], 2) === ['c', 'd', 'a', 'b']`
|
||||
*/
|
||||
export declare function wrapArray<T>(array: T[], startIndex: number): T[];
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Checks if two arrays are equal by comparing their values.
|
||||
*/
|
||||
export function arraysAreEqual(arr1, arr2) {
|
||||
if (arr1.length !== arr2.length)
|
||||
return false;
|
||||
return arr1.every((value, index) => isEqual(value, arr2[index]));
|
||||
}
|
||||
/**
|
||||
* A utility function that compares two values for equality.
|
||||
*/
|
||||
function isEqual(a, b) {
|
||||
if (Number.isNaN(a) && Number.isNaN(b))
|
||||
return true;
|
||||
if (Array.isArray(a) && Array.isArray(b))
|
||||
return arraysAreEqual(a, b);
|
||||
if (typeof a === "object" && typeof b === "object")
|
||||
return isDeepEqual(a, b);
|
||||
return Object.is(a, b);
|
||||
}
|
||||
/**
|
||||
* A utility function that compares two values for deep equality.
|
||||
*/
|
||||
function isDeepEqual(a, b) {
|
||||
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null)
|
||||
return false;
|
||||
const aKeys = Object.keys(a);
|
||||
const bKeys = Object.keys(b);
|
||||
if (aKeys.length !== bKeys.length)
|
||||
return false;
|
||||
for (const key of aKeys) {
|
||||
if (!bKeys.includes(key))
|
||||
return false;
|
||||
if (!isEqual(a[key], b[key])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* Splits an array into chunks of a given size.
|
||||
* @param arr The array to split.
|
||||
* @param size The size of each chunk.
|
||||
* @returns An array of arrays, where each sub-array has `size` elements from the original array.
|
||||
* @example ```ts
|
||||
* const arr = [1, 2, 3, 4, 5, 6, 7, 8];
|
||||
* const chunks = chunk(arr, 3);
|
||||
* // chunks = [[1, 2, 3], [4, 5, 6], [7, 8]]
|
||||
* ```
|
||||
*/
|
||||
export function chunk(arr, size) {
|
||||
if (size <= 0)
|
||||
return [];
|
||||
const result = [];
|
||||
for (let i = 0; i < arr.length; i += size) {
|
||||
result.push(arr.slice(i, i + size));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
* Checks if the given index is valid for the given array.
|
||||
*
|
||||
* @param index - The index to check
|
||||
* @param arr - The array to check
|
||||
*/
|
||||
export function isValidIndex(index, arr) {
|
||||
return index >= 0 && index < arr.length;
|
||||
}
|
||||
/**
|
||||
* Returns the array element after the given index, or undefined for out-of-bounds or empty arrays.
|
||||
* @param array the array.
|
||||
* @param index the index of the current element.
|
||||
* @param loop loop to the beginning of the array if the next index is out of bounds?
|
||||
*/
|
||||
/**
|
||||
* Returns the array element after the given index, or undefined for out-of-bounds or empty arrays.
|
||||
* For single-element arrays, returns the element if the index is 0.
|
||||
* @param array the array.
|
||||
* @param index the index of the current element.
|
||||
* @param loop loop to the beginning of the array if the next index is out of bounds?
|
||||
*/
|
||||
export function next(array, index, loop = true) {
|
||||
if (array.length === 0 || index < 0 || index >= array.length)
|
||||
return;
|
||||
if (array.length === 1 && index === 0)
|
||||
return array[0];
|
||||
if (index === array.length - 1)
|
||||
return loop ? array[0] : undefined;
|
||||
return array[index + 1];
|
||||
}
|
||||
/**
|
||||
* Returns the array element prior to the given index, or undefined for out-of-bounds or empty arrays.
|
||||
* For single-element arrays, returns the element if the index is 0.
|
||||
* @param array the array.
|
||||
* @param index the index of the current element.
|
||||
* @param loop loop to the end of the array if the previous index is out of bounds?
|
||||
*/
|
||||
export function prev(array, index, loop = true) {
|
||||
if (array.length === 0 || index < 0 || index >= array.length)
|
||||
return;
|
||||
if (array.length === 1 && index === 0)
|
||||
return array[0];
|
||||
if (index === 0)
|
||||
return loop ? array[array.length - 1] : undefined;
|
||||
return array[index - 1];
|
||||
}
|
||||
/**
|
||||
* Returns the element some number after the given index. If the target index is out of bounds:
|
||||
* - If looping is disabled, the first or last element will be returned.
|
||||
* - If looping is enabled, it will wrap around the array.
|
||||
* Returns undefined for empty arrays or out-of-bounds initial indices.
|
||||
* @param array the array.
|
||||
* @param index the index of the current element.
|
||||
* @param increment the number of elements to move forward (can be negative).
|
||||
* @param loop loop around the array if the target index is out of bounds?
|
||||
*/
|
||||
export function forward(array, index, increment, loop = true) {
|
||||
if (array.length === 0 || index < 0 || index >= array.length)
|
||||
return;
|
||||
let targetIndex = index + increment;
|
||||
if (loop) {
|
||||
// Ensure positive modulus
|
||||
targetIndex = ((targetIndex % array.length) + array.length) % array.length;
|
||||
}
|
||||
else {
|
||||
// Clamp to array bounds when not looping
|
||||
targetIndex = Math.max(0, Math.min(targetIndex, array.length - 1));
|
||||
}
|
||||
return array[targetIndex];
|
||||
}
|
||||
/**
|
||||
* Returns the element some number before the given index. If the target index is out of bounds:
|
||||
* - If looping is disabled, the first or last element will be returned.
|
||||
* - If looping is enabled, it will wrap around the array.
|
||||
* Returns undefined for empty arrays or out-of-bounds initial indices.
|
||||
* @param array the array.
|
||||
* @param index the index of the current element.
|
||||
* @param decrement the number of elements to move backward (can be negative).
|
||||
* @param loop loop around the array if the target index is out of bounds?
|
||||
*/
|
||||
export function backward(array, index, decrement, loop = true) {
|
||||
if (array.length === 0 || index < 0 || index >= array.length)
|
||||
return;
|
||||
let targetIndex = index - decrement;
|
||||
if (loop) {
|
||||
// Ensure positive modulus
|
||||
targetIndex = ((targetIndex % array.length) + array.length) % array.length;
|
||||
}
|
||||
else {
|
||||
// Clamp to array bounds when not looping
|
||||
targetIndex = Math.max(0, Math.min(targetIndex, array.length - 1));
|
||||
}
|
||||
return array[targetIndex];
|
||||
}
|
||||
/**
|
||||
* Finds the next matching item from a list of values based on a search string.
|
||||
*
|
||||
* This function handles several special cases in typeahead behavior:
|
||||
*
|
||||
* 1. Space handling: When a search string ends with a space, it handles it specially:
|
||||
* - If there's only one match for the text before the space, it ignores the space
|
||||
* - If there are multiple matches and the current match already starts with the search prefix
|
||||
* followed by a space, it keeps the current match (doesn't change selection on space)
|
||||
* - Only after typing characters beyond the space will it move to a more specific match
|
||||
*
|
||||
* 2. Repeated character handling: If a search consists of repeated characters (e.g., "aaa"),
|
||||
* it treats it as a single character for matching purposes
|
||||
*
|
||||
* 3. Cycling behavior: The function wraps around the values array starting from the current match
|
||||
* to find the next appropriate match, creating a cycling selection behavior
|
||||
*
|
||||
* @param values - Array of string values to search through (e.g., the text content of menu items)
|
||||
* @param search - The current search string typed by the user
|
||||
* @param currentMatch - The currently selected/matched item, if any
|
||||
* @returns The next matching value that should be selected, or undefined if no match is found
|
||||
*/
|
||||
export function getNextMatch(values, search, currentMatch) {
|
||||
const lowerSearch = search.toLowerCase();
|
||||
if (lowerSearch.endsWith(" ")) {
|
||||
const searchWithoutSpace = lowerSearch.slice(0, -1);
|
||||
const matchesWithoutSpace = values.filter((value) => value.toLowerCase().startsWith(searchWithoutSpace));
|
||||
/**
|
||||
* If there's only one match for the prefix without space, we don't
|
||||
* watch to match with space.
|
||||
*/
|
||||
if (matchesWithoutSpace.length <= 1) {
|
||||
return getNextMatch(values, searchWithoutSpace, currentMatch);
|
||||
}
|
||||
const currentMatchLowercase = currentMatch?.toLowerCase();
|
||||
/**
|
||||
* If the current match already starts with the search prefix and has a space afterward,
|
||||
* and the user has only typed up to that space, keep the current match until they
|
||||
* disambiguate.
|
||||
*/
|
||||
if (currentMatchLowercase &&
|
||||
currentMatchLowercase.startsWith(searchWithoutSpace) &&
|
||||
currentMatchLowercase.charAt(searchWithoutSpace.length) === " " &&
|
||||
search.trim() === searchWithoutSpace) {
|
||||
return currentMatch;
|
||||
}
|
||||
/**
|
||||
* With multiple matches, find items that match the full search string with space
|
||||
*/
|
||||
const spacedMatches = values.filter((value) => value.toLowerCase().startsWith(lowerSearch));
|
||||
/**
|
||||
* If we found matches with the space, use the first one that's not the current match
|
||||
*/
|
||||
if (spacedMatches.length > 0) {
|
||||
const currentMatchIndex = currentMatch ? values.indexOf(currentMatch) : -1;
|
||||
let wrappedMatches = wrapArray(spacedMatches, Math.max(currentMatchIndex, 0));
|
||||
// return the first match that is not the current one.
|
||||
const nextMatch = wrappedMatches.find((match) => match !== currentMatch);
|
||||
// fallback to current if no other is found.
|
||||
return nextMatch || currentMatch;
|
||||
}
|
||||
}
|
||||
const isRepeated = search.length > 1 && Array.from(search).every((char) => char === search[0]);
|
||||
const normalizedSearch = isRepeated ? search[0] : search;
|
||||
const normalizedLowerSearch = normalizedSearch.toLowerCase();
|
||||
const currentMatchIndex = currentMatch ? values.indexOf(currentMatch) : -1;
|
||||
let wrappedValues = wrapArray(values, Math.max(currentMatchIndex, 0));
|
||||
const excludeCurrentMatch = normalizedSearch.length === 1;
|
||||
if (excludeCurrentMatch)
|
||||
wrappedValues = wrappedValues.filter((v) => v !== currentMatch);
|
||||
const nextMatch = wrappedValues.find((value) => value?.toLowerCase().startsWith(normalizedLowerSearch));
|
||||
return nextMatch !== currentMatch ? nextMatch : undefined;
|
||||
}
|
||||
/**
|
||||
* Wraps an array around itself at a given start index
|
||||
* Example: `wrapArray(['a', 'b', 'c', 'd'], 2) === ['c', 'd', 'a', 'b']`
|
||||
*/
|
||||
export function wrapArray(array, startIndex) {
|
||||
return array.map((_, index) => array[(startIndex + index) % array.length]);
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
export declare function boolToStr(condition: boolean): "true" | "false";
|
||||
export declare function boolToStrTrueOrUndef(condition: boolean): "true" | undefined;
|
||||
export declare function boolToEmptyStrOrUndef(condition: boolean): "" | undefined;
|
||||
export declare function boolToTrueOrUndef(condition: boolean): true | undefined;
|
||||
export declare function getDataOpenClosed(condition: boolean): "open" | "closed";
|
||||
export declare function getDataChecked(condition: boolean): "checked" | "unchecked";
|
||||
export declare function getAriaChecked(checked: boolean, indeterminate: boolean): "true" | "false" | "mixed";
|
||||
export type BitsAttrsConfig<T extends readonly string[]> = {
|
||||
component: string;
|
||||
parts: T;
|
||||
getVariant?: () => string | null;
|
||||
};
|
||||
export type CreateBitsAttrsReturn<T extends readonly string[]> = {
|
||||
[K in T[number]]: string;
|
||||
} & {
|
||||
selector: (part: T[number]) => string;
|
||||
getAttr: (part: T[number], variant?: string) => string;
|
||||
};
|
||||
export declare class BitsAttrs<T extends readonly string[]> {
|
||||
#private;
|
||||
attrs: Record<T[number], string>;
|
||||
constructor(config: BitsAttrsConfig<T>);
|
||||
getAttr(part: T[number], variantOverride?: string): string;
|
||||
selector(part: T[number], variantOverride?: string): string;
|
||||
}
|
||||
export declare function createBitsAttrs<const T extends readonly string[]>(config: Omit<BitsAttrsConfig<T>, "parts"> & {
|
||||
parts: T;
|
||||
}): CreateBitsAttrsReturn<T>;
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
export function boolToStr(condition) {
|
||||
return condition ? "true" : "false";
|
||||
}
|
||||
export function boolToStrTrueOrUndef(condition) {
|
||||
return condition ? "true" : undefined;
|
||||
}
|
||||
export function boolToEmptyStrOrUndef(condition) {
|
||||
return condition ? "" : undefined;
|
||||
}
|
||||
export function boolToTrueOrUndef(condition) {
|
||||
return condition ? true : undefined;
|
||||
}
|
||||
export function getDataOpenClosed(condition) {
|
||||
return condition ? "open" : "closed";
|
||||
}
|
||||
export function getDataChecked(condition) {
|
||||
return condition ? "checked" : "unchecked";
|
||||
}
|
||||
export function getAriaChecked(checked, indeterminate) {
|
||||
if (indeterminate) {
|
||||
return "mixed";
|
||||
}
|
||||
return checked ? "true" : "false";
|
||||
}
|
||||
export class BitsAttrs {
|
||||
#variant;
|
||||
#prefix;
|
||||
attrs;
|
||||
constructor(config) {
|
||||
this.#variant = config.getVariant ? config.getVariant() : null;
|
||||
this.#prefix = this.#variant ? `data-${this.#variant}-` : `data-${config.component}-`;
|
||||
this.getAttr = this.getAttr.bind(this);
|
||||
this.selector = this.selector.bind(this);
|
||||
this.attrs = Object.fromEntries(config.parts.map((part) => [part, this.getAttr(part)]));
|
||||
}
|
||||
getAttr(part, variantOverride) {
|
||||
if (variantOverride)
|
||||
return `data-${variantOverride}-${part}`;
|
||||
return `${this.#prefix}${part}`;
|
||||
}
|
||||
selector(part, variantOverride) {
|
||||
return `[${this.getAttr(part, variantOverride)}]`;
|
||||
}
|
||||
}
|
||||
export function createBitsAttrs(config) {
|
||||
const bitsAttrs = new BitsAttrs(config);
|
||||
return {
|
||||
...bitsAttrs.attrs,
|
||||
selector: bitsAttrs.selector,
|
||||
getAttr: bitsAttrs.getAttr,
|
||||
};
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { type Getter, type ReadableBox } from "svelte-toolbelt";
|
||||
export interface ScrollBodyOption {
|
||||
padding?: boolean | number;
|
||||
margin?: boolean | number;
|
||||
}
|
||||
export declare class BodyScrollLock {
|
||||
#private;
|
||||
readonly locked: ReadableBox<boolean> | undefined;
|
||||
constructor(initialState?: boolean | undefined, restoreScrollDelay?: Getter<number | null>);
|
||||
}
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
import { SvelteMap } from "svelte/reactivity";
|
||||
import { afterTick, boxWith, onDestroyEffect, } from "svelte-toolbelt";
|
||||
import { isIOS } from "./is.js";
|
||||
import { useId } from "./use-id.js";
|
||||
import { watch } from "runed";
|
||||
import { SharedState } from "./shared-state.svelte.js";
|
||||
import { BROWSER } from "esm-env";
|
||||
import { on } from "svelte/events";
|
||||
/** A map of lock ids to their `locked` state. */
|
||||
const lockMap = new SvelteMap();
|
||||
let initialBodyStyle = $state(null);
|
||||
let stopTouchMoveListener = null;
|
||||
let cleanupTimeoutId = null;
|
||||
let isInCleanupTransition = false;
|
||||
const anyLocked = boxWith(() => {
|
||||
for (const value of lockMap.values()) {
|
||||
if (value)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
/**
|
||||
* We track the time we scheduled the cleanup to prevent race conditions
|
||||
* when multiple locks are created/destroyed in the same tick, ensuring
|
||||
* only the last one to schedule the cleanup will run.
|
||||
*
|
||||
* reference: https://github.com/huntabyte/bits-ui/issues/1639
|
||||
*/
|
||||
let cleanupScheduledAt = null;
|
||||
const bodyLockStackCount = new SharedState(() => {
|
||||
function resetBodyStyle() {
|
||||
if (!BROWSER)
|
||||
return;
|
||||
document.body.setAttribute("style", initialBodyStyle ?? "");
|
||||
document.body.style.removeProperty("--scrollbar-width");
|
||||
isIOS && stopTouchMoveListener?.();
|
||||
// reset initialBodyStyle so next locker captures the correct styles
|
||||
initialBodyStyle = null;
|
||||
}
|
||||
function cancelPendingCleanup() {
|
||||
if (cleanupTimeoutId === null)
|
||||
return;
|
||||
window.clearTimeout(cleanupTimeoutId);
|
||||
cleanupTimeoutId = null;
|
||||
}
|
||||
function scheduleCleanupIfNoNewLocks(delay, callback) {
|
||||
cancelPendingCleanup();
|
||||
isInCleanupTransition = true;
|
||||
cleanupScheduledAt = Date.now();
|
||||
const currentCleanupId = cleanupScheduledAt;
|
||||
/**
|
||||
* We schedule the cleanup to run after a delay to allow new locks to register
|
||||
* that might have been added in the same tick as the current cleanup.
|
||||
*
|
||||
* If a new lock is added in the same tick, the cleanup will be cancelled and
|
||||
* a new cleanup will be scheduled.
|
||||
*
|
||||
* This is to prevent the cleanup from running too early and resetting the body
|
||||
* style before the new lock has had a chance to apply its styles.
|
||||
*/
|
||||
const cleanupFn = () => {
|
||||
cleanupTimeoutId = null;
|
||||
// check if this cleanup is still valid (no newer cleanups scheduled)
|
||||
if (cleanupScheduledAt !== currentCleanupId)
|
||||
return;
|
||||
// ensure no new locks were added during the delay
|
||||
if (!isAnyLocked(lockMap)) {
|
||||
isInCleanupTransition = false;
|
||||
callback();
|
||||
}
|
||||
else {
|
||||
isInCleanupTransition = false;
|
||||
}
|
||||
};
|
||||
const actualDelay = delay === null ? 24 : delay;
|
||||
cleanupTimeoutId = window.setTimeout(cleanupFn, actualDelay);
|
||||
}
|
||||
function ensureInitialStyleCaptured() {
|
||||
// only capture initial style once, when no locks exist and no cleanup is in progress
|
||||
if (initialBodyStyle === null && lockMap.size === 0 && !isInCleanupTransition) {
|
||||
initialBodyStyle = document.body.getAttribute("style");
|
||||
}
|
||||
}
|
||||
watch(() => anyLocked.current, () => {
|
||||
if (!anyLocked.current)
|
||||
return;
|
||||
// ensure we've captured the initial style before applying any lock styles
|
||||
ensureInitialStyleCaptured();
|
||||
// if we're applying lock styles, we're no longer in a cleanup transition
|
||||
isInCleanupTransition = false;
|
||||
const htmlStyle = getComputedStyle(document.documentElement);
|
||||
const bodyStyle = getComputedStyle(document.body);
|
||||
// check if scrollbar-gutter: stable is already handling scrollbar space
|
||||
const hasStableGutter = htmlStyle.scrollbarGutter?.includes("stable") ||
|
||||
bodyStyle.scrollbarGutter?.includes("stable");
|
||||
// TODO: account for RTL direction, etc.
|
||||
const verticalScrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
|
||||
const paddingRight = Number.parseInt(bodyStyle.paddingRight ?? "0", 10);
|
||||
const config = {
|
||||
padding: paddingRight + verticalScrollbarWidth,
|
||||
margin: Number.parseInt(bodyStyle.marginRight ?? "0", 10),
|
||||
};
|
||||
// only add padding compensation if stable gutter isn't handling it
|
||||
if (verticalScrollbarWidth > 0 && !hasStableGutter) {
|
||||
document.body.style.paddingRight = `${config.padding}px`;
|
||||
document.body.style.marginRight = `${config.margin}px`;
|
||||
document.body.style.setProperty("--scrollbar-width", `${verticalScrollbarWidth}px`);
|
||||
}
|
||||
document.body.style.overflow = "hidden";
|
||||
if (isIOS) {
|
||||
// IOS devices are special and require a touchmove listener to prevent scrolling
|
||||
stopTouchMoveListener = on(document, "touchmove", (e) => {
|
||||
if (e.target !== document.documentElement)
|
||||
return;
|
||||
if (e.touches.length > 1)
|
||||
return;
|
||||
e.preventDefault();
|
||||
}, { passive: false });
|
||||
}
|
||||
/**
|
||||
* We ensure pointer-events: none is applied _after_ DOM updates, so that any focus/
|
||||
* interaction changes from opening overlays/menus complete _before_ we block pointer
|
||||
* events.
|
||||
*
|
||||
* this avoids race conditions where pointer-events could be set too early and break
|
||||
* focus/interaction.
|
||||
*/
|
||||
afterTick(() => {
|
||||
document.body.style.pointerEvents = "none";
|
||||
document.body.style.overflow = "hidden";
|
||||
});
|
||||
});
|
||||
onDestroyEffect(() => {
|
||||
return () => {
|
||||
stopTouchMoveListener?.();
|
||||
};
|
||||
});
|
||||
return {
|
||||
get lockMap() {
|
||||
return lockMap;
|
||||
},
|
||||
resetBodyStyle,
|
||||
scheduleCleanupIfNoNewLocks,
|
||||
cancelPendingCleanup,
|
||||
ensureInitialStyleCaptured,
|
||||
};
|
||||
});
|
||||
export class BodyScrollLock {
|
||||
#id = useId();
|
||||
#initialState;
|
||||
#restoreScrollDelay = () => null;
|
||||
#countState;
|
||||
locked;
|
||||
constructor(initialState, restoreScrollDelay = () => null) {
|
||||
this.#initialState = initialState;
|
||||
this.#restoreScrollDelay = restoreScrollDelay;
|
||||
this.#countState = bodyLockStackCount.get();
|
||||
if (!this.#countState)
|
||||
return;
|
||||
/**
|
||||
* Since a new lock is being created, we cancel any pending cleanup to
|
||||
* prevent the cleanup from running too early and resetting the body style
|
||||
* before the new lock has had a chance to apply its styles.
|
||||
*
|
||||
* reference: https://github.com/huntabyte/bits-ui/issues/1639
|
||||
*/
|
||||
this.#countState.cancelPendingCleanup();
|
||||
// capture initial style before this lock is registered
|
||||
this.#countState.ensureInitialStyleCaptured();
|
||||
this.#countState.lockMap.set(this.#id, this.#initialState ?? false);
|
||||
this.locked = boxWith(() => this.#countState.lockMap.get(this.#id) ?? false, (v) => this.#countState.lockMap.set(this.#id, v));
|
||||
onDestroyEffect(() => {
|
||||
this.#countState.lockMap.delete(this.#id);
|
||||
// if not the last lock, we don't need to do anything
|
||||
if (isAnyLocked(this.#countState.lockMap))
|
||||
return;
|
||||
const restoreScrollDelay = this.#restoreScrollDelay();
|
||||
/**
|
||||
* We schedule the cleanup to run after a delay to handle same-tick
|
||||
* destroy/create scenarios.
|
||||
*
|
||||
* reference: https://github.com/huntabyte/bits-ui/issues/1639
|
||||
*/
|
||||
this.#countState.scheduleCleanupIfNoNewLocks(restoreScrollDelay, () => {
|
||||
this.#countState.resetBodyStyle();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
function isAnyLocked(map) {
|
||||
for (const [_, value] of map) {
|
||||
if (value)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { type WritableBox } from "svelte-toolbelt";
|
||||
type BoxAutoResetOptions<T> = {
|
||||
afterMs?: number;
|
||||
onChange?: (value: T) => void;
|
||||
getWindow: () => Window & typeof globalThis;
|
||||
};
|
||||
/**
|
||||
* Creates a box which will be reset to the default value after some time.
|
||||
*
|
||||
* @param defaultValue The value which will be set.
|
||||
* @param afterMs A zero-or-greater delay in milliseconds.
|
||||
*/
|
||||
export declare function boxAutoReset<T>(defaultValue: T, options: BoxAutoResetOptions<T>): WritableBox<T>;
|
||||
export {};
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { boxWith } from "svelte-toolbelt";
|
||||
import { noop } from "./noop.js";
|
||||
const defaultOptions = {
|
||||
afterMs: 10000,
|
||||
onChange: noop,
|
||||
};
|
||||
/**
|
||||
* Creates a box which will be reset to the default value after some time.
|
||||
*
|
||||
* @param defaultValue The value which will be set.
|
||||
* @param afterMs A zero-or-greater delay in milliseconds.
|
||||
*/
|
||||
export function boxAutoReset(defaultValue, options) {
|
||||
const { afterMs, onChange, getWindow } = { ...defaultOptions, ...options };
|
||||
let timeout = null;
|
||||
let value = $state(defaultValue);
|
||||
function resetAfter() {
|
||||
return getWindow().setTimeout(() => {
|
||||
value = defaultValue;
|
||||
onChange?.(defaultValue);
|
||||
}, afterMs);
|
||||
}
|
||||
$effect(() => {
|
||||
return () => {
|
||||
if (timeout)
|
||||
getWindow().clearTimeout(timeout);
|
||||
};
|
||||
});
|
||||
return boxWith(() => value, (v) => {
|
||||
value = v;
|
||||
onChange?.(v);
|
||||
if (timeout)
|
||||
getWindow().clearTimeout(timeout);
|
||||
timeout = resetAfter();
|
||||
});
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Clamps a number between a minimum and maximum value.
|
||||
*/
|
||||
export declare function clamp(n: number, min: number, max: number): number;
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Clamps a number between a minimum and maximum value.
|
||||
*/
|
||||
export function clamp(n, min, max) {
|
||||
return Math.min(max, Math.max(min, n));
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Creates a unique ID for a given uid and optional prefix.
|
||||
*
|
||||
* @param uid - the uid generated by $props.id()
|
||||
* @param prefix - optional prefix to use for the id (defaults to "bits")
|
||||
*/
|
||||
export declare function createId(uid: string): string;
|
||||
export declare function createId(prefix: string, uid: string): string;
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export function createId(prefixOrUid, uid) {
|
||||
if (uid === undefined)
|
||||
return `bits-${prefixOrUid}`;
|
||||
return `bits-${prefixOrUid}-${uid}`;
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* https://github.com/mathiasbynens/CSS.escape
|
||||
*
|
||||
* @param value - The value to escape for use as a CSS identifier
|
||||
* @returns The escaped CSS identifier string
|
||||
*/
|
||||
export declare function cssEscape(value: string): string;
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* https://github.com/mathiasbynens/CSS.escape
|
||||
*
|
||||
* @param value - The value to escape for use as a CSS identifier
|
||||
* @returns The escaped CSS identifier string
|
||||
*/
|
||||
export function cssEscape(value) {
|
||||
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
||||
return CSS.escape(value);
|
||||
}
|
||||
const length = value.length;
|
||||
let index = -1;
|
||||
let codeUnit;
|
||||
let result = "";
|
||||
const firstCodeUnit = value.charCodeAt(0);
|
||||
// If the character is the first character and is a `-` (U+002D), and
|
||||
// there is no second character, escape it
|
||||
if (length === 1 && firstCodeUnit === 0x002d)
|
||||
return "\\" + value;
|
||||
while (++index < length) {
|
||||
codeUnit = value.charCodeAt(index);
|
||||
// If the character is NULL (U+0000), then the REPLACEMENT CHARACTER (U+FFFD)
|
||||
if (codeUnit === 0x0000) {
|
||||
result += "\uFFFD";
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
// If the character is in the range [\1-\1F] (U+0001 to U+001F) or is U+007F
|
||||
(codeUnit >= 0x0001 && codeUnit <= 0x001f) ||
|
||||
codeUnit === 0x007f ||
|
||||
// If the character is the first character and is in the range [0-9] (U+0030 to U+0039)
|
||||
(index === 0 && codeUnit >= 0x0030 && codeUnit <= 0x0039) ||
|
||||
// If the character is the second character and is in the range [0-9] (U+0030 to U+0039)
|
||||
// and the first character is a `-` (U+002D)
|
||||
(index === 1 && codeUnit >= 0x0030 && codeUnit <= 0x0039 && firstCodeUnit === 0x002d)) {
|
||||
// https://drafts.csswg.org/cssom/#escape-a-character-as-code-point
|
||||
result += "\\" + codeUnit.toString(16) + " ";
|
||||
continue;
|
||||
}
|
||||
// If the character is not handled by one of the above rules and is
|
||||
// greater than or equal to U+0080, is `-` (U+002D) or `_` (U+005F), or
|
||||
// is in one of the ranges [0-9] (U+0030 to U+0039), [A-Z] (U+0041 to U+005A),
|
||||
// or [a-z] (U+0061 to U+007A)
|
||||
if (codeUnit >= 0x0080 ||
|
||||
codeUnit === 0x002d ||
|
||||
codeUnit === 0x005f ||
|
||||
(codeUnit >= 0x0030 && codeUnit <= 0x0039) ||
|
||||
(codeUnit >= 0x0041 && codeUnit <= 0x005a) ||
|
||||
(codeUnit >= 0x0061 && codeUnit <= 0x007a)) {
|
||||
// Use the character itself
|
||||
result += value.charAt(index);
|
||||
continue;
|
||||
}
|
||||
// Otherwise, escape the character
|
||||
// https://drafts.csswg.org/cssom/#escape-a-character
|
||||
result += "\\" + value.charAt(index);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import type { Getter } from "svelte-toolbelt";
|
||||
interface DataTypeaheadOpts {
|
||||
onMatch: (value: string) => void;
|
||||
getCurrentItem: () => string;
|
||||
candidateValues: Getter<string[]>;
|
||||
enabled: Getter<boolean>;
|
||||
getWindow: () => Window & typeof globalThis;
|
||||
}
|
||||
export declare class DataTypeahead {
|
||||
#private;
|
||||
constructor(opts: DataTypeaheadOpts);
|
||||
handleTypeaheadSearch(key: string): string | undefined;
|
||||
resetTypeahead(): void;
|
||||
}
|
||||
export {};
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { getNextMatch } from "./arrays.js";
|
||||
import { boxAutoReset } from "./box-auto-reset.svelte.js";
|
||||
export class DataTypeahead {
|
||||
#opts;
|
||||
#candidateValues = $derived.by(() => this.#opts.candidateValues());
|
||||
#search;
|
||||
constructor(opts) {
|
||||
this.#opts = opts;
|
||||
this.#search = boxAutoReset("", {
|
||||
afterMs: 1000,
|
||||
getWindow: this.#opts.getWindow,
|
||||
});
|
||||
this.handleTypeaheadSearch = this.handleTypeaheadSearch.bind(this);
|
||||
this.resetTypeahead = this.resetTypeahead.bind(this);
|
||||
}
|
||||
handleTypeaheadSearch(key) {
|
||||
if (!this.#opts.enabled() || !this.#candidateValues.length)
|
||||
return;
|
||||
this.#search.current = this.#search.current + key;
|
||||
const currentItem = this.#opts.getCurrentItem();
|
||||
const currentMatch = this.#candidateValues.find((item) => item === currentItem) ?? "";
|
||||
const values = this.#candidateValues.map((item) => item ?? "");
|
||||
const nextMatch = getNextMatch(values, this.#search.current, currentMatch);
|
||||
const newItem = this.#candidateValues.find((item) => item === nextMatch);
|
||||
if (newItem) {
|
||||
this.#opts.onMatch(newItem);
|
||||
}
|
||||
return newItem;
|
||||
}
|
||||
resetTypeahead() {
|
||||
this.#search.current = "";
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export type Announcer = ReturnType<typeof getAnnouncer>;
|
||||
/**
|
||||
* Creates an announcer object that can be used to make `aria-live` announcements to screen readers.
|
||||
*/
|
||||
export declare function getAnnouncer(doc: Document | null): {
|
||||
announce: (value: string | null | number, kind?: "assertive" | "polite", timeout?: number) => NodeJS.Timeout | undefined;
|
||||
};
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
import { srOnlyStylesString } from "svelte-toolbelt";
|
||||
import { isBrowser, isHTMLElement } from "../is.js";
|
||||
/**
|
||||
* Creates or gets an announcer element which is used to announce messages to screen readers.
|
||||
* Within the date components, we use this to announce when the values of the individual segments
|
||||
* change, as without it we get inconsistent behavior across screen readers.
|
||||
*/
|
||||
function initAnnouncer(doc) {
|
||||
if (!isBrowser || !doc)
|
||||
return null;
|
||||
let el = doc.querySelector("[data-bits-announcer]");
|
||||
/**
|
||||
* Creates a log element for assertive or polite announcements.
|
||||
*/
|
||||
const createLog = (kind) => {
|
||||
const log = doc.createElement("div");
|
||||
log.role = "log";
|
||||
log.ariaLive = kind;
|
||||
log.setAttribute("aria-relevant", "additions");
|
||||
return log;
|
||||
};
|
||||
if (!isHTMLElement(el)) {
|
||||
const div = doc.createElement("div");
|
||||
div.style.cssText = srOnlyStylesString;
|
||||
div.setAttribute("data-bits-announcer", "");
|
||||
div.appendChild(createLog("assertive"));
|
||||
div.appendChild(createLog("polite"));
|
||||
el = div;
|
||||
doc.body.insertBefore(el, doc.body.firstChild);
|
||||
}
|
||||
/**
|
||||
* Retrieves the log element for assertive or polite announcements.
|
||||
*/
|
||||
const getLog = (kind) => {
|
||||
if (!isHTMLElement(el))
|
||||
return null;
|
||||
const log = el.querySelector(`[aria-live="${kind}"]`);
|
||||
if (!isHTMLElement(log))
|
||||
return null;
|
||||
return log;
|
||||
};
|
||||
return {
|
||||
getLog,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Creates an announcer object that can be used to make `aria-live` announcements to screen readers.
|
||||
*/
|
||||
export function getAnnouncer(doc) {
|
||||
const announcer = initAnnouncer(doc);
|
||||
/**
|
||||
* Announces a message to screen readers using the specified kind of announcement.
|
||||
*/
|
||||
function announce(value, kind = "assertive", timeout = 7500) {
|
||||
if (!announcer || !isBrowser || !doc)
|
||||
return;
|
||||
const log = announcer.getLog(kind);
|
||||
const content = doc.createElement("div");
|
||||
if (typeof value === "number") {
|
||||
value = value.toString();
|
||||
}
|
||||
else if (value === null) {
|
||||
value = "Empty";
|
||||
}
|
||||
else {
|
||||
value = value.trim();
|
||||
}
|
||||
content.innerText = value;
|
||||
if (kind === "assertive") {
|
||||
log?.replaceChildren(content);
|
||||
}
|
||||
else {
|
||||
log?.appendChild(content);
|
||||
}
|
||||
return setTimeout(() => {
|
||||
content.remove();
|
||||
}, timeout);
|
||||
}
|
||||
return {
|
||||
announce,
|
||||
};
|
||||
}
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
import { type DateValue } from "@internationalized/date";
|
||||
import { type ReadableBox, type WritableBox } from "svelte-toolbelt";
|
||||
import type { Formatter } from "./formatter.js";
|
||||
import type { DateMatcher, Month } from "../../shared/index.js";
|
||||
/**
|
||||
* Checks if a given node is a calendar cell element.
|
||||
*
|
||||
* @param node - The node to check.
|
||||
*/
|
||||
export declare function isCalendarDayNode(node: unknown): node is HTMLElement;
|
||||
/**
|
||||
* Retrieves an array of date values representing the days between
|
||||
* the provided start and end dates.
|
||||
*/
|
||||
export declare function getDaysBetween(start: DateValue, end: DateValue): DateValue[];
|
||||
export type CreateMonthProps = {
|
||||
/**
|
||||
* The date object representing the month's date (usually the first day of the month).
|
||||
*/
|
||||
dateObj: DateValue;
|
||||
/**
|
||||
* The day of the week to start the calendar on (0 for Sunday, 1 for Monday, etc.).
|
||||
*/
|
||||
weekStartsOn: number | undefined;
|
||||
/**
|
||||
* Whether to always render 6 weeks in the calendar, even if the month doesn't
|
||||
* span 6 weeks.
|
||||
*/
|
||||
fixedWeeks: boolean;
|
||||
/**
|
||||
* The locale to use when creating the calendar month.
|
||||
*/
|
||||
locale: string;
|
||||
};
|
||||
type SetMonthProps = CreateMonthProps & {
|
||||
numberOfMonths: number | undefined;
|
||||
currentMonths?: Month<DateValue>[];
|
||||
};
|
||||
export declare function createMonths(props: SetMonthProps): Month<DateValue>[];
|
||||
export declare function getSelectableCells(calendarNode: HTMLElement | null): HTMLElement[];
|
||||
/**
|
||||
* A helper function to extract the date from the `data-value`
|
||||
* attribute of a date cell and set it as the placeholder value.
|
||||
*
|
||||
* Shared between the calendar and range calendar builders.
|
||||
*
|
||||
* @param node - The node to extract the date from.
|
||||
* @param placeholder - The placeholder value store which will be set to the extracted date.
|
||||
*/
|
||||
export declare function setPlaceholderToNodeValue(node: HTMLElement, placeholder: WritableBox<DateValue>): void;
|
||||
type ShiftCalendarFocusProps = {
|
||||
/**
|
||||
* The day node with current focus.
|
||||
*/
|
||||
node: HTMLElement;
|
||||
/**
|
||||
* The number of days to shift the focus by.
|
||||
*/
|
||||
add: number;
|
||||
/**
|
||||
* The `placeholder` value box
|
||||
*/
|
||||
placeholder: WritableBox<DateValue>;
|
||||
/**
|
||||
* The calendar node.
|
||||
*/
|
||||
calendarNode: HTMLElement | null;
|
||||
/**
|
||||
* Whether the previous button is disabled.
|
||||
*/
|
||||
isPrevButtonDisabled: boolean;
|
||||
/**
|
||||
* Whether the next button is disabled.
|
||||
*/
|
||||
isNextButtonDisabled: boolean;
|
||||
/**
|
||||
* The months array of the calendar.
|
||||
*/
|
||||
months: Month<DateValue>[];
|
||||
/**
|
||||
* The number of months being displayed in the calendar.
|
||||
*/
|
||||
numberOfMonths: number;
|
||||
};
|
||||
/**
|
||||
* Shared logic for shifting focus between cells in the
|
||||
* calendar and range calendar.
|
||||
*/
|
||||
export declare function shiftCalendarFocus({ node, add, placeholder, calendarNode, isPrevButtonDisabled, isNextButtonDisabled, months, numberOfMonths, }: ShiftCalendarFocusProps): void;
|
||||
type HandleCalendarKeydownProps = {
|
||||
event: KeyboardEvent;
|
||||
handleCellClick: (event: Event, date: DateValue) => void;
|
||||
shiftFocus: (node: HTMLElement, add: number) => void;
|
||||
placeholderValue: DateValue;
|
||||
};
|
||||
/**
|
||||
* Shared keyboard event handler for the calendar and range calendar.
|
||||
*/
|
||||
export declare function handleCalendarKeydown({ event, handleCellClick, shiftFocus, placeholderValue, }: HandleCalendarKeydownProps): void;
|
||||
type HandleCalendarPageProps = {
|
||||
months: Month<DateValue>[];
|
||||
setMonths: (months: Month<DateValue>[]) => void;
|
||||
numberOfMonths: number;
|
||||
pagedNavigation: boolean;
|
||||
weekStartsOn: number | undefined;
|
||||
locale: string;
|
||||
fixedWeeks: boolean;
|
||||
setPlaceholder: (date: DateValue) => void;
|
||||
};
|
||||
export declare function handleCalendarNextPage({ months, setMonths, numberOfMonths, pagedNavigation, weekStartsOn, locale, fixedWeeks, setPlaceholder, }: HandleCalendarPageProps): void;
|
||||
export declare function handleCalendarPrevPage({ months, setMonths, numberOfMonths, pagedNavigation, weekStartsOn, locale, fixedWeeks, setPlaceholder, }: HandleCalendarPageProps): void;
|
||||
type GetWeekdaysProps = {
|
||||
months: Month<DateValue>[];
|
||||
weekdayFormat: Intl.DateTimeFormatOptions["weekday"];
|
||||
formatter: Formatter;
|
||||
};
|
||||
export declare function getWeekdays({ months, formatter, weekdayFormat }: GetWeekdaysProps): string[];
|
||||
type UseMonthViewSyncProps = {
|
||||
weekStartsOn: ReadableBox<number | undefined>;
|
||||
locale: ReadableBox<string>;
|
||||
fixedWeeks: ReadableBox<boolean>;
|
||||
numberOfMonths: ReadableBox<number>;
|
||||
placeholder: WritableBox<DateValue>;
|
||||
setMonths: (months: Month<DateValue>[]) => void;
|
||||
};
|
||||
/**
|
||||
* Updates the displayed months based on changes in the options values,
|
||||
* which determines the month to show in the calendar.
|
||||
*/
|
||||
export declare function useMonthViewOptionsSync(props: UseMonthViewSyncProps): void;
|
||||
type CreateAccessibleHeadingProps = {
|
||||
calendarNode: HTMLElement;
|
||||
label: string;
|
||||
accessibleHeadingId: string;
|
||||
};
|
||||
/**
|
||||
* Creates an accessible heading element for the calendar.
|
||||
* Returns a function that removes the heading element.
|
||||
*/
|
||||
export declare function createAccessibleHeading({ calendarNode, label, accessibleHeadingId, }: CreateAccessibleHeadingProps): () => void;
|
||||
type UseMonthViewPlaceholderSyncProps = {
|
||||
placeholder: WritableBox<DateValue>;
|
||||
getVisibleMonths: () => DateValue[];
|
||||
weekStartsOn: ReadableBox<number | undefined>;
|
||||
locale: ReadableBox<string>;
|
||||
fixedWeeks: ReadableBox<boolean>;
|
||||
numberOfMonths: ReadableBox<number>;
|
||||
setMonths: (months: Month<DateValue>[]) => void;
|
||||
};
|
||||
export declare function useMonthViewPlaceholderSync({ placeholder, getVisibleMonths, weekStartsOn, locale, fixedWeeks, numberOfMonths, setMonths, }: UseMonthViewPlaceholderSyncProps): void;
|
||||
type GetIsNextButtonDisabledProps = {
|
||||
maxValue: DateValue | undefined;
|
||||
months: Month<DateValue>[];
|
||||
disabled: boolean;
|
||||
};
|
||||
export declare function getIsNextButtonDisabled({ maxValue, months, disabled, }: GetIsNextButtonDisabledProps): boolean;
|
||||
type GetIsPrevButtonDisabledProps = {
|
||||
minValue: DateValue | undefined;
|
||||
months: Month<DateValue>[];
|
||||
disabled: boolean;
|
||||
};
|
||||
export declare function getIsPrevButtonDisabled({ minValue, months, disabled, }: GetIsPrevButtonDisabledProps): boolean;
|
||||
type GetCalendarHeadingValueProps = {
|
||||
months: Month<DateValue>[];
|
||||
formatter: Formatter;
|
||||
locale: string;
|
||||
};
|
||||
export declare function getCalendarHeadingValue({ months, locale, formatter, }: GetCalendarHeadingValueProps): string;
|
||||
type GetCalendarElementProps = {
|
||||
fullCalendarLabel: string;
|
||||
id: string;
|
||||
isInvalid: boolean;
|
||||
disabled: boolean;
|
||||
readonly: boolean;
|
||||
};
|
||||
export declare function getCalendarElementProps({ fullCalendarLabel, id, isInvalid, disabled, readonly, }: GetCalendarElementProps): {
|
||||
readonly id: string;
|
||||
readonly role: "application";
|
||||
readonly "aria-label": string;
|
||||
readonly "data-invalid": "" | undefined;
|
||||
readonly "data-disabled": "" | undefined;
|
||||
readonly "data-readonly": "" | undefined;
|
||||
};
|
||||
export type CalendarParts = "root" | "grid" | "cell" | "next-button" | "prev-button" | "day" | "grid-body" | "grid-head" | "grid-row" | "head-cell" | "header" | "heading" | "month-select" | "year-select";
|
||||
export declare function pickerOpenFocus(e: Event): void;
|
||||
export declare function getFirstNonDisabledDateInView(calendarRef: HTMLElement): DateValue | undefined;
|
||||
/**
|
||||
* Ensures the placeholder is not set to a disabled date,
|
||||
* which would prevent the user from entering the Calendar
|
||||
* via the keyboard.
|
||||
*/
|
||||
export declare function useEnsureNonDisabledPlaceholder({ ref, placeholder, defaultPlaceholder, minValue, maxValue, isDateDisabled, }: {
|
||||
ref: WritableBox<HTMLElement | null>;
|
||||
placeholder: WritableBox<DateValue | undefined>;
|
||||
isDateDisabled: ReadableBox<DateMatcher>;
|
||||
minValue: ReadableBox<DateValue | undefined>;
|
||||
maxValue: ReadableBox<DateValue | undefined>;
|
||||
defaultPlaceholder: DateValue;
|
||||
}): void;
|
||||
export declare function getDateWithPreviousTime(date: DateValue | undefined, prev: DateValue | undefined): DateValue | undefined;
|
||||
export declare const calendarAttrs: import("../attrs.js").CreateBitsAttrsReturn<readonly ["root", "grid", "cell", "next-button", "prev-button", "day", "grid-body", "grid-head", "grid-row", "head-cell", "header", "heading", "month-select", "year-select"]>;
|
||||
type GetDefaultYearsProps = {
|
||||
placeholderYear: number;
|
||||
minValue: DateValue | undefined;
|
||||
maxValue: DateValue | undefined;
|
||||
};
|
||||
export declare function getDefaultYears(opts: GetDefaultYearsProps): number[];
|
||||
export {};
|
||||
+567
@@ -0,0 +1,567 @@
|
||||
import { endOfMonth, isSameDay, isSameMonth, startOfMonth, } from "@internationalized/date";
|
||||
import { afterTick, getDocument, styleToString, } from "svelte-toolbelt";
|
||||
import { untrack } from "svelte";
|
||||
import { getDaysInMonth, getLastFirstDayOfWeek, getNextLastDayOfWeek, hasTime, isAfter, isBefore, parseAnyDateValue, parseStringToDateValue, toDate, } from "./utils.js";
|
||||
import { createBitsAttrs, boolToEmptyStrOrUndef } from "../attrs.js";
|
||||
import { chunk, isValidIndex } from "../arrays.js";
|
||||
import { isBrowser, isHTMLElement } from "../is.js";
|
||||
import { kbd } from "../kbd.js";
|
||||
import { watch } from "runed";
|
||||
/**
|
||||
* Checks if a given node is a calendar cell element.
|
||||
*
|
||||
* @param node - The node to check.
|
||||
*/
|
||||
export function isCalendarDayNode(node) {
|
||||
if (!isHTMLElement(node))
|
||||
return false;
|
||||
if (!node.hasAttribute("data-bits-day"))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* Retrieves an array of date values representing the days between
|
||||
* the provided start and end dates.
|
||||
*/
|
||||
export function getDaysBetween(start, end) {
|
||||
const days = [];
|
||||
let dCurrent = start.add({ days: 1 });
|
||||
const dEnd = end;
|
||||
while (dCurrent.compare(dEnd) < 0) {
|
||||
days.push(dCurrent);
|
||||
dCurrent = dCurrent.add({ days: 1 });
|
||||
}
|
||||
return days;
|
||||
}
|
||||
/**
|
||||
* Creates a calendar month object.
|
||||
*
|
||||
* @remarks
|
||||
* Given a date, this function returns an object containing
|
||||
* the necessary values to render a calendar month, including
|
||||
* the month's date (the first day of that month), which can be
|
||||
* used to render the name of the month, an array of all dates
|
||||
* in that month, and an array of weeks. Each week is an array
|
||||
* of dates, useful for rendering an accessible calendar grid
|
||||
* using a loop and table elements.
|
||||
*
|
||||
*/
|
||||
function createMonth(props) {
|
||||
const { dateObj, weekStartsOn, fixedWeeks, locale } = props;
|
||||
const daysInMonth = getDaysInMonth(dateObj);
|
||||
const datesArray = Array.from({ length: daysInMonth }, (_, i) => dateObj.set({ day: i + 1 }));
|
||||
const firstDayOfMonth = startOfMonth(dateObj);
|
||||
const lastDayOfMonth = endOfMonth(dateObj);
|
||||
const lastSunday = weekStartsOn !== undefined
|
||||
? getLastFirstDayOfWeek(firstDayOfMonth, weekStartsOn, "en-US")
|
||||
: getLastFirstDayOfWeek(firstDayOfMonth, 0, locale);
|
||||
const nextSaturday = weekStartsOn !== undefined
|
||||
? getNextLastDayOfWeek(lastDayOfMonth, weekStartsOn, "en-US")
|
||||
: getNextLastDayOfWeek(lastDayOfMonth, 0, locale);
|
||||
const lastMonthDays = getDaysBetween(lastSunday.subtract({ days: 1 }), firstDayOfMonth);
|
||||
const nextMonthDays = getDaysBetween(lastDayOfMonth, nextSaturday.add({ days: 1 }));
|
||||
const totalDays = lastMonthDays.length + datesArray.length + nextMonthDays.length;
|
||||
if (fixedWeeks && totalDays < 42) {
|
||||
const extraDays = 42 - totalDays;
|
||||
let startFrom = nextMonthDays[nextMonthDays.length - 1];
|
||||
if (!startFrom) {
|
||||
startFrom = dateObj.add({ months: 1 }).set({ day: 1 });
|
||||
}
|
||||
let length = extraDays;
|
||||
if (nextMonthDays.length === 0) {
|
||||
length = extraDays - 1;
|
||||
nextMonthDays.push(startFrom);
|
||||
}
|
||||
const extraDaysArray = Array.from({ length }, (_, i) => {
|
||||
const incr = i + 1;
|
||||
return startFrom.add({ days: incr });
|
||||
});
|
||||
nextMonthDays.push(...extraDaysArray);
|
||||
}
|
||||
const allDays = lastMonthDays.concat(datesArray, nextMonthDays);
|
||||
const weeks = chunk(allDays, 7);
|
||||
return {
|
||||
value: dateObj,
|
||||
dates: allDays,
|
||||
weeks,
|
||||
};
|
||||
}
|
||||
export function createMonths(props) {
|
||||
const { numberOfMonths, dateObj, ...monthProps } = props;
|
||||
const months = [];
|
||||
if (!numberOfMonths || numberOfMonths === 1) {
|
||||
months.push(createMonth({
|
||||
...monthProps,
|
||||
dateObj,
|
||||
}));
|
||||
return months;
|
||||
}
|
||||
months.push(createMonth({
|
||||
...monthProps,
|
||||
dateObj,
|
||||
}));
|
||||
// Create all the months, starting with the current month
|
||||
for (let i = 1; i < numberOfMonths; i++) {
|
||||
const nextMonth = dateObj.add({ months: i });
|
||||
months.push(createMonth({
|
||||
...monthProps,
|
||||
dateObj: nextMonth,
|
||||
}));
|
||||
}
|
||||
return months;
|
||||
}
|
||||
export function getSelectableCells(calendarNode) {
|
||||
if (!calendarNode)
|
||||
return [];
|
||||
const selectableSelector = `[data-bits-day]:not([data-disabled]):not([data-outside-visible-months])`;
|
||||
return Array.from(calendarNode.querySelectorAll(selectableSelector)).filter((el) => isHTMLElement(el));
|
||||
}
|
||||
/**
|
||||
* A helper function to extract the date from the `data-value`
|
||||
* attribute of a date cell and set it as the placeholder value.
|
||||
*
|
||||
* Shared between the calendar and range calendar builders.
|
||||
*
|
||||
* @param node - The node to extract the date from.
|
||||
* @param placeholder - The placeholder value store which will be set to the extracted date.
|
||||
*/
|
||||
export function setPlaceholderToNodeValue(node, placeholder) {
|
||||
const cellValue = node.getAttribute("data-value");
|
||||
if (!cellValue)
|
||||
return;
|
||||
placeholder.current = parseStringToDateValue(cellValue, placeholder.current);
|
||||
}
|
||||
/**
|
||||
* Shared logic for shifting focus between cells in the
|
||||
* calendar and range calendar.
|
||||
*/
|
||||
export function shiftCalendarFocus({ node, add, placeholder, calendarNode, isPrevButtonDisabled, isNextButtonDisabled, months, numberOfMonths, }) {
|
||||
const candidateCells = getSelectableCells(calendarNode);
|
||||
if (!candidateCells.length)
|
||||
return;
|
||||
const index = candidateCells.indexOf(node);
|
||||
const nextIndex = index + add;
|
||||
/**
|
||||
* If the next cell is within the bounds of the displayed cells,
|
||||
* easy day, we just focus it.
|
||||
*/
|
||||
if (isValidIndex(nextIndex, candidateCells)) {
|
||||
const nextCell = candidateCells[nextIndex];
|
||||
setPlaceholderToNodeValue(nextCell, placeholder);
|
||||
return nextCell.focus();
|
||||
}
|
||||
/**
|
||||
* When the next cell falls outside the displayed cells range,
|
||||
* we update the focus to the previous or next month based on the
|
||||
* direction, and then focus on the relevant cell.
|
||||
*/
|
||||
if (nextIndex < 0) {
|
||||
/**
|
||||
* To handle negative indices, we rewind by one month,
|
||||
* retrieve candidate cells for that month, and shift focus
|
||||
* by the difference between the nextIndex starting from the end
|
||||
* of the array.
|
||||
*/
|
||||
// shift the calendar back a month unless prev month is disabled
|
||||
if (isPrevButtonDisabled)
|
||||
return;
|
||||
const firstMonth = months[0]?.value;
|
||||
if (!firstMonth)
|
||||
return;
|
||||
placeholder.current = firstMonth.subtract({ months: numberOfMonths });
|
||||
// Without a tick here, it seems to be too quick for the DOM to update
|
||||
afterTick(() => {
|
||||
const newCandidateCells = getSelectableCells(calendarNode);
|
||||
if (!newCandidateCells.length)
|
||||
return;
|
||||
/**
|
||||
* Starting at the end of the array, shift focus by the diff
|
||||
* between the nextIndex and the length of the array, since the
|
||||
* nextIndex is negative.
|
||||
*/
|
||||
const newIndex = newCandidateCells.length - Math.abs(nextIndex);
|
||||
if (isValidIndex(newIndex, newCandidateCells)) {
|
||||
const newCell = newCandidateCells[newIndex];
|
||||
setPlaceholderToNodeValue(newCell, placeholder);
|
||||
return newCell.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (nextIndex >= candidateCells.length) {
|
||||
/**
|
||||
* Since we're in the positive index range, we need to go forward
|
||||
* a month, refetch the candidate cells within that month, and then
|
||||
* starting at the beginning of the array, shift focus by the nextIndex
|
||||
* amount.
|
||||
*/
|
||||
// shift the calendar forward a month unless next month is disabled
|
||||
if (isNextButtonDisabled)
|
||||
return;
|
||||
const firstMonth = months[0]?.value;
|
||||
if (!firstMonth)
|
||||
return;
|
||||
placeholder.current = firstMonth.add({ months: numberOfMonths });
|
||||
afterTick(() => {
|
||||
const newCandidateCells = getSelectableCells(calendarNode);
|
||||
if (!newCandidateCells.length)
|
||||
return;
|
||||
/**
|
||||
* We need to determine how far into the next month we need to go
|
||||
* to get the next index. So if we only went over the previous month
|
||||
* by one, we need to go into the next month by 1 to get the right index.
|
||||
*/
|
||||
const newIndex = nextIndex - candidateCells.length;
|
||||
if (isValidIndex(newIndex, newCandidateCells)) {
|
||||
const nextCell = newCandidateCells[newIndex];
|
||||
return nextCell.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
const ARROW_KEYS = [kbd.ARROW_DOWN, kbd.ARROW_UP, kbd.ARROW_LEFT, kbd.ARROW_RIGHT];
|
||||
const SELECT_KEYS = [kbd.ENTER, kbd.SPACE];
|
||||
/**
|
||||
* Shared keyboard event handler for the calendar and range calendar.
|
||||
*/
|
||||
export function handleCalendarKeydown({ event, handleCellClick, shiftFocus, placeholderValue, }) {
|
||||
const currentCell = event.target;
|
||||
if (!isCalendarDayNode(currentCell))
|
||||
return;
|
||||
// oxlint-disable-next-line no-explicit-any
|
||||
if (!ARROW_KEYS.includes(event.key) && !SELECT_KEYS.includes(event.key))
|
||||
return;
|
||||
event.preventDefault();
|
||||
const kbdFocusMap = {
|
||||
[kbd.ARROW_DOWN]: 7,
|
||||
[kbd.ARROW_UP]: -7,
|
||||
[kbd.ARROW_LEFT]: -1,
|
||||
[kbd.ARROW_RIGHT]: 1,
|
||||
};
|
||||
// oxlint-disable-next-line no-explicit-any
|
||||
if (ARROW_KEYS.includes(event.key)) {
|
||||
const add = kbdFocusMap[event.key];
|
||||
if (add !== undefined) {
|
||||
shiftFocus(currentCell, add);
|
||||
}
|
||||
}
|
||||
if (SELECT_KEYS.includes(event.key)) {
|
||||
const cellValue = currentCell.getAttribute("data-value");
|
||||
if (!cellValue)
|
||||
return;
|
||||
handleCellClick(event, parseStringToDateValue(cellValue, placeholderValue));
|
||||
}
|
||||
}
|
||||
export function handleCalendarNextPage({ months, setMonths, numberOfMonths, pagedNavigation, weekStartsOn, locale, fixedWeeks, setPlaceholder, }) {
|
||||
const firstMonth = months[0]?.value;
|
||||
if (!firstMonth)
|
||||
return;
|
||||
if (pagedNavigation) {
|
||||
setPlaceholder(firstMonth.add({ months: numberOfMonths }));
|
||||
}
|
||||
else {
|
||||
// Calculate the target date first, then update both months and placeholder
|
||||
// to ensure they're synchronized and prevent useMonthViewPlaceholderSync from
|
||||
// double-triggering
|
||||
const targetDate = firstMonth.add({ months: 1 });
|
||||
const newMonths = createMonths({
|
||||
dateObj: targetDate,
|
||||
weekStartsOn,
|
||||
locale,
|
||||
fixedWeeks,
|
||||
numberOfMonths,
|
||||
});
|
||||
setPlaceholder(targetDate);
|
||||
setMonths(newMonths);
|
||||
}
|
||||
}
|
||||
export function handleCalendarPrevPage({ months, setMonths, numberOfMonths, pagedNavigation, weekStartsOn, locale, fixedWeeks, setPlaceholder, }) {
|
||||
const firstMonth = months[0]?.value;
|
||||
if (!firstMonth)
|
||||
return;
|
||||
if (pagedNavigation) {
|
||||
setPlaceholder(firstMonth.subtract({ months: numberOfMonths }));
|
||||
}
|
||||
else {
|
||||
// Calculate the target date first, then update both months and placeholder
|
||||
// to ensure they're synchronized and prevent useMonthViewPlaceholderSync from
|
||||
// double-triggering
|
||||
const targetDate = firstMonth.subtract({ months: 1 });
|
||||
const newMonths = createMonths({
|
||||
dateObj: targetDate,
|
||||
weekStartsOn,
|
||||
locale,
|
||||
fixedWeeks,
|
||||
numberOfMonths,
|
||||
});
|
||||
setPlaceholder(targetDate);
|
||||
setMonths(newMonths);
|
||||
}
|
||||
}
|
||||
export function getWeekdays({ months, formatter, weekdayFormat }) {
|
||||
if (!months.length)
|
||||
return [];
|
||||
const firstMonth = months[0];
|
||||
const firstWeek = firstMonth.weeks[0];
|
||||
if (!firstWeek)
|
||||
return [];
|
||||
return firstWeek.map((date) => formatter.dayOfWeek(toDate(date), weekdayFormat));
|
||||
}
|
||||
/**
|
||||
* Updates the displayed months based on changes in the options values,
|
||||
* which determines the month to show in the calendar.
|
||||
*/
|
||||
export function useMonthViewOptionsSync(props) {
|
||||
$effect(() => {
|
||||
const weekStartsOn = props.weekStartsOn.current;
|
||||
const locale = props.locale.current;
|
||||
const fixedWeeks = props.fixedWeeks.current;
|
||||
const numberOfMonths = props.numberOfMonths.current;
|
||||
untrack(() => {
|
||||
const placeholder = props.placeholder.current;
|
||||
if (!placeholder)
|
||||
return;
|
||||
const defaultMonthProps = {
|
||||
weekStartsOn,
|
||||
locale,
|
||||
fixedWeeks,
|
||||
numberOfMonths,
|
||||
};
|
||||
props.setMonths(createMonths({ ...defaultMonthProps, dateObj: placeholder }));
|
||||
});
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Creates an accessible heading element for the calendar.
|
||||
* Returns a function that removes the heading element.
|
||||
*/
|
||||
export function createAccessibleHeading({ calendarNode, label, accessibleHeadingId, }) {
|
||||
const doc = getDocument(calendarNode);
|
||||
const div = doc.createElement("div");
|
||||
div.style.cssText = styleToString({
|
||||
border: "0px",
|
||||
clip: "rect(0px, 0px, 0px, 0px)",
|
||||
clipPath: "inset(50%)",
|
||||
height: "1px",
|
||||
margin: "-1px",
|
||||
overflow: "hidden",
|
||||
padding: "0px",
|
||||
position: "absolute",
|
||||
whiteSpace: "nowrap",
|
||||
width: "1px",
|
||||
});
|
||||
const h2 = doc.createElement("div");
|
||||
h2.textContent = label;
|
||||
h2.id = accessibleHeadingId;
|
||||
h2.role = "heading";
|
||||
h2.ariaLevel = "2";
|
||||
calendarNode.insertBefore(div, calendarNode.firstChild);
|
||||
div.appendChild(h2);
|
||||
return () => {
|
||||
const h2 = doc.getElementById(accessibleHeadingId);
|
||||
if (!h2)
|
||||
return;
|
||||
div.parentElement?.removeChild(div);
|
||||
h2.remove();
|
||||
};
|
||||
}
|
||||
export function useMonthViewPlaceholderSync({ placeholder, getVisibleMonths, weekStartsOn, locale, fixedWeeks, numberOfMonths, setMonths, }) {
|
||||
$effect(() => {
|
||||
placeholder.current;
|
||||
untrack(() => {
|
||||
/**
|
||||
* If the placeholder's month is already in this visible months,
|
||||
* we don't need to do anything.
|
||||
*/
|
||||
if (getVisibleMonths().some((month) => isSameMonth(month, placeholder.current))) {
|
||||
return;
|
||||
}
|
||||
const defaultMonthProps = {
|
||||
weekStartsOn: weekStartsOn.current,
|
||||
locale: locale.current,
|
||||
fixedWeeks: fixedWeeks.current,
|
||||
numberOfMonths: numberOfMonths.current,
|
||||
};
|
||||
setMonths(createMonths({ ...defaultMonthProps, dateObj: placeholder.current }));
|
||||
});
|
||||
});
|
||||
}
|
||||
export function getIsNextButtonDisabled({ maxValue, months, disabled, }) {
|
||||
if (!maxValue || !months.length)
|
||||
return false;
|
||||
if (disabled)
|
||||
return true;
|
||||
const lastMonthInView = months[months.length - 1]?.value;
|
||||
if (!lastMonthInView)
|
||||
return false;
|
||||
const firstMonthOfNextPage = lastMonthInView
|
||||
.add({
|
||||
months: 1,
|
||||
})
|
||||
.set({ day: 1 });
|
||||
return isAfter(firstMonthOfNextPage, maxValue);
|
||||
}
|
||||
export function getIsPrevButtonDisabled({ minValue, months, disabled, }) {
|
||||
if (!minValue || !months.length)
|
||||
return false;
|
||||
if (disabled)
|
||||
return true;
|
||||
const firstMonthInView = months[0]?.value;
|
||||
if (!firstMonthInView)
|
||||
return false;
|
||||
const lastMonthOfPrevPage = firstMonthInView
|
||||
.subtract({
|
||||
months: 1,
|
||||
})
|
||||
.set({ day: 35 });
|
||||
return isBefore(lastMonthOfPrevPage, minValue);
|
||||
}
|
||||
export function getCalendarHeadingValue({ months, locale, formatter, }) {
|
||||
if (!months.length)
|
||||
return "";
|
||||
if (locale !== formatter.getLocale()) {
|
||||
formatter.setLocale(locale);
|
||||
}
|
||||
if (months.length === 1) {
|
||||
const month = toDate(months[0].value);
|
||||
return `${formatter.fullMonthAndYear(month)}`;
|
||||
}
|
||||
const startMonth = toDate(months[0].value);
|
||||
const endMonth = toDate(months[months.length - 1].value);
|
||||
const startMonthName = formatter.fullMonth(startMonth);
|
||||
const endMonthName = formatter.fullMonth(endMonth);
|
||||
const startMonthYear = formatter.fullYear(startMonth);
|
||||
const endMonthYear = formatter.fullYear(endMonth);
|
||||
const content = startMonthYear === endMonthYear
|
||||
? `${startMonthName} - ${endMonthName} ${endMonthYear}`
|
||||
: `${startMonthName} ${startMonthYear} - ${endMonthName} ${endMonthYear}`;
|
||||
return content;
|
||||
}
|
||||
export function getCalendarElementProps({ fullCalendarLabel, id, isInvalid, disabled, readonly, }) {
|
||||
return {
|
||||
id,
|
||||
role: "application",
|
||||
"aria-label": fullCalendarLabel,
|
||||
"data-invalid": boolToEmptyStrOrUndef(isInvalid),
|
||||
"data-disabled": boolToEmptyStrOrUndef(disabled),
|
||||
"data-readonly": boolToEmptyStrOrUndef(readonly),
|
||||
};
|
||||
}
|
||||
export function pickerOpenFocus(e) {
|
||||
const doc = getDocument(e.target);
|
||||
const nodeToFocus = doc.querySelector("[data-bits-day][data-focused]");
|
||||
if (nodeToFocus) {
|
||||
e.preventDefault();
|
||||
nodeToFocus?.focus();
|
||||
}
|
||||
}
|
||||
export function getFirstNonDisabledDateInView(calendarRef) {
|
||||
if (!isBrowser)
|
||||
return;
|
||||
const daysInView = Array.from(calendarRef.querySelectorAll("[data-bits-day]:not([aria-disabled=true])"));
|
||||
if (daysInView.length === 0)
|
||||
return;
|
||||
const element = daysInView[0];
|
||||
const value = element?.getAttribute("data-value");
|
||||
const type = element?.getAttribute("data-type");
|
||||
if (!value || !type)
|
||||
return;
|
||||
return parseAnyDateValue(value, type);
|
||||
}
|
||||
/**
|
||||
* Ensures the placeholder is not set to a disabled date,
|
||||
* which would prevent the user from entering the Calendar
|
||||
* via the keyboard.
|
||||
*/
|
||||
export function useEnsureNonDisabledPlaceholder({ ref, placeholder, defaultPlaceholder, minValue, maxValue, isDateDisabled, }) {
|
||||
function isDisabled(date) {
|
||||
if (isDateDisabled.current(date))
|
||||
return true;
|
||||
if (minValue.current && isBefore(date, minValue.current))
|
||||
return true;
|
||||
if (maxValue.current && isBefore(maxValue.current, date))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
watch(() => ref.current, () => {
|
||||
if (!ref.current)
|
||||
return;
|
||||
/**
|
||||
* If the placeholder is still the default placeholder and it's a disabled date, find
|
||||
* the first available date in the calendar view and set it as the placeholder.
|
||||
*
|
||||
* This prevents the placeholder from being a disabled date and no date being tabbable
|
||||
* preventing the user from entering the Calendar. If all dates in the view are
|
||||
* disabled, currently that is considered an error on the developer's part and should
|
||||
* be handled by them.
|
||||
*
|
||||
* Perhaps in the future we can introduce a dev-only log message to prevent this from
|
||||
* being a silent error.
|
||||
*/
|
||||
if (placeholder.current &&
|
||||
isSameDay(placeholder.current, defaultPlaceholder) &&
|
||||
isDisabled(defaultPlaceholder)) {
|
||||
placeholder.current =
|
||||
getFirstNonDisabledDateInView(ref.current) ?? defaultPlaceholder;
|
||||
}
|
||||
});
|
||||
}
|
||||
export function getDateWithPreviousTime(date, prev) {
|
||||
if (!date || !prev)
|
||||
return date;
|
||||
if (hasTime(date) && hasTime(prev)) {
|
||||
return date.set({
|
||||
hour: prev.hour,
|
||||
minute: prev.minute,
|
||||
millisecond: prev.millisecond,
|
||||
second: prev.second,
|
||||
});
|
||||
}
|
||||
return date;
|
||||
}
|
||||
export const calendarAttrs = createBitsAttrs({
|
||||
component: "calendar",
|
||||
parts: [
|
||||
"root",
|
||||
"grid",
|
||||
"cell",
|
||||
"next-button",
|
||||
"prev-button",
|
||||
"day",
|
||||
"grid-body",
|
||||
"grid-head",
|
||||
"grid-row",
|
||||
"head-cell",
|
||||
"header",
|
||||
"heading",
|
||||
"month-select",
|
||||
"year-select",
|
||||
],
|
||||
});
|
||||
export function getDefaultYears(opts) {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const latestYear = Math.max(opts.placeholderYear, currentYear);
|
||||
// use minValue/maxValue as boundaries if provided, otherwise calculate default range
|
||||
let minYear;
|
||||
let maxYear;
|
||||
if (opts.minValue) {
|
||||
minYear = opts.minValue.year;
|
||||
}
|
||||
else {
|
||||
// (111 years: latestYear - 100 to latestYear + 10)
|
||||
const initialMinYear = latestYear - 100;
|
||||
minYear =
|
||||
opts.placeholderYear < initialMinYear ? opts.placeholderYear - 10 : initialMinYear;
|
||||
}
|
||||
if (opts.maxValue) {
|
||||
maxYear = opts.maxValue.year;
|
||||
}
|
||||
else {
|
||||
maxYear = latestYear + 10;
|
||||
}
|
||||
// ensure we have at least one year and minYear <= maxYear
|
||||
if (minYear > maxYear) {
|
||||
minYear = maxYear;
|
||||
}
|
||||
const totalYears = maxYear - minYear + 1;
|
||||
return Array.from({ length: totalYears }, (_, i) => minYear + i);
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
import type { DateValue } from "@internationalized/date";
|
||||
import type { Formatter } from "../formatter.js";
|
||||
import type { DateAndTimeSegmentObj, DateSegmentPart, EditableSegmentPart, SegmentContentObj, SegmentPart, SegmentStateMap, SegmentValueObj } from "./types.js";
|
||||
import type { Granularity, HourCycle } from "../../../shared/date/types.js";
|
||||
export declare function initializeSegmentValues(granularity: Granularity): SegmentValueObj;
|
||||
type SharedContentProps = {
|
||||
granularity: Granularity;
|
||||
dateRef: DateValue;
|
||||
formatter: Formatter;
|
||||
hideTimeZone: boolean;
|
||||
hourCycle: HourCycle | undefined;
|
||||
};
|
||||
type CreateContentObjProps = SharedContentProps & {
|
||||
segmentValues: SegmentValueObj;
|
||||
locale: string;
|
||||
};
|
||||
type CreateContentProps = CreateContentObjProps;
|
||||
export declare function createContent(props: CreateContentProps): {
|
||||
obj: SegmentContentObj;
|
||||
arr: {
|
||||
part: SegmentPart;
|
||||
value: string;
|
||||
}[];
|
||||
};
|
||||
export declare function initSegmentStates(): SegmentStateMap;
|
||||
export declare function initSegmentIds(): any;
|
||||
export declare function isDateSegmentPart(part: unknown): part is DateSegmentPart;
|
||||
export declare function isSegmentPart(part: string): part is EditableSegmentPart;
|
||||
export declare function isAnySegmentPart(part: unknown): part is SegmentPart;
|
||||
type GetValueFromSegments = {
|
||||
segmentObj: SegmentValueObj;
|
||||
fieldNode: HTMLElement | null;
|
||||
dateRef: DateValue;
|
||||
};
|
||||
export declare function getValueFromSegments(props: GetValueFromSegments): DateValue;
|
||||
/**
|
||||
* Check if all the segments being used have been filled.
|
||||
* We use this to determine when we should set the value
|
||||
* store of the date field(s).
|
||||
*
|
||||
* @param segmentValues - The current `SegmentValueObj`
|
||||
* @param fieldNode - The id of the date field
|
||||
*/
|
||||
export declare function areAllSegmentsFilled(segmentValues: SegmentValueObj, fieldNode: HTMLElement | null): boolean;
|
||||
/**
|
||||
* Determines if the provided object is a valid `DateAndTimeSegmentObj`
|
||||
* by checking if it has the correct keys and values for each key.
|
||||
*/
|
||||
export declare function isDateAndTimeSegmentObj(obj: unknown): obj is DateAndTimeSegmentObj;
|
||||
/**
|
||||
* Infer the granularity to use based on the
|
||||
* value and granularity props.
|
||||
*/
|
||||
export declare function inferGranularity(value: DateValue, granularity: Granularity | undefined): Granularity;
|
||||
export declare function isAcceptableSegmentKey(key: string): boolean;
|
||||
/**
|
||||
* Determines if the element with the provided id is the first focusable
|
||||
* segment in the date field with the provided fieldId.
|
||||
*
|
||||
* @param id - The id of the element to check if it's the first segment
|
||||
* @param fieldNode - The id of the date field associated with the segment
|
||||
*/
|
||||
export declare function isFirstSegment(id: string, fieldNode: HTMLElement | null): boolean;
|
||||
type SetDescriptionProps = {
|
||||
id: string;
|
||||
formatter: Formatter;
|
||||
value: DateValue;
|
||||
doc: Document;
|
||||
};
|
||||
/**
|
||||
* Creates or updates a description element for a date field
|
||||
* which enables screen readers to read the date field's value.
|
||||
*
|
||||
* This element is hidden from view, and is portalled to the body
|
||||
* so it can be associated via `aria-describedby` and read by
|
||||
* screen readers as the user interacts with the date field.
|
||||
*/
|
||||
export declare function setDescription(props: SetDescriptionProps): void;
|
||||
/**
|
||||
* Removes the description element for the date field with
|
||||
* the provided ID. This function should be called when the
|
||||
* date field is unmounted.
|
||||
*/
|
||||
export declare function removeDescriptionElement(id: string, doc: Document): void;
|
||||
export declare function getDefaultHourCycle(locale: string): 12 | 24;
|
||||
export {};
|
||||
+387
@@ -0,0 +1,387 @@
|
||||
import { styleToString } from "svelte-toolbelt";
|
||||
import { getPlaceholder } from "../placeholders.js";
|
||||
import { hasTime, isZonedDateTime } from "../utils.js";
|
||||
import { ALL_SEGMENT_PARTS, DATE_SEGMENT_PARTS, EDITABLE_SEGMENT_PARTS, EDITABLE_TIME_SEGMENT_PARTS, } from "./parts.js";
|
||||
import { getSegments } from "./segments.js";
|
||||
import { isBrowser, isNull, isNumberString } from "../../is.js";
|
||||
import { useId } from "../../use-id.js";
|
||||
import { kbd } from "../../kbd.js";
|
||||
export function initializeSegmentValues(granularity) {
|
||||
const calendarDateTimeGranularities = ["hour", "minute", "second"];
|
||||
const initialParts = EDITABLE_SEGMENT_PARTS.map((part) => {
|
||||
if (part === "dayPeriod") {
|
||||
return [part, "AM"];
|
||||
}
|
||||
return [part, null];
|
||||
}).filter(([key]) => {
|
||||
if (key === "literal" || key === null)
|
||||
return false;
|
||||
if (granularity === "day") {
|
||||
return !calendarDateTimeGranularities.includes(key);
|
||||
}
|
||||
else {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
return Object.fromEntries(initialParts);
|
||||
}
|
||||
function createContentObj(props) {
|
||||
const { segmentValues, formatter, locale, dateRef } = props;
|
||||
const content = Object.keys(segmentValues).reduce((obj, part) => {
|
||||
if (!isSegmentPart(part))
|
||||
return obj;
|
||||
if ("hour" in segmentValues && part === "dayPeriod") {
|
||||
const value = segmentValues[part];
|
||||
if (!isNull(value)) {
|
||||
obj[part] = value;
|
||||
}
|
||||
else {
|
||||
obj[part] = getPlaceholder(part, "AM", locale);
|
||||
}
|
||||
}
|
||||
else {
|
||||
obj[part] = getPartContent(part);
|
||||
}
|
||||
return obj;
|
||||
}, {});
|
||||
function getPartContent(part) {
|
||||
if ("hour" in segmentValues) {
|
||||
const value = segmentValues[part];
|
||||
const leadingZero = typeof value === "string" && value?.startsWith("0");
|
||||
const intValue = value !== null ? Number.parseInt(value) : null;
|
||||
if (value === "0" && part !== "year") {
|
||||
return "0";
|
||||
}
|
||||
else if (!isNull(value) && !isNull(intValue)) {
|
||||
const formatted = formatter.part(dateRef.set({ [part]: value }), part, {
|
||||
hourCycle: props.hourCycle === 24 ? "h23" : undefined,
|
||||
});
|
||||
/**
|
||||
* If we're operating in a 12 hour clock and the part is an hour, we handle
|
||||
* the conversion to 12 hour format with 2 digit hours and leading zeros here.
|
||||
*/
|
||||
const is12HourMode = props.hourCycle === 12 ||
|
||||
(props.hourCycle === undefined && getDefaultHourCycle(locale) === 12);
|
||||
if (part === "hour" && is12HourMode) {
|
||||
/**
|
||||
* If the value is over 12, we convert to 12 hour format and add leading
|
||||
* zeroes if the value is less than 10.
|
||||
*/
|
||||
if (intValue > 12) {
|
||||
const hour = intValue - 12;
|
||||
if (hour === 0) {
|
||||
return "12";
|
||||
}
|
||||
else if (hour < 10) {
|
||||
return `0${hour}`;
|
||||
}
|
||||
else {
|
||||
return `${hour}`;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* If the value is 0, we convert to 12, since 0 is not a valid 12 hour time.
|
||||
*/
|
||||
if (intValue === 0) {
|
||||
return "12";
|
||||
}
|
||||
/**
|
||||
* If the value is less than 10, we add a leading zero to the value.
|
||||
*/
|
||||
if (intValue < 10) {
|
||||
return `0${intValue}`;
|
||||
}
|
||||
/**
|
||||
* Otherwise, we don't need to do anything to the value.
|
||||
*/
|
||||
return `${intValue}`;
|
||||
}
|
||||
if (part === "year") {
|
||||
return `${value}`;
|
||||
}
|
||||
if (leadingZero && formatted.length === 1) {
|
||||
return `0${formatted}`;
|
||||
}
|
||||
return formatted;
|
||||
}
|
||||
else {
|
||||
return getPlaceholder(part, "", locale);
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (isDateSegmentPart(part)) {
|
||||
const value = segmentValues[part];
|
||||
const leadingZero = typeof value === "string" && value?.startsWith("0");
|
||||
if (value === "0") {
|
||||
return "0";
|
||||
}
|
||||
else if (!isNull(value)) {
|
||||
const formatted = formatter.part(dateRef.set({ [part]: value }), part);
|
||||
if (part === "year") {
|
||||
return `${value}`;
|
||||
}
|
||||
if (leadingZero && formatted.length === 1) {
|
||||
return `0${formatted}`;
|
||||
}
|
||||
return formatted;
|
||||
}
|
||||
else {
|
||||
return getPlaceholder(part, "", locale);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
return content;
|
||||
}
|
||||
function createContentArr(props) {
|
||||
const { granularity, dateRef, formatter, contentObj, hideTimeZone, hourCycle } = props;
|
||||
const parts = formatter.toParts(dateRef, getOptsByGranularity(granularity, hourCycle));
|
||||
const segmentContentArr = parts
|
||||
.map((part) => {
|
||||
const defaultParts = ["literal", "dayPeriod", "timeZoneName", null];
|
||||
if (defaultParts.includes(part.type) || !isSegmentPart(part.type)) {
|
||||
return {
|
||||
part: part.type,
|
||||
value: part.value,
|
||||
};
|
||||
}
|
||||
return {
|
||||
part: part.type,
|
||||
value: contentObj[part.type],
|
||||
};
|
||||
})
|
||||
.filter((segment) => {
|
||||
if (isNull(segment.part) || isNull(segment.value))
|
||||
return false;
|
||||
if (segment.part === "timeZoneName" && (!isZonedDateTime(dateRef) || hideTimeZone)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return segmentContentArr;
|
||||
}
|
||||
export function createContent(props) {
|
||||
const contentObj = createContentObj(props);
|
||||
const contentArr = createContentArr({
|
||||
contentObj,
|
||||
...props,
|
||||
});
|
||||
return {
|
||||
obj: contentObj,
|
||||
arr: contentArr,
|
||||
};
|
||||
}
|
||||
function getOptsByGranularity(granularity, hourCycle) {
|
||||
const opts = {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
timeZoneName: "short",
|
||||
hourCycle: hourCycle === 24 ? "h23" : undefined,
|
||||
hour12: hourCycle === 24 ? false : undefined,
|
||||
};
|
||||
if (granularity === "day") {
|
||||
delete opts.second;
|
||||
delete opts.hour;
|
||||
delete opts.minute;
|
||||
delete opts.timeZoneName;
|
||||
}
|
||||
if (granularity === "hour") {
|
||||
delete opts.minute;
|
||||
}
|
||||
if (granularity === "minute") {
|
||||
delete opts.second;
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
export function initSegmentStates() {
|
||||
return EDITABLE_SEGMENT_PARTS.reduce((acc, key) => {
|
||||
acc[key] = {
|
||||
lastKeyZero: false,
|
||||
hasLeftFocus: true,
|
||||
updating: null,
|
||||
};
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
export function initSegmentIds() {
|
||||
return Object.fromEntries(ALL_SEGMENT_PARTS.map((part) => {
|
||||
return [part, useId()];
|
||||
}).filter(([key]) => key !== "literal"));
|
||||
}
|
||||
export function isDateSegmentPart(part) {
|
||||
return DATE_SEGMENT_PARTS.includes(part);
|
||||
}
|
||||
export function isSegmentPart(part) {
|
||||
return EDITABLE_SEGMENT_PARTS.includes(part);
|
||||
}
|
||||
export function isAnySegmentPart(part) {
|
||||
return ALL_SEGMENT_PARTS.includes(part);
|
||||
}
|
||||
/**
|
||||
* Get the segments being used/ are rendered in the DOM.
|
||||
* We're using this to determine when to set the value of
|
||||
* the date picker, which is when all the segments have
|
||||
* been filled.
|
||||
*/
|
||||
function getUsedSegments(fieldNode) {
|
||||
if (!isBrowser || !fieldNode)
|
||||
return [];
|
||||
const usedSegments = getSegments(fieldNode)
|
||||
.map((el) => el.dataset.segment)
|
||||
.filter((part) => {
|
||||
return EDITABLE_SEGMENT_PARTS.includes(part);
|
||||
});
|
||||
return usedSegments;
|
||||
}
|
||||
export function getValueFromSegments(props) {
|
||||
const { segmentObj, fieldNode, dateRef } = props;
|
||||
const usedSegments = getUsedSegments(fieldNode);
|
||||
let date = dateRef;
|
||||
for (const part of usedSegments) {
|
||||
if ("hour" in segmentObj) {
|
||||
const value = segmentObj[part];
|
||||
if (isNull(value))
|
||||
continue;
|
||||
date = date.set({ [part]: segmentObj[part] });
|
||||
}
|
||||
else if (isDateSegmentPart(part)) {
|
||||
const value = segmentObj[part];
|
||||
if (isNull(value))
|
||||
continue;
|
||||
date = date.set({ [part]: segmentObj[part] });
|
||||
}
|
||||
}
|
||||
return date;
|
||||
}
|
||||
/**
|
||||
* Check if all the segments being used have been filled.
|
||||
* We use this to determine when we should set the value
|
||||
* store of the date field(s).
|
||||
*
|
||||
* @param segmentValues - The current `SegmentValueObj`
|
||||
* @param fieldNode - The id of the date field
|
||||
*/
|
||||
export function areAllSegmentsFilled(segmentValues, fieldNode) {
|
||||
const usedSegments = getUsedSegments(fieldNode);
|
||||
for (const part of usedSegments) {
|
||||
if ("hour" in segmentValues) {
|
||||
if (segmentValues[part] === null) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else if (isDateSegmentPart(part)) {
|
||||
if (segmentValues[part] === null) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* Determines if the provided object is a valid `DateAndTimeSegmentObj`
|
||||
* by checking if it has the correct keys and values for each key.
|
||||
*/
|
||||
export function isDateAndTimeSegmentObj(obj) {
|
||||
if (typeof obj !== "object" || obj === null) {
|
||||
return false;
|
||||
}
|
||||
return Object.entries(obj).every(([key, value]) => {
|
||||
const validKey = EDITABLE_TIME_SEGMENT_PARTS.includes(key) ||
|
||||
DATE_SEGMENT_PARTS.includes(key);
|
||||
const validValue = key === "dayPeriod"
|
||||
? value === "AM" || value === "PM" || value === null
|
||||
: typeof value === "string" || typeof value === "number" || value === null;
|
||||
return validKey && validValue;
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Infer the granularity to use based on the
|
||||
* value and granularity props.
|
||||
*/
|
||||
export function inferGranularity(value, granularity) {
|
||||
if (granularity)
|
||||
return granularity;
|
||||
if (hasTime(value))
|
||||
return "minute";
|
||||
return "day";
|
||||
}
|
||||
export function isAcceptableSegmentKey(key) {
|
||||
const acceptableSegmentKeys = [
|
||||
kbd.ENTER,
|
||||
kbd.ARROW_UP,
|
||||
kbd.ARROW_DOWN,
|
||||
kbd.ARROW_LEFT,
|
||||
kbd.ARROW_RIGHT,
|
||||
kbd.BACKSPACE,
|
||||
kbd.SPACE,
|
||||
];
|
||||
if (acceptableSegmentKeys.includes(key))
|
||||
return true;
|
||||
if (isNumberString(key))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* Determines if the element with the provided id is the first focusable
|
||||
* segment in the date field with the provided fieldId.
|
||||
*
|
||||
* @param id - The id of the element to check if it's the first segment
|
||||
* @param fieldNode - The id of the date field associated with the segment
|
||||
*/
|
||||
export function isFirstSegment(id, fieldNode) {
|
||||
if (!isBrowser)
|
||||
return false;
|
||||
const segments = getSegments(fieldNode);
|
||||
return segments.length ? segments[0].id === id : false;
|
||||
}
|
||||
/**
|
||||
* Creates or updates a description element for a date field
|
||||
* which enables screen readers to read the date field's value.
|
||||
*
|
||||
* This element is hidden from view, and is portalled to the body
|
||||
* so it can be associated via `aria-describedby` and read by
|
||||
* screen readers as the user interacts with the date field.
|
||||
*/
|
||||
export function setDescription(props) {
|
||||
const { id, formatter, value, doc } = props;
|
||||
if (!isBrowser)
|
||||
return;
|
||||
const valueString = formatter.selectedDate(value);
|
||||
const el = doc.getElementById(id);
|
||||
if (!el) {
|
||||
const div = doc.createElement("div");
|
||||
div.style.cssText = styleToString({
|
||||
display: "none",
|
||||
});
|
||||
div.id = id;
|
||||
div.innerText = `Selected Date: ${valueString}`;
|
||||
doc.body.appendChild(div);
|
||||
}
|
||||
else {
|
||||
el.innerText = `Selected Date: ${valueString}`;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Removes the description element for the date field with
|
||||
* the provided ID. This function should be called when the
|
||||
* date field is unmounted.
|
||||
*/
|
||||
export function removeDescriptionElement(id, doc) {
|
||||
if (!isBrowser)
|
||||
return;
|
||||
const el = doc.getElementById(id);
|
||||
if (!el)
|
||||
return;
|
||||
doc.body.removeChild(el);
|
||||
}
|
||||
export function getDefaultHourCycle(locale) {
|
||||
const formatter = new Intl.DateTimeFormat(locale, { hour: "numeric" });
|
||||
const parts = formatter.formatToParts(new Date("2023-01-01T13:00:00"));
|
||||
const hourPart = parts.find((part) => part.type === "hour");
|
||||
return hourPart?.value === "1" ? 12 : 24;
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
export declare const DATE_SEGMENT_PARTS: readonly ["day", "month", "year"];
|
||||
export declare const EDITABLE_TIME_SEGMENT_PARTS: readonly ["hour", "minute", "second", "dayPeriod"];
|
||||
export declare const NON_EDITABLE_SEGMENT_PARTS: readonly ["literal", "timeZoneName"];
|
||||
export declare const EDITABLE_SEGMENT_PARTS: readonly ["day", "month", "year", "hour", "minute", "second", "dayPeriod"];
|
||||
export declare const ALL_SEGMENT_PARTS: readonly ["day", "month", "year", "hour", "minute", "second", "dayPeriod", "literal", "timeZoneName"];
|
||||
export declare const ALL_TIME_SEGMENT_PARTS: readonly ["hour", "minute", "second", "dayPeriod", "literal", "timeZoneName"];
|
||||
export declare const ALL_EXCEPT_LITERAL_PARTS: ("day" | "month" | "year" | "hour" | "minute" | "second" | "dayPeriod" | "timeZoneName")[];
|
||||
export declare const ALL_TIME_EXCEPT_LITERAL_PARTS: ("hour" | "minute" | "second" | "dayPeriod" | "timeZoneName")[];
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
export const DATE_SEGMENT_PARTS = ["day", "month", "year"];
|
||||
export const EDITABLE_TIME_SEGMENT_PARTS = ["hour", "minute", "second", "dayPeriod"];
|
||||
export const NON_EDITABLE_SEGMENT_PARTS = ["literal", "timeZoneName"];
|
||||
export const EDITABLE_SEGMENT_PARTS = [
|
||||
...DATE_SEGMENT_PARTS,
|
||||
...EDITABLE_TIME_SEGMENT_PARTS,
|
||||
];
|
||||
export const ALL_SEGMENT_PARTS = [
|
||||
...EDITABLE_SEGMENT_PARTS,
|
||||
...NON_EDITABLE_SEGMENT_PARTS,
|
||||
];
|
||||
export const ALL_TIME_SEGMENT_PARTS = [
|
||||
...EDITABLE_TIME_SEGMENT_PARTS,
|
||||
...NON_EDITABLE_SEGMENT_PARTS,
|
||||
];
|
||||
export const ALL_EXCEPT_LITERAL_PARTS = ALL_SEGMENT_PARTS.filter((part) => part !== "literal");
|
||||
export const ALL_TIME_EXCEPT_LITERAL_PARTS = ALL_TIME_SEGMENT_PARTS.filter((part) => part !== "literal");
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Handles segment navigation based on the provided keyboard event and field ID.
|
||||
*
|
||||
* @param e - The keyboard event
|
||||
* @param fieldNode - The ID of the field we're navigating within
|
||||
*/
|
||||
export declare function handleSegmentNavigation(e: KeyboardEvent, fieldNode: HTMLElement | null): void;
|
||||
export declare function handleTimeSegmentNavigation(e: KeyboardEvent, fieldNode: HTMLElement | null): void;
|
||||
/**
|
||||
* Retrieves the next segment in the list of segments relative to the provided node.
|
||||
*
|
||||
* @param node - The node we're starting from
|
||||
* @param segments - The list of candidate segments to navigate through
|
||||
*/
|
||||
export declare function getNextSegment(node: HTMLElement, segments: HTMLElement[]): HTMLElement | null | undefined;
|
||||
/**
|
||||
* Retrieves the previous segment in the list of segments relative to the provided node.
|
||||
*
|
||||
* @param node - The node we're starting from
|
||||
* @param segments - The list of candidate segments to navigate through
|
||||
*/
|
||||
export declare function getPrevSegment(node: HTMLElement, segments: HTMLElement[]): HTMLElement | null | undefined;
|
||||
/**
|
||||
* Retrieves an object containing the next and previous segments relative to the current node.
|
||||
*
|
||||
* @param startingNode - The node we're starting from
|
||||
* @param fieldNode - The ID of the field we're navigating within
|
||||
*/
|
||||
export declare function getPrevNextSegments(startingNode: HTMLElement, fieldNode: HTMLElement | null): {
|
||||
next: HTMLElement | null | undefined;
|
||||
prev: HTMLElement | null | undefined;
|
||||
};
|
||||
export declare function getPrevNextTimeSegments(startingNode: HTMLElement, fieldNode: HTMLElement | null): {
|
||||
next: HTMLElement | null | undefined;
|
||||
prev: HTMLElement | null | undefined;
|
||||
};
|
||||
/**
|
||||
* Shifts the focus to the next segment in the list of segments
|
||||
* within the field identified by the provided ID.
|
||||
*/
|
||||
export declare function moveToNextSegment(e: KeyboardEvent, fieldNode: HTMLElement | null): void;
|
||||
export declare function moveToNextTimeSegment(e: KeyboardEvent, fieldNode: HTMLElement | null): void;
|
||||
export declare function moveToPrevTimeSegment(e: KeyboardEvent, fieldNode: HTMLElement | null): void;
|
||||
/**
|
||||
* Shifts the focus to the previous segment in the list of segments
|
||||
* within the field identified by the provided ID. If this is the first
|
||||
* segment, focus will not be shifted.
|
||||
*/
|
||||
export declare function moveToPrevSegment(e: KeyboardEvent, fieldNode: HTMLElement | null): void;
|
||||
export declare function isSegmentNavigationKey(key: string): boolean;
|
||||
/**
|
||||
* Retrieves all the interactive segments within the field identified by the provided ID.
|
||||
*/
|
||||
export declare function getSegments(fieldNode: HTMLElement | null): HTMLElement[];
|
||||
export declare function getTimeSegments(fieldNode: HTMLElement | null): HTMLElement[];
|
||||
export declare function getFirstTimeSegment(fieldNode: HTMLElement | null): HTMLElement | undefined;
|
||||
/**
|
||||
* Get the first interactive segment within the field identified by the provided ID.
|
||||
*/
|
||||
export declare function getFirstSegment(fieldNode: HTMLElement | null): HTMLElement | undefined;
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
import { isAnySegmentPart } from "./helpers.js";
|
||||
import { isHTMLElement } from "../../is.js";
|
||||
import { kbd } from "../../kbd.js";
|
||||
/**
|
||||
* Handles segment navigation based on the provided keyboard event and field ID.
|
||||
*
|
||||
* @param e - The keyboard event
|
||||
* @param fieldNode - The ID of the field we're navigating within
|
||||
*/
|
||||
export function handleSegmentNavigation(e, fieldNode) {
|
||||
const currentTarget = e.currentTarget;
|
||||
if (!isHTMLElement(currentTarget))
|
||||
return;
|
||||
const { prev, next } = getPrevNextSegments(currentTarget, fieldNode);
|
||||
if (e.key === kbd.ARROW_LEFT) {
|
||||
if (!prev)
|
||||
return;
|
||||
prev.focus();
|
||||
}
|
||||
else if (e.key === kbd.ARROW_RIGHT) {
|
||||
if (!next)
|
||||
return;
|
||||
next.focus();
|
||||
}
|
||||
}
|
||||
export function handleTimeSegmentNavigation(e, fieldNode) {
|
||||
const currentTarget = e.currentTarget;
|
||||
if (!isHTMLElement(currentTarget))
|
||||
return;
|
||||
const { prev, next } = getPrevNextTimeSegments(currentTarget, fieldNode);
|
||||
if (e.key === kbd.ARROW_LEFT) {
|
||||
if (!prev)
|
||||
return;
|
||||
prev.focus();
|
||||
}
|
||||
else if (e.key === kbd.ARROW_RIGHT) {
|
||||
if (!next)
|
||||
return;
|
||||
next.focus();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Retrieves the next segment in the list of segments relative to the provided node.
|
||||
*
|
||||
* @param node - The node we're starting from
|
||||
* @param segments - The list of candidate segments to navigate through
|
||||
*/
|
||||
export function getNextSegment(node, segments) {
|
||||
const index = segments.indexOf(node);
|
||||
if (index === segments.length - 1 || index === -1)
|
||||
return null;
|
||||
const nextIndex = index + 1;
|
||||
const nextSegment = segments[nextIndex];
|
||||
return nextSegment;
|
||||
}
|
||||
/**
|
||||
* Retrieves the previous segment in the list of segments relative to the provided node.
|
||||
*
|
||||
* @param node - The node we're starting from
|
||||
* @param segments - The list of candidate segments to navigate through
|
||||
*/
|
||||
export function getPrevSegment(node, segments) {
|
||||
const index = segments.indexOf(node);
|
||||
if (index === 0 || index === -1)
|
||||
return null;
|
||||
const prevIndex = index - 1;
|
||||
const prevSegment = segments[prevIndex];
|
||||
return prevSegment;
|
||||
}
|
||||
/**
|
||||
* Retrieves an object containing the next and previous segments relative to the current node.
|
||||
*
|
||||
* @param startingNode - The node we're starting from
|
||||
* @param fieldNode - The ID of the field we're navigating within
|
||||
*/
|
||||
export function getPrevNextSegments(startingNode, fieldNode) {
|
||||
const segments = getSegments(fieldNode);
|
||||
if (!segments.length) {
|
||||
return {
|
||||
next: null,
|
||||
prev: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
next: getNextSegment(startingNode, segments),
|
||||
prev: getPrevSegment(startingNode, segments),
|
||||
};
|
||||
}
|
||||
export function getPrevNextTimeSegments(startingNode, fieldNode) {
|
||||
const segments = getTimeSegments(fieldNode);
|
||||
if (!segments.length) {
|
||||
return {
|
||||
next: null,
|
||||
prev: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
next: getNextSegment(startingNode, segments),
|
||||
prev: getPrevSegment(startingNode, segments),
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Shifts the focus to the next segment in the list of segments
|
||||
* within the field identified by the provided ID.
|
||||
*/
|
||||
export function moveToNextSegment(e, fieldNode) {
|
||||
const node = e.currentTarget;
|
||||
if (!isHTMLElement(node))
|
||||
return;
|
||||
const { next } = getPrevNextSegments(node, fieldNode);
|
||||
if (!next)
|
||||
return;
|
||||
next.focus();
|
||||
}
|
||||
export function moveToNextTimeSegment(e, fieldNode) {
|
||||
const node = e.currentTarget;
|
||||
if (!isHTMLElement(node))
|
||||
return;
|
||||
const { next } = getPrevNextTimeSegments(node, fieldNode);
|
||||
if (!next)
|
||||
return;
|
||||
next.focus();
|
||||
}
|
||||
export function moveToPrevTimeSegment(e, fieldNode) {
|
||||
const node = e.currentTarget;
|
||||
if (!isHTMLElement(node))
|
||||
return;
|
||||
const { prev } = getPrevNextTimeSegments(node, fieldNode);
|
||||
if (!prev)
|
||||
return;
|
||||
prev.focus();
|
||||
}
|
||||
/**
|
||||
* Shifts the focus to the previous segment in the list of segments
|
||||
* within the field identified by the provided ID. If this is the first
|
||||
* segment, focus will not be shifted.
|
||||
*/
|
||||
export function moveToPrevSegment(e, fieldNode) {
|
||||
const node = e.currentTarget;
|
||||
if (!isHTMLElement(node))
|
||||
return;
|
||||
const { prev } = getPrevNextSegments(node, fieldNode);
|
||||
if (!prev)
|
||||
return;
|
||||
prev.focus();
|
||||
}
|
||||
export function isSegmentNavigationKey(key) {
|
||||
if (key === kbd.ARROW_RIGHT || key === kbd.ARROW_LEFT)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* Retrieves all the interactive segments within the field identified by the provided ID.
|
||||
*/
|
||||
export function getSegments(fieldNode) {
|
||||
if (!fieldNode)
|
||||
return [];
|
||||
const segments = Array.from(fieldNode.querySelectorAll("[data-segment]")).filter((el) => {
|
||||
if (!isHTMLElement(el))
|
||||
return false;
|
||||
const segment = el.dataset.segment;
|
||||
if (segment === "trigger")
|
||||
return true;
|
||||
if (!isAnySegmentPart(segment) || segment === "literal")
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
return segments;
|
||||
}
|
||||
export function getTimeSegments(fieldNode) {
|
||||
if (!fieldNode)
|
||||
return [];
|
||||
const segments = Array.from(fieldNode.querySelectorAll("[data-segment]")).filter((el) => {
|
||||
if (!isHTMLElement(el))
|
||||
return false;
|
||||
const segment = el.dataset.segment;
|
||||
if (segment === "trigger")
|
||||
return true;
|
||||
if (segment === "literal")
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
return segments;
|
||||
}
|
||||
export function getFirstTimeSegment(fieldNode) {
|
||||
return getTimeSegments(fieldNode)[0];
|
||||
}
|
||||
/**
|
||||
* Get the first interactive segment within the field identified by the provided ID.
|
||||
*/
|
||||
export function getFirstSegment(fieldNode) {
|
||||
return getSegments(fieldNode)[0];
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
import type { EditableTimeSegmentPart, HourCycle, TimeGranularity, TimeSegmentContentObj, TimeSegmentStateMap, TimeSegmentValueObj, TimeValue } from "../../../shared/date/types.js";
|
||||
import { CalendarDateTime, Time, ZonedDateTime } from "@internationalized/date";
|
||||
import type { TimeFormatter } from "../formatter.js";
|
||||
import type { TimeSegmentPart } from "./types.js";
|
||||
export declare function initializeSegmentValues(): TimeSegmentValueObj;
|
||||
type SharedTimeContentProps = {
|
||||
granularity: TimeGranularity;
|
||||
timeRef: TimeValue;
|
||||
formatter: TimeFormatter;
|
||||
hideTimeZone: boolean;
|
||||
hourCycle: HourCycle | undefined;
|
||||
};
|
||||
type CreateTimeContentObjProps = SharedTimeContentProps & {
|
||||
segmentValues: TimeSegmentValueObj;
|
||||
locale: string;
|
||||
};
|
||||
type CreateTimeContentProps = CreateTimeContentObjProps;
|
||||
export declare function createTimeContent(props: CreateTimeContentProps): {
|
||||
obj: TimeSegmentContentObj;
|
||||
arr: {
|
||||
part: TimeSegmentPart;
|
||||
value: string;
|
||||
}[];
|
||||
};
|
||||
export declare function initTimeSegmentStates(): TimeSegmentStateMap;
|
||||
export declare function initTimeSegmentIds(): any;
|
||||
export declare function isEditableTimeSegmentPart(part: unknown): part is EditableTimeSegmentPart;
|
||||
export declare function isAnyTimeSegmentPart(part: unknown): part is TimeSegmentPart;
|
||||
type GetTimeValueFromSegments<T extends TimeValue = Time> = {
|
||||
segmentObj: TimeSegmentValueObj;
|
||||
fieldNode: HTMLElement | null;
|
||||
timeRef: T;
|
||||
};
|
||||
export declare function getTimeValueFromSegments<T extends TimeValue = Time>(props: GetTimeValueFromSegments<T>): T;
|
||||
/**
|
||||
* Check if all the segments being used have been filled.
|
||||
* We use this to determine when we should set the value
|
||||
* store of the date field(s).
|
||||
*
|
||||
* @param segmentValues - The current `SegmentValueObj`
|
||||
* @param fieldNode - The id of the date field
|
||||
*/
|
||||
export declare function areAllTimeSegmentsFilled(segmentValues: TimeSegmentValueObj, fieldNode: HTMLElement | null): boolean;
|
||||
/**
|
||||
* Infer the granularity to use based on the
|
||||
* value and granularity props.
|
||||
*/
|
||||
export declare function inferTimeGranularity(granularity: TimeGranularity | undefined): TimeGranularity;
|
||||
/**
|
||||
* Determines if the element with the provided id is the first focusable
|
||||
* segment in the date field with the provided fieldId.
|
||||
*
|
||||
* @param id - The id of the element to check if it's the first segment
|
||||
* @param fieldNode - The id of the date field associated with the segment
|
||||
*/
|
||||
export declare function isFirstTimeSegment(id: string, fieldNode: HTMLElement | null): boolean;
|
||||
type SetTimeDescriptionProps = {
|
||||
id: string;
|
||||
formatter: TimeFormatter;
|
||||
value: TimeValue;
|
||||
doc: Document;
|
||||
};
|
||||
/**
|
||||
* Creates or updates a description element for a date field
|
||||
* which enables screen readers to read the date field's value.
|
||||
*
|
||||
* This element is hidden from view, and is portalled to the body
|
||||
* so it can be associated via `aria-describedby` and read by
|
||||
* screen readers as the user interacts with the date field.
|
||||
*/
|
||||
export declare function setTimeDescription(props: SetTimeDescriptionProps): void;
|
||||
/**
|
||||
* Removes the description element for the date field with
|
||||
* the provided ID. This function should be called when the
|
||||
* date field is unmounted.
|
||||
*/
|
||||
export declare function removeTimeDescriptionElement(id: string, doc: Document): void;
|
||||
export declare function convertTimeValueToDateValue(time: TimeValue): CalendarDateTime | ZonedDateTime;
|
||||
export declare function convertTimeValueToTime(time: TimeValue): Time;
|
||||
export declare function isTimeBefore(timeToCompare: Time, referenceTime: Time): boolean;
|
||||
export declare function isTimeAfter(timeToCompare: Time, referenceTime: Time): boolean;
|
||||
export declare function getISOTimeValue(time: TimeValue): string;
|
||||
export {};
|
||||
+301
@@ -0,0 +1,301 @@
|
||||
import { isBrowser, isNull } from "../../is.js";
|
||||
import { CalendarDateTime, Time, ZonedDateTime } from "@internationalized/date";
|
||||
import { ALL_TIME_SEGMENT_PARTS, EDITABLE_TIME_SEGMENT_PARTS } from "./parts.js";
|
||||
import { getTimeSegments } from "./segments.js";
|
||||
import { styleToString } from "svelte-toolbelt";
|
||||
import { useId } from "../../use-id.js";
|
||||
import { getPlaceholder } from "../placeholders.js";
|
||||
import { isZonedDateTime } from "../utils.js";
|
||||
import { getDefaultHourCycle } from "./helpers.js";
|
||||
export function initializeSegmentValues() {
|
||||
const initialParts = EDITABLE_TIME_SEGMENT_PARTS.map((part) => {
|
||||
if (part === "dayPeriod") {
|
||||
return [part, "AM"];
|
||||
}
|
||||
return [part, null];
|
||||
}).filter(([key]) => {
|
||||
if (key === "literal" || key === null)
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
return Object.fromEntries(initialParts);
|
||||
}
|
||||
function createTimeContentObj(props) {
|
||||
const { segmentValues, formatter, locale, timeRef } = props;
|
||||
const content = Object.keys(segmentValues).reduce((obj, part) => {
|
||||
if (!isEditableTimeSegmentPart(part))
|
||||
return obj;
|
||||
if (part === "dayPeriod") {
|
||||
const value = segmentValues[part];
|
||||
if (!isNull(value)) {
|
||||
obj[part] = value;
|
||||
}
|
||||
else {
|
||||
obj[part] = getPlaceholder(part, "AM", locale);
|
||||
}
|
||||
}
|
||||
else {
|
||||
obj[part] = getPartContent(part);
|
||||
}
|
||||
return obj;
|
||||
}, {});
|
||||
function getPartContent(part) {
|
||||
const value = segmentValues[part];
|
||||
const leadingZero = typeof value === "string" && value?.startsWith("0");
|
||||
const intValue = value !== null ? Number.parseInt(value) : null;
|
||||
if (!isNull(value) && !isNull(intValue)) {
|
||||
const formatted = formatter.part(timeRef.set({ [part]: value }), part, {
|
||||
hourCycle: props.hourCycle === 24 ? "h23" : undefined,
|
||||
});
|
||||
/**
|
||||
* If we're operating in a 12 hour clock and the part is an hour, we handle
|
||||
* the conversion to 12 hour format with 2 digit hours and leading zeros here.
|
||||
*/
|
||||
const is12HourMode = props.hourCycle === 12 ||
|
||||
(props.hourCycle === undefined && getDefaultHourCycle(locale) === 12);
|
||||
if (part === "hour" && is12HourMode) {
|
||||
/**
|
||||
* If the value is over 12, we convert to 12 hour format and add leading
|
||||
* zeroes if the value is less than 10.
|
||||
*/
|
||||
if (intValue > 12) {
|
||||
const hour = intValue - 12;
|
||||
if (hour === 0) {
|
||||
return "12";
|
||||
}
|
||||
else if (hour < 10) {
|
||||
return `0${hour}`;
|
||||
}
|
||||
else {
|
||||
return `${hour}`;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* If the value is 0, we convert to 12, since 0 is not a valid 12 hour time.
|
||||
*/
|
||||
if (intValue === 0) {
|
||||
return "12";
|
||||
}
|
||||
/**
|
||||
* If the value is less than 10, we add a leading zero to the value.
|
||||
*/
|
||||
if (intValue < 10) {
|
||||
return `0${intValue}`;
|
||||
}
|
||||
/**
|
||||
* Otherwise, we don't need to do anything to the value.
|
||||
*/
|
||||
return `${intValue}`;
|
||||
}
|
||||
if (leadingZero && formatted.length === 1) {
|
||||
return `0${formatted}`;
|
||||
}
|
||||
return formatted;
|
||||
}
|
||||
else {
|
||||
return getPlaceholder(part, "", locale);
|
||||
}
|
||||
}
|
||||
return content;
|
||||
}
|
||||
function createTimeContentArr(props) {
|
||||
const { granularity, timeRef, formatter, contentObj, hideTimeZone, hourCycle } = props;
|
||||
const parts = formatter.toParts(timeRef, getOptsByGranularity(granularity, hourCycle));
|
||||
const timeSegmentContentArr = parts
|
||||
.map((part) => {
|
||||
const defaultParts = ["literal", "timeZoneName", null];
|
||||
if (defaultParts.includes(part.type) || !isEditableTimeSegmentPart(part.type)) {
|
||||
return {
|
||||
part: part.type,
|
||||
value: part.value,
|
||||
};
|
||||
}
|
||||
return {
|
||||
part: part.type,
|
||||
value: contentObj[part.type],
|
||||
};
|
||||
})
|
||||
.filter((segment) => {
|
||||
if (isNull(segment.part) || isNull(segment.value))
|
||||
return false;
|
||||
if (segment.part === "timeZoneName" && (!isZonedDateTime(timeRef) || hideTimeZone)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return timeSegmentContentArr;
|
||||
}
|
||||
export function createTimeContent(props) {
|
||||
const contentObj = createTimeContentObj(props);
|
||||
const contentArr = createTimeContentArr({
|
||||
contentObj,
|
||||
...props,
|
||||
});
|
||||
return {
|
||||
obj: contentObj,
|
||||
arr: contentArr,
|
||||
};
|
||||
}
|
||||
function getOptsByGranularity(granularity, hourCycle) {
|
||||
const opts = {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
timeZoneName: "short",
|
||||
hourCycle: hourCycle === 24 ? "h23" : undefined,
|
||||
hour12: hourCycle === 24 ? false : undefined,
|
||||
};
|
||||
if (granularity === "hour") {
|
||||
delete opts.minute;
|
||||
delete opts.second;
|
||||
}
|
||||
if (granularity === "minute") {
|
||||
delete opts.second;
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
export function initTimeSegmentStates() {
|
||||
return EDITABLE_TIME_SEGMENT_PARTS.reduce((acc, key) => {
|
||||
acc[key] = {
|
||||
lastKeyZero: false,
|
||||
hasLeftFocus: true,
|
||||
updating: null,
|
||||
};
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
export function initTimeSegmentIds() {
|
||||
return Object.fromEntries(ALL_TIME_SEGMENT_PARTS.map((part) => {
|
||||
return [part, useId()];
|
||||
}).filter(([key]) => key !== "literal"));
|
||||
}
|
||||
export function isEditableTimeSegmentPart(part) {
|
||||
return EDITABLE_TIME_SEGMENT_PARTS.includes(part);
|
||||
}
|
||||
export function isAnyTimeSegmentPart(part) {
|
||||
return ALL_TIME_SEGMENT_PARTS.includes(part);
|
||||
}
|
||||
/**
|
||||
* Get the segments being used/ are rendered in the DOM.
|
||||
* We're using this to determine when to set the value of
|
||||
* the date picker, which is when all the segments have
|
||||
* been filled.
|
||||
*/
|
||||
function getUsedTimeSegments(fieldNode) {
|
||||
if (!isBrowser || !fieldNode)
|
||||
return [];
|
||||
const usedSegments = getTimeSegments(fieldNode)
|
||||
.map((el) => el.dataset.segment)
|
||||
.filter((part) => {
|
||||
return EDITABLE_TIME_SEGMENT_PARTS.includes(part);
|
||||
});
|
||||
return usedSegments;
|
||||
}
|
||||
export function getTimeValueFromSegments(props) {
|
||||
const usedSegments = getUsedTimeSegments(props.fieldNode);
|
||||
for (const part of usedSegments) {
|
||||
const value = props.segmentObj[part];
|
||||
if (isNull(value))
|
||||
continue;
|
||||
// @ts-expect-error shhh
|
||||
props.timeRef = props.timeRef.set({ [part]: props.segmentObj[part] });
|
||||
}
|
||||
return props.timeRef;
|
||||
}
|
||||
/**
|
||||
* Check if all the segments being used have been filled.
|
||||
* We use this to determine when we should set the value
|
||||
* store of the date field(s).
|
||||
*
|
||||
* @param segmentValues - The current `SegmentValueObj`
|
||||
* @param fieldNode - The id of the date field
|
||||
*/
|
||||
export function areAllTimeSegmentsFilled(segmentValues, fieldNode) {
|
||||
const usedSegments = getUsedTimeSegments(fieldNode);
|
||||
for (const part of usedSegments) {
|
||||
if (segmentValues[part] === null)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* Infer the granularity to use based on the
|
||||
* value and granularity props.
|
||||
*/
|
||||
export function inferTimeGranularity(granularity) {
|
||||
if (granularity)
|
||||
return granularity;
|
||||
return "minute";
|
||||
}
|
||||
/**
|
||||
* Determines if the element with the provided id is the first focusable
|
||||
* segment in the date field with the provided fieldId.
|
||||
*
|
||||
* @param id - The id of the element to check if it's the first segment
|
||||
* @param fieldNode - The id of the date field associated with the segment
|
||||
*/
|
||||
export function isFirstTimeSegment(id, fieldNode) {
|
||||
if (!isBrowser)
|
||||
return false;
|
||||
const segments = getTimeSegments(fieldNode);
|
||||
return segments.length ? segments[0].id === id : false;
|
||||
}
|
||||
/**
|
||||
* Creates or updates a description element for a date field
|
||||
* which enables screen readers to read the date field's value.
|
||||
*
|
||||
* This element is hidden from view, and is portalled to the body
|
||||
* so it can be associated via `aria-describedby` and read by
|
||||
* screen readers as the user interacts with the date field.
|
||||
*/
|
||||
export function setTimeDescription(props) {
|
||||
if (!isBrowser)
|
||||
return;
|
||||
const valueString = props.formatter.selectedTime(props.value);
|
||||
const el = props.doc.getElementById(props.id);
|
||||
if (!el) {
|
||||
const div = props.doc.createElement("div");
|
||||
div.style.cssText = styleToString({
|
||||
display: "none",
|
||||
});
|
||||
div.id = props.id;
|
||||
div.innerText = `Selected Time: ${valueString}`;
|
||||
props.doc.body.appendChild(div);
|
||||
}
|
||||
else {
|
||||
el.innerText = `Selected Time: ${valueString}`;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Removes the description element for the date field with
|
||||
* the provided ID. This function should be called when the
|
||||
* date field is unmounted.
|
||||
*/
|
||||
export function removeTimeDescriptionElement(id, doc) {
|
||||
if (!isBrowser)
|
||||
return;
|
||||
const el = doc.getElementById(id);
|
||||
if (!el)
|
||||
return;
|
||||
doc.body.removeChild(el);
|
||||
}
|
||||
export function convertTimeValueToDateValue(time) {
|
||||
if (time instanceof Time) {
|
||||
return new CalendarDateTime(2020, 1, 1, time.hour, time.minute, time.second, time.millisecond);
|
||||
}
|
||||
return time;
|
||||
}
|
||||
export function convertTimeValueToTime(time) {
|
||||
if (time instanceof Time)
|
||||
return time;
|
||||
return new Time(time.hour, time.minute, time.second, time.millisecond);
|
||||
}
|
||||
export function isTimeBefore(timeToCompare, referenceTime) {
|
||||
return timeToCompare.compare(referenceTime) < 0;
|
||||
}
|
||||
export function isTimeAfter(timeToCompare, referenceTime) {
|
||||
return timeToCompare.compare(referenceTime) > 0;
|
||||
}
|
||||
export function getISOTimeValue(time) {
|
||||
return convertTimeValueToTime(time).toString();
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import type { DATE_SEGMENT_PARTS, EDITABLE_SEGMENT_PARTS, NON_EDITABLE_SEGMENT_PARTS, EDITABLE_TIME_SEGMENT_PARTS } from "./parts.js";
|
||||
export type DateSegmentPart = (typeof DATE_SEGMENT_PARTS)[number];
|
||||
export type TimeSegmentPart = (typeof EDITABLE_TIME_SEGMENT_PARTS)[number];
|
||||
export type EditableSegmentPart = (typeof EDITABLE_SEGMENT_PARTS)[number];
|
||||
export type NonEditableSegmentPart = (typeof NON_EDITABLE_SEGMENT_PARTS)[number];
|
||||
export type SegmentPart = EditableSegmentPart | NonEditableSegmentPart;
|
||||
export type AnyExceptLiteral = Exclude<SegmentPart, "literal">;
|
||||
export type DayPeriod = "AM" | "PM" | null;
|
||||
export type DateSegmentObj = {
|
||||
[K in DateSegmentPart]: string | null;
|
||||
};
|
||||
export type TimeSegmentObj = {
|
||||
[K in TimeSegmentPart]: K extends "dayPeriod" ? DayPeriod : string | null;
|
||||
};
|
||||
export type DateAndTimeSegmentObj = DateSegmentObj & TimeSegmentObj;
|
||||
export type SegmentValueObj = DateSegmentObj | DateAndTimeSegmentObj;
|
||||
export type SegmentContentObj = Record<EditableSegmentPart, string>;
|
||||
export type SegmentState = {
|
||||
lastKeyZero: boolean;
|
||||
hasLeftFocus: boolean;
|
||||
updating: string | null;
|
||||
};
|
||||
export type SegmentStateMap = {
|
||||
[K in EditableSegmentPart]: SegmentState;
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export {};
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import { type DateValue } from "@internationalized/date";
|
||||
import type { HourCycle, TimeValue } from "../../shared/date/types.js";
|
||||
import type { ReadableBox } from "svelte-toolbelt";
|
||||
export type Formatter = ReturnType<typeof createFormatter>;
|
||||
export type TimeFormatter = ReturnType<typeof createTimeFormatter>;
|
||||
type CreateFormatterOptions = {
|
||||
initialLocale: string;
|
||||
monthFormat: ReadableBox<Intl.DateTimeFormatOptions["month"] | ((month: number) => string)>;
|
||||
yearFormat: ReadableBox<Intl.DateTimeFormatOptions["year"] | ((year: number) => string)>;
|
||||
};
|
||||
/**
|
||||
* Creates a wrapper around the `DateFormatter`, which is
|
||||
* an improved version of the {@link Intl.DateTimeFormat} API,
|
||||
* that is used internally by the various date builders to
|
||||
* easily format dates in a consistent way.
|
||||
*
|
||||
* @see [DateFormatter](https://react-spectrum.adobe.com/internationalized/date/DateFormatter.html)
|
||||
*/
|
||||
export declare function createFormatter(opts: CreateFormatterOptions): {
|
||||
setLocale: (newLocale: string) => void;
|
||||
getLocale: () => string;
|
||||
fullMonth: (date: Date) => string;
|
||||
fullYear: (date: Date) => string;
|
||||
fullMonthAndYear: (date: Date) => string;
|
||||
toParts: (date: DateValue, options?: Intl.DateTimeFormatOptions) => Intl.DateTimeFormatPart[];
|
||||
custom: (date: Date, options: Intl.DateTimeFormatOptions) => string;
|
||||
part: (dateObj: DateValue, type: Intl.DateTimeFormatPartTypes, options?: Intl.DateTimeFormatOptions) => string;
|
||||
dayPeriod: (date: Date, hourCycle?: HourCycle | undefined) => "AM" | "PM";
|
||||
selectedDate: (date: DateValue, includeTime?: boolean) => string;
|
||||
dayOfWeek: (date: Date, length?: Intl.DateTimeFormatOptions["weekday"]) => string;
|
||||
};
|
||||
export declare function createTimeFormatter(initialLocale: string): {
|
||||
setLocale: (newLocale: string) => void;
|
||||
getLocale: () => string;
|
||||
toParts: (timeValue: TimeValue, options?: Intl.DateTimeFormatOptions) => Intl.DateTimeFormatPart[];
|
||||
custom: (date: Date, options: Intl.DateTimeFormatOptions) => string;
|
||||
part: (dateObj: TimeValue, type: Intl.DateTimeFormatPartTypes, options?: Intl.DateTimeFormatOptions) => string;
|
||||
dayPeriod: (date: Date, hourCycle?: HourCycle | undefined) => "AM" | "PM";
|
||||
selectedTime: (date: TimeValue) => string;
|
||||
};
|
||||
export {};
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
import { DateFormatter } from "@internationalized/date";
|
||||
import { hasTime, isZonedDateTime, toDate } from "./utils.js";
|
||||
import { convertTimeValueToDateValue } from "./field/time-helpers.js";
|
||||
const defaultPartOptions = {
|
||||
year: "numeric",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
};
|
||||
/**
|
||||
* Creates a wrapper around the `DateFormatter`, which is
|
||||
* an improved version of the {@link Intl.DateTimeFormat} API,
|
||||
* that is used internally by the various date builders to
|
||||
* easily format dates in a consistent way.
|
||||
*
|
||||
* @see [DateFormatter](https://react-spectrum.adobe.com/internationalized/date/DateFormatter.html)
|
||||
*/
|
||||
export function createFormatter(opts) {
|
||||
let locale = opts.initialLocale;
|
||||
function setLocale(newLocale) {
|
||||
locale = newLocale;
|
||||
}
|
||||
function getLocale() {
|
||||
return locale;
|
||||
}
|
||||
function custom(date, options) {
|
||||
return new DateFormatter(locale, options).format(date);
|
||||
}
|
||||
function selectedDate(date, includeTime = true) {
|
||||
if (hasTime(date) && includeTime) {
|
||||
return custom(toDate(date), {
|
||||
dateStyle: "long",
|
||||
timeStyle: "long",
|
||||
});
|
||||
}
|
||||
else {
|
||||
return custom(toDate(date), {
|
||||
dateStyle: "long",
|
||||
});
|
||||
}
|
||||
}
|
||||
function fullMonthAndYear(date) {
|
||||
if (typeof opts.monthFormat.current !== "function" &&
|
||||
typeof opts.yearFormat.current !== "function") {
|
||||
return new DateFormatter(locale, {
|
||||
month: opts.monthFormat.current,
|
||||
year: opts.yearFormat.current,
|
||||
}).format(date);
|
||||
}
|
||||
const formattedMonth = typeof opts.monthFormat.current === "function"
|
||||
? opts.monthFormat.current(date.getMonth() + 1)
|
||||
: new DateFormatter(locale, { month: opts.monthFormat.current }).format(date);
|
||||
const formattedYear = typeof opts.yearFormat.current === "function"
|
||||
? opts.yearFormat.current(date.getFullYear())
|
||||
: new DateFormatter(locale, { year: opts.yearFormat.current }).format(date);
|
||||
return `${formattedMonth} ${formattedYear}`;
|
||||
}
|
||||
function fullMonth(date) {
|
||||
return new DateFormatter(locale, { month: "long" }).format(date);
|
||||
}
|
||||
function fullYear(date) {
|
||||
return new DateFormatter(locale, { year: "numeric" }).format(date);
|
||||
}
|
||||
function toParts(date, options) {
|
||||
if (isZonedDateTime(date)) {
|
||||
return new DateFormatter(locale, {
|
||||
...options,
|
||||
timeZone: date.timeZone,
|
||||
}).formatToParts(toDate(date));
|
||||
}
|
||||
else {
|
||||
return new DateFormatter(locale, options).formatToParts(toDate(date));
|
||||
}
|
||||
}
|
||||
function dayOfWeek(date, length = "narrow") {
|
||||
return new DateFormatter(locale, { weekday: length }).format(date);
|
||||
}
|
||||
function dayPeriod(date, hourCycle = undefined) {
|
||||
const parts = new DateFormatter(locale, {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hourCycle: hourCycle === 24 ? "h23" : undefined,
|
||||
}).formatToParts(date);
|
||||
const value = parts.find((p) => p.type === "dayPeriod")?.value;
|
||||
if (value === "PM") {
|
||||
return "PM";
|
||||
}
|
||||
return "AM";
|
||||
}
|
||||
function part(dateObj, type, options = {}) {
|
||||
const opts = { ...defaultPartOptions, ...options };
|
||||
const parts = toParts(dateObj, opts);
|
||||
const part = parts.find((p) => p.type === type);
|
||||
return part ? part.value : "";
|
||||
}
|
||||
return {
|
||||
setLocale,
|
||||
getLocale,
|
||||
fullMonth,
|
||||
fullYear,
|
||||
fullMonthAndYear,
|
||||
toParts,
|
||||
custom,
|
||||
part,
|
||||
dayPeriod,
|
||||
selectedDate,
|
||||
dayOfWeek,
|
||||
};
|
||||
}
|
||||
export function createTimeFormatter(initialLocale) {
|
||||
let locale = initialLocale;
|
||||
function setLocale(newLocale) {
|
||||
locale = newLocale;
|
||||
}
|
||||
function getLocale() {
|
||||
return locale;
|
||||
}
|
||||
function custom(date, options) {
|
||||
return new DateFormatter(locale, options).format(date);
|
||||
}
|
||||
function selectedTime(date) {
|
||||
return custom(toDate(convertTimeValueToDateValue(date)), {
|
||||
timeStyle: "long",
|
||||
});
|
||||
}
|
||||
function toParts(timeValue, options) {
|
||||
const dateValue = convertTimeValueToDateValue(timeValue);
|
||||
if (isZonedDateTime(dateValue)) {
|
||||
return new DateFormatter(locale, {
|
||||
...options,
|
||||
timeZone: dateValue.timeZone,
|
||||
}).formatToParts(toDate(dateValue));
|
||||
}
|
||||
else {
|
||||
return new DateFormatter(locale, options).formatToParts(toDate(dateValue));
|
||||
}
|
||||
}
|
||||
function dayPeriod(date, hourCycle = undefined) {
|
||||
const parts = new DateFormatter(locale, {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hourCycle: hourCycle === 24 ? "h23" : undefined,
|
||||
}).formatToParts(date);
|
||||
const value = parts.find((p) => p.type === "dayPeriod")?.value;
|
||||
if (value === "PM")
|
||||
return "PM";
|
||||
return "AM";
|
||||
}
|
||||
function part(dateObj, type, options = {}) {
|
||||
const opts = { ...defaultPartOptions, ...options };
|
||||
const parts = toParts(dateObj, opts);
|
||||
const part = parts.find((p) => p.type === type);
|
||||
return part ? part.value : "";
|
||||
}
|
||||
return {
|
||||
setLocale,
|
||||
getLocale,
|
||||
toParts,
|
||||
custom,
|
||||
part,
|
||||
dayPeriod,
|
||||
selectedTime,
|
||||
};
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
declare const supportedLocales: readonly ["ach", "af", "am", "an", "ar", "ast", "az", "be", "bg", "bn", "br", "bs", "ca", "cak", "ckb", "cs", "cy", "da", "de", "dsb", "el", "en", "eo", "es", "et", "eu", "fa", "ff", "fi", "fr", "fy", "ga", "gd", "gl", "he", "hr", "hsb", "hu", "ia", "id", "it", "ja", "ka", "kk", "kn", "ko", "lb", "lo", "lt", "lv", "meh", "ml", "ms", "nl", "nn", "no", "oc", "pl", "pt", "rm", "ro", "ru", "sc", "scn", "sk", "sl", "sr", "sv", "szl", "tg", "th", "tr", "uk", "zh-CN", "zh-TW"];
|
||||
declare const placeholderFields: readonly ["year", "month", "day"];
|
||||
type SupportedLocale = (typeof supportedLocales)[number];
|
||||
type PlaceholderField = (typeof placeholderFields)[number];
|
||||
export type PlaceholderMap = Record<SupportedLocale, Record<PlaceholderField, string>>;
|
||||
type Field = "era" | "year" | "month" | "day" | "hour" | "minute" | "second" | "dayPeriod";
|
||||
export declare function getPlaceholder(field: Field, value: string, locale: SupportedLocale | (string & {})): string;
|
||||
export {};
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
// prettier-ignore
|
||||
const supportedLocales = [
|
||||
'ach', 'af', 'am', 'an', 'ar', 'ast', 'az', 'be', 'bg', 'bn', 'br', 'bs',
|
||||
'ca', 'cak', 'ckb', 'cs', 'cy', 'da', 'de', 'dsb', 'el', 'en', 'eo', 'es',
|
||||
'et', 'eu', 'fa', 'ff', 'fi', 'fr', 'fy', 'ga', 'gd', 'gl', 'he', 'hr',
|
||||
'hsb', 'hu', 'ia', 'id', 'it', 'ja', 'ka', 'kk', 'kn', 'ko', 'lb', 'lo',
|
||||
'lt', 'lv', 'meh', 'ml', 'ms', 'nl', 'nn', 'no', 'oc', 'pl', 'pt', 'rm',
|
||||
'ro', 'ru', 'sc', 'scn', 'sk', 'sl', 'sr', 'sv', 'szl', 'tg', 'th', 'tr',
|
||||
'uk', 'zh-CN', 'zh-TW',
|
||||
];
|
||||
const placeholderFields = ["year", "month", "day"];
|
||||
const placeholders = {
|
||||
ach: { year: "mwaka", month: "dwe", day: "nino" },
|
||||
af: { year: "jjjj", month: "mm", day: "dd" },
|
||||
am: { year: "ዓዓዓዓ", month: "ሚሜ", day: "ቀቀ" },
|
||||
an: { year: "aaaa", month: "mm", day: "dd" },
|
||||
ar: { year: "سنة", month: "شهر", day: "يوم" },
|
||||
ast: { year: "aaaa", month: "mm", day: "dd" },
|
||||
az: { year: "iiii", month: "aa", day: "gg" },
|
||||
be: { year: "гггг", month: "мм", day: "дд" },
|
||||
bg: { year: "гггг", month: "мм", day: "дд" },
|
||||
bn: { year: "yyyy", month: "মিমি", day: "dd" },
|
||||
br: { year: "bbbb", month: "mm", day: "dd" },
|
||||
bs: { year: "gggg", month: "mm", day: "dd" },
|
||||
ca: { year: "aaaa", month: "mm", day: "dd" },
|
||||
cak: { year: "jjjj", month: "ii", day: "q'q'" },
|
||||
ckb: { year: "ساڵ", month: "مانگ", day: "ڕۆژ" },
|
||||
cs: { year: "rrrr", month: "mm", day: "dd" },
|
||||
cy: { year: "bbbb", month: "mm", day: "dd" },
|
||||
da: { year: "åååå", month: "mm", day: "dd" },
|
||||
de: { year: "jjjj", month: "mm", day: "tt" },
|
||||
dsb: { year: "llll", month: "mm", day: "źź" },
|
||||
el: { year: "εεεε", month: "μμ", day: "ηη" },
|
||||
en: { year: "yyyy", month: "mm", day: "dd" },
|
||||
eo: { year: "jjjj", month: "mm", day: "tt" },
|
||||
es: { year: "aaaa", month: "mm", day: "dd" },
|
||||
et: { year: "aaaa", month: "kk", day: "pp" },
|
||||
eu: { year: "uuuu", month: "hh", day: "ee" },
|
||||
fa: { year: "سال", month: "ماه", day: "روز" },
|
||||
ff: { year: "hhhh", month: "ll", day: "ññ" },
|
||||
fi: { year: "vvvv", month: "kk", day: "pp" },
|
||||
fr: { year: "aaaa", month: "mm", day: "jj" },
|
||||
fy: { year: "jjjj", month: "mm", day: "dd" },
|
||||
ga: { year: "bbbb", month: "mm", day: "ll" },
|
||||
gd: { year: "bbbb", month: "mm", day: "ll" },
|
||||
gl: { year: "aaaa", month: "mm", day: "dd" },
|
||||
he: { year: "שנה", month: "חודש", day: "יום" },
|
||||
hr: { year: "gggg", month: "mm", day: "dd" },
|
||||
hsb: { year: "llll", month: "mm", day: "dd" },
|
||||
hu: { year: "éééé", month: "hh", day: "nn" },
|
||||
ia: { year: "aaaa", month: "mm", day: "dd" },
|
||||
id: { year: "tttt", month: "bb", day: "hh" },
|
||||
it: { year: "aaaa", month: "mm", day: "gg" },
|
||||
ja: { year: " 年 ", month: "月", day: "日" },
|
||||
ka: { year: "წწწწ", month: "თთ", day: "რრ" },
|
||||
kk: { year: "жжжж", month: "аа", day: "кк" },
|
||||
kn: { year: "ವವವವ", month: "ಮಿಮೀ", day: "ದಿದಿ" },
|
||||
ko: { year: "연도", month: "월", day: "일" },
|
||||
lb: { year: "jjjj", month: "mm", day: "dd" },
|
||||
lo: { year: "ປປປປ", month: "ດດ", day: "ວວ" },
|
||||
lt: { year: "mmmm", month: "mm", day: "dd" },
|
||||
lv: { year: "gggg", month: "mm", day: "dd" },
|
||||
meh: { year: "aaaa", month: "mm", day: "dd" },
|
||||
ml: { year: "വർഷം", month: "മാസം", day: "തീയതി" },
|
||||
ms: { year: "tttt", month: "mm", day: "hh" },
|
||||
nl: { year: "jjjj", month: "mm", day: "dd" },
|
||||
nn: { year: "åååå", month: "mm", day: "dd" },
|
||||
no: { year: "åååå", month: "mm", day: "dd" },
|
||||
oc: { year: "aaaa", month: "mm", day: "jj" },
|
||||
pl: { year: "rrrr", month: "mm", day: "dd" },
|
||||
pt: { year: "aaaa", month: "mm", day: "dd" },
|
||||
rm: { year: "oooo", month: "mm", day: "dd" },
|
||||
ro: { year: "aaaa", month: "ll", day: "zz" },
|
||||
ru: { year: "гггг", month: "мм", day: "дд" },
|
||||
sc: { year: "aaaa", month: "mm", day: "dd" },
|
||||
scn: { year: "aaaa", month: "mm", day: "jj" },
|
||||
sk: { year: "rrrr", month: "mm", day: "dd" },
|
||||
sl: { year: "llll", month: "mm", day: "dd" },
|
||||
sr: { year: "гггг", month: "мм", day: "дд" },
|
||||
sv: { year: "åååå", month: "mm", day: "dd" },
|
||||
szl: { year: "rrrr", month: "mm", day: "dd" },
|
||||
tg: { year: "сссс", month: "мм", day: "рр" },
|
||||
th: { year: "ปปปป", month: "ดด", day: "วว" },
|
||||
tr: { year: "yyyy", month: "aa", day: "gg" },
|
||||
uk: { year: "рррр", month: "мм", day: "дд" },
|
||||
"zh-CN": { year: "年", month: "月", day: "日" },
|
||||
"zh-TW": { year: "年", month: "月", day: "日" },
|
||||
};
|
||||
function getPlaceholderObj(locale) {
|
||||
if (!isSupportedLocale(locale)) {
|
||||
const localeLanguage = getLocaleLanguage(locale);
|
||||
if (!isSupportedLocale(localeLanguage)) {
|
||||
return placeholders.en;
|
||||
}
|
||||
else {
|
||||
return placeholders[localeLanguage];
|
||||
}
|
||||
}
|
||||
else {
|
||||
return placeholders[locale];
|
||||
}
|
||||
}
|
||||
export function getPlaceholder(field, value, locale) {
|
||||
if (isPlaceholderField(field))
|
||||
return getPlaceholderObj(locale)[field];
|
||||
if (isDefaultField(field))
|
||||
return value;
|
||||
if (isTimeField(field))
|
||||
return "––";
|
||||
return "";
|
||||
}
|
||||
function isSupportedLocale(locale) {
|
||||
return supportedLocales.includes(locale);
|
||||
}
|
||||
function isPlaceholderField(field) {
|
||||
return placeholderFields.includes(field);
|
||||
}
|
||||
function isTimeField(field) {
|
||||
return field === "hour" || field === "minute" || field === "second";
|
||||
}
|
||||
function isDefaultField(field) {
|
||||
return field === "era" || field === "dayPeriod";
|
||||
}
|
||||
function getLocaleLanguage(locale) {
|
||||
if (Intl.Locale) {
|
||||
return new Intl.Locale(locale).language;
|
||||
}
|
||||
return locale.split("-")[0];
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import { CalendarDateTime, type DateValue, ZonedDateTime } from "@internationalized/date";
|
||||
import type { DateMatcher, Granularity, TimeGranularity, TimeValue } from "../../shared/date/types.js";
|
||||
type GetDefaultDateProps = {
|
||||
defaultValue?: DateValue | DateValue[] | undefined;
|
||||
minValue?: DateValue;
|
||||
maxValue?: DateValue;
|
||||
granularity?: Granularity;
|
||||
};
|
||||
type GetDefaultTimeProps = {
|
||||
defaultValue?: TimeValue | undefined;
|
||||
granularity?: TimeGranularity;
|
||||
};
|
||||
/**
|
||||
* A helper function used throughout the various date builders
|
||||
* to generate a default `DateValue` using the `defaultValue`,
|
||||
* `defaultPlaceholder`, `minValue`, `maxValue`, and `granularity` props.
|
||||
*
|
||||
* It's important to match the `DateValue` type being used
|
||||
* elsewhere in the builder, so they behave according to the
|
||||
* behavior the user expects based on the props they've provided.
|
||||
*
|
||||
*/
|
||||
export declare function getDefaultDate(opts: GetDefaultDateProps): DateValue;
|
||||
export declare function getDefaultTime(opts: GetDefaultTimeProps): TimeValue;
|
||||
/**
|
||||
* Given a date string and a reference `DateValue` object, parse the
|
||||
* string to the same type as the reference object.
|
||||
*
|
||||
* Useful for parsing strings from data attributes, which are always
|
||||
* strings, to the same type being used by the date component.
|
||||
*/
|
||||
export declare function parseStringToDateValue(dateStr: string, referenceVal: DateValue): DateValue;
|
||||
/**
|
||||
* Given a `DateValue` object, convert it to a native `Date` object.
|
||||
* If a timezone is provided, the date will be converted to that timezone.
|
||||
* If no timezone is provided, the date will be converted to the local timezone.
|
||||
*/
|
||||
export declare function toDate(dateValue: DateValue, tz?: string): Date;
|
||||
export declare function getDateValueType(date: DateValue): string;
|
||||
export declare function parseAnyDateValue(value: string, type: string): DateValue;
|
||||
export declare function isZonedDateTime(dateValue: DateValue | TimeValue): dateValue is ZonedDateTime;
|
||||
export declare function hasTime(dateValue: DateValue): dateValue is CalendarDateTime | ZonedDateTime;
|
||||
/**
|
||||
* Given a date, return the number of days in the month.
|
||||
*/
|
||||
export declare function getDaysInMonth(date: Date | DateValue): number;
|
||||
/**
|
||||
* Determine if a date is before the reference date.
|
||||
* @param dateToCompare - is this date before the `referenceDate`
|
||||
* @param referenceDate - is the `dateToCompare` before this date
|
||||
*
|
||||
* @see {@link isBeforeOrSame} for inclusive
|
||||
*/
|
||||
export declare function isBefore(dateToCompare: DateValue, referenceDate: DateValue): boolean;
|
||||
/**
|
||||
* Determine if a date is after the reference date.
|
||||
* @param dateToCompare - is this date after the `referenceDate`
|
||||
* @param referenceDate - is the `dateToCompare` after this date
|
||||
*
|
||||
* @see {@link isAfterOrSame} for inclusive
|
||||
*/
|
||||
export declare function isAfter(dateToCompare: DateValue, referenceDate: DateValue): boolean;
|
||||
/**
|
||||
* Determine if a date is inclusively between a start and end reference date.
|
||||
*
|
||||
* @param date - is this date inclusively between the `start` and `end` dates
|
||||
* @param start - the start reference date to make the comparison against
|
||||
* @param end - the end reference date to make the comparison against
|
||||
*
|
||||
* @see {@link isBetween} for non-inclusive
|
||||
*/
|
||||
export declare function isBetweenInclusive(date: DateValue, start: DateValue, end: DateValue): boolean;
|
||||
export declare function getLastFirstDayOfWeek<T extends DateValue = DateValue>(date: T, firstDayOfWeek: number, locale: string): T;
|
||||
export declare function getNextLastDayOfWeek<T extends DateValue = DateValue>(date: T, firstDayOfWeek: number, locale: string): T;
|
||||
export declare function areAllDaysBetweenValid(start: DateValue, end: DateValue, isUnavailable: DateMatcher | undefined, isDisabled: DateMatcher | undefined): boolean;
|
||||
export {};
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
import { CalendarDate, CalendarDateTime, Time, ZonedDateTime, getDayOfWeek, getLocalTimeZone, parseDate, parseDateTime, parseZonedDateTime, toCalendar, } from "@internationalized/date";
|
||||
const defaultDateDefaults = {
|
||||
defaultValue: undefined,
|
||||
granularity: "day",
|
||||
};
|
||||
const defaultTimeDefaults = {
|
||||
defaultValue: undefined,
|
||||
granularity: "minute",
|
||||
};
|
||||
/**
|
||||
* A helper function used throughout the various date builders
|
||||
* to generate a default `DateValue` using the `defaultValue`,
|
||||
* `defaultPlaceholder`, `minValue`, `maxValue`, and `granularity` props.
|
||||
*
|
||||
* It's important to match the `DateValue` type being used
|
||||
* elsewhere in the builder, so they behave according to the
|
||||
* behavior the user expects based on the props they've provided.
|
||||
*
|
||||
*/
|
||||
export function getDefaultDate(opts) {
|
||||
const withDefaults = { ...defaultDateDefaults, ...opts };
|
||||
const { defaultValue, granularity, minValue, maxValue } = withDefaults;
|
||||
if (Array.isArray(defaultValue) && defaultValue.length) {
|
||||
return defaultValue[defaultValue.length - 1];
|
||||
}
|
||||
if (defaultValue && !Array.isArray(defaultValue)) {
|
||||
return defaultValue;
|
||||
}
|
||||
else {
|
||||
let date = new Date();
|
||||
if (minValue && date < minValue.toDate(getLocalTimeZone())) {
|
||||
date = minValue.toDate(getLocalTimeZone());
|
||||
}
|
||||
else if (maxValue && date > maxValue.toDate(getLocalTimeZone())) {
|
||||
date = maxValue.toDate(getLocalTimeZone());
|
||||
}
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth() + 1;
|
||||
const day = date.getDate();
|
||||
const calendarDateTimeGranularities = ["hour", "minute", "second"];
|
||||
if (calendarDateTimeGranularities.includes(granularity ?? "day")) {
|
||||
return new CalendarDateTime(year, month, day, 0, 0, 0);
|
||||
}
|
||||
return new CalendarDate(year, month, day);
|
||||
}
|
||||
}
|
||||
export function getDefaultTime(opts) {
|
||||
const withDefaults = { ...defaultTimeDefaults, ...opts };
|
||||
const { defaultValue } = withDefaults;
|
||||
if (defaultValue) {
|
||||
return defaultValue;
|
||||
}
|
||||
else {
|
||||
return new Time(0, 0, 0);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Given a date string and a reference `DateValue` object, parse the
|
||||
* string to the same type as the reference object.
|
||||
*
|
||||
* Useful for parsing strings from data attributes, which are always
|
||||
* strings, to the same type being used by the date component.
|
||||
*/
|
||||
export function parseStringToDateValue(dateStr, referenceVal) {
|
||||
let dateValue;
|
||||
if (referenceVal instanceof ZonedDateTime) {
|
||||
dateValue = parseZonedDateTime(dateStr);
|
||||
}
|
||||
else if (referenceVal instanceof CalendarDateTime) {
|
||||
dateValue = parseDateTime(dateStr);
|
||||
}
|
||||
else {
|
||||
dateValue = parseDate(dateStr);
|
||||
}
|
||||
// ensure the parsed date is in the same calendar as the reference date set by the user.
|
||||
return dateValue.calendar !== referenceVal.calendar
|
||||
? toCalendar(dateValue, referenceVal.calendar)
|
||||
: dateValue;
|
||||
}
|
||||
/**
|
||||
* Given a `DateValue` object, convert it to a native `Date` object.
|
||||
* If a timezone is provided, the date will be converted to that timezone.
|
||||
* If no timezone is provided, the date will be converted to the local timezone.
|
||||
*/
|
||||
export function toDate(dateValue, tz = getLocalTimeZone()) {
|
||||
if (dateValue instanceof ZonedDateTime) {
|
||||
return dateValue.toDate();
|
||||
}
|
||||
else {
|
||||
return dateValue.toDate(tz);
|
||||
}
|
||||
}
|
||||
export function getDateValueType(date) {
|
||||
if (date instanceof CalendarDate)
|
||||
return "date";
|
||||
if (date instanceof CalendarDateTime)
|
||||
return "datetime";
|
||||
if (date instanceof ZonedDateTime)
|
||||
return "zoneddatetime";
|
||||
throw new Error("Unknown date type");
|
||||
}
|
||||
export function parseAnyDateValue(value, type) {
|
||||
switch (type) {
|
||||
case "date":
|
||||
return parseDate(value);
|
||||
case "datetime":
|
||||
return parseDateTime(value);
|
||||
case "zoneddatetime":
|
||||
return parseZonedDateTime(value);
|
||||
default:
|
||||
throw new Error(`Unknown date type: ${type}`);
|
||||
}
|
||||
}
|
||||
function isCalendarDateTime(dateValue) {
|
||||
return dateValue instanceof CalendarDateTime;
|
||||
}
|
||||
export function isZonedDateTime(dateValue) {
|
||||
return dateValue instanceof ZonedDateTime;
|
||||
}
|
||||
export function hasTime(dateValue) {
|
||||
return isCalendarDateTime(dateValue) || isZonedDateTime(dateValue);
|
||||
}
|
||||
/**
|
||||
* Given a date, return the number of days in the month.
|
||||
*/
|
||||
export function getDaysInMonth(date) {
|
||||
if (date instanceof Date) {
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth() + 1;
|
||||
/**
|
||||
* By using zero as the day, we get the
|
||||
* last day of the previous month, which
|
||||
* is the month we originally passed in.
|
||||
*/
|
||||
return new Date(year, month, 0).getDate();
|
||||
}
|
||||
else {
|
||||
return date.set({ day: 100 }).day;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Determine if a date is before the reference date.
|
||||
* @param dateToCompare - is this date before the `referenceDate`
|
||||
* @param referenceDate - is the `dateToCompare` before this date
|
||||
*
|
||||
* @see {@link isBeforeOrSame} for inclusive
|
||||
*/
|
||||
export function isBefore(dateToCompare, referenceDate) {
|
||||
return dateToCompare.compare(referenceDate) < 0;
|
||||
}
|
||||
/**
|
||||
* Determine if a date is after the reference date.
|
||||
* @param dateToCompare - is this date after the `referenceDate`
|
||||
* @param referenceDate - is the `dateToCompare` after this date
|
||||
*
|
||||
* @see {@link isAfterOrSame} for inclusive
|
||||
*/
|
||||
export function isAfter(dateToCompare, referenceDate) {
|
||||
return dateToCompare.compare(referenceDate) > 0;
|
||||
}
|
||||
/**
|
||||
* Determine if a date is before or the same as the reference date.
|
||||
*
|
||||
* @param dateToCompare - the date to compare
|
||||
* @param referenceDate - the reference date to make the comparison against
|
||||
*
|
||||
* @see {@link isBefore} for non-inclusive
|
||||
*/
|
||||
function isBeforeOrSame(dateToCompare, referenceDate) {
|
||||
return dateToCompare.compare(referenceDate) <= 0;
|
||||
}
|
||||
/**
|
||||
* Determine if a date is after or the same as the reference date.
|
||||
*
|
||||
* @param dateToCompare - is this date after or the same as the `referenceDate`
|
||||
* @param referenceDate - is the `dateToCompare` after or the same as this date
|
||||
*
|
||||
* @see {@link isAfter} for non-inclusive
|
||||
*/
|
||||
function isAfterOrSame(dateToCompare, referenceDate) {
|
||||
return dateToCompare.compare(referenceDate) >= 0;
|
||||
}
|
||||
/**
|
||||
* Determine if a date is inclusively between a start and end reference date.
|
||||
*
|
||||
* @param date - is this date inclusively between the `start` and `end` dates
|
||||
* @param start - the start reference date to make the comparison against
|
||||
* @param end - the end reference date to make the comparison against
|
||||
*
|
||||
* @see {@link isBetween} for non-inclusive
|
||||
*/
|
||||
export function isBetweenInclusive(date, start, end) {
|
||||
return isAfterOrSame(date, start) && isBeforeOrSame(date, end);
|
||||
}
|
||||
export function getLastFirstDayOfWeek(date, firstDayOfWeek, locale) {
|
||||
const day = getDayOfWeek(date, locale);
|
||||
if (firstDayOfWeek > day) {
|
||||
return date.subtract({ days: day + 7 - firstDayOfWeek });
|
||||
}
|
||||
if (firstDayOfWeek === day) {
|
||||
return date;
|
||||
}
|
||||
return date.subtract({ days: day - firstDayOfWeek });
|
||||
}
|
||||
export function getNextLastDayOfWeek(date, firstDayOfWeek, locale) {
|
||||
const day = getDayOfWeek(date, locale);
|
||||
const lastDayOfWeek = firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1;
|
||||
if (day === lastDayOfWeek) {
|
||||
return date;
|
||||
}
|
||||
if (day > lastDayOfWeek) {
|
||||
return date.add({ days: 7 - day + lastDayOfWeek });
|
||||
}
|
||||
return date.add({ days: lastDayOfWeek - day });
|
||||
}
|
||||
export function areAllDaysBetweenValid(start, end, isUnavailable, isDisabled) {
|
||||
if (isUnavailable === undefined && isDisabled === undefined) {
|
||||
return true;
|
||||
}
|
||||
let dCurrent = start.add({ days: 1 });
|
||||
if (isDisabled?.(dCurrent) || isUnavailable?.(dCurrent)) {
|
||||
return false;
|
||||
}
|
||||
const dEnd = end;
|
||||
while (dCurrent.compare(dEnd) < 0) {
|
||||
dCurrent = dCurrent.add({ days: 1 });
|
||||
if (isDisabled?.(dCurrent) || isUnavailable?.(dCurrent)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export declare function debounce<T extends (...args: any[]) => any>(fn: T, wait?: number): {
|
||||
(...args: Parameters<T>): void;
|
||||
destroy(): void;
|
||||
};
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
// oxlint-disable-next-line no-explicit-any
|
||||
export function debounce(fn, wait = 500) {
|
||||
let timeout = null;
|
||||
const debounced = (...args) => {
|
||||
if (timeout !== null) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
timeout = setTimeout(() => {
|
||||
fn(...args);
|
||||
}, wait);
|
||||
};
|
||||
debounced.destroy = () => {
|
||||
if (timeout !== null) {
|
||||
clearTimeout(timeout);
|
||||
timeout = null;
|
||||
}
|
||||
};
|
||||
return debounced;
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
type DOMTypeaheadOptions = {
|
||||
onMatch?: (item: HTMLElement) => void;
|
||||
getCurrentItem?: () => HTMLElement | null;
|
||||
getActiveElement: () => HTMLElement | null;
|
||||
getWindow: () => Window & typeof globalThis;
|
||||
};
|
||||
export declare class DOMTypeahead {
|
||||
#private;
|
||||
constructor(opts: DOMTypeaheadOptions);
|
||||
handleTypeaheadSearch(key: string, candidates: HTMLElement[]): HTMLElement | undefined;
|
||||
resetTypeahead(): void;
|
||||
get search(): string;
|
||||
}
|
||||
export {};
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import { getNextMatch } from "./arrays.js";
|
||||
import { boxAutoReset } from "./box-auto-reset.svelte.js";
|
||||
export class DOMTypeahead {
|
||||
#opts;
|
||||
#search;
|
||||
#onMatch = $derived.by(() => {
|
||||
if (this.#opts.onMatch)
|
||||
return this.#opts.onMatch;
|
||||
return (node) => node.focus();
|
||||
});
|
||||
#getCurrentItem = $derived.by(() => {
|
||||
if (this.#opts.getCurrentItem)
|
||||
return this.#opts.getCurrentItem;
|
||||
return this.#opts.getActiveElement;
|
||||
});
|
||||
constructor(opts) {
|
||||
this.#opts = opts;
|
||||
this.#search = boxAutoReset("", {
|
||||
afterMs: 1000,
|
||||
getWindow: opts.getWindow,
|
||||
});
|
||||
this.handleTypeaheadSearch = this.handleTypeaheadSearch.bind(this);
|
||||
this.resetTypeahead = this.resetTypeahead.bind(this);
|
||||
}
|
||||
handleTypeaheadSearch(key, candidates) {
|
||||
if (!candidates.length)
|
||||
return;
|
||||
this.#search.current = this.#search.current + key;
|
||||
const currentItem = this.#getCurrentItem();
|
||||
const currentMatch = candidates.find((item) => item === currentItem)?.textContent?.trim() ?? "";
|
||||
const values = candidates.map((item) => item.textContent?.trim() ?? "");
|
||||
const nextMatch = getNextMatch(values, this.#search.current, currentMatch);
|
||||
const newItem = candidates.find((item) => item.textContent?.trim() === nextMatch);
|
||||
if (newItem)
|
||||
this.#onMatch(newItem);
|
||||
return newItem;
|
||||
}
|
||||
resetTypeahead() {
|
||||
this.#search.current = "";
|
||||
}
|
||||
get search() {
|
||||
return this.#search.current;
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export declare function getFirstNonCommentChild(element: HTMLElement | null): ChildNode | null;
|
||||
/**
|
||||
* Determines if the click event truly occurred outside the content node.
|
||||
* This was added to handle password managers and other elements that may be injected
|
||||
* into the DOM but visually appear inside the content.
|
||||
*/
|
||||
export declare function isClickTrulyOutside(event: PointerEvent, contentNode: HTMLElement): boolean;
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
export function getFirstNonCommentChild(element) {
|
||||
if (!element)
|
||||
return null;
|
||||
for (const child of element.childNodes) {
|
||||
if (child.nodeType !== Node.COMMENT_NODE) {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* Determines if the click event truly occurred outside the content node.
|
||||
* This was added to handle password managers and other elements that may be injected
|
||||
* into the DOM but visually appear inside the content.
|
||||
*/
|
||||
export function isClickTrulyOutside(event, contentNode) {
|
||||
const { clientX, clientY } = event;
|
||||
const rect = contentNode.getBoundingClientRect();
|
||||
return (clientX < rect.left || clientX > rect.right || clientY < rect.top || clientY > rect.bottom);
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export declare function isOrContainsTarget(node: HTMLElement, target: Element): boolean;
|
||||
export declare function getOwnerDocument(el: Element | null | undefined): Document;
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
export function isOrContainsTarget(node, target) {
|
||||
return node === target || node.contains(target);
|
||||
}
|
||||
export function getOwnerDocument(el) {
|
||||
return el?.ownerDocument ?? document;
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
export type EventCallback<E extends Event = Event> = (event: E) => void;
|
||||
/**
|
||||
* Creates a typed event dispatcher and listener pair for custom events
|
||||
* @template T - The type of data that will be passed in the event detail
|
||||
* @param eventName - The name of the custom event
|
||||
* @param options - CustomEvent options (bubbles, cancelable, etc.)
|
||||
*/
|
||||
export declare class CustomEventDispatcher<T = unknown> {
|
||||
readonly eventName: string;
|
||||
readonly options: Omit<CustomEventInit<T>, "detail">;
|
||||
constructor(eventName: string, options?: Omit<CustomEventInit<T>, "detail">);
|
||||
createEvent(detail?: T): CustomEvent<T>;
|
||||
dispatch(element: EventTarget, detail?: T): CustomEvent<T>;
|
||||
listen(element: EventTarget, callback: (event: CustomEvent<T>) => void, options?: AddEventListenerOptions): () => void;
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { on } from "svelte/events";
|
||||
/**
|
||||
* Creates a typed event dispatcher and listener pair for custom events
|
||||
* @template T - The type of data that will be passed in the event detail
|
||||
* @param eventName - The name of the custom event
|
||||
* @param options - CustomEvent options (bubbles, cancelable, etc.)
|
||||
*/
|
||||
export class CustomEventDispatcher {
|
||||
eventName;
|
||||
options;
|
||||
constructor(eventName, options = { bubbles: true, cancelable: true }) {
|
||||
this.eventName = eventName;
|
||||
this.options = options;
|
||||
}
|
||||
createEvent(detail) {
|
||||
return new CustomEvent(this.eventName, {
|
||||
...this.options,
|
||||
detail,
|
||||
});
|
||||
}
|
||||
dispatch(element, detail) {
|
||||
const event = this.createEvent(detail);
|
||||
element.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
listen(element, callback, options) {
|
||||
const handler = (event) => {
|
||||
callback(event);
|
||||
};
|
||||
return on(element, this.eventName, handler, options);
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import type { MaybeGetter } from "svelte-toolbelt";
|
||||
export declare function get<T>(valueOrGetValue: MaybeGetter<T>): T;
|
||||
export declare function getDPR(element: Element): number;
|
||||
export declare function roundByDPR(element: Element, value: number): number;
|
||||
export declare function getFloatingContentCSSVars(name: string): {
|
||||
[x: string]: string;
|
||||
};
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
export function get(valueOrGetValue) {
|
||||
return typeof valueOrGetValue === "function"
|
||||
? valueOrGetValue()
|
||||
: valueOrGetValue;
|
||||
}
|
||||
export function getDPR(element) {
|
||||
if (typeof window === "undefined")
|
||||
return 1;
|
||||
const win = element.ownerDocument.defaultView || window;
|
||||
return win.devicePixelRatio || 1;
|
||||
}
|
||||
export function roundByDPR(element, value) {
|
||||
const dpr = getDPR(element);
|
||||
return Math.round(value * dpr) / dpr;
|
||||
}
|
||||
export function getFloatingContentCSSVars(name) {
|
||||
return {
|
||||
[`--bits-${name}-content-transform-origin`]: `var(--bits-floating-transform-origin)`,
|
||||
[`--bits-${name}-content-available-width`]: `var(--bits-floating-available-width)`,
|
||||
[`--bits-${name}-content-available-height`]: `var(--bits-floating-available-height)`,
|
||||
[`--bits-${name}-anchor-width`]: `var(--bits-floating-anchor-width)`,
|
||||
[`--bits-${name}-anchor-height`]: `var(--bits-floating-anchor-height)`,
|
||||
};
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
import type { FloatingElement, Middleware, MiddlewareData, Placement, ReferenceElement, Strategy } from "@floating-ui/dom";
|
||||
import type { ReadableBox, WritableBox } from "svelte-toolbelt";
|
||||
type ValueOrGetValue<T> = T | (() => T);
|
||||
export type Measurable = {
|
||||
getBoundingClientRect: () => DOMRect;
|
||||
};
|
||||
export type UseFloatingOptions = {
|
||||
/**
|
||||
* Represents the open/close state of the floating element.
|
||||
* @default true
|
||||
*/
|
||||
open?: ValueOrGetValue<boolean | undefined>;
|
||||
/**
|
||||
* Where to place the floating element relative to its reference element.
|
||||
* @default 'bottom'
|
||||
*/
|
||||
placement?: ValueOrGetValue<Placement | undefined>;
|
||||
/**
|
||||
* The type of CSS position property to use.
|
||||
* @default 'absolute'
|
||||
*/
|
||||
strategy?: ValueOrGetValue<Strategy | undefined>;
|
||||
/**
|
||||
* These are plain objects that modify the positioning coordinates in some fashion,
|
||||
* or provide useful data for the consumer to use.
|
||||
* @default undefined
|
||||
*/
|
||||
middleware?: ValueOrGetValue<Middleware[] | undefined>;
|
||||
/**
|
||||
* Whether to use `transform` instead of `top` and `left` styles to
|
||||
* position the floating element (`floatingStyles`).
|
||||
* @default true
|
||||
*/
|
||||
transform?: ValueOrGetValue<boolean | undefined>;
|
||||
/**
|
||||
* Reference / Anchor element to position the floating element relative to
|
||||
*/
|
||||
reference: ReadableBox<Measurable | HTMLElement | null>;
|
||||
/**
|
||||
* Callback to handle mounting/unmounting of the elements.
|
||||
* @default undefined
|
||||
*/
|
||||
whileElementsMounted?: (reference: ReferenceElement, floating: FloatingElement, update: () => void) => () => void;
|
||||
/**
|
||||
* The offset from the reference element along the side axis.
|
||||
* Used to detect bad coordinates during transitions.
|
||||
* @default undefined
|
||||
*/
|
||||
sideOffset?: ValueOrGetValue<number | undefined>;
|
||||
/**
|
||||
* The offset from the reference element along the alignment axis.
|
||||
* Used to detect bad coordinates during transitions.
|
||||
* @default undefined
|
||||
*/
|
||||
alignOffset?: ValueOrGetValue<number | undefined>;
|
||||
};
|
||||
export type UseFloatingReturn = {
|
||||
/**
|
||||
* The reference element to position the floating element relative to.
|
||||
*/
|
||||
reference: ReadableBox<Measurable | HTMLElement | null>;
|
||||
/**
|
||||
* The floating element to position.
|
||||
*/
|
||||
floating: WritableBox<HTMLElement | null>;
|
||||
/**
|
||||
* The stateful placement, which can be different from the initial `placement` passed as options.
|
||||
*/
|
||||
placement: Readonly<Placement>;
|
||||
/**
|
||||
* The type of CSS position property to use.
|
||||
*/
|
||||
strategy: Readonly<Strategy>;
|
||||
/**
|
||||
* Additional data from middleware.
|
||||
*/
|
||||
middlewareData: Readonly<MiddlewareData>;
|
||||
/**
|
||||
* The boolean that let you know if the floating element has been positioned.
|
||||
*/
|
||||
isPositioned: Readonly<boolean>;
|
||||
/**
|
||||
* CSS styles to apply to the floating element to position it.
|
||||
*/
|
||||
floatingStyles: Readonly<{
|
||||
position: Strategy;
|
||||
top: string;
|
||||
left: string;
|
||||
transform?: string;
|
||||
willChange?: string;
|
||||
}>;
|
||||
/**
|
||||
* The function to update floating position manually.
|
||||
*/
|
||||
update: () => void;
|
||||
};
|
||||
export {};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export {};
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
import type { UseFloatingOptions, UseFloatingReturn } from "./types.js";
|
||||
export declare function useFloating(options: UseFloatingOptions): UseFloatingReturn;
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
import { computePosition } from "@floating-ui/dom";
|
||||
import { simpleBox } from "svelte-toolbelt";
|
||||
import { get, getDPR, roundByDPR } from "./floating-utils.svelte.js";
|
||||
export function useFloating(options) {
|
||||
/** Options */
|
||||
const whileElementsMountedOption = options.whileElementsMounted;
|
||||
const openOption = $derived(get(options.open) ?? true);
|
||||
const middlewareOption = $derived(get(options.middleware));
|
||||
const transformOption = $derived(get(options.transform) ?? true);
|
||||
const placementOption = $derived(get(options.placement) ?? "bottom");
|
||||
const strategyOption = $derived(get(options.strategy) ?? "absolute");
|
||||
const sideOffsetOption = $derived(get(options.sideOffset) ?? 0);
|
||||
const alignOffsetOption = $derived(get(options.alignOffset) ?? 0);
|
||||
const reference = options.reference;
|
||||
/** State */
|
||||
let x = $state(0);
|
||||
let y = $state(0);
|
||||
const floating = simpleBox(null);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let strategy = $state(strategyOption);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let placement = $state(placementOption);
|
||||
let middlewareData = $state({});
|
||||
let isPositioned = $state(false);
|
||||
const floatingStyles = $derived.by(() => {
|
||||
// preserve last known position when floating ref is null (during transitions)
|
||||
const xVal = floating.current ? roundByDPR(floating.current, x) : x;
|
||||
const yVal = floating.current ? roundByDPR(floating.current, y) : y;
|
||||
if (transformOption) {
|
||||
return {
|
||||
position: strategy,
|
||||
left: "0",
|
||||
top: "0",
|
||||
transform: `translate(${xVal}px, ${yVal}px)`,
|
||||
...(floating.current &&
|
||||
getDPR(floating.current) >= 1.5 && {
|
||||
willChange: "transform",
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
position: strategy,
|
||||
left: `${xVal}px`,
|
||||
top: `${yVal}px`,
|
||||
};
|
||||
});
|
||||
/** Effects */
|
||||
let whileElementsMountedCleanup;
|
||||
function update() {
|
||||
if (reference.current === null || floating.current === null)
|
||||
return;
|
||||
computePosition(reference.current, floating.current, {
|
||||
middleware: middlewareOption,
|
||||
placement: placementOption,
|
||||
strategy: strategyOption,
|
||||
}).then((position) => {
|
||||
// ignore bad coordinates that cause jumping during close transitions
|
||||
if (!openOption && x !== 0 && y !== 0) {
|
||||
// if we had a good position and now getting coordinates near
|
||||
// the expected offset bounds during close, ignore it
|
||||
const maxExpectedOffset = Math.max(Math.abs(sideOffsetOption), Math.abs(alignOffsetOption), 15);
|
||||
if (position.x <= maxExpectedOffset && position.y <= maxExpectedOffset)
|
||||
return;
|
||||
}
|
||||
x = position.x;
|
||||
y = position.y;
|
||||
strategy = position.strategy;
|
||||
placement = position.placement;
|
||||
middlewareData = position.middlewareData;
|
||||
isPositioned = true;
|
||||
});
|
||||
}
|
||||
function cleanup() {
|
||||
if (typeof whileElementsMountedCleanup === "function") {
|
||||
whileElementsMountedCleanup();
|
||||
whileElementsMountedCleanup = undefined;
|
||||
}
|
||||
}
|
||||
function attach() {
|
||||
cleanup();
|
||||
if (whileElementsMountedOption === undefined) {
|
||||
update();
|
||||
return;
|
||||
}
|
||||
if (reference.current === null || floating.current === null)
|
||||
return;
|
||||
whileElementsMountedCleanup = whileElementsMountedOption(reference.current, floating.current, update);
|
||||
}
|
||||
function reset() {
|
||||
if (!openOption) {
|
||||
isPositioned = false;
|
||||
}
|
||||
}
|
||||
$effect(update);
|
||||
$effect(attach);
|
||||
$effect(reset);
|
||||
$effect(() => cleanup);
|
||||
return {
|
||||
floating,
|
||||
reference,
|
||||
get strategy() {
|
||||
return strategy;
|
||||
},
|
||||
get placement() {
|
||||
return placement;
|
||||
},
|
||||
get middlewareData() {
|
||||
return middlewareData;
|
||||
},
|
||||
get isPositioned() {
|
||||
return isPositioned;
|
||||
},
|
||||
get floatingStyles() {
|
||||
return floatingStyles;
|
||||
},
|
||||
get update() {
|
||||
return update;
|
||||
},
|
||||
};
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
export type FocusableTarget = HTMLElement | {
|
||||
focus: () => void;
|
||||
};
|
||||
/**
|
||||
* Handles `initialFocus` prop behavior for the
|
||||
* Calendar & RangeCalendar components.
|
||||
*/
|
||||
export declare function handleCalendarInitialFocus(calendar: HTMLElement): void;
|
||||
/**
|
||||
* A utility function that focuses an element without scrolling.
|
||||
*/
|
||||
export declare function focusWithoutScroll(element: HTMLElement): void;
|
||||
/**
|
||||
* A utility function that focuses an element.
|
||||
*/
|
||||
export declare function focus(element?: FocusableTarget | null, { select }?: {
|
||||
select?: boolean | undefined;
|
||||
}): void;
|
||||
/**
|
||||
* Attempts to focus the first element in a list of candidates.
|
||||
* Stops when focus is successful.
|
||||
*/
|
||||
export declare function focusFirst(candidates: HTMLElement[], { select }: {
|
||||
select?: boolean | undefined;
|
||||
} | undefined, getActiveElement: () => HTMLElement | null): true | undefined;
|
||||
/**
|
||||
* Returns the first visible element in a list.
|
||||
* NOTE: Only checks visibility up to the `container`.
|
||||
*/
|
||||
export declare function findVisible(elements: HTMLElement[], container: HTMLElement): HTMLElement | undefined;
|
||||
/**
|
||||
* Returns a list of potential tabbable candidates.
|
||||
*
|
||||
* NOTE: This is only a close approximation. For example it doesn't take into account cases like when
|
||||
* elements are not visible. This cannot be worked out easily by just reading a property, but rather
|
||||
* necessitate runtime knowledge (computed styles, etc). We deal with these cases separately.
|
||||
*
|
||||
* See: https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker
|
||||
* Credit: https://github.com/discord/focus-layers/blob/master/src/util/wrapFocus.tsx#L1
|
||||
*/
|
||||
export declare function getTabbableCandidates(container: HTMLElement): HTMLElement[];
|
||||
/**
|
||||
* A utility function that returns the first and last elements within a container that are
|
||||
* visible and focusable.
|
||||
*/
|
||||
export declare function getTabbableEdges(container: HTMLElement): readonly [HTMLElement | undefined, HTMLElement | undefined];
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
import { getDocument, getWindow } from "svelte-toolbelt";
|
||||
import { isBrowser, isElementHidden, isSelectableInput } from "./is.js";
|
||||
/**
|
||||
* Handles `initialFocus` prop behavior for the
|
||||
* Calendar & RangeCalendar components.
|
||||
*/
|
||||
export function handleCalendarInitialFocus(calendar) {
|
||||
if (!isBrowser)
|
||||
return;
|
||||
const selectedDay = calendar.querySelector("[data-selected]");
|
||||
if (selectedDay)
|
||||
return focusWithoutScroll(selectedDay);
|
||||
const today = calendar.querySelector("[data-today]");
|
||||
if (today)
|
||||
return focusWithoutScroll(today);
|
||||
const firstDay = calendar.querySelector("[data-calendar-date]");
|
||||
if (firstDay)
|
||||
return focusWithoutScroll(firstDay);
|
||||
}
|
||||
/**
|
||||
* A utility function that focuses an element without scrolling.
|
||||
*/
|
||||
export function focusWithoutScroll(element) {
|
||||
const doc = getDocument(element);
|
||||
const win = getWindow(element);
|
||||
const scrollPosition = {
|
||||
x: win.pageXOffset || doc.documentElement.scrollLeft,
|
||||
y: win.pageYOffset || doc.documentElement.scrollTop,
|
||||
};
|
||||
element.focus();
|
||||
win.scrollTo(scrollPosition.x, scrollPosition.y);
|
||||
}
|
||||
/**
|
||||
* A utility function that focuses an element.
|
||||
*/
|
||||
export function focus(element, { select = false } = {}) {
|
||||
if (!element || !element.focus)
|
||||
return;
|
||||
const doc = getDocument(element);
|
||||
if (doc.activeElement === element)
|
||||
return;
|
||||
const previouslyFocusedElement = doc.activeElement;
|
||||
// prevent scroll on focus
|
||||
element.focus({ preventScroll: true });
|
||||
// only elect if its not the same element, it supports selection, and we need to select it
|
||||
if (element !== previouslyFocusedElement && isSelectableInput(element) && select) {
|
||||
element.select();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Attempts to focus the first element in a list of candidates.
|
||||
* Stops when focus is successful.
|
||||
*/
|
||||
export function focusFirst(candidates, { select = false } = {}, getActiveElement) {
|
||||
const previouslyFocusedElement = getActiveElement();
|
||||
for (const candidate of candidates) {
|
||||
focus(candidate, { select });
|
||||
if (getActiveElement() !== previouslyFocusedElement)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Returns the first visible element in a list.
|
||||
* NOTE: Only checks visibility up to the `container`.
|
||||
*/
|
||||
export function findVisible(elements, container) {
|
||||
for (const element of elements) {
|
||||
// we stop checking if it's hidden at the `container` level (excluding)
|
||||
if (!isElementHidden(element, container))
|
||||
return element;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Returns a list of potential tabbable candidates.
|
||||
*
|
||||
* NOTE: This is only a close approximation. For example it doesn't take into account cases like when
|
||||
* elements are not visible. This cannot be worked out easily by just reading a property, but rather
|
||||
* necessitate runtime knowledge (computed styles, etc). We deal with these cases separately.
|
||||
*
|
||||
* See: https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker
|
||||
* Credit: https://github.com/discord/focus-layers/blob/master/src/util/wrapFocus.tsx#L1
|
||||
*/
|
||||
export function getTabbableCandidates(container) {
|
||||
const nodes = [];
|
||||
const doc = getDocument(container);
|
||||
const walker = doc.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, {
|
||||
// oxlint-disable-next-line no-explicit-any
|
||||
acceptNode: (node) => {
|
||||
const isHiddenInput = node.tagName === "INPUT" && node.type === "hidden";
|
||||
if (node.disabled || node.hidden || isHiddenInput)
|
||||
return NodeFilter.FILTER_SKIP;
|
||||
// `.tabIndex` is not the same as the `tabindex` attribute. It works on the
|
||||
// runtime's understanding of tabbability, so this automatically accounts
|
||||
// for any kind of element that could be tabbed to.
|
||||
return node.tabIndex >= 0 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
|
||||
},
|
||||
});
|
||||
while (walker.nextNode())
|
||||
nodes.push(walker.currentNode);
|
||||
// we do not take into account the order of nodes with positive `tabIndex` as it
|
||||
// hinders accessibility to have tab order different from visual order.
|
||||
return nodes;
|
||||
}
|
||||
/**
|
||||
* A utility function that returns the first and last elements within a container that are
|
||||
* visible and focusable.
|
||||
*/
|
||||
export function getTabbableEdges(container) {
|
||||
const candidates = getTabbableCandidates(container);
|
||||
const first = findVisible(candidates, container);
|
||||
const last = findVisible(candidates.reverse(), container);
|
||||
return [first, last];
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import type { Direction, Orientation } from "../shared/index.js";
|
||||
export declare const FIRST_KEYS: string[];
|
||||
export declare const LAST_KEYS: string[];
|
||||
export declare const FIRST_LAST_KEYS: string[];
|
||||
export declare const SELECTION_KEYS: string[];
|
||||
/**
|
||||
* A utility function that returns the next key based on the direction and orientation.
|
||||
*/
|
||||
export declare function getNextKey(dir?: Direction, orientation?: Orientation): string;
|
||||
/**
|
||||
* A utility function that returns the previous key based on the direction and orientation.
|
||||
*/
|
||||
export declare function getPrevKey(dir?: Direction, orientation?: Orientation): string;
|
||||
/**
|
||||
* A utility function that returns the next and previous keys based on the direction
|
||||
* and orientation.
|
||||
*/
|
||||
export declare function getDirectionalKeys(dir?: Direction, orientation?: Orientation): {
|
||||
nextKey: string;
|
||||
prevKey: string;
|
||||
};
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import { kbd } from "./kbd.js";
|
||||
export const FIRST_KEYS = [kbd.ARROW_DOWN, kbd.PAGE_UP, kbd.HOME];
|
||||
export const LAST_KEYS = [kbd.ARROW_UP, kbd.PAGE_DOWN, kbd.END];
|
||||
export const FIRST_LAST_KEYS = [...FIRST_KEYS, ...LAST_KEYS];
|
||||
export const SELECTION_KEYS = [kbd.SPACE, kbd.ENTER];
|
||||
/**
|
||||
* A utility function that returns the next key based on the direction and orientation.
|
||||
*/
|
||||
export function getNextKey(dir = "ltr", orientation = "horizontal") {
|
||||
return {
|
||||
horizontal: dir === "rtl" ? kbd.ARROW_LEFT : kbd.ARROW_RIGHT,
|
||||
vertical: kbd.ARROW_DOWN,
|
||||
}[orientation];
|
||||
}
|
||||
/**
|
||||
* A utility function that returns the previous key based on the direction and orientation.
|
||||
*/
|
||||
export function getPrevKey(dir = "ltr", orientation = "horizontal") {
|
||||
return {
|
||||
horizontal: dir === "rtl" ? kbd.ARROW_RIGHT : kbd.ARROW_LEFT,
|
||||
vertical: kbd.ARROW_UP,
|
||||
}[orientation];
|
||||
}
|
||||
/**
|
||||
* A utility function that returns the next and previous keys based on the direction
|
||||
* and orientation.
|
||||
*/
|
||||
export function getDirectionalKeys(dir = "ltr", orientation = "horizontal") {
|
||||
if (!["ltr", "rtl"].includes(dir))
|
||||
dir = "ltr";
|
||||
if (!["horizontal", "vertical"].includes(orientation))
|
||||
orientation = "horizontal";
|
||||
return {
|
||||
nextKey: getNextKey(dir, orientation),
|
||||
prevKey: getPrevKey(dir, orientation),
|
||||
};
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { type Getter } from "svelte-toolbelt";
|
||||
interface GraceAreaOptions {
|
||||
enabled: Getter<boolean>;
|
||||
triggerNode: Getter<HTMLElement | null>;
|
||||
contentNode: Getter<HTMLElement | null>;
|
||||
onPointerExit: () => void;
|
||||
setIsPointerInTransit?: (value: boolean) => void;
|
||||
transitTimeout?: number;
|
||||
}
|
||||
export declare class GraceArea {
|
||||
#private;
|
||||
constructor(opts: GraceAreaOptions);
|
||||
}
|
||||
export {};
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
import { executeCallbacks, getDocument, getWindow, } from "svelte-toolbelt";
|
||||
import { on } from "svelte/events";
|
||||
import { watch } from "runed";
|
||||
import { boxAutoReset } from "./box-auto-reset.svelte.js";
|
||||
import { isElement, isHTMLElement } from "./is.js";
|
||||
export class GraceArea {
|
||||
#opts;
|
||||
#enabled;
|
||||
#isPointerInTransit;
|
||||
#pointerGraceArea = $state(null);
|
||||
constructor(opts) {
|
||||
this.#opts = opts;
|
||||
this.#enabled = $derived(this.#opts.enabled());
|
||||
this.#isPointerInTransit = boxAutoReset(false, {
|
||||
afterMs: opts.transitTimeout ?? 300,
|
||||
onChange: (value) => {
|
||||
if (!this.#enabled)
|
||||
return;
|
||||
this.#opts.setIsPointerInTransit?.(value);
|
||||
},
|
||||
getWindow: () => getWindow(this.#opts.triggerNode()),
|
||||
});
|
||||
watch([opts.triggerNode, opts.contentNode, opts.enabled], ([triggerNode, contentNode, enabled]) => {
|
||||
if (!triggerNode || !contentNode || !enabled)
|
||||
return;
|
||||
const handleTriggerLeave = (e) => {
|
||||
this.#createGraceArea(e, contentNode);
|
||||
};
|
||||
const handleContentLeave = (e) => {
|
||||
this.#createGraceArea(e, triggerNode);
|
||||
};
|
||||
return executeCallbacks(on(triggerNode, "pointerleave", handleTriggerLeave), on(contentNode, "pointerleave", handleContentLeave));
|
||||
});
|
||||
watch(() => this.#pointerGraceArea, () => {
|
||||
const handleTrackPointerGrace = (e) => {
|
||||
if (!this.#pointerGraceArea)
|
||||
return;
|
||||
const target = e.target;
|
||||
if (!isElement(target))
|
||||
return;
|
||||
const pointerPosition = { x: e.clientX, y: e.clientY };
|
||||
const hasEnteredTarget = opts.triggerNode()?.contains(target) ||
|
||||
opts.contentNode()?.contains(target);
|
||||
const isPointerOutsideGraceArea = !isPointInPolygon(pointerPosition, this.#pointerGraceArea);
|
||||
if (hasEnteredTarget) {
|
||||
this.#removeGraceArea();
|
||||
}
|
||||
else if (isPointerOutsideGraceArea) {
|
||||
this.#removeGraceArea();
|
||||
opts.onPointerExit();
|
||||
}
|
||||
};
|
||||
const doc = getDocument(opts.triggerNode() ?? opts.contentNode());
|
||||
if (!doc)
|
||||
return;
|
||||
return on(doc, "pointermove", handleTrackPointerGrace);
|
||||
});
|
||||
}
|
||||
#removeGraceArea() {
|
||||
this.#pointerGraceArea = null;
|
||||
this.#isPointerInTransit.current = false;
|
||||
}
|
||||
#createGraceArea(e, hoverTarget) {
|
||||
const currentTarget = e.currentTarget;
|
||||
if (!isHTMLElement(currentTarget))
|
||||
return;
|
||||
const exitPoint = { x: e.clientX, y: e.clientY };
|
||||
const exitSide = getExitSideFromRect(exitPoint, currentTarget.getBoundingClientRect());
|
||||
const paddedExitPoints = getPaddedExitPoints(exitPoint, exitSide);
|
||||
const hoverTargetPoints = getPointsFromRect(hoverTarget.getBoundingClientRect());
|
||||
const graceArea = getHull([...paddedExitPoints, ...hoverTargetPoints]);
|
||||
this.#pointerGraceArea = graceArea;
|
||||
this.#isPointerInTransit.current = true;
|
||||
}
|
||||
}
|
||||
function getExitSideFromRect(point, rect) {
|
||||
const top = Math.abs(rect.top - point.y);
|
||||
const bottom = Math.abs(rect.bottom - point.y);
|
||||
const right = Math.abs(rect.right - point.x);
|
||||
const left = Math.abs(rect.left - point.x);
|
||||
switch (Math.min(top, bottom, right, left)) {
|
||||
case left:
|
||||
return "left";
|
||||
case right:
|
||||
return "right";
|
||||
case top:
|
||||
return "top";
|
||||
case bottom:
|
||||
return "bottom";
|
||||
default:
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
}
|
||||
function getPaddedExitPoints(exitPoint, exitSide, padding = 5) {
|
||||
// we extend the tip of the exit point to make it easier to navigate without
|
||||
// a minor jitter triggering a pointer exit
|
||||
const tipPadding = padding * 1.5;
|
||||
switch (exitSide) {
|
||||
case "top":
|
||||
return [
|
||||
{ x: exitPoint.x - padding, y: exitPoint.y + padding },
|
||||
{ x: exitPoint.x, y: exitPoint.y - tipPadding },
|
||||
{ x: exitPoint.x + padding, y: exitPoint.y + padding },
|
||||
];
|
||||
case "bottom":
|
||||
return [
|
||||
{ x: exitPoint.x - padding, y: exitPoint.y - padding },
|
||||
{ x: exitPoint.x, y: exitPoint.y + tipPadding },
|
||||
{ x: exitPoint.x + padding, y: exitPoint.y - padding },
|
||||
];
|
||||
case "left":
|
||||
return [
|
||||
{ x: exitPoint.x + padding, y: exitPoint.y - padding },
|
||||
{ x: exitPoint.x - tipPadding, y: exitPoint.y },
|
||||
{ x: exitPoint.x + padding, y: exitPoint.y + padding },
|
||||
];
|
||||
case "right":
|
||||
return [
|
||||
{ x: exitPoint.x - padding, y: exitPoint.y - padding },
|
||||
{ x: exitPoint.x + tipPadding, y: exitPoint.y },
|
||||
{ x: exitPoint.x - padding, y: exitPoint.y + padding },
|
||||
];
|
||||
}
|
||||
}
|
||||
function getPointsFromRect(rect) {
|
||||
const { top, right, bottom, left } = rect;
|
||||
return [
|
||||
{ x: left, y: top },
|
||||
{ x: right, y: top },
|
||||
{ x: right, y: bottom },
|
||||
{ x: left, y: bottom },
|
||||
];
|
||||
}
|
||||
// Determine if a point is inside of a polygon.
|
||||
// Based on https://github.com/substack/point-in-polygon
|
||||
function isPointInPolygon(point, polygon) {
|
||||
const { x, y } = point;
|
||||
let inside = false;
|
||||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||
const xi = polygon[i].x;
|
||||
const yi = polygon[i].y;
|
||||
const xj = polygon[j].x;
|
||||
const yj = polygon[j].y;
|
||||
// prettier-ignore
|
||||
const intersect = ((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi);
|
||||
if (intersect)
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
// Returns a new array of points representing the convex hull of the given set of points.
|
||||
// https://www.nayuki.io/page/convex-hull-algorithm
|
||||
function getHull(points) {
|
||||
const newPoints = points.slice();
|
||||
newPoints.sort((a, b) => {
|
||||
if (a.x < b.x)
|
||||
return -1;
|
||||
else if (a.x > b.x)
|
||||
return +1;
|
||||
else if (a.y < b.y)
|
||||
return -1;
|
||||
else if (a.y > b.y)
|
||||
return +1;
|
||||
else
|
||||
return 0;
|
||||
});
|
||||
return getHullPresorted(newPoints);
|
||||
}
|
||||
// Returns the convex hull, assuming that each points[i] <= points[i + 1]. Runs in O(n) time.
|
||||
function getHullPresorted(points) {
|
||||
if (points.length <= 1)
|
||||
return points.slice();
|
||||
const upperHull = [];
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const p = points[i];
|
||||
while (upperHull.length >= 2) {
|
||||
const q = upperHull[upperHull.length - 1];
|
||||
const r = upperHull[upperHull.length - 2];
|
||||
if ((q.x - r.x) * (p.y - r.y) >= (q.y - r.y) * (p.x - r.x))
|
||||
upperHull.pop();
|
||||
else
|
||||
break;
|
||||
}
|
||||
upperHull.push(p);
|
||||
}
|
||||
upperHull.pop();
|
||||
const lowerHull = [];
|
||||
for (let i = points.length - 1; i >= 0; i--) {
|
||||
const p = points[i];
|
||||
while (lowerHull.length >= 2) {
|
||||
const q = lowerHull[lowerHull.length - 1];
|
||||
const r = lowerHull[lowerHull.length - 2];
|
||||
if ((q.x - r.x) * (p.y - r.y) >= (q.y - r.y) * (p.x - r.x))
|
||||
lowerHull.pop();
|
||||
else
|
||||
break;
|
||||
}
|
||||
lowerHull.push(p);
|
||||
}
|
||||
lowerHull.pop();
|
||||
if (upperHull.length === 1 &&
|
||||
lowerHull.length === 1 &&
|
||||
upperHull[0].x === lowerHull[0].x &&
|
||||
upperHull[0].y === lowerHull[0].y)
|
||||
return upperHull;
|
||||
else
|
||||
return upperHull.concat(lowerHull);
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import type { FocusableTarget } from "./focus.js";
|
||||
export declare const isBrowser: boolean;
|
||||
export declare const isIOS: boolean | "";
|
||||
export declare function isFunction(value: unknown): value is (...args: unknown[]) => unknown;
|
||||
export declare function isHTMLElement(element: unknown): element is HTMLElement;
|
||||
export declare function isElement(element: unknown): element is Element;
|
||||
export declare function isElementOrSVGElement(element: unknown): element is Element | SVGElement;
|
||||
export declare function isNumberString(value: string): boolean;
|
||||
export declare function isNull(value: unknown): value is null;
|
||||
export declare function isTouch(e: PointerEvent): boolean;
|
||||
export declare function isFocusVisible(element: Element): boolean;
|
||||
export declare function isNotNull<T>(value: T | null): value is T;
|
||||
/**
|
||||
* Determines if the provided object is a valid `HTMLInputElement` with
|
||||
* a `select` method available.
|
||||
*/
|
||||
export declare function isSelectableInput(element: unknown): element is FocusableTarget & {
|
||||
select: () => void;
|
||||
};
|
||||
/**
|
||||
* Given a node, determine if it is hidden by walking up the
|
||||
* DOM tree until we hit the `stopAt` node (exclusive), if provided)
|
||||
* otherwise we stop at the document root.
|
||||
*/
|
||||
export declare function isElementHidden(node: HTMLElement, stopAt?: HTMLElement): boolean;
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
export const isBrowser = typeof document !== "undefined";
|
||||
export const isIOS = getIsIOS();
|
||||
function getIsIOS() {
|
||||
return (isBrowser &&
|
||||
window?.navigator?.userAgent &&
|
||||
(/iP(ad|hone|od)/.test(window.navigator.userAgent) ||
|
||||
// The new iPad Pro Gen3 does not identify itself as iPad, but as Macintosh.
|
||||
(window?.navigator?.maxTouchPoints > 2 &&
|
||||
/iPad|Macintosh/.test(window?.navigator.userAgent))));
|
||||
}
|
||||
export function isFunction(value) {
|
||||
return typeof value === "function";
|
||||
}
|
||||
export function isHTMLElement(element) {
|
||||
return element instanceof HTMLElement;
|
||||
}
|
||||
export function isElement(element) {
|
||||
return element instanceof Element;
|
||||
}
|
||||
export function isElementOrSVGElement(element) {
|
||||
return element instanceof Element || element instanceof SVGElement;
|
||||
}
|
||||
export function isNumberString(value) {
|
||||
return !Number.isNaN(Number(value)) && !Number.isNaN(Number.parseFloat(value));
|
||||
}
|
||||
export function isNull(value) {
|
||||
return value === null;
|
||||
}
|
||||
export function isTouch(e) {
|
||||
return e.pointerType === "touch";
|
||||
}
|
||||
export function isFocusVisible(element) {
|
||||
return element.matches(":focus-visible");
|
||||
}
|
||||
export function isNotNull(value) {
|
||||
return value !== null;
|
||||
}
|
||||
/**
|
||||
* Determines if the provided object is a valid `HTMLInputElement` with
|
||||
* a `select` method available.
|
||||
*/
|
||||
export function isSelectableInput(element) {
|
||||
return element instanceof HTMLInputElement && "select" in element;
|
||||
}
|
||||
/**
|
||||
* Given a node, determine if it is hidden by walking up the
|
||||
* DOM tree until we hit the `stopAt` node (exclusive), if provided)
|
||||
* otherwise we stop at the document root.
|
||||
*/
|
||||
export function isElementHidden(node, stopAt) {
|
||||
if (getComputedStyle(node).visibility === "hidden")
|
||||
return true;
|
||||
while (node) {
|
||||
// we stop at `upTo` (excluding it)
|
||||
if (stopAt !== undefined && node === stopAt)
|
||||
return false;
|
||||
if (getComputedStyle(node).display === "none")
|
||||
return true;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
export declare const ALT = "Alt";
|
||||
export declare const ARROW_DOWN = "ArrowDown";
|
||||
export declare const ARROW_LEFT = "ArrowLeft";
|
||||
export declare const ARROW_RIGHT = "ArrowRight";
|
||||
export declare const ARROW_UP = "ArrowUp";
|
||||
export declare const BACKSPACE = "Backspace";
|
||||
export declare const CAPS_LOCK = "CapsLock";
|
||||
export declare const CONTROL = "Control";
|
||||
export declare const DELETE = "Delete";
|
||||
export declare const END = "End";
|
||||
export declare const ENTER = "Enter";
|
||||
export declare const ESCAPE = "Escape";
|
||||
export declare const F1 = "F1";
|
||||
export declare const F10 = "F10";
|
||||
export declare const F11 = "F11";
|
||||
export declare const F12 = "F12";
|
||||
export declare const F2 = "F2";
|
||||
export declare const F3 = "F3";
|
||||
export declare const F4 = "F4";
|
||||
export declare const F5 = "F5";
|
||||
export declare const F6 = "F6";
|
||||
export declare const F7 = "F7";
|
||||
export declare const F8 = "F8";
|
||||
export declare const F9 = "F9";
|
||||
export declare const HOME = "Home";
|
||||
export declare const META = "Meta";
|
||||
export declare const PAGE_DOWN = "PageDown";
|
||||
export declare const PAGE_UP = "PageUp";
|
||||
export declare const SHIFT = "Shift";
|
||||
export declare const SPACE = " ";
|
||||
export declare const TAB = "Tab";
|
||||
export declare const CTRL = "Control";
|
||||
export declare const ASTERISK = "*";
|
||||
export declare const a = "a";
|
||||
export declare const P = "P";
|
||||
export declare const A = "A";
|
||||
export declare const p = "p";
|
||||
export declare const n = "n";
|
||||
export declare const j = "j";
|
||||
export declare const k = "k";
|
||||
export declare const h = "h";
|
||||
export declare const l = "l";
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
export const ALT = "Alt";
|
||||
export const ARROW_DOWN = "ArrowDown";
|
||||
export const ARROW_LEFT = "ArrowLeft";
|
||||
export const ARROW_RIGHT = "ArrowRight";
|
||||
export const ARROW_UP = "ArrowUp";
|
||||
export const BACKSPACE = "Backspace";
|
||||
export const CAPS_LOCK = "CapsLock";
|
||||
export const CONTROL = "Control";
|
||||
export const DELETE = "Delete";
|
||||
export const END = "End";
|
||||
export const ENTER = "Enter";
|
||||
export const ESCAPE = "Escape";
|
||||
export const F1 = "F1";
|
||||
export const F10 = "F10";
|
||||
export const F11 = "F11";
|
||||
export const F12 = "F12";
|
||||
export const F2 = "F2";
|
||||
export const F3 = "F3";
|
||||
export const F4 = "F4";
|
||||
export const F5 = "F5";
|
||||
export const F6 = "F6";
|
||||
export const F7 = "F7";
|
||||
export const F8 = "F8";
|
||||
export const F9 = "F9";
|
||||
export const HOME = "Home";
|
||||
export const META = "Meta";
|
||||
export const PAGE_DOWN = "PageDown";
|
||||
export const PAGE_UP = "PageUp";
|
||||
export const SHIFT = "Shift";
|
||||
export const SPACE = " ";
|
||||
export const TAB = "Tab";
|
||||
export const CTRL = "Control";
|
||||
export const ASTERISK = "*";
|
||||
export const a = "a";
|
||||
export const P = "P";
|
||||
export const A = "A";
|
||||
export const p = "p";
|
||||
export const n = "n";
|
||||
export const j = "j";
|
||||
export const k = "k";
|
||||
export const h = "h";
|
||||
export const l = "l";
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * as kbd from "./kbd-constants.js";
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * as kbd from "./kbd-constants.js";
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import type { Direction } from "../shared/index.js";
|
||||
/**
|
||||
* Detects the text direction in the element.
|
||||
* @returns {Direction} The text direction ('ltr' for left-to-right or 'rtl' for right-to-left).
|
||||
*/
|
||||
export declare function getElemDirection(elem: HTMLElement): Direction;
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Detects the text direction in the element.
|
||||
* @returns {Direction} The text direction ('ltr' for left-to-right or 'rtl' for right-to-left).
|
||||
*/
|
||||
export function getElemDirection(elem) {
|
||||
const style = window.getComputedStyle(elem);
|
||||
const direction = style.getPropertyValue("direction");
|
||||
return direction;
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export declare function linearScale(domain: [number, number], range: [number, number], clamp?: boolean): (x: number) => number;
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
export function linearScale(domain, range, clamp = true) {
|
||||
const [d0, d1] = domain;
|
||||
const [r0, r1] = range;
|
||||
const slope = (r1 - r0) / (d1 - d0);
|
||||
return (x) => {
|
||||
const result = r0 + slope * (x - d0);
|
||||
if (!clamp)
|
||||
return result;
|
||||
if (result > Math.max(r0, r1))
|
||||
return Math.max(r0, r1);
|
||||
if (result < Math.min(r0, r1))
|
||||
return Math.min(r0, r1);
|
||||
return result;
|
||||
};
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* A no operation function (does nothing)
|
||||
*/
|
||||
export declare function noop(): void;
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* A no operation function (does nothing)
|
||||
*/
|
||||
export function noop() { }
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import type { ReadableBoxedValues } from "svelte-toolbelt";
|
||||
interface PresenceManagerOpts extends ReadableBoxedValues<{
|
||||
open: boolean;
|
||||
ref: HTMLElement | null;
|
||||
}> {
|
||||
onComplete?: () => void;
|
||||
enabled?: boolean;
|
||||
}
|
||||
export declare class PresenceManager {
|
||||
#private;
|
||||
constructor(opts: PresenceManagerOpts);
|
||||
get shouldRender(): boolean;
|
||||
}
|
||||
export {};
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { watch } from "runed";
|
||||
import { AnimationsComplete } from "./animations-complete.js";
|
||||
export class PresenceManager {
|
||||
#opts;
|
||||
#enabled;
|
||||
#afterAnimations;
|
||||
#shouldRender = $state(false);
|
||||
constructor(opts) {
|
||||
this.#opts = opts;
|
||||
this.#shouldRender = opts.open.current;
|
||||
this.#enabled = opts.enabled ?? true;
|
||||
this.#afterAnimations = new AnimationsComplete({
|
||||
ref: this.#opts.ref,
|
||||
afterTick: this.#opts.open,
|
||||
});
|
||||
watch(() => this.#opts.open.current, (isOpen) => {
|
||||
if (isOpen)
|
||||
this.#shouldRender = true;
|
||||
if (!this.#enabled)
|
||||
return;
|
||||
this.#afterAnimations.run(() => {
|
||||
if (isOpen === this.#opts.open.current) {
|
||||
if (!this.#opts.open.current) {
|
||||
this.#shouldRender = false;
|
||||
}
|
||||
this.#opts.onComplete?.();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
get shouldRender() {
|
||||
return this.#shouldRender;
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import { type Box, type ReadableBox } from "svelte-toolbelt";
|
||||
import type { Orientation } from "../shared/index.js";
|
||||
type RovingFocusGroupOptions = ({
|
||||
/**
|
||||
* The selector used to find the focusable candidates.
|
||||
*/
|
||||
candidateAttr: string;
|
||||
candidateSelector?: undefined;
|
||||
} | {
|
||||
/**
|
||||
* Custom candidate selector
|
||||
*/
|
||||
candidateSelector: string;
|
||||
candidateAttr?: undefined;
|
||||
}) & {
|
||||
/**
|
||||
* The id of the root node
|
||||
*/
|
||||
rootNode: Box<HTMLElement | null>;
|
||||
/**
|
||||
* Whether to loop through the candidates when reaching the end.
|
||||
*/
|
||||
loop: ReadableBox<boolean>;
|
||||
/**
|
||||
* The orientation of the roving focus group. Used
|
||||
* to determine how keyboard navigation should work.
|
||||
*/
|
||||
orientation: ReadableBox<Orientation>;
|
||||
/**
|
||||
* A callback function called when a candidate is focused.
|
||||
*/
|
||||
onCandidateFocus?: (node: HTMLElement) => void;
|
||||
};
|
||||
export declare class RovingFocusGroup {
|
||||
#private;
|
||||
constructor(opts: RovingFocusGroupOptions);
|
||||
getCandidateNodes(): HTMLElement[];
|
||||
focusFirstCandidate(): void;
|
||||
handleKeydown(node: HTMLElement | null | undefined, e: KeyboardEvent, both?: boolean): HTMLElement | undefined;
|
||||
getTabIndex(node: HTMLElement | null | undefined): 0 | -1;
|
||||
setCurrentTabStopId(id: string): void;
|
||||
focusCurrentTabStop(): void;
|
||||
}
|
||||
export {};
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
import { box } from "svelte-toolbelt";
|
||||
import { getElemDirection } from "./locale.js";
|
||||
import { getDirectionalKeys } from "./get-directional-keys.js";
|
||||
import { kbd } from "./kbd.js";
|
||||
import { BROWSER } from "esm-env";
|
||||
import { isHTMLElement } from "./is.js";
|
||||
export class RovingFocusGroup {
|
||||
#opts;
|
||||
#currentTabStopId = box(null);
|
||||
constructor(opts) {
|
||||
this.#opts = opts;
|
||||
}
|
||||
getCandidateNodes() {
|
||||
if (!BROWSER || !this.#opts.rootNode.current)
|
||||
return [];
|
||||
if (this.#opts.candidateSelector) {
|
||||
const candidates = Array.from(this.#opts.rootNode.current.querySelectorAll(this.#opts.candidateSelector));
|
||||
return candidates;
|
||||
}
|
||||
else if (this.#opts.candidateAttr) {
|
||||
const candidates = Array.from(this.#opts.rootNode.current.querySelectorAll(`[${this.#opts.candidateAttr}]:not([data-disabled])`));
|
||||
return candidates;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
focusFirstCandidate() {
|
||||
const items = this.getCandidateNodes();
|
||||
if (!items.length)
|
||||
return;
|
||||
items[0]?.focus();
|
||||
}
|
||||
handleKeydown(node, e, both = false) {
|
||||
const rootNode = this.#opts.rootNode.current;
|
||||
if (!rootNode || !node)
|
||||
return;
|
||||
const items = this.getCandidateNodes();
|
||||
if (!items.length)
|
||||
return;
|
||||
const currentIndex = items.indexOf(node);
|
||||
const dir = getElemDirection(rootNode);
|
||||
const { nextKey, prevKey } = getDirectionalKeys(dir, this.#opts.orientation.current);
|
||||
const loop = this.#opts.loop.current;
|
||||
const keyToIndex = {
|
||||
[nextKey]: currentIndex + 1,
|
||||
[prevKey]: currentIndex - 1,
|
||||
[kbd.HOME]: 0,
|
||||
[kbd.END]: items.length - 1,
|
||||
};
|
||||
if (both) {
|
||||
const altNextKey = nextKey === kbd.ARROW_DOWN ? kbd.ARROW_RIGHT : kbd.ARROW_DOWN;
|
||||
const altPrevKey = prevKey === kbd.ARROW_UP ? kbd.ARROW_LEFT : kbd.ARROW_UP;
|
||||
keyToIndex[altNextKey] = currentIndex + 1;
|
||||
keyToIndex[altPrevKey] = currentIndex - 1;
|
||||
}
|
||||
let itemIndex = keyToIndex[e.key];
|
||||
if (itemIndex === undefined)
|
||||
return;
|
||||
e.preventDefault();
|
||||
if (itemIndex < 0 && loop) {
|
||||
itemIndex = items.length - 1;
|
||||
}
|
||||
else if (itemIndex === items.length && loop) {
|
||||
itemIndex = 0;
|
||||
}
|
||||
const itemToFocus = items[itemIndex];
|
||||
if (!itemToFocus)
|
||||
return;
|
||||
itemToFocus.focus();
|
||||
this.#currentTabStopId.current = itemToFocus.id;
|
||||
this.#opts.onCandidateFocus?.(itemToFocus);
|
||||
return itemToFocus;
|
||||
}
|
||||
getTabIndex(node) {
|
||||
const items = this.getCandidateNodes();
|
||||
const anyActive = this.#currentTabStopId.current !== null;
|
||||
if (node && !anyActive && items[0] === node) {
|
||||
this.#currentTabStopId.current = node.id;
|
||||
return 0;
|
||||
}
|
||||
else if (node?.id === this.#currentTabStopId.current) {
|
||||
return 0;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
setCurrentTabStopId(id) {
|
||||
this.#currentTabStopId.current = id;
|
||||
}
|
||||
focusCurrentTabStop() {
|
||||
const currentTabStopId = this.#currentTabStopId.current;
|
||||
if (!currentTabStopId)
|
||||
return;
|
||||
const currentTabStop = this.#opts.rootNode.current?.querySelector(`#${currentTabStopId}`);
|
||||
if (!currentTabStop || !isHTMLElement(currentTabStop))
|
||||
return;
|
||||
currentTabStop.focus();
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { type Getter } from "svelte-toolbelt";
|
||||
export interface SafePolygonOptions {
|
||||
enabled: Getter<boolean>;
|
||||
triggerNode: Getter<HTMLElement | null>;
|
||||
contentNode: Getter<HTMLElement | null>;
|
||||
onPointerExit: () => void;
|
||||
buffer?: number;
|
||||
}
|
||||
/**
|
||||
* Creates a safe polygon area that allows users to move their cursor between
|
||||
* the trigger and floating content without closing it.
|
||||
*/
|
||||
export declare class SafePolygon {
|
||||
#private;
|
||||
constructor(opts: SafePolygonOptions);
|
||||
}
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
import { getDocument } from "svelte-toolbelt";
|
||||
import { on } from "svelte/events";
|
||||
import { watch } from "runed";
|
||||
import { isElement } from "./is.js";
|
||||
function isPointInPolygon(point, polygon) {
|
||||
const [x, y] = point;
|
||||
let isInside = false;
|
||||
const length = polygon.length;
|
||||
for (let i = 0, j = length - 1; i < length; j = i++) {
|
||||
const [xi, yi] = polygon[i] ?? [0, 0];
|
||||
const [xj, yj] = polygon[j] ?? [0, 0];
|
||||
const intersect = yi >= y !== yj >= y && x <= ((xj - xi) * (y - yi)) / (yj - yi) + xi;
|
||||
if (intersect) {
|
||||
isInside = !isInside;
|
||||
}
|
||||
}
|
||||
return isInside;
|
||||
}
|
||||
function isInsideRect(point, rect) {
|
||||
return (point[0] >= rect.left &&
|
||||
point[0] <= rect.right &&
|
||||
point[1] >= rect.top &&
|
||||
point[1] <= rect.bottom);
|
||||
}
|
||||
function getSide(triggerRect, contentRect) {
|
||||
// determine which side the content is on relative to trigger
|
||||
const triggerCenterX = triggerRect.left + triggerRect.width / 2;
|
||||
const triggerCenterY = triggerRect.top + triggerRect.height / 2;
|
||||
const contentCenterX = contentRect.left + contentRect.width / 2;
|
||||
const contentCenterY = contentRect.top + contentRect.height / 2;
|
||||
const deltaX = contentCenterX - triggerCenterX;
|
||||
const deltaY = contentCenterY - triggerCenterY;
|
||||
if (Math.abs(deltaX) > Math.abs(deltaY)) {
|
||||
return deltaX > 0 ? "right" : "left";
|
||||
}
|
||||
return deltaY > 0 ? "bottom" : "top";
|
||||
}
|
||||
/**
|
||||
* Creates a safe polygon area that allows users to move their cursor between
|
||||
* the trigger and floating content without closing it.
|
||||
*/
|
||||
export class SafePolygon {
|
||||
#opts;
|
||||
#buffer;
|
||||
// tracks the cursor position when leaving trigger or content
|
||||
#exitPoint = null;
|
||||
// tracks what we're moving toward: "content" when leaving trigger, "trigger" when leaving content
|
||||
#exitTarget = null;
|
||||
constructor(opts) {
|
||||
this.#opts = opts;
|
||||
this.#buffer = opts.buffer ?? 1;
|
||||
watch([opts.triggerNode, opts.contentNode, opts.enabled], ([triggerNode, contentNode, enabled]) => {
|
||||
if (!triggerNode || !contentNode || !enabled) {
|
||||
this.#exitPoint = null;
|
||||
this.#exitTarget = null;
|
||||
return;
|
||||
}
|
||||
const doc = getDocument(triggerNode);
|
||||
const handlePointerMove = (e) => {
|
||||
this.#onPointerMove(e, triggerNode, contentNode);
|
||||
};
|
||||
const handleTriggerLeave = (e) => {
|
||||
// when leaving trigger toward content, record exit point
|
||||
const target = e.relatedTarget;
|
||||
// if going directly to content, no need for polygon tracking
|
||||
if (isElement(target) && contentNode.contains(target)) {
|
||||
return;
|
||||
}
|
||||
this.#exitPoint = [e.clientX, e.clientY];
|
||||
this.#exitTarget = "content";
|
||||
};
|
||||
const handleTriggerEnter = () => {
|
||||
// reached trigger, clear tracking
|
||||
this.#exitPoint = null;
|
||||
this.#exitTarget = null;
|
||||
};
|
||||
const handleContentEnter = () => {
|
||||
// reached content, clear tracking
|
||||
this.#exitPoint = null;
|
||||
this.#exitTarget = null;
|
||||
};
|
||||
const handleContentLeave = (e) => {
|
||||
// when leaving content, check if going directly back to trigger
|
||||
const target = e.relatedTarget;
|
||||
if (isElement(target) && triggerNode.contains(target)) {
|
||||
// going directly to trigger, no polygon tracking needed
|
||||
return;
|
||||
}
|
||||
// might be traversing gap back to trigger, set up polygon tracking
|
||||
this.#exitPoint = [e.clientX, e.clientY];
|
||||
this.#exitTarget = "trigger";
|
||||
};
|
||||
return [
|
||||
on(doc, "pointermove", handlePointerMove),
|
||||
on(triggerNode, "pointerleave", handleTriggerLeave),
|
||||
on(triggerNode, "pointerenter", handleTriggerEnter),
|
||||
on(contentNode, "pointerenter", handleContentEnter),
|
||||
on(contentNode, "pointerleave", handleContentLeave),
|
||||
].reduce((acc, cleanup) => () => {
|
||||
acc();
|
||||
cleanup();
|
||||
}, () => { });
|
||||
});
|
||||
}
|
||||
#onPointerMove(e, triggerNode, contentNode) {
|
||||
// if no exit point recorded, nothing to check
|
||||
if (!this.#exitPoint || !this.#exitTarget)
|
||||
return;
|
||||
const clientPoint = [e.clientX, e.clientY];
|
||||
const triggerRect = triggerNode.getBoundingClientRect();
|
||||
const contentRect = contentNode.getBoundingClientRect();
|
||||
// check if pointer reached the target
|
||||
if (this.#exitTarget === "content" && isInsideRect(clientPoint, contentRect)) {
|
||||
this.#exitPoint = null;
|
||||
this.#exitTarget = null;
|
||||
return;
|
||||
}
|
||||
if (this.#exitTarget === "trigger" && isInsideRect(clientPoint, triggerRect)) {
|
||||
this.#exitPoint = null;
|
||||
this.#exitTarget = null;
|
||||
return;
|
||||
}
|
||||
// check if pointer is in the rectangular corridor between trigger and content
|
||||
const side = getSide(triggerRect, contentRect);
|
||||
const corridorPoly = this.#getCorridorPolygon(triggerRect, contentRect, side);
|
||||
if (corridorPoly && isPointInPolygon(clientPoint, corridorPoly)) {
|
||||
return;
|
||||
}
|
||||
// check if pointer is within the safe polygon from exit point to target
|
||||
const targetRect = this.#exitTarget === "content" ? contentRect : triggerRect;
|
||||
const safePoly = this.#getSafePolygon(this.#exitPoint, targetRect, side, this.#exitTarget);
|
||||
if (isPointInPolygon(clientPoint, safePoly)) {
|
||||
return;
|
||||
}
|
||||
// pointer is outside all safe zones - close
|
||||
this.#exitPoint = null;
|
||||
this.#exitTarget = null;
|
||||
this.#opts.onPointerExit();
|
||||
}
|
||||
/**
|
||||
* Creates a rectangular corridor between trigger and content
|
||||
* This prevents closing when cursor is in the gap between them
|
||||
*/
|
||||
#getCorridorPolygon(triggerRect, contentRect, side) {
|
||||
const buffer = this.#buffer;
|
||||
switch (side) {
|
||||
case "top":
|
||||
return [
|
||||
[Math.min(triggerRect.left, contentRect.left) - buffer, triggerRect.top],
|
||||
[Math.min(triggerRect.left, contentRect.left) - buffer, contentRect.bottom],
|
||||
[Math.max(triggerRect.right, contentRect.right) + buffer, contentRect.bottom],
|
||||
[Math.max(triggerRect.right, contentRect.right) + buffer, triggerRect.top],
|
||||
];
|
||||
case "bottom":
|
||||
return [
|
||||
[Math.min(triggerRect.left, contentRect.left) - buffer, triggerRect.bottom],
|
||||
[Math.min(triggerRect.left, contentRect.left) - buffer, contentRect.top],
|
||||
[Math.max(triggerRect.right, contentRect.right) + buffer, contentRect.top],
|
||||
[Math.max(triggerRect.right, contentRect.right) + buffer, triggerRect.bottom],
|
||||
];
|
||||
case "left":
|
||||
return [
|
||||
[triggerRect.left, Math.min(triggerRect.top, contentRect.top) - buffer],
|
||||
[contentRect.right, Math.min(triggerRect.top, contentRect.top) - buffer],
|
||||
[contentRect.right, Math.max(triggerRect.bottom, contentRect.bottom) + buffer],
|
||||
[triggerRect.left, Math.max(triggerRect.bottom, contentRect.bottom) + buffer],
|
||||
];
|
||||
case "right":
|
||||
return [
|
||||
[triggerRect.right, Math.min(triggerRect.top, contentRect.top) - buffer],
|
||||
[contentRect.left, Math.min(triggerRect.top, contentRect.top) - buffer],
|
||||
[contentRect.left, Math.max(triggerRect.bottom, contentRect.bottom) + buffer],
|
||||
[triggerRect.right, Math.max(triggerRect.bottom, contentRect.bottom) + buffer],
|
||||
];
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Creates a triangular/trapezoidal safe zone from the exit point to the target
|
||||
*/
|
||||
#getSafePolygon(exitPoint, targetRect, side, exitTarget) {
|
||||
const buffer = this.#buffer * 4;
|
||||
const [x, y] = exitPoint;
|
||||
// when going back to trigger, we need to flip the side
|
||||
const effectiveSide = exitTarget === "trigger" ? this.#flipSide(side) : side;
|
||||
// create polygon points from cursor to target edges
|
||||
switch (effectiveSide) {
|
||||
case "top":
|
||||
return [
|
||||
[x - buffer, y + buffer],
|
||||
[x + buffer, y + buffer],
|
||||
[targetRect.right + buffer, targetRect.bottom],
|
||||
[targetRect.right + buffer, targetRect.top],
|
||||
[targetRect.left - buffer, targetRect.top],
|
||||
[targetRect.left - buffer, targetRect.bottom],
|
||||
];
|
||||
case "bottom":
|
||||
return [
|
||||
[x - buffer, y - buffer],
|
||||
[x + buffer, y - buffer],
|
||||
[targetRect.right + buffer, targetRect.top],
|
||||
[targetRect.right + buffer, targetRect.bottom],
|
||||
[targetRect.left - buffer, targetRect.bottom],
|
||||
[targetRect.left - buffer, targetRect.top],
|
||||
];
|
||||
case "left":
|
||||
return [
|
||||
[x + buffer, y - buffer],
|
||||
[x + buffer, y + buffer],
|
||||
[targetRect.right, targetRect.bottom + buffer],
|
||||
[targetRect.left, targetRect.bottom + buffer],
|
||||
[targetRect.left, targetRect.top - buffer],
|
||||
[targetRect.right, targetRect.top - buffer],
|
||||
];
|
||||
case "right":
|
||||
return [
|
||||
[x - buffer, y - buffer],
|
||||
[x - buffer, y + buffer],
|
||||
[targetRect.left, targetRect.bottom + buffer],
|
||||
[targetRect.right, targetRect.bottom + buffer],
|
||||
[targetRect.right, targetRect.top - buffer],
|
||||
[targetRect.left, targetRect.top - buffer],
|
||||
];
|
||||
}
|
||||
}
|
||||
#flipSide(side) {
|
||||
switch (side) {
|
||||
case "top":
|
||||
return "bottom";
|
||||
case "bottom":
|
||||
return "top";
|
||||
case "left":
|
||||
return "right";
|
||||
case "right":
|
||||
return "left";
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import type { AnyFn } from "./types.js";
|
||||
export declare class SharedState<T extends AnyFn> {
|
||||
#private;
|
||||
constructor(factory: T);
|
||||
get(...args: Parameters<T>): ReturnType<T>;
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
export class SharedState {
|
||||
#factory;
|
||||
#subscribers = 0;
|
||||
#state = $state();
|
||||
#scope;
|
||||
constructor(factory) {
|
||||
this.#factory = factory;
|
||||
}
|
||||
#dispose() {
|
||||
this.#subscribers -= 1;
|
||||
if (this.#scope && this.#subscribers <= 0) {
|
||||
this.#scope();
|
||||
this.#state = undefined;
|
||||
this.#scope = undefined;
|
||||
}
|
||||
}
|
||||
get(...args) {
|
||||
this.#subscribers += 1;
|
||||
if (this.#state === undefined) {
|
||||
this.#scope = $effect.root(() => {
|
||||
this.#state = this.#factory(...args);
|
||||
});
|
||||
}
|
||||
$effect(() => {
|
||||
return () => {
|
||||
this.#dispose();
|
||||
};
|
||||
});
|
||||
return this.#state;
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export declare function shouldEnableFocusTrap({ forceMount, open, }: {
|
||||
forceMount: boolean;
|
||||
open: boolean;
|
||||
}): boolean;
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export function shouldEnableFocusTrap({ forceMount, open, }) {
|
||||
if (forceMount)
|
||||
return open;
|
||||
return open;
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { type WritableBox } from "svelte-toolbelt";
|
||||
interface Machine<S> {
|
||||
[k: string]: {
|
||||
[k: string]: S;
|
||||
};
|
||||
}
|
||||
type MachineState<T> = keyof T;
|
||||
type MachineEvent<T> = keyof UnionToIntersection<T[keyof T]>;
|
||||
type UnionToIntersection<T> = (T extends any ? (x: T) => any : never) extends (x: infer R) => any ? R : never;
|
||||
export declare class StateMachine<M> {
|
||||
#private;
|
||||
readonly state: WritableBox<MachineState<M>>;
|
||||
constructor(initialState: MachineState<M>, machine: M & Machine<MachineState<M>>);
|
||||
dispatch(event: MachineEvent<M>): void;
|
||||
}
|
||||
export {};
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { simpleBox } from "svelte-toolbelt";
|
||||
export class StateMachine {
|
||||
state;
|
||||
#machine;
|
||||
constructor(initialState, machine) {
|
||||
this.state = simpleBox(initialState);
|
||||
this.#machine = machine;
|
||||
this.dispatch = this.dispatch.bind(this);
|
||||
}
|
||||
#reducer(event) {
|
||||
// @ts-expect-error state.current is keyof M
|
||||
const nextState = this.#machine[this.state.current][event];
|
||||
return nextState ?? this.state.current;
|
||||
}
|
||||
dispatch(event) {
|
||||
this.state.current = this.#reducer(event);
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import type { Getter } from "svelte-toolbelt";
|
||||
export declare class SvelteResizeObserver {
|
||||
#private;
|
||||
constructor(node: Getter<HTMLElement | null>, onResize: () => void);
|
||||
handler(): (() => void) | undefined;
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
export class SvelteResizeObserver {
|
||||
#node;
|
||||
#onResize;
|
||||
constructor(node, onResize) {
|
||||
this.#node = node;
|
||||
this.#onResize = onResize;
|
||||
this.handler = this.handler.bind(this);
|
||||
$effect(this.handler);
|
||||
}
|
||||
handler() {
|
||||
let rAF = 0;
|
||||
const _node = this.#node();
|
||||
if (!_node)
|
||||
return;
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
cancelAnimationFrame(rAF);
|
||||
rAF = window.requestAnimationFrame(this.#onResize);
|
||||
});
|
||||
resizeObserver.observe(_node);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(rAF);
|
||||
resizeObserver.unobserve(_node);
|
||||
};
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Gets all tabbable elements in the body and finds the next/previous tabbable element
|
||||
* from the `currentNode` based on the `direction` provided.
|
||||
* @param currentNode - the node we want to get the next/previous tabbable from
|
||||
*/
|
||||
export declare function getTabbableFrom(currentNode: HTMLElement, direction: "next" | "prev"): import("tabbable").FocusableElement | undefined;
|
||||
export declare function getTabbableFromFocusable(currentNode: HTMLElement, direction: "next" | "prev"): import("tabbable").FocusableElement;
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import { focusable, isFocusable, isTabbable, tabbable } from "tabbable";
|
||||
import { getDocument } from "svelte-toolbelt";
|
||||
function getTabbableOptions() {
|
||||
return {
|
||||
getShadowRoot: true,
|
||||
displayCheck:
|
||||
// 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.
|
||||
typeof ResizeObserver === "function" &&
|
||||
ResizeObserver.toString().includes("[native code]")
|
||||
? "full"
|
||||
: "none",
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Gets all tabbable elements in the body and finds the next/previous tabbable element
|
||||
* from the `currentNode` based on the `direction` provided.
|
||||
* @param currentNode - the node we want to get the next/previous tabbable from
|
||||
*/
|
||||
export function getTabbableFrom(currentNode, direction) {
|
||||
if (!isTabbable(currentNode, getTabbableOptions())) {
|
||||
return getTabbableFromFocusable(currentNode, direction);
|
||||
}
|
||||
const doc = getDocument(currentNode);
|
||||
const allTabbable = tabbable(doc.body, getTabbableOptions());
|
||||
if (direction === "prev")
|
||||
allTabbable.reverse();
|
||||
const activeIndex = allTabbable.indexOf(currentNode);
|
||||
if (activeIndex === -1)
|
||||
return doc.body;
|
||||
const nextTabbableElements = allTabbable.slice(activeIndex + 1);
|
||||
return nextTabbableElements[0];
|
||||
}
|
||||
export function getTabbableFromFocusable(currentNode, direction) {
|
||||
const doc = getDocument(currentNode);
|
||||
if (!isFocusable(currentNode, getTabbableOptions()))
|
||||
return doc.body;
|
||||
// find all focusable nodes, since some elements may be focusable but not tabbable
|
||||
// such as context menu triggers
|
||||
const allFocusable = focusable(doc.body, getTabbableOptions());
|
||||
// find index of current node among focusable siblings
|
||||
if (direction === "prev")
|
||||
allFocusable.reverse();
|
||||
const activeIndex = allFocusable.indexOf(currentNode);
|
||||
if (activeIndex === -1)
|
||||
return doc.body;
|
||||
const nextFocusableElements = allFocusable.slice(activeIndex + 1);
|
||||
// find the next focusable node that is also tabbable
|
||||
return nextFocusableElements.find((node) => isTabbable(node, getTabbableOptions())) ?? doc.body;
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import type { AnyFn } from "./types.js";
|
||||
export declare class TimeoutFn<T extends AnyFn> {
|
||||
#private;
|
||||
constructor(cb: T, interval: number);
|
||||
stop(): void;
|
||||
start(...args: Parameters<T> | []): void;
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import { onDestroyEffect } from "svelte-toolbelt";
|
||||
export class TimeoutFn {
|
||||
#interval;
|
||||
#cb;
|
||||
#timer = null;
|
||||
constructor(cb, interval) {
|
||||
this.#cb = cb;
|
||||
this.#interval = interval;
|
||||
this.stop = this.stop.bind(this);
|
||||
this.start = this.start.bind(this);
|
||||
onDestroyEffect(this.stop);
|
||||
}
|
||||
#clear() {
|
||||
if (this.#timer !== null) {
|
||||
window.clearTimeout(this.#timer);
|
||||
this.#timer = null;
|
||||
}
|
||||
}
|
||||
stop() {
|
||||
this.#clear();
|
||||
}
|
||||
start(...args) {
|
||||
this.#clear();
|
||||
this.#timer = window.setTimeout(() => {
|
||||
this.#timer = null;
|
||||
this.#cb(...args);
|
||||
}, this.#interval);
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
import type { Snippet } from "svelte";
|
||||
import type { attachRef, Box, ReadableBoxedValues, WritableBoxedValues } from "svelte-toolbelt";
|
||||
import type { StyleProperties } from "../shared/index.js";
|
||||
export type OnChangeFn<T> = (value: T) => void;
|
||||
export type ElementRef = Box<HTMLElement | null>;
|
||||
export type WithChild<
|
||||
/**
|
||||
* The props that the component accepts.
|
||||
*/
|
||||
Props extends Record<PropertyKey, unknown> = {},
|
||||
/**
|
||||
* The props that are passed to the `child` and `children` snippets. The `ElementProps` are
|
||||
* merged with these props for the `child` snippet.
|
||||
*/
|
||||
SnippetProps extends Record<PropertyKey, unknown> = {
|
||||
_default: never;
|
||||
},
|
||||
/**
|
||||
* The underlying DOM element being rendered. You can bind to this prop to
|
||||
* programmatically interact with the element.
|
||||
*/
|
||||
Ref = HTMLElement> = Omit<Props, "child" | "children"> & {
|
||||
child?: SnippetProps extends {
|
||||
_default: never;
|
||||
} ? Snippet<[{
|
||||
props: Record<string, unknown>;
|
||||
}]> : Snippet<[SnippetProps & {
|
||||
props: Record<string, unknown>;
|
||||
}]>;
|
||||
children?: SnippetProps extends {
|
||||
_default: never;
|
||||
} ? Snippet : Snippet<[SnippetProps]>;
|
||||
style?: StyleProperties | string | null | undefined;
|
||||
ref?: Ref | null | undefined;
|
||||
};
|
||||
export type WithChildNoChildrenSnippetProps<
|
||||
/**
|
||||
* The props that the component accepts.
|
||||
*/
|
||||
Props extends Record<PropertyKey, unknown> = {},
|
||||
/**
|
||||
* The props that are passed to the `child` and `children` snippets. The `ElementProps` are
|
||||
* merged with these props for the `child` snippet.
|
||||
*/
|
||||
SnippetProps extends Record<PropertyKey, unknown> = {
|
||||
_default: never;
|
||||
},
|
||||
/**
|
||||
* The underlying DOM element being rendered. You can bind to this prop to
|
||||
* programmatically interact with the element.
|
||||
*/
|
||||
Ref = HTMLElement> = Omit<Props, "child" | "children"> & {
|
||||
child?: SnippetProps extends {
|
||||
_default: never;
|
||||
} ? Snippet<[{
|
||||
props: Record<string, unknown>;
|
||||
}]> : Snippet<[SnippetProps & {
|
||||
props: Record<string, unknown>;
|
||||
}]>;
|
||||
children?: Snippet;
|
||||
style?: StyleProperties | string | null | undefined;
|
||||
ref?: Ref | null | undefined;
|
||||
};
|
||||
export type WithChildren<Props = {}> = Props & {
|
||||
children?: Snippet | undefined;
|
||||
};
|
||||
/**
|
||||
* Constructs a new type by omitting properties from type
|
||||
* 'T' that exist in type 'U'.
|
||||
*
|
||||
* @template T - The base object type from which properties will be omitted.
|
||||
* @template U - The object type whose properties will be omitted from 'T'.
|
||||
* @example
|
||||
* type Result = Without<{ a: number; b: string; }, { b: string; }>;
|
||||
* // Result type will be { a: number; }
|
||||
*/
|
||||
export type Without<T extends object, U extends object> = Omit<T, keyof U>;
|
||||
export type Arrayable<T> = T[] | T;
|
||||
export type Fn = () => void;
|
||||
export type AnyFn = (...args: any[]) => any;
|
||||
export type WithRefOpts<T = {}> = T & ReadableBoxedValues<{
|
||||
id: string;
|
||||
}> & WritableBoxedValues<{
|
||||
ref: HTMLElement | null;
|
||||
}>;
|
||||
export type BitsEvent<T extends Event = Event, U extends HTMLElement = HTMLElement> = T & {
|
||||
currentTarget: U;
|
||||
};
|
||||
export type BitsPointerEvent<T extends HTMLElement = HTMLElement> = BitsEvent<PointerEvent, T>;
|
||||
export type BitsKeyboardEvent<T extends HTMLElement = HTMLElement> = BitsEvent<KeyboardEvent, T>;
|
||||
export type BitsMouseEvent<T extends HTMLElement = HTMLElement> = BitsEvent<MouseEvent, T>;
|
||||
export type BitsFocusEvent<T extends HTMLElement = HTMLElement> = BitsEvent<FocusEvent, T>;
|
||||
export type RefAttachment<T extends HTMLElement = HTMLElement> = ReturnType<typeof attachRef<T>>;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export {};
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import type { Direction } from "../shared/index.js";
|
||||
type ArrowKeyOptions = "horizontal" | "vertical" | "both";
|
||||
interface ArrowNavigationOptions {
|
||||
/**
|
||||
* The arrow key options to allow navigation
|
||||
*
|
||||
* @defaultValue "both"
|
||||
*/
|
||||
arrowKeyOptions?: ArrowKeyOptions;
|
||||
/**
|
||||
* The selector to find the collection items in the parent element.
|
||||
*/
|
||||
candidateSelector: string;
|
||||
/**
|
||||
* The parent element where contains all the collection items, this will collect every item to be used when nav
|
||||
* It will be ignored if attributeName is provided
|
||||
*
|
||||
* @defaultValue []
|
||||
*/
|
||||
itemsArray?: HTMLElement[];
|
||||
/**
|
||||
* Allow loop navigation. If false, it will stop at the first and last element
|
||||
*
|
||||
* @defaultValue true
|
||||
*/
|
||||
loop?: boolean;
|
||||
/**
|
||||
* The orientation of the collection
|
||||
*
|
||||
* @defaultValue "ltr"
|
||||
*/
|
||||
dir?: Direction;
|
||||
/**
|
||||
* Prevent the scroll when navigating. This happens when the direction of the
|
||||
* key matches the scroll direction of any ancestor scrollable elements.
|
||||
*
|
||||
* @defaultValue true
|
||||
*/
|
||||
preventScroll?: boolean;
|
||||
/**
|
||||
* By default all currentElement would trigger navigation. If `true`, currentElement nodeName in the ignore list will return null
|
||||
*
|
||||
* @defaultValue false
|
||||
*/
|
||||
enableIgnoredElement?: boolean;
|
||||
/**
|
||||
* Focus the element after navigation
|
||||
*
|
||||
* @defaultValue false
|
||||
*/
|
||||
focus?: boolean;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param e Keyboard event
|
||||
* @param currentElement Event initiator element or any element that wants to handle the navigation
|
||||
* @param parentElement Parent element where contains all the collection items, this will collect every item to be used when nav
|
||||
* @param options further options
|
||||
* @returns the navigated html element or null if none
|
||||
*/
|
||||
export declare function useArrowNavigation(e: KeyboardEvent, currentElement: HTMLElement, parentElement: HTMLElement | undefined, options: ArrowNavigationOptions): HTMLElement | null;
|
||||
export {};
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
const ignoredElement = ["INPUT", "TEXTAREA"];
|
||||
/**
|
||||
*
|
||||
* @param e Keyboard event
|
||||
* @param currentElement Event initiator element or any element that wants to handle the navigation
|
||||
* @param parentElement Parent element where contains all the collection items, this will collect every item to be used when nav
|
||||
* @param options further options
|
||||
* @returns the navigated html element or null if none
|
||||
*/
|
||||
export function useArrowNavigation(e, currentElement, parentElement, options) {
|
||||
if (!currentElement ||
|
||||
(options.enableIgnoredElement && ignoredElement.includes(currentElement.nodeName))) {
|
||||
return null;
|
||||
}
|
||||
const { arrowKeyOptions = "both", candidateSelector: attributeName, itemsArray = [], loop = true, dir = "ltr", preventScroll = true, focus = false, } = options;
|
||||
const [right, left, up, down, home, end] = [
|
||||
e.key === "ArrowRight",
|
||||
e.key === "ArrowLeft",
|
||||
e.key === "ArrowUp",
|
||||
e.key === "ArrowDown",
|
||||
e.key === "Home",
|
||||
e.key === "End",
|
||||
];
|
||||
const goingVertical = up || down;
|
||||
const goingHorizontal = right || left;
|
||||
if (!home &&
|
||||
!end &&
|
||||
((!goingVertical && !goingHorizontal) ||
|
||||
(arrowKeyOptions === "vertical" && goingHorizontal) ||
|
||||
(arrowKeyOptions === "horizontal" && goingVertical)))
|
||||
return null;
|
||||
const allCollectionItems = parentElement
|
||||
? Array.from(parentElement.querySelectorAll(attributeName))
|
||||
: itemsArray;
|
||||
if (!allCollectionItems.length)
|
||||
return null;
|
||||
if (preventScroll)
|
||||
e.preventDefault();
|
||||
let item = null;
|
||||
if (goingHorizontal || goingVertical) {
|
||||
const goForward = goingVertical ? down : dir === "ltr" ? right : left;
|
||||
item = findNextFocusableElement(allCollectionItems, currentElement, {
|
||||
goForward,
|
||||
loop,
|
||||
});
|
||||
}
|
||||
else if (home) {
|
||||
item = allCollectionItems.at(0) || null;
|
||||
}
|
||||
else if (end) {
|
||||
item = allCollectionItems.at(-1) || null;
|
||||
}
|
||||
if (focus)
|
||||
item?.focus();
|
||||
return item;
|
||||
}
|
||||
/**
|
||||
* Recursive function to find the next focusable element to avoid disabled elements
|
||||
*/
|
||||
function findNextFocusableElement(elements, currentElement, { goForward, loop }, iterations = elements.length) {
|
||||
if (--iterations === 0)
|
||||
return null;
|
||||
const index = elements.indexOf(currentElement);
|
||||
const newIndex = goForward ? index + 1 : index - 1;
|
||||
if (!loop && (newIndex < 0 || newIndex >= elements.length))
|
||||
return null;
|
||||
const adjustedNewIndex = (newIndex + elements.length) % elements.length;
|
||||
const candidate = elements[adjustedNewIndex];
|
||||
if (!candidate)
|
||||
return null;
|
||||
const isDisabled = candidate.hasAttribute("disabled") && candidate.getAttribute("disabled") !== "false";
|
||||
if (isDisabled) {
|
||||
return findNextFocusableElement(elements, candidate, { goForward, loop }, iterations);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Generates a unique ID based on a global counter.
|
||||
*/
|
||||
export declare function useId(prefix?: string): string;
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
globalThis.bitsIdCounter ??= { current: 0 };
|
||||
/**
|
||||
* Generates a unique ID based on a global counter.
|
||||
*/
|
||||
export function useId(prefix = "bits") {
|
||||
globalThis.bitsIdCounter.current++;
|
||||
return `${prefix}-${globalThis.bitsIdCounter.current}`;
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export declare function warn(...messages: string[]): void;
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { DEV } from "esm-env";
|
||||
let set;
|
||||
if (DEV) {
|
||||
set = new Set();
|
||||
}
|
||||
export function warn(...messages) {
|
||||
if (!DEV)
|
||||
return;
|
||||
const msg = messages.join(" ");
|
||||
if (set.has(msg))
|
||||
return;
|
||||
set.add(msg);
|
||||
// oxlint-disable-next-line no-console
|
||||
console.warn(`[Bits UI]: ${msg}`);
|
||||
}
|
||||
Reference in New Issue
Block a user