# Blog Grid Module — Design Spec

## Overview

A new Divi 5 module (`dnxte/blog-grid`) that displays WordPress posts in a filterable, paginated CSS grid layout. Forked from `NextPostCarousel`, replacing carousel logic with grid layout, category filter bar, and pagination.

## Module Identity

- **Module ID:** `dnxte/blog-grid`
- **Component Name:** `NextBlogGrid`
- **CSS Prefix:** `.dnxte-blog-grid`
- **Base:** Forked from `NextPostCarousel`

## Architecture

### Approach

Fork `NextPostCarousel`. Remove all Swiper/carousel logic (autoplay, loop, navigation arrows, dots, slide transitions, equalize height). Keep post query logic (WP_Query, category/post type selection, orderby, offset). Add CSS Grid layout, filter bar, and pagination components.

### File Structure

**TypeScript (Visual Builder):**

```
divi-5/visual-builder/src/components/NextBlogGrid/
├── index.ts
├── module.json
├── types.ts
├── edit.tsx
├── settings-content.tsx
├── settings-design.tsx
├── settings-advanced.tsx
├── styles.tsx
├── module.scss
├── custom-css.ts
├── placeholder-content.ts
├── module-classnames.ts
└── module-script-data.tsx
```

**PHP (Server):**

```
divi-5/server/Modules/NextBlogGrid/
├── NextBlogGrid.php
└── NextBlogGridTrait/
    ├── RenderCallbackTrait.php
    ├── ModuleStylesTrait.php
    ├── ModuleClassnamesTrait.php
    ├── ModuleScriptDataTrait.php
    └── CustomCssTrait.php
```

### Registration

- TypeScript: Add to `divi-5/visual-builder/src/index.ts` as `'dnxte-blog-grid': NextBlogGrid`
- PHP: Add to `divi-5/server/Modules/Modules.php` as `'dnxte-blog-grid' => 'NextBlogGrid'`

## Settings

### Content Settings

**Post Query:**
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| `post.type` | select | `"post"` | Post type (post, page, CPT) |
| `post.number` | number | `6` | Posts per page |
| `post.offset` | number | `0` | Query offset |
| `post.orderby` | select | `"date"` | date/title/author/id/random |
| `post.order` | select | `"DESC"` | ASC / DESC |
| `post.categories` | multi-select | `""` | Category filter (restricts the overall query scope) |
| `post.dateFormat` | text | `"M j, Y"` | PHP date format |
| `post.excerptLength` | number | `20` | Excerpt word count |
| `post.postTarget` | select | `"_self"` | Link target |

**Element Toggles:**
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| `elements.showFeaturedImage` | toggle | `"on"` | Show featured image |
| `elements.featuredImageSize` | select | `"large"` | thumbnail/medium/large/full/custom |
| `elements.customImageWidth` | number | `600` | Custom width in px (visible when featuredImageSize = custom) |
| `elements.customImageHeight` | number | `400` | Custom height in px (visible when featuredImageSize = custom) |
| `elements.showTitle` | toggle | `"on"` | Show post title |
| `elements.showDate` | toggle | `"on"` | Show date |
| `elements.showAuthor` | toggle | `"on"` | Show author |
| `elements.showCategories` | toggle | `"on"` | Show category tags |
| `elements.showExcerpt` | toggle | `"off"` | Show excerpt |
| `elements.showReadMore` | toggle | `"off"` | Show read more link |
| `elements.readMoreText` | text | `"Read More"` | Read more button text |
| `elements.showCommentCount` | toggle | `"off"` | Show comment count |
| `elements.noResultsText` | text | `"No posts found."` | Empty state message |

**Filter Bar:**
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| `filterBar.show` | toggle | `"on"` | Show filter bar |
| `filterBar.allLabel` | text | `"All"` | "All" tab label |
| `filterBar.categories` | multi-select | `""` | Categories to show as tabs (empty = use `post.categories`; if both empty, show all categories that have posts) |

**Pagination:**
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| `pagination.type` | select | `"loadMore"` | loadMore / numbered / infiniteScroll / none |
| `pagination.loadMoreText` | text | `"Load More"` | Load more button label |
| `pagination.loadingText` | text | `"Loading..."` | Loading state text |

### Category Filtering Rules

`post.categories` and `filterBar.categories` interact as follows:

- **Both empty:** Query returns all posts. Filter bar shows all categories that have published posts.
- **`post.categories` set, `filterBar.categories` empty:** Query restricted to selected categories. Filter bar tabs derived from `post.categories`.
- **Both set:** Query restricted to `post.categories`. Filter bar shows only the subset specified in `filterBar.categories` (must be a subset of `post.categories`).
- **`post.categories` empty, `filterBar.categories` set:** Query returns all posts. Filter bar shows only the specified categories. Clicking a tab filters within all posts.

The "All" tab always shows all posts within the `post.categories` scope.

### Design Settings

**Grid Layout:**
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| `grid.layout` | select | `"card"` | card / overlay / horizontal |
| `grid.columns` | responsive | `{ desktop: { value: "3" }, tablet: { value: "2" }, phone: { value: "1" } }` | Columns per breakpoint |
| `grid.columnGap` | responsive range | `"30px"` | Column gap |
| `grid.rowGap` | responsive range | `"30px"` | Row gap |

**Card Styling:**
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| `card.background` | color | `"#ffffff"` | Card background |
| `card.backgroundHover` | color | — | Card background on hover |
| `card.borderRadius` | responsive range | `"0px"` | Border radius |
| `card.boxShadow` | box-shadow | — | Normal state |
| `card.boxShadowHover` | box-shadow | — | Hover state |
| `card.padding` | responsive spacing | `"0px"` | Content area padding (applies to `.dnxte-blog-grid-card-content` only, not the image) |
| `card.border` | border | — | Normal state |
| `card.borderHover` | border | — | Hover state |

**Image:**
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| `image.height` | responsive range | `"auto"` | Fixed or auto height |
| `image.objectFit` | select | `"cover"` | cover / contain |
| `image.borderRadius` | responsive range | `"0px"` | Image border radius |
| `image.hoverEffect` | select | `"none"` | zoom / grayscale / none |

**Typography:**
| Attribute | Type | Description |
|-----------|------|-------------|
| `titleFont` | Divi font group | Post title typography (font, size, color, weight, line-height, letter-spacing, style) |
| `metaFont` | Divi font group | Date/author text |
| `categoryFont` | Divi font group | Category tag text |
| `excerptFont` | Divi font group | Excerpt paragraph |
| `readMoreFont` | Divi font group | Read more link |

**Filter Bar Styling:**
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| `filter.activeBackground` | color | `"#7c3aed"` | Active tab background |
| `filter.activeColor` | color | `"#ffffff"` | Active tab text color |
| `filter.inactiveBackground` | color | `"transparent"` | Inactive tab background |
| `filter.inactiveColor` | color | `"#475569"` | Inactive tab text color |
| `filter.inactiveBorder` | border | `"1px solid #e2e8f0"` | Inactive tab border |
| `filter.borderRadius` | range | `"20px"` | Tab border radius |
| `filter.font` | Divi font group | — | Tab typography |
| `filter.spacing` | range | `"8px"` | Gap between tabs |
| `filter.alignment` | select | `"left"` | left / center / right |

**Pagination Styling:**
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| `pagination.background` | color | `"transparent"` | Button background |
| `pagination.color` | color | `"#475569"` | Button text color |
| `pagination.font` | Divi font group | — | Typography |
| `pagination.borderRadius` | range | `"24px"` | Border radius |
| `pagination.alignment` | select | `"center"` | left / center / right |

**Horizontal Layout Image:**
| Attribute | Type | Default | Description |
|-----------|------|---------|-------------|
| `horizontalImage.width` | responsive range | `"200px"` | Image width in horizontal layout |

### Advanced Settings

Standard Divi advanced settings (CSS ID & Classes, Custom CSS, Visibility, Transitions, Position, Scroll Effects).

**Custom CSS Targets:** Filter Bar, Filter Tab, Active Tab, Grid Container, Card, Card Image, Card Title, Card Meta, Card Excerpt, Read More, Pagination.

## Card Layouts

### Layout 1 — Card (Default)
Image on top, content below. Title, meta (date | author | category), optional excerpt, optional read more link. Matches the reference screenshot. The entire card is wrapped in an `<a>` tag linking to the post permalink, making the whole card clickable.

### Layout 2 — Overlay
Full-height image with gradient overlay (linear-gradient from bottom: `rgba(0,0,0,0.7)` to `transparent`). Gradient is not user-configurable — use Custom CSS target for overrides. Minimum card height: `280px` (controlled by `image.height`, defaults to `280px` when overlay layout is selected). Title, meta, and category badge positioned at bottom over the image. Entire card is an `<a>` tag.

### Layout 3 — Horizontal
Side-by-side: image on left (width controlled by `horizontalImage.width`, default `200px`), content on right. Entire card is an `<a>` tag. Works well with single-column grids for a list view.

## Link Behavior

The entire card is wrapped in a single `<a>` tag pointing to the post permalink. The `post.postTarget` attribute controls whether it opens in the same tab (`_self`) or new tab (`_blank`). Category tags within the meta are plain text (not separate links) to avoid nested `<a>` tags.

## AJAX Behavior

All post data is **server-rendered on initial page load** via PHP `RenderCallbackTrait`. AJAX is used only for subsequent interactions (filter changes, pagination).

### Filtering Flow
1. User clicks a category tab
2. Active tab class updates immediately
3. Grid container gets `.dnxte-blog-grid--loading` class (opacity: 0.5, pointer-events: none, transition: opacity 300ms ease)
4. JavaScript sends POST to `wp-admin/admin-ajax.php` with `action: dnxte_blog_grid_filter`, category slug, page 1, and module settings nonce
5. Server runs WP_Query with category filter applied
6. Server returns rendered card HTML + pagination state (total pages, current page)
7. Grid content is replaced, loading class removed (opacity transitions back to 1)

### Pagination Behavior

**Load More:**
- Appends new posts below existing grid items
- Button hides when no more posts remain
- Button text changes to `pagination.loadingText` during fetch
- Resets to page 1 on filter change

**Numbered Pagination:**
- Replaces grid content (does not append)
- AJAX page load (no full page reload)
- Shows prev/next arrows + page numbers
- Scrolls to top of module on page change (smooth scroll)
- Resets to page 1 on filter change

**Infinite Scroll:**
- Uses IntersectionObserver with `rootMargin: "0px 0px 200px 0px"` (triggers 200px before reaching bottom)
- Appends posts like Load More
- Shows CSS spinner at bottom during load
- Stops observing when no more posts remain
- Resumes from page 1 on filter change

### Error Handling

On AJAX failure (network error, timeout, server error):
- Remove loading state from grid
- Show a subtle inline error: "Failed to load posts. Please try again." below the grid
- Log error to console for debugging
- Do not retry automatically

### AJAX Endpoint

Register via `wp_ajax_` and `wp_ajax_nopriv_` hooks:
- Action: `dnxte_blog_grid_filter`
- Nonce action: `dnxte_blog_grid_nonce`
- Nonce localized via `wp_localize_script()` as `dnxteBlogGrid.nonce`
- Parameters: category, page, posts_per_page, orderby, order, post_type, layout, element toggles, module_id
- Response: `{ success: true, data: { html: string, max_pages: number, current_page: number, found_posts: number } }`
- Security: `wp_verify_nonce()` check, sanitize all inputs

## Accessibility

- Filter bar uses `role="tablist"` with individual tabs as `role="tab"` and `aria-selected="true|false"`
- Grid container has `role="tabpanel"` and `aria-live="polite"` for dynamic content updates
- After AJAX content replacement, focus moves to the first new card (for keyboard users)
- All cards are keyboard-navigable (inherent from `<a>` tag)
- Loading state announced via `aria-busy="true"` on the grid container
- "No posts found" message uses `role="status"`

## Visual Builder Behavior

In the Divi Visual Builder (editor), the module renders with static placeholder data from the WP_Query (real posts from the site). Filter tabs and pagination UI are visible but non-functional (no AJAX). This matches how `NextPostCarousel` handles editor mode.

## CSS Structure

```scss
.dnxte-blog-grid {
  // Module root

  &-filter-bar {
    // Filter tab container
    display: flex;
    flex-wrap: wrap;
    gap: var(--filter-spacing);

    &-tab { /* individual tab */ }
    &-tab--active { /* active state */ }
  }

  &-container {
    // CSS Grid container
    display: grid;
    grid-template-columns: repeat(var(--columns), 1fr);
    gap: var(--row-gap) var(--column-gap);
    transition: opacity 300ms ease;
  }

  &--loading {
    opacity: 0.5;
    pointer-events: none;
  }

  &-card {
    // Individual post card (is an <a> tag)
    display: block;
    text-decoration: none;
    color: inherit;

    &-image { /* featured image wrapper */ }
    &-content { /* text content area — card.padding applies here */ }
    &-title { /* post title */ }
    &-meta { /* date, author, category row */ }
    &-excerpt { /* excerpt text */ }
    &-read-more { /* read more link */ }
  }

  &-card--overlay {
    position: relative;
    min-height: 280px;
    overflow: hidden;

    .dnxte-blog-grid-card-content {
      position: absolute;
      bottom: 0;
      left: 0;
      right: 0;
      z-index: 1;
    }
  }

  &-card--horizontal {
    display: flex;

    .dnxte-blog-grid-card-image {
      flex-shrink: 0;
      width: var(--horizontal-image-width, 200px);
    }
  }

  &-pagination {
    // Pagination container
    &-load-more { /* load more button */ }
    &-numbers { /* numbered pagination */ }
    &-spinner { /* loading indicator */ }
  }

  &-error {
    // AJAX error message
    text-align: center;
    color: #dc2626;
    padding: 12px;
  }
}
```

## Frontend Assets

- **JS:** One frontend script (`dnxte-blog-grid.js`) handling filter clicks, AJAX requests, pagination, and infinite scroll (IntersectionObserver). Enqueued only when module is on page. Uses `wp_localize_script()` to pass nonce and AJAX URL.
- **CSS:** Module SCSS compiled with the build. Responsive columns via CSS custom properties set by PHP in inline styles.

## Dependencies

- No external libraries (no Swiper, no Isotope)
- Pure CSS Grid for layout
- Vanilla JS for AJAX and IntersectionObserver
- WordPress admin-ajax.php for server communication
