Split Native Input
+Three separate fields for day, month, and year with clear placeholders. Supports single D and M formats. Auto-advances on segment completion.
+diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..99d67c7 --- /dev/null +++ b/CLAUDE.md @@ -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 `` 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 diff --git a/__tests__/core/calendar.test.ts b/__tests__/core/calendar.test.ts index b422d4c..a9e0c20 100644 --- a/__tests__/core/calendar.test.ts +++ b/__tests__/core/calendar.test.ts @@ -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', () => { diff --git a/__tests__/core/config.test.ts b/__tests__/core/config.test.ts index f5fcda1..3f1279b 100644 --- a/__tests__/core/config.test.ts +++ b/__tests__/core/config.test.ts @@ -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'); @@ -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'); }); }); @@ -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'); diff --git a/__tests__/dom/natural-input.test.ts b/__tests__/dom/natural-input.test.ts new file mode 100644 index 0000000..ac0e5bc --- /dev/null +++ b/__tests__/dom/natural-input.test.ts @@ -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'); + }); +}); diff --git a/__tests__/integration/datepicker.test.ts b/__tests__/integration/datepicker.test.ts index 0094d80..41ad9eb 100644 --- a/__tests__/integration/datepicker.test.ts +++ b/__tests__/integration/datepicker.test.ts @@ -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', () => { @@ -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', () => { @@ -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()', () => { diff --git a/demo/index.html b/demo/index.html index d303fc1..3f1d612 100644 --- a/demo/index.html +++ b/demo/index.html @@ -3,345 +3,960 @@
-A lightweight, dependency-free, attribute-driven date picker
-Zero-dependency date picker with range selection, swipe navigation, dual-month booking, time picking, and more.
+Just add data-datepicker to any input. That's it.
<input type="text" data-datepicker placeholder="Select a date">Use data-format to change how dates are displayed.
<input type="text"
+
+
+
+
+
+ Split Native Input
+ Three separate fields for day, month, and year with clear placeholders. Supports single D and M formats. Auto-advances on segment completion.
+
+ Native mode
+ Split fields
+ Auto-advance
+
+
+
+
+
+
+
+
+
+
+
+ data-input-mode="native"
+ data-calendar="true"
+ data-format="DD/MM/YYYY"
+ data-theme="light"
+ placeholder="DD/MM/YYYY">
+ Guided segment editing with Tab/Arrow navigation. Calendar popup with slide animation on month change.
+Restrict selectable dates with data-min and data-max.
Two months side by side for booking flows. Min 2 nights, max 14 nights. Quick preset buttons for Tonight, This Weekend, Next 7 Days. Blocked check-in on Sundays. Calendar-only mode prevents manual typing.
+<input type="text"
+
+
+
+
+
+
+
+
+
+ data-slide-animation="true"
+ data-calendar-only="true"
+ data-theme="light"
+ placeholder="Select check-in to check-out">
+ Set data-theme="dark" for a dark calendar. Also supports "light" and "system".
<input type="text" data-datepicker data-theme="dark">Locale-aware month and day names via Intl.DateTimeFormat.
<input type="text"
+
+
+
+
+ Range Selection (Calendar Only)
+ Range selection where input is read-only. Users must select dates from the calendar popup. Includes clear button.
+
+ Range mode
+ Calendar only
+ Clear button
+
+
+
+
+
+
+
+
+
+
+
+ data-selection-mode="range"
+ data-format="DD MMM YYYY"
+ data-range-separator=" — "
+ data-min="2026-01-01"
+ data-calendar-only="true"
+ data-show-clear="true"
+ data-theme="light"
+ placeholder="Select a date range">
+ Supports any BCP 47 locale tag.
-Click any day to select the whole week. Calendar-only mode ensures selection is from the calendar. Row highlights on hover.
+<input type="text"
+
+
+
+ data-selection-mode="week"
+ data-format="DD/MM/YYYY"
+ data-range-separator=" - "
+ data-calendar-only="true"
+ data-theme="light"
+ placeholder="Select a week">
+ Add data-required for form validation. Error messages render automatically.
<input type="text"
+
+
+
+
+ Month Picker (Calendar Only)
+ Opens directly to the month selection grid. Calendar-only mode prevents manual entry.
+
+ Month mode
+ Calendar only
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
- Weekdays Only
- Custom rules via data-validate="weekday".
-
-
-
- <input type="text"
+ data-selection-mode="month"
+ data-format="MMMM YYYY"
+ data-calendar-only="true"
+ data-theme="light"
+ placeholder="Select a month">
+
+
+
+
+
+
+ Inline Calendar
+ Calendar renders inline (not as a popup). Great for embedding in settings panels, dashboards, or sidebars.
+
+ Inline mode
+ Always visible
+
+
+
+
+
+
+
+
+
+
+
+ data-calendar-mode="inline"
+ data-format="YYYY-MM-DD"
+ data-close-on-select="false"
+ data-show-clear="true"
+ data-theme="light"
+ placeholder="Inline calendar">
+
-
-
- Future Dates Only
- Combine rules: data-validate="future".
-
-
+
+
+
+ Date + Time Picker
+ Combined date and time selection. Supports 12-hour and 24-hour formats with hour/minute inputs.
+
+ Time picker
+ 12h / 24h
+
+
+
+
+
+
+
- <input type="text"
+
+
+
+ data-format="DD/MM/YYYY"
+ data-time-picker="true"
+ data-time-format="12"
+ data-close-on-select="false"
+ data-theme="light"
+ placeholder="Date and time">
+
-
-
-
-
- Disabled Specific Dates
- Pass a comma-separated list via data-disabled-dates.
-
-
-
- <input type="text"
+
+
+
+
+ Disabled Dates + Validation
+ Disable weekends via recurring rules. If you manually type a disabled date, an error is shown. Calendar-only mode recommended for strict enforcement.
+
+ Disabled rules
+ Input validation
+ Calendar only
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Portal Mode
+ Calendar is appended to document.body as a portal, avoiding overflow clipping from parent containers. Position auto-adjusts to viewport.
+
+ Portal
+ Auto-position
+
+
+
+
+
+
+
+
+
-
-
-
-
- Web Component
- Use the <date-picker> custom element for encapsulated usage.
-
-
-
- <date-picker
data-format="DD/MM/YYYY"
- data-theme="system">
-</date-picker>
-
-
-
-
- JavaScript API
- Programmatic control via the DatePicker class.
-
-
-
-
-
-
-
+ data-portal="true"
+ data-show-clear="true"
+ data-theme="light"
+ placeholder="Portal calendar">
-
- const picker = new DatePicker(document.getElementById('my-input'));
-picker.open();
-picker.setValue('2026-06-15');
-picker.getValue(); // "2026-06-15"
-picker.destroy();
-
-
-