Skip to content

Make tabs block titles inline-editable with RichText#199

Open
jnealey-godaddy wants to merge 2 commits intomainfrom
claude/inline-tab-editing-U0RZP
Open

Make tabs block titles inline-editable with RichText#199
jnealey-godaddy wants to merge 2 commits intomainfrom
claude/inline-tab-editing-U0RZP

Conversation

@jnealey-godaddy
Copy link
Owner

Description

This PR enhances the tabs block by making tab titles directly editable inline using WordPress's RichText component. Previously, tab titles could only be edited through the block inspector. Now, users can click on the active tab title to edit it directly, improving the editing experience.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Code refactoring
  • Performance improvement

Related Issue

Closes #

Changes Made

  • Imported RichText component from @wordpress/block-editor
  • Added updateBlockAttributes to the dispatch actions from blockEditorStore
  • Changed tab trigger element from <button> to <div> to allow nested RichText editing
  • Implemented inline RichText editing for active tab titles with plain text formatting only
  • Enhanced keyboard navigation to handle Enter/Space keys and avoid interfering with text editing
  • Added conditional rendering: RichText for active tabs, plain text for inactive tabs
  • Updated click handler to prevent unnecessary re-selection of already active tabs
  • Added editor styles for text cursor and focus states on editable tab titles

Testing

  • Tested in WordPress editor
  • Tested on frontend
  • Tested with Twenty Twenty-Five theme
  • Tested responsive behavior (mobile/tablet/desktop)
  • No console errors
  • No PHP errors

Screenshots/Videos

Checklist

  • My code follows the WordPress Coding Standards
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have updated documentation as needed
  • My changes generate no new warnings or errors
  • I have tested on WordPress 6.4+
  • I have followed the patterns in CLAUDE.md
  • All files are under 300 lines (if applicable)
  • I have added JSDoc comments to new functions
  • Accessibility: WCAG 2.1 AA compliant
  • Security: All user input is validated and sanitized
  • Internationalization: All user-facing strings use __() or _e()

Additional Notes

The change from <button> to <div> maintains accessibility through proper ARIA attributes (role="tab", aria-selected, aria-controls) and keyboard navigation support. RichText is only active on the selected tab to prevent multiple simultaneous edits and maintain a clean UX.

https://claude.ai/code/session_015GgNeB8zHXsLigtEUzkaBw

Users can now click directly on tab text to edit it in place, instead of
having to use the sidebar settings panel. Uses RichText (plain-text mode)
on the active tab while inactive tabs remain static and clickable for
switching. Keyboard navigation is preserved with contentEditable awareness.

https://claude.ai/code/session_015GgNeB8zHXsLigtEUzkaBw
Copilot AI review requested due to automatic review settings February 7, 2026 20:16
@claude
Copy link

claude bot commented Feb 7, 2026

Claude finished @jnealey88's task in 2m 15s —— View job


Code Review Complete ✅

I've completed a comprehensive security and code quality review of this PR. Here's what I found:


Summary

Issues Found: 7 total

  • 🔴 Critical: 1 (XSS vulnerability concern)
  • 🟠 High: 1 (Accessibility violation)
  • 🟡 Medium: 3 (Logic and robustness issues)
  • 🟢 Low: 2 (Code quality improvements)

Critical Issues (Must Fix)

  1. XSS Vulnerability Risk (src/blocks/tabs/edit.js:493)
    • RichText output lacks proper sanitization validation
    • While RichText provides basic protection and the frontend renders safely via textContent, the attribute could be manipulated via REST API
    • Fix: Verify the title attribute in block.json uses source: "text" to ensure only plain text is stored

High Priority Issues (Strongly Recommended)

  1. Accessibility Violation (src/blocks/tabs/edit.js:458)
    • Changing from <button> to <div> breaks semantic accessibility
    • Violates WCAG 2.1 AA standards mentioned in PR checklist
    • Divs don't provide native keyboard focus or screen reader semantics
    • Fix: Keep <button> and nest RichText inside with proper event propagation handling

Medium Priority Issues (Should Fix)

  1. Fragile contentEditable Check (src/blocks/tabs/edit.js:96)

    • e.target.isContentEditable may not work for nested RichText elements
    • Fix: Use e.target.closest('[contenteditable="true"]') for robustness
  2. Space Key Conflict (src/blocks/tabs/edit.js:100)

    • Space key handling could interfere with typing spaces in the title
    • Fix: Combine the contentEditable check with the Space key condition
  3. UX Inconsistency (src/blocks/tabs/edit.js:469)

    • Clicking active tab does nothing, preventing users from re-focusing it
    • This is intentional per PR description, but may confuse users
    • Consider: Whether this is desired UX

Low Priority Issues (Nice to Have)

  1. Missing i18n (src/blocks/tabs/edit.js:489)

    • Placeholder text not wrapped in translation function
    • Fix: Use sprintf(__('Tab %d', 'designsetgo'), index + 1)
  2. Cursor UX Issue (src/blocks/tabs/editor.scss:82)

    • cursor: text on entire tab is confusing when hovering icons/padding
    • Fix: Apply only to .dsgo-tabs__tab-title, not the whole tab

What I Checked

Security Review

  • XSS vulnerabilities (found 1 concern with RichText sanitization)
  • SQL injection (N/A - no database queries)
  • CSRF (N/A - no form submissions)
  • Input validation (RichText properly configured with allowedFormats={[]})
  • Frontend rendering (✅ safe via textContent in view.js:145)

Logic and Correctness

  • Keyboard navigation properly handles arrow keys, Home, End
  • State management via updateBlockAttributes is correct
  • Edge cases: contentEditable interference (needs improvement)

WordPress Best Practices

  • RichText component usage is mostly correct
  • Missing sprintf for i18n on placeholder
  • Accessibility: FAILS due to button→div change

Code Quality

  • No duplicate code
  • Clean separation of concerns
  • Minor cursor styling issue in CSS

Overall Assessment

This PR adds a useful feature (inline tab editing), but has one critical accessibility issue that must be addressed before merging. The button→div change violates semantic HTML and WCAG 2.1 AA standards.

Recommendation: Fix the high-priority accessibility issue by keeping <button> elements and handling RichText nesting properly. The XSS concern should be verified by checking block.json attribute configuration.

The inline comments above contain detailed fixes for each issue. Please review and address at least the Critical and High priority items before merging.


// Handle keyboard navigation
const handleKeyDown = (e, index) => {
// Don't interfere with text editing in RichText
if (e.target.isContentEditable) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SEVERITY: Medium

Issue: The check e.target.isContentEditable may not be reliable for RichText components.

Problem: RichText wraps content in a contenteditable element, but the event target might be a child element. This could cause keyboard navigation to still interfere with text editing if the user is editing deeply nested content.

Fix: Use a more robust check:

if (e.target.isContentEditable || e.target.closest('[contenteditable="true"]')) {
    return;
}

This ensures that if the target or any parent is contenteditable, we don't interfere with editing.


return (
<button
<div
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SEVERITY: High - Accessibility Issue

Issue: Changing from <button> to <div> breaks semantic accessibility.

Problem: While ARIA attributes (role="tab") are present, divs are not keyboard-focusable by default and don't convey the same semantics to assistive technologies. This violates WCAG 2.1 AA standards mentioned in the PR checklist. Screen readers expect tabs to be implemented with native interactive elements or proper focus management.

Fix: Keep the <button> element and nest the RichText inside:

<button
    className={`dsgo-tabs__tab ${isActive ? 'is-active' : ''}`}
    role="tab"
    aria-selected={isActive}
    // ... other props
>
    {isActive ? (
        <RichText
            tagName="span"
            value={title}
            onChange={(value) => updateBlockAttributes(block.clientId, { title: value })}
            onClick={(e) => e.stopPropagation()}
            onFocus={(e) => e.stopPropagation()}
        />
    ) : (
        <span>{title || `Tab ${index + 1}`}</span>
    )}
</button>

You'll need to stop propagation on RichText click/focus events to prevent the button from handling those events.

tagName="span"
className="dsgo-tabs__tab-title"
value={title}
onChange={(value) =>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SEVERITY: Critical - XSS Vulnerability

Issue: Missing sanitization on RichText output.

Problem: The title value from block.attributes is rendered directly without sanitization. While RichText with allowedFormats={[]} prevents formatting, it doesn't prevent malicious HTML/script injection if the attribute is manipulated through REST API or direct database modification.

WordPress Security Best Practice: All dynamic content should be sanitized before rendering, even in the editor.

Fix: While RichText handles basic sanitization internally, you should ensure the stored value is sanitized. Add validation to the block's attributes definition in block.json:

"title": {
    "type": "string",
    "default": "",
    "source": "text",  // This ensures only text is extracted, no HTML
    "selector": ".dsgo-tabs__tab-title"
}

If using source: "html", you must sanitize with wp_kses_post() in PHP or use sanitizeHTML() in JavaScript.

return;
}

// Handle Enter/Space for tab activation (divs need explicit handling unlike buttons)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SEVERITY: Medium - Logic Issue

Issue: Space key handling conflicts with text editing.

Problem: If a user wants to type a space character while editing the tab title, this handler will prevent it and try to activate the tab (though the contentEditable check should catch it, it's fragile).

Fix: Be more explicit about when to handle Space:

// Handle Enter/Space for tab activation (only when NOT editing)
if (!e.target.isContentEditable && (e.key === 'Enter' || e.key === ' ')) {
    handleTabClick(index);
    e.preventDefault();
    return;
}

Move the contentEditable check into the condition rather than relying on early return for all subsequent keyboard handling.

tabIndex={isActive ? 0 : -1}
data-tab-index={index}
onClick={() => handleTabClick(index)}
onClick={() => {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SEVERITY: Low - Code Quality

Issue: Inconsistent event handling pattern.

Problem: The onClick handler checks if (!isActive) before calling handleTabClick, but this prevents users from clicking an active tab to focus it (which might be expected behavior for keyboard users or screen reader users).

Observation: This is intentional per the PR description ("prevent unnecessary re-selection"), but it creates an inconsistency where clicking an inactive tab selects its content block, but clicking an active tab does nothing.

Suggestion: Consider if this is the desired UX. Users might expect clicking the active tab to focus the RichText for editing. If that's the intent, the current implementation is correct.

{title || `Tab ${index + 1}`}
</span>
{isActive ? (
<RichText
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SEVERITY: Low - WordPress Best Practice

Issue: Missing internationalization for placeholder text.

Problem: The placeholder Tab ${index + 1} is not wrapped in a translation function, but similar text elsewhere uses __('Tab 1', 'designsetgo').

WordPress i18n Best Practice: All user-facing strings should be translatable.

Fix:
```javascript 'designsetgo') + ${index + 1}}


Or use sprintf for better translation control:
```javascript
import { sprintf, __ } from '@wordpress/i18n';
 %d', 'designsetgo'), index + 1)}

background: var(--dsgo-tab-bg-active, transparent);
color: var(--dsgo-tab-color-active, var(--wp--preset--color--accent-2, #2563eb));
font-weight: 600;
cursor: text;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SEVERITY: Low - Accessibility

Issue: Setting cursor: text on the entire tab element may be confusing.

Problem: When hovering over icon areas or padding, the cursor shows text insertion cursor even though those areas aren't editable. This creates a mismatch between visual affordance and actual functionality.

Fix: Apply cursor: text only to the editable title element:

&.is-active {
    background: var(--dsgo-tab-bg-active, transparent);
    color: var(--dsgo-tab-color-active, var(--wp--preset--color--accent-2, #2563eb));
    font-weight: 600;
    // Remove cursor: text from here
}

// Inline-editable tab title (RichText)
&.is-active .dsgo-tabs__tab-title {
    cursor: text; // Keep only here
    // ...
}

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the Tabs Gutenberg block editor experience by enabling inline editing of tab titles using RichText, replacing the previous inspector-only title editing workflow.

Changes:

  • Adds RichText rendering for the active tab title and updates the child tab block title via updateBlockAttributes.
  • Switches tab triggers from <button> to <div role="tab"> and extends keyboard handling to support Enter/Space activation.
  • Adds editor styling to support inline-edit cursor/focus behavior for the active tab title.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
src/blocks/tabs/edit.js Introduces inline RichText editing for active tab titles and updates keyboard/click behavior for tab triggers.
src/blocks/tabs/editor.scss Adds editor-only styles for the editable tab title (cursor + focus/outline rules).


// Handle Enter/Space for tab activation (divs need explicit handling unlike buttons)
if (e.key === 'Enter' || e.key === ' ') {
handleTabClick(index);
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new Enter/Space handler always calls handleTabClick(index), even when this tab is already active. That’s now inconsistent with the click handler (which skips re-selection when active) and can cause unnecessary selectBlock calls / focus churn while the active tab is focused. Consider guarding Enter/Space the same way (no-op when index === activeTab) or passing isActive into handleKeyDown and skipping activation when already selected.

Suggested change
handleTabClick(index);
if (index !== activeTab) {
handleTabClick(index);
}

Copilot uses AI. Check for mistakes.
Comment on lines 499 to 501
placeholder={`Tab ${index + 1}`}
allowedFormats={[]}
withoutInteractiveFormatting
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

placeholder={Tab ${index + 1}} introduces a new user-facing string that isn’t localized. Since this file already uses __() and the repo localizes placeholders elsewhere, this should use __()/sprintf() (with a translators comment) so the default tab label can be translated.

Copilot uses AI. Check for mistakes.
Comment on lines 89 to 96
[contenteditable] {
outline: none;
cursor: text;
}

&:focus-within {
outline: none;
}
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new [contenteditable] { outline: none; } and :focus-within { outline: none; } remove the visible focus indicator for the inline-editable tab title. This makes keyboard navigation/editing harder and can fail WCAG 2.1 focus-visible expectations. Please keep a visible focus style (or replace the outline with an equivalent high-contrast focus ring) instead of removing it entirely.

Copilot uses AI. Check for mistakes.
- Use closest('[contenteditable="true"]') instead of isContentEditable
  for more reliable nested RichText detection
- Guard Enter/Space handler to skip when tab is already active,
  matching the click handler behavior
- Wrap placeholder text with sprintf/__() for i18n support
- Strip HTML tags in onChange to ensure plain text storage
- Move cursor:text from entire tab to title span only
- Restore WCAG 2.1 focus-visible indicator on tabs instead of
  removing outlines entirely
- Add subtle dashed outline on title focus-within for editing feedback

https://claude.ai/code/session_015GgNeB8zHXsLigtEUzkaBw
value={title}
onChange={(value) => {
// Strip any residual HTML to ensure plain text storage
const plainText = value.replace(/<[^>]*>/g, '');

Check failure

Code scanning / CodeQL

Incomplete multi-character sanitization High

This string may still contain
<script
, which may cause an HTML element injection vulnerability.

Copilot Autofix

AI 18 days ago

In general, the right fix is to avoid hand‑rolled HTML “sanitization” via regex and instead either: (a) rely on WordPress’s built‑in escaping/sanitization, or (b) convert the input to plain text using a proper HTML parser/converter. For this specific case, the intent (per the comment) is “ensure plain text storage” for tab titles. We can achieve that by converting from HTML to plain text before storing, instead of trying to strip tags with a single regex.

Within src/blocks/tabs/edit.js, on line 497 we currently do:

// Strip any residual HTML to ensure plain text storage
const plainText = value.replace(/<[^>]*>/g, '');

The best, minimal‑behavior‑change fix is to use a robust HTML‑to‑text utility. Since we can only add well‑known libraries and not alter other files, we can import a small, widely‑used HTML parsing/conversion package (e.g. striptags) and delegate the tag removal to it. striptags correctly handles nested tags and does not suffer from the multi‑character replacement issue that CodeQL warns about.

Concrete changes:

  1. Add an import for striptags at the top of src/blocks/tabs/edit.js.
  2. Replace the value.replace(/<[^>]*>/g, '') call with striptags(value), keeping the rest of the logic intact.

This keeps the functionality (“store only plain text titles”) while removing the insecure, fragile regex.


Suggested changeset 2
src/blocks/tabs/edit.js

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/blocks/tabs/edit.js b/src/blocks/tabs/edit.js
--- a/src/blocks/tabs/edit.js
+++ b/src/blocks/tabs/edit.js
@@ -25,6 +25,7 @@
 import { useSelect, useDispatch } from '@wordpress/data';
 import { useEffect } from '@wordpress/element';
 import { getIcon } from '../icon/utils/svg-icons';
+import striptags from 'striptags';
 
 const ALLOWED_BLOCKS = ['designsetgo/tab'];
 
@@ -494,7 +495,7 @@
 										value={title}
 										onChange={(value) => {
 											// Strip any residual HTML to ensure plain text storage
-											const plainText = value.replace(/<[^>]*>/g, '');
+											const plainText = striptags(value);
 											updateBlockAttributes(
 												block.clientId,
 												{ title: plainText }
EOF
@@ -25,6 +25,7 @@
import { useSelect, useDispatch } from '@wordpress/data';
import { useEffect } from '@wordpress/element';
import { getIcon } from '../icon/utils/svg-icons';
import striptags from 'striptags';

const ALLOWED_BLOCKS = ['designsetgo/tab'];

@@ -494,7 +495,7 @@
value={title}
onChange={(value) => {
// Strip any residual HTML to ensure plain text storage
const plainText = value.replace(/<[^>]*>/g, '');
const plainText = striptags(value);
updateBlockAttributes(
block.clientId,
{ title: plainText }
package.json
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/package.json b/package.json
--- a/package.json
+++ b/package.json
@@ -74,7 +74,8 @@
     "@wordpress/i18n": "^5.0.0",
     "@wordpress/icons": "^10.0.0",
     "classnames": "^2.3.2",
-    "countup.js": "^2.9.0"
+    "countup.js": "^2.9.0",
+    "striptags": "^3.2.0"
   },
   "lint-staged": {
     "*.js": [
EOF
@@ -74,7 +74,8 @@
"@wordpress/i18n": "^5.0.0",
"@wordpress/icons": "^10.0.0",
"classnames": "^2.3.2",
"countup.js": "^2.9.0"
"countup.js": "^2.9.0",
"striptags": "^3.2.0"
},
"lint-staged": {
"*.js": [
This fix introduces these dependencies
Package Version Security advisories
striptags (npm) 3.2.0 None
Copilot is powered by AI and may make mistakes. Always verify output.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants