From 8816907b78289034f00ef14949095ddfb2ec4382 Mon Sep 17 00:00:00 2001 From: Dziad Borowy Date: Thu, 14 Aug 2025 14:59:20 +0100 Subject: [PATCH] ui-state rewrite --- CHANGELOG.md | 3 +- docs-src/components/utils/Utils.css | 6 +- .../properties/focusable-selector.svelte | 6 +- docs-src/components/utils/properties/index.js | 3 +- .../utils/properties/prefers-dark.svelte | 19 -- .../components/utils/properties/ui.svelte | 56 ++++ docs-src/pages/changelog.html | 3 +- src/dialog/Dialog.svelte | 6 +- src/drawer/Drawer.svelte | 4 +- src/index.ts | 14 +- src/input/combobox/Combobox.svelte | 4 +- src/popover/Popover.svelte | 4 +- src/utils/animations.ts | 2 +- src/utils/dom.ts | 17 -- src/utils/index.ts | 2 +- src/utils/ui.svelte.ts | 50 ---- src/utils/ui.ts | 252 ++++++++++++++++++ tests/_setup.ts | 6 +- tests/utils/alignItem.test.ts | 2 +- tests/utils/animations.test.ts | 44 +-- tests/utils/constants.test.ts | 154 ++--------- tests/utils/date.test.ts | 2 - tests/utils/dom.test.ts | 6 - tests/utils/ui.test.ts | 67 +++++ tests/utils/utilities.test.ts | 155 ++++++++--- 25 files changed, 560 insertions(+), 327 deletions(-) delete mode 100644 docs-src/components/utils/properties/prefers-dark.svelte create mode 100644 docs-src/components/utils/properties/ui.svelte delete mode 100644 src/utils/ui.svelte.ts create mode 100644 src/utils/ui.ts create mode 100644 tests/utils/ui.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 679a9147..1ea3564d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,10 +23,9 @@ Thousand thanks to the Svelte's team, for the [long months of hard work](https:/ and second argument (`data`) is specific to the event & component, e.g. - for Inputs `onchange` event, `data` argument will contain the new value (`{ value: any }`) - Event listeners added to components' events will receive the relevant data as the second argument of the callback. There is no `event.detail` anymore. -- Utils functions & constants have been updated to use the new reactive `$state`: +- Utils functions & constants have been updated to use a new reactive state object `UI`, which is now exported from `src/utils`: - `$ANIMATION_SPEED` -> `UI.ANIMATION_SPEED` - (number) reactive constant - `$PREFERS_DARK` -> `UI.PREFERS_DARK` - (boolean) reactive constant - - `FOCUSABLE_SELECTOR` -> `UI.FOCUSABLE_SELECTOR` - (string) constant - `isMobile()` -> `UI.isMobile` - (boolean) constant - As per Svelte's best practices, attributes bound to variables will not use quotes anymore, so: `value="{value}"` -> `value={value}` or even better: `{value}`. - Attributes are typed now, and are a bit more restrictive, so e.g. a `boolean` or a `number` attribute will not accept a string value. diff --git a/docs-src/components/utils/Utils.css b/docs-src/components/utils/Utils.css index 48853b37..6fdd53cf 100644 --- a/docs-src/components/utils/Utils.css +++ b/docs-src/components/utils/Utils.css @@ -1,10 +1,11 @@ .section-utils { --nav-sidebar-width: 240px; } -.section-utils .dark-mode-switch { right: calc(var(--nav-sidebar-width) + 20px); } .section-utils .sticky-block { padding-bottom: 3rem; margin-right: var(--nav-sidebar-width); } +.section-utils .docs-theme-switcher { right: calc(var(--nav-sidebar-width) + 0.6rem); } + .section-utils .sticky-block .utility h3 { scroll-margin-top: 4.2rem; @@ -39,7 +40,7 @@ @media (1px <= width <= 900px) { - .section-utils .dark-mode-switch { right: 0.6rem; } + .section-utils .docs-theme-switcher { right: 0.6rem; } .section-utils .btn-scroll-top { right: 1rem; } .section-utils .sticky-block { margin-right: 0; } .section-utils .utilities-nav { @@ -50,5 +51,4 @@ margin-top: 2rem; background-color: unset; } - } diff --git a/docs-src/components/utils/properties/focusable-selector.svelte b/docs-src/components/utils/properties/focusable-selector.svelte index 50d82226..a133bbbd 100644 --- a/docs-src/components/utils/properties/focusable-selector.svelte +++ b/docs-src/components/utils/properties/focusable-selector.svelte @@ -1,4 +1,4 @@ - +
  • Type: string
  • Returns a list of selectors that can be focused.
  • @@ -10,9 +10,9 @@ import Util from '../Util.svelte'; const example = ` -
      -
    • Svelte state variable
    • -
    • Type: boolean
    • -
    • Updates on system theme change.
    • -
    • Returns user preference for dark mode.
    • -
    - - - diff --git a/docs-src/components/utils/properties/ui.svelte b/docs-src/components/utils/properties/ui.svelte new file mode 100644 index 00000000..629f3fab --- /dev/null +++ b/docs-src/components/utils/properties/ui.svelte @@ -0,0 +1,56 @@ + +

    A framework helper module. Provides a bunch of useful functions and properties, like:

    +
      +
    • UI.ANIMATION_SPEED - default animation duration in ms. If the user prefers reduced-motion - this will be 0.
    • +
    • UI.IS_MOBILE - returns true if the user is on a mobile device.
    • +
    • UI.PREFERS_DARK - returns true if the user prefers dark mode.
    • +
    • UI.VERSION - the current version of the framework.
    • +
    • UI.init() - initializes the UI module and sets up some event listeners.
    • +
    • UI.destroy() - cleans up the UI module and removes the event listeners.
    • +
    • UI.on(eventName: string, callback: Function) - adds an event listener for the specified event.
    • +
    • UI.off(eventName: string, callback: Function) - removes the event listener for the specified event.
    • +
    +

    Events:

    +
      +
    • change - fired when a property value changes (for ANIMATION_SPEED and PREFERS_DARK, as others don't change at runtime).
    • +
    +

    UI.init()

    +

    Initializes the UI module:

    +
      +
    • It sets the ANIMATION_SPEED and PREFERS_DARK properties based on the user's preferences.
    • +
    • It adds either mobile or desktop class to the html element based on the user's device.
    • +
    • It adds event listeners for keyboard and mouse, and toggles the ui-keyboard class on the html element based on the user's interactions. + This is useful for providing better accessibility and user experience, e.g. show additional controls or visual focus for keyboard users.
    • +
    +
    + + diff --git a/docs-src/pages/changelog.html b/docs-src/pages/changelog.html index d0d0b59c..53348688 100644 --- a/docs-src/pages/changelog.html +++ b/docs-src/pages/changelog.html @@ -20,10 +20,9 @@

    Breaking changes

  • Event listeners added to components' events will receive the relevant data as the second argument of the callback. There is no event.detail anymore.
  • -
  • Utils functions & constants have been updated to use the new reactive $state:
      +
    • Utils functions & constants have been updated to use a new reactive state object UI, which is now exported from src/utils:
      • $ANIMATION_SPEED -> UI.ANIMATION_SPEED - (number) reactive constant
      • $PREFERS_DARK -> UI.PREFERS_DARK - (boolean) reactive constant
      • -
      • FOCUSABLE_SELECTOR -> UI.FOCUSABLE_SELECTOR - (string) constant
      • isMobile() -> UI.isMobile - (boolean) constant
    • diff --git a/src/dialog/Dialog.svelte b/src/dialog/Dialog.svelte index 7c299b21..d86cebf7 100644 --- a/src/dialog/Dialog.svelte +++ b/src/dialog/Dialog.svelte @@ -39,7 +39,7 @@ A modal dialog component with accessibility features and focus management. import './Dialog.css'; import type { DialogProps } from './types'; import { onMount } from 'svelte'; -import { UI } from '../utils'; +import { FOCUSABLE_SELECTOR, UI } from '../utils'; let { @@ -100,8 +100,8 @@ function focusLast () { function getFocusableElements () { if (!dialogEl || !contentEl || !footerEl) return []; - const contentElements = Array.from(contentEl.querySelectorAll(UI.FOCUSABLE_SELECTOR)); - const footerElements = Array.from(footerEl.querySelectorAll(UI.FOCUSABLE_SELECTOR)); + const contentElements = Array.from(contentEl.querySelectorAll(FOCUSABLE_SELECTOR)); + const footerElements = Array.from(footerEl.querySelectorAll(FOCUSABLE_SELECTOR)); return [...contentElements, ...footerElements]; } diff --git a/src/drawer/Drawer.svelte b/src/drawer/Drawer.svelte index 5f4f9b2d..5f41c4f4 100644 --- a/src/drawer/Drawer.svelte +++ b/src/drawer/Drawer.svelte @@ -41,7 +41,7 @@ A sliding panel component that appears from the side of the screen. import './Drawer.css'; import type { DrawerProps } from './types'; import { fly } from 'svelte/transition'; -import { UI } from '../utils'; +import { FOCUSABLE_SELECTOR, UI } from '../utils'; import { Button } from '../button'; @@ -120,7 +120,7 @@ function focusLast () { function getFocusableElements () { - return Array.from(element.querySelectorAll(UI.FOCUSABLE_SELECTOR)); + return Array.from(element.querySelectorAll(FOCUSABLE_SELECTOR)); } diff --git a/src/index.ts b/src/index.ts index 537dc5eb..ed29e0c7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,3 @@ -import { initUI } from './utils'; -import './root.css'; -import './theme-dark.css'; -import './theme-light.css'; - -initUI(); - export * from './button'; export * from './button-group'; export * from './dialog'; @@ -28,3 +21,10 @@ export * from './tooltip'; export * from './tree'; export * from './utils'; export * from './types'; + +import { UI } from './utils'; +import './root.css'; +import './theme-dark.css'; +import './theme-light.css'; + +UI.init(); diff --git a/src/input/combobox/Combobox.svelte b/src/input/combobox/Combobox.svelte index 52870957..c111bab3 100644 --- a/src/input/combobox/Combobox.svelte +++ b/src/input/combobox/Combobox.svelte @@ -307,7 +307,7 @@ function clear () { function selectInputText () { - if (inputElement && !UI.isMobile) { + if (inputElement && !UI.IS_MOBILE) { requestAnimationFrame(() => inputElement.select()); } } @@ -336,7 +336,7 @@ function oninput () { function onItemClick (e: Event, item) { - if (UI.isMobile) { + if (UI.IS_MOBILE) { if (e?.type !== 'touchend') return; } else { diff --git a/src/popover/Popover.svelte b/src/popover/Popover.svelte index 0f08e2fd..fa897a9a 100644 --- a/src/popover/Popover.svelte +++ b/src/popover/Popover.svelte @@ -41,7 +41,7 @@ import './Popover.css'; import type { AlignmentDirection } from '../types'; import type { PopoverProps } from './types'; import { addArias, removeArias } from './utils'; -import { alignItem, UI } from '../utils'; +import { alignItem, FOCUSABLE_SELECTOR } from '../utils'; import { throttle } from 'es-toolkit'; let { @@ -165,7 +165,7 @@ function focusLast () { function getFocusableElements () { if (!contentEl) return []; - return Array.from(contentEl.querySelectorAll(UI.FOCUSABLE_SELECTOR)); + return Array.from(contentEl.querySelectorAll(FOCUSABLE_SELECTOR)); } diff --git a/src/utils/animations.ts b/src/utils/animations.ts index a123c452..fc9e80ef 100644 --- a/src/utils/animations.ts +++ b/src/utils/animations.ts @@ -1,4 +1,4 @@ -import { UI } from './ui.svelte'; +import { UI } from './ui'; /** diff --git a/src/utils/dom.ts b/src/utils/dom.ts index ef79a8db..508518be 100644 --- a/src/utils/dom.ts +++ b/src/utils/dom.ts @@ -33,20 +33,3 @@ export function getMouseY (e: any): number { export function getMouseXY (e: any): [number, number] { return [getMouseX(e), getMouseY(e)]; } - - - -export function initKeyboardTracking (cls: string) { - const keys = ['Escape', 'Tab', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' ', 'Enter']; - - const setKeyboardMode = () => document.documentElement.classList.add(cls); - const unsetKeyboardMode = () => document.documentElement.classList.remove(cls); - const isKey = (key) => keys.includes(key); - - const onkeydown = (e) => { if (isKey(e.key)) setKeyboardMode(); }; - - window.addEventListener('keydown', onkeydown, { passive: true, capture: true }); - window.addEventListener('mousedown', unsetKeyboardMode, { passive: true, capture: true }); - window.addEventListener('pointerdown', unsetKeyboardMode, { passive: true, capture: true }); - window.addEventListener('touchstart', unsetKeyboardMode, { passive: true, capture: true }); -} diff --git a/src/utils/index.ts b/src/utils/index.ts index 664ce606..81805862 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -3,5 +3,5 @@ export * from './animations'; export * from './constants'; export * from './date'; export * from './dom'; -export * from './ui.svelte'; +export * from './ui'; export * from './utilities'; diff --git a/src/utils/ui.svelte.ts b/src/utils/ui.svelte.ts deleted file mode 100644 index 8326531a..00000000 --- a/src/utils/ui.svelte.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { DEFAULT_ANIMATION_SPEED, FOCUSABLE_SELECTOR } from './constants'; -import { isMobile } from './utilities'; -import { initKeyboardTracking } from './dom'; - -const MOBILE_CLASS = 'mobile'; -const DESKTOP_CLASS = 'desktop'; -const KEYBOARD_CLASS = 'ui-keyboard'; - - -export const UI = $state({ - initialised: false, - ANIMATION_SPEED: DEFAULT_ANIMATION_SPEED, - PREFERS_DARK: false, - FOCUSABLE_SELECTOR, - isMobile: isMobile(), -}); - - - - -export function initUI () { - if (UI.initialised) return; - - document.documentElement.classList.add(UI.isMobile ? MOBILE_CLASS : DESKTOP_CLASS); - initKeyboardTracking(KEYBOARD_CLASS); - - UI.initialised = true; -} - - - - -function setReducedMotion (query) { - UI.ANIMATION_SPEED = (!query || query.matches) ? 0 : DEFAULT_ANIMATION_SPEED; -} - -function setPrefersDark (query) { - UI.PREFERS_DARK = query && query.matches; -} - - -if (window.matchMedia) { - const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); - reducedMotion.addEventListener('change', setReducedMotion); - setReducedMotion(reducedMotion); - - const prefersDark = window.matchMedia('(prefers-color-scheme: dark)'); - prefersDark.addEventListener('change', setPrefersDark); - setPrefersDark(prefersDark); -} diff --git a/src/utils/ui.ts b/src/utils/ui.ts new file mode 100644 index 00000000..bdb2cfc4 --- /dev/null +++ b/src/utils/ui.ts @@ -0,0 +1,252 @@ +import { isMobile } from './utilities'; +import { DEFAULT_ANIMATION_SPEED } from './constants'; +import pkg from '../../package.json'; + + +const MOBILE_CLASS = 'mobile'; +const DESKTOP_CLASS = 'desktop'; +const KEYBOARD_CLASS = 'ui-keyboard'; +const keys = ['Escape', 'Tab', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' ', 'Enter']; + +const UI_EVENT = { + CHANGE: 'change' +}; + +type UIEventName = typeof UI_EVENT[keyof typeof UI_EVENT]; +type Callback = (property: string, value: any) => void; + +type UIState = { + ANIMATION_SPEED: number; + IS_MOBILE: boolean; + PREFERS_DARK: boolean; + VERSION: string; + init: () => void; + destroy: () => void; + on: (topic: UIEventName, callback: Callback) => void; + off: (topic: UIEventName, callback: Callback) => void; + // fire: (topic: UIEventName, ...args: any[]) => void; +}; + +const _listeners: Record = { CHANGE: [] }; + + +export const UI: UIState = {} as UIState; +Object.defineProperties(UI, { + _ANIMATION_SPEED: { + value: DEFAULT_ANIMATION_SPEED, + writable: true, + enumerable: false, + configurable: false + }, + + /** + * The animation speed in milliseconds. + * This will change based on user's OS setting (prefers-reduced-motion) + * If prefers-reduced-motion is enabled, this will be 0. + */ + ANIMATION_SPEED: { + get (): number { return this._ANIMATION_SPEED; }, + enumerable: true, + configurable: false + }, + + + IS_MOBILE: { + value: isMobile(), + writable: false, + enumerable: true, + configurable: false + }, + + + _PREFERS_DARK: { + value: true, + writable: true, + enumerable: false, + configurable: false + }, + /** + * Boolean value that reflects the user's OS setting for dark mode. + * If the user has enabled dark mode, this will be true. + */ + PREFERS_DARK: { + get (): boolean { return this._PREFERS_DARK; }, + enumerable: true, + configurable: false + }, + + + VERSION: { + value: pkg.version, + writable: false, // cannot be reassigned + enumerable: true, // shows up in for...in / Object.keys + configurable: false // cannot delete or reconfigure + }, + + + _INITIALISED: { + value: false, + writable: true, + enumerable: false, + configurable: false + }, + + init: { + writable: false, + enumerable: false, + configurable: false, + value () { + if (this._INITIALISED) return; + initPlatformClass(); + initKeyboardTracking(); + initMediaQueries(); + this._INITIALISED = true; + }, + }, + + destroy: { + writable: false, + enumerable: false, + configurable: false, + value () { + if (!this._INITIALISED) return; + removePlatformClass(); + stopKeyboardTracking(); + stopMediaQueries(); + this._INITIALISED = false; + }, + }, + + + + + _listeners: { + value: _listeners, + writable: true, + enumerable: false, + configurable: false + }, + on: { + writable: false, + enumerable: true, + configurable: false, + value (topic: UIEventName, callback: Callback) { + if (!this._listeners[topic]) this._listeners[topic] = []; + this._listeners[topic].push(callback); + }, + }, + + off: { + writable: false, + enumerable: true, + configurable: false, + value (topic: UIEventName, callback: Callback) { + const cached = this._listeners[topic]; + const callbackStr = callback.toString(); + if (!cached) return; + + const idx = cached.findIndex(fn => fn.toString() === callbackStr); + if (idx > -1) cached.splice(idx, 1); + }, + }, + + fire: { + writable: false, + enumerable: false, + configurable: false, + value (topic: UIEventName, ...args) { + if (!this._listeners[topic]) return; + this._listeners[topic].forEach(cb => { + if (typeof cb === 'function') cb.apply(cb, args); + }); + }, + }, +}); + + + + + + + +/*** MOBILE/DESKTOP CLASS *************************************************************************/ +function initPlatformClass () { + removePlatformClass(); + document.documentElement.classList.add(UI.IS_MOBILE ? MOBILE_CLASS : DESKTOP_CLASS); +} + +function removePlatformClass () { + document.documentElement.classList.remove(MOBILE_CLASS, DESKTOP_CLASS); +} +/*** MOBILE/DESKTOP CLASS *************************************************************************/ + + + + + + + +/*** MEDIA QUERIES ********************************************************************************/ +function setReducedMotion (query) { + (UI as any)._ANIMATION_SPEED = (!query || query.matches) ? 0 : DEFAULT_ANIMATION_SPEED; + // fire is not enumerable + (UI as any).fire(UI_EVENT.CHANGE, 'ANIMATION_SPEED', UI.ANIMATION_SPEED); +} + +function setPrefersDark (query) { + (UI as any)._PREFERS_DARK = query && query.matches; + // fire is not enumerable + (UI as any).fire(UI_EVENT.CHANGE, 'PREFERS_DARK', UI.PREFERS_DARK); +} + +function initMediaQueries () { + if (!window.matchMedia) return; + const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); + reducedMotion.addEventListener('change', setReducedMotion); + setReducedMotion(reducedMotion); + + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)'); + prefersDark.addEventListener('change', setPrefersDark); + setPrefersDark(prefersDark); +} + + +function stopMediaQueries () { + if (!window.matchMedia) return; + + const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); + reducedMotion.removeEventListener('change', setReducedMotion); + + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)'); + prefersDark.removeEventListener('change', setPrefersDark); +} +/*** MEDIA QUERIES ********************************************************************************/ + + + + + + +/*** KEYBOARD TRACKING ****************************************************************************/ +const setKeyboardMode = () => document.documentElement.classList.add(KEYBOARD_CLASS); +const unsetKeyboardMode = () => document.documentElement.classList.remove(KEYBOARD_CLASS); +const isKey = (key) => keys.includes(key); + +const onkeydown = (e) => { if (isKey(e.key)) setKeyboardMode(); }; + + +export function initKeyboardTracking () { + window.addEventListener('keydown', onkeydown, { passive: true, capture: true }); + window.addEventListener('mousedown', unsetKeyboardMode, { passive: true, capture: true }); + window.addEventListener('pointerdown', unsetKeyboardMode, { passive: true, capture: true }); + window.addEventListener('touchstart', unsetKeyboardMode, { passive: true, capture: true }); +} + + +export function stopKeyboardTracking () { + window.removeEventListener('keydown', onkeydown, { capture: true }); + window.removeEventListener('mousedown', unsetKeyboardMode, { capture: true }); + window.removeEventListener('pointerdown', unsetKeyboardMode, { capture: true }); + window.removeEventListener('touchstart', unsetKeyboardMode, { capture: true }); +} +/*** KEYBOARD TRACKING ****************************************************************************/ diff --git a/tests/_setup.ts b/tests/_setup.ts index 8b23b979..57df98eb 100644 --- a/tests/_setup.ts +++ b/tests/_setup.ts @@ -1,10 +1,12 @@ import '@testing-library/jest-dom'; import { vi } from 'vitest'; -import * as utils from '../src/utils'; +import { UI } from '../src/utils'; // Mock CSS imports vi.mock('*.css', () => ({})); -utils.UI.ANIMATION_SPEED = 0; + +UI.init(); +(UI as any)._ANIMATION_SPEED = 0; // Mock Element.animate for tests since jsdom doesn't support it diff --git a/tests/utils/alignItem.test.ts b/tests/utils/alignItem.test.ts index b2608df2..1e542087 100644 --- a/tests/utils/alignItem.test.ts +++ b/tests/utils/alignItem.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeEach, describe, expect, test } from 'vitest'; - import * as utils from '../../src/utils'; import '../helpers/utils'; + describe('utils - alignItem', () => { let element, target; diff --git a/tests/utils/animations.test.ts b/tests/utils/animations.test.ts index 0031d797..a187ddd0 100644 --- a/tests/utils/animations.test.ts +++ b/tests/utils/animations.test.ts @@ -1,38 +1,40 @@ import { describe, expect, test, vi } from 'vitest'; - import * as utils from '../../src/utils'; -import '../helpers/utils'; -describe('utils - formatDate', () => { - test('animate is called', async () => { - const div = document.createElement('div'); - document.body.appendChild(div); +describe('animations', () => { - const spy = vi.spyOn(div, 'animate'); - await utils.animate(div, {}, {}); - expect(spy).toHaveBeenCalled(); - }); + describe('animate', () => { + test('runs and calls the callbacks', async () => { + const div = document.createElement('div'); + document.body.appendChild(div); + + const spy = vi.spyOn(div, 'animate'); + await utils.animate(div, {}, {}); + expect(spy).toHaveBeenCalled(); + }); - test('resolves when element does not exist', async () => { - const res = await utils.animate(undefined, {}, {}); - expect(res).toBeUndefined(); + test('resolves when element does not exist', async () => { + const res = await utils.animate(undefined, {}, {}); + expect(res).toBeUndefined(); + }); }); -}); -describe('utils - blink', () => { + describe('blink', () => { + + test('runs and calls the callbacks', async () => { + const div = document.createElement('div'); + document.body.appendChild(div); - test('utils - blink', async () => { - const div = document.createElement('div'); - document.body.appendChild(div); + const spy = vi.spyOn(div, 'animate'); + await utils.blink(div); + expect(spy).toHaveBeenCalled(); + }); - const spy = vi.spyOn(div, 'animate'); - await utils.blink(div); - expect(spy).toHaveBeenCalled(); }); }); diff --git a/tests/utils/constants.test.ts b/tests/utils/constants.test.ts index 6e44f13e..47325fb2 100644 --- a/tests/utils/constants.test.ts +++ b/tests/utils/constants.test.ts @@ -1,150 +1,28 @@ -import { expect, test, vi, afterEach, describe } from 'vitest'; +import { expect, test, describe } from 'vitest'; +import { FOCUSABLE_SELECTOR, DEFAULT_ANIMATION_SPEED } from '../../src/utils'; + -import * as utils from '../../src/utils'; -import '../helpers/utils'; describe('constants', () => { - afterEach(() => { - vi.clearAllMocks(); - vi.unstubAllGlobals(); + test('DEFAULT_ANIMATION_SPEED should be a non-zero number', () => { + expect(typeof DEFAULT_ANIMATION_SPEED).toBe('number'); + expect(DEFAULT_ANIMATION_SPEED).toBeGreaterThan(0); }); - test('UI state should have default values', () => { - expect(utils.UI.ANIMATION_SPEED).toBe(0); // Set to 0 by test helper - expect(utils.UI.PREFERS_DARK).toBe(false); - expect(utils.UI.FOCUSABLE_SELECTOR).toContain('button:not([disabled])'); - expect(typeof utils.UI.isMobile).toBe('boolean'); - }); test('FOCUSABLE_SELECTOR should contain all expected selectors', () => { - const selector = utils.UI.FOCUSABLE_SELECTOR; - - expect(selector).toContain('a[href]:not([disabled])'); - expect(selector).toContain('button:not([disabled])'); - expect(selector).toContain('iframe:not([disabled])'); - expect(selector).toContain('input:not([disabled])'); - expect(selector).toContain('select:not([disabled])'); - expect(selector).toContain('textarea:not([disabled])'); - expect(selector).toContain('[contentEditable]'); - expect(selector).toContain('[tabindex]:not(.focus-trap)'); - }); - - describe('isMobile function', () => { - test('should detect mobile user agents (debug test)', () => { - expect(() => utils.isMobile()).not.toThrow(); - expect(typeof utils.isMobile()).toBe('boolean'); - }); - - test('should detect mobile user agents (Android)', () => { - vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Linux; Android 9; SM-G973F) AppleWebKit/537.36 Mobile Safari/537.36' }); - expect(utils.isMobile()).toBe(true); - }); - - test('should detect mobile user agents (iPhone)', () => { - vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_7_1 like Mac OS X) Mobile/15E148 Safari/604.1' }); - expect(utils.isMobile()).toBe(true); - }); - - test('should detect mobile user agents (BlackBerry)', () => { - vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (BlackBerry; U; BlackBerry 9900; en) AppleWebKit/534.11+ Mobile Safari/534.11+' }); - expect(utils.isMobile()).toBe(true); - }); - - test('should detect mobile from first 4 characters of user agent', () => { - vi.stubGlobal('navigator', { userAgent: '1207/some-mobile-device' }); - expect(utils.isMobile()).toBe(true); - - vi.stubGlobal('navigator', { userAgent: '6310/mobile-device' }); - expect(utils.isMobile()).toBe(true); - - // Test mobile firefox pattern - vi.stubGlobal('navigator', { userAgent: 'mobile firefox some-device' }); - expect(utils.isMobile()).toBe(true); - }); - - test('should not detect desktop user agents as mobile', () => { - // Test desktop Chrome - vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' }); - expect(utils.isMobile()).toBe(false); - - // Test desktop Firefox - vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0' }); - expect(utils.isMobile()).toBe(false); - - // Test macOS Safari - vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Safari/605.1.15' }); - expect(utils.isMobile()).toBe(false); - }); - - test('should handle edge cases', () => { - // Test empty user agent - vi.stubGlobal('navigator', { userAgent: '' }); - expect(utils.isMobile()).toBe(false); - - // Test very short user agent that doesn't match - vi.stubGlobal('navigator', { userAgent: 'xyz' }); - expect(utils.isMobile()).toBe(false); - }); + expect(typeof FOCUSABLE_SELECTOR).toBe('string'); + expect(FOCUSABLE_SELECTOR).toContain('button:not([disabled])'); + expect(FOCUSABLE_SELECTOR).toContain('a[href]:not([disabled])'); + expect(FOCUSABLE_SELECTOR).toContain('button:not([disabled])'); + expect(FOCUSABLE_SELECTOR).toContain('iframe:not([disabled])'); + expect(FOCUSABLE_SELECTOR).toContain('input:not([disabled])'); + expect(FOCUSABLE_SELECTOR).toContain('select:not([disabled])'); + expect(FOCUSABLE_SELECTOR).toContain('textarea:not([disabled])'); + expect(FOCUSABLE_SELECTOR).toContain('[contentEditable]'); + expect(FOCUSABLE_SELECTOR).toContain('[tabindex]:not(.focus-trap)'); }); - test('should handle matchMedia functionality', () => { - // Mock matchMedia for testing - const mockMatchMedia = vi.fn(); - mockMatchMedia.mockImplementation((query: string) => ({ - matches: query === '(prefers-reduced-motion: reduce)', - addEventListener: vi.fn(), - removeEventListener: vi.fn() - })); - - vi.stubGlobal('matchMedia', mockMatchMedia); - - // The constants file should work with or without matchMedia - expect(() => { - // Access the UI object to ensure it's initialized - expect(utils.UI).toBeDefined(); - }).not.toThrow(); - }); - - test('should handle missing matchMedia gracefully', () => { - // Test that code works when matchMedia is undefined - vi.stubGlobal('matchMedia', undefined); - - expect(() => { - // Access the UI object to ensure it works without matchMedia - expect(utils.UI.ANIMATION_SPEED).toBeDefined(); - expect(utils.UI.PREFERS_DARK).toBeDefined(); - }).not.toThrow(); - }); - - test('should maintain backwards compatibility', () => { - // Test that the utils export contains expected properties - expect(utils.UI).toBeDefined(); - expect(utils.isMobile).toBeDefined(); - expect(typeof utils.isMobile).toBe('function'); - }); - - test('UI state should be reactive', () => { - // Test that UI state properties are accessible and have correct types - expect(typeof utils.UI.ANIMATION_SPEED).toBe('number'); - expect(typeof utils.UI.PREFERS_DARK).toBe('boolean'); - expect(typeof utils.UI.FOCUSABLE_SELECTOR).toBe('string'); - expect(typeof utils.UI.isMobile).toBe('boolean'); - }); - - test('FOCUSABLE_SELECTOR should be properly formatted', () => { - const selector = utils.UI.FOCUSABLE_SELECTOR; - - // Should contain commas separating selectors - expect(selector.includes(',')).toBe(true); - - // Should not start or end with comma - expect(selector.startsWith(',')).toBe(false); - expect(selector.endsWith(',')).toBe(false); - - // Should contain expected number of selectors (8 total) - const selectorCount = selector.split(',').length; - expect(selectorCount).toBe(8); - }); }); diff --git a/tests/utils/date.test.ts b/tests/utils/date.test.ts index 7724ad07..bbcc86a0 100644 --- a/tests/utils/date.test.ts +++ b/tests/utils/date.test.ts @@ -1,7 +1,5 @@ import { describe, expect, test } from 'vitest'; - import * as utils from '../../src/utils'; -import '../helpers/utils'; diff --git a/tests/utils/dom.test.ts b/tests/utils/dom.test.ts index 34bc2338..e34c31b6 100644 --- a/tests/utils/dom.test.ts +++ b/tests/utils/dom.test.ts @@ -1,15 +1,9 @@ import { describe, expect, test } from 'vitest'; import * as utils from '../../src/utils'; -import '../helpers/utils'; -test('utils - matchMedia', () => { - expect(utils.UI.ANIMATION_SPEED).toStrictEqual(0); -}); - - describe('utils - mouse events', () => { test('should extract coordinates from touch events', () => { const e = { type: 'touch', changedTouches: [{ clientX: 100, clientY: 100 }], clientX: 200, clientY: 200 }; diff --git a/tests/utils/ui.test.ts b/tests/utils/ui.test.ts new file mode 100644 index 00000000..77a0020f --- /dev/null +++ b/tests/utils/ui.test.ts @@ -0,0 +1,67 @@ +import { expect, test, vi, afterEach, describe } from 'vitest'; +import * as utils from '../../src/utils'; + + + +describe('constants', () => { + + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + }); + + + test('UI state should have default values', () => { + expect(utils.UI.ANIMATION_SPEED).toBe(0); // Set to 0 by test helper + expect(utils.UI.PREFERS_DARK).toBe(true); + expect(typeof utils.UI.IS_MOBILE).toBe('boolean'); + }); + + + test('should handle matchMedia functionality', () => { + // Mock matchMedia for testing + const mockMatchMedia = vi.fn(); + mockMatchMedia.mockImplementation((query: string) => ({ + matches: query === '(prefers-reduced-motion: reduce)', + addEventListener: vi.fn(), + removeEventListener: vi.fn() + })); + + vi.stubGlobal('matchMedia', mockMatchMedia); + + // The constants file should work with or without matchMedia + expect(() => { + // Access the UI object to ensure it's initialized + expect(utils.UI).toBeDefined(); + }).not.toThrow(); + }); + + + test('should handle missing matchMedia gracefully', () => { + // Test that code works when matchMedia is undefined + vi.stubGlobal('matchMedia', undefined); + + expect(() => { + // Access the UI object to ensure it works without matchMedia + expect(utils.UI.ANIMATION_SPEED).toBeDefined(); + expect(utils.UI.PREFERS_DARK).toBeDefined(); + }).not.toThrow(); + }); + + + test('should maintain backwards compatibility', () => { + // Test that the utils export contains expected properties + expect(utils.UI).toBeDefined(); + expect(utils.isMobile).toBeDefined(); + expect(typeof utils.isMobile).toBe('function'); + }); + + + test('UI state should be reactive', () => { + // Test that UI state properties are accessible and have correct types + expect(typeof utils.UI.ANIMATION_SPEED).toBe('number'); + expect(typeof utils.UI.PREFERS_DARK).toBe('boolean'); + expect(typeof utils.UI.IS_MOBILE).toBe('boolean'); + }); + +}); diff --git a/tests/utils/utilities.test.ts b/tests/utils/utilities.test.ts index ba705c44..270c6224 100644 --- a/tests/utils/utilities.test.ts +++ b/tests/utils/utilities.test.ts @@ -1,60 +1,133 @@ -import { describe, expect, it, test } from 'vitest'; - +import { describe, expect, test, vi } from 'vitest'; import * as utils from '../../src/utils'; -import '../helpers/utils'; +describe('utilities', () => { -test('utils - matchMedia', () => { - expect(utils.UI.ANIMATION_SPEED).toStrictEqual(0); -}); + describe('fuzzy', () => { + test('returns true for empty strings', () => { + expect(utils.fuzzy()).toBe(true); + expect(utils.fuzzy('')).toBe(true); + expect(utils.fuzzy('', '')).toBe(true); + }); + test('filters correctly', () => { + expect(utils.fuzzy('a', '')).toBe(true); + expect(utils.fuzzy('', 'a')).toBe(false); + expect(utils.fuzzy('a', 'ab')).toBe(false); + expect(utils.fuzzy('ab', 'ab')).toBe(true); + expect(utils.fuzzy('abc', 'ab')).toBe(true); + expect(utils.fuzzy('abc', 'bc')).toBe(true); + expect(utils.fuzzy('abc', 'AB')).toBe(true); + expect(utils.fuzzy('ABC', 'ac')).toBe(true); + expect(utils.fuzzy('ABC', 'ad')).toBe(false); + }); + }); -test('utils - fuzzy', () => { - expect(utils.fuzzy()).toBe(true); - expect(utils.fuzzy('')).toBe(true); - expect(utils.fuzzy('', '')).toBe(true); - expect(utils.fuzzy('a', '')).toBe(true); - expect(utils.fuzzy('', 'a')).toBe(false); - expect(utils.fuzzy('a', 'ab')).toBe(false); - expect(utils.fuzzy('ab', 'ab')).toBe(true); - expect(utils.fuzzy('abc', 'ab')).toBe(true); - expect(utils.fuzzy('abc', 'bc')).toBe(true); - expect(utils.fuzzy('abc', 'AB')).toBe(true); - expect(utils.fuzzy('ABC', 'ac')).toBe(true); - expect(utils.fuzzy('ABC', 'ad')).toBe(false); -}); + describe('guid', () => { + test('generates a valid GUID', () => { + const id = utils.guid(); + expect(id).toBeTruthy(); + expect(id.length).toBe(36); + }); + }); -test('utils - guid', () => { - const id = utils.guid(); - expect(id).toBeTruthy(); - expect(id.length).toBe(36); -}); + describe('isColorDark', () => { + test('returns true for dark colors', () => { + expect(utils.isColorDark('#333333')).toBe(true); + expect(utils.isColorDark('#000000')).toBe(true); + }); -describe('isColorDark', () => { - it('returns true for dark colors', () => { - expect(utils.isColorDark('#333333')).toBe(true); - expect(utils.isColorDark('#000000')).toBe(true); - }); - it('returns false for light colors', () => { - expect(utils.isColorDark('#ffffff')).toBe(false); - expect(utils.isColorDark('#cccccc')).toBe(false); - }); + test('returns false for light colors', () => { + expect(utils.isColorDark('#ffffff')).toBe(false); + expect(utils.isColorDark('#cccccc')).toBe(false); + }); - it('handles shorthand hex colors', () => { - expect(utils.isColorDark('#333')).toBe(true); - expect(utils.isColorDark('#fff')).toBe(false); + + test('handles shorthand hex colors', () => { + expect(utils.isColorDark('#333')).toBe(true); + expect(utils.isColorDark('#fff')).toBe(false); + }); + + + test('handles invalid hex colors', () => { + expect(utils.isColorDark('not a hex color')).toBe(false); + expect(utils.isColorDark('#12345')).toBe(false); + expect(utils.isColorDark('#gggggg')).toBe(false); + }); }); - it('handles invalid hex colors', () => { - expect(utils.isColorDark('not a hex color')).toBe(false); - expect(utils.isColorDark('#12345')).toBe(false); - expect(utils.isColorDark('#gggggg')).toBe(false); + + + describe('isMobile', () => { + test('detects mobile user agents', () => { + expect(() => utils.isMobile()).not.toThrow(); + expect(typeof utils.isMobile()).toBe('boolean'); + }); + + + test('detects mobile user agents (Android)', () => { + vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Linux; Android 9; SM-G973F) AppleWebKit/537.36 Mobile Safari/537.36' }); + expect(utils.isMobile()).toBe(true); + }); + + + test('detects mobile user agents (iPhone)', () => { + vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_7_1 like Mac OS X) Mobile/15E148 Safari/604.1' }); + expect(utils.isMobile()).toBe(true); + }); + + + test('detects mobile user agents (BlackBerry)', () => { + vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (BlackBerry; U; BlackBerry 9900; en) AppleWebKit/534.11+ Mobile Safari/534.11+' }); + expect(utils.isMobile()).toBe(true); + }); + + + test('detects mobile from first 4 characters of user agent', () => { + vi.stubGlobal('navigator', { userAgent: '1207/some-mobile-device' }); + expect(utils.isMobile()).toBe(true); + + vi.stubGlobal('navigator', { userAgent: '6310/mobile-device' }); + expect(utils.isMobile()).toBe(true); + + // Test mobile firefox pattern + vi.stubGlobal('navigator', { userAgent: 'mobile firefox some-device' }); + expect(utils.isMobile()).toBe(true); + }); + + + test('doesn\'t detect desktop user agents as mobile', () => { + // Test desktop Chrome + vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' }); + expect(utils.isMobile()).toBe(false); + + // Test desktop Firefox + vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0' }); + expect(utils.isMobile()).toBe(false); + + // Test macOS Safari + vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Safari/605.1.15' }); + expect(utils.isMobile()).toBe(false); + }); + + + test('handles edge cases', () => { + // Test empty user agent + vi.stubGlobal('navigator', { userAgent: '' }); + expect(utils.isMobile()).toBe(false); + + // Test very short user agent that doesn't match + vi.stubGlobal('navigator', { userAgent: 'xyz' }); + expect(utils.isMobile()).toBe(false); + }); }); + + });