diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..191541311 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,83 @@ +# AGENTS + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is @studiometa/js-toolkit, a JavaScript data-attributes driven micro-framework with utility functions. The project is a monorepo using NPM workspaces with the following structure: + +- **packages/js-toolkit/**: Main framework code (TypeScript) +- **packages/tests/**: Test suite using Vitest +- **packages/demo/**: Demo application +- **packages/docs/**: VitePress documentation site + +The framework allows defining components as classes that bind to DOM elements via `data-component` attributes, with refs (`data-ref`), options (`data-option-*`), and child components. + +## Development Commands + +### Testing + +- `npm test` - Run all tests +- `npm test -- -- ` - Run tests matching `` +- `npm run test:watch` - Watch mode for tests + +### Linting and Type Checking + +- `npm run lint` - Run all linting (oxlint, TypeScript, docs formatting) +- `npm run lint:oxlint` - JavaScript/TypeScript linting with oxlint +- `npm run lint:types` - TypeScript type checking +- `npm run lint:docs` - Check docs formatting with Prettier +- `npm run fix` - Auto-fix formatting issues + +### Building + +- `npm run build` - Full build (cleans dist, builds package, types, copies files) +- `npm run build:pkg` - Build JavaScript bundle with esbuild +- `npm run build:types` - Generate TypeScript declarations + +### Development Servers + +- `npm run demo:dev` - Start demo development server +- `npm run demo:build` - Build demo for production +- `npm run docs:dev` - Start documentation development server +- `npm run docs:build` - Build documentation + +## Architecture + +### Core Framework Structure + +- **Base class** (`packages/js-toolkit/Base/`): Main component base class with lifecycle methods, managers for refs/options/children/events +- **Managers** (`packages/js-toolkit/Base/managers/`): Handle refs, options, children, events, and services +- **Decorators** (`packages/js-toolkit/decorators/`): Higher-order functions that extend component functionality (withMountWhenInView, withDrag, withMutation, etc.) +- **Services** (`packages/js-toolkit/services/`): Global services like scheduler, mutation observer, etc. +- **Helpers** (`packages/js-toolkit/helpers/`): Framework utilities including `createApp` function +- **Utils** (`packages/js-toolkit/utils/`): Comprehensive utility functions organized by category (DOM, CSS, math, collision detection, etc.) + +### Key Concepts + +- Components extend the `Base` class and define a static `config` object +- `createApp()` instantiates root components on page load +- Components use lifecycle methods (`mounted()`, `destroyed()`, event handlers like `onRefClick()`) +- Decorators provide reusable behaviors across components +- Data attributes drive component instantiation and configuration + +### Build System + +- Uses esbuild for bundling TypeScript to ESM +- TypeScript compilation with multiple tsconfig files (build, lint) +- Outputs to `dist/` directory preserving source structure +- Package.json gets modified during build (index.ts → index.js) + +### Testing + +- Vitest with Happy DOM environment +- Tests use `#private/*` imports to access internal modules +- Coverage tracking with v8 provider +- Test files mirror source structure in `packages/tests/` + +## Import Patterns + +- Main exports: `import { Base, createApp } from '@studiometa/js-toolkit'` +- Utils only: `import { debounce, nextFrame } from '@studiometa/js-toolkit/utils'` +- Internal (tests): `import { ... } from '#private/...'` +- Test utils: `import { ... } from '#test-utils'` diff --git a/packages/js-toolkit/helpers/index.ts b/packages/js-toolkit/helpers/index.ts index 4afe180b1..dc59858b0 100644 --- a/packages/js-toolkit/helpers/index.ts +++ b/packages/js-toolkit/helpers/index.ts @@ -7,3 +7,9 @@ export * from './importOnMediaQuery.js'; export * from './importWhenIdle.js'; export * from './importWhenPrefersMotion.js'; export * from './importWhenVisible.js'; +export { + type QueryOptions, + queryComponent, + queryComponentAll, + closestComponent, +} from './queryComponent.js'; diff --git a/packages/js-toolkit/helpers/queryComponent.ts b/packages/js-toolkit/helpers/queryComponent.ts new file mode 100644 index 000000000..276de2755 --- /dev/null +++ b/packages/js-toolkit/helpers/queryComponent.ts @@ -0,0 +1,117 @@ +import type { Base } from '../Base/index.js'; +import { getInstances } from '../Base/index.js'; +import { memo } from '../utils/memo.js'; +import { getAncestorWhere } from '../utils/dom/ancestors.js'; + +export interface QueryOptions { + from?: HTMLElement | Document; +} + +type ComponentState = 'mounted' | 'destroyed' | 'terminated'; +export type ParsedQuery = { + name: string; + cssSelector?: string; + state?: string; +}; + +const REGEX_QUERY = /([a-zA-Z0-9]+)(\((.*)\))?(:([a-z]+))?/; + +/** + * Parse a query string like 'Foo(.css-selector):mounted' into its parts. + */ +export const parseQuery = memo((query: string): ParsedQuery => { + const [, name, , cssSelector, , state] = query.match(REGEX_QUERY) as [ + string, + string, + string | undefined, + string | undefined, + string | undefined, + ComponentState | undefined, + ]; + + return { + name, + cssSelector, + state, + }; +}); + +export function instanceIsMatching(instance: Base, parsedQuery: ParsedQuery): boolean { + if (instance.$config.name !== parsedQuery.name) return false; + if (parsedQuery.cssSelector && !instance.$el.matches(parsedQuery.cssSelector)) return false; + + if (parsedQuery.state === 'mounted' && !instance.$isMounted) return false; + if (parsedQuery.state === 'destroyed' && instance.$isMounted) return false; + + return true; +} + +/** + * Get the first instance of component with the given query. + */ +export function queryComponent( + query: string, + options: QueryOptions = {}, +): T | undefined { + const parsedQuery = parseQuery(query); + const { from = document } = options; + const instances = getInstances() as Set; + + for (const instance of instances) { + if (!instanceIsMatching(instance, parsedQuery)) continue; + if (from !== document && !(from === instance.$el || from.contains(instance.$el))) continue; + + return instance; + } +} + +/** + * Get all instances of component with the given query. + */ +export function queryComponentAll( + query: string, + options: QueryOptions = {}, +): T[] { + const parsedQuery = parseQuery(query); + const { from = document } = options; + const instances = getInstances() as Set; + const selectedInstances = new Set(); + + for (const instance of instances) { + if (!instanceIsMatching(instance, parsedQuery)) continue; + if (from !== document && !(from === instance.$el || from.contains(instance.$el))) continue; + + selectedInstances.add(instance); + } + + return Array.from(selectedInstances); +} + +/** + * Get the closest component instance by traversing up the DOM tree. + */ +export function closestComponent( + query: string, + options: { from: HTMLElement } = { from: null }, +): T | undefined { + const { from } = options; + const parsedQuery = parseQuery(query); + const instances = getInstances() as Set; + let closestInstance = null; + + getAncestorWhere(from, (element) => { + if (!element) return false; + if (parsedQuery.cssSelector && !element.matches(parsedQuery.cssSelector)) return false; + + for (const instance of instances) { + if (instanceIsMatching(instance, parsedQuery)) { + closestInstance = instance; + return true; + } + } + + return false; + }) ?? undefined; + + return closestInstance ?? undefined; +} diff --git a/packages/tests/__utils__/index.ts b/packages/tests/__utils__/index.ts index a2fa0a37b..03c5c4ebe 100644 --- a/packages/tests/__utils__/index.ts +++ b/packages/tests/__utils__/index.ts @@ -8,3 +8,4 @@ export * from './mockLoad.js'; export * from './mockRequestIdleCallback.js'; export * from './resizeWindow.js'; export * from './scroll.js'; +export * from './lifecycle.js'; diff --git a/packages/tests/__utils__/lifecycle.ts b/packages/tests/__utils__/lifecycle.ts new file mode 100644 index 000000000..a80724d74 --- /dev/null +++ b/packages/tests/__utils__/lifecycle.ts @@ -0,0 +1,9 @@ +import type { Base } from '@studiometa/js-toolkit'; + +export async function mount(...components: Base[]) { + await Promise.all(components.map((component) => component.$mount())); +} + +export async function destroy(...components: Base[]) { + await Promise.all(components.map((component) => component.$destroy())); +} diff --git a/packages/tests/helpers/index.spec.ts b/packages/tests/helpers/index.spec.ts index 5784faa10..ce3a4f00a 100644 --- a/packages/tests/helpers/index.spec.ts +++ b/packages/tests/helpers/index.spec.ts @@ -4,6 +4,7 @@ import * as helpers from '#private/helpers/index.js'; test('helpers exports', () => { expect(Object.keys(helpers).toSorted()).toMatchInlineSnapshot(` [ + "closestComponent", "createApp", "getClosestParent", "getDirectChildren", @@ -14,6 +15,8 @@ test('helpers exports', () => { "importWhenPrefersMotion", "importWhenVisible", "isDirectChild", + "queryComponent", + "queryComponentAll", ] `); }); diff --git a/packages/tests/helpers/queryComponent.spec.ts b/packages/tests/helpers/queryComponent.spec.ts new file mode 100644 index 000000000..3cd071d33 --- /dev/null +++ b/packages/tests/helpers/queryComponent.spec.ts @@ -0,0 +1,145 @@ +import { describe, it, expect } from 'vitest'; +import { + Base, + queryComponent, + queryComponentAll, + closestComponent, + withName, +} from '@studiometa/js-toolkit'; +import { + parseQuery, + type ParsedQuery, + instanceIsMatching, +} from '#private/helpers/queryComponent.js'; +import { destroy, h, mount } from '#test-utils'; + +let index = 0; +function randomName() { + index += 1; + return `Foo${index}`; +} + +describe('The `parseQuery` function', () => { + const specs = [ + ['Foo', { name: 'Foo', cssSelector: undefined, state: undefined }], + ['Foo:mounted', { name: 'Foo', cssSelector: undefined, state: 'mounted' }], + ['Foo(#id)', { name: 'Foo', cssSelector: '#id', state: undefined }], + [ + 'Foo(.foo:is(.bar)):destroyed', + { name: 'Foo', cssSelector: '.foo:is(.bar)', state: 'destroyed' }, + ], + ] as [string, ParsedQuery][]; + + for (const [query, result] of specs) { + it(`should parse "${query}"`, () => { + expect(parseQuery(query)).toEqual(result); + }); + } +}); + +describe('The `instanceIsMatching` function', () => { + it('should match names', async () => { + const div = h(); + const instance = new (withName(Base, 'Foo'))(div); + await mount(instance); + expect(instanceIsMatching(instance, { name: 'Foo' })).toBe(true); + expect(instanceIsMatching(instance, { name: 'Bar' })).toBe(false); + }); + + it('should match CSS selectors', async () => { + const div = h('div', { id: 'foo' }); + const instance = new (withName(Base, 'Foo'))(div); + await mount(instance); + expect(instanceIsMatching(instance, { name: 'Foo', cssSelector: '#foo' })).toBe(true); + expect(instanceIsMatching(instance, { name: 'Foo', cssSelector: '#bar' })).toBe(false); + }); + + it('should match states', async () => { + const div = h('div'); + const instance = new (withName(Base, 'Foo'))(div); + expect(instanceIsMatching(instance, { name: 'Foo', state: 'mounted' })).toBe(false); + expect(instanceIsMatching(instance, { name: 'Foo', state: 'destroyed' })).toBe(true); + await mount(instance); + expect(instanceIsMatching(instance, { name: 'Foo', state: 'mounted' })).toBe(true); + expect(instanceIsMatching(instance, { name: 'Foo', state: 'destroyed' })).toBe(false); + await destroy(instance); + expect(instanceIsMatching(instance, { name: 'Foo', state: 'mounted' })).toBe(false); + expect(instanceIsMatching(instance, { name: 'Foo', state: 'destroyed' })).toBe(true); + }); +}); + +describe('The `queryComponent` function', () => { + it('should return a single component matching the given query', async () => { + const name = randomName(); + const div = h('div'); + const instance = new (withName(Base, name))(div); + await mount(instance); + expect(queryComponent(name)).toBe(instance); + expect(queryComponent(randomName())).toBeUndefined(); + }); + + it('should return a single component matching the given query from the given root', async () => { + const name = randomName(); + const div = h('div'); + const middle = h('div', [div]) + const root = h('div', [middle]); + const instance = new (withName(Base, name))(div); + await mount(instance); + expect(queryComponent(name, { from: root })).toBe(instance); + expect(queryComponent(name, { from: middle })).toBe(instance); + expect(queryComponent(name, { from: div })).toBe(instance); + expect(queryComponent(randomName(), { from: root })).toBeUndefined(); + expect(queryComponent(randomName(), { from: div })).toBeUndefined(); + expect(queryComponent(randomName(), { from: middle })).toBeUndefined(); + expect(queryComponent(randomName(), { from: h() })).toBeUndefined(); + }); +}); + +describe('The `queryComponentAll` function', () => { + it('should return a list of components matching the given query', async () => { + const name = randomName(); + const divA = h('div'); + const divB = h('div'); + const root = h('div', [divA, divB]); + const instanceA = new (withName(Base, name))(divA); + const instanceB = new (withName(Base, name))(divB); + await mount(instanceA, instanceB); + expect(queryComponentAll(name, { from: root })).toEqual([instanceA, instanceB]); + expect(queryComponentAll(randomName(), { from: root })).toEqual([]); + + expect(queryComponentAll(name, { from: divA })).toEqual([instanceA]); + expect(queryComponentAll(randomName(), { from: divA })).toEqual([]); + }); + + it('should return a list of components matching the given query', async () => { + const name = randomName(); + const instanceA = new (withName(Base, name))(h('div')); + const instanceB = new (withName(Base, name))(h('div')); + await mount(instanceA, instanceB); + expect(queryComponentAll(name)).toEqual([instanceA, instanceB]); + expect(queryComponentAll(randomName())).toEqual([]); + }); +}); + +describe('The `closestComponent` function', () => { + it('should return the closest instance matching the given query', async () => { + const name = randomName(); + const child = h('div'); + const div = h('div', [child]); + const instance = new (withName(Base, name))(div); + await mount(instance); + expect(closestComponent(name, { from: child })).toBe(instance); + expect(closestComponent(randomName(), { from: child })).toBeUndefined(); + }); + + it('should return the closest instance matching the given query with CSS selector', async () => { + const name = randomName(); + const grandchild = h('div'); + const child = h('div', [grandchild]); + const div = h('div', { id: 'id' }, [child]); + const instance = new (withName(Base, name))(div); + await mount(instance); + expect(closestComponent(`${name}(#id)`, { from: grandchild })).toBe(instance); + expect(closestComponent(`${randomName()}(#id)`, { from: grandchild })).toBeUndefined(); + }); +}); diff --git a/packages/tests/index.spec.ts b/packages/tests/index.spec.ts index 7eb38bfb5..cc021a6f6 100644 --- a/packages/tests/index.spec.ts +++ b/packages/tests/index.spec.ts @@ -15,6 +15,7 @@ describe('The package exports', () => { "RafService", "ResizeService", "ScrollService", + "closestComponent", "createApp", "getClosestParent", "getDirectChildren", @@ -26,6 +27,8 @@ describe('The package exports', () => { "importWhenPrefersMotion", "importWhenVisible", "isDirectChild", + "queryComponent", + "queryComponentAll", "useDrag", "useKey", "useLoad",