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

@@ -79,14 +79,15 @@ To add a new dependency:
## Shared UI components ## Shared UI components
Shared UI components promote consistency and simplifies the creation of the front-end. Shared UI components ensure consistency and streamline front-end development.
In order to maintain portability and easy maintainability, the preference is towards using homegrown components over third-party ones or comprehensive UI frameworks like Quasar. We use homegrown components over third-party solutions or comprehensive UI frameworks like Quasar to maintain portability and easy maintenance.
Shared components include: Shared components include:
- [ModalDialog.vue](./../src/presentation/components/Shared/Modal/ModalDialog.vue) is utilized for rendering modal windows. - [ModalDialog.vue](./../src/presentation/components/Shared/Modal/ModalDialog.vue): Renders modal windows.
- [TooltipWrapper.vue](./../src/presentation/components/Shared/TooltipWrapper.vue) acts as a wrapper for rendering tooltips. - [TooltipWrapper.vue](./../src/presentation/components/Shared/TooltipWrapper.vue): Provides tooltip functionality for improved information accessibility.
- [FlatButton.vue](./../src/presentation/components/Shared/FlatButton.vue): Creates flat-style buttons for a unified and consistent user interface.
## Desktop builds ## Desktop builds

View File

@@ -30,7 +30,6 @@ $color-on-surface : #4d5156;
// Background | Appears behind scrollable content. // Background | Appears behind scrollable content.
$color-background : #e6ecf4; $color-background : #e6ecf4;
/* /*
Application-specific colors: Application-specific colors:
These are tailored to the specific needs of the application and derived from the above theme colors. These are tailored to the specific needs of the application and derived from the above theme colors.
@@ -38,3 +37,4 @@ $color-background : #e6ecf4;
This approach maintains a cohesive look and feel and simplifies theme adjustments. This approach maintains a cohesive look and feel and simplifies theme adjustments.
*/ */
$color-scripts-bg: $color-primary-darker; $color-scripts-bg: $color-primary-darker;
$color-highlight: $color-primary;

View File

@@ -11,14 +11,10 @@
box-sizing: border-box; box-sizing: border-box;
} }
$globals-color-hover: $color-primary;
a { a {
color:inherit; color: inherit;
text-decoration: underline;
cursor: pointer; cursor: pointer;
@include hover-or-touch { @include flat-button($disabled: false);
color: $globals-color-hover;
}
} }
body { body {

View File

@@ -1,3 +1,5 @@
@use "@/presentation/assets/styles/colors" as *;
@mixin hover-or-touch($selector-suffix: '', $selector-prefix: '&') { @mixin hover-or-touch($selector-suffix: '', $selector-prefix: '&') {
@media (hover: hover) { @media (hover: hover) {
/* /*
@@ -65,3 +67,47 @@
padding: 0; padding: 0;
list-style: none; list-style: none;
} }
@mixin reset-button {
margin: 0;
padding-block: 0;
padding-inline: 0;
font: unset;
border: unset;
background: unset;
align-items: unset;
text-align: unset;
text-shadow: unset;
text-rendering: unset;
color: inherit;
writing-mode: unset;
letter-spacing: unset;
word-spacing: unset;
line-height: unset;
text-transform: unset;
text-indent: unset;
appearance: unset;
cursor: unset;
}
@mixin flat-button($disabled: false) {
@include reset-button;
@if $disabled {
color: $color-primary-light;
} @else {
color: inherit;
@include clickable;
@include hover-or-touch {
text-decoration: underline;
color: $color-highlight;
/*
Using color change and underlining and as hover cues instead of bold text,
due to inconsistent bold rendering in macOS browsers:
- Safari: Renders bold, causes layout shift.
- Firefox: Renders bold correctly, no layout shift.
- Chromium-based browsers (including Electron app): Do not render bold, no layout shift.
*/
}
}
}

View File

@@ -64,6 +64,8 @@ export default defineComponent({
} }
.button { .button {
@include reset-button;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -72,13 +74,13 @@ export default defineComponent({
color: $color-on-secondary; color: $color-on-secondary;
border: none; border: none;
padding:20px; padding: 20px;
transition-duration: 0.4s; transition-duration: 0.4s;
overflow: hidden; overflow: hidden;
box-shadow: 0 3px 9px $color-primary-darkest; box-shadow: 0 3px 9px $color-primary-darkest;
border-radius: 4px; border-radius: 4px;
&__icon { .button__icon {
font-size: 2em; font-size: 2em;
} }

View File

@@ -4,11 +4,7 @@
<code ref="codeElement"><slot /></code> <code ref="codeElement"><slot /></code>
<div class="copy-action-container"> <div class="copy-action-container">
<TooltipWrapper> <TooltipWrapper>
<AppIcon <FlatButton icon="copy" @click="copyCode" />
icon="copy"
class="copy-button"
@click="copyCode"
/>
<template #tooltip> <template #tooltip>
Copy Copy
</template> </template>
@@ -20,13 +16,13 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, shallowRef } from 'vue'; import { defineComponent, shallowRef } from 'vue';
import TooltipWrapper from '@/presentation/components/Shared/TooltipWrapper.vue'; import TooltipWrapper from '@/presentation/components/Shared/TooltipWrapper.vue';
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
import { injectKey } from '@/presentation/injectionSymbols'; import { injectKey } from '@/presentation/injectionSymbols';
import FlatButton from '@/presentation/components/Shared/FlatButton.vue';
export default defineComponent({ export default defineComponent({
components: { components: {
TooltipWrapper, TooltipWrapper,
AppIcon, FlatButton,
}, },
setup() { setup() {
const { copyText } = injectKey((keys) => keys.useClipboard); const { copyText } = injectKey((keys) => keys.useClipboard);
@@ -73,12 +69,6 @@ export default defineComponent({
.copy-action-container { .copy-action-container {
margin-left: 1rem; margin-left: 1rem;
} }
.copy-button {
@include clickable;
@include hover-or-touch {
color: $color-primary;
}
}
code { code {
font-size: 1rem; font-size: 1rem;
} }

View File

@@ -5,9 +5,7 @@
<div class="title"> <div class="title">
Tools Tools
</div> </div>
<button type="button" class="close-button" @click="close"> <FlatButton icon="xmark" class="close-button" @click="close" />
<AppIcon icon="xmark" />
</button>
</div> </div>
<hr /> <hr />
<div class="action-buttons"> <div class="action-buttons">
@@ -28,12 +26,12 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, ref } from 'vue'; import { defineComponent, ref } from 'vue';
import { injectKey } from '@/presentation/injectionSymbols'; import { injectKey } from '@/presentation/injectionSymbols';
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue'; import FlatButton from '@/presentation/components/Shared/FlatButton.vue';
import { dumpNames } from './DumpNames'; import { dumpNames } from './DumpNames';
export default defineComponent({ export default defineComponent({
components: { components: {
AppIcon, FlatButton,
}, },
setup() { setup() {
const { log } = injectKey((keys) => keys.useLogger); const { log } = injectKey((keys) => keys.useLogger);
@@ -118,19 +116,19 @@ interface DevAction {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
}
button { button {
display: block; display: block;
padding: 5px 10px; padding: 5px 10px;
background-color: $color-primary; background-color: $color-primary;
color: $color-on-primary; color: $color-on-primary;
border: none; border: none;
cursor: pointer; cursor: pointer;
@include hover-or-touch { @include hover-or-touch {
background-color: $color-secondary; background-color: $color-secondary;
color: $color-on-secondary; color: $color-on-secondary;
}
} }
} }
} }

View File

@@ -4,23 +4,23 @@
Parent wrapper allows `MenuOptionList` to safely add content inside Parent wrapper allows `MenuOptionList` to safely add content inside
such as adding content in `::before` block without making it clickable. such as adding content in `::before` block without making it clickable.
--> -->
<span <FlatButton
v-non-collapsing :disabled="!enabled"
:class="{ :label="label"
disabled: !enabled, flat
enabled: enabled,
}"
@click="onClicked()" @click="onClicked()"
>{{ label }}</span> />
</span> </span>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent } from 'vue'; import { defineComponent } from 'vue';
import { NonCollapsing } from '@/presentation/components/Scripts/View/Cards/NonCollapsingDirective'; import { NonCollapsing } from '@/presentation/components/Scripts/View/Cards/NonCollapsingDirective';
import FlatButton from '@/presentation/components/Shared/FlatButton.vue';
export default defineComponent({ export default defineComponent({
directives: { NonCollapsing }, directives: { NonCollapsing },
components: { FlatButton },
props: { props: {
enabled: { enabled: {
type: Boolean, type: Boolean,
@@ -48,18 +48,3 @@ export default defineComponent({
}, },
}); });
</script> </script>
<style scoped lang="scss">
@use "@/presentation/assets/styles/main" as *;
.enabled {
@include clickable;
@include hover-or-touch {
font-weight:bold;
text-decoration:underline;
}
}
.disabled {
color: $color-primary-light;
}
</style>

View File

@@ -31,7 +31,7 @@
</div> </div>
<div class="card__expander" @click.stop> <div class="card__expander" @click.stop>
<div class="card__expander__close-button"> <div class="card__expander__close-button">
<AppIcon <FlatButton
icon="xmark" icon="xmark"
@click="collapse()" @click="collapse()"
/> />
@@ -51,6 +51,7 @@ import {
defineComponent, computed, shallowRef, defineComponent, computed, shallowRef,
} from 'vue'; } from 'vue';
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue'; import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
import FlatButton from '@/presentation/components/Shared/FlatButton.vue';
import { injectKey } from '@/presentation/injectionSymbols'; import { injectKey } from '@/presentation/injectionSymbols';
import ScriptsTree from '@/presentation/components/Scripts/View/Tree/ScriptsTree.vue'; import ScriptsTree from '@/presentation/components/Scripts/View/Tree/ScriptsTree.vue';
import { sleep } from '@/infrastructure/Threading/AsyncSleep'; import { sleep } from '@/infrastructure/Threading/AsyncSleep';
@@ -61,6 +62,7 @@ export default defineComponent({
ScriptsTree, ScriptsTree,
AppIcon, AppIcon,
CardSelectionIndicator, CardSelectionIndicator,
FlatButton,
}, },
props: { props: {
categoryId: { categoryId: {

View File

@@ -17,7 +17,7 @@
class="search__query__close-button" class="search__query__close-button"
@click="clearSearchQuery()" @click="clearSearchQuery()"
> >
<AppIcon icon="xmark" /> <FlatButton icon="xmark" />
</div> </div>
</div> </div>
<div v-if="!searchHasMatches" class="search-no-matches"> <div v-if="!searchHasMatches" class="search-no-matches">
@@ -39,19 +39,19 @@
import { import {
defineComponent, PropType, ref, computed, defineComponent, PropType, ref, computed,
} from 'vue'; } from 'vue';
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
import { injectKey } from '@/presentation/injectionSymbols'; import { injectKey } from '@/presentation/injectionSymbols';
import ScriptsTree from '@/presentation/components/Scripts/View/Tree/ScriptsTree.vue'; import ScriptsTree from '@/presentation/components/Scripts/View/Tree/ScriptsTree.vue';
import CardList from '@/presentation/components/Scripts/View/Cards/CardList.vue'; import CardList from '@/presentation/components/Scripts/View/Cards/CardList.vue';
import { ViewType } from '@/presentation/components/Scripts/Menu/View/ViewType'; import { ViewType } from '@/presentation/components/Scripts/Menu/View/ViewType';
import { IReadOnlyUserFilter } from '@/application/Context/State/Filter/IUserFilter'; import { IReadOnlyUserFilter } from '@/application/Context/State/Filter/IUserFilter';
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult'; import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
import FlatButton from '@/presentation/components/Shared/FlatButton.vue';
export default defineComponent({ export default defineComponent({
components: { components: {
ScriptsTree, ScriptsTree,
CardList, CardList,
AppIcon, FlatButton,
}, },
props: { props: {
currentView: { currentView: {
@@ -149,14 +149,10 @@ $margin-inner: 4px;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
margin-top: 1em; margin-top: 1em;
color: $color-primary; color: $color-primary-light;
.search__query__close-button { .search__query__close-button {
@include clickable;
font-size: 1.25em; font-size: 1.25em;
margin-left: 0.25rem; margin-left: 0.25rem;
@include hover-or-touch {
color: $color-primary-dark;
}
} }
} }
.search-no-matches { .search-no-matches {

View File

@@ -128,7 +128,7 @@ $base-spacing: $text-size;
*/ */
@include hover-or-touch { @include hover-or-touch {
&::after{ &::after{
background-color: $globals-color-hover; background-color: $color-highlight;
} }
} }
} }

View File

@@ -1,22 +1,23 @@
<template> <template>
<a <div
class="button" class="documentation-button"
target="_blank" :class="{ expanded: isOn }"
:class="{ 'button-on': isOn }"
@click.stop @click.stop
@click="toggle()"
> >
<AppIcon icon="circle-info" /> <FlatButton
</a> icon="circle-info"
@click="toggle()"
/>
</div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, ref } from 'vue'; import { defineComponent, ref } from 'vue';
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue'; import FlatButton from '@/presentation/components/Shared/FlatButton.vue';
export default defineComponent({ export default defineComponent({
components: { components: {
AppIcon, FlatButton,
}, },
emits: [ emits: [
'show', 'show',
@@ -45,14 +46,15 @@ export default defineComponent({
<style scoped lang="scss"> <style scoped lang="scss">
@use "@/presentation/assets/styles/main" as *; @use "@/presentation/assets/styles/main" as *;
.button { .documentation-button {
@include clickable;
vertical-align: middle; vertical-align: middle;
color: $color-primary; color: $color-primary;
@include hover-or-touch { :deep() { // This override leads to inconsistent highlight color, it should be re-styled.
color: $color-primary-darker; @include hover-or-touch {
color: $color-primary-darker;
}
} }
&-on { &.expanded {
color: $color-primary-light; color: $color-primary-light;
} }
} }

View File

@@ -0,0 +1,73 @@
<template>
<!-- Use `button` instead of DIV as it is semantically correct and accessibility best-practice -->
<button
v-non-collapsing
type="button"
class="flat-button"
:class="{
disabled: disabled,
}"
@click="onClicked"
>
<AppIcon v-if="icon" :icon="icon" />
<span v-if="label">{{ label }}</span>
</button>
</template>
<script lang="ts">
import { defineComponent, PropType } from 'vue';
import { NonCollapsing } from '@/presentation/components/Scripts/View/Cards/NonCollapsingDirective';
import { IconName } from '@/presentation/components/Shared/Icon/IconName';
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
export default defineComponent({
components: { AppIcon },
directives: { NonCollapsing },
props: {
label: {
type: String,
default: undefined,
required: false,
},
disabled: {
type: Boolean,
default: false,
required: false,
},
icon: {
type: String as PropType<IconName | undefined>,
default: undefined,
required: false,
},
},
emits: [
'click',
],
setup(props, { emit }) {
function onClicked() {
if (props.disabled) {
return;
}
emit('click');
}
return { onClicked };
},
});
</script>
<style lang="scss" scoped>
@use "@/presentation/assets/styles/main" as *;
.flat-button {
display: inline-flex;
gap: 0.5em;
font-family: $font-normal;
&.disabled {
@include flat-button($disabled: true);
}
&:not(.disabled) {
@include flat-button($disabled: false);
}
}
</style>

View File

@@ -6,25 +6,24 @@
<div class="dialog__content"> <div class="dialog__content">
<slot /> <slot />
</div> </div>
<div <FlatButton
icon="xmark"
class="dialog__close-button" class="dialog__close-button"
@click="hide" @click="hide"
> />
<AppIcon icon="xmark" />
</div>
</div> </div>
</ModalContainer> </ModalContainer>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, computed } from 'vue'; import { defineComponent, computed } from 'vue';
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue'; import FlatButton from '@/presentation/components/Shared/FlatButton.vue';
import ModalContainer from './ModalContainer.vue'; import ModalContainer from './ModalContainer.vue';
export default defineComponent({ export default defineComponent({
components: { components: {
ModalContainer, ModalContainer,
AppIcon, FlatButton,
}, },
props: { props: {
modelValue: { modelValue: {
@@ -72,16 +71,12 @@ export default defineComponent({
margin: 5%; margin: 5%;
} }
&__close-button { .dialog__close-button {
color: $color-primary-dark; color: $color-primary-dark;
width: auto; width: auto;
font-size: 1.5em; font-size: 1.5em;
margin-right: 0.25em; margin-right: 0.25em;
align-self: flex-start; align-self: flex-start;
@include clickable;
@include hover-or-touch {
color: $color-primary;
}
} }
} }
</style> </style>

View File

@@ -64,7 +64,6 @@ function hasDesktopVersion(os: OperatingSystem): boolean {
<style scoped lang="scss"> <style scoped lang="scss">
@use "@/presentation/assets/styles/main" as *; @use "@/presentation/assets/styles/main" as *;
.url { .url {
@include clickable;
&__active { &__active {
font-size: 1em; font-size: 1em;
} }

View File

@@ -32,8 +32,12 @@
</a> </a>
</div> </div>
<div class="footer__section__item"> <div class="footer__section__item">
<AppIcon class="icon" icon="user-secret" /> <FlatButton
<a @click="showPrivacyDialog()">Privacy</a> label="Privacy"
icon="user-secret"
flat
@click="showPrivacyDialog()"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -50,6 +54,7 @@ import {
import ModalDialog from '@/presentation/components/Shared/Modal/ModalDialog.vue'; import ModalDialog from '@/presentation/components/Shared/Modal/ModalDialog.vue';
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue'; import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
import { injectKey } from '@/presentation/injectionSymbols'; import { injectKey } from '@/presentation/injectionSymbols';
import FlatButton from '@/presentation/components/Shared/FlatButton.vue';
import DownloadUrlList from './DownloadUrlList.vue'; import DownloadUrlList from './DownloadUrlList.vue';
import PrivacyPolicy from './PrivacyPolicy.vue'; import PrivacyPolicy from './PrivacyPolicy.vue';
@@ -59,6 +64,7 @@ export default defineComponent({
PrivacyPolicy, PrivacyPolicy,
DownloadUrlList, DownloadUrlList,
AppIcon, AppIcon,
FlatButton,
}, },
setup() { setup() {
const { info } = injectKey((keys) => keys.useApplication); const { info } = injectKey((keys) => keys.useApplication);
@@ -99,7 +105,6 @@ export default defineComponent({
.icon { .icon {
margin-right: 0.5em; margin-right: 0.5em;
text-decoration: none;
} }
.footer { .footer {

View File

@@ -16,7 +16,7 @@ describe('Modal interaction and layout stability', () => {
}); });
cy cy
.contains('a', 'Privacy') .contains('button', 'Privacy')
.click(); .click();
cy cy

View File

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