|
| 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