feat(forms): add useMaskedField and useMaskedInput composables for input masking
This commit is contained in:
@@ -1,2 +1,3 @@
|
||||
export * from './luhn';
|
||||
export * from './reed-solomon';
|
||||
export * from './qr';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -56,6 +56,7 @@
|
||||
"build": "tsdown"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@robonen/encoding": "workspace:*",
|
||||
"@robonen/eslint": "workspace:*",
|
||||
"@robonen/stdlib": "workspace:*",
|
||||
"@robonen/tsconfig": "workspace:*",
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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<string, string>;
|
||||
|
||||
/**
|
||||
* 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<HTMLElement, StylePatch>();
|
||||
|
||||
/**
|
||||
* @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<CSSStyleDeclaration>} 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<CSSStyleDeclaration>): () => 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
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 `<input>`/`<textarea>`
|
||||
* into a plain {@link InputState}. A `null` selection (some input types report it)
|
||||
* falls back to a collapsed caret at the end of the value.
|
||||
*
|
||||
* @param {TextFieldElement} element The input or textarea to read
|
||||
* @returns {InputState} The element's `{ value, selection }`
|
||||
*
|
||||
* @example
|
||||
* const { value, selection } = readInputState(inputEl);
|
||||
*
|
||||
* @since 0.0.5
|
||||
*/
|
||||
export function readInputState(element: TextFieldElement): InputState {
|
||||
const { value, selectionStart, selectionEnd } = element;
|
||||
const end = value.length;
|
||||
|
||||
return {
|
||||
value,
|
||||
selection: [selectionStart ?? end, selectionEnd ?? end],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @name writeInputState
|
||||
* @category Browsers
|
||||
* @description Writes value and selection back to an `<input>`/`<textarea>`. The
|
||||
* value is only assigned when it actually changed (avoids spurious cursor jumps),
|
||||
* and the caret is moved **only while the element is focused** so programmatic
|
||||
* updates never steal or reposition focus. `setSelectionRange` is guarded because
|
||||
* some input types (`number`, `email`, `date`) forbid it.
|
||||
*
|
||||
* @param {TextFieldElement} element The input or textarea to update
|
||||
* @param {InputState} state The `{ value, selection }` to apply
|
||||
* @returns {void}
|
||||
*
|
||||
* @example
|
||||
* writeInputState(inputEl, { value: '(123)', selection: [5, 5] });
|
||||
*
|
||||
* @since 0.0.5
|
||||
*/
|
||||
export function writeInputState(element: TextFieldElement, state: InputState): void {
|
||||
const [from, to] = state.selection;
|
||||
|
||||
if (element.value !== state.value)
|
||||
element.value = state.value;
|
||||
|
||||
if (element.ownerDocument.activeElement !== element)
|
||||
return;
|
||||
|
||||
try {
|
||||
element.setSelectionRange(from, to);
|
||||
}
|
||||
catch {
|
||||
// Input types like number/email/date throw on setSelectionRange — ignore.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { isMac, isMobileFirefox, isSafari, testUserAgentPlatform } from './index';
|
||||
|
||||
function stubPlatform(platform: string): void {
|
||||
vi.spyOn(globalThis.navigator, 'platform', 'get').mockReturnValue(platform);
|
||||
}
|
||||
|
||||
function stubUserAgent(ua: string): void {
|
||||
vi.spyOn(globalThis.navigator, 'userAgent', 'get').mockReturnValue(ua);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('testUserAgentPlatform', () => {
|
||||
it('matches against navigator.platform', () => {
|
||||
stubPlatform('MacIntel');
|
||||
expect(testUserAgentPlatform(/^Mac/)).toBe(true);
|
||||
expect(testUserAgentPlatform(/^Win/)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMac', () => {
|
||||
it('is true on a Mac platform', () => {
|
||||
stubPlatform('MacIntel');
|
||||
expect(isMac()).toBe(true);
|
||||
});
|
||||
|
||||
it('is false elsewhere', () => {
|
||||
stubPlatform('Win32');
|
||||
expect(isMac()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSafari', () => {
|
||||
it('is true for a Safari UA', () => {
|
||||
stubUserAgent('Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15');
|
||||
expect(isSafari()).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for Chrome', () => {
|
||||
stubUserAgent('Mozilla/5.0 (Macintosh) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36');
|
||||
expect(isSafari()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMobileFirefox', () => {
|
||||
it('is true for Android Firefox', () => {
|
||||
stubUserAgent('Mozilla/5.0 (Android 14; Mobile; rv:121.0) Gecko/121.0 Firefox/121.0');
|
||||
expect(isMobileFirefox()).toBe(true);
|
||||
});
|
||||
|
||||
it('is true for iOS Firefox (FxiOS)', () => {
|
||||
stubUserAgent('Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 FxiOS/121.0 Mobile/15E148 Safari/605.1.15');
|
||||
expect(isMobileFirefox()).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for desktop Firefox', () => {
|
||||
stubUserAgent('Mozilla/5.0 (Windows NT 10.0; rv:121.0) Gecko/20100101 Firefox/121.0');
|
||||
expect(isMobileFirefox()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* @name testUserAgentPlatform
|
||||
* @category Browsers
|
||||
* @description Tests `navigator.platform` against a regular expression, guarding
|
||||
* for non-browser environments. Returns `undefined` when there is no `navigator`
|
||||
* (e.g. during SSR) so callers can distinguish "no" from "unknown".
|
||||
*
|
||||
* @param {RegExp} re The pattern to test `navigator.platform` against
|
||||
* @returns {boolean | undefined} The match result, or `undefined` outside a browser
|
||||
*
|
||||
* @example
|
||||
* const onMac = testUserAgentPlatform(/^Mac/);
|
||||
*
|
||||
* @since 0.0.5
|
||||
*/
|
||||
export function testUserAgentPlatform(re: RegExp): boolean | undefined {
|
||||
return globalThis.navigator !== undefined
|
||||
? re.test(globalThis.navigator.platform)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name isMac
|
||||
* @category Browsers
|
||||
* @description Whether the current platform is macOS (per `navigator.platform`).
|
||||
* Note iPadOS reports as a Mac — combine with {@link isIPad} to disambiguate.
|
||||
*
|
||||
* @returns {boolean | undefined} `true` on macOS, `undefined` outside a browser
|
||||
*
|
||||
* @since 0.0.5
|
||||
*/
|
||||
export function isMac(): boolean | undefined {
|
||||
return testUserAgentPlatform(/^Mac/);
|
||||
}
|
||||
|
||||
/**
|
||||
* @name isIPhone
|
||||
* @category Browsers
|
||||
* @description Whether the current platform is an iPhone (per `navigator.platform`).
|
||||
*
|
||||
* @returns {boolean | undefined} `true` on iPhone, `undefined` outside a browser
|
||||
*
|
||||
* @since 0.0.5
|
||||
*/
|
||||
export function isIPhone(): boolean | undefined {
|
||||
return testUserAgentPlatform(/^iPhone/);
|
||||
}
|
||||
|
||||
/**
|
||||
* @name isIPad
|
||||
* @category Browsers
|
||||
* @description Whether the current device is an iPad. iPadOS 13+ masquerades as a
|
||||
* Mac, so this also treats a touch-capable Mac (`maxTouchPoints > 1`) as an iPad.
|
||||
*
|
||||
* @returns {boolean | undefined} `true` on iPad, `undefined` outside a browser
|
||||
*
|
||||
* @since 0.0.5
|
||||
*/
|
||||
export function isIPad(): boolean | undefined {
|
||||
return (
|
||||
testUserAgentPlatform(/^iPad/)
|
||||
// iPadOS 13+ lies and reports as a Mac; touch support gives it away.
|
||||
|| (isMac() && navigator.maxTouchPoints > 1)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @name isIOS
|
||||
* @category Browsers
|
||||
* @description Whether the current device runs iOS/iPadOS (iPhone or iPad).
|
||||
*
|
||||
* @returns {boolean | undefined} `true` on iOS, `undefined` outside a browser
|
||||
*
|
||||
* @since 0.0.5
|
||||
*/
|
||||
export function isIOS(): boolean | undefined {
|
||||
return isIPhone() || isIPad();
|
||||
}
|
||||
|
||||
/**
|
||||
* @name isSafari
|
||||
* @category Browsers
|
||||
* @description Whether the current browser is Safari (desktop or iOS), excluding
|
||||
* Chrome and Android browsers that also include "Safari" in their UA string.
|
||||
*
|
||||
* @returns {boolean} `true` if the user agent looks like Safari
|
||||
*
|
||||
* @since 0.0.5
|
||||
*/
|
||||
export function isSafari(): boolean {
|
||||
if (typeof navigator === 'undefined')
|
||||
return false;
|
||||
|
||||
// eslint-disable-next-line regexp/no-unused-capturing-group
|
||||
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
/**
|
||||
* @name isMobileFirefox
|
||||
* @category Browsers
|
||||
* @description Whether the current browser is Firefox on a mobile device
|
||||
* (Android Firefox or iOS Firefox / `FxiOS`).
|
||||
*
|
||||
* @returns {boolean} `true` on mobile Firefox
|
||||
*
|
||||
* @since 0.0.5
|
||||
*/
|
||||
export function isMobileFirefox(): boolean {
|
||||
if (typeof navigator === 'undefined')
|
||||
return false;
|
||||
|
||||
const userAgent = navigator.userAgent;
|
||||
return (
|
||||
(/Firefox/.test(userAgent) && /Mobile/.test(userAgent)) // Android Firefox
|
||||
|| /FxiOS/.test(userAgent) // iOS Firefox
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Payment-card brands: detection patterns (IIN/BIN prefixes and ranges), the
|
||||
* number template (`#` = a digit), valid total lengths, and the security-code
|
||||
* info. Derived from the MIT-licensed `credit-card-type` dataset (Braintree).
|
||||
* The template uses the brand's gap positions; the trailing group is sized to the
|
||||
* longest valid length, so shorter numbers simply leave it partly empty.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A card brand's prefix pattern: an exact prefix string, or an inclusive numeric
|
||||
* range `[min, max]` whose bounds have the same digit length.
|
||||
*/
|
||||
export type CardPattern = string | readonly [min: number, max: number];
|
||||
|
||||
/**
|
||||
* One payment-card brand.
|
||||
*/
|
||||
export interface CardBrand {
|
||||
/** Brand id, e.g. `'visa'`. */
|
||||
readonly brand: string;
|
||||
/** Display name, e.g. `'Visa'`. */
|
||||
readonly name: string;
|
||||
/** Number template; `#` is a digit, e.g. `'#### #### #### ####'`. */
|
||||
readonly template: string;
|
||||
/** Valid total digit lengths, e.g. `[16, 18, 19]`. */
|
||||
readonly lengths: readonly number[];
|
||||
/** Security code metadata, e.g. `{ name: 'CVV', size: 3 }`. */
|
||||
readonly code: { readonly name: string; readonly size: number };
|
||||
/** IIN/BIN detection patterns (prefixes and ranges). */
|
||||
readonly patterns: readonly CardPattern[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Every supported card brand. Resolve a number with `findCardBrand`.
|
||||
*/
|
||||
export const CARD_BRANDS: readonly CardBrand[] = [
|
||||
{ brand: 'visa', name: 'Visa', template: '#### #### #### #######', lengths: [16, 18, 19], code: { name: 'CVV', size: 3 }, patterns: ['4'] },
|
||||
{ brand: 'mastercard', name: 'Mastercard', template: '#### #### #### ####', lengths: [16], code: { name: 'CVC', size: 3 }, patterns: [[51, 55], [2221, 2229], [223, 229], [23, 26], [270, 271], '2720'] },
|
||||
{ brand: 'american-express', name: 'American Express', template: '#### ###### #####', lengths: [15], code: { name: 'CID', size: 4 }, patterns: ['34', '37'] },
|
||||
{ brand: 'diners-club', name: 'Diners Club', template: '#### ###### #########', lengths: [14, 16, 19], code: { name: 'CVV', size: 3 }, patterns: [[300, 305], '36', '38', '39'] },
|
||||
{ brand: 'discover', name: 'Discover', template: '#### #### #### #######', lengths: [16, 19], code: { name: 'CID', size: 3 }, patterns: ['6011', [644, 649], '65'] },
|
||||
{ brand: 'jcb', name: 'JCB', template: '#### #### #### #######', lengths: [16, 17, 18, 19], code: { name: 'CVV', size: 3 }, patterns: ['2131', '1800', [3528, 3589]] },
|
||||
{ brand: 'unionpay', name: 'UnionPay', template: '#### #### #### #######', lengths: [14, 15, 16, 17, 18, 19], code: { name: 'CVN', size: 3 }, patterns: ['620', [62100, 62182], [62184, 62187], [62185, 62197], [62200, 62205], [622010, 622999], '622018', [62207, 62209], [623, 626], '6270', '6272', '6276', [627700, 627779], [627781, 627799], [6282, 6289], '6291', '6292', '810', [8110, 8131], [8132, 8151], [8152, 8163], [8164, 8171]] },
|
||||
{ brand: 'maestro', name: 'Maestro', template: '#### #### #### #######', lengths: [12, 13, 14, 15, 16, 17, 18, 19], code: { name: 'CVC', size: 3 }, patterns: ['493698', [500000, 504174], [504176, 506698], [506779, 508999], [56, 59], '63', '67', '6'] },
|
||||
{ brand: 'elo', name: 'Elo', template: '#### #### #### ####', lengths: [16], code: { name: 'CVE', size: 3 }, patterns: ['401178', '401179', '438935', '457631', '457632', '431274', '451416', '457393', '504175', [506699, 506778], [509000, 509999], '627780', '636297', '636368', [650031, 650033], [650035, 650051], [650405, 650439], [650485, 650538], [650541, 650598], [650700, 650718], [650720, 650727], [650901, 650978], [651652, 651679], [655000, 655019], [655021, 655058]] },
|
||||
{ brand: 'mir', name: 'Mir', template: '#### #### #### #######', lengths: [16, 17, 18, 19], code: { name: 'CVP2', size: 3 }, patterns: [[2200, 2204]] },
|
||||
{ brand: 'hiper', name: 'Hiper', template: '#### #### #### ####', lengths: [16], code: { name: 'CVC', size: 3 }, patterns: ['637095', '63737423', '63743358', '637568', '637599', '637609', '637612'] },
|
||||
{ brand: 'hipercard', name: 'Hipercard', template: '#### #### #### ####', lengths: [16], code: { name: 'CVC', size: 3 }, patterns: ['606282'] },
|
||||
{ brand: 'naranja', name: 'Naranja', template: '#### #### #### ####', lengths: [16], code: { name: 'CVV', size: 3 }, patterns: ['589562', '402918', '527572'] },
|
||||
{ brand: 'verve', name: 'Verve', template: '#### #### #### #######', lengths: [16, 18, 19], code: { name: 'CVV', size: 3 }, patterns: [[506099, 506127], '506129', [506133, 506150], [506158, 506163], '506166', '506168', '506170', '506173', [506176, 506180], '506184', [506187, 506188], '506191', '506195', '506197', '507865', '507866', [507868, 507877], [507880, 507888], '507900', '507941'] },
|
||||
];
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CARD_BRANDS } from './card-brands';
|
||||
import { findCardBrand } from './find-card-brand';
|
||||
|
||||
describe(findCardBrand, () => {
|
||||
it('detects major brands by IIN', () => {
|
||||
expect(findCardBrand('4111111111111111')?.brand).toBe('visa');
|
||||
expect(findCardBrand('5500005555555559')?.brand).toBe('mastercard');
|
||||
expect(findCardBrand('2221001234567890')?.brand).toBe('mastercard'); // 2-series
|
||||
expect(findCardBrand('371449635398431')?.brand).toBe('american-express');
|
||||
expect(findCardBrand('30569309025904')?.brand).toBe('diners-club');
|
||||
expect(findCardBrand('6011000990139424')?.brand).toBe('discover');
|
||||
expect(findCardBrand('2200123412341234')?.brand).toBe('mir');
|
||||
});
|
||||
|
||||
it('narrows down as digits are typed', () => {
|
||||
expect(findCardBrand('4')?.brand).toBe('visa');
|
||||
});
|
||||
|
||||
it('exposes a template, lengths and security code for every brand', () => {
|
||||
expect(CARD_BRANDS.length).toBeGreaterThan(5);
|
||||
for (const brand of CARD_BRANDS) {
|
||||
expect(brand.template).toMatch(/#/);
|
||||
expect(brand.lengths.length).toBeGreaterThan(0);
|
||||
expect(brand.code.size).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns undefined for empty or unknown input', () => {
|
||||
expect(findCardBrand('')).toBeUndefined();
|
||||
expect(findCardBrand('0000')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { CARD_BRANDS } from './card-brands';
|
||||
import type { CardBrand, CardPattern } from './card-brands';
|
||||
|
||||
interface RangeMeta {
|
||||
readonly width: number;
|
||||
readonly minString: string;
|
||||
readonly maxString: string;
|
||||
}
|
||||
|
||||
// Precompute the constant string forms of every range pattern once, so the
|
||||
// per-keystroke scan doesn't re-derive them via String() on each comparison.
|
||||
const RANGE_META = new Map<CardPattern, RangeMeta>();
|
||||
for (const brand of CARD_BRANDS) {
|
||||
for (const pattern of brand.patterns) {
|
||||
if (typeof pattern !== 'string') {
|
||||
const maxString = String(pattern[1]);
|
||||
RANGE_META.set(pattern, { width: maxString.length, minString: String(pattern[0]), maxString });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Score how specifically `digits` matches a pattern: the number of leading digits
|
||||
* that match (higher = more specific), or `0` for no match. Supports partial
|
||||
* input as the user types.
|
||||
*/
|
||||
function patternScore(digits: string, pattern: CardPattern): number {
|
||||
if (typeof pattern === 'string') {
|
||||
const length = Math.min(pattern.length, digits.length);
|
||||
return digits.slice(0, length) === pattern.slice(0, length) ? length : 0;
|
||||
}
|
||||
|
||||
const meta = RANGE_META.get(pattern);
|
||||
const maxString = meta ? meta.maxString : String(pattern[1]);
|
||||
const minString = meta ? meta.minString : String(pattern[0]);
|
||||
const width = meta ? meta.width : maxString.length;
|
||||
|
||||
const prefix = digits.slice(0, width);
|
||||
const value = Number(prefix);
|
||||
const low = Number(minString.slice(0, prefix.length));
|
||||
const high = Number(maxString.slice(0, prefix.length));
|
||||
|
||||
return value >= low && value <= high ? prefix.length : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name findCardBrand
|
||||
* @category Multi
|
||||
* @description Detect a payment-card brand from a number's digits by its IIN/BIN
|
||||
* pattern. Returns the brand whose pattern matches the most leading digits (so it
|
||||
* narrows down as the user types), or `undefined` if none match. Pure.
|
||||
*
|
||||
* @param {string} digits The card number's digits (no separators)
|
||||
* @param {readonly CardBrand[]} [brands=CARD_BRANDS] The brand list
|
||||
* @returns {CardBrand | undefined} The detected brand, or `undefined`
|
||||
*
|
||||
* @example
|
||||
* findCardBrand('4111111111111111')?.brand; // 'visa'
|
||||
* findCardBrand('371449635398431')?.brand; // 'american-express'
|
||||
* findCardBrand('2200123412341234')?.brand; // 'mir'
|
||||
*
|
||||
* @since 0.0.5
|
||||
*/
|
||||
export function findCardBrand(
|
||||
digits: string,
|
||||
brands: readonly CardBrand[] = CARD_BRANDS,
|
||||
): CardBrand | undefined {
|
||||
if (!digits)
|
||||
return undefined;
|
||||
|
||||
let best: CardBrand | undefined;
|
||||
let bestScore = 0;
|
||||
|
||||
for (const brand of brands) {
|
||||
for (const pattern of brand.patterns) {
|
||||
const score = patternScore(digits, pattern);
|
||||
if (score > bestScore) {
|
||||
best = brand;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './card-brands';
|
||||
export * from './find-card-brand';
|
||||
export * from './validate';
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { luhn } from '@robonen/encoding';
|
||||
import { isValidCardNumber } from './validate';
|
||||
|
||||
describe(isValidCardNumber, () => {
|
||||
it('requires a valid checksum and a brand-matching length', () => {
|
||||
expect(isValidCardNumber('4111 1111 1111 1111')).toBe(true); // visa, 16
|
||||
expect(isValidCardNumber('371449635398431')).toBe(true); // amex, 15
|
||||
});
|
||||
|
||||
it('rejects a Luhn-valid number outside the valid length range', () => {
|
||||
expect(luhn('79927398713')).toBe(true); // Luhn-valid…
|
||||
expect(isValidCardNumber('79927398713')).toBe(false); // …but only 11 digits
|
||||
});
|
||||
|
||||
it('rejects a bad checksum', () => {
|
||||
expect(isValidCardNumber('4111 1111 1111 1112')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { luhn } from '@robonen/encoding';
|
||||
import { CARD_BRANDS } from './card-brands';
|
||||
import type { CardBrand } from './card-brands';
|
||||
import { findCardBrand } from './find-card-brand';
|
||||
|
||||
const NON_DIGIT = /\D/g;
|
||||
|
||||
/**
|
||||
* @name isValidCardNumber
|
||||
* @category Multi
|
||||
* @description Whether `value` is a complete, valid payment-card number: it passes
|
||||
* the Luhn checksum (`luhn` from `@robonen/encoding`) AND its digit length matches
|
||||
* the detected {@link findCardBrand} brand (or the 12–19 digit ISO/IEC 7812 range
|
||||
* when the brand is unknown). For the bare checksum, use `luhn` directly.
|
||||
*
|
||||
* @param {string} value The card number (separators allowed)
|
||||
* @param {readonly CardBrand[]} [brands=CARD_BRANDS] The brand list
|
||||
* @returns {boolean} Whether the number is structurally valid
|
||||
*
|
||||
* @example
|
||||
* isValidCardNumber('4111 1111 1111 1111'); // true
|
||||
* isValidCardNumber('4111 1111 1111'); // false (wrong length)
|
||||
*
|
||||
* @since 0.0.5
|
||||
*/
|
||||
export function isValidCardNumber(value: string, brands: readonly CardBrand[] = CARD_BRANDS): boolean {
|
||||
if (!luhn(value))
|
||||
return false;
|
||||
|
||||
const digits = value.replaceAll(NON_DIGIT, '');
|
||||
const brand = findCardBrand(digits, brands);
|
||||
|
||||
return brand
|
||||
? brand.lengths.includes(digits.length)
|
||||
: digits.length >= 12 && digits.length <= 19;
|
||||
}
|
||||
@@ -1 +1,3 @@
|
||||
export * from './card';
|
||||
export * from './global';
|
||||
export * from './intl';
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { findPhoneCountry } from './find-phone-country';
|
||||
import { PHONE_COUNTRIES } from './phone-countries';
|
||||
|
||||
describe(findPhoneCountry, () => {
|
||||
it('every dialing code is prefix-free', () => {
|
||||
const codes = PHONE_COUNTRIES.map(country => country.code);
|
||||
expect(codes.some(a => codes.some(b => a !== b && b.startsWith(a)))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns the primary country for a shared code when no area code matches', () => {
|
||||
expect(findPhoneCountry('12025550123')?.iso2).toBe('us');
|
||||
expect(findPhoneCountry('74951234567')?.iso2).toBe('ru');
|
||||
});
|
||||
|
||||
it('disambiguates a shared code by area code', () => {
|
||||
expect(findPhoneCountry('14165550123')?.iso2).toBe('ca'); // +1 416 → Canada
|
||||
expect(findPhoneCountry('12425550123')?.iso2).toBe('bs'); // +1 242 → Bahamas
|
||||
expect(findPhoneCountry('73101234567')?.iso2).toBe('kz'); // +7 310 → Kazakhstan
|
||||
});
|
||||
|
||||
it('matches a unique long code', () => {
|
||||
expect(findPhoneCountry('380441234567')?.iso2).toBe('ua');
|
||||
});
|
||||
|
||||
it('returns undefined for empty or unknown input', () => {
|
||||
expect(findPhoneCountry('')).toBeUndefined();
|
||||
expect(findPhoneCountry('000')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { PHONE_COUNTRIES } from './phone-countries';
|
||||
import type { PhoneCountry } from './phone-countries';
|
||||
|
||||
function buildPhoneIndex(countries: readonly PhoneCountry[]): Map<string, PhoneCountry[]> {
|
||||
const index = new Map<string, PhoneCountry[]>();
|
||||
|
||||
for (const country of countries) {
|
||||
let bucket = index.get(country.code);
|
||||
if (!bucket) {
|
||||
bucket = [];
|
||||
index.set(country.code, bucket);
|
||||
}
|
||||
bucket.push(country);
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
// O(1) lookup over the default dataset, keyed by dialing code. Codes are
|
||||
// prefix-free (E.164), so probing the first 3..1 digits finds the unique code —
|
||||
// far cheaper than scanning all ~211 countries on every keystroke.
|
||||
const PHONE_INDEX = buildPhoneIndex(PHONE_COUNTRIES);
|
||||
|
||||
/**
|
||||
* Among countries sharing a dialing code, pick the most specific matching area
|
||||
* code, else the lowest priority (the primary country). `rest` is the digits
|
||||
* after the dialing code.
|
||||
*/
|
||||
function resolveGroup(group: readonly PhoneCountry[], rest: string): PhoneCountry | undefined {
|
||||
const first = group[0];
|
||||
if (!first)
|
||||
return undefined;
|
||||
if (group.length === 1)
|
||||
return first;
|
||||
|
||||
let best: PhoneCountry | undefined;
|
||||
let bestAreaLength = 0;
|
||||
for (const country of group) {
|
||||
for (const area of country.areaCodes ?? []) {
|
||||
if (area.length > bestAreaLength && rest.startsWith(area)) {
|
||||
best = country;
|
||||
bestAreaLength = area.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (best)
|
||||
return best;
|
||||
|
||||
return group.reduce((winner, country) =>
|
||||
(winner.priority ?? 0) <= (country.priority ?? 0) ? winner : country);
|
||||
}
|
||||
|
||||
/**
|
||||
* @name findPhoneCountry
|
||||
* @category Multi
|
||||
* @description Resolve a digit string to its country among a {@link PhoneCountry}
|
||||
* list. Matches the **longest dialing code** (codes are prefix-free, so this is
|
||||
* unambiguous), then — for countries sharing a code (NANP `+1`, `+7` RU/KZ) — the
|
||||
* most specific **area code**, then the lowest **priority** (the primary country)
|
||||
* when no area code matches. The default dataset is indexed for O(1) lookup; a
|
||||
* custom list falls back to a linear scan.
|
||||
*
|
||||
* @param {string} digits The number's digits (no `+`/separators), e.g. `'14165550123'`
|
||||
* @param {readonly PhoneCountry[]} [countries=PHONE_COUNTRIES] The country list
|
||||
* @returns {PhoneCountry | undefined} The matched country, or `undefined`
|
||||
*
|
||||
* @example
|
||||
* findPhoneCountry('12025550123')?.iso2; // 'us'
|
||||
* findPhoneCountry('14165550123')?.iso2; // 'ca' (area code 416)
|
||||
* findPhoneCountry('12425550123')?.iso2; // 'bs' (area code 242)
|
||||
*
|
||||
* @since 0.0.5
|
||||
*/
|
||||
export function findPhoneCountry(
|
||||
digits: string,
|
||||
countries: readonly PhoneCountry[] = PHONE_COUNTRIES,
|
||||
): PhoneCountry | undefined {
|
||||
if (!digits)
|
||||
return undefined;
|
||||
|
||||
// Fast path: the default dataset is indexed by dialing code.
|
||||
if (countries === PHONE_COUNTRIES) {
|
||||
for (let length = Math.min(3, digits.length); length >= 1; length--) {
|
||||
const bucket = PHONE_INDEX.get(digits.slice(0, length));
|
||||
if (bucket)
|
||||
return resolveGroup(bucket, digits.slice(length));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Fallback (custom list): longest dialing-code prefix via a linear scan.
|
||||
let codeLength = -1;
|
||||
const group: PhoneCountry[] = [];
|
||||
for (const country of countries) {
|
||||
if (!digits.startsWith(country.code))
|
||||
continue;
|
||||
|
||||
if (country.code.length > codeLength) {
|
||||
codeLength = country.code.length;
|
||||
group.length = 0;
|
||||
group.push(country);
|
||||
}
|
||||
else if (country.code.length === codeLength) {
|
||||
group.push(country);
|
||||
}
|
||||
}
|
||||
|
||||
return codeLength === -1 ? undefined : resolveGroup(group, digits.slice(codeLength));
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getCountryFlagByCode } from './flag';
|
||||
|
||||
describe(getCountryFlagByCode, () => {
|
||||
it('maps alpha-2 codes to flag emoji', () => {
|
||||
expect(getCountryFlagByCode('US')).toBe('🇺🇸');
|
||||
expect(getCountryFlagByCode('UA')).toBe('🇺🇦');
|
||||
expect(getCountryFlagByCode('GB')).toBe('🇬🇧');
|
||||
});
|
||||
|
||||
it('is case-insensitive', () => {
|
||||
expect(getCountryFlagByCode('gb')).toBe(getCountryFlagByCode('GB'));
|
||||
});
|
||||
|
||||
it('returns an empty string for invalid input', () => {
|
||||
expect(getCountryFlagByCode('1')).toBe('');
|
||||
expect(getCountryFlagByCode('USA')).toBe('');
|
||||
expect(getCountryFlagByCode('u')).toBe('');
|
||||
expect(getCountryFlagByCode('')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
// Offset from an ASCII uppercase letter to its Unicode regional indicator
|
||||
// symbol: 'A' (U+0041) → 🇦 (U+1F1E6).
|
||||
const REGIONAL_INDICATOR_OFFSET = 0x1F1E6 - 'A'.charCodeAt(0);
|
||||
const ALPHA2 = /^[a-z]{2}$/i;
|
||||
|
||||
/**
|
||||
* @name getCountryFlagByCode
|
||||
* @category Multi
|
||||
* @description Convert an ISO 3166-1 alpha-2 country code (e.g. `'RU'`, `'us'`)
|
||||
* into its flag emoji by mapping each letter to a Unicode regional indicator
|
||||
* symbol. Case-insensitive; returns an empty string for anything that isn't two
|
||||
* ASCII letters. Pure and environment-agnostic.
|
||||
*
|
||||
* @param {string} code The ISO 3166-1 alpha-2 code, e.g. `'US'`
|
||||
* @returns {string} The flag emoji (e.g. `'🇺🇸'`), or `''` when the code is invalid
|
||||
*
|
||||
* @example
|
||||
* getCountryFlagByCode('RU'); // '🇷🇺'
|
||||
* getCountryFlagByCode('us'); // '🇺🇸'
|
||||
* getCountryFlagByCode('123'); // ''
|
||||
*
|
||||
* @since 0.0.5
|
||||
*/
|
||||
export function getCountryFlagByCode(code: string): string {
|
||||
if (!ALPHA2.test(code))
|
||||
return '';
|
||||
|
||||
const upper = code.toUpperCase();
|
||||
|
||||
return String.fromCodePoint(
|
||||
REGIONAL_INDICATOR_OFFSET + upper.charCodeAt(0),
|
||||
REGIONAL_INDICATOR_OFFSET + upper.charCodeAt(1),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './flag';
|
||||
export * from './find-phone-country';
|
||||
export * from './phone-countries';
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Phone-number formats for every country: an international template with the
|
||||
* dialing-code digits as `#` digit placeholders, plus the `priority`/`areaCodes`
|
||||
* used to disambiguate countries sharing a dialing code (NANP `+1`, `+7` RU/KZ).
|
||||
* NANP territories are normalized to code `'1'` (their 3-digit prefix moved into
|
||||
* `areaCodes`), so every dialing code is prefix-free and the `+1` block shares one
|
||||
* numbering-plan template. Derived from the MIT-licensed `react-phone-input-2`
|
||||
* dataset — display formats, not authoritative validation.
|
||||
*/
|
||||
|
||||
/**
|
||||
* One country's phone-number format.
|
||||
*/
|
||||
export interface PhoneCountry {
|
||||
/** Display name, e.g. `'United States'` (omit for custom lists). */
|
||||
readonly name?: string;
|
||||
/** ISO 3166-1 alpha-2 code, lowercase, e.g. `'us'` (omit for custom lists). */
|
||||
readonly iso2?: string;
|
||||
/** International dialing code digits (no `+`), e.g. `'1'`, `'380'`. */
|
||||
readonly code: string;
|
||||
/** International template; `#` is a digit, e.g. `'+# (###) ###-####'`. */
|
||||
readonly template: string;
|
||||
/**
|
||||
* Order among countries sharing `code` — lower wins when no area code matches.
|
||||
* Absent means `0` (the primary country for that code).
|
||||
*/
|
||||
readonly priority?: number;
|
||||
/** Area codes (the digits right after `code`) that belong to this country. */
|
||||
readonly areaCodes?: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Every supported country. Resolve a number with `findPhoneCountry`.
|
||||
*/
|
||||
export const PHONE_COUNTRIES: readonly PhoneCountry[] = [
|
||||
{ name: 'Afghanistan', iso2: 'af', code: '93', template: '+## #############' },
|
||||
{ name: 'Albania', iso2: 'al', code: '355', template: '+### ############' },
|
||||
{ name: 'Algeria', iso2: 'dz', code: '213', template: '+### ############' },
|
||||
{ name: 'Andorra', iso2: 'ad', code: '376', template: '+### ############' },
|
||||
{ name: 'Angola', iso2: 'ao', code: '244', template: '+### ############' },
|
||||
{ name: 'Antigua and Barbuda', iso2: 'ag', code: '1', template: '+# (###) ###-####', priority: 10, areaCodes: ['268'] },
|
||||
{ name: 'Argentina', iso2: 'ar', code: '54', template: '+## (##) ########' },
|
||||
{ name: 'Armenia', iso2: 'am', code: '374', template: '+### ## ######' },
|
||||
{ name: 'Aruba', iso2: 'aw', code: '297', template: '+### ############' },
|
||||
{ name: 'Australia', iso2: 'au', code: '61', template: '+## (##) #### ####' },
|
||||
{ name: 'Austria', iso2: 'at', code: '43', template: '+## #############' },
|
||||
{ name: 'Azerbaijan', iso2: 'az', code: '994', template: '+### (##) ### ## ##' },
|
||||
{ name: 'Bahamas', iso2: 'bs', code: '1', template: '+# (###) ###-####', priority: 10, areaCodes: ['242'] },
|
||||
{ name: 'Bahrain', iso2: 'bh', code: '973', template: '+### ############' },
|
||||
{ name: 'Bangladesh', iso2: 'bd', code: '880', template: '+### ############' },
|
||||
{ name: 'Barbados', iso2: 'bb', code: '1', template: '+# (###) ###-####', priority: 10, areaCodes: ['246'] },
|
||||
{ name: 'Belarus', iso2: 'by', code: '375', template: '+### (##) ### ## ##' },
|
||||
{ name: 'Belgium', iso2: 'be', code: '32', template: '+## ### ## ## ##' },
|
||||
{ name: 'Belize', iso2: 'bz', code: '501', template: '+### ############' },
|
||||
{ name: 'Benin', iso2: 'bj', code: '229', template: '+### ############' },
|
||||
{ name: 'Bhutan', iso2: 'bt', code: '975', template: '+### ############' },
|
||||
{ name: 'Bolivia', iso2: 'bo', code: '591', template: '+### ############' },
|
||||
{ name: 'Bosnia and Herzegovina', iso2: 'ba', code: '387', template: '+### ############' },
|
||||
{ name: 'Botswana', iso2: 'bw', code: '267', template: '+### ############' },
|
||||
{ name: 'Brazil', iso2: 'br', code: '55', template: '+## (##) #########' },
|
||||
{ name: 'British Indian Ocean Territory', iso2: 'io', code: '246', template: '+### ############' },
|
||||
{ name: 'Brunei', iso2: 'bn', code: '673', template: '+### ############' },
|
||||
{ name: 'Bulgaria', iso2: 'bg', code: '359', template: '+### ############' },
|
||||
{ name: 'Burkina Faso', iso2: 'bf', code: '226', template: '+### ############' },
|
||||
{ name: 'Burundi', iso2: 'bi', code: '257', template: '+### ############' },
|
||||
{ name: 'Cambodia', iso2: 'kh', code: '855', template: '+### ############' },
|
||||
{ name: 'Cameroon', iso2: 'cm', code: '237', template: '+### ############' },
|
||||
{ name: 'Canada', iso2: 'ca', code: '1', template: '+# (###) ###-####', priority: 1, areaCodes: ['204', '226', '236', '249', '250', '289', '306', '343', '365', '387', '403', '416', '418', '431', '437', '438', '450', '506', '514', '519', '548', '579', '581', '587', '604', '613', '639', '647', '672', '705', '709', '742', '778', '780', '782', '807', '819', '825', '867', '873', '902', '905'] },
|
||||
{ name: 'Cape Verde', iso2: 'cv', code: '238', template: '+### ############' },
|
||||
{ name: 'Caribbean Netherlands', iso2: 'bq', code: '599', template: '+### ############', priority: 1 },
|
||||
{ name: 'Central African Republic', iso2: 'cf', code: '236', template: '+### ############' },
|
||||
{ name: 'Chad', iso2: 'td', code: '235', template: '+### ############' },
|
||||
{ name: 'Chile', iso2: 'cl', code: '56', template: '+## #############' },
|
||||
{ name: 'China', iso2: 'cn', code: '86', template: '+## ##-#########' },
|
||||
{ name: 'Colombia', iso2: 'co', code: '57', template: '+## ### ### ####' },
|
||||
{ name: 'Comoros', iso2: 'km', code: '269', template: '+### ############' },
|
||||
{ name: 'Congo', iso2: 'cd', code: '243', template: '+### ############' },
|
||||
{ name: 'Congo', iso2: 'cg', code: '242', template: '+### ############' },
|
||||
{ name: 'Costa Rica', iso2: 'cr', code: '506', template: '+### ####-####' },
|
||||
{ name: 'Côte d’Ivoire', iso2: 'ci', code: '225', template: '+### ## ## ## ##' },
|
||||
{ name: 'Croatia', iso2: 'hr', code: '385', template: '+### ############' },
|
||||
{ name: 'Cuba', iso2: 'cu', code: '53', template: '+## #############' },
|
||||
{ name: 'Curaçao', iso2: 'cw', code: '599', template: '+### ############' },
|
||||
{ name: 'Cyprus', iso2: 'cy', code: '357', template: '+### ## ######' },
|
||||
{ name: 'Czech Republic', iso2: 'cz', code: '420', template: '+### ### ### ###' },
|
||||
{ name: 'Denmark', iso2: 'dk', code: '45', template: '+## ## ## ## ##' },
|
||||
{ name: 'Djibouti', iso2: 'dj', code: '253', template: '+### ############' },
|
||||
{ name: 'Dominica', iso2: 'dm', code: '1', template: '+# (###) ###-####', priority: 10, areaCodes: ['767'] },
|
||||
{ name: 'Dominican Republic', iso2: 'do', code: '1', template: '+# (###) ###-####', priority: 2, areaCodes: ['809', '829', '849'] },
|
||||
{ name: 'Ecuador', iso2: 'ec', code: '593', template: '+### ############' },
|
||||
{ name: 'Egypt', iso2: 'eg', code: '20', template: '+## #############' },
|
||||
{ name: 'El Salvador', iso2: 'sv', code: '503', template: '+### ####-####' },
|
||||
{ name: 'Equatorial Guinea', iso2: 'gq', code: '240', template: '+### ############' },
|
||||
{ name: 'Eritrea', iso2: 'er', code: '291', template: '+### ############' },
|
||||
{ name: 'Estonia', iso2: 'ee', code: '372', template: '+### #### ######' },
|
||||
{ name: 'Ethiopia', iso2: 'et', code: '251', template: '+### ############' },
|
||||
{ name: 'Fiji', iso2: 'fj', code: '679', template: '+### ############' },
|
||||
{ name: 'Finland', iso2: 'fi', code: '358', template: '+### ## ### ## ##' },
|
||||
{ name: 'France', iso2: 'fr', code: '33', template: '+## # ## ## ## ##' },
|
||||
{ name: 'French Guiana', iso2: 'gf', code: '594', template: '+### ############' },
|
||||
{ name: 'French Polynesia', iso2: 'pf', code: '689', template: '+### ############' },
|
||||
{ name: 'Gabon', iso2: 'ga', code: '241', template: '+### ############' },
|
||||
{ name: 'Gambia', iso2: 'gm', code: '220', template: '+### ############' },
|
||||
{ name: 'Georgia', iso2: 'ge', code: '995', template: '+### ############' },
|
||||
{ name: 'Germany', iso2: 'de', code: '49', template: '+## #### ########' },
|
||||
{ name: 'Ghana', iso2: 'gh', code: '233', template: '+### ############' },
|
||||
{ name: 'Greece', iso2: 'gr', code: '30', template: '+## #############' },
|
||||
{ name: 'Grenada', iso2: 'gd', code: '1', template: '+# (###) ###-####', priority: 10, areaCodes: ['473'] },
|
||||
{ name: 'Guadeloupe', iso2: 'gp', code: '590', template: '+### ############' },
|
||||
{ name: 'Guam', iso2: 'gu', code: '1', template: '+# (###) ###-####', priority: 10, areaCodes: ['671'] },
|
||||
{ name: 'Guatemala', iso2: 'gt', code: '502', template: '+### ####-####' },
|
||||
{ name: 'Guinea', iso2: 'gn', code: '224', template: '+### ############' },
|
||||
{ name: 'Guinea-Bissau', iso2: 'gw', code: '245', template: '+### ############' },
|
||||
{ name: 'Guyana', iso2: 'gy', code: '592', template: '+### ############' },
|
||||
{ name: 'Haiti', iso2: 'ht', code: '509', template: '+### ####-####' },
|
||||
{ name: 'Honduras', iso2: 'hn', code: '504', template: '+### ############' },
|
||||
{ name: 'Hong Kong', iso2: 'hk', code: '852', template: '+### #### ####' },
|
||||
{ name: 'Hungary', iso2: 'hu', code: '36', template: '+## #############' },
|
||||
{ name: 'Iceland', iso2: 'is', code: '354', template: '+### ### ####' },
|
||||
{ name: 'India', iso2: 'in', code: '91', template: '+## #####-#####' },
|
||||
{ name: 'Indonesia', iso2: 'id', code: '62', template: '+## #############' },
|
||||
{ name: 'Iran', iso2: 'ir', code: '98', template: '+## ### ### ####' },
|
||||
{ name: 'Iraq', iso2: 'iq', code: '964', template: '+### ############' },
|
||||
{ name: 'Ireland', iso2: 'ie', code: '353', template: '+### ## #######' },
|
||||
{ name: 'Israel', iso2: 'il', code: '972', template: '+### ### ### ####' },
|
||||
{ name: 'Italy', iso2: 'it', code: '39', template: '+## ### #######' },
|
||||
{ name: 'Jamaica', iso2: 'jm', code: '1', template: '+# (###) ###-####', priority: 10, areaCodes: ['876'] },
|
||||
{ name: 'Japan', iso2: 'jp', code: '81', template: '+## ## #### ####' },
|
||||
{ name: 'Jordan', iso2: 'jo', code: '962', template: '+### ############' },
|
||||
{ name: 'Kazakhstan', iso2: 'kz', code: '7', template: '+# (###) ###-##-##', priority: 1, areaCodes: ['310', '311', '312', '313', '315', '318', '321', '324', '325', '326', '327', '336', '7172', '73622'] },
|
||||
{ name: 'Kenya', iso2: 'ke', code: '254', template: '+### ############' },
|
||||
{ name: 'Kiribati', iso2: 'ki', code: '686', template: '+### ############' },
|
||||
{ name: 'Kosovo', iso2: 'xk', code: '383', template: '+### ############' },
|
||||
{ name: 'Kuwait', iso2: 'kw', code: '965', template: '+### ############' },
|
||||
{ name: 'Kyrgyzstan', iso2: 'kg', code: '996', template: '+### ### ### ###' },
|
||||
{ name: 'Laos', iso2: 'la', code: '856', template: '+### ############' },
|
||||
{ name: 'Latvia', iso2: 'lv', code: '371', template: '+### ## ### ###' },
|
||||
{ name: 'Lebanon', iso2: 'lb', code: '961', template: '+### ############' },
|
||||
{ name: 'Lesotho', iso2: 'ls', code: '266', template: '+### ############' },
|
||||
{ name: 'Liberia', iso2: 'lr', code: '231', template: '+### ############' },
|
||||
{ name: 'Libya', iso2: 'ly', code: '218', template: '+### ############' },
|
||||
{ name: 'Liechtenstein', iso2: 'li', code: '423', template: '+### ############' },
|
||||
{ name: 'Lithuania', iso2: 'lt', code: '370', template: '+### ############' },
|
||||
{ name: 'Luxembourg', iso2: 'lu', code: '352', template: '+### ############' },
|
||||
{ name: 'Macau', iso2: 'mo', code: '853', template: '+### ############' },
|
||||
{ name: 'Macedonia', iso2: 'mk', code: '389', template: '+### ############' },
|
||||
{ name: 'Madagascar', iso2: 'mg', code: '261', template: '+### ############' },
|
||||
{ name: 'Malawi', iso2: 'mw', code: '265', template: '+### ############' },
|
||||
{ name: 'Malaysia', iso2: 'my', code: '60', template: '+## ##-####-####' },
|
||||
{ name: 'Maldives', iso2: 'mv', code: '960', template: '+### ############' },
|
||||
{ name: 'Mali', iso2: 'ml', code: '223', template: '+### ############' },
|
||||
{ name: 'Malta', iso2: 'mt', code: '356', template: '+### ############' },
|
||||
{ name: 'Marshall Islands', iso2: 'mh', code: '692', template: '+### ############' },
|
||||
{ name: 'Martinique', iso2: 'mq', code: '596', template: '+### ############' },
|
||||
{ name: 'Mauritania', iso2: 'mr', code: '222', template: '+### ############' },
|
||||
{ name: 'Mauritius', iso2: 'mu', code: '230', template: '+### ############' },
|
||||
{ name: 'Mexico', iso2: 'mx', code: '52', template: '+## ### ### ####' },
|
||||
{ name: 'Micronesia', iso2: 'fm', code: '691', template: '+### ############' },
|
||||
{ name: 'Moldova', iso2: 'md', code: '373', template: '+### (##) ##-##-##' },
|
||||
{ name: 'Monaco', iso2: 'mc', code: '377', template: '+### ############' },
|
||||
{ name: 'Mongolia', iso2: 'mn', code: '976', template: '+### ############' },
|
||||
{ name: 'Montenegro', iso2: 'me', code: '382', template: '+### ############' },
|
||||
{ name: 'Morocco', iso2: 'ma', code: '212', template: '+### ############' },
|
||||
{ name: 'Mozambique', iso2: 'mz', code: '258', template: '+### ############' },
|
||||
{ name: 'Myanmar', iso2: 'mm', code: '95', template: '+## #############' },
|
||||
{ name: 'Namibia', iso2: 'na', code: '264', template: '+### ############' },
|
||||
{ name: 'Nauru', iso2: 'nr', code: '674', template: '+### ############' },
|
||||
{ name: 'Nepal', iso2: 'np', code: '977', template: '+### ############' },
|
||||
{ name: 'Netherlands', iso2: 'nl', code: '31', template: '+## ## ########' },
|
||||
{ name: 'New Caledonia', iso2: 'nc', code: '687', template: '+### ############' },
|
||||
{ name: 'New Zealand', iso2: 'nz', code: '64', template: '+## ###-###-####' },
|
||||
{ name: 'Nicaragua', iso2: 'ni', code: '505', template: '+### ############' },
|
||||
{ name: 'Niger', iso2: 'ne', code: '227', template: '+### ############' },
|
||||
{ name: 'Nigeria', iso2: 'ng', code: '234', template: '+### ############' },
|
||||
{ name: 'North Korea', iso2: 'kp', code: '850', template: '+### ############' },
|
||||
{ name: 'Norway', iso2: 'no', code: '47', template: '+## ### ## ###' },
|
||||
{ name: 'Oman', iso2: 'om', code: '968', template: '+### ############' },
|
||||
{ name: 'Pakistan', iso2: 'pk', code: '92', template: '+## ###-#######' },
|
||||
{ name: 'Palau', iso2: 'pw', code: '680', template: '+### ############' },
|
||||
{ name: 'Palestine', iso2: 'ps', code: '970', template: '+### ############' },
|
||||
{ name: 'Panama', iso2: 'pa', code: '507', template: '+### ############' },
|
||||
{ name: 'Papua New Guinea', iso2: 'pg', code: '675', template: '+### ############' },
|
||||
{ name: 'Paraguay', iso2: 'py', code: '595', template: '+### ############' },
|
||||
{ name: 'Peru', iso2: 'pe', code: '51', template: '+## #############' },
|
||||
{ name: 'Philippines', iso2: 'ph', code: '63', template: '+## #### #######' },
|
||||
{ name: 'Poland', iso2: 'pl', code: '48', template: '+## ###-###-###' },
|
||||
{ name: 'Portugal', iso2: 'pt', code: '351', template: '+### ############' },
|
||||
{ name: 'Puerto Rico', iso2: 'pr', code: '1', template: '+# (###) ###-####', priority: 3, areaCodes: ['787', '939'] },
|
||||
{ name: 'Qatar', iso2: 'qa', code: '974', template: '+### ############' },
|
||||
{ name: 'Réunion', iso2: 're', code: '262', template: '+### ############' },
|
||||
{ name: 'Romania', iso2: 'ro', code: '40', template: '+## #############' },
|
||||
{ name: 'Russia', iso2: 'ru', code: '7', template: '+# (###) ###-##-##' },
|
||||
{ name: 'Rwanda', iso2: 'rw', code: '250', template: '+### ############' },
|
||||
{ name: 'Saint Kitts and Nevis', iso2: 'kn', code: '1', template: '+# (###) ###-####', priority: 10, areaCodes: ['869'] },
|
||||
{ name: 'Saint Lucia', iso2: 'lc', code: '1', template: '+# (###) ###-####', priority: 10, areaCodes: ['758'] },
|
||||
{ name: 'Saint Vincent and the Grenadines', iso2: 'vc', code: '1', template: '+# (###) ###-####', priority: 10, areaCodes: ['784'] },
|
||||
{ name: 'Samoa', iso2: 'ws', code: '685', template: '+### ############' },
|
||||
{ name: 'San Marino', iso2: 'sm', code: '378', template: '+### ############' },
|
||||
{ name: 'São Tomé and Príncipe', iso2: 'st', code: '239', template: '+### ############' },
|
||||
{ name: 'Saudi Arabia', iso2: 'sa', code: '966', template: '+### ############' },
|
||||
{ name: 'Senegal', iso2: 'sn', code: '221', template: '+### ############' },
|
||||
{ name: 'Serbia', iso2: 'rs', code: '381', template: '+### ############' },
|
||||
{ name: 'Seychelles', iso2: 'sc', code: '248', template: '+### ############' },
|
||||
{ name: 'Sierra Leone', iso2: 'sl', code: '232', template: '+### ############' },
|
||||
{ name: 'Singapore', iso2: 'sg', code: '65', template: '+## ####-####' },
|
||||
{ name: 'Slovakia', iso2: 'sk', code: '421', template: '+### ############' },
|
||||
{ name: 'Slovenia', iso2: 'si', code: '386', template: '+### ############' },
|
||||
{ name: 'Solomon Islands', iso2: 'sb', code: '677', template: '+### ############' },
|
||||
{ name: 'Somalia', iso2: 'so', code: '252', template: '+### ############' },
|
||||
{ name: 'South Africa', iso2: 'za', code: '27', template: '+## #############' },
|
||||
{ name: 'South Korea', iso2: 'kr', code: '82', template: '+## ### #### ####' },
|
||||
{ name: 'South Sudan', iso2: 'ss', code: '211', template: '+### ############' },
|
||||
{ name: 'Spain', iso2: 'es', code: '34', template: '+## ### ### ###' },
|
||||
{ name: 'Sri Lanka', iso2: 'lk', code: '94', template: '+## #############' },
|
||||
{ name: 'Sudan', iso2: 'sd', code: '249', template: '+### ############' },
|
||||
{ name: 'Suriname', iso2: 'sr', code: '597', template: '+### ############' },
|
||||
{ name: 'Swaziland', iso2: 'sz', code: '268', template: '+### ############' },
|
||||
{ name: 'Sweden', iso2: 'se', code: '46', template: '+## (###) ###-###' },
|
||||
{ name: 'Switzerland', iso2: 'ch', code: '41', template: '+## ## ### ## ##' },
|
||||
{ name: 'Syria', iso2: 'sy', code: '963', template: '+### ############' },
|
||||
{ name: 'Taiwan', iso2: 'tw', code: '886', template: '+### ############' },
|
||||
{ name: 'Tajikistan', iso2: 'tj', code: '992', template: '+### ############' },
|
||||
{ name: 'Tanzania', iso2: 'tz', code: '255', template: '+### ############' },
|
||||
{ name: 'Thailand', iso2: 'th', code: '66', template: '+## #############' },
|
||||
{ name: 'Timor-Leste', iso2: 'tl', code: '670', template: '+### ############' },
|
||||
{ name: 'Togo', iso2: 'tg', code: '228', template: '+### ############' },
|
||||
{ name: 'Tonga', iso2: 'to', code: '676', template: '+### ############' },
|
||||
{ name: 'Trinidad and Tobago', iso2: 'tt', code: '1', template: '+# (###) ###-####', priority: 10, areaCodes: ['868'] },
|
||||
{ name: 'Tunisia', iso2: 'tn', code: '216', template: '+### ############' },
|
||||
{ name: 'Turkey', iso2: 'tr', code: '90', template: '+## ### ### ## ##' },
|
||||
{ name: 'Turkmenistan', iso2: 'tm', code: '993', template: '+### ############' },
|
||||
{ name: 'Tuvalu', iso2: 'tv', code: '688', template: '+### ############' },
|
||||
{ name: 'Uganda', iso2: 'ug', code: '256', template: '+### ############' },
|
||||
{ name: 'Ukraine', iso2: 'ua', code: '380', template: '+### (##) ### ## ##' },
|
||||
{ name: 'United Arab Emirates', iso2: 'ae', code: '971', template: '+### ############' },
|
||||
{ name: 'United Kingdom', iso2: 'gb', code: '44', template: '+## #### ######' },
|
||||
{ name: 'United States', iso2: 'us', code: '1', template: '+# (###) ###-####' },
|
||||
{ name: 'Uruguay', iso2: 'uy', code: '598', template: '+### ############' },
|
||||
{ name: 'Uzbekistan', iso2: 'uz', code: '998', template: '+### ## ### ## ##' },
|
||||
{ name: 'Vanuatu', iso2: 'vu', code: '678', template: '+### ############' },
|
||||
{ name: 'Vatican City', iso2: 'va', code: '39', template: '+## ### #######', priority: 1 },
|
||||
{ name: 'Venezuela', iso2: 've', code: '58', template: '+## #############' },
|
||||
{ name: 'Vietnam', iso2: 'vn', code: '84', template: '+## #############' },
|
||||
{ name: 'Yemen', iso2: 'ye', code: '967', template: '+### ############' },
|
||||
{ name: 'Zambia', iso2: 'zm', code: '260', template: '+### ############' },
|
||||
{ name: 'Zimbabwe', iso2: 'zw', code: '263', template: '+### ############' },
|
||||
];
|
||||
@@ -19,7 +19,10 @@ const anatomyCode = computed(() => {
|
||||
|
||||
const imports = `import {\n${names.map(n => ` ${n},`).join('\n')}\n} from '${importPath.value}';`;
|
||||
|
||||
const [root, ...rest] = names;
|
||||
// Wrap the skeleton in the Root part (not whatever the barrel exports first),
|
||||
// with the remaining parts nested inside it.
|
||||
const root = (props.component.parts.find(p => p.role === 'Root') ?? props.component.parts[0]!).name;
|
||||
const rest = names.filter(n => n !== root);
|
||||
let tree: string;
|
||||
if (rest.length === 0) {
|
||||
tree = `<${root} />`;
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue';
|
||||
import { demoSources } from '#docs/demo-sources';
|
||||
|
||||
const props = defineProps<{
|
||||
component: Component;
|
||||
source: string;
|
||||
/** Key into the lazy demo-source map (`${pkg}/${slug}`). */
|
||||
sourceKey: string;
|
||||
}>();
|
||||
|
||||
const showSource = ref(false);
|
||||
const source = ref('');
|
||||
|
||||
const { highlighted, highlightReactive } = useShiki();
|
||||
|
||||
// Fetch the raw demo source only when the user first opens it, then highlight.
|
||||
watch(showSource, async (show) => {
|
||||
if (show && !highlighted.value) {
|
||||
await highlightReactive(props.source, 'vue');
|
||||
}
|
||||
if (!show) return;
|
||||
if (!source.value)
|
||||
source.value = (await demoSources[props.sourceKey]?.()) ?? '';
|
||||
if (source.value && !highlighted.value)
|
||||
await highlightReactive(source.value, 'vue');
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ defineProps<{
|
||||
</div>
|
||||
|
||||
<p v-if="method.description" class="text-sm text-(--fg-muted) mb-3">
|
||||
{{ method.description }}
|
||||
<DocsText :text="method.description" />
|
||||
</p>
|
||||
|
||||
<DocsCode
|
||||
@@ -38,7 +38,7 @@ defineProps<{
|
||||
<div v-if="method.returns" class="mt-2 text-sm">
|
||||
<span class="text-(--fg-subtle)">Returns</span>
|
||||
<code class="ml-1.5 text-xs font-mono bg-(--bg-inset) border border-(--border) px-1.5 py-0.5 rounded">{{ method.returns.type }}</code>
|
||||
<span v-if="method.returns.description" class="ml-2 text-(--fg-muted)">{{ method.returns.description }}</span>
|
||||
<DocsText v-if="method.returns.description" :text="method.returns.description" class="ml-2 text-(--fg-muted)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,7 +33,8 @@ defineProps<{
|
||||
<span v-else class="text-(--fg-subtle)">—</span>
|
||||
</td>
|
||||
<td class="py-2.5 px-4 text-(--fg-muted) min-w-48">
|
||||
{{ param.description || '—' }}
|
||||
<DocsText v-if="param.description" :text="param.description" />
|
||||
<span v-else>—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -36,7 +36,8 @@ defineProps<{
|
||||
<span v-else class="text-(--fg-subtle)">—</span>
|
||||
</td>
|
||||
<td class="py-2.5 px-4 text-(--fg-muted) min-w-48">
|
||||
{{ prop.description || '—' }}
|
||||
<DocsText v-if="prop.description" :text="prop.description" />
|
||||
<span v-else>—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
// Renders a short description with inline markdown (bold / `code` / links /
|
||||
// {@link}). Content is authored by us (JSDoc), so v-html is safe here.
|
||||
const props = defineProps<{ text?: string | null }>();
|
||||
|
||||
const html = computed(() => renderInline(props.text ?? ''));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="docs-text" v-html="html" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.docs-text :deep(code) {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.9em;
|
||||
background: var(--bg-inset);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.05em 0.3em;
|
||||
}
|
||||
|
||||
.docs-text :deep(a) {
|
||||
color: var(--accent-text);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.docs-text :deep(strong) {
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,9 @@
|
||||
import { marked } from 'marked';
|
||||
|
||||
// JSDoc `{@link Symbol}` / `{@link Symbol|label}`. The capture starts with a
|
||||
// non-space char so the leading `\s+` can't overlap it (no super-linear backtracking).
|
||||
const JSDOC_LINK = /\{@link\s+([^\s}|][^}|]*)(?:\|[^}]+)?\}/g;
|
||||
|
||||
export interface Heading {
|
||||
depth: number;
|
||||
text: string;
|
||||
@@ -46,6 +50,17 @@ export function extractHeadings(markdown: string): Heading[] {
|
||||
return headings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a short description as INLINE HTML (bold/code/links, no block wrapping).
|
||||
* Used for API/param/property descriptions, which are authored as one-line
|
||||
* markdown with the occasional JSDoc `{@link X}` (shown as inline code).
|
||||
*/
|
||||
export function renderInline(text: string): string {
|
||||
if (!text) return '';
|
||||
const withLinks = text.replaceAll(JSDOC_LINK, (_m, name: string) => `\`${name.trim()}\``);
|
||||
return marked.parseInline(withLinks, { async: false }) as string;
|
||||
}
|
||||
|
||||
/** Render markdown to HTML with stable heading ids (matching extractHeadings). */
|
||||
export function renderMarkdown(markdown: string): string {
|
||||
const seen = new Map<string, number>();
|
||||
|
||||
@@ -118,10 +118,17 @@ const sectionTitle = 'text-xs font-semibold uppercase tracking-wider text-(--fg-
|
||||
<DocsBadge :kind="entry.item.kind" size="md" />
|
||||
<h1 class="min-w-0 break-words text-2xl font-bold font-mono tracking-tight text-(--fg)">{{ entry.item.name }}</h1>
|
||||
<DocsTag v-if="entry.item.since" :label="`v${entry.item.since}`" variant="neutral" />
|
||||
<DocsTag v-if="entry.item.hasTests" label="tested" variant="test" />
|
||||
<DocsTag
|
||||
v-if="entry.item.hasTests"
|
||||
:label="typeof entry.item.coverage === 'number' ? `tested · ${entry.item.coverage}%` : 'tested'"
|
||||
variant="test"
|
||||
:title="typeof entry.item.coverage === 'number' ? `${entry.item.coverage}% statement coverage` : undefined"
|
||||
/>
|
||||
<DocsTag v-if="entry.item.hasDemo" label="demo" variant="demo" />
|
||||
</div>
|
||||
<p v-if="entry.item.description" class="text-(--fg-muted) text-[15px] leading-relaxed">{{ entry.item.description }}</p>
|
||||
<p v-if="entry.item.description" class="text-(--fg-muted) text-[15px] leading-relaxed">
|
||||
<DocsText :text="entry.item.description" />
|
||||
</p>
|
||||
<div class="flex items-center gap-4 mt-4 text-sm">
|
||||
<a :href="ghUrl(entry.item.sourcePath)" target="_blank" rel="noopener noreferrer" class="flex items-center gap-1.5 text-(--fg-subtle) hover:text-(--fg) transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" /><path d="M9 18c-4.51 2-5-2-7-2" /></svg>
|
||||
@@ -143,7 +150,7 @@ const sectionTitle = 'text-xs font-semibold uppercase tracking-wider text-(--fg-
|
||||
|
||||
<section v-if="entry.item.hasDemo && demoComponent" id="demo" class="mb-8 scroll-mt-20">
|
||||
<h2 :class="sectionTitle">Demo</h2>
|
||||
<DocsDemo :component="demoComponent" :source="entry.item.demoSource" />
|
||||
<DocsDemo :component="demoComponent" :source-key="`${packageSlug}/${utilitySlug}`" />
|
||||
</section>
|
||||
|
||||
<section v-if="entry.item.signatures.length" id="signature" class="mb-8 scroll-mt-20">
|
||||
@@ -171,10 +178,11 @@ const sectionTitle = 'text-xs font-semibold uppercase tracking-wider text-(--fg-
|
||||
|
||||
<section v-if="entry.item.returns" id="returns" class="mb-8 scroll-mt-20">
|
||||
<h2 :class="sectionTitle">Returns</h2>
|
||||
<div class="flex items-baseline gap-2 text-sm flex-wrap">
|
||||
<div class="flex items-baseline gap-2 text-sm flex-wrap" :class="entry.item.returns.properties?.length ? 'mb-3' : ''">
|
||||
<code class="font-mono bg-(--bg-inset) border border-(--border) px-2 py-1 rounded text-xs wrap-break-word">{{ entry.item.returns.type }}</code>
|
||||
<span v-if="entry.item.returns.description" class="text-(--fg-muted)">{{ entry.item.returns.description }}</span>
|
||||
<DocsText v-if="entry.item.returns.description" :text="entry.item.returns.description" class="text-(--fg-muted)" />
|
||||
</div>
|
||||
<DocsPropsTable v-if="entry.item.returns.properties?.length" :properties="entry.item.returns.properties" />
|
||||
</section>
|
||||
|
||||
<section v-if="entry.item.properties.length" id="properties" class="mb-8 scroll-mt-20">
|
||||
@@ -195,7 +203,9 @@ const sectionTitle = 'text-xs font-semibold uppercase tracking-wider text-(--fg-
|
||||
<DocsBadge :kind="rt.kind" size="sm" />
|
||||
<h3 class="font-mono font-semibold text-sm text-(--fg)">{{ rt.name }}</h3>
|
||||
</div>
|
||||
<p v-if="rt.description" class="text-sm text-(--fg-muted) mb-3">{{ rt.description }}</p>
|
||||
<p v-if="rt.description" class="text-sm text-(--fg-muted) mb-3">
|
||||
<DocsText :text="rt.description" />
|
||||
</p>
|
||||
<DocsCode v-if="rt.signatures.length" :code="rt.signatures[0]!" />
|
||||
<DocsPropsTable v-if="rt.properties.length" :properties="rt.properties" class="mt-3" />
|
||||
</div>
|
||||
@@ -211,7 +221,9 @@ const sectionTitle = 'text-xs font-semibold uppercase tracking-wider text-(--fg-
|
||||
<h1 class="text-2xl font-bold tracking-tight text-(--fg)">{{ entry.component.name }}</h1>
|
||||
<DocsTag :label="`${entry.component.parts.length} parts`" variant="neutral" />
|
||||
</div>
|
||||
<p v-if="entry.component.description" class="text-(--fg-muted) text-[15px] leading-relaxed">{{ entry.component.description }}</p>
|
||||
<p v-if="entry.component.description" class="text-(--fg-muted) text-[15px] leading-relaxed">
|
||||
<DocsText :text="entry.component.description" />
|
||||
</p>
|
||||
<div class="flex items-center gap-4 mt-4 text-sm">
|
||||
<a :href="ghUrl(entry.component.sourcePath)" target="_blank" rel="noopener noreferrer" class="flex items-center gap-1.5 text-(--fg-subtle) hover:text-(--fg) transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" /><path d="M9 18c-4.51 2-5-2-7-2" /></svg>
|
||||
@@ -222,7 +234,7 @@ const sectionTitle = 'text-xs font-semibold uppercase tracking-wider text-(--fg-
|
||||
|
||||
<section v-if="entry.component.hasDemo && demoComponent" class="mb-10">
|
||||
<h2 :class="sectionTitle">Demo</h2>
|
||||
<DocsDemo :component="demoComponent" :source="entry.component.demoSource" />
|
||||
<DocsDemo :component="demoComponent" :source-key="`${packageSlug}/${utilitySlug}`" />
|
||||
</section>
|
||||
|
||||
<DocsComponentAnatomy :component="entry.component" :package-name="pkg.name" />
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
|
||||
import { basename, dirname, relative, resolve } from 'node:path';
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import { Project } from 'ts-morph';
|
||||
import type { ClassDeclaration, FunctionDeclaration, InterfaceDeclaration, JSDoc, JSDocTag, MethodDeclaration, PropertyDeclaration, PropertySignature, SourceFile, TypeAliasDeclaration } from 'ts-morph';
|
||||
import { Node, Project, SyntaxKind } from 'ts-morph';
|
||||
import type { ClassDeclaration, FunctionDeclaration, InterfaceDeclaration, JSDoc, JSDocTag, MethodDeclaration, PropertyDeclaration, PropertySignature, SourceFile, TypeAliasDeclaration, VariableDeclaration } from 'ts-morph';
|
||||
import type {
|
||||
CategoryMeta,
|
||||
ComponentMeta,
|
||||
@@ -36,6 +36,34 @@ import type {
|
||||
/** Repository root — docs/modules/extractor → three levels up */
|
||||
const ROOT = resolve(import.meta.dirname, '..', '..', '..');
|
||||
|
||||
/**
|
||||
* Statement-coverage percentage per source file (repo-relative path), parsed
|
||||
* from Istanbul's `coverage/coverage-final.json` if present. Empty when coverage
|
||||
* hasn't been generated — items then simply omit the coverage badge.
|
||||
*/
|
||||
function loadCoverage(): Map<string, number> {
|
||||
const map = new Map<string, number>();
|
||||
const file = resolve(ROOT, 'coverage', 'coverage-final.json');
|
||||
if (!existsSync(file)) return map;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(file, 'utf-8')) as Record<string, { s?: Record<string, number> }>;
|
||||
for (const [absPath, entry] of Object.entries(data)) {
|
||||
const counts = Object.values(entry.s ?? {});
|
||||
if (counts.length === 0) continue;
|
||||
const covered = counts.filter(c => c > 0).length;
|
||||
map.set(relative(ROOT, absPath), Math.round((covered / counts.length) * 100));
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Malformed/partial coverage file — skip rather than fail extraction.
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
const COVERAGE = loadCoverage();
|
||||
|
||||
interface PackageConfig {
|
||||
/** Path relative to repo root */
|
||||
path: string;
|
||||
@@ -83,6 +111,18 @@ function slugify(name: string): string {
|
||||
return toKebabCase(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean a type string for display: drop the `import("…").` qualifiers the type
|
||||
* checker emits when resolving types (e.g. `import("vue").Ref<T>` → `Ref<T>`) and
|
||||
* collapse whitespace. Prefer this over raw `.getType().getText()`.
|
||||
*/
|
||||
function cleanType(text: string): string {
|
||||
return text
|
||||
.replaceAll(/import\((?:"[^"]*"|'[^']*')\)\./g, '')
|
||||
.replaceAll(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function toPascalCase(slug: string): string {
|
||||
return slug
|
||||
.split(/[-_]/)
|
||||
@@ -118,8 +158,17 @@ function getExamples(tags: JSDocTag[]): string[] {
|
||||
return tags
|
||||
.filter(t => t.getTagName() === 'example')
|
||||
.map((t) => {
|
||||
const text = t.getCommentText()?.trim() ?? '';
|
||||
return text.replace(/^```(?:ts|typescript)?\n?/, '').replace(/\n?```$/, '').trim();
|
||||
let text = t.getCommentText()?.trim() ?? '';
|
||||
// A leading `<caption>…</caption>` (JSDoc example title) isn't valid code —
|
||||
// turn it into a leading comment so the snippet stays clean & highlightable.
|
||||
let caption = '';
|
||||
const cap = text.match(/^<caption>([\s\S]*?)<\/caption>\s*/i);
|
||||
if (cap) {
|
||||
caption = cap[1]!.trim();
|
||||
text = text.slice(cap[0].length);
|
||||
}
|
||||
text = text.replace(/^```(?:ts|typescript|vue|js|javascript)?\n?/, '').replace(/\n?```$/, '').trim();
|
||||
return caption ? `// ${caption}\n${text}` : text;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
@@ -130,7 +179,9 @@ function extractParams(tags: JSDocTag[], node: FunctionDeclaration | MethodDecla
|
||||
|
||||
for (const param of node.getParameters()) {
|
||||
const name = param.getName();
|
||||
const type = param.getType().getText(param);
|
||||
// Prefer the written annotation (`MaybeRefOrGetter<T>`) over the resolved
|
||||
// type, which expands aliases into noise (`T | import("vue").Ref<T> | …`).
|
||||
const type = cleanType(param.getTypeNode()?.getText() ?? param.getType().getText(param));
|
||||
const optional = param.isOptional();
|
||||
const defaultValue = param.getInitializer()?.getText() ?? null;
|
||||
|
||||
@@ -156,20 +207,75 @@ function extractParams(tags: JSDocTag[], node: FunctionDeclaration | MethodDecla
|
||||
function extractTypeParams(node: FunctionDeclaration | ClassDeclaration | InterfaceDeclaration | TypeAliasDeclaration): TypeParamMeta[] {
|
||||
return node.getTypeParameters().map(tp => ({
|
||||
name: tp.getName(),
|
||||
constraint: tp.getConstraint()?.getText() ?? null,
|
||||
default: tp.getDefault()?.getText() ?? null,
|
||||
constraint: tp.getConstraint() ? cleanType(tp.getConstraint()!.getText()) : null,
|
||||
default: tp.getDefault() ? cleanType(tp.getDefault()!.getText()) : null,
|
||||
description: '',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* When a function returns a plain object — a named interface (`UseXReturn`) OR an
|
||||
* inline object literal (`{ first: HTMLElement | undefined; last: … }`) — expand
|
||||
* its properties so the renderer shows a Name/Type/Description table. Skips
|
||||
* unions/intersections, arrays/tuples, callable (function) types, primitives, and
|
||||
* built-ins (`Ref`/`ComputedRef`/`Promise`/`Map`… whose declaration is in
|
||||
* node_modules) — those keep just the type string.
|
||||
*/
|
||||
function extractReturnProperties(node: FunctionDeclaration | MethodDeclaration): PropertyMeta[] {
|
||||
const returnType = node.getReturnType();
|
||||
|
||||
if (
|
||||
returnType.isUnion()
|
||||
|| returnType.isIntersection()
|
||||
|| returnType.isArray()
|
||||
|| returnType.isTuple()
|
||||
|| returnType.getCallSignatures().length > 0
|
||||
|| !returnType.isObject()
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// A named declaration in node_modules (Ref/Promise/Map…) is a built-in we don't
|
||||
// expand; anonymous object literals have no such declaration → keep going.
|
||||
const symbol = returnType.getAliasSymbol() ?? returnType.getSymbol();
|
||||
const decl = symbol?.getDeclarations()?.[0];
|
||||
if (decl && decl.getSourceFile().isInNodeModules())
|
||||
return [];
|
||||
|
||||
const props: PropertyMeta[] = [];
|
||||
for (const prop of returnType.getProperties()) {
|
||||
const propDecl = prop.getDeclarations()?.[0];
|
||||
if (!propDecl || propDecl.getSourceFile().isInNodeModules())
|
||||
continue;
|
||||
|
||||
// Prefer the written annotation (clean); fall back to the resolved type for
|
||||
// method-style members and inferred object-literal returns.
|
||||
const typeNode = Node.isTyped(propDecl) ? propDecl.getTypeNode() : undefined;
|
||||
const jsdocs = Node.isJSDocable(propDecl) ? propDecl.getJsDocs() : [];
|
||||
|
||||
props.push({
|
||||
name: prop.getName(),
|
||||
type: cleanType(typeNode?.getText() ?? prop.getTypeAtLocation(node).getText(node)),
|
||||
description: getDescription(jsdocs, getJsDocTags(jsdocs)),
|
||||
optional: Node.isQuestionTokenable(propDecl) && propDecl.hasQuestionToken(),
|
||||
defaultValue: null,
|
||||
readonly: false,
|
||||
});
|
||||
}
|
||||
|
||||
return props;
|
||||
}
|
||||
|
||||
function extractReturnMeta(tags: JSDocTag[], node: FunctionDeclaration | MethodDeclaration): ReturnMeta | null {
|
||||
const returnType = node.getReturnType().getText(node);
|
||||
const returnType = cleanType(node.getReturnTypeNode()?.getText() ?? node.getReturnType().getText(node));
|
||||
if (returnType === 'void') return null;
|
||||
|
||||
const returnsTag = getTagValue(tags, 'returns') || getTagValue(tags, 'return');
|
||||
const description = returnsTag.replace(/^\{[^}]*\}\s*/, '').trim();
|
||||
|
||||
return { type: returnType, description };
|
||||
const properties = extractReturnProperties(node);
|
||||
|
||||
return { type: returnType, description, properties };
|
||||
}
|
||||
|
||||
function extractMethodMeta(method: MethodDeclaration): MethodMeta {
|
||||
@@ -192,7 +298,7 @@ function extractPropertyMeta(prop: PropertyDeclaration | PropertySignature): Pro
|
||||
|
||||
return {
|
||||
name: prop.getName(),
|
||||
type: prop.getType().getText(prop),
|
||||
type: cleanType(prop.getTypeNode?.()?.getText() ?? prop.getType().getText(prop)),
|
||||
description: getDescription(jsdocs, tags),
|
||||
optional: prop.hasQuestionToken?.() ?? false,
|
||||
defaultValue: getTagValue(tags, 'default') || null,
|
||||
@@ -208,10 +314,11 @@ function hasDemoFile(sourceFilePath: string): boolean {
|
||||
return existsSync(resolve(getSourceDir(sourceFilePath), 'demo.vue'));
|
||||
}
|
||||
|
||||
function readDemoSource(sourceFilePath: string): string {
|
||||
const demoPath = resolve(getSourceDir(sourceFilePath), 'demo.vue');
|
||||
if (!existsSync(demoPath)) return '';
|
||||
return readFileSync(demoPath, 'utf-8');
|
||||
// Demo SOURCE is loaded lazily on the client (via `#docs/demo-sources`) only when
|
||||
// "View source" is opened, so it is intentionally NOT embedded in the metadata
|
||||
// payload (it was ~850KB). `hasDemo`/the lazy map carry what the UI needs.
|
||||
function readDemoSource(_sourceFilePath: string): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
function hasTestFile(sourceFilePath: string): boolean {
|
||||
@@ -274,7 +381,7 @@ function extractClass(cls: ClassDeclaration, sourceFilePath: string, entryPoint:
|
||||
.filter(g => (g.getScope() ?? 'public') === 'public')
|
||||
.map(g => ({
|
||||
name: g.getName(),
|
||||
type: g.getReturnType().getText(g),
|
||||
type: cleanType(g.getReturnTypeNode()?.getText() ?? g.getReturnType().getText(g)),
|
||||
description: getDescription(g.getJsDocs(), getJsDocTags(g.getJsDocs())),
|
||||
optional: false,
|
||||
defaultValue: null,
|
||||
@@ -377,6 +484,43 @@ function extractTypeAlias(typeAlias: TypeAliasDeclaration, sourceFilePath: strin
|
||||
};
|
||||
}
|
||||
|
||||
function extractVariable(
|
||||
decl: VariableDeclaration,
|
||||
jsdocs: JSDoc[],
|
||||
tags: JSDocTag[],
|
||||
sourceFilePath: string,
|
||||
entryPoint: string,
|
||||
): ItemMeta | null {
|
||||
const name = decl.getName();
|
||||
if (!name || name.startsWith('_')) return null;
|
||||
|
||||
const typeText = cleanType(decl.getTypeNode()?.getText() ?? decl.getType().getText(decl));
|
||||
const keyword = decl.getVariableStatement()?.getDeclarationKind() ?? 'const';
|
||||
// Show the declaration shape, not the (potentially huge) initializer value.
|
||||
const signature = `${keyword} ${name}: ${typeText}`;
|
||||
|
||||
return {
|
||||
name,
|
||||
slug: slugify(name),
|
||||
kind: 'variable',
|
||||
description: getDescription(jsdocs, tags),
|
||||
since: getTagValue(tags, 'since'),
|
||||
signatures: [signature],
|
||||
params: [],
|
||||
returns: null,
|
||||
typeParams: [],
|
||||
examples: getExamples(tags),
|
||||
methods: [],
|
||||
properties: [],
|
||||
hasDemo: hasDemoFile(sourceFilePath),
|
||||
demoSource: readDemoSource(sourceFilePath),
|
||||
hasTests: hasTestFile(sourceFilePath),
|
||||
relatedTypes: [],
|
||||
sourcePath: relative(ROOT, sourceFilePath),
|
||||
entryPoint,
|
||||
};
|
||||
}
|
||||
|
||||
function collectExportedItems(sourceFile: SourceFile, entryPoint: string, visited = new Set<string>()): ItemMeta[] {
|
||||
const filePath = sourceFile.getFilePath();
|
||||
if (visited.has(filePath)) return [];
|
||||
@@ -448,6 +592,21 @@ function collectExportedItems(sourceFile: SourceFile, entryPoint: string, visite
|
||||
if (item) items.push(item);
|
||||
}
|
||||
|
||||
for (const varStatement of sourceFile.getVariableStatements()) {
|
||||
if (!varStatement.isExported()) continue;
|
||||
const jsdocs = varStatement.getJsDocs();
|
||||
const tags = getJsDocTags(jsdocs);
|
||||
// Gate (like types/interfaces): only documented consts, so we don't surface
|
||||
// every internal constant — desirable but not always.
|
||||
const hasCategory = getTagValue(tags, 'category') !== '';
|
||||
if (!hasCategory && jsdocs.length === 0) continue;
|
||||
|
||||
for (const decl of varStatement.getDeclarations()) {
|
||||
const item = extractVariable(decl, jsdocs, tags, filePath, entryPoint);
|
||||
if (item) items.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
for (const exportDecl of sourceFile.getExportDeclarations()) {
|
||||
if (!exportDecl.getModuleSpecifierValue()) continue;
|
||||
const referencedFile = exportDecl.getModuleSpecifierSourceFile();
|
||||
@@ -461,13 +620,35 @@ function collectExportedItems(sourceFile: SourceFile, entryPoint: string, visite
|
||||
* Groups types/interfaces from `types.ts` files with their sibling
|
||||
* class/function items from the same directory as `relatedTypes`.
|
||||
*/
|
||||
/**
|
||||
* A trimmed copy of a type/interface for embedding as a primary's `relatedType`:
|
||||
* keeps the shape (signature/properties/description) but drops the heavy fields
|
||||
* (demo source, examples, nested types, params/returns) that would otherwise be
|
||||
* duplicated into the metadata payload.
|
||||
*/
|
||||
function slimRelatedType(type: ItemMeta): ItemMeta {
|
||||
return {
|
||||
...type,
|
||||
examples: [],
|
||||
params: [],
|
||||
returns: null,
|
||||
methods: [],
|
||||
relatedTypes: [],
|
||||
hasDemo: false,
|
||||
demoSource: '',
|
||||
};
|
||||
}
|
||||
|
||||
function groupCoLocatedTypes(items: ItemMeta[]): ItemMeta[] {
|
||||
const typesByDir = new Map<string, ItemMeta[]>();
|
||||
const primaryByDir = new Map<string, ItemMeta[]>();
|
||||
|
||||
for (const item of items) {
|
||||
const dir = dirname(item.sourcePath);
|
||||
const isSecondary = item.sourcePath.endsWith('/types.ts') && (item.kind === 'type' || item.kind === 'interface');
|
||||
// Types/interfaces are documentation-secondary: when a function/class lives
|
||||
// in the same directory they fold into it as `relatedTypes` instead of
|
||||
// competing as standalone pages (keeps the reference to the important items).
|
||||
const isSecondary = item.kind === 'type' || item.kind === 'interface';
|
||||
|
||||
const target = isSecondary ? typesByDir : primaryByDir;
|
||||
const existing = target.get(dir) ?? [];
|
||||
@@ -479,8 +660,24 @@ function groupCoLocatedTypes(items: ItemMeta[]): ItemMeta[] {
|
||||
for (const [dir, types] of Array.from(typesByDir.entries())) {
|
||||
const primaries = primaryByDir.get(dir);
|
||||
if (!primaries || primaries.length === 0) continue;
|
||||
for (const primary of primaries) primary.relatedTypes = [...types];
|
||||
for (const t of types) absorbed.add(`${t.entryPoint}:${t.name}`);
|
||||
|
||||
for (const type of types) {
|
||||
// Attach each type to the SINGLE most-relevant primary (longest name-prefix
|
||||
// match, else the first) — never every primary — so it isn't duplicated N×,
|
||||
// and store a slim copy (no demo source / nested types).
|
||||
const typeName = type.name.toLowerCase();
|
||||
let owner = primaries[0]!;
|
||||
let bestLen = -1;
|
||||
for (const primary of primaries) {
|
||||
const primaryName = primary.name.toLowerCase();
|
||||
if (typeName.startsWith(primaryName) && primaryName.length > bestLen) {
|
||||
owner = primary;
|
||||
bestLen = primaryName.length;
|
||||
}
|
||||
}
|
||||
owner.relatedTypes.push(slimRelatedType(type));
|
||||
absorbed.add(`${type.entryPoint}:${type.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
return items.filter(item => !absorbed.has(`${item.entryPoint}:${item.name}`));
|
||||
@@ -571,6 +768,28 @@ function buildApiCategories(pkgDir: string): CategoryMeta[] {
|
||||
|
||||
const groupedItems = groupCoLocatedTypes(uniqueItems);
|
||||
|
||||
// Per-package slug uniqueness — the [package]/[utility] route keys on slug, so
|
||||
// a function `foo` and interface `Foo` (same kebab slug) would otherwise clash.
|
||||
// Functions/classes keep the base slug; lower-priority kinds get suffixed.
|
||||
const KIND_PRIORITY: Record<string, number> = { function: 0, class: 1, variable: 2, enum: 3, interface: 4, type: 5 };
|
||||
const usedSlugs = new Set<string>();
|
||||
for (const item of [...groupedItems].sort((a, b) => (KIND_PRIORITY[a.kind] ?? 9) - (KIND_PRIORITY[b.kind] ?? 9))) {
|
||||
if (!usedSlugs.has(item.slug)) {
|
||||
usedSlugs.add(item.slug);
|
||||
continue;
|
||||
}
|
||||
let candidate = `${item.slug}-${item.kind}`;
|
||||
let n = 2;
|
||||
while (usedSlugs.has(candidate))
|
||||
candidate = `${item.slug}-${item.kind}-${n++}`;
|
||||
item.slug = candidate;
|
||||
usedSlugs.add(candidate);
|
||||
}
|
||||
|
||||
// Attach statement-coverage % (when coverage data exists) for the test badge.
|
||||
for (const item of groupedItems)
|
||||
item.coverage = COVERAGE.get(item.sourcePath) ?? null;
|
||||
|
||||
const categoryMap = new Map<string, ItemMeta[]>();
|
||||
for (const item of groupedItems) {
|
||||
const cat = inferCategoryFromItem(item);
|
||||
@@ -621,6 +840,43 @@ function extractEmits(setupScript: string): EmitMeta[] {
|
||||
|
||||
let partProjectCounter = 0;
|
||||
|
||||
/**
|
||||
* Parse `defineModel(...)` calls from a setup block into the v-model prop(s) +
|
||||
* their `update:*` emit(s) — these don't appear in the `XxxProps` interface or
|
||||
* `defineEmits`, so without this the controlled v-model API is invisible in docs.
|
||||
*/
|
||||
function extractModels(setupScript: string): { props: PropertyMeta[]; emits: EmitMeta[] } {
|
||||
const props: PropertyMeta[] = [];
|
||||
const emits: EmitMeta[] = [];
|
||||
if (!setupScript.includes('defineModel')) return { props, emits };
|
||||
|
||||
const project = new Project({ useInMemoryFileSystem: true, skipAddingFilesFromTsConfig: true, compilerOptions: { allowJs: true, skipLibCheck: true } });
|
||||
const sf = project.createSourceFile(`__model_${partProjectCounter++}.ts`, setupScript);
|
||||
|
||||
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
||||
if (call.getExpression().getText() !== 'defineModel') continue;
|
||||
|
||||
const typeArg = call.getTypeArguments()[0];
|
||||
const type = typeArg ? cleanType(typeArg.getText()) : 'unknown';
|
||||
const firstArg = call.getArguments()[0];
|
||||
const name = firstArg && Node.isStringLiteral(firstArg) ? firstArg.getLiteralValue() : 'modelValue';
|
||||
|
||||
props.push({
|
||||
name,
|
||||
type,
|
||||
description: name === 'modelValue'
|
||||
? 'Two-way bound value (`v-model`).'
|
||||
: `Two-way bound value (\`v-model:${name}\`).`,
|
||||
optional: true,
|
||||
defaultValue: null,
|
||||
readonly: false,
|
||||
});
|
||||
emits.push({ name: `update:${name}`, payload: `[value: ${type}]`, description: '' });
|
||||
}
|
||||
|
||||
return { props, emits };
|
||||
}
|
||||
|
||||
/** Parse the `XxxProps` interface from a `.vue` part using ts-morph in-memory. */
|
||||
function extractPartProps(plainScript: string): { props: PropertyMeta[]; description: string } {
|
||||
if (!plainScript.trim()) return { props: [], description: '' };
|
||||
@@ -688,12 +944,19 @@ function buildComponents(pkgDir: string): ComponentMeta[] {
|
||||
const slug = entry.name;
|
||||
const base = toPascalCase(slug);
|
||||
|
||||
// Preserve the anatomy order declared in index.ts; fall back to filenames.
|
||||
// Anatomy = the PUBLIC parts exported from index.ts, in declared order. This
|
||||
// excludes demo.vue and internal parts (*Impl, *Modal/NonModal, *Position, …)
|
||||
// that aren't part of the public API. Fall back to all .vue (minus demo) only
|
||||
// when the barrel exposes no parseable `export { default as X }`.
|
||||
const order = readPartOrder(resolve(dir, 'index.ts'));
|
||||
const orderedFiles = [
|
||||
...order.map(name => `${name}.vue`).filter(f => vueFiles.includes(f)),
|
||||
...vueFiles.filter(f => !order.includes(f.replace(/\.vue$/, ''))),
|
||||
];
|
||||
const publicFiles = order.map(name => `${name}.vue`).filter(f => vueFiles.includes(f));
|
||||
const candidates = publicFiles.length > 0
|
||||
? publicFiles
|
||||
: vueFiles.filter(f => f !== 'demo.vue');
|
||||
// Drop internal implementation/variant parts users never compose directly
|
||||
// (the public part is e.g. `Content`, not `ContentImpl`/`ContentModal`).
|
||||
const INTERNAL_PART = /(?:Impl|ContentModal|ContentNonModal|RootContentModal|RootContentNonModal|Position)\.vue$/;
|
||||
const orderedFiles = candidates.filter(f => !INTERNAL_PART.test(f));
|
||||
|
||||
const parts: ComponentPartMeta[] = [];
|
||||
let groupDescription = '';
|
||||
@@ -706,7 +969,17 @@ function buildComponents(pkgDir: string): ComponentMeta[] {
|
||||
const name = file.replace(/\.vue$/, '');
|
||||
const role = roleFromName(name, base);
|
||||
if (role === 'Root' && description && !groupDescription) groupDescription = description;
|
||||
parts.push({ name, role, description, props, emits: extractEmits(setup) });
|
||||
|
||||
// Merge in `defineModel` v-model props/emits (invisible to the interface/
|
||||
// defineEmits parsers), de-duping against any explicitly-declared ones.
|
||||
const models = extractModels(setup);
|
||||
const emits = extractEmits(setup);
|
||||
for (const mp of models.props)
|
||||
if (!props.some(p => p.name === mp.name)) props.push(mp);
|
||||
for (const me of models.emits)
|
||||
if (!emits.some(e => e.name === me.name)) emits.push(me);
|
||||
|
||||
parts.push({ name, role, description, props, emits });
|
||||
}
|
||||
|
||||
const entryPoint = `./${slug}`;
|
||||
@@ -720,7 +993,7 @@ function buildComponents(pkgDir: string): ComponentMeta[] {
|
||||
entryPoint,
|
||||
parts,
|
||||
hasDemo,
|
||||
demoSource: hasDemo ? readFileSync(demoPath, 'utf-8') : '',
|
||||
demoSource: '', // loaded lazily client-side via #docs/demo-sources
|
||||
sourcePath: relative(ROOT, dir),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -50,6 +50,16 @@ export default defineNuxtModule({
|
||||
};
|
||||
|
||||
nuxt.hook('vite:extendConfig', (config) => {
|
||||
// Workspace SOURCE (e.g. @robonen/primitives) references the `__DEV__`
|
||||
// compile-time flag (each package defines it in its own vitest/tsdown
|
||||
// config). The docs bundle consumes that source directly via the aliases
|
||||
// below, so it must define `__DEV__` too — otherwise it throws
|
||||
// "ReferenceError: __DEV__ is not defined" at runtime (e.g. in the
|
||||
// Primitive `as="template"` / Slot path), silently blanking every demo
|
||||
// that hits it. `import.meta.env.DEV` resolves correctly in dev & prod.
|
||||
config.define ??= {};
|
||||
(config.define as Record<string, unknown>).__DEV__ ??= 'import.meta.env.DEV';
|
||||
|
||||
const existing = config.resolve?.alias;
|
||||
const sourceAliases = [
|
||||
{ find: '@/composables', replacement: resolve(vueSrc, 'composables') },
|
||||
@@ -75,8 +85,9 @@ export default defineNuxtModule({
|
||||
filename: 'docs-metadata.ts',
|
||||
write: true,
|
||||
getContents: () => {
|
||||
const json = JSON.stringify(metadata, null, 2);
|
||||
return `export default ${json} as const;`;
|
||||
// No indentation (smaller module) and no `as const` — a multi-MB literal
|
||||
// type is pathological for tsc, and consumers cast to DocsMetadata anyway.
|
||||
return `export default ${JSON.stringify(metadata)};`;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -204,6 +215,50 @@ declare module '#docs/demos' {
|
||||
`,
|
||||
});
|
||||
|
||||
// Lazy demo SOURCE loaders (raw text) — kept out of the metadata payload and
|
||||
// fetched only when a user opens "View source", so the ~850KB of demo source
|
||||
// never ships in the always-loaded metadata bundle.
|
||||
addTemplate({
|
||||
filename: 'docs-demo-sources.ts',
|
||||
write: true,
|
||||
getContents: () => {
|
||||
const entries: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (key: string, demoPath: string) => {
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
entries.push(` '${key}': () => import('${demoPath}?raw').then(m => m.default),`);
|
||||
};
|
||||
|
||||
for (const pkg of metadata.packages) {
|
||||
for (const cat of pkg.categories)
|
||||
for (const item of cat.items)
|
||||
if (item.hasDemo) add(`${pkg.slug}/${item.slug}`, resolve(ROOT, dirname(item.sourcePath), 'demo.vue'));
|
||||
for (const component of pkg.components)
|
||||
if (component.hasDemo) add(`${pkg.slug}/${component.slug}`, resolve(ROOT, component.sourcePath, 'demo.vue'));
|
||||
}
|
||||
|
||||
return [
|
||||
`export const demoSources: Record<string, () => Promise<string>> = {`,
|
||||
...entries,
|
||||
`};`,
|
||||
``,
|
||||
].join('\n');
|
||||
},
|
||||
});
|
||||
|
||||
nuxt.options.alias['#docs/demo-sources'] = resolve(nuxt.options.buildDir, 'docs-demo-sources');
|
||||
|
||||
addTemplate({
|
||||
filename: 'docs-demo-sources-types.d.ts',
|
||||
write: true,
|
||||
getContents: () => `
|
||||
declare module '#docs/demo-sources' {
|
||||
export const demoSources: Record<string, () => Promise<string>>;
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
// Generate hand-authored doc-section import map (`<pkg>/docs/*.vue`)
|
||||
addTemplate({
|
||||
filename: 'docs-sections.ts',
|
||||
|
||||
@@ -98,6 +98,8 @@ export interface ItemMeta {
|
||||
demoSource: string;
|
||||
/** Whether an index.test.ts file exists alongside */
|
||||
hasTests: boolean;
|
||||
/** Statement-coverage percentage for the source file, if coverage data exists */
|
||||
coverage?: number | null;
|
||||
/** Related types/interfaces co-located in the same module directory */
|
||||
relatedTypes: ItemMeta[];
|
||||
/** Relative path to the source file from repo root */
|
||||
@@ -188,6 +190,11 @@ export interface ParamMeta {
|
||||
export interface ReturnMeta {
|
||||
type: string;
|
||||
description: string;
|
||||
/**
|
||||
* Properties of the returned object, when the return type is one of the
|
||||
* package's own interfaces — rendered as a table like parameters.
|
||||
*/
|
||||
properties?: PropertyMeta[];
|
||||
}
|
||||
|
||||
export interface TypeParamMeta {
|
||||
|
||||
Generated
+6
@@ -201,6 +201,9 @@ importers:
|
||||
|
||||
core/platform:
|
||||
devDependencies:
|
||||
'@robonen/encoding':
|
||||
specifier: workspace:*
|
||||
version: link:../encoding
|
||||
'@robonen/eslint':
|
||||
specifier: workspace:*
|
||||
version: link:../../configs/eslint
|
||||
@@ -384,6 +387,9 @@ importers:
|
||||
'@floating-ui/vue':
|
||||
specifier: ^1.1.11
|
||||
version: 1.1.11(vue@3.5.35(typescript@6.0.3))
|
||||
'@robonen/encoding':
|
||||
specifier: workspace:*
|
||||
version: link:../../core/encoding
|
||||
'@robonen/platform':
|
||||
specifier: workspace:*
|
||||
version: link:../../core/platform
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/vue": "^1.1.11",
|
||||
"@robonen/encoding": "workspace:*",
|
||||
"@robonen/platform": "workspace:*",
|
||||
"@robonen/stdlib": "workspace:*",
|
||||
"@robonen/vue": "workspace:*",
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
/**
|
||||
* The collapsible panel revealed when its item is open. Rendered as an ARIA
|
||||
* `region` labelled by its trigger and mounted/unmounted via `Presence` so
|
||||
* enter/leave transitions can run (use `forceMount` to keep it mounted for
|
||||
* custom animation).
|
||||
*/
|
||||
export interface AccordionContentProps extends PrimitiveProps {
|
||||
/** Keep content mounted even when closed. */
|
||||
forceMount?: boolean;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
/**
|
||||
* A single collapsible section of the accordion, grouping one trigger with
|
||||
* its content. Identified by a unique `value` that the root uses to track
|
||||
* open state; provides item-level context (open, disabled, ids) to its
|
||||
* `AccordionTrigger` and `AccordionContent`.
|
||||
*/
|
||||
export interface AccordionItemProps extends PrimitiveProps {
|
||||
/** Unique value for this item. */
|
||||
value: string;
|
||||
|
||||
@@ -2,10 +2,16 @@
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
import type { RovingDirection } from '../utils/roving-focus';
|
||||
|
||||
/**
|
||||
* A vertically (or horizontally) stacked set of headers that each reveal an
|
||||
* associated panel of content. Use it to let users expand and collapse
|
||||
* sections to manage information density — FAQs, settings groups, or any
|
||||
* place a `Collapsible` per item would be repetitive.
|
||||
*
|
||||
* The root owns open state (single or multiple panels), keyboard roving
|
||||
* focus across triggers, and provides context to every `AccordionItem`.
|
||||
*/
|
||||
export interface AccordionRootProps extends PrimitiveProps {
|
||||
/** Current open value(s) for controlled mode. */
|
||||
modelValue?: string | string[];
|
||||
|
||||
/** Initial value(s) for uncontrolled mode. */
|
||||
defaultValue?: string | string[];
|
||||
|
||||
@@ -30,7 +36,7 @@ export interface AccordionRootProps extends PrimitiveProps {
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, shallowRef, toRef, watch } from 'vue';
|
||||
import { computed, ref, toRef } from 'vue';
|
||||
import { resolveNextIndex, rovingKeyToAction } from '../utils/roving-focus';
|
||||
import { Primitive } from '../primitive';
|
||||
import { provideAccordionContext } from './context';
|
||||
@@ -45,34 +51,25 @@ const {
|
||||
orientation = 'vertical',
|
||||
dir = 'ltr',
|
||||
loop = true,
|
||||
modelValue,
|
||||
defaultValue,
|
||||
as = 'div',
|
||||
} = defineProps<AccordionRootProps>();
|
||||
|
||||
const { forwardRef } = useForwardExpose();
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string | string[] | undefined] }>();
|
||||
|
||||
type RovingAction = NonNullable<ReturnType<typeof rovingKeyToAction>>;
|
||||
|
||||
const openSet = shallowRef<Set<string>>(
|
||||
new Set(toArray(modelValue ?? defaultValue)),
|
||||
);
|
||||
const localValue = ref<string | string[] | undefined>(defaultValue);
|
||||
|
||||
function setEqualsArray(set: Set<string>, arr: string[]): boolean {
|
||||
if (arr.length !== set.size) return false;
|
||||
for (let i = 0; i < arr.length; i++) if (!set.has(arr[i]!)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
watch(() => modelValue, (v) => {
|
||||
if (v === undefined) return;
|
||||
const arr = toArray(v);
|
||||
if (setEqualsArray(openSet.value, arr)) return;
|
||||
openSet.value = new Set(arr);
|
||||
const model = defineModel<string | string[] | undefined>({
|
||||
get: v => v ?? localValue.value,
|
||||
set: (v) => {
|
||||
localValue.value = v;
|
||||
return v;
|
||||
},
|
||||
});
|
||||
|
||||
const openSet = computed<Set<string>>(() => new Set(toArray(model.value)));
|
||||
|
||||
function nextOpenSet(cur: Set<string>, value: string): Set<string> {
|
||||
const present = cur.has(value);
|
||||
@@ -88,13 +85,12 @@ function nextOpenSet(cur: Set<string>, value: string): Set<string> {
|
||||
return next;
|
||||
}
|
||||
|
||||
function toEmitValue(set: Set<string>): string | string[] | undefined {
|
||||
function toModelValue(set: Set<string>): string | string[] | undefined {
|
||||
return type === 'single' ? set.values().next().value : [...set];
|
||||
}
|
||||
|
||||
function commit(next: Set<string>): void {
|
||||
openSet.value = next;
|
||||
emit('update:modelValue', toEmitValue(next));
|
||||
model.value = toModelValue(next);
|
||||
}
|
||||
|
||||
function isOpen(value: string): boolean {
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
/**
|
||||
* The interactive header button that toggles its item's content open and
|
||||
* closed. Renders as a `<button>` by default, wires up the correct ARIA
|
||||
* (`aria-expanded`/`aria-controls`) and participates in arrow-key roving
|
||||
* focus across all triggers.
|
||||
*/
|
||||
export interface AccordionTriggerProps extends PrimitiveProps {
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionRoot,
|
||||
AccordionTrigger,
|
||||
} from '@robonen/primitives';
|
||||
|
||||
const open = ref<string>('shipping');
|
||||
|
||||
const items = [
|
||||
{
|
||||
value: 'shipping',
|
||||
question: 'How long does shipping take?',
|
||||
answer: 'Orders ship within 1–2 business days and arrive in 3–5 days with standard delivery. Express options are offered at checkout.',
|
||||
},
|
||||
{
|
||||
value: 'returns',
|
||||
question: 'What is your return policy?',
|
||||
answer: 'Returns are free within 30 days of delivery. Items must be unused and in their original packaging.',
|
||||
},
|
||||
{
|
||||
value: 'support',
|
||||
question: 'How can I reach support?',
|
||||
answer: 'Our team is available 24/7 by email and weekdays 9–5 by live chat. Most tickets are answered within a few hours.',
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionRoot
|
||||
v-model="open"
|
||||
type="single"
|
||||
collapsible
|
||||
class="w-full max-w-md divide-y divide-(--border) rounded-lg border border-(--border) bg-(--bg) text-(--fg)"
|
||||
>
|
||||
<AccordionItem
|
||||
v-for="item in items"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
>
|
||||
<AccordionTrigger
|
||||
class="group flex w-full items-center justify-between gap-4 px-4 py-3.5 text-left text-sm font-medium outline-none transition-colors hover:bg-(--bg-subtle) focus-visible:ring-2 focus-visible:ring-(--ring)"
|
||||
>
|
||||
<span>{{ item.question }}</span>
|
||||
<svg
|
||||
class="size-4 shrink-0 text-(--fg-subtle) transition-transform duration-200 group-data-[state=open]:rotate-180"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent
|
||||
class="px-4 pb-4 text-sm text-(--fg-muted)"
|
||||
>
|
||||
{{ item.answer }}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</AccordionRoot>
|
||||
</template>
|
||||
@@ -1,6 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
/**
|
||||
* The button that confirms the alert and closes the dialog. Use it for the
|
||||
* action being warned about (e.g. "Delete"); wire your own handler to perform
|
||||
* the work, the part only handles closing.
|
||||
*/
|
||||
export interface AlertDialogActionProps extends PrimitiveProps {}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
/**
|
||||
* The button that dismisses the alert without acting and closes the dialog.
|
||||
* Receives focus automatically when the alert opens, making it the safe default
|
||||
* choice; always include one so the user has a non-destructive way out.
|
||||
*/
|
||||
export interface AlertDialogCancelProps extends PrimitiveProps {}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<script lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from '../dialog';
|
||||
|
||||
/**
|
||||
* The container for the alert's content, rendered into the portal with
|
||||
* `role="alertdialog"`. Hosts the Title, Description, Cancel, and Action parts,
|
||||
* moves focus to Cancel on open, and disables dismissal via outside clicks or
|
||||
* loss of focus so the alert can only be resolved by an explicit choice.
|
||||
*/
|
||||
export interface AlertDialogContentProps extends Omit<DialogContentProps, 'role'> {}
|
||||
export type AlertDialogContentEmits = DialogContentEmits;
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { DialogRootProps } from '../dialog';
|
||||
|
||||
/**
|
||||
* A modal dialog that interrupts the user with important content and expects a
|
||||
* deliberate response. Built on top of Dialog, but always modal and rendered
|
||||
* with `role="alertdialog"` — focus moves to the Cancel button on open and
|
||||
* outside clicks are ignored, so the user must explicitly confirm or cancel.
|
||||
*
|
||||
* Use it for destructive or irreversible actions (deleting data, discarding
|
||||
* changes); for non-blocking content prefer Dialog instead. Manages open state
|
||||
* and provides context to all parts. Bind `v-model:open` to control it.
|
||||
*/
|
||||
export interface AlertDialogRootProps extends Omit<DialogRootProps, 'modal'> {}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogRoot,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@robonen/primitives';
|
||||
|
||||
const open = ref(false);
|
||||
const deleted = ref(false);
|
||||
|
||||
function confirmDelete() {
|
||||
deleted.value = true;
|
||||
}
|
||||
|
||||
function restore() {
|
||||
deleted.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-start gap-3 text-(--fg)">
|
||||
<p v-if="!deleted" class="text-sm text-(--fg-muted)">
|
||||
Project <span class="font-medium text-(--fg)">"acme-web"</span> is live.
|
||||
</p>
|
||||
<p
|
||||
v-else
|
||||
class="text-sm text-red-600 dark:text-red-400"
|
||||
>
|
||||
Project deleted.
|
||||
<button
|
||||
type="button"
|
||||
class="ml-1 underline underline-offset-2 hover:text-red-700 dark:hover:text-red-300"
|
||||
@click="restore"
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<AlertDialogRoot v-model:open="open">
|
||||
<AlertDialogTrigger
|
||||
:disabled="deleted"
|
||||
class="inline-flex items-center rounded-md border border-red-300 bg-(--bg) px-3 py-1.5 text-sm font-medium text-red-600 transition-colors hover:bg-red-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-400 disabled:cursor-not-allowed disabled:opacity-50 dark:border-red-900 dark:text-red-400 dark:hover:bg-red-950/40"
|
||||
>
|
||||
Delete project
|
||||
</AlertDialogTrigger>
|
||||
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay
|
||||
class="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"
|
||||
/>
|
||||
<AlertDialogContent
|
||||
class="fixed left-1/2 top-1/2 z-50 w-[min(92vw,26rem)] -translate-x-1/2 -translate-y-1/2 rounded-xl border border-(--border) bg-(--bg-elevated) p-5 shadow-xl"
|
||||
>
|
||||
<AlertDialogTitle class="text-base font-semibold text-(--fg)">
|
||||
Delete this project?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription class="mt-1.5 text-sm text-(--fg-muted)">
|
||||
This permanently removes "acme-web" and all of its deployments.
|
||||
This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
|
||||
<div class="mt-5 flex justify-end gap-2">
|
||||
<AlertDialogCancel
|
||||
class="inline-flex items-center rounded-md border border-(--border) bg-(--bg) px-3 py-1.5 text-sm font-medium text-(--fg) transition-colors hover:bg-(--bg-subtle) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--ring)"
|
||||
>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
class="inline-flex items-center rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-red-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
|
||||
@click="confirmDelete"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</AlertDialogRoot>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,12 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
/**
|
||||
* Displays content within a fixed, responsive width-to-height ratio. The
|
||||
* element grows to fill its container's width and derives its height from the
|
||||
* `ratio`, so the box keeps its proportions at any size. Use it to reserve
|
||||
* layout space for images, video, maps, or embeds and avoid content shift.
|
||||
*/
|
||||
export interface AspectRatioProps extends PrimitiveProps {
|
||||
/**
|
||||
* Desired width-to-height ratio (e.g. `16 / 9`, `1`, `4 / 3`).
|
||||
@@ -14,7 +20,7 @@ export interface AspectRatioProps extends PrimitiveProps {
|
||||
import { Primitive } from '../primitive';
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
|
||||
useForwardExpose();
|
||||
const { forwardRef } = useForwardExpose();
|
||||
|
||||
const { ratio = 1, as = 'div' } = defineProps<AspectRatioProps>();
|
||||
|
||||
@@ -33,7 +39,7 @@ const INNER_STYLE = {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :style="wrapperStyle" data-aspect-ratio-wrapper>
|
||||
<div :ref="forwardRef" :style="wrapperStyle" data-aspect-ratio-wrapper>
|
||||
<Primitive :as="as" :style="INNER_STYLE" :data-aspect-ratio="true">
|
||||
<slot />
|
||||
</Primitive>
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { AspectRatio } from '@robonen/primitives';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const ratios = [
|
||||
{ label: '16 / 9', value: 16 / 9 },
|
||||
{ label: '4 / 3', value: 4 / 3 },
|
||||
{ label: '1 / 1', value: 1 },
|
||||
] as const;
|
||||
|
||||
const ratio = ref(ratios[0].value);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 w-full max-w-md text-(--fg)">
|
||||
<div class="flex items-center gap-1 p-1 rounded-lg bg-(--bg-inset) border border-(--border) w-fit">
|
||||
<button
|
||||
v-for="r in ratios"
|
||||
:key="r.label"
|
||||
type="button"
|
||||
class="px-3 py-1 text-sm rounded-md transition-colors"
|
||||
:class="ratio === r.value
|
||||
? 'bg-(--accent) text-(--accent-fg)'
|
||||
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-subtle)'"
|
||||
@click="ratio = r.value"
|
||||
>
|
||||
{{ r.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AspectRatio
|
||||
:ratio="ratio"
|
||||
class="overflow-hidden rounded-xl border border-(--border) bg-(--bg-subtle)"
|
||||
>
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1535025183041-0991a977e25b?w=800&q=80"
|
||||
alt="Mountain landscape at dusk"
|
||||
class="h-full w-full object-cover"
|
||||
>
|
||||
</AspectRatio>
|
||||
|
||||
<p class="text-sm text-(--fg-muted)">
|
||||
The frame keeps a fixed
|
||||
<span class="font-medium text-(--fg)">{{ ratios.find((r) => r.value === ratio)?.label }}</span>
|
||||
proportion as the container resizes, so the image never shifts surrounding layout.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,12 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
/**
|
||||
* Content shown while the image is loading or when it fails to load — typically
|
||||
* the user's initials or a generic icon. It renders only when the image is not
|
||||
* yet `loaded`, and can be delayed to avoid a flash of fallback on fast
|
||||
* connections.
|
||||
*/
|
||||
export interface AvatarFallbackProps extends PrimitiveProps {
|
||||
|
||||
/** Delay in ms before rendering the fallback (avoids flicker on fast networks). */
|
||||
|
||||
@@ -2,11 +2,17 @@
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
import type { AvatarImageLoadingStatus } from './context';
|
||||
|
||||
/**
|
||||
* The image to display. It loads the `src` out of band and only renders once
|
||||
* the image has successfully loaded, reporting its loading status to the root
|
||||
* so the fallback can take over while loading or on error.
|
||||
*/
|
||||
export interface AvatarImageProps extends PrimitiveProps {
|
||||
|
||||
/** Image source URL — loaded out of band before the image is shown. */
|
||||
src?: string;
|
||||
/** Alternative text describing the image. */
|
||||
alt?: string;
|
||||
/** Optional hook to reject loaded images by their dimensions/src. */
|
||||
/** Called whenever the image's loading status changes (`idle`/`loading`/`loaded`/`error`). */
|
||||
onLoadingStatusChange?: (status: AvatarImageLoadingStatus) => void;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
/**
|
||||
* An image element representing a user, with a graceful text/icon fallback for
|
||||
* when the image is loading or fails to load. Use it for profile pictures in
|
||||
* avatars, comment threads, member lists, or anywhere a user identity is shown
|
||||
* and you need a reliable placeholder.
|
||||
*
|
||||
* The root tracks the image's loading status and provides it via context so
|
||||
* `AvatarImage` and `AvatarFallback` can coordinate which one is rendered. It
|
||||
* exposes the current status on the `data-status` attribute for styling.
|
||||
*/
|
||||
export interface AvatarRootProps extends PrimitiveProps {}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { AvatarFallback, AvatarImage, AvatarRoot } from '@robonen/primitives';
|
||||
|
||||
const people = [
|
||||
{
|
||||
name: 'Ada Lovelace',
|
||||
initials: 'AL',
|
||||
src: 'https://i.pravatar.cc/96?img=47',
|
||||
},
|
||||
{
|
||||
name: 'Alan Turing',
|
||||
initials: 'AT',
|
||||
src: 'https://example.com/this-image-does-not-exist.png',
|
||||
},
|
||||
{
|
||||
name: 'Grace Hopper',
|
||||
initials: 'GH',
|
||||
src: '',
|
||||
},
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
v-for="person in people"
|
||||
:key="person.name"
|
||||
class="flex flex-col items-center gap-2"
|
||||
>
|
||||
<AvatarRoot
|
||||
class="relative inline-flex h-14 w-14 select-none items-center justify-center overflow-hidden rounded-full border border-(--border) bg-(--bg-subtle) align-middle"
|
||||
>
|
||||
<AvatarImage
|
||||
:src="person.src"
|
||||
:alt="person.name"
|
||||
class="h-full w-full rounded-[inherit] object-cover"
|
||||
/>
|
||||
<AvatarFallback
|
||||
:delay-ms="200"
|
||||
class="flex h-full w-full items-center justify-center bg-(--bg-inset) text-sm font-medium text-(--fg-muted)"
|
||||
>
|
||||
{{ person.initials }}
|
||||
</AvatarFallback>
|
||||
</AvatarRoot>
|
||||
<span class="text-xs text-(--fg-subtle)">{{ person.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,12 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
/**
|
||||
* A single `role="gridcell"` day container (`<td>`). Reflects the date's state
|
||||
* (selected, disabled, unavailable, outside-view, today) as `data-*`
|
||||
* attributes and `aria-*` for styling, and wraps the focusable
|
||||
* `CalendarCellTrigger`.
|
||||
*/
|
||||
export interface CalendarCellProps extends PrimitiveProps {
|
||||
/** The date this cell represents. */
|
||||
date: Date;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
/**
|
||||
* The focusable, clickable day button inside a `CalendarCell`. Selects its
|
||||
* `day` on click/Enter/Space, drives roving focus and full arrow-key /
|
||||
* Home-End / PageUp-Down keyboard navigation (paging the month when focus
|
||||
* crosses the visible range), and exposes day state through its slot.
|
||||
*/
|
||||
export interface CalendarCellTriggerProps extends PrimitiveProps {
|
||||
/** The day this trigger represents. */
|
||||
day: Date;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
/**
|
||||
* The `role="grid"` table for a single month. Provides grid context (the month
|
||||
* it renders) to its head/body cells; render one per visible month when
|
||||
* `numberOfMonths > 1`.
|
||||
*/
|
||||
export interface CalendarGridProps extends PrimitiveProps {
|
||||
/** The month this grid represents. Defaults to the root placeholder's month. */
|
||||
month?: Date;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
/**
|
||||
* The grid's `<tbody>` wrapper containing the week rows (`CalendarGridRow`) of
|
||||
* day cells.
|
||||
*/
|
||||
export interface CalendarGridBodyProps extends PrimitiveProps {}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
/**
|
||||
* The grid's `<thead>` wrapper holding the row of weekday `CalendarHeadCell`
|
||||
* labels.
|
||||
*/
|
||||
export interface CalendarGridHeadProps extends PrimitiveProps {}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
/**
|
||||
* A single table row (`<tr>`) representing one week of the month, or the
|
||||
* weekday-label row inside the grid head.
|
||||
*/
|
||||
export interface CalendarGridRowProps extends PrimitiveProps {}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
/**
|
||||
* A `scope="col"` weekday header cell (`<th>`). Renders the localized short
|
||||
* label in its slot while exposing the full weekday name as the `aria-label`
|
||||
* when a `day` is provided.
|
||||
*/
|
||||
export interface CalendarHeadCellProps extends PrimitiveProps {
|
||||
/** The day this header cell represents — used for `aria-label`. */
|
||||
day?: Date;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
/**
|
||||
* Layout container for the calendar's top bar. Holds the `CalendarPrev`,
|
||||
* `CalendarHeading`, and `CalendarNext` controls above the month grid(s).
|
||||
*/
|
||||
export interface CalendarHeaderProps extends PrimitiveProps {}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
/**
|
||||
* Displays the currently visible month and year (e.g. "June 2026"), or a range
|
||||
* when multiple months are shown. Marked `aria-hidden` since the grid already
|
||||
* carries the full accessible label; expose the value via its default slot to
|
||||
* customize the rendering.
|
||||
*/
|
||||
export interface CalendarHeadingProps extends PrimitiveProps {}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
/**
|
||||
* Button that pages the calendar forward (by one month, or by
|
||||
* `numberOfMonths` when paged navigation is enabled). Auto-disables when the
|
||||
* next page would fall after `maxValue` or the calendar is disabled.
|
||||
*/
|
||||
export interface CalendarNextProps extends PrimitiveProps {
|
||||
/** Override the root's `nextPage` for just this button. */
|
||||
nextPage?: (placeholder: Date) => Date;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
/**
|
||||
* Button that pages the calendar backward (by one month, or by
|
||||
* `numberOfMonths` when paged navigation is enabled). Auto-disables when the
|
||||
* previous page would fall before `minValue` or the calendar is disabled.
|
||||
*/
|
||||
export interface CalendarPrevProps extends PrimitiveProps {
|
||||
/** Override the root's `prevPage` for just this button. */
|
||||
prevPage?: (placeholder: Date) => Date;
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
import type { CalendarMonth, WeekDayFormat } from './utils';
|
||||
|
||||
/**
|
||||
* A fully accessible, headless date calendar for picking a single day. The
|
||||
* root owns the selected value and the displayed month ("placeholder"), builds
|
||||
* the localized month grid(s), and wires up roving keyboard navigation,
|
||||
* min/max bounds, and disabled/unavailable predicates. Use it to build an
|
||||
* inline date picker or as the body of a popover/`DatePicker`.
|
||||
*
|
||||
* Compose it with `CalendarHeader` (`CalendarPrev` / `CalendarHeading` /
|
||||
* `CalendarNext`) and one `CalendarGrid` per month. Supports `v-model` for the
|
||||
* selected date and `v-model:placeholder` for the visible month.
|
||||
*/
|
||||
export interface CalendarRootProps extends PrimitiveProps {
|
||||
/** Uncontrolled default selected date. */
|
||||
defaultValue?: Date;
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CalendarCell,
|
||||
CalendarCellTrigger,
|
||||
CalendarGrid,
|
||||
CalendarGridBody,
|
||||
CalendarGridHead,
|
||||
CalendarGridRow,
|
||||
CalendarHeadCell,
|
||||
CalendarHeader,
|
||||
CalendarHeading,
|
||||
CalendarNext,
|
||||
CalendarPrev,
|
||||
CalendarRoot,
|
||||
} from '@robonen/primitives';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const value = ref<Date>(new Date());
|
||||
|
||||
function formatSelected(date: Date | undefined) {
|
||||
if (!date) return 'None';
|
||||
return date.toLocaleDateString('en', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex w-full max-w-sm flex-col gap-3">
|
||||
<CalendarRoot
|
||||
v-slot="{ grid, weekDays }"
|
||||
v-model="value"
|
||||
class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4 text-(--fg) shadow-sm"
|
||||
>
|
||||
<CalendarHeader class="mb-3 flex items-center justify-between gap-2">
|
||||
<CalendarPrev
|
||||
aria-label="Previous month"
|
||||
class="inline-flex size-8 items-center justify-center rounded-lg border border-(--border) bg-(--bg) text-(--fg-muted) transition hover:bg-(--bg-inset) hover:text-(--fg) active:scale-95 cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
‹
|
||||
</CalendarPrev>
|
||||
<CalendarHeading class="text-sm font-semibold tracking-tight" />
|
||||
<CalendarNext
|
||||
aria-label="Next month"
|
||||
class="inline-flex size-8 items-center justify-center rounded-lg border border-(--border) bg-(--bg) text-(--fg-muted) transition hover:bg-(--bg-inset) hover:text-(--fg) active:scale-95 cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
›
|
||||
</CalendarNext>
|
||||
</CalendarHeader>
|
||||
|
||||
<CalendarGrid
|
||||
v-for="month in grid"
|
||||
:key="month.value.toString()"
|
||||
:month="month.value"
|
||||
class="w-full border-collapse select-none"
|
||||
>
|
||||
<CalendarGridHead>
|
||||
<CalendarGridRow class="mb-1 flex">
|
||||
<CalendarHeadCell
|
||||
v-for="(weekday, i) in weekDays"
|
||||
:key="weekday + i"
|
||||
class="w-9 text-center text-xs font-medium text-(--fg-subtle)"
|
||||
>
|
||||
{{ weekday }}
|
||||
</CalendarHeadCell>
|
||||
</CalendarGridRow>
|
||||
</CalendarGridHead>
|
||||
|
||||
<CalendarGridBody>
|
||||
<CalendarGridRow
|
||||
v-for="(week, w) in month.weeks"
|
||||
:key="w"
|
||||
class="flex w-full"
|
||||
>
|
||||
<CalendarCell
|
||||
v-for="day in week"
|
||||
:key="day.toString()"
|
||||
:date="day"
|
||||
class="p-0.5"
|
||||
>
|
||||
<CalendarCellTrigger
|
||||
v-slot="{ dayValue, selected, today }"
|
||||
:day="day"
|
||||
:month="month.value"
|
||||
class="flex size-8 items-center justify-center rounded-lg text-sm tabular-nums transition outline-none cursor-pointer
|
||||
focus-visible:ring-2 focus-visible:ring-(--ring)
|
||||
hover:bg-(--bg-inset)
|
||||
data-[selected]:bg-(--accent) data-[selected]:font-semibold data-[selected]:text-(--accent-fg) data-[selected]:hover:bg-(--accent-hover)
|
||||
data-[outside-view]:text-(--fg-subtle) data-[outside-view]:opacity-50
|
||||
data-[unavailable]:cursor-not-allowed data-[unavailable]:text-red-500 data-[unavailable]:line-through data-[unavailable]:hover:bg-transparent
|
||||
data-[disabled]:cursor-not-allowed data-[disabled]:opacity-30"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
today && !selected ? 'relative after:absolute after:bottom-1 after:left-1/2 after:size-1 after:-translate-x-1/2 after:rounded-full after:bg-(--accent)' : '',
|
||||
]"
|
||||
>
|
||||
{{ dayValue }}
|
||||
</span>
|
||||
</CalendarCellTrigger>
|
||||
</CalendarCell>
|
||||
</CalendarGridRow>
|
||||
</CalendarGridBody>
|
||||
</CalendarGrid>
|
||||
</CalendarRoot>
|
||||
|
||||
<p class="text-xs text-(--fg-muted)">
|
||||
Selected:
|
||||
<span class="font-medium text-(--fg)">{{ formatSelected(value) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,5 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
/**
|
||||
* Renders its content only when the parent `CheckboxRoot` is checked or
|
||||
* indeterminate, mirroring that state via `data-state`. Place the check/dash
|
||||
* icon inside it; use `forceMount` to keep it mounted for CSS exit animations.
|
||||
*/
|
||||
export interface CheckboxIndicatorProps extends PrimitiveProps {
|
||||
/** Keep mounted even when unchecked (for CSS exit animations). */
|
||||
forceMount?: boolean;
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
import type { CheckedState } from './context';
|
||||
|
||||
/**
|
||||
* A toggleable control with checked, unchecked, and `'indeterminate'` states,
|
||||
* built on a native `<button role="checkbox">`. The interactive root: it owns
|
||||
* the checked state (controlled via `v-model:checked` or uncontrolled via
|
||||
* `defaultChecked`), handles toggling, exposes a hidden form input when `name`
|
||||
* is set, and provides context to `CheckboxIndicator`. Use it whenever you need
|
||||
* a styled checkbox that integrates with forms or supports a mixed/partial state.
|
||||
*/
|
||||
export interface CheckboxRootProps extends PrimitiveProps {
|
||||
/** Uncontrolled initial checked state. */
|
||||
defaultChecked?: CheckedState;
|
||||
@@ -22,7 +30,7 @@ export interface CheckboxRootEmits {
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Primitive } from '../primitive';
|
||||
import { ref, toRef, watch } from 'vue';
|
||||
import { computed, ref, toRef } from 'vue';
|
||||
import { provideCheckboxContext } from './context';
|
||||
import { useForwardExpose } from '@robonen/vue';
|
||||
|
||||
@@ -31,40 +39,51 @@ const { disabled = false, required = false, value = 'on', defaultChecked, name,
|
||||
const { forwardRef } = useForwardExpose();
|
||||
|
||||
const emit = defineEmits<CheckboxRootEmits>();
|
||||
const model = defineModel<CheckedState | undefined>('checked', { default: undefined });
|
||||
|
||||
const localChecked = ref<CheckedState>(model.value ?? defaultChecked ?? false);
|
||||
const localChecked = ref<CheckedState>(defaultChecked ?? false);
|
||||
|
||||
watch(model, (v) => {
|
||||
if (v === undefined) return;
|
||||
if (v !== localChecked.value) localChecked.value = v;
|
||||
// `defineModel` handles both controlled (parent `v-model:checked`) and
|
||||
// uncontrolled modes; `localChecked` backs the uncontrolled state seeded from
|
||||
// `defaultChecked`. `checkedChange` is a separate public emit, so it stays.
|
||||
const checked = defineModel<CheckedState | undefined>('checked', {
|
||||
default: undefined,
|
||||
get: v => v ?? localChecked.value,
|
||||
set: (v) => {
|
||||
localChecked.value = v as CheckedState;
|
||||
return v;
|
||||
},
|
||||
});
|
||||
|
||||
function setChecked(v: CheckedState): void {
|
||||
localChecked.value = v;
|
||||
model.value = v;
|
||||
checked.value = v;
|
||||
emit('checkedChange', v);
|
||||
}
|
||||
|
||||
function toggle(): void {
|
||||
if (disabled) return;
|
||||
setChecked(localChecked.value !== true);
|
||||
setChecked(checked.value !== true);
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent): void {
|
||||
// Prevent form submit on Enter when inside a form.
|
||||
if (event.key === 'Enter') event.preventDefault();
|
||||
// <button> handles Space natively; synthesize toggle only for non-button hosts.
|
||||
if (as !== 'button' && event.key === ' ') {
|
||||
event.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
}
|
||||
|
||||
provideCheckboxContext({
|
||||
// `localChecked` is already a `Ref<CheckedState>`; forward directly without
|
||||
// wrapping in a computed. `toRef(() => disabled)` gives a reactive identity
|
||||
// Read through the model so the context reflects both controlled (parent
|
||||
// `v-model:checked`) and uncontrolled state; coalesce the model's `undefined`
|
||||
// default to `false`. `toRef(() => disabled)` gives a reactive identity
|
||||
// passthrough without `ReactiveEffect`/cache.
|
||||
checked: localChecked,
|
||||
const checkedState = computed<CheckedState>(() => checked.value ?? false);
|
||||
|
||||
provideCheckboxContext({
|
||||
checked: checkedState,
|
||||
disabled: toRef(() => disabled),
|
||||
});
|
||||
|
||||
// Inlined in template — no need for a cached computed for a single call site.
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -72,17 +91,18 @@ provideCheckboxContext({
|
||||
:ref="forwardRef"
|
||||
:as="as"
|
||||
:type="as === 'button' ? 'button' : undefined"
|
||||
:tabindex="as === 'button' ? undefined : (disabled ? -1 : 0)"
|
||||
role="checkbox"
|
||||
:aria-checked="localChecked === 'indeterminate' ? 'mixed' : localChecked"
|
||||
:aria-checked="checkedState === 'indeterminate' ? 'mixed' : checkedState"
|
||||
:aria-required="required || undefined"
|
||||
:aria-disabled="disabled || undefined"
|
||||
:data-state="localChecked === 'indeterminate' ? 'indeterminate' : (localChecked ? 'checked' : 'unchecked')"
|
||||
:data-state="checkedState === 'indeterminate' ? 'indeterminate' : (checkedState ? 'checked' : 'unchecked')"
|
||||
:data-disabled="disabled ? '' : undefined"
|
||||
:disabled="disabled || undefined"
|
||||
@click="toggle"
|
||||
@keydown="onKeyDown"
|
||||
>
|
||||
<slot :checked="localChecked" />
|
||||
<slot :checked="checkedState" />
|
||||
<input
|
||||
v-if="name"
|
||||
type="checkbox"
|
||||
@@ -90,7 +110,7 @@ provideCheckboxContext({
|
||||
aria-hidden="true"
|
||||
:name="name"
|
||||
:value="value"
|
||||
:checked="localChecked === true"
|
||||
:checked="checkedState === true"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
style="position: absolute; pointer-events: none; opacity: 0; margin: 0; transform: translateX(-100%);"
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import type { CheckedState } from '@robonen/primitives';
|
||||
import { CheckboxIndicator, CheckboxRoot } from '@robonen/primitives';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
const ingredients = [
|
||||
{ id: 'cheese', label: 'Extra cheese' },
|
||||
{ id: 'mushrooms', label: 'Mushrooms' },
|
||||
{ id: 'olives', label: 'Olives' },
|
||||
];
|
||||
|
||||
const selected = ref<Record<string, boolean>>({
|
||||
cheese: true,
|
||||
mushrooms: false,
|
||||
olives: false,
|
||||
});
|
||||
|
||||
const checkedCount = computed(() => Object.values(selected.value).filter(Boolean).length);
|
||||
|
||||
// Parent reflects the children: checked when all, unchecked when none, else indeterminate.
|
||||
const allChecked = computed<CheckedState>(() => {
|
||||
if (checkedCount.value === 0) return false;
|
||||
if (checkedCount.value === ingredients.length) return true;
|
||||
return 'indeterminate';
|
||||
});
|
||||
|
||||
function toggleAll(next: CheckedState) {
|
||||
const value = next === true;
|
||||
for (const item of ingredients) selected.value[item.id] = value;
|
||||
}
|
||||
|
||||
const acceptedTerms = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 p-6 max-w-sm bg-(--bg) text-(--fg) border border-(--border) rounded-xl">
|
||||
<fieldset class="flex flex-col gap-3 m-0 p-0 border-0">
|
||||
<legend class="text-sm font-semibold text-(--fg)">
|
||||
Toppings
|
||||
</legend>
|
||||
|
||||
<label class="flex items-center gap-3 cursor-pointer select-none">
|
||||
<CheckboxRoot
|
||||
:checked="allChecked"
|
||||
class="grid place-items-center w-5 h-5 rounded-md border border-(--border) bg-(--bg-inset) outline-none transition-colors data-[state=checked]:bg-(--accent) data-[state=indeterminate]:bg-(--accent) data-[state=checked]:border-(--accent) data-[state=indeterminate]:border-(--accent) focus-visible:ring-2 focus-visible:ring-(--ring)"
|
||||
@checked-change="toggleAll"
|
||||
>
|
||||
<CheckboxIndicator v-slot="{ checked }" class="text-(--accent-fg)">
|
||||
<svg v-if="checked === 'indeterminate'" width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M2.5 6h7" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
<svg v-else width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M2.5 6.5 5 9l4.5-5.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</CheckboxIndicator>
|
||||
</CheckboxRoot>
|
||||
<span class="text-sm font-medium">Select all</span>
|
||||
<span class="ml-auto text-xs text-(--fg-subtle)">{{ checkedCount }}/{{ ingredients.length }}</span>
|
||||
</label>
|
||||
|
||||
<div class="flex flex-col gap-2 pl-2 border-l border-(--border)">
|
||||
<label v-for="item in ingredients" :key="item.id" class="flex items-center gap-3 cursor-pointer select-none">
|
||||
<CheckboxRoot
|
||||
v-model:checked="selected[item.id]"
|
||||
class="grid place-items-center w-5 h-5 rounded-md border border-(--border) bg-(--bg-inset) outline-none transition-colors data-[state=checked]:bg-(--accent) data-[state=checked]:border-(--accent) focus-visible:ring-2 focus-visible:ring-(--ring)"
|
||||
>
|
||||
<CheckboxIndicator class="text-(--accent-fg)">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M2.5 6.5 5 9l4.5-5.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</CheckboxIndicator>
|
||||
</CheckboxRoot>
|
||||
<span class="text-sm text-(--fg)">{{ item.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<label class="flex items-start gap-3 cursor-pointer select-none">
|
||||
<CheckboxRoot
|
||||
v-model:checked="acceptedTerms"
|
||||
required
|
||||
class="grid place-items-center w-5 h-5 mt-0.5 rounded-md border border-(--border) bg-(--bg-inset) outline-none transition-colors data-[state=checked]:bg-emerald-500 data-[state=checked]:border-emerald-500 dark:data-[state=checked]:bg-emerald-400 dark:data-[state=checked]:border-emerald-400 focus-visible:ring-2 focus-visible:ring-(--ring)"
|
||||
>
|
||||
<CheckboxIndicator class="text-white dark:text-(--bg)">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M2.5 6.5 5 9l4.5-5.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</CheckboxIndicator>
|
||||
</CheckboxRoot>
|
||||
<span class="text-sm text-(--fg-muted)">I accept the terms and conditions</span>
|
||||
</label>
|
||||
|
||||
<p
|
||||
class="text-xs"
|
||||
:class="acceptedTerms ? 'text-emerald-600 dark:text-emerald-400' : 'text-(--fg-subtle)'"
|
||||
>
|
||||
{{ acceptedTerms ? 'Ready to submit' : 'Please accept the terms to continue' }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
/**
|
||||
* The panel revealed when the collapsible is open. Mounts and unmounts with
|
||||
* the open state (via `Presence`), is referenced by the trigger's
|
||||
* `aria-controls`, and is hidden from layout and assistive tech while closed.
|
||||
*/
|
||||
export interface CollapsibleContentProps extends PrimitiveProps {
|
||||
|
||||
/** Render the content even when closed (useful for animation control). */
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
/**
|
||||
* An interactive component that expands and collapses a panel of content.
|
||||
*
|
||||
* `CollapsibleRoot` owns the open/closed state (controlled via `v-model:open`
|
||||
* or uncontrolled via `defaultOpen`), provides it to the `Trigger` and
|
||||
* `Content` parts, and reflects it as `data-state`. Use it for show/hide
|
||||
* disclosures such as "read more" sections, FAQ entries, or settings panels.
|
||||
*/
|
||||
export interface CollapsibleRootProps extends PrimitiveProps {
|
||||
|
||||
defaultOpen?: boolean;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
/**
|
||||
* The button that toggles the collapsible open and closed. Wires up
|
||||
* `aria-expanded`, `aria-controls`, and the disabled state from the root, and
|
||||
* renders as a `<button>` by default.
|
||||
*/
|
||||
export interface CollapsibleTriggerProps extends PrimitiveProps {}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
CollapsibleContent,
|
||||
CollapsibleRoot,
|
||||
CollapsibleTrigger,
|
||||
} from '@robonen/primitives';
|
||||
|
||||
const open = ref(false);
|
||||
|
||||
const commits = [
|
||||
{ id: 'a1c3f9', msg: 'Reflect open state via data-state' },
|
||||
{ id: 'b7e2d4', msg: 'Wire aria-controls to content id' },
|
||||
{ id: 'c0f8a1', msg: 'Unmount content with Presence when closed' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CollapsibleRoot
|
||||
v-model:open="open"
|
||||
class="w-full max-w-sm rounded-xl border border-(--border) bg-(--bg-elevated) p-3 text-(--fg)"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 px-1">
|
||||
<span class="text-sm font-medium">
|
||||
<span class="font-mono text-(--fg-muted)">@robonen</span> pushed 3 commits
|
||||
</span>
|
||||
|
||||
<CollapsibleTrigger
|
||||
class="inline-flex size-7 items-center justify-center rounded-md border border-(--border) bg-(--bg) text-(--fg-muted) transition hover:bg-(--bg-inset) hover:text-(--fg) active:scale-95 cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
|
||||
:aria-label="open ? 'Collapse commits' : 'Expand commits'"
|
||||
>
|
||||
<svg
|
||||
class="size-4 transition-transform duration-200"
|
||||
:class="open ? 'rotate-180' : ''"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 rounded-lg border border-(--border) bg-(--bg) px-3 py-2 font-mono text-xs text-(--fg-muted)">
|
||||
<span class="text-emerald-600 dark:text-emerald-400">{{ commits[0].id }}</span>
|
||||
{{ commits[0].msg }}
|
||||
</div>
|
||||
|
||||
<CollapsibleContent class="mt-1.5 space-y-1.5">
|
||||
<div
|
||||
v-for="commit in commits.slice(1)"
|
||||
:key="commit.id"
|
||||
class="rounded-lg border border-(--border) bg-(--bg) px-3 py-2 font-mono text-xs text-(--fg-muted)"
|
||||
>
|
||||
<span class="text-emerald-600 dark:text-emerald-400">{{ commit.id }}</span>
|
||||
{{ commit.msg }}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</CollapsibleRoot>
|
||||
</template>
|
||||
@@ -1,6 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type { PopperAnchorProps } from '../popper';
|
||||
|
||||
/**
|
||||
* The element the popup is positioned against, typically wrapping the Input and Trigger.
|
||||
* Acts as the Popper anchor and the boundary used for the blur-to-close heuristic.
|
||||
*/
|
||||
export interface ComboboxAnchorProps extends PopperAnchorProps {}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type { PopperArrowProps } from '../popper';
|
||||
|
||||
/**
|
||||
* An optional arrow that visually points from the popup back to the anchor. Renders only
|
||||
* while the combobox is open. Place inside ComboboxContent.
|
||||
*/
|
||||
export type ComboboxArrowProps = PopperArrowProps;
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
/**
|
||||
* A button that clears the current search term and refocuses the input. Typically shown
|
||||
* as an "x" inside the field while the user is typing.
|
||||
*/
|
||||
export interface ComboboxCancelProps extends PrimitiveProps {}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type { ComboboxContentImplEmits, ComboboxContentImplProps } from './ComboboxContentImpl.vue';
|
||||
|
||||
/**
|
||||
* The popup listbox that holds the options. Mounts only while open (via Presence) and
|
||||
* positions itself relative to the anchor. Place the Viewport, Items, and Empty inside it.
|
||||
*/
|
||||
export type ComboboxContentProps = ComboboxContentImplProps;
|
||||
export type ComboboxContentEmits = ComboboxContentImplEmits;
|
||||
</script>
|
||||
|
||||
@@ -4,6 +4,10 @@ import type { FocusScopeEmits } from '../focus-scope';
|
||||
import type { PopperContentProps } from '../popper';
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
/**
|
||||
* Internal implementation of the content popup: wires up focus scoping, dismiss-on-outside,
|
||||
* Popper positioning, and the screen-reader result announcer. Use ComboboxContent instead.
|
||||
*/
|
||||
export interface ComboboxContentImplProps extends PrimitiveProps, /* @vue-ignore */ Partial<PopperContentProps> {
|
||||
/** Position strategy. @default 'popper' */
|
||||
position?: 'inline' | 'popper';
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
/**
|
||||
* Fallback content shown when the current search term matches no items. Renders only when
|
||||
* the filtered count is zero, unless `always` is set.
|
||||
*/
|
||||
export interface ComboboxEmptyProps extends PrimitiveProps {
|
||||
/** Render even when items exist but none are filtered out. */
|
||||
always?: boolean;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
/**
|
||||
* Groups related items under a shared ComboboxLabel. Hides itself automatically when none
|
||||
* of its items survive the current filter.
|
||||
*/
|
||||
export interface ComboboxGroupProps extends PrimitiveProps {}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
/**
|
||||
* The text field users type into to filter options. Owns the search term, ARIA combobox
|
||||
* semantics, and keyboard navigation (arrows, Home/End, Enter to select, Escape to close).
|
||||
*/
|
||||
export interface ComboboxInputProps extends PrimitiveProps {
|
||||
/** Disable the input. */
|
||||
disabled?: boolean;
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
import type { AcceptableValue } from './utils';
|
||||
|
||||
/**
|
||||
* A single selectable option in the list. Registers itself for filtering and keyboard
|
||||
* navigation, toggles selection on click, and highlights on pointer move.
|
||||
*/
|
||||
export interface ComboboxItemProps<T extends AcceptableValue = AcceptableValue> extends PrimitiveProps {
|
||||
/** Item value. Selected/registered identity. */
|
||||
value: T;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
/**
|
||||
* Marks the selected state of its parent ComboboxItem, e.g. a checkmark. Renders only when
|
||||
* that item is selected.
|
||||
*/
|
||||
export interface ComboboxItemIndicatorProps extends PrimitiveProps {}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
/**
|
||||
* An accessible label for a ComboboxGroup. Its id is referenced by the group's
|
||||
* `aria-labelledby`, so place it as a direct child of ComboboxGroup.
|
||||
*/
|
||||
export interface ComboboxLabelProps extends PrimitiveProps {}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type { PortalProps } from '../teleport';
|
||||
|
||||
/**
|
||||
* Teleports the ComboboxContent into another part of the DOM (defaults to `body`) to escape
|
||||
* overflow/stacking-context clipping. Wrap ComboboxContent with it.
|
||||
*/
|
||||
export interface ComboboxPortalProps extends PortalProps {}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
import type { Direction } from '../config-provider';
|
||||
import type { AcceptableValue, ComboboxFilterFunction, ComboboxFilterItem } from './utils';
|
||||
|
||||
/**
|
||||
* An autocomplete / typeahead input that filters a list of options as the user types.
|
||||
* Combine a text input with a popup listbox, supporting single or multiple selection,
|
||||
* custom filtering, and full keyboard navigation. Reach for it when users must pick from
|
||||
* a large or searchable set of options; for a small fixed list a plain Select is simpler.
|
||||
* Wraps everything in a Popper and provides shared state to every other Combobox part.
|
||||
*/
|
||||
export interface ComboboxRootProps<T extends AcceptableValue = AcceptableValue> {
|
||||
/** Controlled selected value. Use `v-model`. */
|
||||
modelValue?: T | T[];
|
||||
@@ -69,8 +76,6 @@ const {
|
||||
by,
|
||||
} = defineProps<ComboboxRootProps<T>>();
|
||||
|
||||
const emit = defineEmits<ComboboxRootEmits<T>>();
|
||||
|
||||
const config = useConfig();
|
||||
const direction = computed(() => dir ?? config.dir.value);
|
||||
|
||||
@@ -203,7 +208,6 @@ function isSelected(v: T): boolean {
|
||||
|
||||
function commitValue(next: T | T[] | undefined) {
|
||||
value.value = next;
|
||||
emit('update:modelValue', next);
|
||||
}
|
||||
|
||||
function onValueChange(v: T) {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
/**
|
||||
* A purely visual divider between items or groups inside the popup. Decorative and hidden
|
||||
* from assistive technology.
|
||||
*/
|
||||
export interface ComboboxSeparatorProps extends PrimitiveProps {}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
/**
|
||||
* A button, usually a chevron next to the input, that toggles the popup open and closed.
|
||||
* Optional: typing in the Input also opens the list.
|
||||
*/
|
||||
export interface ComboboxTriggerProps extends PrimitiveProps {
|
||||
/** Disable the trigger independently from the root. */
|
||||
disabled?: boolean;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
/**
|
||||
* The scrollable region inside ComboboxContent that holds the items. Provides the overflow
|
||||
* container that keeps the highlighted item scrolled into view.
|
||||
*/
|
||||
export interface ComboboxViewportProps extends PrimitiveProps {}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
import {
|
||||
ComboboxAnchor,
|
||||
ComboboxCancel,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxGroup,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxItemIndicator,
|
||||
ComboboxLabel,
|
||||
ComboboxPortal,
|
||||
ComboboxRoot,
|
||||
ComboboxTrigger,
|
||||
ComboboxViewport,
|
||||
} from '@robonen/primitives';
|
||||
|
||||
interface Framework {
|
||||
value: string;
|
||||
label: string;
|
||||
group: 'JavaScript' | 'Native';
|
||||
}
|
||||
|
||||
const frameworks: Framework[] = [
|
||||
{ value: 'vue', label: 'Vue', group: 'JavaScript' },
|
||||
{ value: 'react', label: 'React', group: 'JavaScript' },
|
||||
{ value: 'svelte', label: 'Svelte', group: 'JavaScript' },
|
||||
{ value: 'solid', label: 'Solid', group: 'JavaScript' },
|
||||
{ value: 'angular', label: 'Angular', group: 'JavaScript' },
|
||||
{ value: 'swiftui', label: 'SwiftUI', group: 'Native' },
|
||||
{ value: 'compose', label: 'Jetpack Compose', group: 'Native' },
|
||||
{ value: 'flutter', label: 'Flutter', group: 'Native' },
|
||||
];
|
||||
|
||||
const selected = ref<string>();
|
||||
|
||||
function labelFor(value: string | undefined) {
|
||||
return frameworks.find(f => f.value === value)?.label ?? '';
|
||||
}
|
||||
|
||||
const groups = ['JavaScript', 'Native'] as const;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex w-full max-w-xs flex-col gap-3">
|
||||
<ComboboxRoot
|
||||
v-model="selected"
|
||||
:display-value="labelFor"
|
||||
class="relative"
|
||||
>
|
||||
<ComboboxAnchor
|
||||
class="flex items-center gap-1 rounded-lg border border-(--border) bg-(--bg-inset) px-2 py-1.5 focus-within:border-(--accent) focus-within:ring-2 focus-within:ring-(--ring)"
|
||||
>
|
||||
<ComboboxInput
|
||||
placeholder="Search a framework..."
|
||||
open-on-click
|
||||
class="min-w-0 flex-1 bg-transparent px-1 text-sm text-(--fg) outline-none placeholder:text-(--fg-subtle)"
|
||||
/>
|
||||
|
||||
<ComboboxCancel
|
||||
class="grid size-5 place-items-center rounded text-(--fg-subtle) hover:bg-(--bg-subtle) hover:text-(--fg)"
|
||||
>
|
||||
<span aria-hidden="true" class="text-xs">✕</span>
|
||||
</ComboboxCancel>
|
||||
|
||||
<ComboboxTrigger
|
||||
class="grid size-5 place-items-center rounded text-(--fg-muted) hover:bg-(--bg-subtle) hover:text-(--fg) data-[state=open]:rotate-180"
|
||||
>
|
||||
<span aria-hidden="true" class="text-xs">▾</span>
|
||||
</ComboboxTrigger>
|
||||
</ComboboxAnchor>
|
||||
|
||||
<ComboboxPortal>
|
||||
<ComboboxContent
|
||||
:side-offset="6"
|
||||
class="z-50 w-(--popper-anchor-width) overflow-hidden rounded-lg border border-(--border) bg-(--bg-elevated) shadow-lg"
|
||||
>
|
||||
<ComboboxViewport class="max-h-60 p-1">
|
||||
<ComboboxEmpty class="px-3 py-6 text-center text-sm text-(--fg-subtle)">
|
||||
No frameworks found.
|
||||
</ComboboxEmpty>
|
||||
|
||||
<ComboboxGroup
|
||||
v-for="group in groups"
|
||||
:key="group"
|
||||
class="mb-1 last:mb-0"
|
||||
>
|
||||
<ComboboxLabel
|
||||
class="px-2 py-1 text-xs font-medium uppercase tracking-wide text-(--fg-subtle)"
|
||||
>
|
||||
{{ group }}
|
||||
</ComboboxLabel>
|
||||
|
||||
<ComboboxItem
|
||||
v-for="framework in frameworks.filter(f => f.group === group)"
|
||||
:key="framework.value"
|
||||
:value="framework.value"
|
||||
:text-value="framework.label"
|
||||
class="flex cursor-pointer items-center justify-between rounded-md px-2 py-1.5 text-sm text-(--fg) outline-none data-[highlighted]:bg-(--accent) data-[highlighted]:text-(--accent-fg) data-[disabled]:opacity-50"
|
||||
>
|
||||
<span>{{ framework.label }}</span>
|
||||
<ComboboxItemIndicator>
|
||||
<span aria-hidden="true">✓</span>
|
||||
</ComboboxItemIndicator>
|
||||
</ComboboxItem>
|
||||
</ComboboxGroup>
|
||||
</ComboboxViewport>
|
||||
</ComboboxContent>
|
||||
</ComboboxPortal>
|
||||
</ComboboxRoot>
|
||||
|
||||
<p class="text-sm text-(--fg-muted)">
|
||||
Selected:
|
||||
<span class="font-medium text-(--fg)">{{ selected ? labelFor(selected) : 'none' }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
/**
|
||||
* Empty-state message shown when the search yields no matching items. By default
|
||||
* it appears only while a search term is active; set `always` to also show it for
|
||||
* an empty list with no query.
|
||||
*/
|
||||
export interface CommandEmptyProps extends PrimitiveProps {
|
||||
/** Render even while there is no active search term. */
|
||||
always?: boolean;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
/**
|
||||
* Labelled section that visually clusters related items under an optional
|
||||
* heading. Hides itself automatically when every item it contains is filtered
|
||||
* out (unless `forceMount`), so empty categories disappear during search.
|
||||
*/
|
||||
export interface CommandGroupProps extends PrimitiveProps {
|
||||
/** Group heading text (rendered when the default slot doesn't override it). */
|
||||
heading?: string;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
/**
|
||||
* Search box that drives the command palette: typing updates the root search
|
||||
* term (and re-filters items), while Arrow/Home/End/Enter move the highlight and
|
||||
* commit the selected item. Renders a combobox `<input>` wired up for assistive tech.
|
||||
*/
|
||||
export interface CommandInputProps extends PrimitiveProps {
|
||||
/** Controlled value; falls back to root `searchTerm`. */
|
||||
modelValue?: string;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
/**
|
||||
* A selectable option in the list. Registers itself with the root (so it can be
|
||||
* filtered, highlighted, and selected), reflects highlight/selection/disabled
|
||||
* state via data attributes, and emits `select` when chosen by click or Enter.
|
||||
*/
|
||||
export interface CommandItemProps extends PrimitiveProps {
|
||||
/** Item value — used by filter, selection, and `data-value`. */
|
||||
value: string;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
/**
|
||||
* Scrollable listbox container that holds the items, groups, and empty/loading
|
||||
* states. Tracks its content height in the `--primitives-command-list-height`
|
||||
* CSS variable so you can animate the palette as results filter in and out.
|
||||
*/
|
||||
export interface CommandListProps extends PrimitiveProps {}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
|
||||
/**
|
||||
* Progress indicator for asynchronous results — render it inside the list while
|
||||
* fetching items so screen readers announce the loading state. Exposes an
|
||||
* optional `progress` value as an accessible progressbar.
|
||||
*/
|
||||
export interface CommandLoadingProps extends PrimitiveProps {
|
||||
/** Accessible label describing the loading state. */
|
||||
label?: string;
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
import type { PrimitiveProps } from '../primitive';
|
||||
import type { CommandFilterFunction } from './utils';
|
||||
|
||||
/**
|
||||
* Root of a command palette / fuzzy-finder menu (cmdk-style): owns the search
|
||||
* term, the registry of items and groups, scoring/filtering, and keyboard-driven
|
||||
* highlight + selection. Compose it with `CommandInput`, `CommandList`,
|
||||
* `CommandGroup`, `CommandItem`, `CommandEmpty`, `CommandLoading`, and
|
||||
* `CommandSeparator`. Reach for it whenever you need a searchable, keyboard-first
|
||||
* list of actions or options — a Spotlight-style launcher, an autocomplete menu,
|
||||
* or a quick-switcher.
|
||||
*/
|
||||
export interface CommandRootProps extends PrimitiveProps {
|
||||
/** Controlled selected value. Use `v-model`. */
|
||||
modelValue?: string;
|
||||
@@ -50,8 +59,6 @@ const {
|
||||
label,
|
||||
} = defineProps<CommandRootProps>();
|
||||
|
||||
const emit = defineEmits<CommandRootEmits>();
|
||||
|
||||
const { forwardRef } = useForwardExpose();
|
||||
|
||||
const localValue = ref<string | undefined>(defaultValue);
|
||||
@@ -179,12 +186,10 @@ function getItemId(val: string): string {
|
||||
|
||||
function setModelValue(v: string | undefined) {
|
||||
value.value = v;
|
||||
emit('update:modelValue', v);
|
||||
}
|
||||
|
||||
function setSearchTerm(v: string) {
|
||||
search.value = v;
|
||||
emit('update:searchTerm', v);
|
||||
}
|
||||
|
||||
function setSelectedValue(v: string | undefined) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user