Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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 -- -- <PATTERN>` - Run tests matching `<PATTERN>`
- `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'`
6 changes: 6 additions & 0 deletions packages/js-toolkit/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
117 changes: 117 additions & 0 deletions packages/js-toolkit/helpers/queryComponent.ts
Original file line number Diff line number Diff line change
@@ -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<T extends Base = Base>(
query: string,
options: QueryOptions = {},
): T | undefined {
const parsedQuery = parseQuery(query);
const { from = document } = options;
const instances = getInstances() as Set<T>;

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<T extends Base = Base>(
query: string,
options: QueryOptions = {},
): T[] {
const parsedQuery = parseQuery(query);
const { from = document } = options;
const instances = getInstances() as Set<T>;
const selectedInstances = new Set<T>();

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<T extends Base = Base>(
query: string,
options: { from: HTMLElement } = { from: null },
): T | undefined {
const { from } = options;
const parsedQuery = parseQuery(query);
const instances = getInstances() as Set<T>;
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;
}
1 change: 1 addition & 0 deletions packages/tests/__utils__/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export * from './mockLoad.js';
export * from './mockRequestIdleCallback.js';
export * from './resizeWindow.js';
export * from './scroll.js';
export * from './lifecycle.js';
9 changes: 9 additions & 0 deletions packages/tests/__utils__/lifecycle.ts
Original file line number Diff line number Diff line change
@@ -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()));
}
3 changes: 3 additions & 0 deletions packages/tests/helpers/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -14,6 +15,8 @@ test('helpers exports', () => {
"importWhenPrefersMotion",
"importWhenVisible",
"isDirectChild",
"queryComponent",
"queryComponentAll",
]
`);
});
145 changes: 145 additions & 0 deletions packages/tests/helpers/queryComponent.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading