Skip to content

Commit df130cf

Browse files
committed
test(ui): pin the read-mode copy button's keyboard focus reveal
Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6746140 commit df130cf

1 file changed

Lines changed: 149 additions & 0 deletions

File tree

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest';
10+
import { render, fireEvent, cleanup } from '@testing-library/react';
11+
import type { DetailViewSection } from '@object-ui/types';
12+
import { DetailSection } from '../DetailSection';
13+
14+
/**
15+
* objectstack#26329 — the read-mode copy-to-clipboard button on a copyable,
16+
* non-editable field was revealed on HOVER only (`opacity-0
17+
* group-hover:opacity-100`). It is a real `<button>`, so a keyboard Tab stop
18+
* landed on it while it stayed fully transparent: the focus ring the shared
19+
* `Button` draws was painted at opacity 0. The inline-edit pencil beside it had
20+
* the same defect and was fixed the same way (objectstack#25883; its pin is
21+
* `inlineEditPencil.focusReveal-25883.test.tsx`).
22+
*
23+
* The copy button must now also show while it, or anything else in its field
24+
* (the field's own click-to-copy row is a Tab stop too), has keyboard focus.
25+
* Hover behaves exactly as before.
26+
*
27+
* jsdom has no layout and no CSS engine, so "visible on focus" cannot be
28+
* measured here. What these tests pin is the contract a browser turns into
29+
* visibility: the copy button is a focusable button, it carries the
30+
* focus-reveal utilities next to the unchanged hover reveal, the shared focus
31+
* ring is still on it, and the `group` its `group-*` variants resolve against
32+
* is its own field. Activation is asserted behaviourally.
33+
*
34+
* The copy button is found by its icon, not by its localized label, so the
35+
* tests hold in every locale.
36+
*/
37+
38+
const copyButtons = (root: HTMLElement): HTMLButtonElement[] =>
39+
Array.from(root.querySelectorAll('svg.lucide-copy')).map(
40+
(svg) => svg.closest('button') as HTMLButtonElement,
41+
);
42+
43+
function expectFocusRevealContract(copy: HTMLButtonElement) {
44+
// A real, enabled, tabbable control: the Tab stop the defect is about.
45+
expect(copy.tagName).toBe('BUTTON');
46+
expect(copy).not.toBeDisabled();
47+
expect(copy.getAttribute('tabindex')).not.toBe('-1');
48+
copy.focus();
49+
expect(copy).toHaveFocus();
50+
51+
const cls = copy.classList;
52+
// Hidden at rest, revealed on hover: unchanged.
53+
expect(cls.contains('opacity-0')).toBe(true);
54+
expect(cls.contains('group-hover:opacity-100')).toBe(true);
55+
// Revealed on keyboard focus of the copy button itself …
56+
expect(cls.contains('focus-visible:opacity-100')).toBe(true);
57+
// … and while focus is anywhere inside its field (e.g. its click-to-copy row).
58+
expect(cls.contains('group-focus-within:opacity-100')).toBe(true);
59+
// The shared Button's focus ring survives the className merge, so the
60+
// revealed button also shows where focus is.
61+
expect(cls.contains('focus-visible:ring-2')).toBe(true);
62+
63+
// `group-*` variants resolve against the nearest `.group` ancestor; it must
64+
// be this field's own wrapper, so focus in one field reveals only its button.
65+
const group = copy.closest('.group');
66+
expect(group).not.toBeNull();
67+
expect(copyButtons(group as HTMLElement)).toEqual([copy]);
68+
}
69+
70+
describe('DetailSection read-mode copy button — keyboard focus reveal (#26329)', () => {
71+
beforeAll(() => {
72+
// useIsMobile() keys off window.innerWidth; the desktop row carries the button.
73+
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 });
74+
});
75+
76+
afterEach(() => {
77+
cleanup();
78+
});
79+
80+
const objectSchema = {
81+
fields: {
82+
title: { type: 'text' },
83+
city: { type: 'text' },
84+
notes: { type: 'text' },
85+
},
86+
};
87+
const section: DetailViewSection = {
88+
fields: [
89+
{ name: 'title', label: 'Title' },
90+
{ name: 'city', label: 'City' },
91+
{ name: 'notes', label: 'Notes' },
92+
],
93+
} as DetailViewSection;
94+
const data = { title: 'Hello', city: 'Paris', notes: '' };
95+
96+
it('draws one focus-revealed copy button per copyable field, none on an empty one', () => {
97+
const { container } = render(
98+
<DetailSection section={section} data={data} objectSchema={objectSchema} />,
99+
);
100+
const buttons = copyButtons(container);
101+
expect(buttons).toHaveLength(2); // title + city; the empty `notes` offers no copy
102+
buttons.forEach(expectFocusRevealContract);
103+
});
104+
105+
it('focus on the field row sits inside the same group as its copy button', () => {
106+
const { container } = render(
107+
<DetailSection section={section} data={data} objectSchema={objectSchema} />,
108+
);
109+
const [titleCopy] = copyButtons(container);
110+
const group = titleCopy.closest('.group') as HTMLElement;
111+
// The click-to-copy row is itself a Tab stop; focusing it is what
112+
// `group-focus-within` turns into the button's reveal.
113+
const row = group.querySelector('[role="button"]') as HTMLElement;
114+
expect(row).not.toBeNull();
115+
expect(row.getAttribute('tabindex')).toBe('0');
116+
row.focus();
117+
expect(row).toHaveFocus();
118+
expect(group.contains(document.activeElement)).toBe(true);
119+
});
120+
121+
it('activating the focused copy button copies its own field', async () => {
122+
const writeText = vi.fn().mockResolvedValue(undefined);
123+
Object.defineProperty(navigator, 'clipboard', {
124+
configurable: true,
125+
value: { writeText },
126+
});
127+
const { container } = render(
128+
<DetailSection section={section} data={data} objectSchema={objectSchema} />,
129+
);
130+
const [, cityCopy] = copyButtons(container);
131+
cityCopy.focus();
132+
// Enter / Space on a native <button> dispatch `click`.
133+
fireEvent.click(cityCopy);
134+
expect(writeText).toHaveBeenCalledTimes(1);
135+
expect(writeText).toHaveBeenCalledWith('Paris');
136+
});
137+
138+
it('draws no copy button on an editable field (the pencil takes its place)', () => {
139+
const { container } = render(
140+
<DetailSection
141+
section={section}
142+
data={data}
143+
objectSchema={objectSchema}
144+
onEnterInlineEdit={vi.fn()}
145+
/>,
146+
);
147+
expect(copyButtons(container)).toHaveLength(0);
148+
});
149+
});

0 commit comments

Comments
 (0)