From dbdb687ce4970e7a77b394113af8795017c86a93 Mon Sep 17 00:00:00 2001 From: Ignat Remizov Date: Wed, 11 Feb 2026 22:59:50 +0200 Subject: [PATCH 01/12] docs(spec-kit): track artifacts for feat(native-scroll-month-year-selector) Add the spec-kit artifact set for feature 001-feat-native-scroll-month-year-selector: - spec.md, plan.md, research.md, data-model.md - contracts/selector-mode-contract.md - quickstart.md and tasks.md Include approved clarifications, native reference asset links, analysis-driven updates, and task progress with T001-T006 checked. This commit intentionally includes only specs/ so source implementation in src/ can be reviewed and committed separately. --- .../contracts/selector-mode-contract.md | 62 +++++++ .../data-model.md | 63 ++++++++ .../plan.md | 120 ++++++++++++++ .../quickstart.md | 71 ++++++++ .../references/native-picker-reference.png | Bin 0 -> 55401 bytes .../references/native-selector-reference.png | Bin 0 -> 35770 bytes .../research.md | 46 ++++++ .../spec.md | 122 ++++++++++++++ .../tasks.md | 152 ++++++++++++++++++ specs/constitution.md | 69 ++++++++ 10 files changed, 705 insertions(+) create mode 100644 specs/001-feat-native-scroll-month-year-selector/contracts/selector-mode-contract.md create mode 100644 specs/001-feat-native-scroll-month-year-selector/data-model.md create mode 100644 specs/001-feat-native-scroll-month-year-selector/plan.md create mode 100644 specs/001-feat-native-scroll-month-year-selector/quickstart.md create mode 100644 specs/001-feat-native-scroll-month-year-selector/references/native-picker-reference.png create mode 100644 specs/001-feat-native-scroll-month-year-selector/references/native-selector-reference.png create mode 100644 specs/001-feat-native-scroll-month-year-selector/research.md create mode 100644 specs/001-feat-native-scroll-month-year-selector/spec.md create mode 100644 specs/001-feat-native-scroll-month-year-selector/tasks.md create mode 100644 specs/constitution.md diff --git a/specs/001-feat-native-scroll-month-year-selector/contracts/selector-mode-contract.md b/specs/001-feat-native-scroll-month-year-selector/contracts/selector-mode-contract.md new file mode 100644 index 0000000..720bc35 --- /dev/null +++ b/specs/001-feat-native-scroll-month-year-selector/contracts/selector-mode-contract.md @@ -0,0 +1,62 @@ +# Contract: Selector Mode Interface and Behavior + +## Scope + +Public and internal behavior contract for native-like month/year selector mode. + +## Public Component Contract + +### New Prop + +- Name: `selectorMode` +- Type: `boolean` +- Default: `false` +- Behavior: + - `false`: existing month/year panel behavior remains unchanged. + - `true`: header interaction toggles between calendar and selector views. + +## Toggle Behavior Contract + +1. Enter selector mode +- Trigger: click month or year label in header while calendar view is active. +- Result: + - view switches to selector mode. + - focus target is month/year column based on click target. + +2. Exit selector mode +- Trigger: header toggle action from selector view. +- Result: + - view switches back to calendar mode. + - selected month/year remains applied. + +3. Month/year selection +- Trigger: selecting month or year item in selector mode. +- Result: + - updates calendar month/year for current `SelectionContext`. + - does not force-close popover; user remains in selector until toggled back. + +## Range-Context Semantics + +### Single-panel range (`use-range` + `as-single`) + +- Selector applies month/year updates to displayed context only. +- Existing range endpoint selection behavior remains unchanged. + +### Double-panel range + +- Selector applies month/year updates only to clicked panel context: + - left header -> `previousPanel` + - right header -> `nextPanel` +- Cross-panel switching inside selector is out of scope for v1. + +## Year Virtualization Contract + +- Year choices are conceptually unbounded. +- Implementation may render a moving window around `anchorYear`. +- Scrolling must not require pre-rendering the full integer range. + +## Accessibility Contract + +- Selector mode remains keyboard reachable. +- Focus transition on enter/exit is deterministic. +- Existing escape/cancel semantics for popover remain intact. diff --git a/specs/001-feat-native-scroll-month-year-selector/data-model.md b/specs/001-feat-native-scroll-month-year-selector/data-model.md new file mode 100644 index 0000000..466d165 --- /dev/null +++ b/specs/001-feat-native-scroll-month-year-selector/data-model.md @@ -0,0 +1,63 @@ +# Data Model: Native-Like Month/Year Scrolling Selector + +## Overview + +This feature introduces UI state entities only. No persistent storage changes. + +## Entities + +### PickerViewMode + +- Purpose: controls which major picker view is shown. +- Values: + - `calendar` + - `selector` + +### SelectorFocus + +- Purpose: controls which selector column is primary. +- Values: + - `month` + - `year` + +### SelectionContext + +- Purpose: identifies which calendar context receives month/year changes. +- Values: + - `single` (single-date mode) + - `singleRangeDisplayed` (single-panel range; displayed month/year) + - `previousPanel` (double-panel range, left) + - `nextPanel` (double-panel range, right) + +### SelectorState + +- Purpose: current month/year values displayed and selected in selector mode. +- Fields: + - `selectedMonth: number` (0-11) + - `selectedYear: number` (unbounded integer) + - `anchorYear: number` (used for virtual year windowing) + +### FeatureConfig + +- Purpose: opt-in behavior gate. +- Fields: + - `selectorMode: boolean` (default `false`) + +## State Transitions + +1. Header month/year click in calendar view: + - `PickerViewMode: calendar -> selector` + - `SelectorFocus` set from clicked header element. + - `SelectionContext` derived from active panel/mode. +2. Selector value change: + - updates `SelectorState` and calendar context month/year. +3. Toggle back to calendar: + - `PickerViewMode: selector -> calendar` + - calendar reflects selected month/year. + +## Invariants + +- Legacy behavior is unchanged when `selectorMode = false`. +- Single-panel range never edits hidden/non-existent second panel. +- Double-panel range selector never mutates opposite panel in v1. +- Year selection supports unbounded values without exhausting DOM rendering. diff --git a/specs/001-feat-native-scroll-month-year-selector/plan.md b/specs/001-feat-native-scroll-month-year-selector/plan.md new file mode 100644 index 0000000..27875e0 --- /dev/null +++ b/specs/001-feat-native-scroll-month-year-selector/plan.md @@ -0,0 +1,120 @@ +# Implementation Plan: Native-Like Month/Year Scrolling Selector + +**Branch**: `001-feat-native-scroll-month-year-selector` | **Date**: 2026-02-11 | **Spec**: `specs/001-feat-native-scroll-month-year-selector/spec.md` +**Input**: Feature specification from `specs/001-feat-native-scroll-month-year-selector/spec.md` + +## Summary + +Add an opt-in selector mode for `vue-tailwind-datepicker` that toggles between calendar view and native-like month/year scrolling selectors. The implementation must preserve existing single/range behavior, support single-panel range (`use-range` + `as-single`) and double-panel range (per-panel selector context), and keep the existing default behavior unchanged unless explicitly enabled. + +## Technical Context + +**Language/Version**: TypeScript 5.9, Vue 3.5 SFCs +**Primary Dependencies**: Vue 3, dayjs, headlessui/vue, tailwindcss +**Storage**: N/A (component-local state only) +**Testing**: `vue-tsc --noEmit`, `vite build`, manual demo verification in local app and consumer usage +**Target Platform**: Browser (desktop + mobile), Vue 3 library consumers +**Project Type**: Single frontend component library +**Performance Goals**: Keep interactions smooth and maintain 60fps scroll/animation during selector use +**Constraints**: Non-breaking API by default, preserve current range semantics and accessibility behavior +**Scale/Scope**: One core component (`src/VueTailwindDatePicker.vue`) plus selector/header subcomponents + +## Constitution Check + +Project constitution is defined at `.specify/memory/constitution.md`. This plan is checked against its MUST-level principles: + +- Backward compatibility by default: PASS (feature remains opt-in via `selectorMode`) +- Spec-driven traceability: PASS (spec/plan/tasks/contracts/quickstart are linked and requirement-tagged) +- Deterministic UX semantics: PASS (single-panel and double-panel selection-context rules are explicit) +- Verification before merge: PASS (`npm run typecheck`, `npm run build`, manual quickstart checks including SC-002 and edge-case matrix) +- Minimal-risk evolution: PASS (incremental changes in existing component architecture) + +Re-check after Phase 1 design: PASS (design and tasks remain aligned with constitution gates). + +## Project Structure + +### Documentation (this feature) + +```text +specs/001-feat-native-scroll-month-year-selector/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── selector-mode-contract.md +└── tasks.md +``` + +### Source Code (repository root) + +```text +src/ +├── VueTailwindDatePicker.vue +├── components/ +│ ├── Header.vue +│ ├── Month.vue +│ ├── Year.vue +│ └── Calendar.vue +├── types.ts +└── App.vue +``` + +**Structure Decision**: Use existing component structure. Add selector mode state and rendering control in `src/VueTailwindDatePicker.vue`; evolve or replace `Month.vue` and `Year.vue` into scroll-selector variants; minimally extend `Header.vue` for toggle behavior. + +## Phase 0: Research and Tradeoffs + +See `research.md`. + +## Phase 1: Design + +1. Introduce opt-in prop for selector mode and keep legacy behavior as default. +2. Add explicit view mode state (`calendar` vs `selector`) and selector focus (`month` or `year`). +3. Implement scroll selector UI and interactions for month/year with virtually unbounded year generation. +4. Encode range selection context rules: + - Single-panel range: operate on displayed month/year only. + - Double-panel range: operate on clicked panel only. +5. Preserve model update behavior and auto-apply/manual apply semantics. + +Detailed entities and transitions are in `data-model.md` and `contracts/selector-mode-contract.md`. + +## Phase 2: Implementation Outline + +1. Add prop and default handling in `src/VueTailwindDatePicker.vue`. +2. Add view mode and active context state. +3. Update header interactions to toggle selector mode. +4. Implement month/year scroll selector rendering and selection handlers. +5. Integrate range context behavior rules. +6. Validate accessibility keyboard/focus behavior. +7. Update demo usage (`src/App.vue`) if needed for manual verification only. +8. Run `npm run typecheck` and `npm run build`. + +## Test and Verification Strategy + +- Type safety: `npm run typecheck` +- Build integrity: `npm run build` +- Manual checks: + - Single date + selector mode on/off + - Single-panel range (`use-range` + `as-single`) + - Double-panel range (per-panel selector) + - Auto-apply and manual apply + - Keyboard interaction and focus transitions + - SC-002 check: from selector open state, target month/year can be reached in <=2 direct interactions (selection actions), excluding scroll travel distance + +### Edge Case Coverage Plan + +- Far-year navigation: verify virtual year window behavior at large positive/negative offsets (maps to FR-011). +- Small screens: verify selector mode layout and toggle behavior on mobile breakpoints (maps to FR-004, FR-010). +- Disabled dates/month constraints: verify month/year changes do not break disabled-date semantics (maps to FR-006, FR-007). +- Invalid/empty model values: verify selector entry and fallback anchoring behavior remain stable (maps to FR-006). +- Double-panel interaction boundaries: verify clicked-panel-only rule is preserved (maps to FR-006). + +## Linear and Traceability + +- Linear issue reference: None provided. +- Spec reference: `specs/001-feat-native-scroll-month-year-selector/spec.md` +- Contract reference: `specs/001-feat-native-scroll-month-year-selector/contracts/selector-mode-contract.md` + +## Complexity Tracking + +No constitution violations identified that require exceptions. diff --git a/specs/001-feat-native-scroll-month-year-selector/quickstart.md b/specs/001-feat-native-scroll-month-year-selector/quickstart.md new file mode 100644 index 0000000..40c94dc --- /dev/null +++ b/specs/001-feat-native-scroll-month-year-selector/quickstart.md @@ -0,0 +1,71 @@ +# Quickstart: Native-Like Month/Year Scrolling Selector + +## 1. Install and baseline checks + +```bash +npm install +npm run typecheck +npm run build +``` + +## 2. Enable selector mode in a local usage example + +In a consumer or local demo usage, enable the new prop: + +```vue + +``` + +## 3. Verify core flows + +1. Calendar -> selector toggle via header month/year click. +2. Selector -> calendar toggle preserves selected month/year. +3. Single-date mode continues working with selector mode off and on. +4. Single-panel range (`use-range` + `as-single`) updates displayed month/year context only. +5. Double-panel range updates clicked panel context only. + +## 4. Verify non-regression behaviors + +1. `selector-mode` omitted (`false`) keeps legacy month/year panels. +2. `autoApply` and manual apply still behave as before. +3. Keyboard navigation/focus remains usable. + +## 5. Verify SC-002 interaction-count criterion + +1. Open selector mode from calendar view. +2. Validate that choosing the target month/year requires no more than 2 direct interactions (selection actions), excluding scroll travel distance. +3. Repeat for single-date, single-panel range (`use-range` + `as-single`), and double-panel range contexts. + +## 6. Final validation + +```bash +npm run typecheck +npm run build +``` + +## 7. QA evidence tracking (SC-001, SC-004) + +Record each manual run to make success criteria auditable. + +| Date | Tester | Scenario | Console Errors (SC-001) | Smooth Interaction Pass (SC-004) | Notes | +|------|--------|----------|---------------------------|-----------------------------------|-------| +| YYYY-MM-DD | name | single / single-range / double-range | pass/fail | pass/fail | details | + +Checklist per run: +1. Open browser devtools console and confirm no errors during calendar -> selector -> calendar transitions. +2. Execute at least one scenario per mode (single, `use-range` + `as-single`, double-panel range). +3. Mark smoothness pass/fail using the SC-004 checklist in spec/plan. +4. Attach run notes to PR description. + +## 8. Implementation TODO checkpoints (T002) + +- [ ] TODO-SC001-1: Run calendar -> selector -> calendar flow in browser and confirm no console errors. +- [ ] TODO-SC001-2: Repeat console-error check for single, single-panel range, and double-panel range scenarios. +- [ ] TODO-SC004-1: Validate smooth selector scrolling and selection response for month and year columns. +- [ ] TODO-SC004-2: Record pass/fail outcomes in the QA evidence table with notes per scenario. diff --git a/specs/001-feat-native-scroll-month-year-selector/references/native-picker-reference.png b/specs/001-feat-native-scroll-month-year-selector/references/native-picker-reference.png new file mode 100644 index 0000000000000000000000000000000000000000..7205079f5bbf8e12550ebc175bf6c93314788777 GIT binary patch literal 55401 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ʑ|M=tJ_Y-*F1Mq?>dR_h- z=lL%1=-q6UHwptzZh(p>jDLzX-PIMlDaHnhfr-5nM{IGr=?4LryuCW(#+uP$engQ_ zRVi;5!#^8UF*RPCYOl-PHI7YGACy0md$% zWi8UT-MfK>PT|W02dQrVx;DZ3=kxzzGT5A(a zL>sq$$648liJfiM5hEP-s5k2XkKqg~5{<5gni>9vL?Lhj_Fs@n>IjGO<}C*sX&?ZJ zwkn$a#PihKks*_f5kn@VlghZ>tq@VoWtP!VtbnO_u4=<4_8ejVU`UwCL?sR8Kn0 z)b>tj0(!)Q@t#DukRh+&h-sVklbs0Q=O}$R`${Pc*lYRTC*PmgW0b}a2LixC3eLzq z1JpD)0}bFCOlVZj0h!+4n?|e5dn(73()kpqefD5!V&3&)g}_-p_~HNS(U8bCJK*;| z{j@FMIL90-aowntC8AZJn(a6|Z5}JO2Iz6MlkJz^R8tI}rUBaTLiX3F z=$rd^vlCdHjgmv+7xsbjx-FhnaGvG?;LAOL!2$s=!?5WGPD6(G-Y4~5@bj}SkI!aQ z<{AWUy>YbC_SYc#V?W(s44;DIjl3WpQU5mAR6|Tg*8j-qJp(+{5jz5dPFjt*H`Hto zur3!69w~xFTm!Q>Ihe=M)dC(InPAi`+|J8HK`OxhNo`9=`X>{P+M`kI{z0$Bln;KIYs{&*Ok1gRnp zDhR*eK55%8HXGWA^kcT>rD}F4JmP|?!RBs6;!p zsvuXizMf)CY7qPQ7AH)M`|7T2Y}?*)=Q^Mh)_-AU%5;`0jyzuQcDk3(Ydgj)3}>pX zqTO+e+g${wrfQ7vS8^^I%G8(NX6j1ce+LxQc)$VpLLJ6`OPqz@b}Y_g0VFzVYJ%Se z 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 Ifl2WH2Rjr{m;e9( literal 0 HcmV?d00001 diff --git a/specs/001-feat-native-scroll-month-year-selector/research.md b/specs/001-feat-native-scroll-month-year-selector/research.md new file mode 100644 index 0000000..2711328 --- /dev/null +++ b/specs/001-feat-native-scroll-month-year-selector/research.md @@ -0,0 +1,46 @@ +# Research: Native-Like Month/Year Scrolling Selector + +## Decision 1: Rollout Strategy + +- Decision: Make selector mode opt-in via a new prop. +- Rationale: Prevent regressions for existing consumers and keep upgrade non-breaking. +- Alternatives considered: + - Default-on behavior: rejected due to migration risk. + +## Decision 2: View Flow Behavior + +- Decision: Mimic native toggle behavior between calendar and selector views. +- Rationale: Aligns with requested UX and avoids auto-close surprises. +- Alternatives considered: + - Auto-close after month/year selection: rejected as non-native for this target flow. + +## Decision 3: Range Context Rules + +- Decision: + - Single-panel range (`use-range` + `as-single`): selector edits displayed month/year context only. + - Double-panel range: selector applies only to clicked panel. +- Rationale: Clear and predictable behavior with low state complexity. +- Alternatives considered: + - Cross-panel switching inside selector: deferred due to higher complexity and bug risk. + +## Decision 4: Year Selector Bounds + +- Decision: Virtually unbounded year scrolling. +- Rationale: Meets requirement FR-011 and avoids arbitrary hard limits. +- Implementation note: Use lazily generated windows around current anchor year to avoid rendering huge DOM lists. + +## Decision 5: Technical Shape + +- Decision: Keep implementation inside existing component structure and state model. +- Rationale: Minimizes code churn and aligns with current architecture. +- Alternatives considered: + - New fully separate picker component: rejected due to duplication and maintenance overhead. + +## Risks and Mitigations + +- Risk: Scroll selector UX jitter on mobile. + - Mitigation: constrain selector item height, use CSS scroll snap, and avoid heavy reactive work per scroll event. +- Risk: Range synchronization regressions. + - Mitigation: centralize selector apply handlers and reuse existing month/year update logic where possible. +- Risk: Keyboard/focus regressions. + - Mitigation: explicit focus targets on mode entry/exit and manual keyboard regression checks. diff --git a/specs/001-feat-native-scroll-month-year-selector/spec.md b/specs/001-feat-native-scroll-month-year-selector/spec.md new file mode 100644 index 0000000..9d43fd6 --- /dev/null +++ b/specs/001-feat-native-scroll-month-year-selector/spec.md @@ -0,0 +1,122 @@ +# Feature Specification: Native-Like Month/Year Scrolling Selector + +**Feature Branch**: `001-feat-native-scroll-month-year-selector` +**Created**: 2026-02-11 +**Status**: Approved +**Input**: User description: "Introduce native-similar scrolling for date selection: start in calendar view, click header, then use month/year scrolling selectors instead of split paginated month and year panels." + +## Reference Assets + +- Picker reference: `specs/001-feat-native-scroll-month-year-selector/references/native-picker-reference.png` +- Selector reference: `specs/001-feat-native-scroll-month-year-selector/references/native-selector-reference.png` + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Switch from Calendar to Scroll Selectors (Priority: P1) + +As a date picker user, I can click the header in calendar view and toggle into a month/year selector view that behaves like a native scrolling picker. + +**Why this priority**: This is the core workflow change the feature is intended to deliver. + +**Independent Test**: Open date picker in calendar mode, click header month/year area, and verify selector view appears with scrollable month and year columns. + +**Acceptance Scenarios**: + +1. **Given** the picker is open in calendar view, **When** the user clicks the month label in the header, **Then** the picker transitions to the selector view focused on month selection while showing year selection context. +2. **Given** the picker is open in calendar view, **When** the user clicks the year label in the header, **Then** the picker transitions to the selector view focused on year selection while showing month selection context. +3. **Given** selector view is open, **When** the user toggles back to calendar view from the header control, **Then** the picker returns to calendar view with the selected month/year applied. + +--- + +### User Story 2 - Scroll and Select Month/Year Efficiently (Priority: P1) + +As a date picker user, I can scroll month and year lists with native-like behavior and select values quickly without stepping through paginated grids. + +**Why this priority**: Replacing split paginated grids with scroll selection is the main usability improvement. + +**Independent Test**: In selector view, scroll month and year columns, select target values, and verify selection updates the visible calendar period. + +**Acceptance Scenarios**: + +1. **Given** selector view is open, **When** the user scrolls month or year lists, **Then** scrolling remains smooth and values remain readable and selectable according to the SC-004 verification checklist. +2. **Given** selector view is open, **When** the user selects a month and/or year, **Then** the selected values become active and update the calendar period. +3. **Given** selector view is open, **When** the user confirms selection via direct selection behavior, **Then** the picker returns to calendar mode with correct month/year. + +--- + +### User Story 3 - Preserve Existing Date-Picker Behavior (Priority: P2) + +As an integrator using existing picker modes (single/range, autoApply/manual), I can adopt the new selector flow without regressions in selection semantics. + +**Why this priority**: The UX update must not break existing model value behavior or range logic. + +**Independent Test**: Run single-date and range-date flows in autoApply and manual apply modes before and after selector interaction. + +**Acceptance Scenarios**: + +1. **Given** range mode is enabled, **When** month/year is changed through selector view, **Then** range panel synchronization rules remain intact. +2. **Given** manual apply mode is enabled, **When** month/year is changed via selector view, **Then** Apply/Cancel behavior remains consistent with current component semantics. +3. **Given** accessibility users navigate via keyboard, **When** selector view is active, **Then** focus order, arrow/scroll interaction, and escape/back behavior are operable. + +--- + +### User Story 4 - Customizable and Backward Compatible API Surface (Priority: P3) + +As a library consumer, I can enable or configure native-like selector behavior without mandatory breaking API changes. + +**Why this priority**: Consumers need controlled rollout and migration safety. + +**Independent Test**: Enable and disable selector mode via component options/props and verify legacy behavior remains available where configured. + +**Acceptance Scenarios**: + +1. **Given** the feature flag/config is disabled, **When** user interacts with header, **Then** existing month/year panel behavior is preserved. +2. **Given** the feature flag/config is enabled, **When** user interacts with header, **Then** selector view behavior is used. + +### Edge Cases + +- What happens when year lists are scrolled far outside the default visible range (very old or far-future years)? +- How does selector mode behave on small screens where picker is already full-screen or modal-like? +- How are disabled dates/month constraints represented when month/year can be jumped via selectors? +- What happens if the current model value is invalid/empty when opening selector view? +- How does selector view interact with range mode when the second panel is visible and one panel is currently active? + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST provide a calendar default view that can transition to a selector view via header interaction. +- **FR-002**: System MUST provide month and year scroll selectors in selector view, replacing split paginated month/year grids for this mode. +- **FR-003**: System MUST allow users to select month and year from selector view and apply the selection to the active calendar context. +- **FR-004**: System MUST support smooth pointer/touch scrolling interaction in selector view on desktop and mobile. +- **FR-005**: System MUST return users from selector view back to calendar view after selection or explicit back action. +- **FR-006**: System MUST preserve single and range selection semantics after month/year changes initiated from selector view. +- **FR-007**: System MUST preserve auto-apply and manual apply flows consistent with existing behavior. +- **FR-008**: System MUST expose a non-breaking opt-in prop to enable selector mode while preserving legacy behavior by default. +- **FR-009**: System MUST maintain keyboard accessibility and focus management in selector mode. +- **FR-010**: System MUST maintain visual consistency with current component styling system. +- **FR-011**: System MUST support virtually unbounded year scrolling in selector mode. +- **FR-012**: System MUST use a native-like toggle flow between calendar view and selector view rather than auto-closing on month/year selection. + +### Key Entities *(include if feature involves data)* + +- **Picker View Mode**: Represents active UI state (`calendar`, `selector`) and optional focus (`month`, `year`). +- **Selector State**: Represents currently highlighted/selected month and year values used to update calendar period. +- **Selection Context**: Represents which panel is active in range mode (`previous` vs `next`) when applying month/year changes. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: 100% of header-to-selector transitions complete without console errors in supported browsers. +- **SC-002**: Users can change to a target month/year in at most 2 interactions after opening selector view (excluding scrolling distance). +- **SC-003**: Existing single/range automated tests for model update semantics continue passing after feature integration. +- **SC-004**: At least 90% of manual QA runs confirm selector interaction is smooth and visually stable on desktop and mobile breakpoints. + +## Clarifications + +- [RESOLVED 2026-02-11] Native-like selector mode is opt-in via prop. +- [RESOLVED 2026-02-11] In double-panel range mode, selector behavior is per-panel (clicked-panel only). +- [RESOLVED 2026-02-11] In single-panel range mode (`use-range` + `as-single`), selector edits the currently displayed month/year context only. +- [RESOLVED 2026-02-11] Selector flow mimics native picker toggle behavior between calendar and selector views. +- [RESOLVED 2026-02-11] Year selector should be virtually unbounded. diff --git a/specs/001-feat-native-scroll-month-year-selector/tasks.md b/specs/001-feat-native-scroll-month-year-selector/tasks.md new file mode 100644 index 0000000..9eb0326 --- /dev/null +++ b/specs/001-feat-native-scroll-month-year-selector/tasks.md @@ -0,0 +1,152 @@ +# Tasks: Native-Like Month/Year Scrolling Selector + +**Input**: Design documents from `specs/001-feat-native-scroll-month-year-selector/` +**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/selector-mode-contract.md`, `quickstart.md` + +## Format: `[ID] [P?] [Story] Description` + +- `[P]`: Can run in parallel (different files, no direct dependency) +- `[Story]`: `US1`, `US2`, `US3`, `US4`, or `Shared` +- Each task includes requirement mapping tags (FR/SC) + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Introduce the feature scaffolding without changing default behavior. + +- [x] T001 [Shared] Update `specs/001-feat-native-scroll-month-year-selector/contracts/selector-mode-contract.md` to document `selectorMode`, toggle behavior, and range-context semantics [FR-008] +- [x] T002 [Shared] Add implementation TODO checkpoints in `specs/001-feat-native-scroll-month-year-selector/quickstart.md` for manual verification runs [SC-001, SC-004] + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core state and toggling primitives that all stories depend on. + +**⚠️ CRITICAL**: No user-story completion before this phase is done. + +- [x] T003 [Shared] Add `selectorMode?: boolean` prop (default `false`) in `src/VueTailwindDatePicker.vue` [FR-008] +- [x] T004 [Shared] Add internal state entities in `src/VueTailwindDatePicker.vue`: `pickerViewMode`, `selectorFocus`, `selectionContext`, `selectorState` [FR-001, FR-003] +- [x] T005 [Shared] Implement view toggle handlers in `src/VueTailwindDatePicker.vue` for calendar <-> selector transitions [FR-001, FR-005, FR-012] +- [x] T006 [Shared] Extend `src/components/Header.vue` emit/props to trigger selector mode entry and focus (`month`/`year`) [FR-001, FR-012] + +**Checkpoint**: Foundational selector state and mode transitions are working behind opt-in prop. + +--- + +## Phase 3: User Story 1 - Switch from Calendar to Scroll Selectors (Priority: P1) 🎯 MVP + +**Goal**: Header click toggles calendar/selector mode with native-like flow. + +**Independent Test**: With `:selector-mode="true"`, clicking month/year in header enters selector view; toggling back returns to calendar with applied month/year. + +### Implementation for User Story 1 + +- [ ] T007 [US1] Add selector-view rendering branch in `src/VueTailwindDatePicker.vue` while preserving current calendar rendering [FR-001, FR-012] +- [ ] T008 [P] [US1] Add month selector UI component implementation in `src/components/Month.vue` (or new selector component wired here) with scrollable interaction [FR-002, FR-004] +- [ ] T009 [P] [US1] Add year selector UI component implementation in `src/components/Year.vue` (or new selector component wired here) with scrollable interaction [FR-002, FR-004] +- [ ] T010 [US1] Wire Header interaction to selector focus (month/year) and selector-to-calendar toggle in `src/VueTailwindDatePicker.vue` [FR-001, FR-005, FR-012] +- [ ] T011 [US1] Ensure default mode (`selectorMode=false`) path uses legacy month/year panels unchanged in `src/VueTailwindDatePicker.vue` [FR-008] + +**Checkpoint**: Selector mode flow is functional and legacy flow still works when disabled. + +--- + +## Phase 4: User Story 2 - Scroll and Select Month/Year Efficiently (Priority: P1) + +**Goal**: Native-like month/year scrolling and selection updates active calendar context. + +**Independent Test**: In selector mode, scrolling/selecting month/year updates displayed calendar period smoothly, and target month/year can be reached in <=2 direct interactions after selector view is opened (excluding scroll distance). + +### Implementation for User Story 2 + +- [ ] T012 [US2] Implement month selection handler in `src/VueTailwindDatePicker.vue` that updates active context without popover close [FR-003, FR-012] +- [ ] T013 [US2] Implement year selection handler in `src/VueTailwindDatePicker.vue` that updates active context without popover close [FR-003, FR-012] +- [ ] T014 [US2] Add virtually unbounded year window generation/anchoring logic in `src/VueTailwindDatePicker.vue` and/or `src/composables/date.ts` [FR-011] +- [ ] T015 [US2] Add scroll-snap and item-state styling for selector lists in `src/index.css` (and any related component classes) [FR-004, FR-010] +- [ ] T016 [US2] Validate selector update latency and avoid heavy per-scroll recomputation in `src/VueTailwindDatePicker.vue` [SC-004] + +**Checkpoint**: Selector interactions are smooth and correctly drive month/year changes. + +--- + +## Phase 5: User Story 3 - Preserve Existing Date-Picker Behavior (Priority: P2) + +**Goal**: Preserve single/range semantics and apply behavior with new selector mode. + +**Independent Test**: Single, single-panel range, and double-panel range all retain expected model semantics. + +### Implementation for User Story 3 + +- [ ] T017 [US3] Implement `selectionContext` derivation for single mode and single-panel range (`use-range` + `as-single`) in `src/VueTailwindDatePicker.vue` [FR-006] +- [ ] T018 [US3] Implement per-panel context routing for double-panel range (clicked header determines panel) in `src/VueTailwindDatePicker.vue` [FR-006] +- [ ] T019 [US3] Verify autoApply/manual apply behavior remains unchanged after selector updates in `src/VueTailwindDatePicker.vue` [FR-007] +- [ ] T020 [US3] Ensure keyboard entry/exit focus management for selector mode in `src/VueTailwindDatePicker.vue` and `src/components/Header.vue` [FR-009] +- [ ] T021 [US3] Manual non-regression run for range panel sync edge cases using `src/App.vue` demo scenarios [SC-003, SC-004] + +**Checkpoint**: Existing model semantics and accessibility behavior are preserved. + +--- + +## Phase 6: User Story 4 - Customizable and Backward Compatible API Surface (Priority: P3) + +**Goal**: Provide clear opt-in API and integration guidance. + +**Independent Test**: Consumers can enable/disable selector mode with no breakage. + +### Implementation for User Story 4 + +- [ ] T022 [US4] Document new `selectorMode` prop in `README.md` with examples for single and range usage [FR-008] +- [ ] T023 [US4] Add opt-in selector mode example in `src/App.vue` for manual verification [FR-008, SC-001] +- [ ] T024 [US4] Ensure emitted behavior and exposed API remain unchanged unless `selectorMode` is enabled in `src/VueTailwindDatePicker.vue` [FR-008] + +**Checkpoint**: API is backward compatible and documented. + +--- + +## Phase 7: Polish & Verification + +**Purpose**: Final checks across all stories. + +- [ ] T025 [Shared] Run `npm run typecheck` and address failures [SC-003] +- [ ] T026 [Shared] Run `npm run build` and address failures [SC-001, SC-003] +- [ ] T027 [Shared] Execute quickstart verification checklist in `specs/001-feat-native-scroll-month-year-selector/quickstart.md` and record outcomes in PR notes [SC-001, SC-004] +- [ ] T028 [Shared] Verify and document SC-002 interaction-count criterion (<=2 direct interactions after selector open, excluding scroll distance) using `src/App.vue` scenarios [SC-002] +- [ ] T029 [Shared] Execute and document edge-case matrix checks (far-year offsets, small screens, disabled-date constraints, invalid/empty model, double-panel clicked-context) in `specs/001-feat-native-scroll-month-year-selector/quickstart.md` [FR-004, FR-006, FR-007, FR-010, FR-011, SC-004] + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- Phase 1 -> Phase 2 -> Phase 3/4/5/6 -> Phase 7 +- User stories begin only after Phase 2 checkpoint. + +### User Story Dependencies + +- **US1 (P1)**: Depends on foundational tasks T003-T006. +- **US2 (P1)**: Depends on US1 rendering/toggle baseline (T007-T011). +- **US3 (P2)**: Depends on US1 + US2 handlers. +- **US4 (P3)**: Depends on stable behavior from US1-US3. + +### Parallel Opportunities + +- T008 and T009 can run in parallel. +- T022 and T023 can run in parallel after US3 stabilizes. +- Final verification T025 and T026 can run in sequence; T027-T029 after both pass. + +## Implementation Strategy + +### MVP First + +1. Complete Phases 1-2. +2. Complete US1. +3. Complete US2. +4. Validate with T025-T026 before moving to higher-priority polish. + +### Incremental Delivery + +1. Deliver opt-in toggle + selector entry/exit (US1). +2. Deliver full scroll selection and unbounded years (US2). +3. Harden range/accessibility semantics (US3). +4. Finalize docs and demo integration (US4). diff --git a/specs/constitution.md b/specs/constitution.md new file mode 100644 index 0000000..1427fa0 --- /dev/null +++ b/specs/constitution.md @@ -0,0 +1,69 @@ + + +# vue-tailwind-datepicker Constitution + +## Core Principles + +### I. Backward Compatibility by Default +All new behavior MUST be opt-in unless explicitly approved as breaking. Existing consumers MUST +retain current behavior when new feature flags/props are not enabled. + +### II. Spec-Driven Changes +Feature work MUST trace back to `spec.md`, `plan.md`, and `tasks.md` under `specs/`. +Implementation tasks MUST map to explicit requirements and success criteria. + +### III. Deterministic UX Semantics +UI behavior MUST be explicit, testable, and mode-aware. For range flows, selection context +rules MUST be documented and preserved without hidden side effects. + +### IV. Verification Before Merge +Changes MUST pass typecheck and build, and MUST include manual verification for UX-critical +flows and edge cases defined in the feature quickstart. + +### V. Minimal-Risk Evolution +Prefer incremental, low-churn changes in existing architecture. Introduce new component or API +surface only when simpler extensions cannot satisfy requirements. + +## Sources of Truth + +- **Requirements, scope, and status**: `specs/001-feat-native-scroll-month-year-selector/spec.md` and companion artifacts in `specs/001-feat-native-scroll-month-year-selector/` +- **Engineering standards and decision rules**: TODO(ENGINEERING_GUIDE): define repository-local AGENTS.md (or equivalent canonical standards file) path + +## Quality Gates + +All feature PRs MUST satisfy: +- `npm run typecheck` +- `npm run build` +- Manual checks documented in the relevant `quickstart.md` +- Traceability from tasks to requirements/success criteria + +## Workflow & Review + +Use Spec-Kit phases in order: specify -> plan -> tasks -> analyze -> implement. +Before implementation, cross-artifact analysis MUST report no unaddressed critical issues. +Warnings MUST be either resolved or explicitly accepted with rationale. + +## Governance + +This constitution governs feature-process rules for this repository and supersedes conflicting +local practices for spec-driven work. + +Amendment policy: +- MAJOR: incompatible governance/principle changes +- MINOR: new principle or materially expanded requirement +- PATCH: clarifications and editorial updates + +Compliance review expectations: +- Planning artifacts MUST include constitution check status +- Reviewers SHOULD verify traceability and quality-gate completion + +**Version**: 1.0.0 | **Ratified**: 2026-02-11 | **Last Amended**: 2026-02-11 From d13438024c6f140cf39bd3e4d219ddd178254e01 Mon Sep 17 00:00:00 2001 From: Ignat Remizov Date: Thu, 12 Feb 2026 14:19:32 +0200 Subject: [PATCH 02/12] feat(selector): deliver native-like wheel selector UX with configurable year scroll modes and stability controls Implement the selector-mode UX end-to-end with native-like calendar<->wheel toggling, click-to-center wheel behavior, smooth month/year transitions, and opt-in rendering/styling controls for integrators. This commit also updates spec artifacts to match the delivered behavior and validation matrix. Problem: - Selector mode required several interaction and visual refinements to feel native-like in real usage: header affordance, wheel centering, smooth transitions, panel context correctness, and stable container geometry. - Integrators needed styling and behavior options (focus tint and year-scroll strategy) without forking component logic. - Existing specs/tasks needed to reflect the expanded implemented UX contract. Approach: - Rework month/year selector interactions around wheel/list centering semantics and calendar<->selector toggle flow. - Add configurable selector options (`selectorFocusTint`, `selectorYearScrollMode`) with conservative defaults. - Stabilize selector container dimensions and header behavior, then document all delivered semantics in Spec-Kit artifacts. Changes: - Add selector-mode header as a single combined month+year toggle with explicit affordance; hide side month arrows in selector mode. - Implement click-to-center behavior for month and year wheels with smooth centering motion. - Implement wheel-like continuous month scrolling and robust month/year synchronization in selector mode. - Add `selectorFocusTint` prop to enable/disable active column tint styling. - Add `selectorYearScrollMode` prop with `boundary` (default clarity-first) and `fractional` (continuous drift) variants. - Align month/year selector styling and centering behavior for visual consistency. - Add selector container width/height stabilization for single-panel selector flows to reduce toggle jitter. - Improve year fast-scroll UX with tuned virtualization window/reanchor settings and lightweight active-scroll styling safeguards. - Expand demo coverage in `src/App.vue`, including boundary vs fractional year-scroll variants. - Update `README.md` selector-mode documentation and options usage. - Update spec artifacts (`spec.md`, `plan.md`, `research.md`, `contracts/selector-mode-contract.md`, `quickstart.md`, `tasks.md`) to capture header styling, focus tint controls, stability requirements, click-to-center semantics, smooth scrolling behavior, and year-scroll mode variants. Tests: - `npm run typecheck` passed. - `npm run build` passed. Behavioral effect: - Selector mode now behaves like a native wheel-based month/year selector while remaining opt-in and backward compatible by default. - Consumers can choose clarity-focused or continuous year-wheel behavior and can disable focus tint styling to fit custom design systems. --- README.md | 35 + .../contracts/selector-mode-contract.md | 23 + .../plan.md | 5 + .../quickstart.md | 38 +- .../research.md | 25 +- .../spec.md | 10 + .../tasks.md | 52 +- src/App.vue | 151 +++- src/VueTailwindDatePicker.vue | 654 ++++++++++++++++-- src/components/Header.vue | 148 ++-- src/components/Month.vue | 402 ++++++++++- src/components/Year.vue | 320 ++++++++- src/index.css | 53 ++ 13 files changed, 1739 insertions(+), 177 deletions(-) diff --git a/README.md b/README.md index 487488e..21edac0 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,41 @@ const formatter = ref({ ``` +## Native Scroll Selector Mode + +Enable native-like month/year scrolling with `selectorMode` (use `:selector-mode` in templates). Default is `false`. + +**Single date** + +```vue + + + +``` + +**Range** + +```vue + + + +``` + ## Theming options **Light Mode** diff --git a/specs/001-feat-native-scroll-month-year-selector/contracts/selector-mode-contract.md b/specs/001-feat-native-scroll-month-year-selector/contracts/selector-mode-contract.md index 720bc35..75e2dbb 100644 --- a/specs/001-feat-native-scroll-month-year-selector/contracts/selector-mode-contract.md +++ b/specs/001-feat-native-scroll-month-year-selector/contracts/selector-mode-contract.md @@ -15,6 +15,22 @@ Public and internal behavior contract for native-like month/year selector mode. - `false`: existing month/year panel behavior remains unchanged. - `true`: header interaction toggles between calendar and selector views. +### Additional Selector Props + +- Name: `selectorFocusTint` +- Type: `boolean` +- Default: `true` +- Behavior: + - `true`: active selector column can apply focus tint/background accent. + - `false`: selector columns keep neutral container styling while functional focus/selection remains unchanged. + +- Name: `selectorYearScrollMode` +- Type: `'boundary' | 'fractional'` +- Default: `'boundary'` +- Behavior: + - `boundary`: year wheel moves discretely when year changes. + - `fractional`: year wheel position drifts continuously with month progress (June-centered anchor) while selected year semantics remain discrete. + ## Toggle Behavior Contract 1. Enter selector mode @@ -22,6 +38,8 @@ Public and internal behavior contract for native-like month/year selector mode. - Result: - view switches to selector mode. - focus target is month/year column based on click target. + - selector-mode header is presented as a single combined month+year toggle button. + - side month navigation arrows are hidden while selector mode is enabled. 2. Exit selector mode - Trigger: header toggle action from selector view. @@ -34,6 +52,7 @@ Public and internal behavior contract for native-like month/year selector mode. - Result: - updates calendar month/year for current `SelectionContext`. - does not force-close popover; user remains in selector until toggled back. + - clicked item is centered in its wheel/list, with smooth centering where applicable. ## Range-Context Semantics @@ -60,3 +79,7 @@ Public and internal behavior contract for native-like month/year selector mode. - Selector mode remains keyboard reachable. - Focus transition on enter/exit is deterministic. - Existing escape/cancel semantics for popover remain intact. + +## Visual Stability Contract + +- In selector mode, calendar and selector toggles should not introduce noticeable container width/height jitter for the same picker configuration. diff --git a/specs/001-feat-native-scroll-month-year-selector/plan.md b/specs/001-feat-native-scroll-month-year-selector/plan.md index 27875e0..7faeadb 100644 --- a/specs/001-feat-native-scroll-month-year-selector/plan.md +++ b/specs/001-feat-native-scroll-month-year-selector/plan.md @@ -7,6 +7,8 @@ Add an opt-in selector mode for `vue-tailwind-datepicker` that toggles between calendar view and native-like month/year scrolling selectors. The implementation must preserve existing single/range behavior, support single-panel range (`use-range` + `as-single`) and double-panel range (per-panel selector context), and keep the existing default behavior unchanged unless explicitly enabled. +Current UX scope also includes: explicit selector-header toggle affordance, configurable selector focus tinting, selector container size stability across view toggles, click-to-center selector behavior, and selectable year scroll sync variants (`boundary` and `fractional`). + ## Technical Context **Language/Version**: TypeScript 5.9, Vue 3.5 SFCs @@ -75,6 +77,8 @@ See `research.md`. - Single-panel range: operate on displayed month/year only. - Double-panel range: operate on clicked panel only. 5. Preserve model update behavior and auto-apply/manual apply semantics. +6. Provide stable visual container geometry and clear selector-toggle affordance. +7. Support both clarity-first and continuous year-wheel sync variants via prop. Detailed entities and transitions are in `data-model.md` and `contracts/selector-mode-contract.md`. @@ -105,6 +109,7 @@ Detailed entities and transitions are in `data-model.md` and `contracts/selector - Far-year navigation: verify virtual year window behavior at large positive/negative offsets (maps to FR-011). - Small screens: verify selector mode layout and toggle behavior on mobile breakpoints (maps to FR-004, FR-010). +- Visual stability: verify no width/height jitter when toggling calendar <-> selector for same mode/config (maps to FR-014). - Disabled dates/month constraints: verify month/year changes do not break disabled-date semantics (maps to FR-006, FR-007). - Invalid/empty model values: verify selector entry and fallback anchoring behavior remain stable (maps to FR-006). - Double-panel interaction boundaries: verify clicked-panel-only rule is preserved (maps to FR-006). diff --git a/specs/001-feat-native-scroll-month-year-selector/quickstart.md b/specs/001-feat-native-scroll-month-year-selector/quickstart.md index 40c94dc..2cf50e7 100644 --- a/specs/001-feat-native-scroll-month-year-selector/quickstart.md +++ b/specs/001-feat-native-scroll-month-year-selector/quickstart.md @@ -16,12 +16,25 @@ In a consumer or local demo usage, enable the new prop: ``` +Optional experimental variant: + +```vue + +``` + ## 3. Verify core flows 1. Calendar -> selector toggle via header month/year click. @@ -29,6 +42,11 @@ In a consumer or local demo usage, enable the new prop: 3. Single-date mode continues working with selector mode off and on. 4. Single-panel range (`use-range` + `as-single`) updates displayed month/year context only. 5. Double-panel range updates clicked panel context only. +6. `selector-year-scroll-mode="boundary"` keeps year movement discrete and clarity-first. +7. `selector-year-scroll-mode="fractional"` keeps year wheel in continuous month-synced drift. +8. Selector-mode header is a single combined month+year toggle with explicit button affordance. +9. Clicking month/year selector items recenters the wheel/list to the clicked item. +10. Selector-mode container size remains visually stable across calendar <-> selector toggles. ## 4. Verify non-regression behaviors @@ -56,6 +74,13 @@ Record each manual run to make success criteria auditable. | Date | Tester | Scenario | Console Errors (SC-001) | Smooth Interaction Pass (SC-004) | Notes | |------|--------|----------|---------------------------|-----------------------------------|-------| | YYYY-MM-DD | name | single / single-range / double-range | pass/fail | pass/fail | details | +| 2026-02-11 | Codex | selector-mode demo (`src/App.vue`) | fail (non-picker favicon 404) | pass (manual visual check) | Month selector and year selector listboxes rendered; toggle flow observed | +| 2026-02-11 | Codex | SC-002 sample checks | pass | pass | Month change: 1 direct selection (`May`); Year change: 1 direct selection (`2030`) after selector open | +| 2026-02-11 | Codex | T021 double-panel range clicked-context sync (`:selector-mode=true`) | fail (non-picker favicon 404 only) | pass | Left panel month/year stayed on its own context while right-panel header interaction opened selector on right context; no cross-panel focus bleed observed | +| 2026-02-11 | Codex | T021 single-panel range sync (`use-range` + `as-single`) | fail (non-picker favicon 404 only) | pass | Selector transitions stayed scoped to single displayed context; no second-panel state was introduced | +| 2026-02-11 | Codex | T029 disabled-date constraints (`disableDate` weekend function) | fail (non-picker favicon 404 only) | pass | Weekend cells remained disabled after selector-mode month change and return to calendar | +| 2026-02-11 | Codex | T029 invalid/empty model seeds (`''`, `not-a-date ~ not-a-date`) | fail (non-picker favicon 404 only) | pass | Popovers opened without runtime exceptions; both scenarios anchored to stable calendar months (Feb/Mar 2026) | +| 2026-02-11 | Codex | T029 far-year offsets + small-screen 375x812 | fail (non-picker favicon 404 only) | pass with caveat | Year list remained virtually unbounded (visible through 2137 after jumping to 2077); mobile viewport stayed operable, but selected year vs header year showed offset drift in observed runs (`2035 -> 2037`, `2077 -> 2079`) requiring follow-up | Checklist per run: 1. Open browser devtools console and confirm no errors during calendar -> selector -> calendar transitions. @@ -65,7 +90,12 @@ Checklist per run: ## 8. Implementation TODO checkpoints (T002) -- [ ] TODO-SC001-1: Run calendar -> selector -> calendar flow in browser and confirm no console errors. -- [ ] TODO-SC001-2: Repeat console-error check for single, single-panel range, and double-panel range scenarios. -- [ ] TODO-SC004-1: Validate smooth selector scrolling and selection response for month and year columns. -- [ ] TODO-SC004-2: Record pass/fail outcomes in the QA evidence table with notes per scenario. +- [x] TODO-SC001-1: Run calendar -> selector -> calendar flow in browser and confirm no console errors. +- [x] TODO-SC001-2: Repeat console-error check for single, single-panel range, and double-panel range scenarios. +- [x] TODO-SC004-1: Validate smooth selector scrolling and selection response for month and year columns. +- [x] TODO-SC004-2: Record pass/fail outcomes in the QA evidence table with notes per scenario. + +Notes: +- Observed console error is a dev demo static asset miss (`/favicon.ico` 404), not a picker runtime exception. +- T021 and T029 manual checks were executed with Playwright against `http://127.0.0.1:4173` on 2026-02-11. +- Added `src/App.vue` manual-test scenarios for `use-range + as-single`, `disableDate`, and empty/invalid model seeds to complete matrix coverage. diff --git a/specs/001-feat-native-scroll-month-year-selector/research.md b/specs/001-feat-native-scroll-month-year-selector/research.md index 2711328..b6c0909 100644 --- a/specs/001-feat-native-scroll-month-year-selector/research.md +++ b/specs/001-feat-native-scroll-month-year-selector/research.md @@ -36,10 +36,31 @@ - Alternatives considered: - New fully separate picker component: rejected due to duplication and maintenance overhead. +## Decision 6: Selector Header Affordance + +- Decision: In selector mode, use a single combined month+year toggle button with explicit affordance styling; hide side month arrows. +- Rationale: Better aligns with native-like toggle mental model and removes conflicting pagination affordances. +- Alternatives considered: + - Keep split month/year header buttons in selector mode: rejected due to weaker toggle clarity. + +## Decision 7: Selector Styling Controls + +- Decision: Expose `selectorFocusTint` to allow custom UIs to disable active column tinting while retaining behavior. +- Rationale: Integrators need visual-system flexibility without forking selector logic. +- Alternatives considered: + - Hard-coded focus tint only: rejected due to integration friction. + +## Decision 8: Year Wheel Sync Variants + +- Decision: Expose `selectorYearScrollMode` with `boundary` default and `fractional` experimental mode. +- Rationale: `boundary` is clearer for most users; `fractional` offers a native-like continuous feel for advanced adopters. +- Alternatives considered: + - Single fixed behavior only: rejected due to unresolved UX preference tradeoff. + ## Risks and Mitigations -- Risk: Scroll selector UX jitter on mobile. - - Mitigation: constrain selector item height, use CSS scroll snap, and avoid heavy reactive work per scroll event. +- Risk: Fast-flick rendering gaps (especially year wheel) under high momentum. + - Mitigation: maintain stable list structures, reduce expensive per-scroll visual effects during active scroll, and tune virtual window/reanchor strategy. - Risk: Range synchronization regressions. - Mitigation: centralize selector apply handlers and reuse existing month/year update logic where possible. - Risk: Keyboard/focus regressions. diff --git a/specs/001-feat-native-scroll-month-year-selector/spec.md b/specs/001-feat-native-scroll-month-year-selector/spec.md index 9d43fd6..38971b1 100644 --- a/specs/001-feat-native-scroll-month-year-selector/spec.md +++ b/specs/001-feat-native-scroll-month-year-selector/spec.md @@ -25,6 +25,7 @@ As a date picker user, I can click the header in calendar view and toggle into a 1. **Given** the picker is open in calendar view, **When** the user clicks the month label in the header, **Then** the picker transitions to the selector view focused on month selection while showing year selection context. 2. **Given** the picker is open in calendar view, **When** the user clicks the year label in the header, **Then** the picker transitions to the selector view focused on year selection while showing month selection context. 3. **Given** selector view is open, **When** the user toggles back to calendar view from the header control, **Then** the picker returns to calendar view with the selected month/year applied. +4. **Given** selector mode is enabled, **When** the header renders, **Then** month and year are shown as a single explicit toggle button and side month-navigation arrows are hidden. --- @@ -97,6 +98,12 @@ As a library consumer, I can enable or configure native-like selector behavior w - **FR-010**: System MUST maintain visual consistency with current component styling system. - **FR-011**: System MUST support virtually unbounded year scrolling in selector mode. - **FR-012**: System MUST use a native-like toggle flow between calendar view and selector view rather than auto-closing on month/year selection. +- **FR-013**: System MUST expose styling control to disable selector focus tinting between month/year columns while preserving functional focus behavior. +- **FR-014**: System MUST keep selector-mode container dimensions visually stable (no width/height jitter) during calendar <-> selector toggles in the same mode/context. +- **FR-015**: System MUST center clicked selector items in their wheel/list view using smooth motion where applicable. +- **FR-016**: System MUST support wheel-like continuous month scrolling behavior that mimics native calendar selectors while keeping selected month/year semantics correct. +- **FR-017**: System MUST expose a selector year-scroll mode option with `boundary` (clarity-first default) and `fractional` (continuous drift) variants. +- **FR-018**: System MUST present selector-mode header control styling that clearly communicates clickability/toggle intent. ### Key Entities *(include if feature involves data)* @@ -120,3 +127,6 @@ As a library consumer, I can enable or configure native-like selector behavior w - [RESOLVED 2026-02-11] In single-panel range mode (`use-range` + `as-single`), selector edits the currently displayed month/year context only. - [RESOLVED 2026-02-11] Selector flow mimics native picker toggle behavior between calendar and selector views. - [RESOLVED 2026-02-11] Year selector should be virtually unbounded. +- [RESOLVED 2026-02-12] Selector-mode header uses a combined month+year toggle button and hides side month arrows in selector mode. +- [RESOLVED 2026-02-12] Selector focus tinting is configurable and can be disabled for custom visual systems. +- [RESOLVED 2026-02-12] Selector mode supports two year wheel sync variants: `boundary` (default) and `fractional` (experimental/continuous). diff --git a/specs/001-feat-native-scroll-month-year-selector/tasks.md b/specs/001-feat-native-scroll-month-year-selector/tasks.md index 9eb0326..59741ad 100644 --- a/specs/001-feat-native-scroll-month-year-selector/tasks.md +++ b/specs/001-feat-native-scroll-month-year-selector/tasks.md @@ -41,11 +41,11 @@ ### Implementation for User Story 1 -- [ ] T007 [US1] Add selector-view rendering branch in `src/VueTailwindDatePicker.vue` while preserving current calendar rendering [FR-001, FR-012] -- [ ] T008 [P] [US1] Add month selector UI component implementation in `src/components/Month.vue` (or new selector component wired here) with scrollable interaction [FR-002, FR-004] -- [ ] T009 [P] [US1] Add year selector UI component implementation in `src/components/Year.vue` (or new selector component wired here) with scrollable interaction [FR-002, FR-004] -- [ ] T010 [US1] Wire Header interaction to selector focus (month/year) and selector-to-calendar toggle in `src/VueTailwindDatePicker.vue` [FR-001, FR-005, FR-012] -- [ ] T011 [US1] Ensure default mode (`selectorMode=false`) path uses legacy month/year panels unchanged in `src/VueTailwindDatePicker.vue` [FR-008] +- [x] T007 [US1] Add selector-view rendering branch in `src/VueTailwindDatePicker.vue` while preserving current calendar rendering [FR-001, FR-012] +- [x] T008 [P] [US1] Add month selector UI component implementation in `src/components/Month.vue` (or new selector component wired here) with scrollable interaction [FR-002, FR-004] +- [x] T009 [P] [US1] Add year selector UI component implementation in `src/components/Year.vue` (or new selector component wired here) with scrollable interaction [FR-002, FR-004] +- [x] T010 [US1] Wire Header interaction to selector focus (month/year) and selector-to-calendar toggle in `src/VueTailwindDatePicker.vue` [FR-001, FR-005, FR-012] +- [x] T011 [US1] Ensure default mode (`selectorMode=false`) path uses legacy month/year panels unchanged in `src/VueTailwindDatePicker.vue` [FR-008] **Checkpoint**: Selector mode flow is functional and legacy flow still works when disabled. @@ -59,11 +59,11 @@ ### Implementation for User Story 2 -- [ ] T012 [US2] Implement month selection handler in `src/VueTailwindDatePicker.vue` that updates active context without popover close [FR-003, FR-012] -- [ ] T013 [US2] Implement year selection handler in `src/VueTailwindDatePicker.vue` that updates active context without popover close [FR-003, FR-012] -- [ ] T014 [US2] Add virtually unbounded year window generation/anchoring logic in `src/VueTailwindDatePicker.vue` and/or `src/composables/date.ts` [FR-011] -- [ ] T015 [US2] Add scroll-snap and item-state styling for selector lists in `src/index.css` (and any related component classes) [FR-004, FR-010] -- [ ] T016 [US2] Validate selector update latency and avoid heavy per-scroll recomputation in `src/VueTailwindDatePicker.vue` [SC-004] +- [x] T012 [US2] Implement month selection handler in `src/VueTailwindDatePicker.vue` that updates active context without popover close [FR-003, FR-012] +- [x] T013 [US2] Implement year selection handler in `src/VueTailwindDatePicker.vue` that updates active context without popover close [FR-003, FR-012] +- [x] T014 [US2] Add virtually unbounded year window generation/anchoring logic in `src/VueTailwindDatePicker.vue` and/or `src/composables/date.ts` [FR-011] +- [x] T015 [US2] Add scroll-snap and item-state styling for selector lists in `src/index.css` (and any related component classes) [FR-004, FR-010] +- [x] T016 [US2] Validate selector update latency and avoid heavy per-scroll recomputation in `src/VueTailwindDatePicker.vue` [SC-004] **Checkpoint**: Selector interactions are smooth and correctly drive month/year changes. @@ -77,11 +77,11 @@ ### Implementation for User Story 3 -- [ ] T017 [US3] Implement `selectionContext` derivation for single mode and single-panel range (`use-range` + `as-single`) in `src/VueTailwindDatePicker.vue` [FR-006] -- [ ] T018 [US3] Implement per-panel context routing for double-panel range (clicked header determines panel) in `src/VueTailwindDatePicker.vue` [FR-006] -- [ ] T019 [US3] Verify autoApply/manual apply behavior remains unchanged after selector updates in `src/VueTailwindDatePicker.vue` [FR-007] -- [ ] T020 [US3] Ensure keyboard entry/exit focus management for selector mode in `src/VueTailwindDatePicker.vue` and `src/components/Header.vue` [FR-009] -- [ ] T021 [US3] Manual non-regression run for range panel sync edge cases using `src/App.vue` demo scenarios [SC-003, SC-004] +- [x] T017 [US3] Implement `selectionContext` derivation for single mode and single-panel range (`use-range` + `as-single`) in `src/VueTailwindDatePicker.vue` [FR-006] +- [x] T018 [US3] Implement per-panel context routing for double-panel range (clicked header determines panel) in `src/VueTailwindDatePicker.vue` [FR-006] +- [x] T019 [US3] Verify autoApply/manual apply behavior remains unchanged after selector updates in `src/VueTailwindDatePicker.vue` [FR-007] +- [x] T020 [US3] Ensure keyboard entry/exit focus management for selector mode in `src/VueTailwindDatePicker.vue` and `src/components/Header.vue` [FR-009] +- [x] T021 [US3] Manual non-regression run for range panel sync edge cases using `src/App.vue` demo scenarios [SC-003, SC-004] **Checkpoint**: Existing model semantics and accessibility behavior are preserved. @@ -95,9 +95,15 @@ ### Implementation for User Story 4 -- [ ] T022 [US4] Document new `selectorMode` prop in `README.md` with examples for single and range usage [FR-008] -- [ ] T023 [US4] Add opt-in selector mode example in `src/App.vue` for manual verification [FR-008, SC-001] -- [ ] T024 [US4] Ensure emitted behavior and exposed API remain unchanged unless `selectorMode` is enabled in `src/VueTailwindDatePicker.vue` [FR-008] +- [x] T022 [US4] Document new `selectorMode` prop in `README.md` with examples for single and range usage [FR-008] +- [x] T023 [US4] Add opt-in selector mode example in `src/App.vue` for manual verification [FR-008, SC-001] +- [x] T024 [US4] Ensure emitted behavior and exposed API remain unchanged unless `selectorMode` is enabled in `src/VueTailwindDatePicker.vue` [FR-008] +- [x] T030 [US4] Add configurable selector year scroll mode (`boundary` default, `fractional` experimental) in `src/VueTailwindDatePicker.vue` and `src/components/Year.vue` [FR-008, FR-011] +- [x] T031 [US4] Add both selector year-scroll variants in `src/App.vue` for side-by-side UX validation [SC-004] +- [x] T032 [US1] Update selector-mode header to a single combined month+year toggle and hide side arrows in selector mode in `src/components/Header.vue` [FR-001, FR-018] +- [x] T033 [US2] Ensure selector item click recenters wheel/list with smooth motion in `src/components/Month.vue` and `src/components/Year.vue` [FR-015, FR-016] +- [x] T034 [US2] Stabilize selector-mode container dimensions across view toggles in `src/VueTailwindDatePicker.vue` [FR-014] +- [x] T035 [US4] Add configurable selector focus tint behavior in `src/VueTailwindDatePicker.vue` and demo usage in `src/App.vue` [FR-013] **Checkpoint**: API is backward compatible and documented. @@ -107,11 +113,11 @@ **Purpose**: Final checks across all stories. -- [ ] T025 [Shared] Run `npm run typecheck` and address failures [SC-003] -- [ ] T026 [Shared] Run `npm run build` and address failures [SC-001, SC-003] -- [ ] T027 [Shared] Execute quickstart verification checklist in `specs/001-feat-native-scroll-month-year-selector/quickstart.md` and record outcomes in PR notes [SC-001, SC-004] -- [ ] T028 [Shared] Verify and document SC-002 interaction-count criterion (<=2 direct interactions after selector open, excluding scroll distance) using `src/App.vue` scenarios [SC-002] -- [ ] T029 [Shared] Execute and document edge-case matrix checks (far-year offsets, small screens, disabled-date constraints, invalid/empty model, double-panel clicked-context) in `specs/001-feat-native-scroll-month-year-selector/quickstart.md` [FR-004, FR-006, FR-007, FR-010, FR-011, SC-004] +- [x] T025 [Shared] Run `npm run typecheck` and address failures [SC-003] +- [x] T026 [Shared] Run `npm run build` and address failures [SC-001, SC-003] +- [x] T027 [Shared] Execute quickstart verification checklist in `specs/001-feat-native-scroll-month-year-selector/quickstart.md` and record outcomes in PR notes [SC-001, SC-004] +- [x] T028 [Shared] Verify and document SC-002 interaction-count criterion (<=2 direct interactions after selector open, excluding scroll distance) using `src/App.vue` scenarios [SC-002] +- [x] T029 [Shared] Execute and document edge-case matrix checks (far-year offsets, small screens, disabled-date constraints, invalid/empty model, double-panel clicked-context) in `specs/001-feat-native-scroll-month-year-selector/quickstart.md` [FR-004, FR-006, FR-007, FR-010, FR-011, SC-004] --- diff --git a/src/App.vue b/src/App.vue index 67b7fdf..fa30659 100644 --- a/src/App.vue +++ b/src/App.vue @@ -8,10 +8,31 @@ const dateValue = ref({ startDate: dayjs().format('YYYY-MM-DD HH:mm:ss'), endDate: dayjs().format('YYYY-MM-DD HH:mm:ss'), }) +const selectorModeValue = ref({ + startDate: dayjs().subtract(3, 'month').format('YYYY-MM-DD HH:mm:ss'), + endDate: dayjs().add(3, 'month').format('YYYY-MM-DD HH:mm:ss'), +}) +const selectorSingleRangeValue = ref({ + startDate: dayjs().subtract(10, 'day').format('YYYY-MM-DD HH:mm:ss'), + endDate: dayjs().add(10, 'day').format('YYYY-MM-DD HH:mm:ss'), +}) +const selectorSingleRangeFractionalValue = ref({ + startDate: dayjs().subtract(10, 'day').format('YYYY-MM-DD HH:mm:ss'), + endDate: dayjs().add(10, 'day').format('YYYY-MM-DD HH:mm:ss'), +}) +const selectorDisabledValue = ref(dayjs().format('YYYY-MM-DD HH:mm:ss')) +const selectorEmptyModelValue = ref('') +const selectorInvalidModelValue = ref('not-a-date ~ not-a-date') +const singleDateValue = ref(dayjs().format('YYYY-MM-DD HH:mm:ss')) -const currentLocale = ref('es') +const currentLocale = ref('en') const locales = ['en', 'es', 'de'] +function disableWeekendDates(date: Date) { + const day = dayjs(date).day() + return day === 0 || day === 6 +} + function onClickSomething(e: Dayjs) { console.log(e) } @@ -23,23 +44,127 @@ function onSelectSomething(e: Dayjs) { diff --git a/src/VueTailwindDatePicker.vue b/src/VueTailwindDatePicker.vue index 6aa8162..1acfea3 100644 --- a/src/VueTailwindDatePicker.vue +++ b/src/VueTailwindDatePicker.vue @@ -70,6 +70,9 @@ export interface Props { startFrom?: Date weekdaysSize?: string weekNumber?: boolean + selectorMode?: boolean + selectorFocusTint?: boolean + selectorYearScrollMode?: 'boundary' | 'fractional' options?: { shortcuts: { today: string @@ -110,6 +113,9 @@ const props = withDefaults(defineProps(), { startFrom: () => new Date(), weekdaysSize: 'short', weekNumber: false, + selectorMode: false, + selectorFocusTint: true, + selectorYearScrollMode: 'boundary', options: () => ({ shortcuts: { today: 'Today', @@ -159,6 +165,7 @@ dayjs.extend(duration) dayjs.extend(weekOfYear) const VtdRef = ref(null) +const VtdStaticRef = ref(null) const VtdInputRef = ref(null) const placement = ref(null) const givenPlaceholder = ref('') @@ -440,6 +447,376 @@ const calendar = computed(() => { }) const displayDatepicker = ref(false) +type PickerViewMode = 'calendar' | 'selector' +type SelectorFocus = 'month' | 'year' +type SelectionContext = 'single' | 'singleRangeDisplayed' | 'previousPanel' | 'nextPanel' +type SelectionPanel = 'previous' | 'next' +type SelectorMonthPayload = number | { month: number, year: number } + +interface SelectorState { + selectedMonth: number + selectedYear: number + anchorYear: number +} + +interface HeaderInteractionPayload { + panel: SelectionPanel + focus: SelectorFocus +} + +interface TogglePickerViewOptions { + restoreFocus?: boolean +} + +const SELECTOR_YEAR_WINDOW_SIZE = 401 +const SELECTOR_YEAR_WINDOW_RADIUS = Math.floor(SELECTOR_YEAR_WINDOW_SIZE / 2) +const SELECTOR_YEAR_REANCHOR_THRESHOLD = 24 + +const pickerViewMode = ref('calendar') +const selectorFocus = ref('month') +const selectionContext = ref('single') +const selectorState = reactive({ + selectedMonth: datepicker.value.previous.month(), + selectedYear: datepicker.value.previous.year(), + anchorYear: datepicker.value.previous.year(), +}) + +function generateSelectorYears(anchorYear: number) { + const startYear = anchorYear - SELECTOR_YEAR_WINDOW_RADIUS + return Array.from({ length: SELECTOR_YEAR_WINDOW_SIZE }, (_, index) => startYear + index) +} + +function anchorSelectorYearWindow(targetYear: number) { + if (selectorState.anchorYear === targetYear) + return + selectorState.anchorYear = targetYear +} + +const selectorYears = computed(() => { + return generateSelectorYears(selectorState.anchorYear) +}) + +function resolveSelectionContext(panel: SelectionPanel): SelectionContext { + const isSingleDateMode = !!props.asSingle && !props.useRange + const isSinglePanelRangeMode = !!props.asSingle && !!props.useRange + + if (isSingleDateMode) + return 'single' + if (isSinglePanelRangeMode) + return 'singleRangeDisplayed' + return panel === 'next' ? 'nextPanel' : 'previousPanel' +} + +function resolveContextDate(context: SelectionContext): Dayjs { + if (context === 'nextPanel') + return datepicker.value.next + return datepicker.value.previous +} + +function resolveContextPanel(context: SelectionContext): SelectionPanel { + return context === 'nextPanel' ? 'next' : 'previous' +} + +function getPickerQueryRoot() { + if (props.noInput) + return VtdStaticRef.value + return VtdRef.value as HTMLElement | null +} + +function focusHeaderValue(panel: SelectionPanel, focus: SelectorFocus) { + const queryRoot = getPickerQueryRoot() + if (!queryRoot) + return + const headerButton = queryRoot.querySelector(`#vtd-header-${panel}-${focus}`) + if (headerButton instanceof HTMLElement) + headerButton.focus() +} + +function focusSelectorModeTarget( + context: SelectionContext = selectionContext.value, + focus: SelectorFocus = selectorFocus.value, +) { + const queryRoot = getPickerQueryRoot() + if (!queryRoot) + return + const panel = resolveContextPanel(context) + const panelElement = queryRoot.querySelector(`[data-vtd-selector-panel="${panel}"]`) + if (!panelElement) + return + + if (focus === 'month') { + const monthSelector = panelElement.querySelector('[aria-label="Month selector"]') + if (monthSelector) { + monthSelector.focus() + return + } + } + else { + const yearSelectorActiveButton + = panelElement.querySelector('[aria-label="Year selector"] [aria-selected="true"]') + ?? panelElement.querySelector('[aria-label="Year selector"] button') + if (yearSelectorActiveButton) { + yearSelectorActiveButton.focus() + return + } + } +} + +interface SyncSelectorStateOptions { + syncAnchor?: boolean +} + +function syncSelectorState( + context: SelectionContext = selectionContext.value, + options: SyncSelectorStateOptions = {}, +) { + const { syncAnchor = true } = options + const contextDate = resolveContextDate(context) + selectorState.selectedMonth = contextDate.month() + selectorState.selectedYear = contextDate.year() + if (syncAnchor) + anchorSelectorYearWindow(contextDate.year()) +} + +function syncDatepickerYears() { + datepicker.value.year.previous = datepicker.value.previous.year() + datepicker.value.year.next = datepicker.value.next.year() +} + +function syncSelectorRangeOrder(context: SelectionContext) { + if (context === 'nextPanel') { + if ( + datepicker.value.previous.isSame(datepicker.value.next, 'month') + || datepicker.value.previous.isAfter(datepicker.value.next) + ) { + datepicker.value.previous = datepicker.value.next.subtract(1, 'month') + } + return + } + + if ( + datepicker.value.next.isSame(datepicker.value.previous, 'month') + || datepicker.value.next.isBefore(datepicker.value.previous) + ) { + datepicker.value.next = datepicker.value.previous.add(1, 'month') + } +} + +function applySelectorMonth(context: SelectionContext, month: number, year?: number) { + if (context === 'nextPanel') { + let nextDate = datepicker.value.next + if (typeof year === 'number') + nextDate = nextDate.year(year) + nextDate = nextDate.month(month) + datepicker.value.next = nextDate + emit('selectRightMonth', datepicker.value.next) + } + else { + let previousDate = datepicker.value.previous + if (typeof year === 'number') + previousDate = previousDate.year(year) + previousDate = previousDate.month(month) + datepicker.value.previous = previousDate + emit('selectMonth', datepicker.value.previous) + } + + syncSelectorRangeOrder(context) + syncDatepickerYears() +} + +function resolveSelectorMonthDelta(currentMonth: number, targetMonth: number) { + const rawDelta = targetMonth - currentMonth + if (rawDelta > 6) + return rawDelta - 12 + if (rawDelta < -6) + return rawDelta + 12 + return rawDelta +} + +function applySelectorYear(context: SelectionContext, year: number) { + if (context === 'nextPanel') { + datepicker.value.next = datepicker.value.next.year(year) + emit('selectRightYear', datepicker.value.next) + } + else { + datepicker.value.previous = datepicker.value.previous.year(year) + emit('selectYear', datepicker.value.previous) + } + + syncSelectorRangeOrder(context) + syncDatepickerYears() +} + +function closeLegacyPanels() { + const isAlreadyClosed + = panel.previous.calendar + && !panel.previous.month + && !panel.previous.year + && panel.next.calendar + && !panel.next.month + && !panel.next.year + if (isAlreadyClosed) + return + + panel.previous.calendar = true + panel.previous.month = false + panel.previous.year = false + panel.next.calendar = true + panel.next.month = false + panel.next.year = false +} + +function shouldReanchorSelectorYearWindow(year: number) { + const years = selectorYears.value + if (years.length === 0) + return true + const firstYear = years[0] + const lastYear = years[years.length - 1] + return year <= firstYear + SELECTOR_YEAR_REANCHOR_THRESHOLD + || year >= lastYear - SELECTOR_YEAR_REANCHOR_THRESHOLD +} + +function isSelectorPanel(panelName: SelectionPanel) { + if (!props.selectorMode || pickerViewMode.value !== 'selector') + return false + return selectionContext.value === resolveSelectionContext(panelName) +} + +function getSelectorColumnClass(focus: SelectorFocus) { + if (!props.selectorFocusTint) + return 'border-black/[.08] dark:border-vtd-secondary-700/[1]' + + return selectorFocus.value === focus + ? 'border-vtd-primary-300 bg-vtd-primary-50/40 dark:border-vtd-primary-500 dark:bg-vtd-secondary-700/50' + : 'border-black/[.08] dark:border-vtd-secondary-700/[1]' +} + +function enterSelectorMode(payload: HeaderInteractionPayload) { + if (!props.selectorMode) + return + const { panel, focus } = payload + + selectorFocus.value = focus + selectionContext.value = resolveSelectionContext(panel) + syncSelectorState(selectionContext.value) + pickerViewMode.value = 'selector' + closeLegacyPanels() + nextTick(() => { + if (pickerViewMode.value === 'selector') { + closeLegacyPanels() + focusSelectorModeTarget(selectionContext.value, selectorFocus.value) + } + }) +} + +function togglePickerViewMode( + payload: HeaderInteractionPayload, + options: TogglePickerViewOptions = {}, +) { + if (!props.selectorMode) + return + const { restoreFocus = false } = options + const { panel, focus } = payload + + selectorFocus.value = focus + selectionContext.value = resolveSelectionContext(panel) + if (pickerViewMode.value === 'selector') { + pickerViewMode.value = 'calendar' + closeLegacyPanels() + if (restoreFocus) + nextTick(() => focusHeaderValue(panel, focus)) + return + } + syncSelectorState(selectionContext.value) + pickerViewMode.value = 'selector' + closeLegacyPanels() + nextTick(() => { + if (pickerViewMode.value === 'selector') { + closeLegacyPanels() + focusSelectorModeTarget(selectionContext.value, selectorFocus.value) + } + }) +} + +function onSelectorMonthUpdate(panelName: SelectionPanel, payload: SelectorMonthPayload) { + if (!props.selectorMode) + return + selectorFocus.value = 'month' + const context = resolveSelectionContext(panelName) + selectionContext.value = context + const targetMonth = typeof payload === 'number' ? payload : payload.month + const targetYear = typeof payload === 'number' ? undefined : payload.year + const contextDate = resolveContextDate(context) + const isSameMonth = contextDate.month() === targetMonth + const isSameYear = targetYear === undefined || contextDate.year() === targetYear + if (isSameMonth && isSameYear) { + closeLegacyPanels() + return + } + + if (typeof targetYear === 'number') { + applySelectorMonth(context, targetMonth, targetYear) + } + else { + // Month-only selector events infer year rollover from cyclic month deltas. + const delta = resolveSelectorMonthDelta(contextDate.month(), targetMonth) + const inferredDate = contextDate.add(delta, 'month') + applySelectorMonth(context, inferredDate.month(), inferredDate.year()) + } + + syncSelectorState(context, { syncAnchor: false }) + if (shouldReanchorSelectorYearWindow(selectorState.selectedYear)) + anchorSelectorYearWindow(selectorState.selectedYear) + closeLegacyPanels() +} + +function onSelectorYearUpdate(panelName: SelectionPanel, year: number) { + if (!props.selectorMode) + return + selectorFocus.value = 'year' + const context = resolveSelectionContext(panelName) + selectionContext.value = context + if (resolveContextDate(context).year() === year) { + closeLegacyPanels() + return + } + applySelectorYear(context, year) + syncSelectorState(context, { syncAnchor: false }) + if (shouldReanchorSelectorYearWindow(selectorState.selectedYear)) + anchorSelectorYearWindow(selectorState.selectedYear) + closeLegacyPanels() +} + +function resetPickerViewMode() { + pickerViewMode.value = 'calendar' + if (props.selectorMode) + closeLegacyPanels() +} + +function onPickerPanelKeydown(event: KeyboardEvent) { + if (event.key !== 'Escape') + return + if (!props.selectorMode || pickerViewMode.value !== 'selector') + return + + event.preventDefault() + event.stopPropagation() + togglePickerViewMode( + { + panel: resolveContextPanel(selectionContext.value), + focus: selectorFocus.value, + }, + { restoreFocus: true }, + ) +} + +watch( + () => props.selectorMode, + (enabled) => { + if (!enabled) + pickerViewMode.value = 'calendar' + }, +) setTimeout(() => { displayDatepicker.value = true @@ -1417,6 +1794,11 @@ function getAbsoluteParentClass(open: boolean) { return 'left-0 right-auto' } +watchEffect(() => { + if (pickerViewMode.value === 'calendar') + syncSelectorState(resolveSelectionContext('previous')) +}) + provide(isBetweenRangeKey, isBetweenRange) provide(betweenRangeClassesKey, betweenRangeClasses) provide(datepickerClassesKey, datepickerClasses) @@ -1433,7 +1815,7 @@ provide(setToCustomShortcutKey, setToCustomShortcut) - +
-
+ :class="[getAbsoluteClass(open), props.selectorMode && props.asSingle ? 'sm:w-[28.5rem] sm:h-[23.5rem]' : '']" + @keydown.capture="onPickerPanelKeydown"> +
-
+
@@ -1552,43 +2023,134 @@ provide(setToCustomShortcutKey, setToCustomShortcut)
-
+ ref="VtdStaticRef" + class="bg-white rounded-lg shadow-sm border border-black/[.1] px-3 py-3 sm:px-4 sm:py-4 dark:bg-vtd-secondary-800 dark:border-vtd-secondary-700/[1]" + :class="props.selectorMode && props.asSingle ? 'sm:w-[28.5rem] sm:h-[23.5rem]' : ''" + @keydown.capture="onPickerPanelKeydown"> +
-
+
diff --git a/src/components/Header.vue b/src/components/Header.vue index 8ae4967..7c19964 100644 --- a/src/components/Header.vue +++ b/src/components/Header.vue @@ -1,8 +1,12 @@ - - - -``` + /> + +``` + +## Selector Mode + +Enable native-like month/year wheel selectors. Default is `false`. + + + + + +```vue + +``` + +## Selector Year Scroll Mode + +Choose year-wheel sync behavior when selector mode is enabled. + +- `boundary` (default): year wheel moves discretely on year boundaries. +- `fractional`: year wheel drifts continuously with month progress. + + + + + +```vue + +``` + +## Selector Focus Tint + +Control whether the active selector column receives extra focus tint styling. +Default is `true`. + +```vue + +``` diff --git a/docs/theming-options.md b/docs/theming-options.md index 7cd2226..0c7eaec 100644 --- a/docs/theming-options.md +++ b/docs/theming-options.md @@ -37,3 +37,45 @@ Light mode color system using custom color `vtd-primary`. ## Dark mode Dark mode color system using color palette `vtd-secondary`. Vue Tailwind Datepicker work it well with Tailwind CSS `dark` mode configuration. + +## Selector Wheel Tokens + +When `selector-mode` is enabled, month/year wheel visuals can be tuned via CSS variables on `.vtd-datepicker`. + +```css +.vtd-datepicker { + --vtd-selector-wheel-cell-height: 40px; + + --vtd-selector-month-font-family: inherit; + --vtd-selector-month-font-size: 0.875rem; + --vtd-selector-month-font-weight: 500; + --vtd-selector-month-line-height: 1.5rem; + --vtd-selector-month-text: rgb(163 163 163 / 100%); + --vtd-selector-month-hover-bg: rgb(14 165 233 / 10%); + --vtd-selector-month-hover-border: rgb(56 189 248 / 45%); + --vtd-selector-month-hover-border-width: 0.85px; + --vtd-selector-month-hover-text: rgb(14 116 144 / 100%); + --vtd-selector-month-selected-bg: rgb(14 165 233 / 13%); + --vtd-selector-month-selected-border: rgb(14 165 233 / 62%); + --vtd-selector-month-selected-border-width: 0.85px; + --vtd-selector-month-selected-text: rgb(56 189 248 / 100%); + + --vtd-selector-year-font-family: inherit; + --vtd-selector-year-font-size: 0.875rem; + --vtd-selector-year-font-weight: 500; + --vtd-selector-year-text: rgb(163 163 163 / 100%); + --vtd-selector-year-hover-bg: rgb(14 165 233 / 10%); + --vtd-selector-year-hover-border: rgb(56 189 248 / 45%); + --vtd-selector-year-hover-border-width: 0.85px; + --vtd-selector-year-hover-text: rgb(14 116 144 / 100%); + --vtd-selector-year-selected-bg: rgb(14 165 233 / 13%); + --vtd-selector-year-selected-border: rgb(14 165 233 / 62%); + --vtd-selector-year-selected-border-width: 0.85px; + --vtd-selector-year-selected-text: rgb(56 189 248 / 100%); + + /* Advanced year-canvas tuning */ + --vtd-selector-year-canvas-border-width-scale: 0.5; + --vtd-selector-year-canvas-dpr: 4; + --vtd-selector-year-text-offset-y: 0px; +} +``` diff --git a/specs/001-feat-native-scroll-month-year-selector/contracts/selector-mode-contract.md b/specs/001-feat-native-scroll-month-year-selector/contracts/selector-mode-contract.md index 75e2dbb..25a9e7a 100644 --- a/specs/001-feat-native-scroll-month-year-selector/contracts/selector-mode-contract.md +++ b/specs/001-feat-native-scroll-month-year-selector/contracts/selector-mode-contract.md @@ -31,6 +31,19 @@ Public and internal behavior contract for native-like month/year selector mode. - `boundary`: year wheel moves discretely when year changes. - `fractional`: year wheel position drifts continuously with month progress (June-centered anchor) while selected year semantics remain discrete. +### Selector Styling Surface + +- Month and year wheels expose CSS variable-based styling hooks on `.vtd-datepicker`. +- Required customizable groups: + - typography (font family, size, weight) + - default/hover/selected text colors + - default/hover/selected background and border colors + - wheel cell sizing (`--vtd-selector-wheel-cell-height`) +- Year wheel additionally exposes canvas tuning hooks: + - `--vtd-selector-year-canvas-border-width-scale` + - `--vtd-selector-year-canvas-dpr` + - `--vtd-selector-year-text-offset-y` + ## Toggle Behavior Contract 1. Enter selector mode diff --git a/specs/001-feat-native-scroll-month-year-selector/quickstart.md b/specs/001-feat-native-scroll-month-year-selector/quickstart.md index 2cf50e7..f2ed970 100644 --- a/specs/001-feat-native-scroll-month-year-selector/quickstart.md +++ b/specs/001-feat-native-scroll-month-year-selector/quickstart.md @@ -80,7 +80,7 @@ Record each manual run to make success criteria auditable. | 2026-02-11 | Codex | T021 single-panel range sync (`use-range` + `as-single`) | fail (non-picker favicon 404 only) | pass | Selector transitions stayed scoped to single displayed context; no second-panel state was introduced | | 2026-02-11 | Codex | T029 disabled-date constraints (`disableDate` weekend function) | fail (non-picker favicon 404 only) | pass | Weekend cells remained disabled after selector-mode month change and return to calendar | | 2026-02-11 | Codex | T029 invalid/empty model seeds (`''`, `not-a-date ~ not-a-date`) | fail (non-picker favicon 404 only) | pass | Popovers opened without runtime exceptions; both scenarios anchored to stable calendar months (Feb/Mar 2026) | -| 2026-02-11 | Codex | T029 far-year offsets + small-screen 375x812 | fail (non-picker favicon 404 only) | pass with caveat | Year list remained virtually unbounded (visible through 2137 after jumping to 2077); mobile viewport stayed operable, but selected year vs header year showed offset drift in observed runs (`2035 -> 2037`, `2077 -> 2079`) requiring follow-up | +| 2026-02-12 | Codex | T029 far-year offsets + small-screen 375x812 + selector sync modes | fail (non-picker favicon 404 only) | pass | Year list remained virtually unbounded under boundary/fractional modes; mobile viewport stayed operable; prior year/header drift issue was resolved | Checklist per run: 1. Open browser devtools console and confirm no errors during calendar -> selector -> calendar transitions. diff --git a/specs/001-feat-native-scroll-month-year-selector/spec.md b/specs/001-feat-native-scroll-month-year-selector/spec.md index 38971b1..63efcbc 100644 --- a/specs/001-feat-native-scroll-month-year-selector/spec.md +++ b/specs/001-feat-native-scroll-month-year-selector/spec.md @@ -130,3 +130,4 @@ As a library consumer, I can enable or configure native-like selector behavior w - [RESOLVED 2026-02-12] Selector-mode header uses a combined month+year toggle button and hides side month arrows in selector mode. - [RESOLVED 2026-02-12] Selector focus tinting is configurable and can be disabled for custom visual systems. - [RESOLVED 2026-02-12] Selector mode supports two year wheel sync variants: `boundary` (default) and `fractional` (experimental/continuous). +- [RESOLVED 2026-02-12] Selector wheel month/year visual styling is exposed via CSS variables (including typography, hover/selected states, and year-canvas tuning hooks). diff --git a/specs/001-feat-native-scroll-month-year-selector/tasks.md b/specs/001-feat-native-scroll-month-year-selector/tasks.md index 59741ad..26e051f 100644 --- a/specs/001-feat-native-scroll-month-year-selector/tasks.md +++ b/specs/001-feat-native-scroll-month-year-selector/tasks.md @@ -104,6 +104,8 @@ - [x] T033 [US2] Ensure selector item click recenters wheel/list with smooth motion in `src/components/Month.vue` and `src/components/Year.vue` [FR-015, FR-016] - [x] T034 [US2] Stabilize selector-mode container dimensions across view toggles in `src/VueTailwindDatePicker.vue` [FR-014] - [x] T035 [US4] Add configurable selector focus tint behavior in `src/VueTailwindDatePicker.vue` and demo usage in `src/App.vue` [FR-013] +- [x] T036 [US4] Document `selectorYearScrollMode` and `selectorFocusTint` usage in `README.md` and `docs/props.md` [FR-013, FR-017] +- [x] T037 [US4] Document selector wheel styling tokens (including year-canvas tuning variables) in `docs/theming-options.md` [FR-010] **Checkpoint**: API is backward compatible and documented. @@ -118,6 +120,7 @@ - [x] T027 [Shared] Execute quickstart verification checklist in `specs/001-feat-native-scroll-month-year-selector/quickstart.md` and record outcomes in PR notes [SC-001, SC-004] - [x] T028 [Shared] Verify and document SC-002 interaction-count criterion (<=2 direct interactions after selector open, excluding scroll distance) using `src/App.vue` scenarios [SC-002] - [x] T029 [Shared] Execute and document edge-case matrix checks (far-year offsets, small screens, disabled-date constraints, invalid/empty model, double-panel clicked-context) in `specs/001-feat-native-scroll-month-year-selector/quickstart.md` [FR-004, FR-006, FR-007, FR-010, FR-011, SC-004] +- [x] T038 [Shared] Update quickstart QA evidence after selector sync fixes (`boundary` + `fractional`) in `specs/001-feat-native-scroll-month-year-selector/quickstart.md` [SC-004] --- diff --git a/src/App.vue b/src/App.vue index fa30659..6fa16d1 100644 --- a/src/App.vue +++ b/src/App.vue @@ -27,6 +27,7 @@ const singleDateValue = ref(dayjs().format('YYYY-MM-DD HH:mm:ss')) const currentLocale = ref('en') const locales = ['en', 'es', 'de'] +const isDark = ref(false) function disableWeekendDates(date: Date) { const day = dayjs(date).day() @@ -43,16 +44,25 @@ function onSelectSomething(e: Dayjs) { + + diff --git a/src/components/Year.vue b/src/components/Year.vue index 420f0e6..d91fce1 100644 --- a/src/components/Year.vue +++ b/src/components/Year.vue @@ -20,31 +20,75 @@ const emit = defineEmits<{ }>() const selectorContainerRef = ref(null) +const selectorCanvasRef = ref(null) const isUserScrolling = ref(false) const isProgrammaticScrollSync = ref(false) const selectorScrollTop = ref(0) const selectorViewportHeight = ref(256) -const selectorRowHeight = ref(44) +const selectorViewportWidth = ref(0) const selectorPaddingTop = ref(0) let scrollFrameId: number | null = null +let drawFrameId: number | null = null let scrollIdleTimeoutId: ReturnType | null = null let programmaticSyncTimeoutId: ReturnType | null = null let lastEmittedScrollYear: number | null = null let suppressSelectedYearSyncUntil = 0 +let resizeObserver: ResizeObserver | null = null +let pendingScrollYear: number | null = null +const previewYear = ref(null) +let lastPreanchorEmitAt = 0 +let awaitingPreanchor = false +let queuedPreanchorYear: number | null = null +const edgeLoadingSide = ref<'top' | 'bottom' | null>(null) +const hoveredYear = ref(null) + const USER_SCROLL_IDLE_MS = 120 const PROGRAMMATIC_SCROLL_SYNC_MS = 180 -const CLICK_SYNC_SUPPRESS_MS = 360 +const CLICK_SYNC_SUPPRESS_MS = 560 const SMOOTH_SCROLL_SYNC_MS = 520 -const FALLBACK_ROW_HEIGHT = 44 +const ROW_HEIGHT = 44 +const CONTENT_TOP_BOTTOM_PADDING = 4 +const REANCHOR_SCROLL_DEADZONE_PX = 0.75 +const EDGE_GUARD_ROWS = 140 +const PREANCHOR_EDGE_ROWS = 90 +const PREANCHOR_EMIT_COOLDOWN_MS = 90 function isSelectedYear(year: number) { - return props.selectedYear === year + const selected = isUserScrolling.value + ? (previewYear.value ?? props.selectedYear) + : props.selectedYear + return selected === year } function clamp(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max) } +function readCssNumber(value: string | undefined, fallback: number) { + const parsed = Number.parseFloat(value ?? '') + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback +} + +function resolveInheritedFontToken(token: string | undefined, inherited: string, fallback: string) { + const normalized = (token ?? '').trim() + if (!normalized || normalized === 'inherit') + return inherited || fallback + return normalized +} + +function getCanvasDpr(container: HTMLElement | null) { + const deviceDpr = Math.max(1, window.devicePixelRatio || 1) + if (!container) + return Math.min(4, deviceDpr) + + const cssDpr = readCssNumber( + getComputedStyle(container).getPropertyValue('--vtd-selector-year-canvas-dpr').trim(), + deviceDpr, + ) + + return Math.min(4, Math.max(1, cssDpr)) +} + function markProgrammaticScrollSync(durationMs = PROGRAMMATIC_SCROLL_SYNC_MS) { isProgrammaticScrollSync.value = true if (programmaticSyncTimeoutId !== null) @@ -60,31 +104,9 @@ function updateSelectorMetrics() { return selectorViewportHeight.value = container.clientHeight || selectorViewportHeight.value + selectorViewportWidth.value = container.clientWidth || selectorViewportWidth.value selectorScrollTop.value = container.scrollTop selectorPaddingTop.value = Number.parseFloat(getComputedStyle(container).paddingTop || '0') || 0 - - const firstRow = container.querySelector('[data-year-index="0"]') - if (firstRow && firstRow.offsetHeight > 0) - selectorRowHeight.value = firstRow.offsetHeight -} - -function centerYearByIndex(index: number, behavior: ScrollBehavior = 'auto') { - const container = selectorContainerRef.value - if (!container) - return - - const rowHeight = selectorRowHeight.value || FALLBACK_ROW_HEIGHT - const centeredScrollTop - = selectorPaddingTop.value - + (index * rowHeight) - - container.clientHeight / 2 - + rowHeight / 2 - - markProgrammaticScrollSync(behavior === 'smooth' ? SMOOTH_SCROLL_SYNC_MS : PROGRAMMATIC_SCROLL_SYNC_MS) - container.scrollTo({ - top: Math.max(0, centeredScrollTop), - behavior, - }) } function getYearFractionalOffset() { @@ -99,32 +121,136 @@ function shouldSuppressSelectedYearSync() { return Date.now() < suppressSelectedYearSyncUntil } +function getCenteredIndexFloat() { + const viewportHeight = selectorViewportHeight.value || 256 + const guardOffsetPx = EDGE_GUARD_ROWS * ROW_HEIGHT + return ( + selectorScrollTop.value + - selectorPaddingTop.value + - guardOffsetPx + + (viewportHeight / 2) + - (ROW_HEIGHT / 2) + ) / ROW_HEIGHT +} + function getCenteredYear() { const years = props.years if (years.length === 0) return null - const rowHeight = selectorRowHeight.value || FALLBACK_ROW_HEIGHT - const viewportHeight = selectorViewportHeight.value || 256 - const centeredIndex = clamp( - Math.round( - ( - selectorScrollTop.value - - selectorPaddingTop.value - + (viewportHeight / 2) - - (rowHeight / 2) - ) / rowHeight, - ), - 0, - years.length - 1, + const centeredIndex = clamp(Math.round(getCenteredIndexFloat()), 0, years.length - 1) + const baseYear = years[0] + return baseYear + centeredIndex +} + +function maybeEmitPreanchor( + indexFloat: number, + centeredYear: number | null, + options: { force?: boolean } = {}, +) { + const { force = false } = options + const years = props.years + if (centeredYear === null || years.length === 0) + return + + const maxIndex = years.length - 1 + const nearTop = indexFloat <= PREANCHOR_EDGE_ROWS + const nearBottom = indexFloat >= maxIndex - PREANCHOR_EDGE_ROWS + if (!nearTop && !nearBottom) { + edgeLoadingSide.value = null + return + } + edgeLoadingSide.value = nearTop ? 'top' : 'bottom' + + if (awaitingPreanchor) { + queuedPreanchorYear = centeredYear + return + } + + const now = performance.now() + if (!force && (now - lastPreanchorEmitAt) < PREANCHOR_EMIT_COOLDOWN_MS) + return + + if (!force && centeredYear === lastEmittedScrollYear) + return + + lastPreanchorEmitAt = now + awaitingPreanchor = true + queuedPreanchorYear = null + lastEmittedScrollYear = centeredYear + emit('scrollYear', centeredYear) +} + +function getScrollContentHeight() { + return Math.max( + 1, + ((props.years.length + (EDGE_GUARD_ROWS * 2)) * ROW_HEIGHT) + (CONTENT_TOP_BOTTOM_PADDING * 2), ) +} + +function centerIndexToScrollTop(index: number, container: HTMLDivElement) { + return selectorPaddingTop.value + + ((index + EDGE_GUARD_ROWS) * ROW_HEIGHT) + - (container.clientHeight / 2) + + (ROW_HEIGHT / 2) +} + +function centerYearByIndex(index: number, behavior: ScrollBehavior = 'auto') { + const container = selectorContainerRef.value + if (!container) + return + + const centeredScrollTop = centerIndexToScrollTop(index, container) + const targetTop = Math.max(0, centeredScrollTop) + if (Math.abs(container.scrollTop - targetTop) <= REANCHOR_SCROLL_DEADZONE_PX) { + selectorScrollTop.value = container.scrollTop + queueCanvasDraw() + return + } + + markProgrammaticScrollSync(behavior === 'smooth' ? SMOOTH_SCROLL_SYNC_MS : PROGRAMMATIC_SCROLL_SYNC_MS) + container.scrollTo({ + top: targetTop, + behavior, + }) + selectorScrollTop.value = container.scrollTop + queueCanvasDraw() +} - return years[centeredIndex] ?? null +function lockScrollAtEdgeIfNeeded() { + const container = selectorContainerRef.value + if (!container || props.years.length === 0) + return false + + const rawIndex = getCenteredIndexFloat() + const minIndex = -1 + const maxIndex = props.years.length + const lockedIndex = clamp(rawIndex, minIndex, maxIndex) + if (Math.abs(rawIndex - lockedIndex) < 0.01) + return false + + const maxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight) + const targetTop = clamp(centerIndexToScrollTop(lockedIndex, container), 0, maxScrollTop) + if (Math.abs(container.scrollTop - targetTop) < REANCHOR_SCROLL_DEADZONE_PX) + return false + + markProgrammaticScrollSync(90) + container.scrollTop = targetTop + selectorScrollTop.value = targetTop + return true +} + +interface ScrollSelectedYearOptions { + includeFractionalOffset?: boolean } -function scrollSelectedYearIntoView(behavior: ScrollBehavior = 'auto') { +function scrollSelectedYearIntoView( + behavior: ScrollBehavior = 'auto', + options: ScrollSelectedYearOptions = {}, +) { if (!props.selectorMode || props.selectedYear === null) return + const { includeFractionalOffset = true } = options const selectedYearIndex = props.years.findIndex(year => year === props.selectedYear) if (selectedYearIndex < 0) @@ -132,32 +258,361 @@ function scrollSelectedYearIntoView(behavior: ScrollBehavior = 'auto') { updateSelectorMetrics() lastEmittedScrollYear = props.selectedYear - centerYearByIndex(selectedYearIndex + getYearFractionalOffset(), behavior) + const fractionalOffset = includeFractionalOffset ? getYearFractionalOffset() : 0 + centerYearByIndex(selectedYearIndex + fractionalOffset, behavior) +} + +function preserveOffsetAcrossYearWindowShift(previousFirstYear: number, nextFirstYear: number) { + const container = selectorContainerRef.value + if (!container) + return + + const yearShift = nextFirstYear - previousFirstYear + if (yearShift === 0) + return + + const deltaPx = -yearShift * ROW_HEIGHT + if (Math.abs(deltaPx) <= 0.01) + return + + markProgrammaticScrollSync(80) + const maxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight) + container.scrollTop = clamp(container.scrollTop + deltaPx, 0, maxScrollTop) + selectorScrollTop.value = container.scrollTop + queueCanvasDraw() } -function onYearClick(year: number) { - if (!props.selectorMode) { - emit('updateYear', year) +function resizeCanvasToContainer() { + const canvas = selectorCanvasRef.value + const container = selectorContainerRef.value + if (!canvas || !container) return + + const dpr = getCanvasDpr(container) + const width = container.clientWidth + const height = container.clientHeight + + if (width <= 0 || height <= 0) + return + + const nextCanvasWidth = Math.floor(width * dpr) + const nextCanvasHeight = Math.floor(height * dpr) + + if (canvas.width !== nextCanvasWidth || canvas.height !== nextCanvasHeight) { + canvas.width = nextCanvasWidth + canvas.height = nextCanvasHeight + canvas.style.width = `${width}px` + canvas.style.height = `${height}px` } - const targetIndex = props.years.findIndex(value => value === year) - if (targetIndex >= 0) { - updateSelectorMetrics() - centerYearByIndex(targetIndex, 'smooth') + selectorViewportWidth.value = width + selectorViewportHeight.value = height +} + +function drawRoundedRectPath( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + radius: number, +) { + const r = Math.min(radius, width / 2, height / 2) + ctx.beginPath() + ctx.moveTo(x + r, y) + ctx.arcTo(x + width, y, x + width, y + height, r) + ctx.arcTo(x + width, y + height, x, y + height, r) + ctx.arcTo(x, y + height, x, y, r) + ctx.arcTo(x, y, x + width, y, r) + ctx.closePath() +} + +function strokeRoundedRectPath( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + radius: number, + lineWidth: number, +) { + const lw = Math.max(0.5, lineWidth) + const inset = lw / 2 + // Snap to half-pixels to reduce antialias bloom versus DOM borders. + const sx = Math.round((x + inset) * 2) / 2 + const sy = Math.round((y + inset) * 2) / 2 + const sw = Math.max(0, width - lw) + const sh = Math.max(0, height - lw) + drawRoundedRectPath(ctx, sx, sy, sw, sh, Math.max(0, radius - inset)) + ctx.lineWidth = lw + ctx.stroke() +} + +function drawClockIcon( + ctx: CanvasRenderingContext2D, + cx: number, + cy: number, + size: number, + color: string, + rotation = 0, +) { + const r = size / 2 + ctx.save() + ctx.strokeStyle = color + ctx.lineWidth = 1.9 + ctx.beginPath() + ctx.arc(cx, cy, r, 0, Math.PI * 2) + ctx.stroke() + + ctx.translate(cx, cy) + ctx.rotate(rotation) + ctx.beginPath() + ctx.moveTo(0, 0) + ctx.lineTo(0, -(r * 0.58)) + ctx.moveTo(0, 0) + ctx.lineTo(r * 0.48, 0) + ctx.stroke() + ctx.beginPath() + ctx.fillStyle = color + ctx.arc(0, 0, 1.4, 0, Math.PI * 2) + ctx.fill() + ctx.restore() +} + +function drawYearCanvas() { + drawFrameId = null + + if (!props.selectorMode) + return + + const canvas = selectorCanvasRef.value + if (!canvas) + return + + const ctx = canvas.getContext('2d') + if (!ctx) + return + + const years = props.years + if (years.length === 0) + return + const baseYear = years[0] + + const width = canvas.clientWidth || selectorViewportWidth.value || 0 + const height = canvas.clientHeight || selectorViewportHeight.value || 0 + if (width <= 0 || height <= 0) + return + const dpr = canvas.width / width + + ctx.setTransform(1, 0, 0, 1, 0, 0) + ctx.clearRect(0, 0, canvas.width, canvas.height) + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + + const rawIndexFloat = getCenteredIndexFloat() + const maxIndex = years.length - 1 + const nearTopEdge = rawIndexFloat <= PREANCHOR_EDGE_ROWS + const nearBottomEdge = rawIndexFloat >= (maxIndex - PREANCHOR_EDGE_ROWS) + const topEdgeActive = nearTopEdge || edgeLoadingSide.value === 'top' + const bottomEdgeActive = nearBottomEdge || edgeLoadingSide.value === 'bottom' + const shouldAnimateEdgeClocks = topEdgeActive || bottomEdgeActive + const clockRotation = shouldAnimateEdgeClocks + ? ((performance.now() / 650) % (Math.PI * 2)) + : 0 + const indexFloat = clamp(rawIndexFloat, -1, maxIndex + 1) + const startIndex = Math.floor(indexFloat - 4) + const endIndex = Math.ceil(indexFloat + 4) + + const centerY = height / 2 + const buttonX = 2 + const buttonWidth = Math.max(0, width - 4) + const cssVars = selectorContainerRef.value ? getComputedStyle(selectorContainerRef.value) : null + const buttonHeight = readCssNumber(cssVars?.getPropertyValue('--vtd-selector-wheel-cell-height').trim(), 40) + const textCenterX = width / 2 + const hoverBg = cssVars?.getPropertyValue('--vtd-selector-year-hover-bg').trim() || 'rgba(30, 41, 59, 0.92)' + const hoverBorder = cssVars?.getPropertyValue('--vtd-selector-year-hover-border').trim() || 'rgba(100, 116, 139, 0.5)' + const borderWidthScale = clamp( + readCssNumber(cssVars?.getPropertyValue('--vtd-selector-year-canvas-border-width-scale').trim(), 0.5), + 0.1, + 1, + ) + const hoverBorderWidth = readCssNumber(cssVars?.getPropertyValue('--vtd-selector-year-hover-border-width').trim(), 0.85) * borderWidthScale + const hoverText = cssVars?.getPropertyValue('--vtd-selector-year-hover-text').trim() || 'rgba(226, 232, 240, 0.96)' + const selectedBg = cssVars?.getPropertyValue('--vtd-selector-year-selected-bg').trim() || 'rgba(14, 165, 233, 0.13)' + const selectedBorder = cssVars?.getPropertyValue('--vtd-selector-year-selected-border').trim() || 'rgba(14, 165, 233, 0.62)' + const selectedBorderWidth = readCssNumber(cssVars?.getPropertyValue('--vtd-selector-year-selected-border-width').trim(), 0.85) * borderWidthScale + const selectedText = cssVars?.getPropertyValue('--vtd-selector-year-selected-text').trim() || 'rgba(56, 189, 248, 1)' + const defaultText = cssVars?.getPropertyValue('--vtd-selector-year-text').trim() || 'rgba(82, 82, 91, 0.92)' + const yearTextOffsetY = readCssNumber(cssVars?.getPropertyValue('--vtd-selector-year-text-offset-y').trim(), 0) + const inheritedFontFamily = cssVars?.fontFamily?.trim() || 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif' + const inheritedFontSize = cssVars?.fontSize?.trim() || '14px' + const inheritedFontWeight = cssVars?.fontWeight?.trim() || '500' + const yearFontFamily = resolveInheritedFontToken( + cssVars?.getPropertyValue('--vtd-selector-year-font-family'), + inheritedFontFamily, + 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif', + ) + const yearFontSize = resolveInheritedFontToken( + cssVars?.getPropertyValue('--vtd-selector-year-font-size'), + inheritedFontSize, + '14px', + ) + const yearFontWeight = resolveInheritedFontToken( + cssVars?.getPropertyValue('--vtd-selector-year-font-weight'), + inheritedFontWeight, + '500', + ) + + ctx.textAlign = 'center' + ctx.font = `${yearFontWeight} ${yearFontSize} ${yearFontFamily}` + const textMetrics = ctx.measureText('0123456789') + const hasBoundingMetrics + = Number.isFinite(textMetrics.actualBoundingBoxAscent) + && Number.isFinite(textMetrics.actualBoundingBoxDescent) + && (textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent) > 0 + const baselineOffset = hasBoundingMetrics + ? ((textMetrics.actualBoundingBoxAscent - textMetrics.actualBoundingBoxDescent) / 2) + : 0 + ctx.textBaseline = hasBoundingMetrics ? 'alphabetic' : 'middle' + + for (let i = startIndex - 1; i <= endIndex + 1; i += 1) { + const y = centerY + ((i - indexFloat) * ROW_HEIGHT) + + if (y < -ROW_HEIGHT || y > height + ROW_HEIGHT) + continue + + const top = y - (buttonHeight / 2) + const isTopEdgeRow = i === -1 + const isBottomEdgeRow = i === (maxIndex + 1) + const isEdgeRow = isTopEdgeRow || isBottomEdgeRow + + if (i < 0 || i > maxIndex) { + if (!isEdgeRow) + continue + } + + if (isEdgeRow) { + const active = isTopEdgeRow ? topEdgeActive : bottomEdgeActive + drawRoundedRectPath(ctx, buttonX, top, buttonWidth, buttonHeight, 8) + ctx.fillStyle = active ? 'rgba(56, 189, 248, 0.18)' : 'rgba(148, 163, 184, 0.08)' + ctx.fill() + ctx.strokeStyle = active ? 'rgba(56, 189, 248, 0.58)' : 'rgba(148, 163, 184, 0.34)' + strokeRoundedRectPath(ctx, buttonX, top, buttonWidth, buttonHeight, 8, 1) + const iconColor = active ? 'rgba(56, 189, 248, 1)' : 'rgba(148, 163, 184, 0.86)' + drawClockIcon(ctx, textCenterX, y, 14, iconColor, clockRotation) + continue + } + + const year = baseYear + i + const isSelected = isSelectedYear(year) + const isHovered = hoveredYear.value === year + + if (isSelected) { + drawRoundedRectPath(ctx, buttonX, top, buttonWidth, buttonHeight, 8) + ctx.fillStyle = selectedBg + ctx.fill() + ctx.strokeStyle = selectedBorder + strokeRoundedRectPath(ctx, buttonX, top, buttonWidth, buttonHeight, 8, selectedBorderWidth) + ctx.fillStyle = selectedText + } + else if (isHovered) { + drawRoundedRectPath(ctx, buttonX, top, buttonWidth, buttonHeight, 8) + ctx.fillStyle = hoverBg + ctx.fill() + ctx.strokeStyle = hoverBorder + strokeRoundedRectPath(ctx, buttonX, top, buttonWidth, buttonHeight, 8, hoverBorderWidth) + ctx.fillStyle = hoverText + } + else { + ctx.fillStyle = defaultText + } + + ctx.fillText(String(year), textCenterX, y + baselineOffset + yearTextOffsetY) } + + if (shouldAnimateEdgeClocks) + queueCanvasDraw() +} + +function queueCanvasDraw() { + if (drawFrameId !== null) + return + drawFrameId = requestAnimationFrame(drawYearCanvas) +} + +function onYearCanvasClick(event: MouseEvent) { + if (!props.selectorMode) + return + + const container = selectorContainerRef.value + if (!container) + return + + const rect = container.getBoundingClientRect() + const localY = event.clientY - rect.top + const deltaRows = Math.round((localY - (rect.height / 2)) / ROW_HEIGHT) + const targetIndex = clamp( + Math.round(getCenteredIndexFloat()) + deltaRows, + 0, + props.years.length - 1, + ) + + const year = props.years[targetIndex] + if (typeof year !== 'number') + return + suppressSelectedYearSyncUntil = Date.now() + CLICK_SYNC_SUPPRESS_MS + lastEmittedScrollYear = year + previewYear.value = year + pendingScrollYear = null emit('updateYear', year) + centerYearByIndex(targetIndex, 'smooth') +} + +function onSelectorPointerMove(event: MouseEvent) { + if (!props.selectorMode || props.years.length === 0) + return + + const container = selectorContainerRef.value + if (!container) + return + + const rect = container.getBoundingClientRect() + const localY = event.clientY - rect.top + const deltaRows = Math.round((localY - (rect.height / 2)) / ROW_HEIGHT) + const targetIndex = clamp( + Math.round(getCenteredIndexFloat()) + deltaRows, + 0, + props.years.length - 1, + ) + hoveredYear.value = props.years[targetIndex] ?? null + queueCanvasDraw() +} + +function clearHoveredYear() { + if (hoveredYear.value === null) + return + hoveredYear.value = null + queueCanvasDraw() } function flushScrollYearUpdate() { scrollFrameId = null + const indexFloat = getCenteredIndexFloat() const centeredYear = getCenteredYear() - if (centeredYear === null || centeredYear === props.selectedYear || centeredYear === lastEmittedScrollYear) + queueCanvasDraw() + + if (centeredYear === null) return - lastEmittedScrollYear = centeredYear - emit('scrollYear', centeredYear) + maybeEmitPreanchor(indexFloat, centeredYear) + + previewYear.value = centeredYear + if (centeredYear === props.selectedYear || centeredYear === lastEmittedScrollYear) { + pendingScrollYear = null + return + } + + pendingScrollYear = centeredYear } function onSelectorScroll() { @@ -165,8 +620,14 @@ function onSelectorScroll() { return updateSelectorMetrics() - // Ignore synthetic scroll events triggered by selected-year auto-centering. - if (isProgrammaticScrollSync.value) + const edgeLocked = lockScrollAtEdgeIfNeeded() + if (edgeLocked) { + updateSelectorMetrics() + maybeEmitPreanchor(getCenteredIndexFloat(), getCenteredYear(), { force: true }) + } + queueCanvasDraw() + + if (isProgrammaticScrollSync.value && !edgeLocked) return isUserScrolling.value = true @@ -174,6 +635,12 @@ function onSelectorScroll() { clearTimeout(scrollIdleTimeoutId) scrollIdleTimeoutId = setTimeout(() => { isUserScrolling.value = false + if (pendingScrollYear !== null && pendingScrollYear !== props.selectedYear && pendingScrollYear !== lastEmittedScrollYear) { + lastEmittedScrollYear = pendingScrollYear + emit('scrollYear', lastEmittedScrollYear) + } + pendingScrollYear = null + previewYear.value = props.selectedYear }, USER_SCROLL_IDLE_MS) if (scrollFrameId !== null) @@ -188,9 +655,10 @@ watch( props.selectedMonth, props.years[0], props.years[props.years.length - 1], + props.years.length, ], ( - [isSelectorMode, selectedYear, selectedMonth, firstYear, lastYear], + [isSelectorMode, selectedYear, selectedMonth, firstYear, lastYear, length], previous = [], ) => { if (!isSelectorMode || selectedYear === null) @@ -198,14 +666,67 @@ watch( const previousFirstYear = previous[3] const previousLastYear = previous[4] + const previousLength = previous[5] const previousSelectedYear = typeof previous[1] === 'number' ? previous[1] : null const previousSelectedMonth = typeof previous[2] === 'number' ? previous[2] : null - const yearWindowChanged = firstYear !== previousFirstYear || lastYear !== previousLastYear + const yearWindowChanged + = firstYear !== previousFirstYear + || lastYear !== previousLastYear + || length !== previousLength + if (yearWindowChanged) { + awaitingPreanchor = false + if (queuedPreanchorYear !== null) { + lastPreanchorEmitAt = 0 + if (queuedPreanchorYear !== lastEmittedScrollYear) { + lastEmittedScrollYear = queuedPreanchorYear + emit('scrollYear', queuedPreanchorYear) + } + queuedPreanchorYear = null + } + edgeLoadingSide.value = null + } const selectedYearNumber = typeof selectedYear === 'number' ? selectedYear : null const selectedMonthNumber = typeof selectedMonth === 'number' ? selectedMonth : null const monthDriftEnabled = props.yearScrollMode === 'fractional' - if (!monthDriftEnabled && selectedYearNumber === previousSelectedYear && !yearWindowChanged) + const monthChanged = selectedMonthNumber !== previousSelectedMonth + + const isScrollDrivenReanchor + = yearWindowChanged + && ( + selectedYearNumber === previousSelectedYear + || selectedYearNumber === lastEmittedScrollYear + || isUserScrolling.value + || pendingScrollYear !== null + ) + if (isScrollDrivenReanchor) { + if (typeof previousFirstYear === 'number' && typeof firstYear === 'number') { + nextTick(() => { + preserveOffsetAcrossYearWindowShift(previousFirstYear, firstYear) + }) + } + else { + queueCanvasDraw() + } + return + } + + // If the parent value reflects our latest scroll emission, avoid re-centering + // (especially when the year window is re-anchored), which causes visible snap. + if ( + selectedYearNumber !== null + && selectedYearNumber === lastEmittedScrollYear + && !yearWindowChanged + && !(monthDriftEnabled && monthChanged) + ) { + queueCanvasDraw() + return + } + + if (!monthDriftEnabled && selectedYearNumber === previousSelectedYear && !yearWindowChanged) { + queueCanvasDraw() return + } + const shouldSmoothSync = previousSelectedYear !== null && selectedYearNumber !== null @@ -222,12 +743,20 @@ watch( ) ) && !yearWindowChanged - if (shouldSuppressSelectedYearSync() && !yearWindowChanged) + + if (shouldSuppressSelectedYearSync()) { + queueCanvasDraw() return - if (isUserScrolling.value && !yearWindowChanged) + } + if (isUserScrolling.value && !yearWindowChanged) { + queueCanvasDraw() return + } + nextTick(() => { + resizeCanvasToContainer() scrollSelectedYearIntoView(shouldSmoothSync ? 'smooth' : 'auto') + queueCanvasDraw() }) }, { immediate: true }, @@ -238,45 +767,80 @@ watch( (year) => { if (year !== null) lastEmittedScrollYear = year + if (!isUserScrolling.value) + previewYear.value = year + queueCanvasDraw() + }, +) + +watch( + () => props.selectorMode, + (enabled) => { + if (!enabled) { + hoveredYear.value = null + return + } + + nextTick(() => { + updateSelectorMetrics() + resizeCanvasToContainer() + scrollSelectedYearIntoView('auto', { includeFractionalOffset: props.yearScrollMode === 'fractional' }) + queueCanvasDraw() + + const container = selectorContainerRef.value + if (!container || typeof ResizeObserver === 'undefined') + return + + if (resizeObserver) + resizeObserver.disconnect() + + resizeObserver = new ResizeObserver(() => { + updateSelectorMetrics() + resizeCanvasToContainer() + queueCanvasDraw() + }) + resizeObserver.observe(container) + }) }, + { immediate: true }, ) onBeforeUnmount(() => { if (scrollFrameId !== null) cancelAnimationFrame(scrollFrameId) + if (drawFrameId !== null) + cancelAnimationFrame(drawFrameId) if (scrollIdleTimeoutId !== null) clearTimeout(scrollIdleTimeoutId) if (programmaticSyncTimeoutId !== null) clearTimeout(programmaticSyncTimeoutId) + if (resizeObserver) + resizeObserver.disconnect() })