Compare commits

..
23 Commits
Author SHA1 Message Date
Keith Solomon 06e09e8285 🐞 fix: Intercept summary click to animate both directions
Deploy to Dreamhost (dev) / build (push) Successful in 34s
Sync TODOs with Issues / sync_todos (push) Successful in 6s
2026-07-25 15:50:59 -05:00
Keith Solomon 01072f6788 🐞 fix: Keep max-height at pixel value after open so close animates 2026-07-25 15:45:14 -05:00
Keith Solomon 842818ec7e 🐞 fix: Animate team-grid bio with JS measuring natural height 2026-07-25 15:41:46 -05:00
Keith Solomon 7c1c145846 🐞 fix: Use max-height for team-grid bio animation 2026-07-25 15:33:10 -05:00
Keith Solomon aabed2b437 🐞 fix: Use @starting-style for team-grid open transition 2026-07-25 15:23:43 -05:00
Keith Solomon e5301f34c4 🐞 fix: Animate team-grid bio on close and subsequent opens 2026-07-25 15:18:38 -05:00
Keith Solomon 18dde4c9f4 feat: Enhance team-grid block with single open mode and updated styles 2026-07-25 15:15:13 -05:00
Keith Solomon 1c5c727691 test: Add Playwright tests for team-grid block 2026-07-25 13:38:36 -05:00
Keith Solomon 3a07eb384f 🎨 build: Rebuild dist with team-grid block styles 2026-07-25 13:32:58 -05:00
Keith Solomon cb883ca869 📝 docs: Fix Task 6 Step 2 grep — Tailwind v4 preserves CSS-nesting 2026-07-25 13:32:58 -05:00
Keith Solomon 3c786ee9d9 🔧 chore: Import team-grid block CSS into theme 2026-07-25 13:27:16 -05:00
Keith Solomon c20510814e feat: Add team-grid block PHP template 2026-07-25 13:24:06 -05:00
Keith Solomon a816e56cfa feat: Add team-grid block CSS 2026-07-25 13:21:02 -05:00
Keith Solomon c1ac1d659b feat: Add team-grid block.json registration 2026-07-25 13:17:40 -05:00
Keith Solomon 379dee3ea6 feat: Add ACF field group for team-grid block 2026-07-25 13:13:53 -05:00
Keith Solomon 54d79cdcdf 📝 docs: Add implementation plan for team grid block 2026-07-25 12:48:10 -05:00
Keith Solomon 619409eabb 📝 docs: Add design spec for team grid block 2026-07-25 12:46:27 -05:00
Keith Solomon e7de88551d 🐞 fix: Adjust text alignment styles
Deploy to Dreamhost (dev) / build (push) Successful in 35s
Sync TODOs with Issues / sync_todos (push) Successful in 6s
2026-07-25 11:21:47 -05:00
Keith Solomon f8247ff7af 🐞 fix: Adjust line thickness
Deploy to Dreamhost (dev) / build (push) Successful in 36s
Sync TODOs with Issues / sync_todos (push) Successful in 6s
2026-07-25 11:16:39 -05:00
Keith Solomon 2441cef539 rollback: Revert file move, change url for svg
Deploy to Dreamhost (dev) / build (push) Successful in 36s
Sync TODOs with Issues / sync_todos (push) Successful in 5s
2026-07-25 11:13:26 -05:00
Keith Solomon 75055e6f6e 🐞 fix: Move line image into block folder
Deploy to Dreamhost (dev) / build (push) Successful in 34s
Sync TODOs with Issues / sync_todos (push) Successful in 6s
2026-07-25 11:07:06 -05:00
Keith Solomon 63df8e9a88 🐞 fix: Update service heading font size
Deploy to Dreamhost (dev) / build (push) Successful in 35s
Sync TODOs with Issues / sync_todos (push) Successful in 6s
2026-07-25 10:54:30 -05:00
Keith Solomon 704b0dc266 🐞 fix: Unregister core pullquote block 2026-07-25 10:54:07 -05:00
14 changed files with 1626 additions and 8 deletions
+166
View File
@@ -0,0 +1,166 @@
{
"key": "group_team_grid",
"title": "Team Grid",
"fields": [
{
"key": "field_6a650dee2a34c",
"label": "Single Open Mode",
"name": "single",
"aria-label": "",
"type": "true_false",
"instructions": "",
"required": 0,
"conditional_logic": 0,
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"message": "Check to allow only one bio open at a time",
"default_value": 0,
"allow_in_bindings": 0,
"ui": 0,
"ui_on_text": "",
"ui_off_text": ""
},
{
"key": "field_team_grid_members",
"label": "Members",
"name": "members",
"aria-label": "",
"type": "repeater",
"instructions": "Add team members. Click a card to expand the bio.",
"required": 0,
"conditional_logic": 0,
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"layout": "block",
"pagination": 0,
"min": 0,
"max": 0,
"collapsed": "",
"button_label": "Add Team Member",
"rows_per_page": 20,
"sub_fields": [
{
"key": "field_team_grid_member_image",
"label": "Image",
"name": "image",
"aria-label": "",
"type": "image",
"instructions": "",
"required": 0,
"conditional_logic": 0,
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"return_format": "array",
"library": "all",
"min_width": "",
"min_height": "",
"min_size": "",
"max_width": "",
"max_height": "",
"max_size": "",
"mime_types": "",
"allow_in_bindings": 0,
"preview_size": "medium",
"parent_repeater": "field_team_grid_members"
},
{
"key": "field_team_grid_member_name",
"label": "Name",
"name": "name",
"aria-label": "",
"type": "text",
"instructions": "",
"required": 0,
"conditional_logic": 0,
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"default_value": "",
"maxlength": "",
"allow_in_bindings": 0,
"placeholder": "",
"prepend": "",
"append": "",
"parent_repeater": "field_team_grid_members"
},
{
"key": "field_team_grid_member_title",
"label": "Title",
"name": "title",
"aria-label": "",
"type": "text",
"instructions": "",
"required": 0,
"conditional_logic": 0,
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"default_value": "",
"maxlength": "",
"allow_in_bindings": 0,
"placeholder": "",
"prepend": "",
"append": "",
"parent_repeater": "field_team_grid_members"
},
{
"key": "field_team_grid_member_bio",
"label": "Bio",
"name": "bio",
"aria-label": "",
"type": "wysiwyg",
"instructions": "",
"required": 0,
"conditional_logic": 0,
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"default_value": "",
"tabs": "visual",
"toolbar": "basic",
"media_upload": 0,
"delay": 0,
"allow_in_bindings": 0,
"parent_repeater": "field_team_grid_members"
}
]
}
],
"location": [
[
{
"param": "block",
"operator": "==",
"value": "acf\/team-grid"
}
]
],
"menu_order": 0,
"position": "normal",
"style": "default",
"label_placement": "top",
"instruction_placement": "label",
"hide_on_screen": "",
"active": true,
"description": "Team grid block. Each member is a card with photo, name, title, and a collapsible bio.",
"show_in_rest": 0,
"display_title": "",
"allow_ai_access": false,
"ai_description": "",
"modified": 1785007821
}
@@ -0,0 +1,620 @@
# Team Grid Block 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:** Build a new `team-grid` ACF block for the Team page (and any other page that needs a team grid) that renders a responsive grid of team members. Each member shows their photo (rounded rectangle), name, and title. The bio is collapsed by default and expands vertically with a smooth animation when the photo, name, or title is clicked.
**Architecture:** One new ACF block (`acf/team-grid`) with a single repeater of `members` (image, name, title, bio). The block uses native HTML `<details>`/`<summary>` for the click-to-expand interaction, animated with the `grid-template-rows: 0fr → 1fr` CSS pattern (no JavaScript). The block has no built-in background — the editor wraps it in an existing Section block when a dark/light background is needed. The page template is unchanged; the editor adds the block to the Team page in Gutenberg.
**Tech Stack:** WordPress 6.x, PHP 8.x, Tailwind CSS v4, Playwright, axe-core, PHPCS (WordPress standard).
## Global Constraints
- Tabs for PHP indentation (project standard).
- `static/dist/theme.css` is committed (not gitignored) per project convention.
- PHPCS uses the WordPress coding standard; `composer lint` must pass for all touched files.
- All design colors use the project's existing CSS custom properties from `styles/base/colors.css`. No hardcoded hex values for theme colors.
- Reuse existing design tokens where possible (e.g. `text-cwc-blue-01`, `var(--color-secondary)`).
- The headshot uses `rounded-md` to match other image cards in the project.
- All commits follow the project's emoji-prefixed commit-message style (e.g. `✨ feat: …`).
- This work does **not** edit `header.php`, `footer.php`, `views/partials/page-hero.php`, or `views/partials/page-hero-services.php`.
- `page.php` is not modified.
- No JavaScript files are added.
- Reuse the same `regACFBlocks()` auto-registration pattern as `services-list`.
- Tailwind v4 picks up class names from source files at build time. The dynamic class names used in the CSS (`grid-cols-1`, `sm:grid-cols-2`, `lg:grid-cols-3`, `w-32 h-32`, etc.) must all be literal in the source for the build to emit them.
## File Structure
| File | Responsibility | Created/Modified |
| --- | --- | --- |
| `views/blocks/team-grid/team-grid.php` | Block template (PHP) — renders the grid. | Create |
| `views/blocks/team-grid/team-grid.css` | Block styles (CSS) — layout, photo, summary, animation. | Create |
| `views/blocks/team-grid/block.json` | Block registration metadata. | Create |
| `acf/group_team_grid.json` | ACF field group for the `acf/team-grid` block. | Create |
| `styles/blocks/index.css` | Import the new block's CSS. | Modify |
| `tests/team-page.spec.js` | Playwright tests for the rendered block. | Create |
| `static/dist/theme.css` | Rebuilt via `npm run build`. | Modified |
---
## Task 1: ACF Field Group
**Files:**
- Create: `acf/group_team_grid.json`
**Interfaces:**
- Produces: ACF group with `key: "group_team_grid"`, location scoped to `block == acf/team-grid`, fields: `members` (repeater) → `image`, `name`, `title`, `bio`.
- [ ] **Step 1: Create the ACF field group JSON file**
Create the file `acf/group_team_grid.json` with the following exact content:
```json
{
"key": "group_team_grid",
"title": "Team Grid",
"fields": [
{
"key": "field_team_grid_members",
"label": "Members",
"name": "members",
"aria-label": "",
"type": "repeater",
"instructions": "Add team members. Click a card to expand the bio.",
"required": 0,
"conditional_logic": 0,
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"layout": "block",
"pagination": 0,
"min": 0,
"max": 0,
"collapsed": "",
"button_label": "Add Team Member",
"rows_per_page": 20,
"sub_fields": [
{
"key": "field_team_grid_member_image",
"label": "Image",
"name": "image",
"aria-label": "",
"type": "image",
"instructions": "",
"required": 0,
"conditional_logic": 0,
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"return_format": "array",
"library": "all",
"min_width": "",
"min_height": "",
"min_size": "",
"max_width": "",
"max_height": "",
"max_size": "",
"mime_types": "",
"allow_in_bindings": 0,
"preview_size": "medium"
},
{
"key": "field_team_grid_member_name",
"label": "Name",
"name": "name",
"aria-label": "",
"type": "text",
"instructions": "",
"required": 0,
"conditional_logic": 0,
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"default_value": "",
"maxlength": "",
"allow_in_bindings": 0,
"placeholder": "",
"prepend": "",
"append": ""
},
{
"key": "field_team_grid_member_title",
"label": "Title",
"name": "title",
"aria-label": "",
"type": "text",
"instructions": "",
"required": 0,
"conditional_logic": 0,
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"default_value": "",
"maxlength": "",
"allow_in_bindings": 0,
"placeholder": "",
"prepend": "",
"append": ""
},
{
"key": "field_team_grid_member_bio",
"label": "Bio",
"name": "bio",
"aria-label": "",
"type": "wysiwyg",
"instructions": "",
"required": 0,
"conditional_logic": 0,
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"default_value": "",
"tabs": "visual",
"toolbar": "basic",
"media_upload": 0,
"delay": 0,
"allow_in_bindings": 0
}
]
}
],
"location": [
[
{
"param": "block",
"operator": "==",
"value": "acf/team-grid"
}
]
],
"menu_order": 0,
"position": "normal",
"style": "default",
"label_placement": "top",
"instruction_placement": "label",
"hide_on_screen": "",
"active": true,
"description": "Team grid block. Each member is a card with photo, name, title, and a collapsible bio.",
"show_in_rest": 0,
"display_title": "",
"allow_ai_access": false,
"ai_description": "",
"modified": 1753401600
}
```
- [ ] **Step 2: Verify the JSON parses**
Run: `node -e "JSON.parse(require('fs').readFileSync('acf/group_team_grid.json','utf8')); console.log('OK')"`
Expected: prints `OK` and exits 0.
- [ ] **Step 3: Commit**
```bash
cd "C:/Users/ksolo/Herd/community-works-collaborative/wp-content/themes/community-works-collaborative"
git add acf/group_team_grid.json
git commit -m "✨ feat: Add ACF field group for team-grid block"
```
---
## Task 2: Block Registration JSON
**Files:**
- Create: `views/blocks/team-grid/block.json`
**Interfaces:**
- Produces: `block.json` declaring `acf/team-grid` as the block name, `team-grid.php` as the render template, `groups` icon, `common` category. `regACFBlocks()` in `functions.php` will pick this up automatically.
- [ ] **Step 1: Create the block.json file**
Create the file `views/blocks/team-grid/block.json` with the following exact content:
```json
{
"name": "acf/team-grid",
"title": "Team Grid",
"description": "A responsive grid of team members. Click a card to expand the bio.",
"category": "common",
"icon": "groups",
"keywords": ["team", "people", "staff", "bio"],
"acf": {
"mode": "preview",
"renderTemplate": "team-grid.php"
},
"supports": {
"anchor": true,
"align": false
}
}
```
- [ ] **Step 2: Verify the JSON parses**
Run: `node -e "JSON.parse(require('fs').readFileSync('views/blocks/team-grid/block.json','utf8')); console.log('OK')"`
Expected: prints `OK` and exits 0.
- [ ] **Step 3: Commit**
```bash
cd "C:/Users/ksolo/Herd/community-works-collaborative/wp-content/themes/community-works-collaborative"
git add views/blocks/team-grid/block.json
git commit -m "✨ feat: Add team-grid block.json registration"
```
---
## Task 3: Block CSS
**Files:**
- Create: `views/blocks/team-grid/team-grid.css`
**Interfaces:**
- Produces: BEM-styled CSS for `.team-grid`, `__inner`, `__card`, `__details`, `__summary`, `__photo`, `__name`, `__title`, `__bio-wrap`, `__bio-inner`, `__bio`. Reuses `text-cwc-blue-01` for the name. Implements the `grid-template-rows: 0fr → 1fr` animation for the bio.
- [ ] **Step 1: Create the block CSS file**
Create the file `views/blocks/team-grid/team-grid.css` with the following exact content:
```css
/* Team Grid block */
.team-grid {
&__card {
@apply flex flex-col;
}
&__details {
@apply flex flex-col;
}
&__summary {
@apply flex flex-col items-center text-center cursor-pointer list-none;
&::-webkit-details-marker {
display: none;
}
&::marker {
display: none;
content: '';
}
}
&__photo {
@apply w-32 h-32 sm:w-40 sm:h-40 lg:w-48 lg:h-48 object-cover rounded-md mb-4;
}
&__name {
@apply text-cwc-blue-01 text-22px font-bold leading-tight mb-1;
}
&__title {
@apply text-gray-600 text-16px font-light italic mb-3;
}
&__bio-wrap {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 300ms ease;
}
&__details[open] &__bio-wrap {
grid-template-rows: 1fr;
}
&__bio-inner {
overflow: hidden;
min-height: 0;
}
&__bio {
@apply text-gray-700 text-16px font-light leading-snug pt-2;
}
}
```
- [ ] **Step 2: Verify the BEM children are present**
Run: `grep -cE "&__(card|details|summary|photo|name|title|bio-wrap|bio-inner|bio)\b" views/blocks/team-grid/team-grid.css`
Expected: `9` (one match per BEM child).
- [ ] **Step 3: Commit**
```bash
cd "C:/Users/ksolo/Herd/community-works-collaborative/wp-content/themes/community-works-collaborative"
git add views/blocks/team-grid/team-grid.css
git commit -m "✨ feat: Add team-grid block CSS"
```
---
## Task 4: Block Template (PHP)
**Files:**
- Create: `views/blocks/team-grid/team-grid.php`
**Interfaces:**
- Consumes: `get_field( 'members' )` → array of members each with `image` (array: `url`, `alt`), `name` (string), `title` (string), `bio` (wysiwyg HTML).
- Produces: A `<section class="team-grid ...">` element with a `__inner` grid div, and an `<article class="team-grid__card">` per member. Each card contains a `<details>` with a `<summary>` (photo + name + title) and a `__bio-wrap` containing the bio.
- [ ] **Step 1: Create the block PHP template**
Create the file `views/blocks/team-grid/team-grid.php` with the following exact content (tabs for indentation):
```php
<?php
/**
* Block Name: Team Grid
*
* A responsive grid of team members. Each card uses a native <details>
* element so the bio expands when the photo, name, or title is clicked.
*
* @package CWC
*/
namespace CWC;
$members = get_field( 'members' );
if ( empty( $members ) ) {
return;
}
$classes = 'team-grid container my-section mx-auto';
$wrapper = blockWrapperAttributes( $classes, $is_preview );
?>
<section <?php echo wp_kses_post( $wrapper ); ?>>
<div class="team-grid__inner grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8 lg:gap-12">
<?php foreach ( $members as $member ) : ?>
<article class="team-grid__card">
<details class="team-grid__details">
<summary class="team-grid__summary">
<?php if ( ! empty( $member['image'] ) ) : ?>
<img
class="team-grid__photo"
src="<?php echo esc_url( $member['image']['url'] ); ?>"
alt="<?php echo esc_attr( $member['image']['alt'] ?? '' ); ?>"
loading="lazy"
/>
<?php endif; ?>
<?php if ( ! empty( $member['name'] ) ) : ?>
<h3 class="team-grid__name">
<?php echo wp_kses_post( $member['name'] ); ?>
</h3>
<?php endif; ?>
<?php if ( ! empty( $member['title'] ) ) : ?>
<p class="team-grid__title">
<?php echo wp_kses_post( $member['title'] ); ?>
</p>
<?php endif; ?>
</summary>
<div class="team-grid__bio-wrap">
<div class="team-grid__bio-inner">
<?php if ( ! empty( $member['bio'] ) ) : ?>
<div class="team-grid__bio">
<?php echo wp_kses_post( $member['bio'] ); ?>
</div>
<?php endif; ?>
</div>
</div>
</details>
</article>
<?php endforeach; ?>
</div>
</section>
```
- [ ] **Step 2: Lint the PHP file with PHPCS**
Run: `composer exec phpcs -- views/blocks/team-grid/team-grid.php`
Expected: exits 0 with no errors. If PHPCS reports alignment warnings on the assignment block (similar to the Task 5 services-list fix), run `composer exec phpcbf -- views/blocks/team-grid/team-grid.php` to auto-fix, then re-run PHPCS to confirm exit 0. Whitespace-only changes are acceptable; do not modify logic.
- [ ] **Step 3: Verify the dynamic utility classes are in the source**
Run: `grep -E "grid-cols-1|sm:grid-cols-2|lg:grid-cols-3|w-32|w-40|w-48" views/blocks/team-grid/team-grid.php`
Expected: at least 3 matches (Tailwind v4 must see the literal class names in the source to emit the rules).
- [ ] **Step 4: Commit**
```bash
cd "C:/Users/ksolo/Herd/community-works-collaborative/wp-content/themes/community-works-collaborative"
git add views/blocks/team-grid/team-grid.php
git commit -m "✨ feat: Add team-grid block PHP template"
```
---
## Task 5: CSS Import Wiring
**Files:**
- Modify: `styles/blocks/index.css`
**Interfaces:**
- Consumes: `views/blocks/team-grid/team-grid.css` (from Task 3).
- Produces: A new `@import` line at the end of `styles/blocks/index.css` so Tailwind's build picks up the block's CSS.
- [ ] **Step 1: Add the import to the block index CSS**
Append the following line at the end of `styles/blocks/index.css`:
```css
@import '../../views/blocks/team-grid/team-grid.css';
```
After the edit, the file should end with:
```
@import '../../views/blocks/services-list/services-list.css';
@import '../../views/blocks/team-grid/team-grid.css';
```
- [ ] **Step 2: Verify the import is present**
Run: `grep -cF "team-grid/team-grid.css" styles/blocks/index.css`
Expected: `1`.
- [ ] **Step 3: Commit**
```bash
cd "C:/Users/ksolo/Herd/community-works-collaborative/wp-content/themes/community-works-collaborative"
git add styles/blocks/index.css
git commit -m "🔧 chore: Import team-grid block CSS into theme"
```
---
## Task 6: Build the CSS
**Files:**
- Modify: `static/dist/theme.css`
**Interfaces:**
- Consumes: The new import in `styles/blocks/index.css` (from Task 5) and the block CSS (from Task 3).
- Produces: A rebuilt `static/dist/theme.css` that includes the `.team-grid*` rules and the responsive `grid-cols-1 sm:grid-cols-2 lg:grid-cols-3` utilities.
- [ ] **Step 1: Run the production Tailwind build**
Run: `npm run build`
Expected: completes with no errors.
- [ ] **Step 2: Verify the team-grid CSS classes are in the built output**
Tailwind v4 with `--optimize` **preserves** CSS-nesting rather than unwrapping `&__name` into `__name.team-grid`. Use fixed-string search (Windows-safe) that matches the preserved form:
```bash
grep -cF "&__name" static/dist/theme.css
```
Expected: `1` or more (the `&__name` rule is nested inside `.team-grid { ... }` in the dist).
- [ ] **Step 3: Verify the responsive grid utilities are in the built output**
Same Windows-safe form:
```bash
grep -cF "sm\:grid-cols-2" static/dist/theme.css
grep -cF "lg\:grid-cols-3" static/dist/theme.css
```
Expected: each returns `1` or more.
- [ ] **Step 4: Commit the rebuilt dist**
```bash
cd "C:/Users/ksolo/Herd/community-works-collaborative/wp-content/themes/community-works-collaborative"
git add -f static/dist/theme.css
git commit -m "🎨 build: Rebuild dist with team-grid block styles"
```
---
## Task 7: Playwright Tests
**Files:**
- Create: `tests/team-page.spec.js`
**Interfaces:**
- Consumes: The rendered `views/blocks/team-grid/team-grid.php` template (Tasks 3 + 4) on the WordPress page `/team/`.
- Produces: Three tests:
1. Photo, name, and title visible for the first card.
2. Bio is closed by default; clicking the summary opens the details and reveals the bio; clicking again closes it.
3. No axe-core a11y violations scoped to `.team-grid`.
> **Prerequisite:** the test depends on the Team page being composed in Gutenberg with at least one `Team Grid` block containing one or more members. That composition is the editor's responsibility and is not part of this plan. The tests should be authored against the intended state, not a placeholder state.
- [ ] **Step 1: Create the test spec file**
Create the file `tests/team-page.spec.js` with the following exact content:
```js
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
test.describe("Team Grid block on /team/", () => {
test("renders the photo, name, and title for each member", async ({ page }) => {
await page.goto("/team/");
const cards = page.locator(".team-grid__card");
await expect(cards.first()).toBeVisible();
const firstCard = cards.first();
await expect(firstCard.locator(".team-grid__photo")).toBeVisible();
await expect(firstCard.locator(".team-grid__name")).toBeVisible();
await expect(firstCard.locator(".team-grid__title")).toBeVisible();
});
test("bio is hidden by default and expands on summary click", async ({ page }) => {
await page.goto("/team/");
const firstDetails = page.locator(".team-grid__details").first();
// Bio is closed by default.
await expect(firstDetails).not.toHaveAttribute("open", "");
// Click the summary; the details should open and the bio should be visible.
await firstDetails.locator(".team-grid__summary").click();
await expect(firstDetails).toHaveAttribute("open", "");
await expect(firstDetails.locator(".team-grid__bio")).toBeVisible();
// Click again; the details should close.
await firstDetails.locator(".team-grid__summary").click();
await expect(firstDetails).not.toHaveAttribute("open", "");
});
test("no axe violations on the team-grid block", async ({ page }) => {
await page.goto("/team/");
const results = await new AxeBuilder({ page })
.include(".team-grid")
.analyze();
expect(results.violations).toEqual([]);
});
});
```
- [ ] **Step 2: Run the tests**
Run: `npx playwright test tests/team-page.spec.js`
Expected: all 3 tests pass. If tests fail because the Team page has not yet been composed in the editor, that's an expected prerequisite — note the prerequisite in the final report and skip the test run.
- [ ] **Step 3: Commit**
```bash
cd "C:/Users/ksolo/Herd/community-works-collaborative/wp-content/themes/community-works-collaborative"
git add tests/team-page.spec.js
git commit -m "✅ test: Add Playwright tests for team-grid block"
```
---
## Self-Review Notes
- **Spec coverage:**
- One new ACF block (`team-grid`) — covered by Tasks 1, 2, 3, 4.
- Repeater of `members` with `image` / `name` / `title` / `bio` — covered by Task 1.
- Responsive grid `grid-cols-1 sm:grid-cols-2 lg:grid-cols-3` — covered by Task 4 (PHP) and Task 6 (Tailwind emits the rules).
- Rounded-rectangle headshots via `rounded-md` — covered by Task 3 (CSS).
- Native `<details>`/`<summary>` click-to-expand — covered by Task 4 (PHP) and Task 3 (CSS).
- `grid-template-rows: 0fr → 1fr` animation — covered by Task 3 (CSS).
- No built-in background — explicitly out of scope (Section block wrapper, editor's responsibility).
- Page template not modified — confirmed; `page.php` is not in any file list.
- `header.php` / `footer.php` / page hero not touched — confirmed.
- Reuse existing design tokens (`text-cwc-blue-01`, `rounded-md`) — covered by Tasks 1, 3, 4.
- Playwright tests covering photo + name + title, summary click toggles, and a11y — covered by Task 7.
- Dist rebuild — covered by Task 6.
- **Placeholder scan:** No TBDs. Every step has a concrete file path, exact content, exact command, and expected output. The only conditional is in Task 7 Step 2 — explicitly called out as a prerequisite (editor must compose the page in Gutenberg), not a placeholder.
- **Type consistency:** ACF field `name: "members"` is used in Task 4 (`get_field( 'members' )`). Sub-field names `image`, `name`, `title`, `bio` are used in Task 1 (defined) and Task 4 (consumed). Class names `team-grid`, `__inner`, `__card`, `__details`, `__summary`, `__photo`, `__name`, `__title`, `__bio-wrap`, `__bio-inner`, `__bio` are used in Task 3 (defined in CSS) and Task 4 (rendered in markup) and Task 7 (asserted in tests) — consistent.
- **Ambiguity check:** "Rounded rectangle" is concretely specified as `rounded-md`. "Opens vertically" is concretely specified as `grid-template-rows: 0fr → 1fr` with 300ms transition. "Responsive grid" is concretely specified as `grid-cols-1 sm:grid-cols-2 lg:grid-cols-3`. "No built-in background" is concretely scoped to the Section block wrapper (editor's responsibility, not this block's). All are explicit.
@@ -0,0 +1,332 @@
# Team Grid Block Design
> **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:** Build a new `team-grid` ACF block for the Team page (and any other page that needs a team grid) that renders a responsive grid of team members. Each member shows their photo (rounded rectangle), name, and title. The bio is collapsed by default and expands vertically with a smooth animation when the photo, name, or title is clicked.
**Architecture:** One new ACF block (`acf/team-grid`) with a single repeater of `members` (image, name, title, bio). The block uses native HTML `<details>`/`<summary>` for the click-to-expand interaction, animated with the `grid-template-rows: 0fr → 1fr` CSS pattern (no JavaScript). The block has no built-in background — the editor wraps it in an existing Section block when a dark/light background is needed. The page template is unchanged; the editor adds the block to the Team page in Gutenberg.
**Tech Stack:** WordPress 6.x, PHP 8.x, Tailwind CSS v4, Playwright, axe-core, PHPCS (WordPress standard).
## Global Constraints
- Tabs for PHP indentation (project standard).
- `static/dist/theme.css` is committed (not gitignored) per project convention.
- PHPCS uses the WordPress coding standard; `composer lint` must pass for all touched files.
- All design colors use the project's existing CSS custom properties from `styles/base/colors.css`. No hardcoded hex values for theme colors.
- Reuse existing design tokens where possible (e.g. the `text-cwc-blue-01` heading color, `var(--color-secondary)` accent if needed).
- The headshot uses `rounded-md` to match other image cards in the project (`post-list`, `services-list`).
- All commits follow the project's emoji-prefixed commit-message style (e.g. `✨ feat: …`).
- This work does **not** edit `header.php`, `footer.php`, `views/partials/page-hero.php`, or `views/partials/page-hero-services.php`.
- `page.php` is not modified.
- No JavaScript files are added. The `<details>`/`<summary>` interaction is fully native.
- Reuse the same `regACFBlocks()` auto-registration pattern as `services-list`.
## Reference Mockup
`team-example.png` shows a dark-blue section with 8 team members in a 2-column grid. Each member has a circular headshot, name (bold), title (italic), and a multi-line bio below. All bios are visible in the mockup.
**Intent of the design (per the user):**
- The bio should be a `<details>` element that **opens vertically** when the photo, name, or title is clicked. The mockup shows the open state for every member for context; the live behavior is collapsed by default.
- The headshot should be a **rectangle with rounded corners that match other images in the project**`rounded-md` is the project standard. The mockup's circular headshots are a visual approximation; the live design uses rounded rectangles.
- The block has no built-in background. The editor wraps it in a Section block (`acf/section`) when a dark or light background is needed.
- Layout is responsive: 1 column on mobile, 2 on tablet, 3 on desktop (the user's "3-2-1" choice).
## File Structure
| File | Responsibility | Created/Modified |
| --- | --- | --- |
| `acf/group_team_grid.json` | ACF field group for the `acf/team-grid` block. | Create |
| `views/blocks/team-grid/team-grid.php` | Block template (PHP) — renders the grid. | Create |
| `views/blocks/team-grid/team-grid.css` | Block styles (CSS) — layout, photo, summary, animation. | Create |
| `views/blocks/team-grid/block.json` | Block registration metadata. | Create |
| `styles/blocks/index.css` | Import the new block's CSS. | Modify |
| `tests/team-page.spec.js` | Playwright tests for the rendered block. | Create |
| `static/dist/theme.css` | Rebuilt via `npm run build`. | Modified |
---
## Design Details
### 1. ACF field group: `acf/team-grid`
**Block name (registration key):** `acf/team-grid`
**Editor title:** "Team Grid"
**Category:** common
**Description (in block.json):** "A responsive grid of team members. Click a card to expand the bio."
#### ACF field group (`acf/group_team_grid.json`)
```
Group key: group_team_grid
Group title: Team Grid
Position: normal
Menu order: 0
Location: block == acf/team-grid
```
Fields:
- `members` (repeater, layout: block, button label: "Add Team Member")
- `image` (image, return_format: array, required: 0) — Headshot.
- `name` (text, required: 0) — Person's name (e.g. "Christopher Werner").
- `title` (text, required: 0) — Person's role (e.g. "Principal Consultant & CEO").
- `bio` (wysiwyg, tabs: visual, toolbar: basic, media_upload: 0, required: 0) — Bio body copy.
The block is intentionally minimal. No per-card size, no global layout toggle, no CTA field, no group-level background color. Layout derives from CSS (`grid-cols-1 sm:grid-cols-2 lg:grid-cols-3`).
#### Block registration (`views/blocks/team-grid/block.json`)
```json
{
"name": "acf/team-grid",
"title": "Team Grid",
"description": "A responsive grid of team members. Click a card to expand the bio.",
"category": "common",
"icon": "groups",
"keywords": ["team", "people", "staff", "bio"],
"acf": {
"mode": "preview",
"renderTemplate": "team-grid.php"
},
"supports": {
"anchor": true,
"align": false
}
}
```
The `icon: "groups"` is a WordPress Dashicon that visually represents a group of people.
#### Block template (`views/blocks/team-grid/team-grid.php`)
```php
<?php
/**
* Block Name: Team Grid
*
* A responsive grid of team members. Each card uses a native <details>
* element so the bio expands when the photo, name, or title is clicked.
*
* @package CWC
*/
namespace CWC;
$members = get_field( 'members' );
if ( empty( $members ) ) {
return;
}
$classes = 'team-grid container my-section mx-auto';
$wrapper = blockWrapperAttributes( $classes, $is_preview );
?>
<section <?php echo wp_kses_post( $wrapper ); ?>>
<div class="team-grid__inner grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8 lg:gap-12">
<?php foreach ( $members as $member ) : ?>
<article class="team-grid__card">
<details class="team-grid__details">
<summary class="team-grid__summary">
<?php if ( ! empty( $member['image'] ) ) : ?>
<img
class="team-grid__photo"
src="<?php echo esc_url( $member['image']['url'] ); ?>"
alt="<?php echo esc_attr( $member['image']['alt'] ?? '' ); ?>"
loading="lazy"
/>
<?php endif; ?>
<?php if ( ! empty( $member['name'] ) ) : ?>
<h3 class="team-grid__name">
<?php echo wp_kses_post( $member['name'] ); ?>
</h3>
<?php endif; ?>
<?php if ( ! empty( $member['title'] ) ) : ?>
<p class="team-grid__title">
<?php echo wp_kses_post( $member['title'] ); ?>
</p>
<?php endif; ?>
</summary>
<div class="team-grid__bio-wrap">
<div class="team-grid__bio-inner">
<?php if ( ! empty( $member['bio'] ) ) : ?>
<div class="team-grid__bio">
<?php echo wp_kses_post( $member['bio'] ); ?>
</div>
<?php endif; ?>
</div>
</div>
</details>
</article>
<?php endforeach; ?>
</div>
</section>
```
Notes:
- Native `<details>`/`<summary>`: the summary is the click target (photo + name + title); the body is the bio wrap.
- The `<details>` element is closed by default — no `open` attribute. The user opens it on click.
- The bio uses `wysiwyg` so editors can add links or basic formatting. Output is sanitized via `wp_kses_post`.
- `team-grid__bio-wrap` is a 1-row grid container that animates `grid-template-rows` from `0fr` to `1fr`. The `__bio-inner` inside has `overflow: hidden; min-height: 0` so the bio is clipped during the transition.
#### Block styles (`views/blocks/team-grid/team-grid.css`)
```css
/* Team Grid block */
.team-grid {
&__card {
@apply flex flex-col;
}
&__details {
@apply flex flex-col;
}
/* summary is the click target. Strip the default disclosure triangle. */
&__summary {
@apply flex flex-col items-center text-center cursor-pointer list-none;
&::-webkit-details-marker {
display: none;
}
&::marker {
display: none;
content: '';
}
}
&__photo {
@apply w-32 h-32 sm:w-40 sm:h-40 lg:w-48 lg:h-48 object-cover rounded-md mb-4;
}
&__name {
@apply text-cwc-blue-01 text-22px font-bold leading-tight mb-1;
}
&__title {
@apply text-gray-600 text-16px font-light italic mb-3;
}
/* Animation: animate grid-template-rows 0fr -> 1fr. */
&__bio-wrap {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 300ms ease;
}
&__details[open] &__bio-wrap {
grid-template-rows: 1fr;
}
&__bio-inner {
overflow: hidden;
min-height: 0;
}
&__bio {
@apply text-gray-700 text-16px font-light leading-snug pt-2;
}
}
```
Notes:
- The summary is a flex column with `items-center` so the photo, name, and title stack centered. This matches the mockup.
- The default `<summary>` marker (▶ triangle) is hidden with `list-style: none` plus vendor-prefixed `::-webkit-details-marker` and `::marker` reset.
- The `&__details[open] &__bio-wrap` selector uses CSS nesting to target the bio wrap when the parent details is open. This compiles to `.team-grid__details[open] .team-grid__bio-wrap { grid-template-rows: 1fr; }`.
- The `team-grid__details[open] &__bio-wrap` syntax uses CSS nesting: `&` inside `&__details[open]` is the previous compound selector. Tailwind v4 (lightningcss) unwraps this correctly.
- Photo sizes: `w-32 h-32` (8rem / 128px) on mobile, `w-40 h-40` (10rem / 160px) on small, `w-48 h-48` (12rem / 192px) on large.
### 2. CSS import wiring (`styles/blocks/index.css`)
Append at the end of `styles/blocks/index.css`:
```css
@import '../../views/blocks/team-grid/team-grid.css';
```
After the edit, the file ends with:
```
@import '../../views/blocks/services-list/services-list.css';
@import '../../views/blocks/team-grid/team-grid.css';
```
### 3. Page integration
`page.php` is unchanged. The editor adds a `Team Grid` block to the Team page (`/team/`) in Gutenberg. To get the dark-blue background, the editor wraps the `Team Grid` block in a `Section` block (`acf/section`) and sets the Section's `background_color` to the dark blue.
No PHP template change is required.
### 4. CSS Build
Run `npm run build` to compile Tailwind v4 → `static/dist/theme.css`. The new utilities (e.g. `text-22px`, `text-16px`, `w-32 h-32`, `rounded-md`) must appear in the dist.
### 5. Tests (`tests/team-page.spec.js`)
```js
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
test.describe("Team Grid block on /team/", () => {
test("renders the photo, name, and title for each member", async ({ page }) => {
await page.goto("/team/");
const cards = page.locator(".team-grid__card");
await expect(cards.first()).toBeVisible();
const firstCard = cards.first();
await expect(firstCard.locator(".team-grid__photo")).toBeVisible();
await expect(firstCard.locator(".team-grid__name")).toBeVisible();
await expect(firstCard.locator(".team-grid__title")).toBeVisible();
});
test("bio is hidden by default and expands on summary click", async ({ page }) => {
await page.goto("/team/");
const firstDetails = page.locator(".team-grid__details").first();
// Bio is closed by default.
await expect(firstDetails).not.toHaveAttribute("open", "");
// Click the summary; the details should open and the bio should be visible.
await firstDetails.locator(".team-grid__summary").click();
await expect(firstDetails).toHaveAttribute("open", "");
await expect(firstDetails.locator(".team-grid__bio")).toBeVisible();
// Click again; the details should close.
await firstDetails.locator(".team-grid__summary").click();
await expect(firstDetails).not.toHaveAttribute("open", "");
});
test("no axe violations on the team-grid block", async ({ page }) => {
await page.goto("/team/");
const results = await new AxeBuilder({ page })
.include(".team-grid")
.analyze();
expect(results.violations).toEqual([]);
});
});
```
### Out of Scope
- The Team page template — `page.php` is unchanged. The editor adds the block in Gutenberg.
- The Section block wrapper for the dark-blue background — the editor composes this in Gutenberg.
- A custom open/close icon or arrow indicator — the click target is the entire summary (photo + name + title), so no chevron is needed.
- Animations beyond the bio's expand/collapse transition. No hover transforms, no entry animations.
- Per-card size variants — all cards are the same size.
- A `data-team-member-id` slug for deep-linking to a specific member's expanded bio.
- Dark-mode overrides — the block is color-agnostic and inherits the parent Section's color scheme.
- Per-page header/footer changes.
## Self-Review Notes
- **Spec coverage:** One new ACF block (4 files), one CSS import, dist rebuild, one test file. Every spec section is covered.
- **Placeholder scan:** No TBDs. Every step has a concrete file path, exact content, exact command, and expected output.
- **Type consistency:** Class names use the existing BEM convention (`__card`, `__details`, `__summary`, `__photo`, `__name`, `__title`, `__bio-wrap`, `__bio-inner`, `__bio`). Field names (`members`, `image`, `name`, `title`, `bio`) are used in the JSON, the PHP, and the tests.
- **Scope check:** Single focused build, no decomposition needed. Block is small (≤4 files), testable in isolation, and composable on the Team page (and any other page that needs a team grid).
- **Ambiguity check:** The "rounded rectangle" headshot is concretely specified as `rounded-md`. The "open vertically" animation is concretely specified as `grid-template-rows: 0fr → 1fr` with a 300ms transition. The "3-2-1" responsive grid is concretely specified. The "no background" decision is concretely scoped to the Section block wrapper (editor's responsibility, not this block's).
+1
View File
@@ -154,6 +154,7 @@ add_filter(
unset( $blocks['core/calendar'] );
unset( $blocks['core/buttons'] );
unset( $blocks['core/search'] );
unset( $blocks['core/pullquote'] );
// return the new list of allowed blocks
return array_keys( $blocks );
+146 -3
View File
@@ -8,6 +8,7 @@
--color-gray-100: oklch(0.967 0.003 264.542);
--color-gray-500: oklch(0.551 0.027 264.364);
--color-gray-600: oklch(0.446 0.03 256.802);
--color-gray-700: oklch(0.373 0.034 259.733);
--color-gray-800: oklch(0.278 0.033 256.848);
--color-black: oklch(0% 0 0);
--color-white: oklch(100% 0 0);
@@ -550,6 +551,15 @@
.h-5 {
height: calc(var(--spacing) * 5);
}
.h-32 {
height: calc(var(--spacing) * 32);
}
.h-40 {
height: calc(var(--spacing) * 40);
}
.h-48 {
height: calc(var(--spacing) * 48);
}
.h-auto {
height: auto;
}
@@ -568,6 +578,15 @@
.w-5 {
width: calc(var(--spacing) * 5);
}
.w-32 {
width: calc(var(--spacing) * 32);
}
.w-40 {
width: calc(var(--spacing) * 40);
}
.w-48 {
width: calc(var(--spacing) * 48);
}
.w-auto {
width: auto;
}
@@ -631,6 +650,9 @@
.cursor-default {
cursor: default;
}
.cursor-pointer {
cursor: pointer;
}
.resize {
resize: both;
}
@@ -1073,6 +1095,9 @@
.text-gray-600 {
color: var(--color-gray-600);
}
.text-gray-700 {
color: var(--color-gray-700);
}
.text-gray-800 {
color: var(--color-gray-800);
}
@@ -1091,6 +1116,9 @@
.uppercase {
text-transform: uppercase;
}
.italic {
font-style: italic;
}
.underline {
text-decoration-line: underline;
}
@@ -1238,6 +1266,16 @@
margin-top: calc(var(--spacing) * 6);
}
}
.sm\:h-40 {
@media (width >= 40rem) {
height: calc(var(--spacing) * 40);
}
}
.sm\:w-40 {
@media (width >= 40rem) {
width: calc(var(--spacing) * 40);
}
}
.sm\:grid-cols-1 {
@media (width >= 40rem) {
grid-template-columns: repeat(1, minmax(0, 1fr));
@@ -1464,6 +1502,16 @@
display: none;
}
}
.lg\:h-48 {
@media (width >= 64rem) {
height: calc(var(--spacing) * 48);
}
}
.lg\:w-48 {
@media (width >= 64rem) {
width: calc(var(--spacing) * 48);
}
}
.lg\:grid-cols-1 {
@media (width >= 64rem) {
grid-template-columns: repeat(1, minmax(0, 1fr));
@@ -5679,10 +5727,10 @@ a.contact-info__value:hover {
position: absolute;
inset-inline: 0;
top: var(--services-line-top);
height: 32px;
height: 16px;
pointer-events: none;
z-index: 1;
background-image: url('/wp-content/themes/community-works-collaborative/static/img/services-list-line.svg');
background-image: url('../img/services-list-line.svg');
background-repeat: no-repeat;
background-position: center center;
background-size: 100% 100%;
@@ -5708,7 +5756,7 @@ a.contact-info__value:hover {
.services-list__heading {
margin-top: calc(var(--spacing) * 6);
margin-bottom: calc(var(--spacing) * 4);
font-size: var(--text-24px);
font-size: var(--text-32px);
--tw-leading: 1;
line-height: 1;
--tw-font-weight: var(--font-weight-black);
@@ -5737,6 +5785,101 @@ a.contact-info__value:hover {
display: none;
}
}
.team-grid {
margin-block: calc(var(--spacing) * 12);
.team-grid__card {
display: flex;
flex-direction: column;
}
.team-grid__details {
display: flex;
flex-direction: column;
}
.team-grid__summary {
display: flex;
cursor: pointer;
list-style-type: none;
flex-direction: column;
align-items: center;
text-align: center;
&::-webkit-details-marker {
display: none;
}
&::marker {
display: none;
content: '';
}
&:hover {
.team-grid__read-bio::after {
content: url("data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20transform%3D%22rotate(0%200%200)%22%3E%3Cpath%20d%3D%22M6.28033%2010.5947C5.98744%2010.3018%205.51256%2010.3018%205.21967%2010.5947C4.92678%2010.8876%204.92678%2011.3624%205.21967%2011.6553L11.4697%2017.9053C11.7626%2018.1982%2012.2374%2018.1982%2012.5303%2017.9053L18.7803%2011.6553C19.0732%2011.3624%2019.0732%2010.8876%2018.7803%2010.5947C18.4874%2010.3018%2018.0126%2010.3018%2017.7197%2010.5947L12%2016.3143L6.28033%2010.5947Z%22%20fill%3D%22%23006196%22%2F%3E%3Cpath%20opacity%3D%221%22%20d%3D%22M6.28033%206.09467C5.98744%205.80178%205.51256%205.80178%205.21967%206.09467C4.92678%206.38756%204.92678%206.86244%205.21967%207.15533L11.4697%2013.4053C11.7626%2013.6982%2012.2374%2018.1982%2012.5303%2013.4053L18.7803%207.15533C19.0732%206.86244%2019.0732%206.38756%2018.7803%206.09467C18.4874%205.80178%2018.0126%205.80178%2017.7197%206.09467L12%2011.8143L6.28033%206.09467Z%22%20fill%3D%22%23006196%22%2F%3E%3C%2Fsvg%3E");
width: 1.25rem;
height: 1.25rem;
}
}
}
.team-grid__photo {
margin-bottom: calc(var(--spacing) * 4);
height: 100%;
width: 100%;
border-radius: var(--radius-md);
object-fit: cover;
}
.team-grid__name {
margin-bottom: calc(var(--spacing) * 1);
font-size: var(--text-22px);
--tw-leading: var(--leading-tight);
line-height: var(--leading-tight);
--tw-font-weight: var(--font-weight-bold);
font-weight: var(--font-weight-bold);
color: var(--color-cwc-blue-01);
}
.team-grid__title {
margin-bottom: calc(var(--spacing) * 3);
font-size: var(--text-16px);
--tw-font-weight: var(--font-weight-normal);
font-weight: var(--font-weight-normal);
color: var(--color-cwc-blue-01);
font-style: italic;
}
.team-grid__read-bio {
margin-bottom: calc(var(--spacing) * 1);
display: flex;
align-items: center;
justify-content: center;
gap: calc(var(--spacing) * 2);
font-size: var(--text-16px);
--tw-leading: var(--leading-snug);
line-height: var(--leading-snug);
--tw-font-weight: var(--font-weight-bold);
font-weight: var(--font-weight-bold);
color: var(--color-cwc-blue-01);
transition: transform 300ms ease;
&::after {
content: url("data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20transform%3D%22rotate(0%200%200)%22%3E%3Cpath%20d%3D%22M6.28033%2010.5947C5.98744%2010.3018%205.51256%2010.3018%205.21967%2010.5947C4.92678%2010.8876%204.92678%2011.3624%205.21967%2011.6553L11.4697%2017.9053C11.7626%2018.1982%2012.2374%2018.1982%2012.5303%2017.9053L18.7803%2011.6553C19.0732%2011.3624%2019.0732%2010.8876%2018.7803%2010.5947C18.4874%2010.3018%2018.0126%2010.3018%2017.7197%2010.5947L12%2016.3143L6.28033%2010.5947Z%22%20fill%3D%22%23006196%22%2F%3E%3Cpath%20opacity%3D%220.5%22%20d%3D%22M6.28033%206.09467C5.98744%205.80178%205.51256%205.80178%205.21967%206.09467C4.92678%206.38756%204.92678%206.86244%205.21967%207.15533L11.4697%2013.4053C11.7626%2013.6982%2012.2374%2018.1982%2012.5303%2013.4053L18.7803%207.15533C19.0732%206.86244%2019.0732%206.38756%2018.7803%206.09467C18.4874%205.80178%2018.0126%205.80178%2017.7197%206.09467L12%2011.8143L6.28033%206.09467Z%22%20fill%3D%22%23006196%22%2F%3E%3C%2Fsvg%3E");
width: 1.25rem;
height: 1.25rem;
transition: transform 300ms ease;
}
}
.team-grid__details[open] .team-grid__read-bio::after {
transform: rotate(180deg);
}
.team-grid__bio-wrap {
max-height: 0;
overflow: hidden;
transition: max-height 300ms ease;
}
.team-grid__bio {
padding-top: calc(var(--spacing) * 2);
text-align: center;
font-size: var(--text-16px);
--tw-leading: var(--leading-snug);
line-height: var(--leading-snug);
--tw-font-weight: var(--font-weight-light);
font-weight: var(--font-weight-light);
text-wrap: balance;
}
}
@property --tw-rotate-x {
syntax: "*";
inherits: false;
+1
View File
@@ -6,3 +6,4 @@
@import './page-hero.css';
@import '../../views/blocks/contact-info/contact-info.css';
@import '../../views/blocks/services-list/services-list.css';
@import '../../views/blocks/team-grid/team-grid.css';
+41
View File
@@ -0,0 +1,41 @@
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
test.describe("Team Grid block on /team/", () => {
test("renders the photo, name, and title for each member", async ({ page }) => {
await page.goto("/team/");
const cards = page.locator(".team-grid__card");
await expect(cards.first()).toBeVisible();
const firstCard = cards.first();
await expect(firstCard.locator(".team-grid__photo")).toBeVisible();
await expect(firstCard.locator(".team-grid__name")).toBeVisible();
await expect(firstCard.locator(".team-grid__title")).toBeVisible();
});
test("bio is hidden by default and expands on summary click", async ({ page }) => {
await page.goto("/team/");
const firstDetails = page.locator(".team-grid__details").first();
// Bio is closed by default.
await expect(firstDetails).not.toHaveAttribute("open", "");
// Click the summary; the details should open and the bio should be visible.
await firstDetails.locator(".team-grid__summary").click();
await expect(firstDetails).toHaveAttribute("open", "");
await expect(firstDetails.locator(".team-grid__bio")).toBeVisible();
// Click again; the details should close.
await firstDetails.locator(".team-grid__summary").click();
await expect(firstDetails).not.toHaveAttribute("open", "");
});
test("no axe violations on the team-grid block", async ({ page }) => {
await page.goto("/team/");
const results = await new AxeBuilder({ page })
.include(".team-grid")
.analyze();
expect(results.violations).toEqual([]);
});
});
-1
View File
@@ -34,7 +34,6 @@
font-weight: 500;
line-height: 1;
text-align: center;
text-wrap: balance;
strong,
b {
+1 -1
View File
@@ -36,7 +36,7 @@ $wrapper = blockWrapperAttributes( $classes, $is_preview );
</div>
<div class="container wider pull-quote__content">
<div class="pull-quote__text">
<div class="pull-quote__text <?php if ( is_front_page() ) { echo 'text-balance'; } // phpcs:ignore ?>">
<?php echo wp_kses_post( $quote_text ); ?>
</div>
</div>
+3 -3
View File
@@ -9,10 +9,10 @@
position: absolute;
inset-inline: 0;
top: var(--services-line-top);
height: 32px;
height: 16px;
pointer-events: none;
z-index: 1;
background-image: url('/wp-content/themes/community-works-collaborative/static/img/services-list-line.svg');
background-image: url('../img/services-list-line.svg');
background-repeat: no-repeat;
background-position: center center;
background-size: 100% 100%;
@@ -36,7 +36,7 @@
}
.services-list__heading {
@apply text-cwc-blue-01 text-24px font-black leading-none mt-6 mb-4;
@apply text-cwc-blue-01 text-32px font-black leading-none mt-6 mb-4;
&::after {
background: var(--color-secondary);
+19
View File
@@ -0,0 +1,19 @@
{
"name": "acf/team-grid",
"title": "Team Grid",
"description": "A responsive grid of team members. Click a card to expand the bio.",
"category": "common",
"style": [
"file:./team-grid.css"
],
"icon": "groups",
"keywords": ["team", "people", "staff", "bio"],
"acf": {
"mode": "preview",
"renderTemplate": "team-grid.php"
},
"supports": {
"anchor": true,
"align": false
}
}
+66
View File
@@ -0,0 +1,66 @@
/* Team Grid block */
.team-grid {
@apply my-12;
.team-grid__card { @apply flex flex-col; }
.team-grid__details { @apply flex flex-col; }
.team-grid__summary {
@apply flex flex-col items-center text-center cursor-pointer list-none;
&::-webkit-details-marker {
display: none;
}
&::marker {
display: none;
content: '';
}
&:hover {
.team-grid__read-bio::after {
content: url("data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20transform%3D%22rotate(0%200%200)%22%3E%3Cpath%20d%3D%22M6.28033%2010.5947C5.98744%2010.3018%205.51256%2010.3018%205.21967%2010.5947C4.92678%2010.8876%204.92678%2011.3624%205.21967%2011.6553L11.4697%2017.9053C11.7626%2018.1982%2012.2374%2018.1982%2012.5303%2017.9053L18.7803%2011.6553C19.0732%2011.3624%2019.0732%2010.8876%2018.7803%2010.5947C18.4874%2010.3018%2018.0126%2010.3018%2017.7197%2010.5947L12%2016.3143L6.28033%2010.5947Z%22%20fill%3D%22%23006196%22%2F%3E%3Cpath%20opacity%3D%221%22%20d%3D%22M6.28033%206.09467C5.98744%205.80178%205.51256%205.80178%205.21967%206.09467C4.92678%206.38756%204.92678%206.86244%205.21967%207.15533L11.4697%2013.4053C11.7626%2013.6982%2012.2374%2018.1982%2012.5303%2013.4053L18.7803%207.15533C19.0732%206.86244%2019.0732%206.38756%2018.7803%206.09467C18.4874%205.80178%2018.0126%205.80178%2017.7197%206.09467L12%2011.8143L6.28033%206.09467Z%22%20fill%3D%22%23006196%22%2F%3E%3C%2Fsvg%3E");
width: 1.25rem;
height: 1.25rem;
}
}
}
.team-grid__photo {
@apply w-full h-full object-cover rounded-md mb-4;
}
.team-grid__name {
@apply text-cwc-blue-01 text-22px font-bold leading-tight mb-1;
}
.team-grid__title {
@apply text-cwc-blue-01 text-16px font-normal italic mb-3;
}
.team-grid__read-bio {
@apply flex gap-2 justify-center items-center text-cwc-blue-01 text-16px font-bold leading-snug mb-1;
transition: transform 300ms ease;
&::after {
content: url("data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20transform%3D%22rotate(0%200%200)%22%3E%3Cpath%20d%3D%22M6.28033%2010.5947C5.98744%2010.3018%205.51256%2010.3018%205.21967%2010.5947C4.92678%2010.8876%204.92678%2011.3624%205.21967%2011.6553L11.4697%2017.9053C11.7626%2018.1982%2012.2374%2018.1982%2012.5303%2017.9053L18.7803%2011.6553C19.0732%2011.3624%2019.0732%2010.8876%2018.7803%2010.5947C18.4874%2010.3018%2018.0126%2010.3018%2017.7197%2010.5947L12%2016.3143L6.28033%2010.5947Z%22%20fill%3D%22%23006196%22%2F%3E%3Cpath%20opacity%3D%220.5%22%20d%3D%22M6.28033%206.09467C5.98744%205.80178%205.51256%205.80178%205.21967%206.09467C4.92678%206.38756%204.92678%206.86244%205.21967%207.15533L11.4697%2013.4053C11.7626%2013.6982%2012.2374%2018.1982%2012.5303%2013.4053L18.7803%207.15533C19.0732%206.86244%2019.0732%206.38756%2018.7803%206.09467C18.4874%205.80178%2018.0126%205.80178%2017.7197%206.09467L12%2011.8143L6.28033%206.09467Z%22%20fill%3D%22%23006196%22%2F%3E%3C%2Fsvg%3E");
width: 1.25rem;
height: 1.25rem;
transition: transform 300ms ease;
}
}
.team-grid__details[open] .team-grid__read-bio::after {
transform: rotate(180deg);
}
.team-grid__bio-wrap {
max-height: 0;
overflow: hidden;
transition: max-height 300ms ease;
}
.team-grid__bio { @apply text-16px font-light leading-snug pt-2 text-center text-balance; }
}
+144
View File
@@ -0,0 +1,144 @@
/**
* Team Grid block — bio expand/collapse animation.
*
* Native <details>/<summary> handles accessibility (keyboard, screen
* reader), but its default toggle behavior hides the children of the
* details element synchronously when [open] is removed. That makes
* CSS transitions impossible on the close direction, because by the
* time the toggle event fires, the element is already hidden.
*
* This module intercepts the click on the summary, prevents the default
* toggle, animates max-height manually, and toggles [open] after the
* animation completes. The bio is always at a known state during the
* animation.
*
* The module is enqueued per-block by the team-grid PHP template. It
* auto-initializes on import: queries the DOM for any existing
* .team-grid__details elements and attaches a click handler to each.
*/
const DETAILS_SELECTOR = '.team-grid__details';
const BIO_WRAP_SELECTOR = '.team-grid__bio-wrap';
const DURATION_MS = 300;
const setMaxHeight = (el, value) => {
el.style.maxHeight = typeof value === 'number' ? `${value}px` : value;
};
const measureBioHeight = (bioWrap) => {
// Temporarily allow the inner content to render at its natural size
// so scrollHeight reports the real height rather than the clipped height.
const previousMax = bioWrap.style.maxHeight;
const previousOverflow = bioWrap.style.overflow;
setMaxHeight(bioWrap, 'none');
bioWrap.style.overflow = 'visible';
const height = bioWrap.scrollHeight;
bioWrap.style.overflow = previousOverflow;
setMaxHeight(bioWrap, previousMax || '');
return height;
};
const animateOpen = (bioWrap, onComplete) => {
const target = measureBioHeight(bioWrap);
setMaxHeight(bioWrap, '0px');
// Force a reflow so the browser registers the starting value before
// we change it to the target — otherwise the transition is skipped.
// eslint-disable-next-line no-unused-expressions
bioWrap.offsetHeight;
requestAnimationFrame(() => {
setMaxHeight(bioWrap, `${target}px`);
});
const onEnd = (event) => {
if (event.propertyName !== 'max-height' || event.target !== bioWrap) {
return;
}
bioWrap.removeEventListener('transitionend', onEnd);
if (onComplete) {
onComplete();
}
};
bioWrap.addEventListener('transitionend', onEnd);
};
const animateClose = (bioWrap, onComplete) => {
// The bio is currently open and rendered at its natural height.
// Capture it before we change anything.
const current = bioWrap.getBoundingClientRect().height;
setMaxHeight(bioWrap, `${current}px`);
// Force a reflow so the browser registers the starting value.
// eslint-disable-next-line no-unused-expressions
bioWrap.offsetHeight;
requestAnimationFrame(() => {
setMaxHeight(bioWrap, '0px');
});
const onEnd = (event) => {
if (event.propertyName !== 'max-height' || event.target !== bioWrap) {
return;
}
bioWrap.removeEventListener('transitionend', onEnd);
if (onComplete) {
onComplete();
}
};
bioWrap.addEventListener('transitionend', onEnd);
};
const handleClick = (details, bioWrap, event) => {
// Only intercept clicks on the summary (or its descendants).
const summary = details.querySelector('summary');
if (!summary || !summary.contains(event.target)) {
return;
}
// Prevent the browser's default toggle so we can animate first.
event.preventDefault();
if (details.open) {
// Closing: animate to 0, then remove [open] so the children hide.
animateClose(bioWrap, () => {
details.open = false;
});
} else {
// Opening: set [open] first (so the children are visible), then
// measure and animate to the natural height.
details.open = true;
animateOpen(bioWrap);
}
};
const attach = (details) => {
if (details.dataset.teamGridBound === 'true') {
return;
}
details.dataset.teamGridBound = 'true';
const bioWrap = details.querySelector(BIO_WRAP_SELECTOR);
if (!bioWrap) {
return;
}
// Use click (not toggle) so we can preventDefault. Keyboard activation
// (Enter/Space on the summary) also dispatches a click event, so this
// covers both mouse and keyboard.
details.addEventListener('click', (event) => {
handleClick(details, bioWrap, event);
});
};
const init = () => {
document.querySelectorAll(DETAILS_SELECTOR).forEach(attach);
};
init();
+86
View File
@@ -0,0 +1,86 @@
<?php
/**
* Block Name: Team Grid
*
* A responsive grid of team members. Each card uses a native <details>
* element so the bio expands when the photo, name, or title is clicked.
*
* @package CWC
*/
namespace CWC;
$single = get_field( 'single' );
$members = get_field( 'members' );
if ( empty( $members ) ) {
return;
}
// Enqueue the per-block JS only when this block actually renders.
$js_path = '/views/blocks/team-grid/team-grid.js';
if ( file_exists( get_stylesheet_directory() . $js_path ) ) {
wp_enqueue_script_module(
'cwc-team-grid',
get_stylesheet_directory_uri() . $js_path,
array(),
(string) filemtime( get_stylesheet_directory() . $js_path )
);
}
$modeSingle = '';
$blockID = wp_unique_id( 'single-' );
if ( $single ) {
$modeSingle = 'name=' . $blockID;
}
$classes = 'team-grid container my-section mx-auto';
$wrapper = blockWrapperAttributes( $classes, $is_preview );
?>
<section <?php echo wp_kses_post( $wrapper ); ?>>
<div class="team-grid__inner grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8 lg:gap-12">
<?php foreach ( $members as $member ) : ?>
<article class="team-grid__card">
<details class="team-grid__details" <?php echo esc_attr( $modeSingle ); ?>>
<summary class="team-grid__summary">
<?php if ( ! empty( $member['image'] ) ) : ?>
<img
class="team-grid__photo"
src="<?php echo esc_url( $member['image']['url'] ); ?>"
alt="<?php echo esc_attr( $member['image']['alt'] ?? '' ); ?>"
loading="lazy"
/>
<?php endif; ?>
<?php if ( ! empty( $member['name'] ) ) : ?>
<h2 class="team-grid__name">
<?php echo wp_kses_post( $member['name'] ); ?>
</h2>
<?php endif; ?>
<?php if ( ! empty( $member['title'] ) ) : ?>
<h3 class="team-grid__title">
<?php echo wp_kses_post( $member['title'] ); ?>
</h3>
<?php endif; ?>
<h4 class="team-grid__read-bio" aria-label="Read Bio for <?php echo esc_attr( $member['name'] ); ?>">Read Bio</h4>
</summary>
<div class="team-grid__bio-wrap">
<div class="team-grid__bio-inner">
<?php if ( ! empty( $member['bio'] ) ) : ?>
<div class="team-grid__bio">
<?php echo wp_kses_post( $member['bio'] ); ?>
</div>
<?php endif; ?>
</div>
</div>
</details>
</article>
<?php endforeach; ?>
</div>
</section>