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:
@@ -0,0 +1,32 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { Menu } from './Menu';
|
||||
|
||||
const toHtml = (Menu as any).toHtml;
|
||||
|
||||
describe('Menu.toHtml deterministic + unique scope ids (thread node id, no Math.random)', () => {
|
||||
test('same node id -> identical output across calls (deterministic)', () => {
|
||||
const { html: html1 } = toHtml({}, '', 'node-menu1');
|
||||
const { html: html2 } = toHtml({}, '', 'node-menu1');
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
|
||||
test('different node ids -> different, non-colliding scope classes (identical default links, no collision)', () => {
|
||||
const { html: html1 } = toHtml({}, '', 'node-menu1');
|
||||
const { html: html2 } = toHtml({}, '', 'node-menu2');
|
||||
const cls1 = html1.match(/\.([a-z0-9_]+-link):hover/)![1];
|
||||
const cls2 = html2.match(/\.([a-z0-9_]+-link):hover/)![1];
|
||||
expect(cls1).not.toBe(cls2);
|
||||
});
|
||||
|
||||
test('the anchor class= and the <style> hover rule use the SAME scope', () => {
|
||||
const { html } = toHtml({}, '', 'node-menu1');
|
||||
const hoverCls = html.match(/\.([a-z0-9_]+-link):hover/)![1];
|
||||
expect(html).toContain(`class="${hoverCls}"`);
|
||||
});
|
||||
|
||||
test('no nodeId (legacy 2-arg call): still deterministic across repeated calls, not random', () => {
|
||||
const { html: html1 } = toHtml({}, '');
|
||||
const { html: html2 } = toHtml({}, '');
|
||||
expect(html1).toBe(html2);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user