Fix button inconsistencies and macOS layout shifts

This commit fixes layout shifts experienced in macOS Safari when
hovering over top menu items. Instead of making text bold — which was
causing layout shifts — the hover effect now changes the text color.
This ensures a consistent UI across different browsers and platforms.

Additionally, this commit fixes the styling of the privacy button
located in the bottom right corner. Previously styled as an `<a>`
element, it is now correctly represented as a `<button>`.

Furthermore, the commit enhances HTML conformity and accessibility by
correctly using `<button>` and `<a>` tags instead of relying on click
interactions on `<span>` elements.

This commit introduces `FlatButton` Vue component and a new
`flat-button` mixin. These centralize button usage and link styles,
aligning the hover/touch reactions of buttons across the application,
thereby creating a more consistent user interface.
This commit is contained in:
undergroundwires
2023-12-29 17:26:40 +01:00
parent 2f06043559
commit 86fde6d7dc
19 changed files with 363 additions and 106 deletions

View File

@@ -7,9 +7,9 @@ import { Clipboard } from '@/presentation/components/Shared/Hooks/Clipboard/Clip
import { UseClipboardStub } from '@tests/unit/shared/Stubs/UseClipboardStub';
import { ClipboardStub } from '@tests/unit/shared/Stubs/ClipboardStub';
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import FlatButton from '@/presentation/components/Shared/FlatButton.vue';
const DOM_SELECTOR_CODE_SLOT = 'code';
const DOM_SELECTOR_COPY_BUTTON = '.copy-button';
const COMPONENT_TOOLTIP_WRAPPER_NAME = 'TooltipWrapper';
describe('CodeInstruction.vue', () => {
@@ -35,7 +35,7 @@ describe('CodeInstruction.vue', () => {
});
wrapper.vm.codeElement = { textContent: expectedCode } as HTMLElement;
// act
const copyButton = wrapper.find(DOM_SELECTOR_COPY_BUTTON);
const copyButton = wrapper.findComponent(FlatButton);
await copyButton.trigger('click');
// assert
const calls = clipboardStub.callHistory;

View File

@@ -0,0 +1,167 @@
import {
describe, it, expect,
} from 'vitest';
import { shallowMount } from '@vue/test-utils';
import FlatButton from '@/presentation/components/Shared/FlatButton.vue';
import { IconName } from '@/presentation/components/Shared/Icon/IconName';
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
import { hasDirective } from '@/presentation/components/Scripts/View/Cards/NonCollapsingDirective';
const DOM_SELECTOR_LABEL = 'span';
const DOM_SELECTOR_BUTTON = 'button';
const DOM_CLASS_DISABLED_CLASS = 'disabled';
describe('FlatButton.vue', () => {
describe('label', () => {
it('renders label when provided', () => {
// arrange
const expectedLabel = 'expected label';
// act
const wrapper = mountComponent({ labelPropValue: expectedLabel });
// assert
const labelElement = wrapper.find(DOM_SELECTOR_LABEL);
expect(labelElement.text()).to.equal(expectedLabel);
});
it('does not render label when not provided', () => {
// arrange
const absentLabelValue = undefined;
// act
const wrapper = mountComponent({ labelPropValue: absentLabelValue });
// assert
const labelElement = wrapper.find(DOM_SELECTOR_LABEL);
expect(labelElement.exists()).to.equal(false);
});
});
describe('icon', () => {
it('renders icon when provided', () => {
// arrange
const expectedIcon: IconName = 'globe';
// act
const wrapper = mountComponent({ iconPropValue: expectedIcon });
// assert
expect(wrapper.findComponent(AppIcon).exists()).to.equal(true);
});
it('does not render icon when not provided', () => {
// arrange
const absentIconValue = undefined;
// act
const wrapper = mountComponent({ iconPropValue: absentIconValue });
// assert
expect(wrapper.findComponent(AppIcon).exists()).to.equal(false);
});
it('correctly binds given icon', () => {
// arrange
const expectedIcon: IconName = 'globe';
// act
const wrapper = mountComponent({ iconPropValue: expectedIcon });
// assert
const appIconComponent = wrapper.findComponent(AppIcon);
expect(appIconComponent.props('icon')).toEqual(expectedIcon);
});
});
describe('label + icon', () => {
it('renders both label and icon when provided', () => {
// arrange
const expectedLabel = 'Test Label';
const expectedIcon: IconName = 'globe';
// act
const wrapper = mountComponent({
labelPropValue: expectedLabel,
iconPropValue: expectedIcon,
});
// assert
const labelElement = wrapper.find(DOM_SELECTOR_LABEL);
expect(labelElement.text()).to.equal(expectedLabel);
expect(wrapper.findComponent(AppIcon).exists()).to.equal(true);
});
});
describe('disabled', () => {
it('emits click event when enabled and clicked', async () => {
// arrange
const wrapper = mountComponent({ isDisabledPropValue: false });
// act
await wrapper.find(DOM_SELECTOR_BUTTON).trigger('click');
// assert
expect(wrapper.emitted().click).to.have.lengthOf(1, formatAssertionMessage([
`Disabled prop value: ${wrapper.props('disabled')}`,
`Emitted events: ${JSON.stringify(wrapper.emitted())}`,
'Inner HTML:', wrapper.html(),
]));
});
it('does not emit click event when disabled and clicked', async () => {
// arrange
const wrapper = mountComponent({ isDisabledPropValue: true });
// act
await wrapper.find(DOM_SELECTOR_BUTTON).trigger('click');
// assert
expect(wrapper.emitted().click ?? []).to.have.lengthOf(0, formatAssertionMessage([
`Disabled prop value: ${wrapper.props('disabled')}`,
'Inner HTML:', wrapper.html(),
]));
});
it('applies disabled class when disabled', () => {
// arrange & act
const wrapper = mountComponent({ isDisabledPropValue: true });
// assert
const classes = wrapper.find(DOM_SELECTOR_BUTTON).classes();
expect(classes).to.contain(DOM_CLASS_DISABLED_CLASS, formatAssertionMessage([
`Disabled prop value: ${wrapper.props('disabled')}`,
'Inner HTML:', wrapper.html(),
]));
});
it('does not apply disabled class when enabled', () => {
// arrange & act
const wrapper = mountComponent({ isDisabledPropValue: false });
// assert
const classes = wrapper.find(DOM_SELECTOR_BUTTON).classes();
expect(classes).not.contain(DOM_CLASS_DISABLED_CLASS, formatAssertionMessage([
`Disabled prop value: ${wrapper.props('disabled')}`,
'Inner HTML:', wrapper.html(),
]));
});
});
it('applies non-collapsing directive correctly', () => {
// act & arrange
const wrapper = mountComponent();
// assert
const button = wrapper.find(DOM_SELECTOR_BUTTON);
const isDirectiveApplied = hasDirective(button.element);
expect(isDirectiveApplied).to.equal(true, formatAssertionMessage([
`Attributes: ${JSON.stringify(button.attributes())}`,
'Button HTML:', button.html(),
]));
});
});
function mountComponent(options?: {
readonly iconPropValue?: IconName,
readonly labelPropValue?: string,
readonly isDisabledPropValue?: boolean,
readonly nonCollapsingDirective?: () => void,
}) {
return shallowMount(FlatButton, {
props: {
icon: options === undefined ? 'globe' : options?.iconPropValue,
label: options === undefined ? 'stub-label' : options?.labelPropValue,
disabled: options?.isDisabledPropValue,
},
});
}