diff --git a/aa-events.php b/aa-events.php index dfa5f14..26f4fc8 100644 --- a/aa-events.php +++ b/aa-events.php @@ -3,7 +3,7 @@ * Plugin Name: AA Events * Plugin URI: https://github.com/automattic/aa-events * Description: An Events management plugin for WordPress, with accessible calendar (WCAG 2.2AA) and card grid views. - * Version: 1.2.6 + * Version: 1.2.7 * Author: Keith Solomon * Author URI: https://vincentdesign.ca * License: GPL-2.0+ @@ -49,7 +49,7 @@ register_deactivation_hook( __FILE__, 'deactivate_aa_events' ); * Define Constants */ define( 'AA_EVENTS_PLUGIN_FILE', __FILE__ ); -define( 'AA_EVENTS_VERSION', '1.2.6' ); +define( 'AA_EVENTS_VERSION', '1.2.7' ); define( 'AA_EVENTS_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); define( 'AA_EVENTS_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); diff --git a/assets/css/aa-events.css b/assets/css/aa-events.css index 5a605ac..981108c 100644 --- a/assets/css/aa-events.css +++ b/assets/css/aa-events.css @@ -196,7 +196,8 @@ line-height: 1; } -.aa-events-calendar-event:hover { +.aa-events-calendar-event:hover, +.aa-events-calendar-event.is-occurrence-active { background: #163d5d; text-decoration: underline; } diff --git a/assets/js/aa-events-calendar-view.js b/assets/js/aa-events-calendar-view.js index 53a2b15..90a101c 100644 --- a/assets/js/aa-events-calendar-view.js +++ b/assets/js/aa-events-calendar-view.js @@ -63,6 +63,92 @@ } } + function initOccurrenceGroups(root) { + if (root.__aaEventsOccurrenceGroupsInitialized) { + return; + } + + root.__aaEventsOccurrenceGroupsInitialized = true; + + var segments = root.querySelectorAll('[data-aa-events-occurrence]'); + var groups = Object.create(null); + var segmentIndex; + + for (segmentIndex = 0; segmentIndex < segments.length; segmentIndex += 1) { + var segment = segments[segmentIndex]; + var occurrenceKey = segment.getAttribute('data-aa-events-occurrence'); + var group; + + if ( + !occurrenceKey || + segment.closest('[data-aa-events-calendar]') !== root + ) { + continue; + } + + group = groups[occurrenceKey]; + if (!group) { + group = groups[occurrenceKey] = { + segments: [], + hoverCount: 0, + focusCount: 0, + }; + } + + group.segments.push(segment); + } + + for (var occurrence in groups) { + if (Object.prototype.hasOwnProperty.call(groups, occurrence)) { + (function (group) { + function update() { + var active = group.hoverCount > 0 || group.focusCount > 0; + + for (var index = 0; index < group.segments.length; index += 1) { + group.segments[index].classList.toggle('is-occurrence-active', active); + } + } + + for (var index = 0; index < group.segments.length; index += 1) { + (function (segment) { + var hovered = false; + var focused = false; + + segment.addEventListener('mouseenter', function () { + if (!hovered) { + hovered = true; + group.hoverCount += 1; + update(); + } + }); + segment.addEventListener('mouseleave', function () { + if (hovered) { + hovered = false; + group.hoverCount -= 1; + update(); + } + }); + segment.addEventListener('focus', function () { + if (!focused) { + focused = true; + group.focusCount += 1; + update(); + } + }); + segment.addEventListener('blur', function () { + if (focused) { + focused = false; + group.focusCount -= 1; + update(); + } + }); + })(group.segments[index]); + } + })(groups[occurrence]); + } + } + } + function getInstance(root) { var calendarButton = root.querySelector( '[data-aa-events-view-button="calendar"]' @@ -92,6 +178,7 @@ var preference; for (var index = 0; index < roots.length; index += 1) { + initOccurrenceGroups(roots[index]); var instance = getInstance(roots[index]); if (instance) { diff --git a/docs/superpowers/plans/2026-07-30-multi-day-segment-interaction.md b/docs/superpowers/plans/2026-07-30-multi-day-segment-interaction.md new file mode 100644 index 0000000..f2d3de0 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-multi-day-segment-interaction.md @@ -0,0 +1,670 @@ +# Multi-Day Calendar Segment Interaction Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Synchronize the existing hover appearance across every week segment of one continuous event occurrence, with equivalent keyboard-focus behavior and strict isolation between recurrences and calendar instances. + +**Architecture:** PHP assigns each normalized occurrence a key derived from event ID and occurrence index, and every desktop week segment exposes that key in a data attribute. The existing calendar controller groups matching anchors inside each calendar root and maintains independent hover/focus counters, while plugin and MNBC CSS reuse their existing hover colors through one synchronized-state class. + +**Tech Stack:** PHP 8, WordPress, vanilla JavaScript, CSS, DOMDocument/XPath contract tests, Node’s built-in test runner, WordPress Coding Standards. + +--- + +## Repository and File Map + +### AA Events plugin repository + +- Modify `aa-events.php`: release header and runtime asset version `1.2.7`. +- Modify `tests/bootstrap.php`: standalone version fixture. +- Modify `tests/test-admin-consumers.php`: version contract. +- Modify `includes/calendar-layout.php`: canonical occurrence-key helper. +- Modify `templates/calendar.php`: enrich occurrences and emit segment data attributes. +- Modify `tests/test-calendar-layout.php`: occurrence-key and segment-preservation contracts. +- Modify `tests/test-calendar-render.php`: rendered cross-week grouping contract. +- Modify `assets/js/aa-events-calendar-view.js`: occurrence-group interaction controller. +- Modify `tests/test-calendar-view.js`: hover, focus, isolation, and incomplete-markup tests. +- Modify `assets/css/aa-events.css`: synchronized state selector. +- Modify `templates/aa-events.css`: byte-identical stylesheet copy. +- Modify `tests/test-calendar-css.php`: synchronized selector contract. + +### MNBC theme repository + +- Modify `aa-events/calendar.php`: mirror canonical occurrence keys and data attributes. +- Modify `aa-events/aa-events.css`: reuse MNBC’s existing hover colors for synchronized state. +- Create `tests/test-aa-events-segment-interaction.php`: theme override source and CSS contracts. + +## Task 1: Establish the `1.2.7` Version Baseline + +**Files:** + +- Modify: `tests/test-admin-consumers.php` +- Modify: `aa-events.php` +- Modify: `tests/bootstrap.php` + +- [ ] **Step 1: Write the `1.2.7` version expectations** + +In `tests/test-admin-consumers.php`, set both hard-coded release assertions to `1.2.7`: + +```php +aa_events_assert_same( '1.2.7', $header_match[1] ?? '', 'Plugin header uses the feature release version.' ); +aa_events_assert_same( $header_match[1] ?? '', $constant_match[1] ?? '', 'Plugin header and asset version constant match.' ); +aa_events_assert_same( '1.2.7', AA_EVENTS_VERSION, 'Standalone runtime uses the feature release version.' ); +``` + +- [ ] **Step 2: Run the PHP suite and verify RED** + +Run: + +```bash +php tests/run.php +``` + +Expected: version failures showing production `1.2.6` and standalone `1.2.5` instead of `1.2.7`. + +- [ ] **Step 3: Align production and standalone versions** + +In `aa-events.php`: + +```php + * Version: 1.2.7 +``` + +```php +define( 'AA_EVENTS_VERSION', '1.2.7' ); +``` + +In `tests/bootstrap.php`: + +```php +define( 'AA_EVENTS_VERSION', '1.2.7' ); +``` + +- [ ] **Step 4: Run the PHP suite and verify GREEN** + +Run: + +```bash +php tests/run.php +``` + +Expected: `All AA Events tests passed.` + +- [ ] **Step 5: Commit the version alignment** + +```bash +git add aa-events.php tests/bootstrap.php tests/test-admin-consumers.php +git commit -m "chore: prepare AA Events 1.2.7" +``` + +## Task 2: Add Canonical Occurrence Identity to Calendar Segments + +**Files:** + +- Modify: `tests/test-calendar-layout.php` +- Modify: `tests/test-calendar-render.php` +- Modify: `includes/calendar-layout.php` +- Modify: `templates/calendar.php` + +- [ ] **Step 1: Write failing occurrence-key layout contracts** + +In `tests/test-calendar-layout.php`, before the cross-week occurrence fixture, add: + +```php +aa_events_assert_same( '501-0', aa_events_calendar_occurrence_key( 501, 0 ), 'Occurrence key combines event ID and normalized index.' ); +aa_events_assert_same( '501-1', aa_events_calendar_occurrence_key( 501, 1 ), 'Separate recurrences of one event receive distinct keys.' ); +``` + +Add a key to the existing cross-week `$occurrence`: + +```php +'calendar_occurrence_key' => '1-0', +``` + +After its segment assertions, add: + +```php +aa_events_assert_same( + array( '1-0', '1-0' ), + array_column( $segments, 'calendar_occurrence_key' ), + 'Every cross-week segment preserves its occurrence key.' +); +``` + +- [ ] **Step 2: Write the failing rendered-key contract** + +In `tests/test-calendar-render.php`, replace the single event-link lookup with a node-list contract: + +```php +$event_links = $xpath->query( './/a[contains(concat(" ", normalize-space(@class), " "), " aa-events-calendar-event ")]', $grid ); +aa_events_assert_same( 2, $event_links->length, 'Cross-week occurrence renders two event segments.' ); +$event_link = $event_links->item( 0 ); +$occurrence_keys = array(); +foreach ( $event_links as $rendered_event_link ) { + $occurrence_keys[] = $rendered_event_link->getAttribute( 'data-aa-events-occurrence' ); +} +aa_events_assert_same( array( '501-0', '501-0' ), $occurrence_keys, 'Cross-week segments expose one shared occurrence key.' ); +``` + +Retain the existing assertions that use `$event_link`. + +- [ ] **Step 3: Run the PHP suite and verify RED** + +Run: + +```bash +php tests/run.php +``` + +Expected: failure because `aa_events_calendar_occurrence_key()` and the rendered data attributes do not exist. + +- [ ] **Step 4: Implement the canonical key helper** + +Add before `aa_events_calendar_segments()` in `includes/calendar-layout.php`: + +```php +/** + * Build the request-stable key shared by one occurrence's calendar segments. + * + * @param int $post_id Event post ID. + * @param int $occurrence_index Zero-based normalized occurrence index. + * @return string Occurrence key safe for an HTML data attribute. + */ +function aa_events_calendar_occurrence_key( $post_id, $occurrence_index ) { + return (int) $post_id . '-' . (int) $occurrence_index; +} +``` + +- [ ] **Step 5: Enrich occurrences with their key** + +In `templates/calendar.php`, enumerate normalized occurrences: + +```php +foreach ( aa_events_get_occurrences( $event_id ) as $occurrence_index => $occurrence ) { +``` + +Before appending to `$calendar_occurrences`, add: + +```php +$occurrence['calendar_occurrence_key'] = aa_events_calendar_occurrence_key( $event_id, $occurrence_index ); +``` + +- [ ] **Step 6: Emit the occurrence attribute** + +Add this attribute to the desktop segment anchor in `templates/calendar.php`: + +```php +data-aa-events-occurrence="" +``` + +The anchor must retain its classes, inline layout variables, `href`, and `aria-label`. + +- [ ] **Step 7: Run the PHP suite and verify GREEN** + +Run: + +```bash +php tests/run.php +``` + +Expected: `All AA Events tests passed.` + +- [ ] **Step 8: Commit occurrence identity** + +```bash +git add includes/calendar-layout.php templates/calendar.php tests/test-calendar-layout.php tests/test-calendar-render.php +git commit -m "feat: identify continuous calendar segments" +``` + +## Task 3: Synchronize Hover and Focus in the Controller + +**Files:** + +- Modify: `tests/test-calendar-view.js` +- Modify: `assets/js/aa-events-calendar-view.js` + +- [ ] **Step 1: Extend the DOM test double** + +In `tests/test-calendar-view.js`, give `FakeElement` an `allMatches` map, a `querySelectorAll()` method, and a generic dispatcher: + +```js +this.allMatches = {}; +``` + +```js +querySelectorAll(selector) { + return this.allMatches[selector] || []; +} +``` + +```js +dispatch(type) { + for (const listener of this.listeners[type] || []) { + listener({ currentTarget: this }); + } +} +``` + +In `makeCalendar()`, create segment fixtures from `options.segmentKeys`: + +```js +const occurrenceSegments = (options.segmentKeys || []).map((key) => { + const segment = new FakeElement(); + segment.setAttribute('data-aa-events-occurrence', key); + return segment; +}); +root.allMatches['[data-aa-events-occurrence]'] = occurrenceSegments; +``` + +Include `occurrenceSegments` in the returned calendar object. + +- [ ] **Step 2: Write failing synchronized interaction tests** + +Append: + +```js +test('hover synchronizes only matching occurrence segments', () => { + const calendar = makeCalendar({ + segmentKeys: ['501-0', '501-0', '501-1'], + }); + + init(makeDocument([calendar]), makeStorage(), makeMediaQuery()); + calendar.occurrenceSegments[0].dispatch('mouseenter'); + + assert.equal(calendar.occurrenceSegments[0].classList.contains('is-occurrence-active'), true); + assert.equal(calendar.occurrenceSegments[1].classList.contains('is-occurrence-active'), true); + assert.equal(calendar.occurrenceSegments[2].classList.contains('is-occurrence-active'), false); + + calendar.occurrenceSegments[0].dispatch('mouseleave'); + assert.equal(calendar.occurrenceSegments[0].classList.contains('is-occurrence-active'), false); + assert.equal(calendar.occurrenceSegments[1].classList.contains('is-occurrence-active'), false); +}); + +test('hover and keyboard focus keep independent group state', () => { + const calendar = makeCalendar({ segmentKeys: ['501-0', '501-0'] }); + + init(makeDocument([calendar]), makeStorage(), makeMediaQuery()); + calendar.occurrenceSegments[0].dispatch('focus'); + calendar.occurrenceSegments[1].dispatch('mouseenter'); + calendar.occurrenceSegments[0].dispatch('blur'); + + assert.equal(calendar.occurrenceSegments[0].classList.contains('is-occurrence-active'), true); + assert.equal(calendar.occurrenceSegments[1].classList.contains('is-occurrence-active'), true); + + calendar.occurrenceSegments[1].dispatch('mouseleave'); + assert.equal(calendar.occurrenceSegments[0].classList.contains('is-occurrence-active'), false); + assert.equal(calendar.occurrenceSegments[1].classList.contains('is-occurrence-active'), false); +}); + +test('matching keys remain isolated between calendar instances', () => { + const first = makeCalendar({ segmentKeys: ['501-0', '501-0'] }); + const second = makeCalendar({ segmentKeys: ['501-0', '501-0'] }); + + init(makeDocument([first, second]), makeStorage(), makeMediaQuery()); + first.occurrenceSegments[0].dispatch('focus'); + + assert.equal(first.occurrenceSegments[1].classList.contains('is-occurrence-active'), true); + assert.equal(second.occurrenceSegments[0].classList.contains('is-occurrence-active'), false); + assert.equal(second.occurrenceSegments[1].classList.contains('is-occurrence-active'), false); +}); +``` + +Extend the existing incomplete-markup test with segment keys and assert initialization does not throw. + +- [ ] **Step 3: Run the JavaScript tests and verify RED** + +Run: + +```bash +node --test tests/test-calendar-view.js +``` + +Expected: new synchronized-state assertions fail because no occurrence listeners exist. + +- [ ] **Step 4: Implement occurrence-group initialization** + +Add before `getInstance()` in `assets/js/aa-events-calendar-view.js`: + +```js +function initOccurrenceGroups(root) { + var segments = root.querySelectorAll('[data-aa-events-occurrence]'); + var groups = Object.create(null); + + function updateGroup(group) { + var active = group.hoverCount > 0 || group.focusCount > 0; + + for (var memberIndex = 0; memberIndex < group.segments.length; memberIndex += 1) { + group.segments[memberIndex].classList.toggle( + 'is-occurrence-active', + active + ); + } + } + + for (var segmentIndex = 0; segmentIndex < segments.length; segmentIndex += 1) { + var segment = segments[segmentIndex]; + var key = segment.getAttribute('data-aa-events-occurrence'); + + if (!key) { + continue; + } + + if (!groups[key]) { + groups[key] = { + segments: [], + hoverCount: 0, + focusCount: 0, + }; + } + + groups[key].segments.push(segment); + } + + Object.keys(groups).forEach(function (key) { + var group = groups[key]; + + group.segments.forEach(function (segment) { + var hovered = false; + var focused = false; + + segment.addEventListener('mouseenter', function () { + if (!hovered) { + hovered = true; + group.hoverCount += 1; + updateGroup(group); + } + }); + + segment.addEventListener('mouseleave', function () { + if (hovered) { + hovered = false; + group.hoverCount -= 1; + updateGroup(group); + } + }); + + segment.addEventListener('focus', function () { + if (!focused) { + focused = true; + group.focusCount += 1; + updateGroup(group); + } + }); + + segment.addEventListener('blur', function () { + if (focused) { + focused = false; + group.focusCount -= 1; + updateGroup(group); + } + }); + }); + }); +} +``` + +In the root loop inside `init()`, initialize segment groups before validating view-switcher markup: + +```js +initOccurrenceGroups(roots[index]); +``` + +- [ ] **Step 5: Run JavaScript and PHP suites** + +Run: + +```bash +node --test tests/test-calendar-view.js +php tests/run.php +``` + +Expected: all JavaScript tests and the PHP suite pass. + +- [ ] **Step 6: Commit controller synchronization** + +```bash +git add assets/js/aa-events-calendar-view.js tests/test-calendar-view.js +git commit -m "feat: synchronize calendar segment interaction" +``` + +## Task 4: Reuse Plugin Hover Styling for Active Groups + +**Files:** + +- Modify: `tests/test-calendar-css.php` +- Modify: `assets/css/aa-events.css` +- Modify: `templates/aa-events.css` + +- [ ] **Step 1: Change the CSS contract first** + +In `tests/test-calendar-css.php`, replace the event hover contract with: + +```php +'/\.aa-events-calendar-event:hover\s*,\s*\.aa-events-calendar-event\.is-occurrence-active\s*\{(?=[^}]*background\s*:\s*#163d5d)(?=[^}]*text-decoration\s*:\s*underline)[^}]*\}/s' => 'Hovered and synchronized event segments share the established hover appearance.', +``` + +- [ ] **Step 2: Run the PHP suite and verify RED** + +Run: + +```bash +php tests/run.php +``` + +Expected: synchronized selector contract fails. + +- [ ] **Step 3: Add the synchronized selector** + +In both `assets/css/aa-events.css` and `templates/aa-events.css`, change: + +```css +.aa-events-calendar-event:hover { +``` + +to: + +```css +.aa-events-calendar-event:hover, +.aa-events-calendar-event.is-occurrence-active { +``` + +Do not change the declaration values or the separate `:focus-visible` rule. + +- [ ] **Step 4: Verify CSS behavior and byte identity** + +Run: + +```bash +php tests/run.php +cmp -s assets/css/aa-events.css templates/aa-events.css +``` + +Expected: PHP suite passes and `cmp` exits zero. + +- [ ] **Step 5: Commit plugin styling** + +```bash +git add assets/css/aa-events.css templates/aa-events.css tests/test-calendar-css.php +git commit -m "style: highlight continuous event segments" +``` + +## Task 5: Integrate the MNBC Theme Override + +**Files:** + +- Modify: `/Users/keith/Local Sites/mnbc/app/public/wp-content/themes/MNBC/aa-events/calendar.php` +- Modify: `/Users/keith/Local Sites/mnbc/app/public/wp-content/themes/MNBC/aa-events/aa-events.css` +- Create: `/Users/keith/Local Sites/mnbc/app/public/wp-content/themes/MNBC/tests/test-aa-events-segment-interaction.php` + +- [ ] **Step 1: Work on an isolated theme branch** + +Create an MNBC worktree from current `main` without touching the parent checkout’s existing `views/blocks/section/section.php` change. Use branch: + +```text +keith/multi-day-segment-interaction +``` + +- [ ] **Step 2: Write the failing theme contract** + +Create `tests/test-aa-events-segment-interaction.php`: + +```php +"' ), + 'Calendar override must expose the escaped occurrence key on each segment.' +); +$assert( + 1 === preg_match( + '/\.aa-events-calendar-event:hover\s*,\s*\.aa-events-calendar-event\.is-occurrence-active\s*\{(?=[^}]*background\s*:\s*var\(--color-light-blue\))(?=[^}]*color\s*:\s*var\(--color-dark-blue\))[^}]*\}/s', + $css + ), + 'MNBC synchronized segments must retain the established theme hover colors.' +); + +if ( $failures ) { + exit( 1 ); +} + +echo "All AA Events segment interaction theme contracts passed.\n"; +``` + +- [ ] **Step 3: Run the theme contract and verify RED** + +Run: + +```bash +php tests/test-aa-events-segment-interaction.php +``` + +Expected: key, data attribute, and active selector assertions fail. + +- [ ] **Step 4: Mirror occurrence identity in the theme template** + +In `aa-events/calendar.php`, enumerate occurrences with `$occurrence_index`, set: + +```php +$occurrence['calendar_occurrence_key'] = aa_events_calendar_occurrence_key( $event_id, $occurrence_index ); +``` + +and add to every desktop segment anchor: + +```php +data-aa-events-occurrence="" +``` + +- [ ] **Step 5: Reuse MNBC’s hover appearance** + +In `aa-events/aa-events.css`, change: + +```css +.aa-events-calendar-event:hover { +``` + +to: + +```css +.aa-events-calendar-event:hover, +.aa-events-calendar-event.is-occurrence-active { +``` + +Keep MNBC’s existing light-blue background and dark-blue text. + +- [ ] **Step 6: Run theme verification** + +Run: + +```bash +php tests/test-aa-events-segment-interaction.php +composer lint +git diff --check +``` + +Expected: the segment contract passes, PHPCS completes with zero violations, and no whitespace errors are reported. + +- [ ] **Step 7: Commit the theme integration** + +```bash +git add aa-events/calendar.php aa-events/aa-events.css tests/test-aa-events-segment-interaction.php +git commit -m "feat: synchronize multi-day event segments" +``` + +## Task 6: Full Verification + +**Files:** + +- Verify all plugin and theme changes. + +- [ ] **Step 1: Run plugin behavior suites** + +```bash +php tests/run.php +node --test tests/test-calendar-view.js +``` + +Expected: all PHP and JavaScript tests pass. + +- [ ] **Step 2: Run plugin syntax and focused coding standards** + +```bash +find . -type f -name '*.php' -print0 | xargs -0 -n1 php -l +vendor/bin/phpcs --standard=.phpcs.xml aa-events.php includes/calendar-layout.php templates/calendar.php tests/test-calendar-layout.php tests/test-calendar-render.php tests/test-admin-consumers.php +``` + +If the plugin repository has no `vendor/bin/phpcs`, use the MNBC worktree’s installed `vendor/bin/phpcs` with the plugin `.phpcs.xml`. + +Expected: syntax passes for every plugin PHP file and changed feature files have no PHPCS violations. + +- [ ] **Step 3: Verify plugin source boundaries** + +```bash +cmp -s assets/css/aa-events.css templates/aa-events.css +rg -n "1\\.2\\.[0-9]+|calendar_occurrence_key|data-aa-events-occurrence|is-occurrence-active" aa-events.php includes templates assets tests +git diff --check main..HEAD +git status --short --branch +``` + +Expected: CSS copies are identical; release references are `1.2.7`; occurrence interaction appears only in intended calendar consumers; the feature branch is clean. + +- [ ] **Step 4: Run theme verification** + +From the MNBC feature worktree: + +```bash +php tests/test-aa-events-segment-interaction.php +composer lint +git diff --check main..HEAD +git status --short --branch +``` + +Expected: contracts and lint pass, whitespace is clean, and only intended commits exist. + +- [ ] **Step 5: Review deployment order** + +Confirm the plugin is deployed before or atomically with the theme because the theme template calls `aa_events_calendar_occurrence_key()`. Confirm WordPress caches receive plugin version `1.2.7`, and note that the existing occurrence-index backfill will run in bounded batches after the version change. diff --git a/docs/superpowers/specs/2026-07-30-multi-day-segment-interaction-design.md b/docs/superpowers/specs/2026-07-30-multi-day-segment-interaction-design.md new file mode 100644 index 0000000..7a6f873 --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-multi-day-segment-interaction-design.md @@ -0,0 +1,96 @@ +# Multi-Day Calendar Segment Interaction Design + +## Objective + +When one continuous event occurrence is split into multiple desktop calendar segments at week boundaries, hovering or keyboard-focusing any segment will apply the existing hover appearance to every segment belonging to that occurrence. + +## Occurrence Identity + +Calendar segments need an occurrence-level identity because one event post can contain several distinct recurring occurrences. Grouping by event ID or permalink would incorrectly combine those separate dates. + +During calendar enrichment, each normalized occurrence receives a key derived from: + +- The event post ID. +- The occurrence’s zero-based index within that event’s normalized occurrence list. + +The key is stable for the rendered request, contains only integers and a separator, and is unique within a calendar instance. `aa_events_calendar_segments()` already copies complete occurrence metadata into each week segment, so every segment of the continuous occurrence retains the same key. + +Each desktop segment anchor exposes the key through `data-aa-events-occurrence`. Mobile list items are not split and do not require synchronized interaction. + +## Interaction Controller + +The existing calendar-view controller will also initialize occurrence-segment groups within each `[data-aa-events-calendar]` root. + +For each group, the controller will: + +- Add `is-occurrence-active` to every segment when any group segment receives pointer hover. +- Keep the group active while any segment remains hovered. +- Add the same active class when any group segment receives keyboard focus. +- Keep hover and focus state independent, so pointer exit does not cancel a keyboard-focused group and blur does not cancel a still-hovered group. +- Remove the active class from the group only when neither hover nor focus remains. + +Grouping is scoped to one calendar root. Identical occurrence keys in two independently rendered calendars will not affect each other. + +Per-element listeners and group counters will be used because the number of rendered segments is bounded and small. This avoids dynamic selector escaping and correctly handles movement between segments in the same group. + +If JavaScript is unavailable, the existing `:hover` and `:focus-visible` rules continue to style the directly interacted segment. + +## Visual Behavior + +The synchronized class will share the existing hover appearance: + +- Darker background. +- Underlined event title. + +The actual keyboard-focused anchor retains its existing focus outline and higher stacking order. Other segments in the same group receive the hover appearance without showing a false focus outline. + +Both plugin stylesheet copies and MNBC’s theme override stylesheet will add `is-occurrence-active` alongside their existing hover selector. Colors and other presentation remain native to each stylesheet. + +## Template Compatibility + +The plugin calendar template and MNBC calendar override will both: + +- Enumerate normalized occurrences so distinct recurring rows receive distinct keys. +- Preserve the key through calendar layout. +- Add the escaped `data-aa-events-occurrence` attribute to desktop segment anchors. + +No archive, agenda-list, or single-event markup changes are required. + +## Versioning + +The plugin header, `AA_EVENTS_VERSION`, standalone test bootstrap, and version contract will move from the existing `1.2.6` release to `1.2.7`. This patch release ensures browsers request the updated calendar controller and plugin stylesheet instead of reusing cached `1.2.6` assets. + +Because the occurrence index currently uses `AA_EVENTS_VERSION` as its schema marker, the bump will schedule the existing bounded index backfill. No occurrence data migration or schema change is otherwise required. + +## Testing + +Plugin tests will verify: + +- Version references agree on `1.2.7`. +- Continuous occurrences preserve one key across all week segments. +- Separate normalized occurrences for the same event receive different keys. +- Rendered cross-week anchors expose matching occurrence attributes. +- Hovering one segment activates every matching segment. +- Pointer exit removes synchronized styling only when no group segment remains hovered or focused. +- Keyboard focus activates every matching segment and blur clears it when appropriate. +- Hover and focus state do not cancel each other. +- Different occurrence groups and different calendar instances remain isolated. +- Incomplete markup remains safe. +- Plugin CSS applies the established hover appearance to `is-occurrence-active`. +- Both plugin stylesheet copies remain byte-identical. + +Theme contract tests will verify: + +- MNBC emits the occurrence attribute using the canonical key. +- MNBC’s active-group selector shares the existing theme hover appearance. +- The existing calendar/list switching behavior remains unchanged. + +PHP syntax, plugin PHP tests, JavaScript tests, plugin coding standards for changed feature files, theme contracts, theme coding standards, and whitespace checks must pass. + +## Out of Scope + +- Synchronizing agenda-list items with desktop segments. +- Highlighting separate recurring occurrences of the same event. +- Persisting hover or focus state across page loads or month navigation. +- Changing the event bar colors, dimensions, or focus-outline design. +- Highlighting segments across separate calendar instances. diff --git a/includes/calendar-layout.php b/includes/calendar-layout.php index 2494b72..d42dac9 100644 --- a/includes/calendar-layout.php +++ b/includes/calendar-layout.php @@ -81,6 +81,17 @@ function aa_events_occurrence_intersects_range( array $occurrence, DateTimeImmut return null !== $occurrence_start && null !== $occurrence_end && $occurrence_end >= $occurrence_start && $end >= $start && $occurrence_start <= $end && $occurrence_end >= $start; } +/** + * Build a stable key for an event occurrence within a calendar render. + * + * @param mixed $post_id Event post ID. + * @param mixed $occurrence_index Zero-based occurrence index for the event. + * @return string + */ +function aa_events_calendar_occurrence_key( $post_id, $occurrence_index ) { + return (int) $post_id . '-' . (int) $occurrence_index; +} + /** * Clip an occurrence to a month and split it at week boundaries. * diff --git a/templates/aa-events.css b/templates/aa-events.css index 5a605ac..981108c 100644 --- a/templates/aa-events.css +++ b/templates/aa-events.css @@ -196,7 +196,8 @@ line-height: 1; } -.aa-events-calendar-event:hover { +.aa-events-calendar-event:hover, +.aa-events-calendar-event.is-occurrence-active { background: #163d5d; text-decoration: underline; } diff --git a/templates/calendar.php b/templates/calendar.php index 46d271d..9b55e03 100644 --- a/templates/calendar.php +++ b/templates/calendar.php @@ -117,14 +117,15 @@ $events = new WP_Query( foreach ( $events->posts as $event_id ) { $event_title = get_the_title( $event_id ); $event_permalink = get_permalink( $event_id ); - foreach ( aa_events_get_occurrences( $event_id ) as $occurrence ) { + foreach ( aa_events_get_occurrences( $event_id ) as $occurrence_index => $occurrence ) { if ( ! is_array( $occurrence ) || ! aa_events_occurrence_intersects_range( $occurrence, $month['month_start'], $month['month_end'] ) ) { continue; } - $occurrence['post_id'] = (int) $event_id; - $occurrence['title'] = $event_title; - $occurrence['permalink'] = $event_permalink; - $calendar_occurrences[] = $occurrence; + $occurrence['post_id'] = (int) $event_id; + $occurrence['title'] = $event_title; + $occurrence['permalink'] = $event_permalink; + $occurrence['calendar_occurrence_key'] = aa_events_calendar_occurrence_key( $event_id, $occurrence_index ); + $calendar_occurrences[] = $occurrence; } } @@ -211,7 +212,7 @@ $calendar_url = static function ( DateTimeImmutable $date ) use ( $calendar /* translators: 1: event title, 2: complete event date and time range. */ $event_label = sprintf( __( '%1$s: %2$s', 'aa-events' ), $segment['title'], aa_events_format_occurrence( $segment ) ); ?> - +