diff --git a/core/encoding/src/index.ts b/core/encoding/src/index.ts index 0de9894..450cbcf 100644 --- a/core/encoding/src/index.ts +++ b/core/encoding/src/index.ts @@ -1,2 +1,3 @@ +export * from './luhn'; export * from './reed-solomon'; export * from './qr'; diff --git a/core/encoding/src/luhn/index.test.ts b/core/encoding/src/luhn/index.test.ts new file mode 100644 index 0000000..00eac10 --- /dev/null +++ b/core/encoding/src/luhn/index.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { luhn } from './index'; + +describe(luhn, () => { + it('passes valid checksums (separators ignored)', () => { + expect(luhn('4111 1111 1111 1111')).toBe(true); + expect(luhn('5500005555555559')).toBe(true); + expect(luhn('371449635398431')).toBe(true); + expect(luhn('79927398713')).toBe(true); // classic Luhn example + }); + + it('fails bad checksums', () => { + expect(luhn('4111 1111 1111 1112')).toBe(false); + expect(luhn('79927398710')).toBe(false); + }); + + it('returns false for empty input', () => { + expect(luhn('')).toBe(false); + expect(luhn('----')).toBe(false); + }); +}); diff --git a/core/encoding/src/luhn/index.ts b/core/encoding/src/luhn/index.ts new file mode 100644 index 0000000..05b4c75 --- /dev/null +++ b/core/encoding/src/luhn/index.ts @@ -0,0 +1,39 @@ +const NON_DIGIT = /\D/g; +const ASCII_ZERO = 0x30; + +/** + * @name luhn + * @category Encoding + * @description Validate a number string against the Luhn (mod 10) checksum — the + * check digit used by payment cards, IMEIs, SIM ICCIDs, and more. Non-digits are + * ignored; an empty input is `false`. + * + * @param {string} value The number (separators allowed) + * @returns {boolean} Whether the Luhn checksum passes + * + * @example + * luhn('4111 1111 1111 1111'); // true + * luhn('4111 1111 1111 1112'); // false + * + * @since 0.0.2 + */ +export function luhn(value: string): boolean { + const digits = value.replaceAll(NON_DIGIT, ''); + if (!digits) + return false; + + let sum = 0; + let double = false; + for (let i = digits.length - 1; i >= 0; i--) { + let digit = digits.charCodeAt(i) - ASCII_ZERO; + if (double) { + digit *= 2; + if (digit > 9) + digit -= 9; + } + sum += digit; + double = !double; + } + + return sum % 10 === 0; +} diff --git a/core/platform/package.json b/core/platform/package.json index f2d13dd..9092c74 100644 --- a/core/platform/package.json +++ b/core/platform/package.json @@ -56,6 +56,7 @@ "build": "tsdown" }, "devDependencies": { + "@robonen/encoding": "workspace:*", "@robonen/eslint": "workspace:*", "@robonen/stdlib": "workspace:*", "@robonen/tsconfig": "workspace:*", diff --git a/core/platform/src/browsers/domStyle/index.test.ts b/core/platform/src/browsers/domStyle/index.test.ts new file mode 100644 index 0000000..c50075b --- /dev/null +++ b/core/platform/src/browsers/domStyle/index.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; +import { assignStyle, getTranslate, isInView, resetStyle, setStyle } from './index'; + +function makeEl(): HTMLElement { + const el = document.createElement('div'); + document.body.append(el); + return el; +} + +describe('setStyle / resetStyle', () => { + it('applies styles and caches the overwritten values', () => { + const el = makeEl(); + el.style.transform = 'translateY(0px)'; + + setStyle(el, { transform: 'translateY(20px)', transition: 'none' }); + expect(el.style.transform).toBe('translateY(20px)'); + expect(el.style.transition).toBe('none'); + + resetStyle(el); + expect(el.style.transform).toBe('translateY(0px)'); + }); + + it('restores a single property when given prop', () => { + const el = makeEl(); + el.style.opacity = '1'; + + setStyle(el, { opacity: '0', transform: 'scale(0.9)' }); + resetStyle(el, 'opacity'); + + expect(el.style.opacity).toBe('1'); + expect(el.style.transform).toBe('scale(0.9)'); + }); + + it('writes custom properties via setProperty', () => { + const el = makeEl(); + setStyle(el, { '--snap-point-height': '120px' }); + expect(el.style.getPropertyValue('--snap-point-height')).toBe('120px'); + }); + + it('does not cache when ignoreCache is true', () => { + const el = makeEl(); + el.style.opacity = '1'; + + setStyle(el, { opacity: '0.5' }, true); + resetStyle(el); + + expect(el.style.opacity).toBe('0.5'); + }); + + it('is a no-op for non-elements', () => { + expect(() => setStyle(null, { opacity: '0' })).not.toThrow(); + expect(() => resetStyle(null)).not.toThrow(); + }); +}); + +describe('getTranslate', () => { + it('returns null when there is no matrix transform', () => { + const el = makeEl(); + expect(getTranslate(el, 'y')).toBeNull(); + }); + + it('reads x and y from a 2D matrix', () => { + const el = makeEl(); + el.style.transform = 'matrix(1, 0, 0, 1, 12, 34)'; + expect(getTranslate(el, 'x')).toBe(12); + expect(getTranslate(el, 'y')).toBe(34); + }); + + it('reads x and y from a 3D matrix', () => { + const el = makeEl(); + el.style.transform = 'matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 50, 60, 0, 1)'; + expect(getTranslate(el, 'x')).toBe(50); + expect(getTranslate(el, 'y')).toBe(60); + }); +}); + +describe('assignStyle', () => { + it('assigns styles and restores the previous cssText on cleanup', () => { + const el = makeEl(); + el.style.cssText = 'color: red;'; + + const restore = assignStyle(el, { overflow: 'hidden' }); + expect(el.style.overflow).toBe('hidden'); + + restore(); + expect(el.style.overflow).toBe(''); + expect(el.style.color).toBe('red'); + }); + + it('returns a no-op cleanup for a missing element', () => { + expect(() => assignStyle(null, { overflow: 'hidden' })()).not.toThrow(); + }); +}); + +describe('isInView', () => { + it('returns false when visualViewport is unavailable', () => { + const el = makeEl(); + const original = window.visualViewport; + Object.defineProperty(globalThis, 'visualViewport', { value: null, configurable: true }); + + expect(isInView(el)).toBe(false); + + Object.defineProperty(globalThis, 'visualViewport', { value: original, configurable: true }); + }); +}); diff --git a/core/platform/src/browsers/domStyle/index.ts b/core/platform/src/browsers/domStyle/index.ts new file mode 100644 index 0000000..b9adba5 --- /dev/null +++ b/core/platform/src/browsers/domStyle/index.ts @@ -0,0 +1,190 @@ +/** + * A patch of inline styles — a map of CSS property names to string values. + * Keys may be camelCase DOM style properties (`borderRadius`) or `--custom` + * properties (set verbatim via `setProperty`). + */ +export type StylePatch = Record; + +/** + * The axis a translation is read along: `x` (horizontal) or `y` (vertical). + */ +export type TranslateAxis = 'x' | 'y'; + +// Remembers the styles that {@link setStyle} overwrote, keyed by element, so +// {@link resetStyle} can put them back. A WeakMap lets the entry be collected +// once the element is gone. +const originalStyles = new WeakMap(); + +/** + * @name setStyle + * @category Browsers + * @description Applies a batch of inline styles to an element, remembering the + * values it overwrote so {@link resetStyle} can restore them later. `--custom` + * properties are written through `setProperty`. Pass `ignoreCache` to apply the + * styles without recording the originals (e.g. for transient, per-frame writes + * during a drag that you intend to clear wholesale). + * + * @param {Element | HTMLElement | null} [element] The element to style (ignored if not an `HTMLElement`) + * @param {StylePatch} [styles] The property/value pairs to apply + * @param {boolean} [ignoreCache] Skip remembering the overwritten values + * @returns {void} + * + * @example + * setStyle(el, { transition: 'none', transform: 'translateY(20px)' }); + * setStyle(el, { opacity: '0.5' }, true); // transient — won't be restored + * + * @since 0.0.5 + */ +export function setStyle(element?: Element | HTMLElement | null, styles?: StylePatch, ignoreCache = false): void { + if (!element || !(element instanceof HTMLElement) || !styles) + return; + + const previous: StylePatch = {}; + + for (const [key, value] of Object.entries(styles)) { + if (key.startsWith('--')) { + element.style.setProperty(key, value); + continue; + } + + previous[key] = (element.style as unknown as StylePatch)[key]; + (element.style as unknown as StylePatch)[key] = value; + } + + if (ignoreCache) + return; + + originalStyles.set(element, previous); +} + +/** + * @name resetStyle + * @category Browsers + * @description Restores the inline styles an element had before the most recent + * cached {@link setStyle}. With `prop` it restores a single property; otherwise + * it restores every property that was remembered. A no-op if nothing was cached. + * + * @param {Element | HTMLElement | null} element The element to restore + * @param {string} [prop] Restore only this property instead of all of them + * @returns {void} + * + * @example + * resetStyle(el); // restore everything setStyle changed + * resetStyle(el, 'transform'); // restore just the transform + * + * @since 0.0.5 + */ +export function resetStyle(element: Element | HTMLElement | null, prop?: string): void { + if (!element || !(element instanceof HTMLElement)) + return; + + const previous = originalStyles.get(element); + + if (!previous) + return; + + if (prop) { + (element.style as unknown as StylePatch)[prop] = previous[prop]; + return; + } + + for (const [key, value] of Object.entries(previous)) + (element.style as unknown as StylePatch)[key] = value; +} + +/** + * @name getTranslate + * @category Browsers + * @description Reads the current translation of an element along one axis from + * its computed `transform`, parsing both `matrix(...)` (2D) and `matrix3d(...)` + * (3D) forms. Returns `null` when the element has no matrix transform. + * + * @param {HTMLElement} element The element to measure + * @param {TranslateAxis} axis `'x'` for horizontal, `'y'` for vertical + * @returns {number | null} The translation in pixels, or `null` if none + * + * @example + * const offset = getTranslate(panel, 'y'); // px the panel is shifted down + * + * @since 0.0.5 + */ +export function getTranslate(element: HTMLElement, axis: TranslateAxis): number | null { + const style = globalThis.getComputedStyle(element); + const transform + // @ts-expect-error — vendor-prefixed transforms only exist in some browsers + = style.transform || style.webkitTransform || style.mozTransform; + + let match = transform.match(/^matrix3d\((.+)\)$/); + if (match) { + // matrix3d: the translate components live at indices 12 (x) and 13 (y). + // https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function/matrix3d + return Number.parseFloat(match[1].split(', ')[axis === 'y' ? 13 : 12]); + } + + // matrix: the translate components live at indices 4 (x) and 5 (y). + // https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function/matrix + match = transform.match(/^matrix\((.+)\)$/); + return match ? Number.parseFloat(match[1].split(', ')[axis === 'y' ? 5 : 4]) : null; +} + +/** + * @name assignStyle + * @category Browsers + * @description Merges a style patch onto an element's inline `style` and returns + * a cleanup function that restores the element's entire previous `cssText`. + * Unlike {@link setStyle}, the snapshot is the full `cssText`, so the cleanup is + * an all-or-nothing revert — handy for scoped effects. + * + * @param {HTMLElement | null | undefined} element The element to style + * @param {Partial} style The styles to assign + * @returns {() => void} A cleanup function that restores the previous `cssText` + * + * @example + * const restore = assignStyle(document.body, { overflow: 'hidden' }); + * // ...later + * restore(); + * + * @since 0.0.5 + */ +export function assignStyle(element: HTMLElement | null | undefined, style: Partial): () => void { + if (!element) + return () => {}; + + const previousCssText = element.style.cssText; + Object.assign(element.style, style); + + return () => { + element.style.cssText = previousCssText; + }; +} + +/** + * @name isInView + * @category Browsers + * @description Reports whether an element is fully within the visual viewport, + * accounting for on-screen keyboards via `window.visualViewport`. A 40px slack + * is allowed at the bottom to tolerate Safari's viewport quirks. Returns `false` + * when `visualViewport` is unavailable. + * + * @param {HTMLElement} element The element to test + * @returns {boolean} `true` if the element's rect fits inside the visual viewport + * + * @example + * if (!isInView(focusedField)) scrollIntoView(focusedField); + * + * @since 0.0.5 + */ +export function isInView(element: HTMLElement): boolean { + const rect = element.getBoundingClientRect(); + + if (!window.visualViewport) + return false; + + return ( + rect.top >= 0 + && rect.left >= 0 + // +40 of slack for Safari's visual-viewport reporting. + && rect.bottom <= window.visualViewport.height - 40 + && rect.right <= window.visualViewport.width + ); +} diff --git a/core/platform/src/browsers/index.ts b/core/platform/src/browsers/index.ts index d82f88c..4f0ca1f 100644 --- a/core/platform/src/browsers/index.ts +++ b/core/platform/src/browsers/index.ts @@ -1,4 +1,7 @@ export * from './animationLifecycle'; +export * from './domStyle'; export * from './focusGuard'; export * from './focusScope'; export * from './hideOthers'; +export * from './inputState'; +export * from './userAgent'; diff --git a/core/platform/src/browsers/inputState/index.test.ts b/core/platform/src/browsers/inputState/index.test.ts new file mode 100644 index 0000000..839a2f6 --- /dev/null +++ b/core/platform/src/browsers/inputState/index.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { readInputState, writeInputState } from './index'; + +function makeInput(value = ''): HTMLInputElement { + const input = document.createElement('input'); + input.value = value; + document.body.append(input); + return input; +} + +describe('readInputState', () => { + it('reads value and selection', () => { + const input = makeInput('hello'); + input.setSelectionRange(1, 3); + + expect(readInputState(input)).toEqual({ value: 'hello', selection: [1, 3] }); + }); + + it('falls back to a collapsed caret at the end when selection is null', () => { + const input = makeInput('abc'); + // Force a null selection (some input types report null). + Object.defineProperty(input, 'selectionStart', { value: null }); + Object.defineProperty(input, 'selectionEnd', { value: null }); + + expect(readInputState(input)).toEqual({ value: 'abc', selection: [3, 3] }); + }); +}); + +describe('writeInputState', () => { + it('writes the value', () => { + const input = makeInput('a'); + writeInputState(input, { value: '(12)', selection: [4, 4] }); + + expect(input.value).toBe('(12)'); + }); + + it('moves the caret only while the element is focused', () => { + const input = makeInput('12345'); + input.focus(); + writeInputState(input, { value: '12345', selection: [2, 4] }); + + expect(input.selectionStart).toBe(2); + expect(input.selectionEnd).toBe(4); + }); + + it('does not reposition the caret when not focused', () => { + const input = makeInput('12345'); + input.setSelectionRange(0, 0); + input.blur(); + writeInputState(input, { value: '12345', selection: [3, 3] }); + + // Unfocused: selection is left untouched by setSelectionRange. + expect(input.selectionStart).toBe(0); + }); +}); diff --git a/core/platform/src/browsers/inputState/index.ts b/core/platform/src/browsers/inputState/index.ts new file mode 100644 index 0000000..7fbfc9b --- /dev/null +++ b/core/platform/src/browsers/inputState/index.ts @@ -0,0 +1,80 @@ +/** + * The editable state of a text field: its current text and selection bounds. + * Framework-agnostic and structurally compatible with any `{ value, selection }` + * pair (e.g. an input-masking engine's element state). + */ +export interface InputState { + /** + * The element's current `value`. + */ + readonly value: string; + /** + * The selection as `[from, to]` (collapsed caret when `from === to`). + */ + readonly selection: readonly [from: number, to: number]; +} + +/** + * A text field whose value and selection can be read and written. + */ +export type TextFieldElement = HTMLInputElement | HTMLTextAreaElement; + +/** + * @name readInputState + * @category Browsers + * @description Reads the value and current selection of an ``/`