Skip to content
Merged
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
79 changes: 79 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# CLAUDE.md - @elementmints/date

## Project Overview

A zero-dependency, attribute-driven date picker library. Supports single/range/week/month selection modes, segmented and natural input editing, dual-month booking layouts, time picker, and more.

## Commands

```bash
npm run build # Full build: JS (rollup) + types (tsc) + CSS (sass)
npm run build:js # Rollup bundles: ESM, CJS, IIFE
npm run build:types # TypeScript declarations only
npm run build:css # Sass -> dist/date.min.css
npm test # Jest tests (jsdom)
npm run test:coverage # Tests with coverage report
npm run lint # ESLint (warnings only, 0 errors expected)
npm run typecheck # tsc --noEmit
npm run size # size-limit check (JS <25kB, CSS <4kB gzipped)
npm run demo # Serve demo at localhost:3000
```

## Architecture

### Source Layout

- `src/core/` - Pure logic: types, config parser, date utils, formatter, parser, validator, calendar generation, locale
- `src/dom/` - DOM layer: renderer, input-mask (segmented), natural-input, positioning, diff engine, event delegation
- `src/features/` - Optional features: accessibility, keyboard nav, error display, async validation
- `src/datepicker.ts` - Main orchestrator class that wires everything together
- `src/styles/` - SCSS: `_variables.scss` (CSS custom props), `_base.scss` (calendar/grid), `_input.scss`, `_themes.scss`, `_rtl.scss`
- `src/adapters/` - React, Vue, Angular wrappers
- `src/wrapper/web-component.ts` - Custom element wrapper
- `src/plugins/` - Plugin system
- `src/index.ts` - Public ESM exports
- `src/browser.ts` - IIFE entry with auto-init

### Build Outputs

- `dist/esm/` - ES modules (preserveModules)
- `dist/cjs/index.cjs` - CommonJS
- `dist/iife/date.min.js` - Browser bundle (minified, global `DatePicker`)
- `dist/types/` - TypeScript declarations
- `dist/date.min.css` - Compiled styles

### Key Patterns

- **Configuration**: All options via `data-*` attributes on the input element, parsed in `config.ts`
- **Event delegation**: Single listener per event type on calendar root (`dom/events.ts`), not per-element
- **DOM diffing**: `dom/diff.ts` patches the calendar grid efficiently without full re-render
- **Input modes**: `segmented` (keyboard-navigable segments) vs `native` (natural masked typing with split fields)
- **Selection modes**: `single`, `range` (hotel booking), `week`, `month`

## Conventions

- Strict TypeScript (`strict: true`, `noUnusedLocals`, `noUnusedParameters`)
- Prefix unused parameters with `_` (eslint rule: `argsIgnorePattern: ^_`)
- CSS custom properties namespaced with `--dp-` prefix
- CSS classes namespaced with `dp-` prefix, BEM-style modifiers (`dp-day--selected`, `dp-day--disabled`)
- Commit messages follow Conventional Commits (commitlint enforced via husky)
- Tests in `__tests__/` mirroring `src/` structure, using jest + jsdom
- No runtime dependencies. React/Vue are optional peer deps.

## CI Pipeline

GitHub Actions (`ci.yml`): lint -> typecheck -> test (with coverage) -> build -> size-check

Size budgets: JS IIFE < 25kB gzipped, CSS < 4kB gzipped.

## Demo

`demo/index.html` - Interactive playground with live-editable markup cards. Loads from `dist/`. Has dark/light theme toggle. Run with `npm run demo`.

## Important Notes

- The `DatePicker` constructor takes an `<input>` element (or container with an input)
- `calendarOnly` mode makes the input readonly - users must select from the calendar
- `portal` mode appends the calendar to `document.body` to avoid overflow clipping
- The `rerenderDualMonth()` method does a full innerHTML replacement and must re-append extras (footer, custom header) via `appendCalendarExtras()`
- Disabled date validation runs on both calendar click AND manual input
18 changes: 18 additions & 0 deletions __tests__/core/calendar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,24 @@ describe('generateMonth', () => {
const selectedDays = allDays.filter(d => d.isSelected);
expect(selectedDays).toHaveLength(0);
});

it('marks range start, range end, and in-between days', () => {
const result = generateMonth(2024, 5, {
weekStart: 0,
rangeStart: new Date(2024, 5, 10),
rangeEnd: new Date(2024, 5, 13),
});
const allDays = result.days.flat().filter(d => !d.isOtherMonth);
const day10 = allDays.find(d => d.day === 10);
const day11 = allDays.find(d => d.day === 11);
const day13 = allDays.find(d => d.day === 13);

expect(day10?.isRangeStart).toBe(true);
expect(day10?.isSelected).toBe(true);
expect(day11?.isInRange).toBe(true);
expect(day13?.isRangeEnd).toBe(true);
expect(day13?.isSelected).toBe(true);
});
});

describe('disabled dates', () => {
Expand Down
23 changes: 23 additions & 0 deletions __tests__/core/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ describe('DEFAULT_CONFIG', () => {
expect(DEFAULT_CONFIG.min).toBeNull();
expect(DEFAULT_CONFIG.max).toBeNull();
expect(DEFAULT_CONFIG.valueType).toBe('iso');
expect(DEFAULT_CONFIG.selectionMode).toBe('single');
expect(DEFAULT_CONFIG.inputMode).toBe('segmented');
expect(DEFAULT_CONFIG.calendar).toBe(true);
expect(DEFAULT_CONFIG.locale).toBe('en');
expect(DEFAULT_CONFIG.weekStart).toBe(1);
expect(DEFAULT_CONFIG.theme).toBe('system');
Expand All @@ -29,6 +32,8 @@ describe('DEFAULT_CONFIG', () => {
expect(DEFAULT_CONFIG.keyboard).toBe(true);
expect(DEFAULT_CONFIG.className).toBe('');
expect(DEFAULT_CONFIG.position).toBe('auto');
expect(DEFAULT_CONFIG.rangeSeparator).toBe(' to ');
expect(DEFAULT_CONFIG.analytics).toBe('events');
});
});

Expand All @@ -39,11 +44,29 @@ describe('parseConfig', () => {
expect(config.min).toBeNull();
expect(config.max).toBeNull();
expect(config.valueType).toBe('iso');
expect(config.selectionMode).toBe('single');
expect(config.inputMode).toBe('segmented');
expect(config.calendar).toBe(true);
expect(config.locale).toBe('en');
expect(config.weekStart).toBe(1);
expect(config.required).toBe(false);
});

it('reads range mode and related options', () => {
const config = parseConfig(mockElement({
selectionMode: 'range',
inputMode: 'native',
calendar: 'false',
rangeSeparator: ' -> ',
analytics: 'datalayer',
}));
expect(config.selectionMode).toBe('range');
expect(config.inputMode).toBe('native');
expect(config.calendar).toBe(false);
expect(config.rangeSeparator).toBe(' -> ');
expect(config.analytics).toBe('datalayer');
});

it('reads data-format', () => {
const config = parseConfig(mockElement({ format: 'DD/MM/YYYY' }));
expect(config.format).toBe('DD/MM/YYYY');
Expand Down
103 changes: 103 additions & 0 deletions __tests__/dom/natural-input.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import {
NaturalDateInput,
supportsNaturalInputFormat,
} from '../../src/dom/natural-input';

function press(input: HTMLInputElement, key: string): void {
input.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
}

describe('NaturalDateInput', () => {
let input: HTMLInputElement;
let naturalInput: NaturalDateInput;

beforeEach(() => {
input = document.createElement('input');
input.type = 'text';
document.body.appendChild(input);
naturalInput = new NaturalDateInput(input, 'DD/MM/YYYY');
input.focus();
input.setSelectionRange(0, 0);
});

afterEach(() => {
naturalInput.destroy();
document.body.innerHTML = '';
});

it('supports numeric formats with different token widths and separators', () => {
expect(supportsNaturalInputFormat('DD/MM/YYYY')).toBe(true);
expect(supportsNaturalInputFormat('D/M/YYYY')).toBe(true);
expect(supportsNaturalInputFormat('DD.MM.YYYY')).toBe(true);
expect(supportsNaturalInputFormat('M-D-YY')).toBe(true);
expect(supportsNaturalInputFormat('YYYY-MM-DD')).toBe(true);
expect(supportsNaturalInputFormat('DD MMM YYYY')).toBe(false);
});

it('auto-inserts separators when segments complete', () => {
press(input, '1');
expect(input.value).toBe('1');
expect(input.selectionStart).toBe(1);

press(input, '2');
expect(input.value).toBe('12/');
expect(input.selectionStart).toBe(3);

press(input, '0');
expect(input.value).toBe('12/0');
expect(input.selectionStart).toBe(4);

press(input, '5');
expect(input.value).toBe('12/05/');
expect(input.selectionStart).toBe(6);
});

it('clears naturally across separators with backspace', () => {
naturalInput.setDate(new Date(2026, 4, 12));
input.setSelectionRange(3, 3);

press(input, 'Backspace');
expect(input.value).toBe('1/05/2026');
expect(input.selectionStart).toBe(1);

press(input, '2');
expect(input.value).toBe('12/05/2026');
expect(input.selectionStart).toBe(3);
});

it('supports variable-width day and month tokens with manual separator jumps', () => {
naturalInput.destroy();
naturalInput = new NaturalDateInput(input, 'D/M/YYYY');
input.focus();
input.setSelectionRange(0, 0);

press(input, '4');
expect(input.value).toBe('4');

press(input, '/');
expect(input.value).toBe('4/');
expect(input.selectionStart).toBe(2);

press(input, '2');
expect(input.value).toBe('4/2');

press(input, '/');
expect(input.value).toBe('4/2/');
expect(input.selectionStart).toBe(4);

press(input, '2');
press(input, '0');
press(input, '2');
press(input, '6');

expect(input.value).toBe('4/2/2026');
});

it('renders dates without zero padding for D and M tokens', () => {
naturalInput.destroy();
naturalInput = new NaturalDateInput(input, 'D.M.YYYY');

naturalInput.setDate(new Date(2026, 1, 4));
expect(input.value).toBe('4.2.2026');
});
});
80 changes: 80 additions & 0 deletions __tests__/integration/datepicker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,15 @@ describe('DatePicker integration', () => {
p.destroy();
document.body.removeChild(wrapper);
});

it('does not create a toggle when calendar is disabled', () => {
input.setAttribute('data-calendar', 'false');
input.setAttribute('data-input-mode', 'native');
picker = new DatePicker(input);
const wrapper = input.closest('.dp-input');
expect(wrapper?.querySelector('.dp-toggle')).toBeNull();
expect(input.getAttribute('role')).toBeNull();
});
});

describe('open/close', () => {
Expand Down Expand Up @@ -172,6 +181,27 @@ describe('DatePicker integration', () => {
expect(handler.mock.calls[0][0].detail).toHaveProperty('value');
}
});

it('supports hotel-style range selection', () => {
input.setAttribute('data-selection-mode', 'range');
picker.destroy();
picker = new DatePicker(input);
picker.open();

const days = Array.from(
document.querySelectorAll('.dp-day:not([disabled]):not(.dp-day--other-month)'),
) as HTMLElement[];

expect(days.length).toBeGreaterThan(3);
days[1].click();
days[3].click();

expect(picker.getValue()).toContain(',');
expect(input.value).toContain(' to ');
const range = picker.getRange();
expect(range.start).toBeInstanceOf(Date);
expect(range.end).toBeInstanceOf(Date);
});
});

describe('getValue/setValue API', () => {
Expand Down Expand Up @@ -216,6 +246,56 @@ describe('DatePicker integration', () => {
expect(d1).not.toBe(d2);
expect(d1!.getTime()).toBe(d2!.getTime());
});

it('supports native manual typing mode', () => {
picker.destroy();
input.setAttribute('data-calendar', 'false');
input.setAttribute('data-input-mode', 'native');
input.setAttribute('data-format', 'DD/MM/YYYY');
picker = new DatePicker(input);

input.focus();
const keys = ['2', '4', '1', '2', '2', '0', '2', '6'];
for (const key of keys) {
input.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
}

expect(input.value).toBe('24/12/2026');

input.dispatchEvent(new Event('blur', { bubbles: true }));

expect(picker.getValue()).toBe('2026-12-24');
});

it('supports variable-width native formats declared in HTML', () => {
picker.destroy();
input.setAttribute('data-calendar', 'false');
input.setAttribute('data-input-mode', 'native');
input.setAttribute('data-format', 'D.M.YYYY');
picker = new DatePicker(input);

input.focus();
const keys = ['4', '.', '2', '.', '2', '0', '2', '6'];
for (const key of keys) {
input.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
}

expect(input.value).toBe('4.2.2026');

input.dispatchEvent(new Event('blur', { bubbles: true }));
expect(picker.getValue()).toBe('2026-02-04');
});

it('emits analytics events when enabled', () => {
picker.destroy();
input.setAttribute('data-analytics', 'events');
picker = new DatePicker(input);
const handler = jest.fn();
input.addEventListener('datepicker:analytics', handler);

picker.open();
expect(handler).toHaveBeenCalled();
});
});

describe('destroy()', () => {
Expand Down
Loading
Loading