diff --git a/craft/src/panels/right/styles/NavStylePanel.test.tsx b/craft/src/panels/right/styles/NavStylePanel.test.tsx
index 818f2fa..b71ceef 100644
--- a/craft/src/panels/right/styles/NavStylePanel.test.tsx
+++ b/craft/src/panels/right/styles/NavStylePanel.test.tsx
@@ -13,9 +13,12 @@ const setPropSpy = vi.fn((_id: string, updater: (p: any) => void) => {
updater(lastProps);
});
let lastProps: any;
+/* history.merge() is a spy so undo-burst behaviour is observable: a call to
+ it means the setProp folded into the previous undo entry. */
+const mergeSpy = vi.fn(() => ({ setProp: setPropSpy }));
vi.mock('@craftjs/core', () => ({
- useEditor: () => ({ actions: { setProp: setPropSpy, history: { merge: () => ({ setProp: setPropSpy }) } } }),
+ useEditor: () => ({ actions: { setProp: setPropSpy, history: { merge: mergeSpy } } }),
}));
let mockPages: { id: string; name: string; slug: string; craftState: string | null; nav?: any }[] = [];
@@ -31,6 +34,7 @@ vi.mock('../../../ui/AssetPicker', () => ({
}));
import { NavStylePanel, LinkPicker, pageHref } from './NavStylePanel';
+import { closeContinuousBurst } from './shared';
let container: HTMLDivElement;
let root: Root;
@@ -70,6 +74,8 @@ function click(el: Element | null) {
beforeEach(() => {
try { window.localStorage.clear(); } catch { /* ignore */ }
setPropSpy.mockClear();
+ mergeSpy.mockClear();
+ closeContinuousBurst();
mockPageApi = {};
mockPages = [
{ id: 'home', name: 'Home', slug: 'index', craftState: null },
@@ -373,3 +379,52 @@ describe('NavStylePanel: Follow pages (#53)', () => {
expect(lastProps.links[0].children).toBeUndefined();
});
});
+
+describe('NavStylePanel: link href typing is one undo burst (#53 fix round 1)', () => {
+ const typeInto = (el: HTMLInputElement, v: string) => {
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;
+ act(() => { setter.call(el, v); el.dispatchEvent(new Event('input', { bubbles: true })); });
+ };
+ const hrefInput = () => container.querySelector('[data-testid="link-picker"] input[type="text"]') as HTMLInputElement;
+
+ test('consecutive URL keystrokes fold into one history entry via history.merge()', () => {
+ lastProps = { links: [{ text: 'Docs', href: 'https://e' }] };
+ render();
+ for (const v of ['https://ex', 'https://exa', 'https://exam', 'https://example.com']) typeInto(hrefInput(), v);
+ expect(setPropSpy).toHaveBeenCalledTimes(4);
+ expect(mergeSpy).toHaveBeenCalledTimes(3); // first keystroke opens the entry, the rest merge
+ expect(lastProps.links[0]).toEqual({ text: 'Docs', href: 'https://example.com' });
+ });
+
+ test('tel typing on a Follow-pages nav merges too, strips source/pageId and keeps the page merge', () => {
+ lastProps = {
+ followPages: true,
+ links: [
+ { text: 'Home', href: '/', source: 'page', pageId: 'home' },
+ { text: 'About', href: '/about', source: 'page', pageId: 'about' },
+ { text: 'Call', href: 'tel:1' },
+ ],
+ };
+ render();
+ const telInput = Array.from(container.querySelectorAll('[data-testid="link-picker"] input[type="text"]'))
+ .find((el) => (el as HTMLInputElement).placeholder === '+15551234567') as HTMLInputElement;
+ for (const v of ['15', '155', '1555']) typeInto(telInput, v);
+ expect(mergeSpy).toHaveBeenCalledTimes(2);
+ const call = lastProps.links.find((l: any) => l.text === 'Call');
+ expect(call).toEqual({ text: 'Call', href: 'tel:1555' });
+ // page links are still present (mergeLinks ran inside the updater)
+ expect(lastProps.links.filter((l: any) => l.source === 'page').map((l: any) => l.pageId)).toEqual(['home', 'about']);
+ });
+
+ test('a page pick after typing is its own undo step (no merge)', () => {
+ lastProps = { links: [{ text: 'Go', href: 'https://a' }] };
+ render();
+ typeInto(hrefInput(), 'https://ab');
+ typeInto(hrefInput(), 'https://abc');
+ expect(mergeSpy).toHaveBeenCalledTimes(1);
+ const modeSelect = container.querySelector('[data-testid="link-picker"] select') as HTMLSelectElement;
+ setValue(modeSelect, 'page');
+ expect(mergeSpy).toHaveBeenCalledTimes(1);
+ expect(lastProps.links[0]).toEqual({ text: 'Go', href: '/', source: 'page', pageId: 'home' });
+ });
+});
diff --git a/craft/src/panels/right/styles/NavStylePanel.tsx b/craft/src/panels/right/styles/NavStylePanel.tsx
index 47f2b33..5209d75 100644
--- a/craft/src/panels/right/styles/NavStylePanel.tsx
+++ b/craft/src/panels/right/styles/NavStylePanel.tsx
@@ -24,6 +24,7 @@ import {
AnimationControl,
VisibilityControl,
continuousSetProp,
+ discreteSetProp,
} from './shared';
import { AssetPicker } from '../../../ui/AssetPicker';
import { usePages } from '../../../state/PageContext';
@@ -204,14 +205,18 @@ export const NavStylePanel: React.FC = ({ selectedId, nodeProps
/* #53: LinkPicker reports the picked page; a page pick records
source:'page' + pageId, any other mode makes the link manual again. */
const updateLinkHref = useCallback((index: number, href: string, pageId: string | null) => {
- actions.setProp(selectedId, (props: any) => {
+ const updater = (props: any) => {
const updated = [...(props.links || [])];
const { source: _s, pageId: _p, ...rest } = updated[index] || {};
updated[index] = pageId ? { ...rest, href, source: 'page', pageId } : { ...rest, href };
// On a page-following nav the write is re-merged, so a link re-pointed
// at a page is materialised (and deduped) at once.
props.links = props.followPages ? mergeLinks(updated, pages) : updated;
- });
+ };
+ // A page pick is a discrete choice (its own undo step); URL / anchor /
+ // tel / mailto typing is one undo burst per control, as before #53.
+ if (pageId) discreteSetProp(actions, selectedId, updater);
+ else continuousSetProp(actions, selectedId, `link:${index}:href`, updater);
}, [actions, selectedId, pages]);
/* Nested (dropdown) links of a MANUAL link. Page nesting is set per page in