deterministic + unique ids in 6 components (node-derived scope)

Migrates Menu, ColumnLayout, Countdown, Gallery, Tabs, InputField, and
TextareaField from Math.random()/content-hash scope ids to the threaded
Craft node id (via scopeId()), keeping every emitted element id,
aria-controls/aria-labelledby/for, and inline <script> function
name/getElementById() call consistently scoped per component.

Resolves the two Important id-collision review findings:
- Tabs: tabId was djb2(anchorId||labels) -- two default Tabs instances
  produced identical aria-controls/aria-labelledby ids, so one instance's
  arrow-key script clobbered the other's tab/panel wiring.
- InputField/TextareaField: fieldId was `field-${name}` -- two fields
  sharing a (often default) name produced duplicate <label for>/<input
  id> pairs, breaking the for/id association for one of them.

Also eliminates the remaining Math.random() scope ids in Menu (hover CSS
class scope) and ColumnLayout (nth-child width CSS class scope), so no
export component is non-deterministic anymore.

All fall back to a stable content hash (never Math.random) for legacy
2-arg toHtml() call sites without a node id.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 14:35:12 -07:00
co-authored by Claude Opus 4.8
parent c2aac870e7
commit 8029126ab7
14 changed files with 291 additions and 53 deletions
+10 -7
View File
@@ -1,7 +1,7 @@
import React, { CSSProperties, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
import { escapeHtml, escapeAttr, safeUrl, scopeId } from '../../utils/escape';
/* ---------- Types ---------- */
@@ -124,7 +124,7 @@ Menu.craft = {
/* ---------- HTML export ---------- */
(Menu as any).toHtml = (props: MenuProps, _childrenHtml: string) => {
(Menu as any).toHtml = (props: MenuProps, _childrenHtml: string, nodeId?: string) => {
const linkCol = props.linkColor || '#3f3f46';
const hoverCol = props.linkHoverColor || '#3b82f6';
const ctaBg = props.ctaBgColor || '#3b82f6';
@@ -150,12 +150,15 @@ Menu.craft = {
const links = props.links || defaultLinks;
// Unique ID suffix for scoped CSS
const scopeId = `menu-${Math.random().toString(36).slice(2, 8)}`;
// Scope for the hover CSS classes below. Deterministic AND unique: scoped
// on the Craft node id so two Menu instances with identical/default links
// don't collide on the same `.menu-link`/`.menu-cta` class names (which
// would let one instance's hover styling bleed into the other's).
const scope = scopeId(nodeId, JSON.stringify(links) + orientation + alignment, 'menu');
const linksHtml = links.map((link) => {
const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
const cls = link.isCta ? `${scopeId}-cta` : `${scopeId}-link`;
const cls = link.isCta ? `${scope}-cta` : `${scope}-link`;
const linkStyle = cssPropsToString({
textDecoration: 'none',
fontSize: fSize,
@@ -170,8 +173,8 @@ Menu.craft = {
}).join('\n ');
const hoverCss = `<style>
.${scopeId}-link:hover { color: ${hoverCol} !important; }
.${scopeId}-cta:hover { filter: brightness(1.1); }
.${scope}-link:hover { color: ${hoverCol} !important; }
.${scope}-cta:hover { filter: brightness(1.1); }
</style>`;
return {