feat(forms): add useMaskedField and useMaskedInput composables for input masking

This commit is contained in:
2026-06-09 13:54:52 +07:00
parent 6de7c72fb3
commit 07937e26db
426 changed files with 12981 additions and 311 deletions
+1
View File
@@ -1,2 +1,3 @@
export * from './luhn';
export * from './reed-solomon';
export * from './qr';
+21
View File
@@ -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);
});
});
+39
View File
@@ -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;
}
+1
View File
@@ -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
);
}
+3
View File
@@ -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;
}
+3
View File
@@ -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);
});
});
+36
View File
@@ -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 1219 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;
}
+2
View File
@@ -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));
}
+21
View File
@@ -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('');
});
});
+34
View File
@@ -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),
);
}
+3
View File
@@ -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 dIvoire', 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: '+### ############' },
];
+4 -1
View File
@@ -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} />`;
+10 -4
View File
@@ -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>
+2 -2
View File
@@ -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>
+2 -1
View File
@@ -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>
+2 -1
View File
@@ -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>
+33
View File
@@ -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>
+15
View File
@@ -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>();
+20 -8
View File
@@ -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" />
+298 -25
View File
@@ -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),
});
}
+57 -2
View File
@@ -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',
+7
View File
@@ -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 {
+6
View File
@@ -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
+1
View File
@@ -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;
+20 -24
View File
@@ -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>
+66
View File
@@ -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 12 business days and arrive in 35 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 95 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>
+86
View File
@@ -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>
+48
View File
@@ -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). */
+8 -2
View File
@@ -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>
+10
View File
@@ -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>
+48
View File
@@ -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;
+110
View File
@@ -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;
+39 -19
View File
@@ -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();
}
}
// 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.
const checkedState = computed<CheckedState>(() => checked.value ?? false);
provideCheckboxContext({
// `localChecked` is already a `Ref<CheckedState>`; forward directly without
// wrapping in a computed. `toRef(() => disabled)` gives a reactive identity
// passthrough without `ReactiveEffect`/cache.
checked: localChecked,
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%);"
+100
View File
@@ -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>
+59
View File
@@ -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>
+7 -3
View File
@@ -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>
+119
View File
@@ -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;
+9 -4
View File
@@ -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