feat(forms): add useMaskedField and useMaskedInput composables for input masking
This commit is contained in:
@@ -1,2 +1,3 @@
|
|||||||
|
export * from './luhn';
|
||||||
export * from './reed-solomon';
|
export * from './reed-solomon';
|
||||||
export * from './qr';
|
export * from './qr';
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { luhn } from './index';
|
||||||
|
|
||||||
|
describe(luhn, () => {
|
||||||
|
it('passes valid checksums (separators ignored)', () => {
|
||||||
|
expect(luhn('4111 1111 1111 1111')).toBe(true);
|
||||||
|
expect(luhn('5500005555555559')).toBe(true);
|
||||||
|
expect(luhn('371449635398431')).toBe(true);
|
||||||
|
expect(luhn('79927398713')).toBe(true); // classic Luhn example
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails bad checksums', () => {
|
||||||
|
expect(luhn('4111 1111 1111 1112')).toBe(false);
|
||||||
|
expect(luhn('79927398710')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for empty input', () => {
|
||||||
|
expect(luhn('')).toBe(false);
|
||||||
|
expect(luhn('----')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
const NON_DIGIT = /\D/g;
|
||||||
|
const ASCII_ZERO = 0x30;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name luhn
|
||||||
|
* @category Encoding
|
||||||
|
* @description Validate a number string against the Luhn (mod 10) checksum — the
|
||||||
|
* check digit used by payment cards, IMEIs, SIM ICCIDs, and more. Non-digits are
|
||||||
|
* ignored; an empty input is `false`.
|
||||||
|
*
|
||||||
|
* @param {string} value The number (separators allowed)
|
||||||
|
* @returns {boolean} Whether the Luhn checksum passes
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* luhn('4111 1111 1111 1111'); // true
|
||||||
|
* luhn('4111 1111 1111 1112'); // false
|
||||||
|
*
|
||||||
|
* @since 0.0.2
|
||||||
|
*/
|
||||||
|
export function luhn(value: string): boolean {
|
||||||
|
const digits = value.replaceAll(NON_DIGIT, '');
|
||||||
|
if (!digits)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
let sum = 0;
|
||||||
|
let double = false;
|
||||||
|
for (let i = digits.length - 1; i >= 0; i--) {
|
||||||
|
let digit = digits.charCodeAt(i) - ASCII_ZERO;
|
||||||
|
if (double) {
|
||||||
|
digit *= 2;
|
||||||
|
if (digit > 9)
|
||||||
|
digit -= 9;
|
||||||
|
}
|
||||||
|
sum += digit;
|
||||||
|
double = !double;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sum % 10 === 0;
|
||||||
|
}
|
||||||
@@ -56,6 +56,7 @@
|
|||||||
"build": "tsdown"
|
"build": "tsdown"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@robonen/encoding": "workspace:*",
|
||||||
"@robonen/eslint": "workspace:*",
|
"@robonen/eslint": "workspace:*",
|
||||||
"@robonen/stdlib": "workspace:*",
|
"@robonen/stdlib": "workspace:*",
|
||||||
"@robonen/tsconfig": "workspace:*",
|
"@robonen/tsconfig": "workspace:*",
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { assignStyle, getTranslate, isInView, resetStyle, setStyle } from './index';
|
||||||
|
|
||||||
|
function makeEl(): HTMLElement {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
document.body.append(el);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('setStyle / resetStyle', () => {
|
||||||
|
it('applies styles and caches the overwritten values', () => {
|
||||||
|
const el = makeEl();
|
||||||
|
el.style.transform = 'translateY(0px)';
|
||||||
|
|
||||||
|
setStyle(el, { transform: 'translateY(20px)', transition: 'none' });
|
||||||
|
expect(el.style.transform).toBe('translateY(20px)');
|
||||||
|
expect(el.style.transition).toBe('none');
|
||||||
|
|
||||||
|
resetStyle(el);
|
||||||
|
expect(el.style.transform).toBe('translateY(0px)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores a single property when given prop', () => {
|
||||||
|
const el = makeEl();
|
||||||
|
el.style.opacity = '1';
|
||||||
|
|
||||||
|
setStyle(el, { opacity: '0', transform: 'scale(0.9)' });
|
||||||
|
resetStyle(el, 'opacity');
|
||||||
|
|
||||||
|
expect(el.style.opacity).toBe('1');
|
||||||
|
expect(el.style.transform).toBe('scale(0.9)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes custom properties via setProperty', () => {
|
||||||
|
const el = makeEl();
|
||||||
|
setStyle(el, { '--snap-point-height': '120px' });
|
||||||
|
expect(el.style.getPropertyValue('--snap-point-height')).toBe('120px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not cache when ignoreCache is true', () => {
|
||||||
|
const el = makeEl();
|
||||||
|
el.style.opacity = '1';
|
||||||
|
|
||||||
|
setStyle(el, { opacity: '0.5' }, true);
|
||||||
|
resetStyle(el);
|
||||||
|
|
||||||
|
expect(el.style.opacity).toBe('0.5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op for non-elements', () => {
|
||||||
|
expect(() => setStyle(null, { opacity: '0' })).not.toThrow();
|
||||||
|
expect(() => resetStyle(null)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getTranslate', () => {
|
||||||
|
it('returns null when there is no matrix transform', () => {
|
||||||
|
const el = makeEl();
|
||||||
|
expect(getTranslate(el, 'y')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads x and y from a 2D matrix', () => {
|
||||||
|
const el = makeEl();
|
||||||
|
el.style.transform = 'matrix(1, 0, 0, 1, 12, 34)';
|
||||||
|
expect(getTranslate(el, 'x')).toBe(12);
|
||||||
|
expect(getTranslate(el, 'y')).toBe(34);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads x and y from a 3D matrix', () => {
|
||||||
|
const el = makeEl();
|
||||||
|
el.style.transform = 'matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 50, 60, 0, 1)';
|
||||||
|
expect(getTranslate(el, 'x')).toBe(50);
|
||||||
|
expect(getTranslate(el, 'y')).toBe(60);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('assignStyle', () => {
|
||||||
|
it('assigns styles and restores the previous cssText on cleanup', () => {
|
||||||
|
const el = makeEl();
|
||||||
|
el.style.cssText = 'color: red;';
|
||||||
|
|
||||||
|
const restore = assignStyle(el, { overflow: 'hidden' });
|
||||||
|
expect(el.style.overflow).toBe('hidden');
|
||||||
|
|
||||||
|
restore();
|
||||||
|
expect(el.style.overflow).toBe('');
|
||||||
|
expect(el.style.color).toBe('red');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a no-op cleanup for a missing element', () => {
|
||||||
|
expect(() => assignStyle(null, { overflow: 'hidden' })()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isInView', () => {
|
||||||
|
it('returns false when visualViewport is unavailable', () => {
|
||||||
|
const el = makeEl();
|
||||||
|
const original = window.visualViewport;
|
||||||
|
Object.defineProperty(globalThis, 'visualViewport', { value: null, configurable: true });
|
||||||
|
|
||||||
|
expect(isInView(el)).toBe(false);
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'visualViewport', { value: original, configurable: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* A patch of inline styles — a map of CSS property names to string values.
|
||||||
|
* Keys may be camelCase DOM style properties (`borderRadius`) or `--custom`
|
||||||
|
* properties (set verbatim via `setProperty`).
|
||||||
|
*/
|
||||||
|
export type StylePatch = Record<string, string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The axis a translation is read along: `x` (horizontal) or `y` (vertical).
|
||||||
|
*/
|
||||||
|
export type TranslateAxis = 'x' | 'y';
|
||||||
|
|
||||||
|
// Remembers the styles that {@link setStyle} overwrote, keyed by element, so
|
||||||
|
// {@link resetStyle} can put them back. A WeakMap lets the entry be collected
|
||||||
|
// once the element is gone.
|
||||||
|
const originalStyles = new WeakMap<HTMLElement, StylePatch>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name setStyle
|
||||||
|
* @category Browsers
|
||||||
|
* @description Applies a batch of inline styles to an element, remembering the
|
||||||
|
* values it overwrote so {@link resetStyle} can restore them later. `--custom`
|
||||||
|
* properties are written through `setProperty`. Pass `ignoreCache` to apply the
|
||||||
|
* styles without recording the originals (e.g. for transient, per-frame writes
|
||||||
|
* during a drag that you intend to clear wholesale).
|
||||||
|
*
|
||||||
|
* @param {Element | HTMLElement | null} [element] The element to style (ignored if not an `HTMLElement`)
|
||||||
|
* @param {StylePatch} [styles] The property/value pairs to apply
|
||||||
|
* @param {boolean} [ignoreCache] Skip remembering the overwritten values
|
||||||
|
* @returns {void}
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* setStyle(el, { transition: 'none', transform: 'translateY(20px)' });
|
||||||
|
* setStyle(el, { opacity: '0.5' }, true); // transient — won't be restored
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function setStyle(element?: Element | HTMLElement | null, styles?: StylePatch, ignoreCache = false): void {
|
||||||
|
if (!element || !(element instanceof HTMLElement) || !styles)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const previous: StylePatch = {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(styles)) {
|
||||||
|
if (key.startsWith('--')) {
|
||||||
|
element.style.setProperty(key, value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
previous[key] = (element.style as unknown as StylePatch)[key];
|
||||||
|
(element.style as unknown as StylePatch)[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ignoreCache)
|
||||||
|
return;
|
||||||
|
|
||||||
|
originalStyles.set(element, previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name resetStyle
|
||||||
|
* @category Browsers
|
||||||
|
* @description Restores the inline styles an element had before the most recent
|
||||||
|
* cached {@link setStyle}. With `prop` it restores a single property; otherwise
|
||||||
|
* it restores every property that was remembered. A no-op if nothing was cached.
|
||||||
|
*
|
||||||
|
* @param {Element | HTMLElement | null} element The element to restore
|
||||||
|
* @param {string} [prop] Restore only this property instead of all of them
|
||||||
|
* @returns {void}
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* resetStyle(el); // restore everything setStyle changed
|
||||||
|
* resetStyle(el, 'transform'); // restore just the transform
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function resetStyle(element: Element | HTMLElement | null, prop?: string): void {
|
||||||
|
if (!element || !(element instanceof HTMLElement))
|
||||||
|
return;
|
||||||
|
|
||||||
|
const previous = originalStyles.get(element);
|
||||||
|
|
||||||
|
if (!previous)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (prop) {
|
||||||
|
(element.style as unknown as StylePatch)[prop] = previous[prop];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(previous))
|
||||||
|
(element.style as unknown as StylePatch)[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name getTranslate
|
||||||
|
* @category Browsers
|
||||||
|
* @description Reads the current translation of an element along one axis from
|
||||||
|
* its computed `transform`, parsing both `matrix(...)` (2D) and `matrix3d(...)`
|
||||||
|
* (3D) forms. Returns `null` when the element has no matrix transform.
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} element The element to measure
|
||||||
|
* @param {TranslateAxis} axis `'x'` for horizontal, `'y'` for vertical
|
||||||
|
* @returns {number | null} The translation in pixels, or `null` if none
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const offset = getTranslate(panel, 'y'); // px the panel is shifted down
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function getTranslate(element: HTMLElement, axis: TranslateAxis): number | null {
|
||||||
|
const style = globalThis.getComputedStyle(element);
|
||||||
|
const transform
|
||||||
|
// @ts-expect-error — vendor-prefixed transforms only exist in some browsers
|
||||||
|
= style.transform || style.webkitTransform || style.mozTransform;
|
||||||
|
|
||||||
|
let match = transform.match(/^matrix3d\((.+)\)$/);
|
||||||
|
if (match) {
|
||||||
|
// matrix3d: the translate components live at indices 12 (x) and 13 (y).
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function/matrix3d
|
||||||
|
return Number.parseFloat(match[1].split(', ')[axis === 'y' ? 13 : 12]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// matrix: the translate components live at indices 4 (x) and 5 (y).
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function/matrix
|
||||||
|
match = transform.match(/^matrix\((.+)\)$/);
|
||||||
|
return match ? Number.parseFloat(match[1].split(', ')[axis === 'y' ? 5 : 4]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name assignStyle
|
||||||
|
* @category Browsers
|
||||||
|
* @description Merges a style patch onto an element's inline `style` and returns
|
||||||
|
* a cleanup function that restores the element's entire previous `cssText`.
|
||||||
|
* Unlike {@link setStyle}, the snapshot is the full `cssText`, so the cleanup is
|
||||||
|
* an all-or-nothing revert — handy for scoped effects.
|
||||||
|
*
|
||||||
|
* @param {HTMLElement | null | undefined} element The element to style
|
||||||
|
* @param {Partial<CSSStyleDeclaration>} style The styles to assign
|
||||||
|
* @returns {() => void} A cleanup function that restores the previous `cssText`
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const restore = assignStyle(document.body, { overflow: 'hidden' });
|
||||||
|
* // ...later
|
||||||
|
* restore();
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function assignStyle(element: HTMLElement | null | undefined, style: Partial<CSSStyleDeclaration>): () => void {
|
||||||
|
if (!element)
|
||||||
|
return () => {};
|
||||||
|
|
||||||
|
const previousCssText = element.style.cssText;
|
||||||
|
Object.assign(element.style, style);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
element.style.cssText = previousCssText;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name isInView
|
||||||
|
* @category Browsers
|
||||||
|
* @description Reports whether an element is fully within the visual viewport,
|
||||||
|
* accounting for on-screen keyboards via `window.visualViewport`. A 40px slack
|
||||||
|
* is allowed at the bottom to tolerate Safari's viewport quirks. Returns `false`
|
||||||
|
* when `visualViewport` is unavailable.
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} element The element to test
|
||||||
|
* @returns {boolean} `true` if the element's rect fits inside the visual viewport
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* if (!isInView(focusedField)) scrollIntoView(focusedField);
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function isInView(element: HTMLElement): boolean {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (!window.visualViewport)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
rect.top >= 0
|
||||||
|
&& rect.left >= 0
|
||||||
|
// +40 of slack for Safari's visual-viewport reporting.
|
||||||
|
&& rect.bottom <= window.visualViewport.height - 40
|
||||||
|
&& rect.right <= window.visualViewport.width
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
export * from './animationLifecycle';
|
export * from './animationLifecycle';
|
||||||
|
export * from './domStyle';
|
||||||
export * from './focusGuard';
|
export * from './focusGuard';
|
||||||
export * from './focusScope';
|
export * from './focusScope';
|
||||||
export * from './hideOthers';
|
export * from './hideOthers';
|
||||||
|
export * from './inputState';
|
||||||
|
export * from './userAgent';
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { readInputState, writeInputState } from './index';
|
||||||
|
|
||||||
|
function makeInput(value = ''): HTMLInputElement {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.value = value;
|
||||||
|
document.body.append(input);
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('readInputState', () => {
|
||||||
|
it('reads value and selection', () => {
|
||||||
|
const input = makeInput('hello');
|
||||||
|
input.setSelectionRange(1, 3);
|
||||||
|
|
||||||
|
expect(readInputState(input)).toEqual({ value: 'hello', selection: [1, 3] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a collapsed caret at the end when selection is null', () => {
|
||||||
|
const input = makeInput('abc');
|
||||||
|
// Force a null selection (some input types report null).
|
||||||
|
Object.defineProperty(input, 'selectionStart', { value: null });
|
||||||
|
Object.defineProperty(input, 'selectionEnd', { value: null });
|
||||||
|
|
||||||
|
expect(readInputState(input)).toEqual({ value: 'abc', selection: [3, 3] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('writeInputState', () => {
|
||||||
|
it('writes the value', () => {
|
||||||
|
const input = makeInput('a');
|
||||||
|
writeInputState(input, { value: '(12)', selection: [4, 4] });
|
||||||
|
|
||||||
|
expect(input.value).toBe('(12)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves the caret only while the element is focused', () => {
|
||||||
|
const input = makeInput('12345');
|
||||||
|
input.focus();
|
||||||
|
writeInputState(input, { value: '12345', selection: [2, 4] });
|
||||||
|
|
||||||
|
expect(input.selectionStart).toBe(2);
|
||||||
|
expect(input.selectionEnd).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not reposition the caret when not focused', () => {
|
||||||
|
const input = makeInput('12345');
|
||||||
|
input.setSelectionRange(0, 0);
|
||||||
|
input.blur();
|
||||||
|
writeInputState(input, { value: '12345', selection: [3, 3] });
|
||||||
|
|
||||||
|
// Unfocused: selection is left untouched by setSelectionRange.
|
||||||
|
expect(input.selectionStart).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* The editable state of a text field: its current text and selection bounds.
|
||||||
|
* Framework-agnostic and structurally compatible with any `{ value, selection }`
|
||||||
|
* pair (e.g. an input-masking engine's element state).
|
||||||
|
*/
|
||||||
|
export interface InputState {
|
||||||
|
/**
|
||||||
|
* The element's current `value`.
|
||||||
|
*/
|
||||||
|
readonly value: string;
|
||||||
|
/**
|
||||||
|
* The selection as `[from, to]` (collapsed caret when `from === to`).
|
||||||
|
*/
|
||||||
|
readonly selection: readonly [from: number, to: number];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A text field whose value and selection can be read and written.
|
||||||
|
*/
|
||||||
|
export type TextFieldElement = HTMLInputElement | HTMLTextAreaElement;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name readInputState
|
||||||
|
* @category Browsers
|
||||||
|
* @description Reads the value and current selection of an `<input>`/`<textarea>`
|
||||||
|
* into a plain {@link InputState}. A `null` selection (some input types report it)
|
||||||
|
* falls back to a collapsed caret at the end of the value.
|
||||||
|
*
|
||||||
|
* @param {TextFieldElement} element The input or textarea to read
|
||||||
|
* @returns {InputState} The element's `{ value, selection }`
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { value, selection } = readInputState(inputEl);
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function readInputState(element: TextFieldElement): InputState {
|
||||||
|
const { value, selectionStart, selectionEnd } = element;
|
||||||
|
const end = value.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
value,
|
||||||
|
selection: [selectionStart ?? end, selectionEnd ?? end],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name writeInputState
|
||||||
|
* @category Browsers
|
||||||
|
* @description Writes value and selection back to an `<input>`/`<textarea>`. The
|
||||||
|
* value is only assigned when it actually changed (avoids spurious cursor jumps),
|
||||||
|
* and the caret is moved **only while the element is focused** so programmatic
|
||||||
|
* updates never steal or reposition focus. `setSelectionRange` is guarded because
|
||||||
|
* some input types (`number`, `email`, `date`) forbid it.
|
||||||
|
*
|
||||||
|
* @param {TextFieldElement} element The input or textarea to update
|
||||||
|
* @param {InputState} state The `{ value, selection }` to apply
|
||||||
|
* @returns {void}
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* writeInputState(inputEl, { value: '(123)', selection: [5, 5] });
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function writeInputState(element: TextFieldElement, state: InputState): void {
|
||||||
|
const [from, to] = state.selection;
|
||||||
|
|
||||||
|
if (element.value !== state.value)
|
||||||
|
element.value = state.value;
|
||||||
|
|
||||||
|
if (element.ownerDocument.activeElement !== element)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
element.setSelectionRange(from, to);
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// Input types like number/email/date throw on setSelectionRange — ignore.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { isMac, isMobileFirefox, isSafari, testUserAgentPlatform } from './index';
|
||||||
|
|
||||||
|
function stubPlatform(platform: string): void {
|
||||||
|
vi.spyOn(globalThis.navigator, 'platform', 'get').mockReturnValue(platform);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubUserAgent(ua: string): void {
|
||||||
|
vi.spyOn(globalThis.navigator, 'userAgent', 'get').mockReturnValue(ua);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('testUserAgentPlatform', () => {
|
||||||
|
it('matches against navigator.platform', () => {
|
||||||
|
stubPlatform('MacIntel');
|
||||||
|
expect(testUserAgentPlatform(/^Mac/)).toBe(true);
|
||||||
|
expect(testUserAgentPlatform(/^Win/)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isMac', () => {
|
||||||
|
it('is true on a Mac platform', () => {
|
||||||
|
stubPlatform('MacIntel');
|
||||||
|
expect(isMac()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false elsewhere', () => {
|
||||||
|
stubPlatform('Win32');
|
||||||
|
expect(isMac()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isSafari', () => {
|
||||||
|
it('is true for a Safari UA', () => {
|
||||||
|
stubUserAgent('Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15');
|
||||||
|
expect(isSafari()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false for Chrome', () => {
|
||||||
|
stubUserAgent('Mozilla/5.0 (Macintosh) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36');
|
||||||
|
expect(isSafari()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isMobileFirefox', () => {
|
||||||
|
it('is true for Android Firefox', () => {
|
||||||
|
stubUserAgent('Mozilla/5.0 (Android 14; Mobile; rv:121.0) Gecko/121.0 Firefox/121.0');
|
||||||
|
expect(isMobileFirefox()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is true for iOS Firefox (FxiOS)', () => {
|
||||||
|
stubUserAgent('Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 FxiOS/121.0 Mobile/15E148 Safari/605.1.15');
|
||||||
|
expect(isMobileFirefox()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false for desktop Firefox', () => {
|
||||||
|
stubUserAgent('Mozilla/5.0 (Windows NT 10.0; rv:121.0) Gecko/20100101 Firefox/121.0');
|
||||||
|
expect(isMobileFirefox()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* @name testUserAgentPlatform
|
||||||
|
* @category Browsers
|
||||||
|
* @description Tests `navigator.platform` against a regular expression, guarding
|
||||||
|
* for non-browser environments. Returns `undefined` when there is no `navigator`
|
||||||
|
* (e.g. during SSR) so callers can distinguish "no" from "unknown".
|
||||||
|
*
|
||||||
|
* @param {RegExp} re The pattern to test `navigator.platform` against
|
||||||
|
* @returns {boolean | undefined} The match result, or `undefined` outside a browser
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const onMac = testUserAgentPlatform(/^Mac/);
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function testUserAgentPlatform(re: RegExp): boolean | undefined {
|
||||||
|
return globalThis.navigator !== undefined
|
||||||
|
? re.test(globalThis.navigator.platform)
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name isMac
|
||||||
|
* @category Browsers
|
||||||
|
* @description Whether the current platform is macOS (per `navigator.platform`).
|
||||||
|
* Note iPadOS reports as a Mac — combine with {@link isIPad} to disambiguate.
|
||||||
|
*
|
||||||
|
* @returns {boolean | undefined} `true` on macOS, `undefined` outside a browser
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function isMac(): boolean | undefined {
|
||||||
|
return testUserAgentPlatform(/^Mac/);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name isIPhone
|
||||||
|
* @category Browsers
|
||||||
|
* @description Whether the current platform is an iPhone (per `navigator.platform`).
|
||||||
|
*
|
||||||
|
* @returns {boolean | undefined} `true` on iPhone, `undefined` outside a browser
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function isIPhone(): boolean | undefined {
|
||||||
|
return testUserAgentPlatform(/^iPhone/);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name isIPad
|
||||||
|
* @category Browsers
|
||||||
|
* @description Whether the current device is an iPad. iPadOS 13+ masquerades as a
|
||||||
|
* Mac, so this also treats a touch-capable Mac (`maxTouchPoints > 1`) as an iPad.
|
||||||
|
*
|
||||||
|
* @returns {boolean | undefined} `true` on iPad, `undefined` outside a browser
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function isIPad(): boolean | undefined {
|
||||||
|
return (
|
||||||
|
testUserAgentPlatform(/^iPad/)
|
||||||
|
// iPadOS 13+ lies and reports as a Mac; touch support gives it away.
|
||||||
|
|| (isMac() && navigator.maxTouchPoints > 1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name isIOS
|
||||||
|
* @category Browsers
|
||||||
|
* @description Whether the current device runs iOS/iPadOS (iPhone or iPad).
|
||||||
|
*
|
||||||
|
* @returns {boolean | undefined} `true` on iOS, `undefined` outside a browser
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function isIOS(): boolean | undefined {
|
||||||
|
return isIPhone() || isIPad();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name isSafari
|
||||||
|
* @category Browsers
|
||||||
|
* @description Whether the current browser is Safari (desktop or iOS), excluding
|
||||||
|
* Chrome and Android browsers that also include "Safari" in their UA string.
|
||||||
|
*
|
||||||
|
* @returns {boolean} `true` if the user agent looks like Safari
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function isSafari(): boolean {
|
||||||
|
if (typeof navigator === 'undefined')
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// eslint-disable-next-line regexp/no-unused-capturing-group
|
||||||
|
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name isMobileFirefox
|
||||||
|
* @category Browsers
|
||||||
|
* @description Whether the current browser is Firefox on a mobile device
|
||||||
|
* (Android Firefox or iOS Firefox / `FxiOS`).
|
||||||
|
*
|
||||||
|
* @returns {boolean} `true` on mobile Firefox
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function isMobileFirefox(): boolean {
|
||||||
|
if (typeof navigator === 'undefined')
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const userAgent = navigator.userAgent;
|
||||||
|
return (
|
||||||
|
(/Firefox/.test(userAgent) && /Mobile/.test(userAgent)) // Android Firefox
|
||||||
|
|| /FxiOS/.test(userAgent) // iOS Firefox
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Payment-card brands: detection patterns (IIN/BIN prefixes and ranges), the
|
||||||
|
* number template (`#` = a digit), valid total lengths, and the security-code
|
||||||
|
* info. Derived from the MIT-licensed `credit-card-type` dataset (Braintree).
|
||||||
|
* The template uses the brand's gap positions; the trailing group is sized to the
|
||||||
|
* longest valid length, so shorter numbers simply leave it partly empty.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A card brand's prefix pattern: an exact prefix string, or an inclusive numeric
|
||||||
|
* range `[min, max]` whose bounds have the same digit length.
|
||||||
|
*/
|
||||||
|
export type CardPattern = string | readonly [min: number, max: number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One payment-card brand.
|
||||||
|
*/
|
||||||
|
export interface CardBrand {
|
||||||
|
/** Brand id, e.g. `'visa'`. */
|
||||||
|
readonly brand: string;
|
||||||
|
/** Display name, e.g. `'Visa'`. */
|
||||||
|
readonly name: string;
|
||||||
|
/** Number template; `#` is a digit, e.g. `'#### #### #### ####'`. */
|
||||||
|
readonly template: string;
|
||||||
|
/** Valid total digit lengths, e.g. `[16, 18, 19]`. */
|
||||||
|
readonly lengths: readonly number[];
|
||||||
|
/** Security code metadata, e.g. `{ name: 'CVV', size: 3 }`. */
|
||||||
|
readonly code: { readonly name: string; readonly size: number };
|
||||||
|
/** IIN/BIN detection patterns (prefixes and ranges). */
|
||||||
|
readonly patterns: readonly CardPattern[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every supported card brand. Resolve a number with `findCardBrand`.
|
||||||
|
*/
|
||||||
|
export const CARD_BRANDS: readonly CardBrand[] = [
|
||||||
|
{ brand: 'visa', name: 'Visa', template: '#### #### #### #######', lengths: [16, 18, 19], code: { name: 'CVV', size: 3 }, patterns: ['4'] },
|
||||||
|
{ brand: 'mastercard', name: 'Mastercard', template: '#### #### #### ####', lengths: [16], code: { name: 'CVC', size: 3 }, patterns: [[51, 55], [2221, 2229], [223, 229], [23, 26], [270, 271], '2720'] },
|
||||||
|
{ brand: 'american-express', name: 'American Express', template: '#### ###### #####', lengths: [15], code: { name: 'CID', size: 4 }, patterns: ['34', '37'] },
|
||||||
|
{ brand: 'diners-club', name: 'Diners Club', template: '#### ###### #########', lengths: [14, 16, 19], code: { name: 'CVV', size: 3 }, patterns: [[300, 305], '36', '38', '39'] },
|
||||||
|
{ brand: 'discover', name: 'Discover', template: '#### #### #### #######', lengths: [16, 19], code: { name: 'CID', size: 3 }, patterns: ['6011', [644, 649], '65'] },
|
||||||
|
{ brand: 'jcb', name: 'JCB', template: '#### #### #### #######', lengths: [16, 17, 18, 19], code: { name: 'CVV', size: 3 }, patterns: ['2131', '1800', [3528, 3589]] },
|
||||||
|
{ brand: 'unionpay', name: 'UnionPay', template: '#### #### #### #######', lengths: [14, 15, 16, 17, 18, 19], code: { name: 'CVN', size: 3 }, patterns: ['620', [62100, 62182], [62184, 62187], [62185, 62197], [62200, 62205], [622010, 622999], '622018', [62207, 62209], [623, 626], '6270', '6272', '6276', [627700, 627779], [627781, 627799], [6282, 6289], '6291', '6292', '810', [8110, 8131], [8132, 8151], [8152, 8163], [8164, 8171]] },
|
||||||
|
{ brand: 'maestro', name: 'Maestro', template: '#### #### #### #######', lengths: [12, 13, 14, 15, 16, 17, 18, 19], code: { name: 'CVC', size: 3 }, patterns: ['493698', [500000, 504174], [504176, 506698], [506779, 508999], [56, 59], '63', '67', '6'] },
|
||||||
|
{ brand: 'elo', name: 'Elo', template: '#### #### #### ####', lengths: [16], code: { name: 'CVE', size: 3 }, patterns: ['401178', '401179', '438935', '457631', '457632', '431274', '451416', '457393', '504175', [506699, 506778], [509000, 509999], '627780', '636297', '636368', [650031, 650033], [650035, 650051], [650405, 650439], [650485, 650538], [650541, 650598], [650700, 650718], [650720, 650727], [650901, 650978], [651652, 651679], [655000, 655019], [655021, 655058]] },
|
||||||
|
{ brand: 'mir', name: 'Mir', template: '#### #### #### #######', lengths: [16, 17, 18, 19], code: { name: 'CVP2', size: 3 }, patterns: [[2200, 2204]] },
|
||||||
|
{ brand: 'hiper', name: 'Hiper', template: '#### #### #### ####', lengths: [16], code: { name: 'CVC', size: 3 }, patterns: ['637095', '63737423', '63743358', '637568', '637599', '637609', '637612'] },
|
||||||
|
{ brand: 'hipercard', name: 'Hipercard', template: '#### #### #### ####', lengths: [16], code: { name: 'CVC', size: 3 }, patterns: ['606282'] },
|
||||||
|
{ brand: 'naranja', name: 'Naranja', template: '#### #### #### ####', lengths: [16], code: { name: 'CVV', size: 3 }, patterns: ['589562', '402918', '527572'] },
|
||||||
|
{ brand: 'verve', name: 'Verve', template: '#### #### #### #######', lengths: [16, 18, 19], code: { name: 'CVV', size: 3 }, patterns: [[506099, 506127], '506129', [506133, 506150], [506158, 506163], '506166', '506168', '506170', '506173', [506176, 506180], '506184', [506187, 506188], '506191', '506195', '506197', '507865', '507866', [507868, 507877], [507880, 507888], '507900', '507941'] },
|
||||||
|
];
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { CARD_BRANDS } from './card-brands';
|
||||||
|
import { findCardBrand } from './find-card-brand';
|
||||||
|
|
||||||
|
describe(findCardBrand, () => {
|
||||||
|
it('detects major brands by IIN', () => {
|
||||||
|
expect(findCardBrand('4111111111111111')?.brand).toBe('visa');
|
||||||
|
expect(findCardBrand('5500005555555559')?.brand).toBe('mastercard');
|
||||||
|
expect(findCardBrand('2221001234567890')?.brand).toBe('mastercard'); // 2-series
|
||||||
|
expect(findCardBrand('371449635398431')?.brand).toBe('american-express');
|
||||||
|
expect(findCardBrand('30569309025904')?.brand).toBe('diners-club');
|
||||||
|
expect(findCardBrand('6011000990139424')?.brand).toBe('discover');
|
||||||
|
expect(findCardBrand('2200123412341234')?.brand).toBe('mir');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('narrows down as digits are typed', () => {
|
||||||
|
expect(findCardBrand('4')?.brand).toBe('visa');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes a template, lengths and security code for every brand', () => {
|
||||||
|
expect(CARD_BRANDS.length).toBeGreaterThan(5);
|
||||||
|
for (const brand of CARD_BRANDS) {
|
||||||
|
expect(brand.template).toMatch(/#/);
|
||||||
|
expect(brand.lengths.length).toBeGreaterThan(0);
|
||||||
|
expect(brand.code.size).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for empty or unknown input', () => {
|
||||||
|
expect(findCardBrand('')).toBeUndefined();
|
||||||
|
expect(findCardBrand('0000')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { CARD_BRANDS } from './card-brands';
|
||||||
|
import type { CardBrand, CardPattern } from './card-brands';
|
||||||
|
|
||||||
|
interface RangeMeta {
|
||||||
|
readonly width: number;
|
||||||
|
readonly minString: string;
|
||||||
|
readonly maxString: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Precompute the constant string forms of every range pattern once, so the
|
||||||
|
// per-keystroke scan doesn't re-derive them via String() on each comparison.
|
||||||
|
const RANGE_META = new Map<CardPattern, RangeMeta>();
|
||||||
|
for (const brand of CARD_BRANDS) {
|
||||||
|
for (const pattern of brand.patterns) {
|
||||||
|
if (typeof pattern !== 'string') {
|
||||||
|
const maxString = String(pattern[1]);
|
||||||
|
RANGE_META.set(pattern, { width: maxString.length, minString: String(pattern[0]), maxString });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Score how specifically `digits` matches a pattern: the number of leading digits
|
||||||
|
* that match (higher = more specific), or `0` for no match. Supports partial
|
||||||
|
* input as the user types.
|
||||||
|
*/
|
||||||
|
function patternScore(digits: string, pattern: CardPattern): number {
|
||||||
|
if (typeof pattern === 'string') {
|
||||||
|
const length = Math.min(pattern.length, digits.length);
|
||||||
|
return digits.slice(0, length) === pattern.slice(0, length) ? length : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = RANGE_META.get(pattern);
|
||||||
|
const maxString = meta ? meta.maxString : String(pattern[1]);
|
||||||
|
const minString = meta ? meta.minString : String(pattern[0]);
|
||||||
|
const width = meta ? meta.width : maxString.length;
|
||||||
|
|
||||||
|
const prefix = digits.slice(0, width);
|
||||||
|
const value = Number(prefix);
|
||||||
|
const low = Number(minString.slice(0, prefix.length));
|
||||||
|
const high = Number(maxString.slice(0, prefix.length));
|
||||||
|
|
||||||
|
return value >= low && value <= high ? prefix.length : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name findCardBrand
|
||||||
|
* @category Multi
|
||||||
|
* @description Detect a payment-card brand from a number's digits by its IIN/BIN
|
||||||
|
* pattern. Returns the brand whose pattern matches the most leading digits (so it
|
||||||
|
* narrows down as the user types), or `undefined` if none match. Pure.
|
||||||
|
*
|
||||||
|
* @param {string} digits The card number's digits (no separators)
|
||||||
|
* @param {readonly CardBrand[]} [brands=CARD_BRANDS] The brand list
|
||||||
|
* @returns {CardBrand | undefined} The detected brand, or `undefined`
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* findCardBrand('4111111111111111')?.brand; // 'visa'
|
||||||
|
* findCardBrand('371449635398431')?.brand; // 'american-express'
|
||||||
|
* findCardBrand('2200123412341234')?.brand; // 'mir'
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function findCardBrand(
|
||||||
|
digits: string,
|
||||||
|
brands: readonly CardBrand[] = CARD_BRANDS,
|
||||||
|
): CardBrand | undefined {
|
||||||
|
if (!digits)
|
||||||
|
return undefined;
|
||||||
|
|
||||||
|
let best: CardBrand | undefined;
|
||||||
|
let bestScore = 0;
|
||||||
|
|
||||||
|
for (const brand of brands) {
|
||||||
|
for (const pattern of brand.patterns) {
|
||||||
|
const score = patternScore(digits, pattern);
|
||||||
|
if (score > bestScore) {
|
||||||
|
best = brand;
|
||||||
|
bestScore = score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './card-brands';
|
||||||
|
export * from './find-card-brand';
|
||||||
|
export * from './validate';
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { luhn } from '@robonen/encoding';
|
||||||
|
import { isValidCardNumber } from './validate';
|
||||||
|
|
||||||
|
describe(isValidCardNumber, () => {
|
||||||
|
it('requires a valid checksum and a brand-matching length', () => {
|
||||||
|
expect(isValidCardNumber('4111 1111 1111 1111')).toBe(true); // visa, 16
|
||||||
|
expect(isValidCardNumber('371449635398431')).toBe(true); // amex, 15
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a Luhn-valid number outside the valid length range', () => {
|
||||||
|
expect(luhn('79927398713')).toBe(true); // Luhn-valid…
|
||||||
|
expect(isValidCardNumber('79927398713')).toBe(false); // …but only 11 digits
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a bad checksum', () => {
|
||||||
|
expect(isValidCardNumber('4111 1111 1111 1112')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { luhn } from '@robonen/encoding';
|
||||||
|
import { CARD_BRANDS } from './card-brands';
|
||||||
|
import type { CardBrand } from './card-brands';
|
||||||
|
import { findCardBrand } from './find-card-brand';
|
||||||
|
|
||||||
|
const NON_DIGIT = /\D/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name isValidCardNumber
|
||||||
|
* @category Multi
|
||||||
|
* @description Whether `value` is a complete, valid payment-card number: it passes
|
||||||
|
* the Luhn checksum (`luhn` from `@robonen/encoding`) AND its digit length matches
|
||||||
|
* the detected {@link findCardBrand} brand (or the 12–19 digit ISO/IEC 7812 range
|
||||||
|
* when the brand is unknown). For the bare checksum, use `luhn` directly.
|
||||||
|
*
|
||||||
|
* @param {string} value The card number (separators allowed)
|
||||||
|
* @param {readonly CardBrand[]} [brands=CARD_BRANDS] The brand list
|
||||||
|
* @returns {boolean} Whether the number is structurally valid
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* isValidCardNumber('4111 1111 1111 1111'); // true
|
||||||
|
* isValidCardNumber('4111 1111 1111'); // false (wrong length)
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function isValidCardNumber(value: string, brands: readonly CardBrand[] = CARD_BRANDS): boolean {
|
||||||
|
if (!luhn(value))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const digits = value.replaceAll(NON_DIGIT, '');
|
||||||
|
const brand = findCardBrand(digits, brands);
|
||||||
|
|
||||||
|
return brand
|
||||||
|
? brand.lengths.includes(digits.length)
|
||||||
|
: digits.length >= 12 && digits.length <= 19;
|
||||||
|
}
|
||||||
@@ -1 +1,3 @@
|
|||||||
|
export * from './card';
|
||||||
export * from './global';
|
export * from './global';
|
||||||
|
export * from './intl';
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { findPhoneCountry } from './find-phone-country';
|
||||||
|
import { PHONE_COUNTRIES } from './phone-countries';
|
||||||
|
|
||||||
|
describe(findPhoneCountry, () => {
|
||||||
|
it('every dialing code is prefix-free', () => {
|
||||||
|
const codes = PHONE_COUNTRIES.map(country => country.code);
|
||||||
|
expect(codes.some(a => codes.some(b => a !== b && b.startsWith(a)))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the primary country for a shared code when no area code matches', () => {
|
||||||
|
expect(findPhoneCountry('12025550123')?.iso2).toBe('us');
|
||||||
|
expect(findPhoneCountry('74951234567')?.iso2).toBe('ru');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disambiguates a shared code by area code', () => {
|
||||||
|
expect(findPhoneCountry('14165550123')?.iso2).toBe('ca'); // +1 416 → Canada
|
||||||
|
expect(findPhoneCountry('12425550123')?.iso2).toBe('bs'); // +1 242 → Bahamas
|
||||||
|
expect(findPhoneCountry('73101234567')?.iso2).toBe('kz'); // +7 310 → Kazakhstan
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches a unique long code', () => {
|
||||||
|
expect(findPhoneCountry('380441234567')?.iso2).toBe('ua');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for empty or unknown input', () => {
|
||||||
|
expect(findPhoneCountry('')).toBeUndefined();
|
||||||
|
expect(findPhoneCountry('000')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { PHONE_COUNTRIES } from './phone-countries';
|
||||||
|
import type { PhoneCountry } from './phone-countries';
|
||||||
|
|
||||||
|
function buildPhoneIndex(countries: readonly PhoneCountry[]): Map<string, PhoneCountry[]> {
|
||||||
|
const index = new Map<string, PhoneCountry[]>();
|
||||||
|
|
||||||
|
for (const country of countries) {
|
||||||
|
let bucket = index.get(country.code);
|
||||||
|
if (!bucket) {
|
||||||
|
bucket = [];
|
||||||
|
index.set(country.code, bucket);
|
||||||
|
}
|
||||||
|
bucket.push(country);
|
||||||
|
}
|
||||||
|
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
// O(1) lookup over the default dataset, keyed by dialing code. Codes are
|
||||||
|
// prefix-free (E.164), so probing the first 3..1 digits finds the unique code —
|
||||||
|
// far cheaper than scanning all ~211 countries on every keystroke.
|
||||||
|
const PHONE_INDEX = buildPhoneIndex(PHONE_COUNTRIES);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Among countries sharing a dialing code, pick the most specific matching area
|
||||||
|
* code, else the lowest priority (the primary country). `rest` is the digits
|
||||||
|
* after the dialing code.
|
||||||
|
*/
|
||||||
|
function resolveGroup(group: readonly PhoneCountry[], rest: string): PhoneCountry | undefined {
|
||||||
|
const first = group[0];
|
||||||
|
if (!first)
|
||||||
|
return undefined;
|
||||||
|
if (group.length === 1)
|
||||||
|
return first;
|
||||||
|
|
||||||
|
let best: PhoneCountry | undefined;
|
||||||
|
let bestAreaLength = 0;
|
||||||
|
for (const country of group) {
|
||||||
|
for (const area of country.areaCodes ?? []) {
|
||||||
|
if (area.length > bestAreaLength && rest.startsWith(area)) {
|
||||||
|
best = country;
|
||||||
|
bestAreaLength = area.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (best)
|
||||||
|
return best;
|
||||||
|
|
||||||
|
return group.reduce((winner, country) =>
|
||||||
|
(winner.priority ?? 0) <= (country.priority ?? 0) ? winner : country);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name findPhoneCountry
|
||||||
|
* @category Multi
|
||||||
|
* @description Resolve a digit string to its country among a {@link PhoneCountry}
|
||||||
|
* list. Matches the **longest dialing code** (codes are prefix-free, so this is
|
||||||
|
* unambiguous), then — for countries sharing a code (NANP `+1`, `+7` RU/KZ) — the
|
||||||
|
* most specific **area code**, then the lowest **priority** (the primary country)
|
||||||
|
* when no area code matches. The default dataset is indexed for O(1) lookup; a
|
||||||
|
* custom list falls back to a linear scan.
|
||||||
|
*
|
||||||
|
* @param {string} digits The number's digits (no `+`/separators), e.g. `'14165550123'`
|
||||||
|
* @param {readonly PhoneCountry[]} [countries=PHONE_COUNTRIES] The country list
|
||||||
|
* @returns {PhoneCountry | undefined} The matched country, or `undefined`
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* findPhoneCountry('12025550123')?.iso2; // 'us'
|
||||||
|
* findPhoneCountry('14165550123')?.iso2; // 'ca' (area code 416)
|
||||||
|
* findPhoneCountry('12425550123')?.iso2; // 'bs' (area code 242)
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function findPhoneCountry(
|
||||||
|
digits: string,
|
||||||
|
countries: readonly PhoneCountry[] = PHONE_COUNTRIES,
|
||||||
|
): PhoneCountry | undefined {
|
||||||
|
if (!digits)
|
||||||
|
return undefined;
|
||||||
|
|
||||||
|
// Fast path: the default dataset is indexed by dialing code.
|
||||||
|
if (countries === PHONE_COUNTRIES) {
|
||||||
|
for (let length = Math.min(3, digits.length); length >= 1; length--) {
|
||||||
|
const bucket = PHONE_INDEX.get(digits.slice(0, length));
|
||||||
|
if (bucket)
|
||||||
|
return resolveGroup(bucket, digits.slice(length));
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback (custom list): longest dialing-code prefix via a linear scan.
|
||||||
|
let codeLength = -1;
|
||||||
|
const group: PhoneCountry[] = [];
|
||||||
|
for (const country of countries) {
|
||||||
|
if (!digits.startsWith(country.code))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (country.code.length > codeLength) {
|
||||||
|
codeLength = country.code.length;
|
||||||
|
group.length = 0;
|
||||||
|
group.push(country);
|
||||||
|
}
|
||||||
|
else if (country.code.length === codeLength) {
|
||||||
|
group.push(country);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return codeLength === -1 ? undefined : resolveGroup(group, digits.slice(codeLength));
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { getCountryFlagByCode } from './flag';
|
||||||
|
|
||||||
|
describe(getCountryFlagByCode, () => {
|
||||||
|
it('maps alpha-2 codes to flag emoji', () => {
|
||||||
|
expect(getCountryFlagByCode('US')).toBe('🇺🇸');
|
||||||
|
expect(getCountryFlagByCode('UA')).toBe('🇺🇦');
|
||||||
|
expect(getCountryFlagByCode('GB')).toBe('🇬🇧');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is case-insensitive', () => {
|
||||||
|
expect(getCountryFlagByCode('gb')).toBe(getCountryFlagByCode('GB'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty string for invalid input', () => {
|
||||||
|
expect(getCountryFlagByCode('1')).toBe('');
|
||||||
|
expect(getCountryFlagByCode('USA')).toBe('');
|
||||||
|
expect(getCountryFlagByCode('u')).toBe('');
|
||||||
|
expect(getCountryFlagByCode('')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// Offset from an ASCII uppercase letter to its Unicode regional indicator
|
||||||
|
// symbol: 'A' (U+0041) → 🇦 (U+1F1E6).
|
||||||
|
const REGIONAL_INDICATOR_OFFSET = 0x1F1E6 - 'A'.charCodeAt(0);
|
||||||
|
const ALPHA2 = /^[a-z]{2}$/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name getCountryFlagByCode
|
||||||
|
* @category Multi
|
||||||
|
* @description Convert an ISO 3166-1 alpha-2 country code (e.g. `'RU'`, `'us'`)
|
||||||
|
* into its flag emoji by mapping each letter to a Unicode regional indicator
|
||||||
|
* symbol. Case-insensitive; returns an empty string for anything that isn't two
|
||||||
|
* ASCII letters. Pure and environment-agnostic.
|
||||||
|
*
|
||||||
|
* @param {string} code The ISO 3166-1 alpha-2 code, e.g. `'US'`
|
||||||
|
* @returns {string} The flag emoji (e.g. `'🇺🇸'`), or `''` when the code is invalid
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* getCountryFlagByCode('RU'); // '🇷🇺'
|
||||||
|
* getCountryFlagByCode('us'); // '🇺🇸'
|
||||||
|
* getCountryFlagByCode('123'); // ''
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function getCountryFlagByCode(code: string): string {
|
||||||
|
if (!ALPHA2.test(code))
|
||||||
|
return '';
|
||||||
|
|
||||||
|
const upper = code.toUpperCase();
|
||||||
|
|
||||||
|
return String.fromCodePoint(
|
||||||
|
REGIONAL_INDICATOR_OFFSET + upper.charCodeAt(0),
|
||||||
|
REGIONAL_INDICATOR_OFFSET + upper.charCodeAt(1),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './flag';
|
||||||
|
export * from './find-phone-country';
|
||||||
|
export * from './phone-countries';
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
/**
|
||||||
|
* Phone-number formats for every country: an international template with the
|
||||||
|
* dialing-code digits as `#` digit placeholders, plus the `priority`/`areaCodes`
|
||||||
|
* used to disambiguate countries sharing a dialing code (NANP `+1`, `+7` RU/KZ).
|
||||||
|
* NANP territories are normalized to code `'1'` (their 3-digit prefix moved into
|
||||||
|
* `areaCodes`), so every dialing code is prefix-free and the `+1` block shares one
|
||||||
|
* numbering-plan template. Derived from the MIT-licensed `react-phone-input-2`
|
||||||
|
* dataset — display formats, not authoritative validation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One country's phone-number format.
|
||||||
|
*/
|
||||||
|
export interface PhoneCountry {
|
||||||
|
/** Display name, e.g. `'United States'` (omit for custom lists). */
|
||||||
|
readonly name?: string;
|
||||||
|
/** ISO 3166-1 alpha-2 code, lowercase, e.g. `'us'` (omit for custom lists). */
|
||||||
|
readonly iso2?: string;
|
||||||
|
/** International dialing code digits (no `+`), e.g. `'1'`, `'380'`. */
|
||||||
|
readonly code: string;
|
||||||
|
/** International template; `#` is a digit, e.g. `'+# (###) ###-####'`. */
|
||||||
|
readonly template: string;
|
||||||
|
/**
|
||||||
|
* Order among countries sharing `code` — lower wins when no area code matches.
|
||||||
|
* Absent means `0` (the primary country for that code).
|
||||||
|
*/
|
||||||
|
readonly priority?: number;
|
||||||
|
/** Area codes (the digits right after `code`) that belong to this country. */
|
||||||
|
readonly areaCodes?: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every supported country. Resolve a number with `findPhoneCountry`.
|
||||||
|
*/
|
||||||
|
export const PHONE_COUNTRIES: readonly PhoneCountry[] = [
|
||||||
|
{ name: 'Afghanistan', iso2: 'af', code: '93', template: '+## #############' },
|
||||||
|
{ name: 'Albania', iso2: 'al', code: '355', template: '+### ############' },
|
||||||
|
{ name: 'Algeria', iso2: 'dz', code: '213', template: '+### ############' },
|
||||||
|
{ name: 'Andorra', iso2: 'ad', code: '376', template: '+### ############' },
|
||||||
|
{ name: 'Angola', iso2: 'ao', code: '244', template: '+### ############' },
|
||||||
|
{ name: 'Antigua and Barbuda', iso2: 'ag', code: '1', template: '+# (###) ###-####', priority: 10, areaCodes: ['268'] },
|
||||||
|
{ name: 'Argentina', iso2: 'ar', code: '54', template: '+## (##) ########' },
|
||||||
|
{ name: 'Armenia', iso2: 'am', code: '374', template: '+### ## ######' },
|
||||||
|
{ name: 'Aruba', iso2: 'aw', code: '297', template: '+### ############' },
|
||||||
|
{ name: 'Australia', iso2: 'au', code: '61', template: '+## (##) #### ####' },
|
||||||
|
{ name: 'Austria', iso2: 'at', code: '43', template: '+## #############' },
|
||||||
|
{ name: 'Azerbaijan', iso2: 'az', code: '994', template: '+### (##) ### ## ##' },
|
||||||
|
{ name: 'Bahamas', iso2: 'bs', code: '1', template: '+# (###) ###-####', priority: 10, areaCodes: ['242'] },
|
||||||
|
{ name: 'Bahrain', iso2: 'bh', code: '973', template: '+### ############' },
|
||||||
|
{ name: 'Bangladesh', iso2: 'bd', code: '880', template: '+### ############' },
|
||||||
|
{ name: 'Barbados', iso2: 'bb', code: '1', template: '+# (###) ###-####', priority: 10, areaCodes: ['246'] },
|
||||||
|
{ name: 'Belarus', iso2: 'by', code: '375', template: '+### (##) ### ## ##' },
|
||||||
|
{ name: 'Belgium', iso2: 'be', code: '32', template: '+## ### ## ## ##' },
|
||||||
|
{ name: 'Belize', iso2: 'bz', code: '501', template: '+### ############' },
|
||||||
|
{ name: 'Benin', iso2: 'bj', code: '229', template: '+### ############' },
|
||||||
|
{ name: 'Bhutan', iso2: 'bt', code: '975', template: '+### ############' },
|
||||||
|
{ name: 'Bolivia', iso2: 'bo', code: '591', template: '+### ############' },
|
||||||
|
{ name: 'Bosnia and Herzegovina', iso2: 'ba', code: '387', template: '+### ############' },
|
||||||
|
{ name: 'Botswana', iso2: 'bw', code: '267', template: '+### ############' },
|
||||||
|
{ name: 'Brazil', iso2: 'br', code: '55', template: '+## (##) #########' },
|
||||||
|
{ name: 'British Indian Ocean Territory', iso2: 'io', code: '246', template: '+### ############' },
|
||||||
|
{ name: 'Brunei', iso2: 'bn', code: '673', template: '+### ############' },
|
||||||
|
{ name: 'Bulgaria', iso2: 'bg', code: '359', template: '+### ############' },
|
||||||
|
{ name: 'Burkina Faso', iso2: 'bf', code: '226', template: '+### ############' },
|
||||||
|
{ name: 'Burundi', iso2: 'bi', code: '257', template: '+### ############' },
|
||||||
|
{ name: 'Cambodia', iso2: 'kh', code: '855', template: '+### ############' },
|
||||||
|
{ name: 'Cameroon', iso2: 'cm', code: '237', template: '+### ############' },
|
||||||
|
{ name: 'Canada', iso2: 'ca', code: '1', template: '+# (###) ###-####', priority: 1, areaCodes: ['204', '226', '236', '249', '250', '289', '306', '343', '365', '387', '403', '416', '418', '431', '437', '438', '450', '506', '514', '519', '548', '579', '581', '587', '604', '613', '639', '647', '672', '705', '709', '742', '778', '780', '782', '807', '819', '825', '867', '873', '902', '905'] },
|
||||||
|
{ name: 'Cape Verde', iso2: 'cv', code: '238', template: '+### ############' },
|
||||||
|
{ name: 'Caribbean Netherlands', iso2: 'bq', code: '599', template: '+### ############', priority: 1 },
|
||||||
|
{ name: 'Central African Republic', iso2: 'cf', code: '236', template: '+### ############' },
|
||||||
|
{ name: 'Chad', iso2: 'td', code: '235', template: '+### ############' },
|
||||||
|
{ name: 'Chile', iso2: 'cl', code: '56', template: '+## #############' },
|
||||||
|
{ name: 'China', iso2: 'cn', code: '86', template: '+## ##-#########' },
|
||||||
|
{ name: 'Colombia', iso2: 'co', code: '57', template: '+## ### ### ####' },
|
||||||
|
{ name: 'Comoros', iso2: 'km', code: '269', template: '+### ############' },
|
||||||
|
{ name: 'Congo', iso2: 'cd', code: '243', template: '+### ############' },
|
||||||
|
{ name: 'Congo', iso2: 'cg', code: '242', template: '+### ############' },
|
||||||
|
{ name: 'Costa Rica', iso2: 'cr', code: '506', template: '+### ####-####' },
|
||||||
|
{ name: 'Côte d’Ivoire', iso2: 'ci', code: '225', template: '+### ## ## ## ##' },
|
||||||
|
{ name: 'Croatia', iso2: 'hr', code: '385', template: '+### ############' },
|
||||||
|
{ name: 'Cuba', iso2: 'cu', code: '53', template: '+## #############' },
|
||||||
|
{ name: 'Curaçao', iso2: 'cw', code: '599', template: '+### ############' },
|
||||||
|
{ name: 'Cyprus', iso2: 'cy', code: '357', template: '+### ## ######' },
|
||||||
|
{ name: 'Czech Republic', iso2: 'cz', code: '420', template: '+### ### ### ###' },
|
||||||
|
{ name: 'Denmark', iso2: 'dk', code: '45', template: '+## ## ## ## ##' },
|
||||||
|
{ name: 'Djibouti', iso2: 'dj', code: '253', template: '+### ############' },
|
||||||
|
{ name: 'Dominica', iso2: 'dm', code: '1', template: '+# (###) ###-####', priority: 10, areaCodes: ['767'] },
|
||||||
|
{ name: 'Dominican Republic', iso2: 'do', code: '1', template: '+# (###) ###-####', priority: 2, areaCodes: ['809', '829', '849'] },
|
||||||
|
{ name: 'Ecuador', iso2: 'ec', code: '593', template: '+### ############' },
|
||||||
|
{ name: 'Egypt', iso2: 'eg', code: '20', template: '+## #############' },
|
||||||
|
{ name: 'El Salvador', iso2: 'sv', code: '503', template: '+### ####-####' },
|
||||||
|
{ name: 'Equatorial Guinea', iso2: 'gq', code: '240', template: '+### ############' },
|
||||||
|
{ name: 'Eritrea', iso2: 'er', code: '291', template: '+### ############' },
|
||||||
|
{ name: 'Estonia', iso2: 'ee', code: '372', template: '+### #### ######' },
|
||||||
|
{ name: 'Ethiopia', iso2: 'et', code: '251', template: '+### ############' },
|
||||||
|
{ name: 'Fiji', iso2: 'fj', code: '679', template: '+### ############' },
|
||||||
|
{ name: 'Finland', iso2: 'fi', code: '358', template: '+### ## ### ## ##' },
|
||||||
|
{ name: 'France', iso2: 'fr', code: '33', template: '+## # ## ## ## ##' },
|
||||||
|
{ name: 'French Guiana', iso2: 'gf', code: '594', template: '+### ############' },
|
||||||
|
{ name: 'French Polynesia', iso2: 'pf', code: '689', template: '+### ############' },
|
||||||
|
{ name: 'Gabon', iso2: 'ga', code: '241', template: '+### ############' },
|
||||||
|
{ name: 'Gambia', iso2: 'gm', code: '220', template: '+### ############' },
|
||||||
|
{ name: 'Georgia', iso2: 'ge', code: '995', template: '+### ############' },
|
||||||
|
{ name: 'Germany', iso2: 'de', code: '49', template: '+## #### ########' },
|
||||||
|
{ name: 'Ghana', iso2: 'gh', code: '233', template: '+### ############' },
|
||||||
|
{ name: 'Greece', iso2: 'gr', code: '30', template: '+## #############' },
|
||||||
|
{ name: 'Grenada', iso2: 'gd', code: '1', template: '+# (###) ###-####', priority: 10, areaCodes: ['473'] },
|
||||||
|
{ name: 'Guadeloupe', iso2: 'gp', code: '590', template: '+### ############' },
|
||||||
|
{ name: 'Guam', iso2: 'gu', code: '1', template: '+# (###) ###-####', priority: 10, areaCodes: ['671'] },
|
||||||
|
{ name: 'Guatemala', iso2: 'gt', code: '502', template: '+### ####-####' },
|
||||||
|
{ name: 'Guinea', iso2: 'gn', code: '224', template: '+### ############' },
|
||||||
|
{ name: 'Guinea-Bissau', iso2: 'gw', code: '245', template: '+### ############' },
|
||||||
|
{ name: 'Guyana', iso2: 'gy', code: '592', template: '+### ############' },
|
||||||
|
{ name: 'Haiti', iso2: 'ht', code: '509', template: '+### ####-####' },
|
||||||
|
{ name: 'Honduras', iso2: 'hn', code: '504', template: '+### ############' },
|
||||||
|
{ name: 'Hong Kong', iso2: 'hk', code: '852', template: '+### #### ####' },
|
||||||
|
{ name: 'Hungary', iso2: 'hu', code: '36', template: '+## #############' },
|
||||||
|
{ name: 'Iceland', iso2: 'is', code: '354', template: '+### ### ####' },
|
||||||
|
{ name: 'India', iso2: 'in', code: '91', template: '+## #####-#####' },
|
||||||
|
{ name: 'Indonesia', iso2: 'id', code: '62', template: '+## #############' },
|
||||||
|
{ name: 'Iran', iso2: 'ir', code: '98', template: '+## ### ### ####' },
|
||||||
|
{ name: 'Iraq', iso2: 'iq', code: '964', template: '+### ############' },
|
||||||
|
{ name: 'Ireland', iso2: 'ie', code: '353', template: '+### ## #######' },
|
||||||
|
{ name: 'Israel', iso2: 'il', code: '972', template: '+### ### ### ####' },
|
||||||
|
{ name: 'Italy', iso2: 'it', code: '39', template: '+## ### #######' },
|
||||||
|
{ name: 'Jamaica', iso2: 'jm', code: '1', template: '+# (###) ###-####', priority: 10, areaCodes: ['876'] },
|
||||||
|
{ name: 'Japan', iso2: 'jp', code: '81', template: '+## ## #### ####' },
|
||||||
|
{ name: 'Jordan', iso2: 'jo', code: '962', template: '+### ############' },
|
||||||
|
{ name: 'Kazakhstan', iso2: 'kz', code: '7', template: '+# (###) ###-##-##', priority: 1, areaCodes: ['310', '311', '312', '313', '315', '318', '321', '324', '325', '326', '327', '336', '7172', '73622'] },
|
||||||
|
{ name: 'Kenya', iso2: 'ke', code: '254', template: '+### ############' },
|
||||||
|
{ name: 'Kiribati', iso2: 'ki', code: '686', template: '+### ############' },
|
||||||
|
{ name: 'Kosovo', iso2: 'xk', code: '383', template: '+### ############' },
|
||||||
|
{ name: 'Kuwait', iso2: 'kw', code: '965', template: '+### ############' },
|
||||||
|
{ name: 'Kyrgyzstan', iso2: 'kg', code: '996', template: '+### ### ### ###' },
|
||||||
|
{ name: 'Laos', iso2: 'la', code: '856', template: '+### ############' },
|
||||||
|
{ name: 'Latvia', iso2: 'lv', code: '371', template: '+### ## ### ###' },
|
||||||
|
{ name: 'Lebanon', iso2: 'lb', code: '961', template: '+### ############' },
|
||||||
|
{ name: 'Lesotho', iso2: 'ls', code: '266', template: '+### ############' },
|
||||||
|
{ name: 'Liberia', iso2: 'lr', code: '231', template: '+### ############' },
|
||||||
|
{ name: 'Libya', iso2: 'ly', code: '218', template: '+### ############' },
|
||||||
|
{ name: 'Liechtenstein', iso2: 'li', code: '423', template: '+### ############' },
|
||||||
|
{ name: 'Lithuania', iso2: 'lt', code: '370', template: '+### ############' },
|
||||||
|
{ name: 'Luxembourg', iso2: 'lu', code: '352', template: '+### ############' },
|
||||||
|
{ name: 'Macau', iso2: 'mo', code: '853', template: '+### ############' },
|
||||||
|
{ name: 'Macedonia', iso2: 'mk', code: '389', template: '+### ############' },
|
||||||
|
{ name: 'Madagascar', iso2: 'mg', code: '261', template: '+### ############' },
|
||||||
|
{ name: 'Malawi', iso2: 'mw', code: '265', template: '+### ############' },
|
||||||
|
{ name: 'Malaysia', iso2: 'my', code: '60', template: '+## ##-####-####' },
|
||||||
|
{ name: 'Maldives', iso2: 'mv', code: '960', template: '+### ############' },
|
||||||
|
{ name: 'Mali', iso2: 'ml', code: '223', template: '+### ############' },
|
||||||
|
{ name: 'Malta', iso2: 'mt', code: '356', template: '+### ############' },
|
||||||
|
{ name: 'Marshall Islands', iso2: 'mh', code: '692', template: '+### ############' },
|
||||||
|
{ name: 'Martinique', iso2: 'mq', code: '596', template: '+### ############' },
|
||||||
|
{ name: 'Mauritania', iso2: 'mr', code: '222', template: '+### ############' },
|
||||||
|
{ name: 'Mauritius', iso2: 'mu', code: '230', template: '+### ############' },
|
||||||
|
{ name: 'Mexico', iso2: 'mx', code: '52', template: '+## ### ### ####' },
|
||||||
|
{ name: 'Micronesia', iso2: 'fm', code: '691', template: '+### ############' },
|
||||||
|
{ name: 'Moldova', iso2: 'md', code: '373', template: '+### (##) ##-##-##' },
|
||||||
|
{ name: 'Monaco', iso2: 'mc', code: '377', template: '+### ############' },
|
||||||
|
{ name: 'Mongolia', iso2: 'mn', code: '976', template: '+### ############' },
|
||||||
|
{ name: 'Montenegro', iso2: 'me', code: '382', template: '+### ############' },
|
||||||
|
{ name: 'Morocco', iso2: 'ma', code: '212', template: '+### ############' },
|
||||||
|
{ name: 'Mozambique', iso2: 'mz', code: '258', template: '+### ############' },
|
||||||
|
{ name: 'Myanmar', iso2: 'mm', code: '95', template: '+## #############' },
|
||||||
|
{ name: 'Namibia', iso2: 'na', code: '264', template: '+### ############' },
|
||||||
|
{ name: 'Nauru', iso2: 'nr', code: '674', template: '+### ############' },
|
||||||
|
{ name: 'Nepal', iso2: 'np', code: '977', template: '+### ############' },
|
||||||
|
{ name: 'Netherlands', iso2: 'nl', code: '31', template: '+## ## ########' },
|
||||||
|
{ name: 'New Caledonia', iso2: 'nc', code: '687', template: '+### ############' },
|
||||||
|
{ name: 'New Zealand', iso2: 'nz', code: '64', template: '+## ###-###-####' },
|
||||||
|
{ name: 'Nicaragua', iso2: 'ni', code: '505', template: '+### ############' },
|
||||||
|
{ name: 'Niger', iso2: 'ne', code: '227', template: '+### ############' },
|
||||||
|
{ name: 'Nigeria', iso2: 'ng', code: '234', template: '+### ############' },
|
||||||
|
{ name: 'North Korea', iso2: 'kp', code: '850', template: '+### ############' },
|
||||||
|
{ name: 'Norway', iso2: 'no', code: '47', template: '+## ### ## ###' },
|
||||||
|
{ name: 'Oman', iso2: 'om', code: '968', template: '+### ############' },
|
||||||
|
{ name: 'Pakistan', iso2: 'pk', code: '92', template: '+## ###-#######' },
|
||||||
|
{ name: 'Palau', iso2: 'pw', code: '680', template: '+### ############' },
|
||||||
|
{ name: 'Palestine', iso2: 'ps', code: '970', template: '+### ############' },
|
||||||
|
{ name: 'Panama', iso2: 'pa', code: '507', template: '+### ############' },
|
||||||
|
{ name: 'Papua New Guinea', iso2: 'pg', code: '675', template: '+### ############' },
|
||||||
|
{ name: 'Paraguay', iso2: 'py', code: '595', template: '+### ############' },
|
||||||
|
{ name: 'Peru', iso2: 'pe', code: '51', template: '+## #############' },
|
||||||
|
{ name: 'Philippines', iso2: 'ph', code: '63', template: '+## #### #######' },
|
||||||
|
{ name: 'Poland', iso2: 'pl', code: '48', template: '+## ###-###-###' },
|
||||||
|
{ name: 'Portugal', iso2: 'pt', code: '351', template: '+### ############' },
|
||||||
|
{ name: 'Puerto Rico', iso2: 'pr', code: '1', template: '+# (###) ###-####', priority: 3, areaCodes: ['787', '939'] },
|
||||||
|
{ name: 'Qatar', iso2: 'qa', code: '974', template: '+### ############' },
|
||||||
|
{ name: 'Réunion', iso2: 're', code: '262', template: '+### ############' },
|
||||||
|
{ name: 'Romania', iso2: 'ro', code: '40', template: '+## #############' },
|
||||||
|
{ name: 'Russia', iso2: 'ru', code: '7', template: '+# (###) ###-##-##' },
|
||||||
|
{ name: 'Rwanda', iso2: 'rw', code: '250', template: '+### ############' },
|
||||||
|
{ name: 'Saint Kitts and Nevis', iso2: 'kn', code: '1', template: '+# (###) ###-####', priority: 10, areaCodes: ['869'] },
|
||||||
|
{ name: 'Saint Lucia', iso2: 'lc', code: '1', template: '+# (###) ###-####', priority: 10, areaCodes: ['758'] },
|
||||||
|
{ name: 'Saint Vincent and the Grenadines', iso2: 'vc', code: '1', template: '+# (###) ###-####', priority: 10, areaCodes: ['784'] },
|
||||||
|
{ name: 'Samoa', iso2: 'ws', code: '685', template: '+### ############' },
|
||||||
|
{ name: 'San Marino', iso2: 'sm', code: '378', template: '+### ############' },
|
||||||
|
{ name: 'São Tomé and Príncipe', iso2: 'st', code: '239', template: '+### ############' },
|
||||||
|
{ name: 'Saudi Arabia', iso2: 'sa', code: '966', template: '+### ############' },
|
||||||
|
{ name: 'Senegal', iso2: 'sn', code: '221', template: '+### ############' },
|
||||||
|
{ name: 'Serbia', iso2: 'rs', code: '381', template: '+### ############' },
|
||||||
|
{ name: 'Seychelles', iso2: 'sc', code: '248', template: '+### ############' },
|
||||||
|
{ name: 'Sierra Leone', iso2: 'sl', code: '232', template: '+### ############' },
|
||||||
|
{ name: 'Singapore', iso2: 'sg', code: '65', template: '+## ####-####' },
|
||||||
|
{ name: 'Slovakia', iso2: 'sk', code: '421', template: '+### ############' },
|
||||||
|
{ name: 'Slovenia', iso2: 'si', code: '386', template: '+### ############' },
|
||||||
|
{ name: 'Solomon Islands', iso2: 'sb', code: '677', template: '+### ############' },
|
||||||
|
{ name: 'Somalia', iso2: 'so', code: '252', template: '+### ############' },
|
||||||
|
{ name: 'South Africa', iso2: 'za', code: '27', template: '+## #############' },
|
||||||
|
{ name: 'South Korea', iso2: 'kr', code: '82', template: '+## ### #### ####' },
|
||||||
|
{ name: 'South Sudan', iso2: 'ss', code: '211', template: '+### ############' },
|
||||||
|
{ name: 'Spain', iso2: 'es', code: '34', template: '+## ### ### ###' },
|
||||||
|
{ name: 'Sri Lanka', iso2: 'lk', code: '94', template: '+## #############' },
|
||||||
|
{ name: 'Sudan', iso2: 'sd', code: '249', template: '+### ############' },
|
||||||
|
{ name: 'Suriname', iso2: 'sr', code: '597', template: '+### ############' },
|
||||||
|
{ name: 'Swaziland', iso2: 'sz', code: '268', template: '+### ############' },
|
||||||
|
{ name: 'Sweden', iso2: 'se', code: '46', template: '+## (###) ###-###' },
|
||||||
|
{ name: 'Switzerland', iso2: 'ch', code: '41', template: '+## ## ### ## ##' },
|
||||||
|
{ name: 'Syria', iso2: 'sy', code: '963', template: '+### ############' },
|
||||||
|
{ name: 'Taiwan', iso2: 'tw', code: '886', template: '+### ############' },
|
||||||
|
{ name: 'Tajikistan', iso2: 'tj', code: '992', template: '+### ############' },
|
||||||
|
{ name: 'Tanzania', iso2: 'tz', code: '255', template: '+### ############' },
|
||||||
|
{ name: 'Thailand', iso2: 'th', code: '66', template: '+## #############' },
|
||||||
|
{ name: 'Timor-Leste', iso2: 'tl', code: '670', template: '+### ############' },
|
||||||
|
{ name: 'Togo', iso2: 'tg', code: '228', template: '+### ############' },
|
||||||
|
{ name: 'Tonga', iso2: 'to', code: '676', template: '+### ############' },
|
||||||
|
{ name: 'Trinidad and Tobago', iso2: 'tt', code: '1', template: '+# (###) ###-####', priority: 10, areaCodes: ['868'] },
|
||||||
|
{ name: 'Tunisia', iso2: 'tn', code: '216', template: '+### ############' },
|
||||||
|
{ name: 'Turkey', iso2: 'tr', code: '90', template: '+## ### ### ## ##' },
|
||||||
|
{ name: 'Turkmenistan', iso2: 'tm', code: '993', template: '+### ############' },
|
||||||
|
{ name: 'Tuvalu', iso2: 'tv', code: '688', template: '+### ############' },
|
||||||
|
{ name: 'Uganda', iso2: 'ug', code: '256', template: '+### ############' },
|
||||||
|
{ name: 'Ukraine', iso2: 'ua', code: '380', template: '+### (##) ### ## ##' },
|
||||||
|
{ name: 'United Arab Emirates', iso2: 'ae', code: '971', template: '+### ############' },
|
||||||
|
{ name: 'United Kingdom', iso2: 'gb', code: '44', template: '+## #### ######' },
|
||||||
|
{ name: 'United States', iso2: 'us', code: '1', template: '+# (###) ###-####' },
|
||||||
|
{ name: 'Uruguay', iso2: 'uy', code: '598', template: '+### ############' },
|
||||||
|
{ name: 'Uzbekistan', iso2: 'uz', code: '998', template: '+### ## ### ## ##' },
|
||||||
|
{ name: 'Vanuatu', iso2: 'vu', code: '678', template: '+### ############' },
|
||||||
|
{ name: 'Vatican City', iso2: 'va', code: '39', template: '+## ### #######', priority: 1 },
|
||||||
|
{ name: 'Venezuela', iso2: 've', code: '58', template: '+## #############' },
|
||||||
|
{ name: 'Vietnam', iso2: 'vn', code: '84', template: '+## #############' },
|
||||||
|
{ name: 'Yemen', iso2: 'ye', code: '967', template: '+### ############' },
|
||||||
|
{ name: 'Zambia', iso2: 'zm', code: '260', template: '+### ############' },
|
||||||
|
{ name: 'Zimbabwe', iso2: 'zw', code: '263', template: '+### ############' },
|
||||||
|
];
|
||||||
@@ -19,7 +19,10 @@ const anatomyCode = computed(() => {
|
|||||||
|
|
||||||
const imports = `import {\n${names.map(n => ` ${n},`).join('\n')}\n} from '${importPath.value}';`;
|
const 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;
|
let tree: string;
|
||||||
if (rest.length === 0) {
|
if (rest.length === 0) {
|
||||||
tree = `<${root} />`;
|
tree = `<${root} />`;
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Component } from 'vue';
|
import type { Component } from 'vue';
|
||||||
|
import { demoSources } from '#docs/demo-sources';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
component: Component;
|
component: Component;
|
||||||
source: string;
|
/** Key into the lazy demo-source map (`${pkg}/${slug}`). */
|
||||||
|
sourceKey: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const showSource = ref(false);
|
const showSource = ref(false);
|
||||||
|
const source = ref('');
|
||||||
|
|
||||||
const { highlighted, highlightReactive } = useShiki();
|
const { highlighted, highlightReactive } = useShiki();
|
||||||
|
|
||||||
|
// Fetch the raw demo source only when the user first opens it, then highlight.
|
||||||
watch(showSource, async (show) => {
|
watch(showSource, async (show) => {
|
||||||
if (show && !highlighted.value) {
|
if (!show) return;
|
||||||
await highlightReactive(props.source, 'vue');
|
if (!source.value)
|
||||||
}
|
source.value = (await demoSources[props.sourceKey]?.()) ?? '';
|
||||||
|
if (source.value && !highlighted.value)
|
||||||
|
await highlightReactive(source.value, 'vue');
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ defineProps<{
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="method.description" class="text-sm text-(--fg-muted) mb-3">
|
<p v-if="method.description" class="text-sm text-(--fg-muted) mb-3">
|
||||||
{{ method.description }}
|
<DocsText :text="method.description" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<DocsCode
|
<DocsCode
|
||||||
@@ -38,7 +38,7 @@ defineProps<{
|
|||||||
<div v-if="method.returns" class="mt-2 text-sm">
|
<div v-if="method.returns" class="mt-2 text-sm">
|
||||||
<span class="text-(--fg-subtle)">Returns</span>
|
<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>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ defineProps<{
|
|||||||
<span v-else class="text-(--fg-subtle)">—</span>
|
<span v-else class="text-(--fg-subtle)">—</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-2.5 px-4 text-(--fg-muted) min-w-48">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ defineProps<{
|
|||||||
<span v-else class="text-(--fg-subtle)">—</span>
|
<span v-else class="text-(--fg-subtle)">—</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-2.5 px-4 text-(--fg-muted) min-w-48">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Renders a short description with inline markdown (bold / `code` / links /
|
||||||
|
// {@link}). Content is authored by us (JSDoc), so v-html is safe here.
|
||||||
|
const props = defineProps<{ text?: string | null }>();
|
||||||
|
|
||||||
|
const html = computed(() => renderInline(props.text ?? ''));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="docs-text" v-html="html" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.docs-text :deep(code) {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
background: var(--bg-inset);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
padding: 0.05em 0.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-text :deep(a) {
|
||||||
|
color: var(--accent-text);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-text :deep(strong) {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import { marked } from 'marked';
|
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 {
|
export interface Heading {
|
||||||
depth: number;
|
depth: number;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -46,6 +50,17 @@ export function extractHeadings(markdown: string): Heading[] {
|
|||||||
return headings;
|
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). */
|
/** Render markdown to HTML with stable heading ids (matching extractHeadings). */
|
||||||
export function renderMarkdown(markdown: string): string {
|
export function renderMarkdown(markdown: string): string {
|
||||||
const seen = new Map<string, number>();
|
const seen = new Map<string, number>();
|
||||||
|
|||||||
@@ -118,10 +118,17 @@ const sectionTitle = 'text-xs font-semibold uppercase tracking-wider text-(--fg-
|
|||||||
<DocsBadge :kind="entry.item.kind" size="md" />
|
<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>
|
<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.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" />
|
<DocsTag v-if="entry.item.hasDemo" label="demo" variant="demo" />
|
||||||
</div>
|
</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">
|
<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">
|
<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>
|
<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">
|
<section v-if="entry.item.hasDemo && demoComponent" id="demo" class="mb-8 scroll-mt-20">
|
||||||
<h2 :class="sectionTitle">Demo</h2>
|
<h2 :class="sectionTitle">Demo</h2>
|
||||||
<DocsDemo :component="demoComponent" :source="entry.item.demoSource" />
|
<DocsDemo :component="demoComponent" :source-key="`${packageSlug}/${utilitySlug}`" />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="entry.item.signatures.length" id="signature" class="mb-8 scroll-mt-20">
|
<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">
|
<section v-if="entry.item.returns" id="returns" class="mb-8 scroll-mt-20">
|
||||||
<h2 :class="sectionTitle">Returns</h2>
|
<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>
|
<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>
|
</div>
|
||||||
|
<DocsPropsTable v-if="entry.item.returns.properties?.length" :properties="entry.item.returns.properties" />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="entry.item.properties.length" id="properties" class="mb-8 scroll-mt-20">
|
<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" />
|
<DocsBadge :kind="rt.kind" size="sm" />
|
||||||
<h3 class="font-mono font-semibold text-sm text-(--fg)">{{ rt.name }}</h3>
|
<h3 class="font-mono font-semibold text-sm text-(--fg)">{{ rt.name }}</h3>
|
||||||
</div>
|
</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]!" />
|
<DocsCode v-if="rt.signatures.length" :code="rt.signatures[0]!" />
|
||||||
<DocsPropsTable v-if="rt.properties.length" :properties="rt.properties" class="mt-3" />
|
<DocsPropsTable v-if="rt.properties.length" :properties="rt.properties" class="mt-3" />
|
||||||
</div>
|
</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>
|
<h1 class="text-2xl font-bold tracking-tight text-(--fg)">{{ entry.component.name }}</h1>
|
||||||
<DocsTag :label="`${entry.component.parts.length} parts`" variant="neutral" />
|
<DocsTag :label="`${entry.component.parts.length} parts`" variant="neutral" />
|
||||||
</div>
|
</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">
|
<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">
|
<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>
|
<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">
|
<section v-if="entry.component.hasDemo && demoComponent" class="mb-10">
|
||||||
<h2 :class="sectionTitle">Demo</h2>
|
<h2 :class="sectionTitle">Demo</h2>
|
||||||
<DocsDemo :component="demoComponent" :source="entry.component.demoSource" />
|
<DocsDemo :component="demoComponent" :source-key="`${packageSlug}/${utilitySlug}`" />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<DocsComponentAnatomy :component="entry.component" :package-name="pkg.name" />
|
<DocsComponentAnatomy :component="entry.component" :package-name="pkg.name" />
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
|
|
||||||
import { basename, dirname, relative, resolve } from 'node:path';
|
import { basename, dirname, relative, resolve } from 'node:path';
|
||||||
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||||
import { Project } from 'ts-morph';
|
import { Node, Project, SyntaxKind } from 'ts-morph';
|
||||||
import type { ClassDeclaration, FunctionDeclaration, InterfaceDeclaration, JSDoc, JSDocTag, MethodDeclaration, PropertyDeclaration, PropertySignature, SourceFile, TypeAliasDeclaration } from 'ts-morph';
|
import type { ClassDeclaration, FunctionDeclaration, InterfaceDeclaration, JSDoc, JSDocTag, MethodDeclaration, PropertyDeclaration, PropertySignature, SourceFile, TypeAliasDeclaration, VariableDeclaration } from 'ts-morph';
|
||||||
import type {
|
import type {
|
||||||
CategoryMeta,
|
CategoryMeta,
|
||||||
ComponentMeta,
|
ComponentMeta,
|
||||||
@@ -36,6 +36,34 @@ import type {
|
|||||||
/** Repository root — docs/modules/extractor → three levels up */
|
/** Repository root — docs/modules/extractor → three levels up */
|
||||||
const ROOT = resolve(import.meta.dirname, '..', '..', '..');
|
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 {
|
interface PackageConfig {
|
||||||
/** Path relative to repo root */
|
/** Path relative to repo root */
|
||||||
path: string;
|
path: string;
|
||||||
@@ -83,6 +111,18 @@ function slugify(name: string): string {
|
|||||||
return toKebabCase(name);
|
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 {
|
function toPascalCase(slug: string): string {
|
||||||
return slug
|
return slug
|
||||||
.split(/[-_]/)
|
.split(/[-_]/)
|
||||||
@@ -118,8 +158,17 @@ function getExamples(tags: JSDocTag[]): string[] {
|
|||||||
return tags
|
return tags
|
||||||
.filter(t => t.getTagName() === 'example')
|
.filter(t => t.getTagName() === 'example')
|
||||||
.map((t) => {
|
.map((t) => {
|
||||||
const text = t.getCommentText()?.trim() ?? '';
|
let text = t.getCommentText()?.trim() ?? '';
|
||||||
return text.replace(/^```(?:ts|typescript)?\n?/, '').replace(/\n?```$/, '').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);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
@@ -130,7 +179,9 @@ function extractParams(tags: JSDocTag[], node: FunctionDeclaration | MethodDecla
|
|||||||
|
|
||||||
for (const param of node.getParameters()) {
|
for (const param of node.getParameters()) {
|
||||||
const name = param.getName();
|
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 optional = param.isOptional();
|
||||||
const defaultValue = param.getInitializer()?.getText() ?? null;
|
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[] {
|
function extractTypeParams(node: FunctionDeclaration | ClassDeclaration | InterfaceDeclaration | TypeAliasDeclaration): TypeParamMeta[] {
|
||||||
return node.getTypeParameters().map(tp => ({
|
return node.getTypeParameters().map(tp => ({
|
||||||
name: tp.getName(),
|
name: tp.getName(),
|
||||||
constraint: tp.getConstraint()?.getText() ?? null,
|
constraint: tp.getConstraint() ? cleanType(tp.getConstraint()!.getText()) : null,
|
||||||
default: tp.getDefault()?.getText() ?? null,
|
default: tp.getDefault() ? cleanType(tp.getDefault()!.getText()) : null,
|
||||||
description: '',
|
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 {
|
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;
|
if (returnType === 'void') return null;
|
||||||
|
|
||||||
const returnsTag = getTagValue(tags, 'returns') || getTagValue(tags, 'return');
|
const returnsTag = getTagValue(tags, 'returns') || getTagValue(tags, 'return');
|
||||||
const description = returnsTag.replace(/^\{[^}]*\}\s*/, '').trim();
|
const description = returnsTag.replace(/^\{[^}]*\}\s*/, '').trim();
|
||||||
|
|
||||||
return { type: returnType, description };
|
const properties = extractReturnProperties(node);
|
||||||
|
|
||||||
|
return { type: returnType, description, properties };
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractMethodMeta(method: MethodDeclaration): MethodMeta {
|
function extractMethodMeta(method: MethodDeclaration): MethodMeta {
|
||||||
@@ -192,7 +298,7 @@ function extractPropertyMeta(prop: PropertyDeclaration | PropertySignature): Pro
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
name: prop.getName(),
|
name: prop.getName(),
|
||||||
type: prop.getType().getText(prop),
|
type: cleanType(prop.getTypeNode?.()?.getText() ?? prop.getType().getText(prop)),
|
||||||
description: getDescription(jsdocs, tags),
|
description: getDescription(jsdocs, tags),
|
||||||
optional: prop.hasQuestionToken?.() ?? false,
|
optional: prop.hasQuestionToken?.() ?? false,
|
||||||
defaultValue: getTagValue(tags, 'default') || null,
|
defaultValue: getTagValue(tags, 'default') || null,
|
||||||
@@ -208,10 +314,11 @@ function hasDemoFile(sourceFilePath: string): boolean {
|
|||||||
return existsSync(resolve(getSourceDir(sourceFilePath), 'demo.vue'));
|
return existsSync(resolve(getSourceDir(sourceFilePath), 'demo.vue'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function readDemoSource(sourceFilePath: string): string {
|
// Demo SOURCE is loaded lazily on the client (via `#docs/demo-sources`) only when
|
||||||
const demoPath = resolve(getSourceDir(sourceFilePath), 'demo.vue');
|
// "View source" is opened, so it is intentionally NOT embedded in the metadata
|
||||||
if (!existsSync(demoPath)) return '';
|
// payload (it was ~850KB). `hasDemo`/the lazy map carry what the UI needs.
|
||||||
return readFileSync(demoPath, 'utf-8');
|
function readDemoSource(_sourceFilePath: string): string {
|
||||||
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasTestFile(sourceFilePath: string): boolean {
|
function hasTestFile(sourceFilePath: string): boolean {
|
||||||
@@ -274,7 +381,7 @@ function extractClass(cls: ClassDeclaration, sourceFilePath: string, entryPoint:
|
|||||||
.filter(g => (g.getScope() ?? 'public') === 'public')
|
.filter(g => (g.getScope() ?? 'public') === 'public')
|
||||||
.map(g => ({
|
.map(g => ({
|
||||||
name: g.getName(),
|
name: g.getName(),
|
||||||
type: g.getReturnType().getText(g),
|
type: cleanType(g.getReturnTypeNode()?.getText() ?? g.getReturnType().getText(g)),
|
||||||
description: getDescription(g.getJsDocs(), getJsDocTags(g.getJsDocs())),
|
description: getDescription(g.getJsDocs(), getJsDocTags(g.getJsDocs())),
|
||||||
optional: false,
|
optional: false,
|
||||||
defaultValue: null,
|
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[] {
|
function collectExportedItems(sourceFile: SourceFile, entryPoint: string, visited = new Set<string>()): ItemMeta[] {
|
||||||
const filePath = sourceFile.getFilePath();
|
const filePath = sourceFile.getFilePath();
|
||||||
if (visited.has(filePath)) return [];
|
if (visited.has(filePath)) return [];
|
||||||
@@ -448,6 +592,21 @@ function collectExportedItems(sourceFile: SourceFile, entryPoint: string, visite
|
|||||||
if (item) items.push(item);
|
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()) {
|
for (const exportDecl of sourceFile.getExportDeclarations()) {
|
||||||
if (!exportDecl.getModuleSpecifierValue()) continue;
|
if (!exportDecl.getModuleSpecifierValue()) continue;
|
||||||
const referencedFile = exportDecl.getModuleSpecifierSourceFile();
|
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
|
* Groups types/interfaces from `types.ts` files with their sibling
|
||||||
* class/function items from the same directory as `relatedTypes`.
|
* 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[] {
|
function groupCoLocatedTypes(items: ItemMeta[]): ItemMeta[] {
|
||||||
const typesByDir = new Map<string, ItemMeta[]>();
|
const typesByDir = new Map<string, ItemMeta[]>();
|
||||||
const primaryByDir = new Map<string, ItemMeta[]>();
|
const primaryByDir = new Map<string, ItemMeta[]>();
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const dir = dirname(item.sourcePath);
|
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 target = isSecondary ? typesByDir : primaryByDir;
|
||||||
const existing = target.get(dir) ?? [];
|
const existing = target.get(dir) ?? [];
|
||||||
@@ -479,8 +660,24 @@ function groupCoLocatedTypes(items: ItemMeta[]): ItemMeta[] {
|
|||||||
for (const [dir, types] of Array.from(typesByDir.entries())) {
|
for (const [dir, types] of Array.from(typesByDir.entries())) {
|
||||||
const primaries = primaryByDir.get(dir);
|
const primaries = primaryByDir.get(dir);
|
||||||
if (!primaries || primaries.length === 0) continue;
|
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}`));
|
return items.filter(item => !absorbed.has(`${item.entryPoint}:${item.name}`));
|
||||||
@@ -571,6 +768,28 @@ function buildApiCategories(pkgDir: string): CategoryMeta[] {
|
|||||||
|
|
||||||
const groupedItems = groupCoLocatedTypes(uniqueItems);
|
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[]>();
|
const categoryMap = new Map<string, ItemMeta[]>();
|
||||||
for (const item of groupedItems) {
|
for (const item of groupedItems) {
|
||||||
const cat = inferCategoryFromItem(item);
|
const cat = inferCategoryFromItem(item);
|
||||||
@@ -621,6 +840,43 @@ function extractEmits(setupScript: string): EmitMeta[] {
|
|||||||
|
|
||||||
let partProjectCounter = 0;
|
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. */
|
/** Parse the `XxxProps` interface from a `.vue` part using ts-morph in-memory. */
|
||||||
function extractPartProps(plainScript: string): { props: PropertyMeta[]; description: string } {
|
function extractPartProps(plainScript: string): { props: PropertyMeta[]; description: string } {
|
||||||
if (!plainScript.trim()) return { props: [], description: '' };
|
if (!plainScript.trim()) return { props: [], description: '' };
|
||||||
@@ -688,12 +944,19 @@ function buildComponents(pkgDir: string): ComponentMeta[] {
|
|||||||
const slug = entry.name;
|
const slug = entry.name;
|
||||||
const base = toPascalCase(slug);
|
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 order = readPartOrder(resolve(dir, 'index.ts'));
|
||||||
const orderedFiles = [
|
const publicFiles = order.map(name => `${name}.vue`).filter(f => vueFiles.includes(f));
|
||||||
...order.map(name => `${name}.vue`).filter(f => vueFiles.includes(f)),
|
const candidates = publicFiles.length > 0
|
||||||
...vueFiles.filter(f => !order.includes(f.replace(/\.vue$/, ''))),
|
? 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[] = [];
|
const parts: ComponentPartMeta[] = [];
|
||||||
let groupDescription = '';
|
let groupDescription = '';
|
||||||
@@ -706,7 +969,17 @@ function buildComponents(pkgDir: string): ComponentMeta[] {
|
|||||||
const name = file.replace(/\.vue$/, '');
|
const name = file.replace(/\.vue$/, '');
|
||||||
const role = roleFromName(name, base);
|
const role = roleFromName(name, base);
|
||||||
if (role === 'Root' && description && !groupDescription) groupDescription = description;
|
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}`;
|
const entryPoint = `./${slug}`;
|
||||||
@@ -720,7 +993,7 @@ function buildComponents(pkgDir: string): ComponentMeta[] {
|
|||||||
entryPoint,
|
entryPoint,
|
||||||
parts,
|
parts,
|
||||||
hasDemo,
|
hasDemo,
|
||||||
demoSource: hasDemo ? readFileSync(demoPath, 'utf-8') : '',
|
demoSource: '', // loaded lazily client-side via #docs/demo-sources
|
||||||
sourcePath: relative(ROOT, dir),
|
sourcePath: relative(ROOT, dir),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,16 @@ export default defineNuxtModule({
|
|||||||
};
|
};
|
||||||
|
|
||||||
nuxt.hook('vite:extendConfig', (config) => {
|
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 existing = config.resolve?.alias;
|
||||||
const sourceAliases = [
|
const sourceAliases = [
|
||||||
{ find: '@/composables', replacement: resolve(vueSrc, 'composables') },
|
{ find: '@/composables', replacement: resolve(vueSrc, 'composables') },
|
||||||
@@ -75,8 +85,9 @@ export default defineNuxtModule({
|
|||||||
filename: 'docs-metadata.ts',
|
filename: 'docs-metadata.ts',
|
||||||
write: true,
|
write: true,
|
||||||
getContents: () => {
|
getContents: () => {
|
||||||
const json = JSON.stringify(metadata, null, 2);
|
// No indentation (smaller module) and no `as const` — a multi-MB literal
|
||||||
return `export default ${json} as const;`;
|
// 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`)
|
// Generate hand-authored doc-section import map (`<pkg>/docs/*.vue`)
|
||||||
addTemplate({
|
addTemplate({
|
||||||
filename: 'docs-sections.ts',
|
filename: 'docs-sections.ts',
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ export interface ItemMeta {
|
|||||||
demoSource: string;
|
demoSource: string;
|
||||||
/** Whether an index.test.ts file exists alongside */
|
/** Whether an index.test.ts file exists alongside */
|
||||||
hasTests: boolean;
|
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 */
|
/** Related types/interfaces co-located in the same module directory */
|
||||||
relatedTypes: ItemMeta[];
|
relatedTypes: ItemMeta[];
|
||||||
/** Relative path to the source file from repo root */
|
/** Relative path to the source file from repo root */
|
||||||
@@ -188,6 +190,11 @@ export interface ParamMeta {
|
|||||||
export interface ReturnMeta {
|
export interface ReturnMeta {
|
||||||
type: string;
|
type: string;
|
||||||
description: 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 {
|
export interface TypeParamMeta {
|
||||||
|
|||||||
Generated
+6
@@ -201,6 +201,9 @@ importers:
|
|||||||
|
|
||||||
core/platform:
|
core/platform:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@robonen/encoding':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../encoding
|
||||||
'@robonen/eslint':
|
'@robonen/eslint':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../configs/eslint
|
version: link:../../configs/eslint
|
||||||
@@ -384,6 +387,9 @@ importers:
|
|||||||
'@floating-ui/vue':
|
'@floating-ui/vue':
|
||||||
specifier: ^1.1.11
|
specifier: ^1.1.11
|
||||||
version: 1.1.11(vue@3.5.35(typescript@6.0.3))
|
version: 1.1.11(vue@3.5.35(typescript@6.0.3))
|
||||||
|
'@robonen/encoding':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../core/encoding
|
||||||
'@robonen/platform':
|
'@robonen/platform':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../core/platform
|
version: link:../../core/platform
|
||||||
|
|||||||
@@ -70,6 +70,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/vue": "^1.1.11",
|
"@floating-ui/vue": "^1.1.11",
|
||||||
|
"@robonen/encoding": "workspace:*",
|
||||||
"@robonen/platform": "workspace:*",
|
"@robonen/platform": "workspace:*",
|
||||||
"@robonen/stdlib": "workspace:*",
|
"@robonen/stdlib": "workspace:*",
|
||||||
"@robonen/vue": "workspace:*",
|
"@robonen/vue": "workspace:*",
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PrimitiveProps } from '../primitive';
|
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 {
|
export interface AccordionContentProps extends PrimitiveProps {
|
||||||
/** Keep content mounted even when closed. */
|
/** Keep content mounted even when closed. */
|
||||||
forceMount?: boolean;
|
forceMount?: boolean;
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PrimitiveProps } from '../primitive';
|
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 {
|
export interface AccordionItemProps extends PrimitiveProps {
|
||||||
/** Unique value for this item. */
|
/** Unique value for this item. */
|
||||||
value: string;
|
value: string;
|
||||||
|
|||||||
@@ -2,10 +2,16 @@
|
|||||||
import type { PrimitiveProps } from '../primitive';
|
import type { PrimitiveProps } from '../primitive';
|
||||||
import type { RovingDirection } from '../utils/roving-focus';
|
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 {
|
export interface AccordionRootProps extends PrimitiveProps {
|
||||||
/** Current open value(s) for controlled mode. */
|
|
||||||
modelValue?: string | string[];
|
|
||||||
|
|
||||||
/** Initial value(s) for uncontrolled mode. */
|
/** Initial value(s) for uncontrolled mode. */
|
||||||
defaultValue?: string | string[];
|
defaultValue?: string | string[];
|
||||||
|
|
||||||
@@ -30,7 +36,7 @@ export interface AccordionRootProps extends PrimitiveProps {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { resolveNextIndex, rovingKeyToAction } from '../utils/roving-focus';
|
||||||
import { Primitive } from '../primitive';
|
import { Primitive } from '../primitive';
|
||||||
import { provideAccordionContext } from './context';
|
import { provideAccordionContext } from './context';
|
||||||
@@ -45,34 +51,25 @@ const {
|
|||||||
orientation = 'vertical',
|
orientation = 'vertical',
|
||||||
dir = 'ltr',
|
dir = 'ltr',
|
||||||
loop = true,
|
loop = true,
|
||||||
modelValue,
|
|
||||||
defaultValue,
|
defaultValue,
|
||||||
as = 'div',
|
as = 'div',
|
||||||
} = defineProps<AccordionRootProps>();
|
} = defineProps<AccordionRootProps>();
|
||||||
|
|
||||||
const { forwardRef } = useForwardExpose();
|
const { forwardRef } = useForwardExpose();
|
||||||
|
|
||||||
const emit = defineEmits<{ 'update:modelValue': [value: string | string[] | undefined] }>();
|
|
||||||
|
|
||||||
type RovingAction = NonNullable<ReturnType<typeof rovingKeyToAction>>;
|
type RovingAction = NonNullable<ReturnType<typeof rovingKeyToAction>>;
|
||||||
|
|
||||||
const openSet = shallowRef<Set<string>>(
|
const localValue = ref<string | string[] | undefined>(defaultValue);
|
||||||
new Set(toArray(modelValue ?? defaultValue)),
|
|
||||||
);
|
|
||||||
|
|
||||||
function setEqualsArray(set: Set<string>, arr: string[]): boolean {
|
const model = defineModel<string | string[] | undefined>({
|
||||||
if (arr.length !== set.size) return false;
|
get: v => v ?? localValue.value,
|
||||||
for (let i = 0; i < arr.length; i++) if (!set.has(arr[i]!)) return false;
|
set: (v) => {
|
||||||
return true;
|
localValue.value = v;
|
||||||
}
|
return v;
|
||||||
|
},
|
||||||
watch(() => modelValue, (v) => {
|
|
||||||
if (v === undefined) return;
|
|
||||||
const arr = toArray(v);
|
|
||||||
if (setEqualsArray(openSet.value, arr)) return;
|
|
||||||
openSet.value = new Set(arr);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const openSet = computed<Set<string>>(() => new Set(toArray(model.value)));
|
||||||
|
|
||||||
function nextOpenSet(cur: Set<string>, value: string): Set<string> {
|
function nextOpenSet(cur: Set<string>, value: string): Set<string> {
|
||||||
const present = cur.has(value);
|
const present = cur.has(value);
|
||||||
@@ -88,13 +85,12 @@ function nextOpenSet(cur: Set<string>, value: string): Set<string> {
|
|||||||
return next;
|
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];
|
return type === 'single' ? set.values().next().value : [...set];
|
||||||
}
|
}
|
||||||
|
|
||||||
function commit(next: Set<string>): void {
|
function commit(next: Set<string>): void {
|
||||||
openSet.value = next;
|
model.value = toModelValue(next);
|
||||||
emit('update:modelValue', toEmitValue(next));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isOpen(value: string): boolean {
|
function isOpen(value: string): boolean {
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PrimitiveProps } from '../primitive';
|
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 {
|
export interface AccordionTriggerProps extends PrimitiveProps {
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import {
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionRoot,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from '@robonen/primitives';
|
||||||
|
|
||||||
|
const open = ref<string>('shipping');
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
value: 'shipping',
|
||||||
|
question: 'How long does shipping take?',
|
||||||
|
answer: 'Orders ship within 1–2 business days and arrive in 3–5 days with standard delivery. Express options are offered at checkout.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'returns',
|
||||||
|
question: 'What is your return policy?',
|
||||||
|
answer: 'Returns are free within 30 days of delivery. Items must be unused and in their original packaging.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'support',
|
||||||
|
question: 'How can I reach support?',
|
||||||
|
answer: 'Our team is available 24/7 by email and weekdays 9–5 by live chat. Most tickets are answered within a few hours.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AccordionRoot
|
||||||
|
v-model="open"
|
||||||
|
type="single"
|
||||||
|
collapsible
|
||||||
|
class="w-full max-w-md divide-y divide-(--border) rounded-lg border border-(--border) bg-(--bg) text-(--fg)"
|
||||||
|
>
|
||||||
|
<AccordionItem
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.value"
|
||||||
|
:value="item.value"
|
||||||
|
>
|
||||||
|
<AccordionTrigger
|
||||||
|
class="group flex w-full items-center justify-between gap-4 px-4 py-3.5 text-left text-sm font-medium outline-none transition-colors hover:bg-(--bg-subtle) focus-visible:ring-2 focus-visible:ring-(--ring)"
|
||||||
|
>
|
||||||
|
<span>{{ item.question }}</span>
|
||||||
|
<svg
|
||||||
|
class="size-4 shrink-0 text-(--fg-subtle) transition-transform duration-200 group-data-[state=open]:rotate-180"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="6 9 12 15 18 9" />
|
||||||
|
</svg>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent
|
||||||
|
class="px-4 pb-4 text-sm text-(--fg-muted)"
|
||||||
|
>
|
||||||
|
{{ item.answer }}
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</AccordionRoot>
|
||||||
|
</template>
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PrimitiveProps } from '../primitive';
|
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 {}
|
export interface AlertDialogActionProps extends PrimitiveProps {}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PrimitiveProps } from '../primitive';
|
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 {}
|
export interface AlertDialogCancelProps extends PrimitiveProps {}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { DialogContentEmits, DialogContentProps } from '../dialog';
|
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 interface AlertDialogContentProps extends Omit<DialogContentProps, 'role'> {}
|
||||||
export type AlertDialogContentEmits = DialogContentEmits;
|
export type AlertDialogContentEmits = DialogContentEmits;
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { DialogRootProps } from '../dialog';
|
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'> {}
|
export interface AlertDialogRootProps extends Omit<DialogRootProps, 'modal'> {}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import {
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogRoot,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@robonen/primitives';
|
||||||
|
|
||||||
|
const open = ref(false);
|
||||||
|
const deleted = ref(false);
|
||||||
|
|
||||||
|
function confirmDelete() {
|
||||||
|
deleted.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function restore() {
|
||||||
|
deleted.value = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col items-start gap-3 text-(--fg)">
|
||||||
|
<p v-if="!deleted" class="text-sm text-(--fg-muted)">
|
||||||
|
Project <span class="font-medium text-(--fg)">"acme-web"</span> is live.
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-else
|
||||||
|
class="text-sm text-red-600 dark:text-red-400"
|
||||||
|
>
|
||||||
|
Project deleted.
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ml-1 underline underline-offset-2 hover:text-red-700 dark:hover:text-red-300"
|
||||||
|
@click="restore"
|
||||||
|
>
|
||||||
|
Undo
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<AlertDialogRoot v-model:open="open">
|
||||||
|
<AlertDialogTrigger
|
||||||
|
:disabled="deleted"
|
||||||
|
class="inline-flex items-center rounded-md border border-red-300 bg-(--bg) px-3 py-1.5 text-sm font-medium text-red-600 transition-colors hover:bg-red-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-400 disabled:cursor-not-allowed disabled:opacity-50 dark:border-red-900 dark:text-red-400 dark:hover:bg-red-950/40"
|
||||||
|
>
|
||||||
|
Delete project
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay
|
||||||
|
class="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"
|
||||||
|
/>
|
||||||
|
<AlertDialogContent
|
||||||
|
class="fixed left-1/2 top-1/2 z-50 w-[min(92vw,26rem)] -translate-x-1/2 -translate-y-1/2 rounded-xl border border-(--border) bg-(--bg-elevated) p-5 shadow-xl"
|
||||||
|
>
|
||||||
|
<AlertDialogTitle class="text-base font-semibold text-(--fg)">
|
||||||
|
Delete this project?
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription class="mt-1.5 text-sm text-(--fg-muted)">
|
||||||
|
This permanently removes "acme-web" and all of its deployments.
|
||||||
|
This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
|
||||||
|
<div class="mt-5 flex justify-end gap-2">
|
||||||
|
<AlertDialogCancel
|
||||||
|
class="inline-flex items-center rounded-md border border-(--border) bg-(--bg) px-3 py-1.5 text-sm font-medium text-(--fg) transition-colors hover:bg-(--bg-subtle) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--ring)"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
class="inline-flex items-center rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-red-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
|
||||||
|
@click="confirmDelete"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</div>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
</AlertDialogRoot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PrimitiveProps } from '../primitive';
|
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 {
|
export interface AspectRatioProps extends PrimitiveProps {
|
||||||
/**
|
/**
|
||||||
* Desired width-to-height ratio (e.g. `16 / 9`, `1`, `4 / 3`).
|
* 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 { Primitive } from '../primitive';
|
||||||
import { useForwardExpose } from '@robonen/vue';
|
import { useForwardExpose } from '@robonen/vue';
|
||||||
|
|
||||||
useForwardExpose();
|
const { forwardRef } = useForwardExpose();
|
||||||
|
|
||||||
const { ratio = 1, as = 'div' } = defineProps<AspectRatioProps>();
|
const { ratio = 1, as = 'div' } = defineProps<AspectRatioProps>();
|
||||||
|
|
||||||
@@ -33,7 +39,7 @@ const INNER_STYLE = {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<Primitive :as="as" :style="INNER_STYLE" :data-aspect-ratio="true">
|
||||||
<slot />
|
<slot />
|
||||||
</Primitive>
|
</Primitive>
|
||||||
|
|||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,48 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { AspectRatio } from '@robonen/primitives';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
const ratios = [
|
||||||
|
{ label: '16 / 9', value: 16 / 9 },
|
||||||
|
{ label: '4 / 3', value: 4 / 3 },
|
||||||
|
{ label: '1 / 1', value: 1 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const ratio = ref(ratios[0].value);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-4 w-full max-w-md text-(--fg)">
|
||||||
|
<div class="flex items-center gap-1 p-1 rounded-lg bg-(--bg-inset) border border-(--border) w-fit">
|
||||||
|
<button
|
||||||
|
v-for="r in ratios"
|
||||||
|
:key="r.label"
|
||||||
|
type="button"
|
||||||
|
class="px-3 py-1 text-sm rounded-md transition-colors"
|
||||||
|
:class="ratio === r.value
|
||||||
|
? 'bg-(--accent) text-(--accent-fg)'
|
||||||
|
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-subtle)'"
|
||||||
|
@click="ratio = r.value"
|
||||||
|
>
|
||||||
|
{{ r.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AspectRatio
|
||||||
|
:ratio="ratio"
|
||||||
|
class="overflow-hidden rounded-xl border border-(--border) bg-(--bg-subtle)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="https://images.unsplash.com/photo-1535025183041-0991a977e25b?w=800&q=80"
|
||||||
|
alt="Mountain landscape at dusk"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
>
|
||||||
|
</AspectRatio>
|
||||||
|
|
||||||
|
<p class="text-sm text-(--fg-muted)">
|
||||||
|
The frame keeps a fixed
|
||||||
|
<span class="font-medium text-(--fg)">{{ ratios.find((r) => r.value === ratio)?.label }}</span>
|
||||||
|
proportion as the container resizes, so the image never shifts surrounding layout.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PrimitiveProps } from '../primitive';
|
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 {
|
export interface AvatarFallbackProps extends PrimitiveProps {
|
||||||
|
|
||||||
/** Delay in ms before rendering the fallback (avoids flicker on fast networks). */
|
/** Delay in ms before rendering the fallback (avoids flicker on fast networks). */
|
||||||
|
|||||||
@@ -2,11 +2,17 @@
|
|||||||
import type { PrimitiveProps } from '../primitive';
|
import type { PrimitiveProps } from '../primitive';
|
||||||
import type { AvatarImageLoadingStatus } from './context';
|
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 {
|
export interface AvatarImageProps extends PrimitiveProps {
|
||||||
|
/** Image source URL — loaded out of band before the image is shown. */
|
||||||
src?: string;
|
src?: string;
|
||||||
|
/** Alternative text describing the image. */
|
||||||
alt?: string;
|
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;
|
onLoadingStatusChange?: (status: AvatarImageLoadingStatus) => void;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PrimitiveProps } from '../primitive';
|
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 {}
|
export interface AvatarRootProps extends PrimitiveProps {}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { AvatarFallback, AvatarImage, AvatarRoot } from '@robonen/primitives';
|
||||||
|
|
||||||
|
const people = [
|
||||||
|
{
|
||||||
|
name: 'Ada Lovelace',
|
||||||
|
initials: 'AL',
|
||||||
|
src: 'https://i.pravatar.cc/96?img=47',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Alan Turing',
|
||||||
|
initials: 'AT',
|
||||||
|
src: 'https://example.com/this-image-does-not-exist.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Grace Hopper',
|
||||||
|
initials: 'GH',
|
||||||
|
src: '',
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div
|
||||||
|
v-for="person in people"
|
||||||
|
:key="person.name"
|
||||||
|
class="flex flex-col items-center gap-2"
|
||||||
|
>
|
||||||
|
<AvatarRoot
|
||||||
|
class="relative inline-flex h-14 w-14 select-none items-center justify-center overflow-hidden rounded-full border border-(--border) bg-(--bg-subtle) align-middle"
|
||||||
|
>
|
||||||
|
<AvatarImage
|
||||||
|
:src="person.src"
|
||||||
|
:alt="person.name"
|
||||||
|
class="h-full w-full rounded-[inherit] object-cover"
|
||||||
|
/>
|
||||||
|
<AvatarFallback
|
||||||
|
:delay-ms="200"
|
||||||
|
class="flex h-full w-full items-center justify-center bg-(--bg-inset) text-sm font-medium text-(--fg-muted)"
|
||||||
|
>
|
||||||
|
{{ person.initials }}
|
||||||
|
</AvatarFallback>
|
||||||
|
</AvatarRoot>
|
||||||
|
<span class="text-xs text-(--fg-subtle)">{{ person.name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PrimitiveProps } from '../primitive';
|
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 {
|
export interface CalendarCellProps extends PrimitiveProps {
|
||||||
/** The date this cell represents. */
|
/** The date this cell represents. */
|
||||||
date: Date;
|
date: Date;
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PrimitiveProps } from '../primitive';
|
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 {
|
export interface CalendarCellTriggerProps extends PrimitiveProps {
|
||||||
/** The day this trigger represents. */
|
/** The day this trigger represents. */
|
||||||
day: Date;
|
day: Date;
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PrimitiveProps } from '../primitive';
|
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 {
|
export interface CalendarGridProps extends PrimitiveProps {
|
||||||
/** The month this grid represents. Defaults to the root placeholder's month. */
|
/** The month this grid represents. Defaults to the root placeholder's month. */
|
||||||
month?: Date;
|
month?: Date;
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PrimitiveProps } from '../primitive';
|
import type { PrimitiveProps } from '../primitive';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The grid's `<tbody>` wrapper containing the week rows (`CalendarGridRow`) of
|
||||||
|
* day cells.
|
||||||
|
*/
|
||||||
export interface CalendarGridBodyProps extends PrimitiveProps {}
|
export interface CalendarGridBodyProps extends PrimitiveProps {}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PrimitiveProps } from '../primitive';
|
import type { PrimitiveProps } from '../primitive';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The grid's `<thead>` wrapper holding the row of weekday `CalendarHeadCell`
|
||||||
|
* labels.
|
||||||
|
*/
|
||||||
export interface CalendarGridHeadProps extends PrimitiveProps {}
|
export interface CalendarGridHeadProps extends PrimitiveProps {}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PrimitiveProps } from '../primitive';
|
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 {}
|
export interface CalendarGridRowProps extends PrimitiveProps {}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PrimitiveProps } from '../primitive';
|
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 {
|
export interface CalendarHeadCellProps extends PrimitiveProps {
|
||||||
/** The day this header cell represents — used for `aria-label`. */
|
/** The day this header cell represents — used for `aria-label`. */
|
||||||
day?: Date;
|
day?: Date;
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PrimitiveProps } from '../primitive';
|
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 {}
|
export interface CalendarHeaderProps extends PrimitiveProps {}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PrimitiveProps } from '../primitive';
|
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 {}
|
export interface CalendarHeadingProps extends PrimitiveProps {}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PrimitiveProps } from '../primitive';
|
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 {
|
export interface CalendarNextProps extends PrimitiveProps {
|
||||||
/** Override the root's `nextPage` for just this button. */
|
/** Override the root's `nextPage` for just this button. */
|
||||||
nextPage?: (placeholder: Date) => Date;
|
nextPage?: (placeholder: Date) => Date;
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PrimitiveProps } from '../primitive';
|
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 {
|
export interface CalendarPrevProps extends PrimitiveProps {
|
||||||
/** Override the root's `prevPage` for just this button. */
|
/** Override the root's `prevPage` for just this button. */
|
||||||
prevPage?: (placeholder: Date) => Date;
|
prevPage?: (placeholder: Date) => Date;
|
||||||
|
|||||||
@@ -2,6 +2,17 @@
|
|||||||
import type { PrimitiveProps } from '../primitive';
|
import type { PrimitiveProps } from '../primitive';
|
||||||
import type { CalendarMonth, WeekDayFormat } from './utils';
|
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 {
|
export interface CalendarRootProps extends PrimitiveProps {
|
||||||
/** Uncontrolled default selected date. */
|
/** Uncontrolled default selected date. */
|
||||||
defaultValue?: Date;
|
defaultValue?: Date;
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
CalendarCell,
|
||||||
|
CalendarCellTrigger,
|
||||||
|
CalendarGrid,
|
||||||
|
CalendarGridBody,
|
||||||
|
CalendarGridHead,
|
||||||
|
CalendarGridRow,
|
||||||
|
CalendarHeadCell,
|
||||||
|
CalendarHeader,
|
||||||
|
CalendarHeading,
|
||||||
|
CalendarNext,
|
||||||
|
CalendarPrev,
|
||||||
|
CalendarRoot,
|
||||||
|
} from '@robonen/primitives';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
const value = ref<Date>(new Date());
|
||||||
|
|
||||||
|
function formatSelected(date: Date | undefined) {
|
||||||
|
if (!date) return 'None';
|
||||||
|
return date.toLocaleDateString('en', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-3">
|
||||||
|
<CalendarRoot
|
||||||
|
v-slot="{ grid, weekDays }"
|
||||||
|
v-model="value"
|
||||||
|
class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4 text-(--fg) shadow-sm"
|
||||||
|
>
|
||||||
|
<CalendarHeader class="mb-3 flex items-center justify-between gap-2">
|
||||||
|
<CalendarPrev
|
||||||
|
aria-label="Previous month"
|
||||||
|
class="inline-flex size-8 items-center justify-center rounded-lg border border-(--border) bg-(--bg) text-(--fg-muted) transition hover:bg-(--bg-inset) hover:text-(--fg) active:scale-95 cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</CalendarPrev>
|
||||||
|
<CalendarHeading class="text-sm font-semibold tracking-tight" />
|
||||||
|
<CalendarNext
|
||||||
|
aria-label="Next month"
|
||||||
|
class="inline-flex size-8 items-center justify-center rounded-lg border border-(--border) bg-(--bg) text-(--fg-muted) transition hover:bg-(--bg-inset) hover:text-(--fg) active:scale-95 cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</CalendarNext>
|
||||||
|
</CalendarHeader>
|
||||||
|
|
||||||
|
<CalendarGrid
|
||||||
|
v-for="month in grid"
|
||||||
|
:key="month.value.toString()"
|
||||||
|
:month="month.value"
|
||||||
|
class="w-full border-collapse select-none"
|
||||||
|
>
|
||||||
|
<CalendarGridHead>
|
||||||
|
<CalendarGridRow class="mb-1 flex">
|
||||||
|
<CalendarHeadCell
|
||||||
|
v-for="(weekday, i) in weekDays"
|
||||||
|
:key="weekday + i"
|
||||||
|
class="w-9 text-center text-xs font-medium text-(--fg-subtle)"
|
||||||
|
>
|
||||||
|
{{ weekday }}
|
||||||
|
</CalendarHeadCell>
|
||||||
|
</CalendarGridRow>
|
||||||
|
</CalendarGridHead>
|
||||||
|
|
||||||
|
<CalendarGridBody>
|
||||||
|
<CalendarGridRow
|
||||||
|
v-for="(week, w) in month.weeks"
|
||||||
|
:key="w"
|
||||||
|
class="flex w-full"
|
||||||
|
>
|
||||||
|
<CalendarCell
|
||||||
|
v-for="day in week"
|
||||||
|
:key="day.toString()"
|
||||||
|
:date="day"
|
||||||
|
class="p-0.5"
|
||||||
|
>
|
||||||
|
<CalendarCellTrigger
|
||||||
|
v-slot="{ dayValue, selected, today }"
|
||||||
|
:day="day"
|
||||||
|
:month="month.value"
|
||||||
|
class="flex size-8 items-center justify-center rounded-lg text-sm tabular-nums transition outline-none cursor-pointer
|
||||||
|
focus-visible:ring-2 focus-visible:ring-(--ring)
|
||||||
|
hover:bg-(--bg-inset)
|
||||||
|
data-[selected]:bg-(--accent) data-[selected]:font-semibold data-[selected]:text-(--accent-fg) data-[selected]:hover:bg-(--accent-hover)
|
||||||
|
data-[outside-view]:text-(--fg-subtle) data-[outside-view]:opacity-50
|
||||||
|
data-[unavailable]:cursor-not-allowed data-[unavailable]:text-red-500 data-[unavailable]:line-through data-[unavailable]:hover:bg-transparent
|
||||||
|
data-[disabled]:cursor-not-allowed data-[disabled]:opacity-30"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
today && !selected ? 'relative after:absolute after:bottom-1 after:left-1/2 after:size-1 after:-translate-x-1/2 after:rounded-full after:bg-(--accent)' : '',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ dayValue }}
|
||||||
|
</span>
|
||||||
|
</CalendarCellTrigger>
|
||||||
|
</CalendarCell>
|
||||||
|
</CalendarGridRow>
|
||||||
|
</CalendarGridBody>
|
||||||
|
</CalendarGrid>
|
||||||
|
</CalendarRoot>
|
||||||
|
|
||||||
|
<p class="text-xs text-(--fg-muted)">
|
||||||
|
Selected:
|
||||||
|
<span class="font-medium text-(--fg)">{{ formatSelected(value) }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PrimitiveProps } from '../primitive';
|
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 {
|
export interface CheckboxIndicatorProps extends PrimitiveProps {
|
||||||
/** Keep mounted even when unchecked (for CSS exit animations). */
|
/** Keep mounted even when unchecked (for CSS exit animations). */
|
||||||
forceMount?: boolean;
|
forceMount?: boolean;
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
import type { PrimitiveProps } from '../primitive';
|
import type { PrimitiveProps } from '../primitive';
|
||||||
import type { CheckedState } from './context';
|
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 {
|
export interface CheckboxRootProps extends PrimitiveProps {
|
||||||
/** Uncontrolled initial checked state. */
|
/** Uncontrolled initial checked state. */
|
||||||
defaultChecked?: CheckedState;
|
defaultChecked?: CheckedState;
|
||||||
@@ -22,7 +30,7 @@ export interface CheckboxRootEmits {
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Primitive } from '../primitive';
|
import { Primitive } from '../primitive';
|
||||||
import { ref, toRef, watch } from 'vue';
|
import { computed, ref, toRef } from 'vue';
|
||||||
import { provideCheckboxContext } from './context';
|
import { provideCheckboxContext } from './context';
|
||||||
import { useForwardExpose } from '@robonen/vue';
|
import { useForwardExpose } from '@robonen/vue';
|
||||||
|
|
||||||
@@ -31,40 +39,51 @@ const { disabled = false, required = false, value = 'on', defaultChecked, name,
|
|||||||
const { forwardRef } = useForwardExpose();
|
const { forwardRef } = useForwardExpose();
|
||||||
|
|
||||||
const emit = defineEmits<CheckboxRootEmits>();
|
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) => {
|
// `defineModel` handles both controlled (parent `v-model:checked`) and
|
||||||
if (v === undefined) return;
|
// uncontrolled modes; `localChecked` backs the uncontrolled state seeded from
|
||||||
if (v !== localChecked.value) localChecked.value = v;
|
// `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 {
|
function setChecked(v: CheckedState): void {
|
||||||
localChecked.value = v;
|
checked.value = v;
|
||||||
model.value = v;
|
|
||||||
emit('checkedChange', v);
|
emit('checkedChange', v);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggle(): void {
|
function toggle(): void {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
setChecked(localChecked.value !== true);
|
setChecked(checked.value !== true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeyDown(event: KeyboardEvent): void {
|
function onKeyDown(event: KeyboardEvent): void {
|
||||||
// Prevent form submit on Enter when inside a form.
|
// Prevent form submit on Enter when inside a form.
|
||||||
if (event.key === 'Enter') event.preventDefault();
|
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({
|
provideCheckboxContext({
|
||||||
// `localChecked` is already a `Ref<CheckedState>`; forward directly without
|
checked: checkedState,
|
||||||
// wrapping in a computed. `toRef(() => disabled)` gives a reactive identity
|
|
||||||
// passthrough without `ReactiveEffect`/cache.
|
|
||||||
checked: localChecked,
|
|
||||||
disabled: toRef(() => disabled),
|
disabled: toRef(() => disabled),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Inlined in template — no need for a cached computed for a single call site.
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -72,17 +91,18 @@ provideCheckboxContext({
|
|||||||
:ref="forwardRef"
|
:ref="forwardRef"
|
||||||
:as="as"
|
:as="as"
|
||||||
:type="as === 'button' ? 'button' : undefined"
|
:type="as === 'button' ? 'button' : undefined"
|
||||||
|
:tabindex="as === 'button' ? undefined : (disabled ? -1 : 0)"
|
||||||
role="checkbox"
|
role="checkbox"
|
||||||
:aria-checked="localChecked === 'indeterminate' ? 'mixed' : localChecked"
|
:aria-checked="checkedState === 'indeterminate' ? 'mixed' : checkedState"
|
||||||
:aria-required="required || undefined"
|
:aria-required="required || undefined"
|
||||||
:aria-disabled="disabled || 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"
|
:data-disabled="disabled ? '' : undefined"
|
||||||
:disabled="disabled || undefined"
|
:disabled="disabled || undefined"
|
||||||
@click="toggle"
|
@click="toggle"
|
||||||
@keydown="onKeyDown"
|
@keydown="onKeyDown"
|
||||||
>
|
>
|
||||||
<slot :checked="localChecked" />
|
<slot :checked="checkedState" />
|
||||||
<input
|
<input
|
||||||
v-if="name"
|
v-if="name"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -90,7 +110,7 @@ provideCheckboxContext({
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
:name="name"
|
:name="name"
|
||||||
:value="value"
|
:value="value"
|
||||||
:checked="localChecked === true"
|
:checked="checkedState === true"
|
||||||
:required="required"
|
:required="required"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
style="position: absolute; pointer-events: none; opacity: 0; margin: 0; transform: translateX(-100%);"
|
style="position: absolute; pointer-events: none; opacity: 0; margin: 0; transform: translateX(-100%);"
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { CheckedState } from '@robonen/primitives';
|
||||||
|
import { CheckboxIndicator, CheckboxRoot } from '@robonen/primitives';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
const ingredients = [
|
||||||
|
{ id: 'cheese', label: 'Extra cheese' },
|
||||||
|
{ id: 'mushrooms', label: 'Mushrooms' },
|
||||||
|
{ id: 'olives', label: 'Olives' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const selected = ref<Record<string, boolean>>({
|
||||||
|
cheese: true,
|
||||||
|
mushrooms: false,
|
||||||
|
olives: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const checkedCount = computed(() => Object.values(selected.value).filter(Boolean).length);
|
||||||
|
|
||||||
|
// Parent reflects the children: checked when all, unchecked when none, else indeterminate.
|
||||||
|
const allChecked = computed<CheckedState>(() => {
|
||||||
|
if (checkedCount.value === 0) return false;
|
||||||
|
if (checkedCount.value === ingredients.length) return true;
|
||||||
|
return 'indeterminate';
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleAll(next: CheckedState) {
|
||||||
|
const value = next === true;
|
||||||
|
for (const item of ingredients) selected.value[item.id] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const acceptedTerms = ref(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-6 p-6 max-w-sm bg-(--bg) text-(--fg) border border-(--border) rounded-xl">
|
||||||
|
<fieldset class="flex flex-col gap-3 m-0 p-0 border-0">
|
||||||
|
<legend class="text-sm font-semibold text-(--fg)">
|
||||||
|
Toppings
|
||||||
|
</legend>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer select-none">
|
||||||
|
<CheckboxRoot
|
||||||
|
:checked="allChecked"
|
||||||
|
class="grid place-items-center w-5 h-5 rounded-md border border-(--border) bg-(--bg-inset) outline-none transition-colors data-[state=checked]:bg-(--accent) data-[state=indeterminate]:bg-(--accent) data-[state=checked]:border-(--accent) data-[state=indeterminate]:border-(--accent) focus-visible:ring-2 focus-visible:ring-(--ring)"
|
||||||
|
@checked-change="toggleAll"
|
||||||
|
>
|
||||||
|
<CheckboxIndicator v-slot="{ checked }" class="text-(--accent-fg)">
|
||||||
|
<svg v-if="checked === 'indeterminate'" width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||||
|
<path d="M2.5 6h7" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
<svg v-else width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||||
|
<path d="M2.5 6.5 5 9l4.5-5.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</CheckboxIndicator>
|
||||||
|
</CheckboxRoot>
|
||||||
|
<span class="text-sm font-medium">Select all</span>
|
||||||
|
<span class="ml-auto text-xs text-(--fg-subtle)">{{ checkedCount }}/{{ ingredients.length }}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 pl-2 border-l border-(--border)">
|
||||||
|
<label v-for="item in ingredients" :key="item.id" class="flex items-center gap-3 cursor-pointer select-none">
|
||||||
|
<CheckboxRoot
|
||||||
|
v-model:checked="selected[item.id]"
|
||||||
|
class="grid place-items-center w-5 h-5 rounded-md border border-(--border) bg-(--bg-inset) outline-none transition-colors data-[state=checked]:bg-(--accent) data-[state=checked]:border-(--accent) focus-visible:ring-2 focus-visible:ring-(--ring)"
|
||||||
|
>
|
||||||
|
<CheckboxIndicator class="text-(--accent-fg)">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||||
|
<path d="M2.5 6.5 5 9l4.5-5.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</CheckboxIndicator>
|
||||||
|
</CheckboxRoot>
|
||||||
|
<span class="text-sm text-(--fg)">{{ item.label }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<label class="flex items-start gap-3 cursor-pointer select-none">
|
||||||
|
<CheckboxRoot
|
||||||
|
v-model:checked="acceptedTerms"
|
||||||
|
required
|
||||||
|
class="grid place-items-center w-5 h-5 mt-0.5 rounded-md border border-(--border) bg-(--bg-inset) outline-none transition-colors data-[state=checked]:bg-emerald-500 data-[state=checked]:border-emerald-500 dark:data-[state=checked]:bg-emerald-400 dark:data-[state=checked]:border-emerald-400 focus-visible:ring-2 focus-visible:ring-(--ring)"
|
||||||
|
>
|
||||||
|
<CheckboxIndicator class="text-white dark:text-(--bg)">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||||
|
<path d="M2.5 6.5 5 9l4.5-5.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</CheckboxIndicator>
|
||||||
|
</CheckboxRoot>
|
||||||
|
<span class="text-sm text-(--fg-muted)">I accept the terms and conditions</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p
|
||||||
|
class="text-xs"
|
||||||
|
:class="acceptedTerms ? 'text-emerald-600 dark:text-emerald-400' : 'text-(--fg-subtle)'"
|
||||||
|
>
|
||||||
|
{{ acceptedTerms ? 'Ready to submit' : 'Please accept the terms to continue' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PrimitiveProps } from '../primitive';
|
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 {
|
export interface CollapsibleContentProps extends PrimitiveProps {
|
||||||
|
|
||||||
/** Render the content even when closed (useful for animation control). */
|
/** Render the content even when closed (useful for animation control). */
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PrimitiveProps } from '../primitive';
|
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 {
|
export interface CollapsibleRootProps extends PrimitiveProps {
|
||||||
|
|
||||||
defaultOpen?: boolean;
|
defaultOpen?: boolean;
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PrimitiveProps } from '../primitive';
|
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 {}
|
export interface CollapsibleTriggerProps extends PrimitiveProps {}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import {
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleRoot,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from '@robonen/primitives';
|
||||||
|
|
||||||
|
const open = ref(false);
|
||||||
|
|
||||||
|
const commits = [
|
||||||
|
{ id: 'a1c3f9', msg: 'Reflect open state via data-state' },
|
||||||
|
{ id: 'b7e2d4', msg: 'Wire aria-controls to content id' },
|
||||||
|
{ id: 'c0f8a1', msg: 'Unmount content with Presence when closed' },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CollapsibleRoot
|
||||||
|
v-model:open="open"
|
||||||
|
class="w-full max-w-sm rounded-xl border border-(--border) bg-(--bg-elevated) p-3 text-(--fg)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-3 px-1">
|
||||||
|
<span class="text-sm font-medium">
|
||||||
|
<span class="font-mono text-(--fg-muted)">@robonen</span> pushed 3 commits
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<CollapsibleTrigger
|
||||||
|
class="inline-flex size-7 items-center justify-center rounded-md border border-(--border) bg-(--bg) text-(--fg-muted) transition hover:bg-(--bg-inset) hover:text-(--fg) active:scale-95 cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
:aria-label="open ? 'Collapse commits' : 'Expand commits'"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="size-4 transition-transform duration-200"
|
||||||
|
:class="open ? 'rotate-180' : ''"
|
||||||
|
viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="m6 9 6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 rounded-lg border border-(--border) bg-(--bg) px-3 py-2 font-mono text-xs text-(--fg-muted)">
|
||||||
|
<span class="text-emerald-600 dark:text-emerald-400">{{ commits[0].id }}</span>
|
||||||
|
{{ commits[0].msg }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CollapsibleContent class="mt-1.5 space-y-1.5">
|
||||||
|
<div
|
||||||
|
v-for="commit in commits.slice(1)"
|
||||||
|
:key="commit.id"
|
||||||
|
class="rounded-lg border border-(--border) bg-(--bg) px-3 py-2 font-mono text-xs text-(--fg-muted)"
|
||||||
|
>
|
||||||
|
<span class="text-emerald-600 dark:text-emerald-400">{{ commit.id }}</span>
|
||||||
|
{{ commit.msg }}
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</CollapsibleRoot>
|
||||||
|
</template>
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PopperAnchorProps } from '../popper';
|
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 {}
|
export interface ComboboxAnchorProps extends PopperAnchorProps {}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PopperArrowProps } from '../popper';
|
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;
|
export type ComboboxArrowProps = PopperArrowProps;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PrimitiveProps } from '../primitive';
|
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 {}
|
export interface ComboboxCancelProps extends PrimitiveProps {}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ComboboxContentImplEmits, ComboboxContentImplProps } from './ComboboxContentImpl.vue';
|
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 ComboboxContentProps = ComboboxContentImplProps;
|
||||||
export type ComboboxContentEmits = ComboboxContentImplEmits;
|
export type ComboboxContentEmits = ComboboxContentImplEmits;
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import type { FocusScopeEmits } from '../focus-scope';
|
|||||||
import type { PopperContentProps } from '../popper';
|
import type { PopperContentProps } from '../popper';
|
||||||
import type { PrimitiveProps } from '../primitive';
|
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> {
|
export interface ComboboxContentImplProps extends PrimitiveProps, /* @vue-ignore */ Partial<PopperContentProps> {
|
||||||
/** Position strategy. @default 'popper' */
|
/** Position strategy. @default 'popper' */
|
||||||
position?: 'inline' | 'popper';
|
position?: 'inline' | 'popper';
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PrimitiveProps } from '../primitive';
|
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 {
|
export interface ComboboxEmptyProps extends PrimitiveProps {
|
||||||
/** Render even when items exist but none are filtered out. */
|
/** Render even when items exist but none are filtered out. */
|
||||||
always?: boolean;
|
always?: boolean;
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PrimitiveProps } from '../primitive';
|
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 {}
|
export interface ComboboxGroupProps extends PrimitiveProps {}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PrimitiveProps } from '../primitive';
|
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 {
|
export interface ComboboxInputProps extends PrimitiveProps {
|
||||||
/** Disable the input. */
|
/** Disable the input. */
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
import type { PrimitiveProps } from '../primitive';
|
import type { PrimitiveProps } from '../primitive';
|
||||||
import type { AcceptableValue } from './utils';
|
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 {
|
export interface ComboboxItemProps<T extends AcceptableValue = AcceptableValue> extends PrimitiveProps {
|
||||||
/** Item value. Selected/registered identity. */
|
/** Item value. Selected/registered identity. */
|
||||||
value: T;
|
value: T;
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PrimitiveProps } from '../primitive';
|
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 {}
|
export interface ComboboxItemIndicatorProps extends PrimitiveProps {}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PrimitiveProps } from '../primitive';
|
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 {}
|
export interface ComboboxLabelProps extends PrimitiveProps {}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PortalProps } from '../teleport';
|
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 {}
|
export interface ComboboxPortalProps extends PortalProps {}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,13 @@
|
|||||||
import type { Direction } from '../config-provider';
|
import type { Direction } from '../config-provider';
|
||||||
import type { AcceptableValue, ComboboxFilterFunction, ComboboxFilterItem } from './utils';
|
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> {
|
export interface ComboboxRootProps<T extends AcceptableValue = AcceptableValue> {
|
||||||
/** Controlled selected value. Use `v-model`. */
|
/** Controlled selected value. Use `v-model`. */
|
||||||
modelValue?: T | T[];
|
modelValue?: T | T[];
|
||||||
@@ -69,8 +76,6 @@ const {
|
|||||||
by,
|
by,
|
||||||
} = defineProps<ComboboxRootProps<T>>();
|
} = defineProps<ComboboxRootProps<T>>();
|
||||||
|
|
||||||
const emit = defineEmits<ComboboxRootEmits<T>>();
|
|
||||||
|
|
||||||
const config = useConfig();
|
const config = useConfig();
|
||||||
const direction = computed(() => dir ?? config.dir.value);
|
const direction = computed(() => dir ?? config.dir.value);
|
||||||
|
|
||||||
@@ -203,7 +208,6 @@ function isSelected(v: T): boolean {
|
|||||||
|
|
||||||
function commitValue(next: T | T[] | undefined) {
|
function commitValue(next: T | T[] | undefined) {
|
||||||
value.value = next;
|
value.value = next;
|
||||||
emit('update:modelValue', next);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onValueChange(v: T) {
|
function onValueChange(v: T) {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PrimitiveProps } from '../primitive';
|
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 {}
|
export interface ComboboxSeparatorProps extends PrimitiveProps {}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PrimitiveProps } from '../primitive';
|
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 {
|
export interface ComboboxTriggerProps extends PrimitiveProps {
|
||||||
/** Disable the trigger independently from the root. */
|
/** Disable the trigger independently from the root. */
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PrimitiveProps } from '../primitive';
|
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 {}
|
export interface ComboboxViewportProps extends PrimitiveProps {}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ComboboxAnchor,
|
||||||
|
ComboboxCancel,
|
||||||
|
ComboboxContent,
|
||||||
|
ComboboxEmpty,
|
||||||
|
ComboboxGroup,
|
||||||
|
ComboboxInput,
|
||||||
|
ComboboxItem,
|
||||||
|
ComboboxItemIndicator,
|
||||||
|
ComboboxLabel,
|
||||||
|
ComboboxPortal,
|
||||||
|
ComboboxRoot,
|
||||||
|
ComboboxTrigger,
|
||||||
|
ComboboxViewport,
|
||||||
|
} from '@robonen/primitives';
|
||||||
|
|
||||||
|
interface Framework {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
group: 'JavaScript' | 'Native';
|
||||||
|
}
|
||||||
|
|
||||||
|
const frameworks: Framework[] = [
|
||||||
|
{ value: 'vue', label: 'Vue', group: 'JavaScript' },
|
||||||
|
{ value: 'react', label: 'React', group: 'JavaScript' },
|
||||||
|
{ value: 'svelte', label: 'Svelte', group: 'JavaScript' },
|
||||||
|
{ value: 'solid', label: 'Solid', group: 'JavaScript' },
|
||||||
|
{ value: 'angular', label: 'Angular', group: 'JavaScript' },
|
||||||
|
{ value: 'swiftui', label: 'SwiftUI', group: 'Native' },
|
||||||
|
{ value: 'compose', label: 'Jetpack Compose', group: 'Native' },
|
||||||
|
{ value: 'flutter', label: 'Flutter', group: 'Native' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const selected = ref<string>();
|
||||||
|
|
||||||
|
function labelFor(value: string | undefined) {
|
||||||
|
return frameworks.find(f => f.value === value)?.label ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = ['JavaScript', 'Native'] as const;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-xs flex-col gap-3">
|
||||||
|
<ComboboxRoot
|
||||||
|
v-model="selected"
|
||||||
|
:display-value="labelFor"
|
||||||
|
class="relative"
|
||||||
|
>
|
||||||
|
<ComboboxAnchor
|
||||||
|
class="flex items-center gap-1 rounded-lg border border-(--border) bg-(--bg-inset) px-2 py-1.5 focus-within:border-(--accent) focus-within:ring-2 focus-within:ring-(--ring)"
|
||||||
|
>
|
||||||
|
<ComboboxInput
|
||||||
|
placeholder="Search a framework..."
|
||||||
|
open-on-click
|
||||||
|
class="min-w-0 flex-1 bg-transparent px-1 text-sm text-(--fg) outline-none placeholder:text-(--fg-subtle)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ComboboxCancel
|
||||||
|
class="grid size-5 place-items-center rounded text-(--fg-subtle) hover:bg-(--bg-subtle) hover:text-(--fg)"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" class="text-xs">✕</span>
|
||||||
|
</ComboboxCancel>
|
||||||
|
|
||||||
|
<ComboboxTrigger
|
||||||
|
class="grid size-5 place-items-center rounded text-(--fg-muted) hover:bg-(--bg-subtle) hover:text-(--fg) data-[state=open]:rotate-180"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" class="text-xs">▾</span>
|
||||||
|
</ComboboxTrigger>
|
||||||
|
</ComboboxAnchor>
|
||||||
|
|
||||||
|
<ComboboxPortal>
|
||||||
|
<ComboboxContent
|
||||||
|
:side-offset="6"
|
||||||
|
class="z-50 w-(--popper-anchor-width) overflow-hidden rounded-lg border border-(--border) bg-(--bg-elevated) shadow-lg"
|
||||||
|
>
|
||||||
|
<ComboboxViewport class="max-h-60 p-1">
|
||||||
|
<ComboboxEmpty class="px-3 py-6 text-center text-sm text-(--fg-subtle)">
|
||||||
|
No frameworks found.
|
||||||
|
</ComboboxEmpty>
|
||||||
|
|
||||||
|
<ComboboxGroup
|
||||||
|
v-for="group in groups"
|
||||||
|
:key="group"
|
||||||
|
class="mb-1 last:mb-0"
|
||||||
|
>
|
||||||
|
<ComboboxLabel
|
||||||
|
class="px-2 py-1 text-xs font-medium uppercase tracking-wide text-(--fg-subtle)"
|
||||||
|
>
|
||||||
|
{{ group }}
|
||||||
|
</ComboboxLabel>
|
||||||
|
|
||||||
|
<ComboboxItem
|
||||||
|
v-for="framework in frameworks.filter(f => f.group === group)"
|
||||||
|
:key="framework.value"
|
||||||
|
:value="framework.value"
|
||||||
|
:text-value="framework.label"
|
||||||
|
class="flex cursor-pointer items-center justify-between rounded-md px-2 py-1.5 text-sm text-(--fg) outline-none data-[highlighted]:bg-(--accent) data-[highlighted]:text-(--accent-fg) data-[disabled]:opacity-50"
|
||||||
|
>
|
||||||
|
<span>{{ framework.label }}</span>
|
||||||
|
<ComboboxItemIndicator>
|
||||||
|
<span aria-hidden="true">✓</span>
|
||||||
|
</ComboboxItemIndicator>
|
||||||
|
</ComboboxItem>
|
||||||
|
</ComboboxGroup>
|
||||||
|
</ComboboxViewport>
|
||||||
|
</ComboboxContent>
|
||||||
|
</ComboboxPortal>
|
||||||
|
</ComboboxRoot>
|
||||||
|
|
||||||
|
<p class="text-sm text-(--fg-muted)">
|
||||||
|
Selected:
|
||||||
|
<span class="font-medium text-(--fg)">{{ selected ? labelFor(selected) : 'none' }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PrimitiveProps } from '../primitive';
|
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 {
|
export interface CommandEmptyProps extends PrimitiveProps {
|
||||||
/** Render even while there is no active search term. */
|
/** Render even while there is no active search term. */
|
||||||
always?: boolean;
|
always?: boolean;
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PrimitiveProps } from '../primitive';
|
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 {
|
export interface CommandGroupProps extends PrimitiveProps {
|
||||||
/** Group heading text (rendered when the default slot doesn't override it). */
|
/** Group heading text (rendered when the default slot doesn't override it). */
|
||||||
heading?: string;
|
heading?: string;
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PrimitiveProps } from '../primitive';
|
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 {
|
export interface CommandInputProps extends PrimitiveProps {
|
||||||
/** Controlled value; falls back to root `searchTerm`. */
|
/** Controlled value; falls back to root `searchTerm`. */
|
||||||
modelValue?: string;
|
modelValue?: string;
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PrimitiveProps } from '../primitive';
|
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 {
|
export interface CommandItemProps extends PrimitiveProps {
|
||||||
/** Item value — used by filter, selection, and `data-value`. */
|
/** Item value — used by filter, selection, and `data-value`. */
|
||||||
value: string;
|
value: string;
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PrimitiveProps } from '../primitive';
|
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 {}
|
export interface CommandListProps extends PrimitiveProps {}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PrimitiveProps } from '../primitive';
|
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 {
|
export interface CommandLoadingProps extends PrimitiveProps {
|
||||||
/** Accessible label describing the loading state. */
|
/** Accessible label describing the loading state. */
|
||||||
label?: string;
|
label?: string;
|
||||||
|
|||||||
@@ -2,6 +2,15 @@
|
|||||||
import type { PrimitiveProps } from '../primitive';
|
import type { PrimitiveProps } from '../primitive';
|
||||||
import type { CommandFilterFunction } from './utils';
|
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 {
|
export interface CommandRootProps extends PrimitiveProps {
|
||||||
/** Controlled selected value. Use `v-model`. */
|
/** Controlled selected value. Use `v-model`. */
|
||||||
modelValue?: string;
|
modelValue?: string;
|
||||||
@@ -50,8 +59,6 @@ const {
|
|||||||
label,
|
label,
|
||||||
} = defineProps<CommandRootProps>();
|
} = defineProps<CommandRootProps>();
|
||||||
|
|
||||||
const emit = defineEmits<CommandRootEmits>();
|
|
||||||
|
|
||||||
const { forwardRef } = useForwardExpose();
|
const { forwardRef } = useForwardExpose();
|
||||||
|
|
||||||
const localValue = ref<string | undefined>(defaultValue);
|
const localValue = ref<string | undefined>(defaultValue);
|
||||||
@@ -179,12 +186,10 @@ function getItemId(val: string): string {
|
|||||||
|
|
||||||
function setModelValue(v: string | undefined) {
|
function setModelValue(v: string | undefined) {
|
||||||
value.value = v;
|
value.value = v;
|
||||||
emit('update:modelValue', v);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSearchTerm(v: string) {
|
function setSearchTerm(v: string) {
|
||||||
search.value = v;
|
search.value = v;
|
||||||
emit('update:searchTerm', v);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSelectedValue(v: string | undefined) {
|
function setSelectedValue(v: string | undefined) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user