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:
@@ -79,14 +79,15 @@ To add a new dependency:
|
||||
|
||||
## 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:
|
||||
|
||||
- [ModalDialog.vue](./../src/presentation/components/Shared/Modal/ModalDialog.vue) is utilized for rendering modal windows.
|
||||
- [TooltipWrapper.vue](./../src/presentation/components/Shared/TooltipWrapper.vue) acts as a wrapper for rendering tooltips.
|
||||
- [ModalDialog.vue](./../src/presentation/components/Shared/Modal/ModalDialog.vue): Renders modal windows.
|
||||
- [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
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ $color-on-surface : #4d5156;
|
||||
// Background | Appears behind scrollable content.
|
||||
$color-background : #e6ecf4;
|
||||
|
||||
|
||||
/*
|
||||
Application-specific 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.
|
||||
*/
|
||||
$color-scripts-bg: $color-primary-darker;
|
||||
$color-highlight: $color-primary;
|
||||
|
||||
@@ -11,14 +11,10 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
$globals-color-hover: $color-primary;
|
||||
a {
|
||||
color:inherit;
|
||||
text-decoration: underline;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
@include hover-or-touch {
|
||||
color: $globals-color-hover;
|
||||
}
|
||||
@include flat-button($disabled: false);
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@use "@/presentation/assets/styles/colors" as *;
|
||||
|
||||
@mixin hover-or-touch($selector-suffix: '', $selector-prefix: '&') {
|
||||
@media (hover: hover) {
|
||||
/*
|
||||
@@ -65,3 +67,47 @@
|
||||
padding: 0;
|
||||
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.
|
||||
*/
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,8 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
.button {
|
||||
@include reset-button;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -72,13 +74,13 @@ export default defineComponent({
|
||||
color: $color-on-secondary;
|
||||
|
||||
border: none;
|
||||
padding:20px;
|
||||
padding: 20px;
|
||||
transition-duration: 0.4s;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 3px 9px $color-primary-darkest;
|
||||
border-radius: 4px;
|
||||
|
||||
&__icon {
|
||||
.button__icon {
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,7 @@
|
||||
<code ref="codeElement"><slot /></code>
|
||||
<div class="copy-action-container">
|
||||
<TooltipWrapper>
|
||||
<AppIcon
|
||||
icon="copy"
|
||||
class="copy-button"
|
||||
@click="copyCode"
|
||||
/>
|
||||
<FlatButton icon="copy" @click="copyCode" />
|
||||
<template #tooltip>
|
||||
Copy
|
||||
</template>
|
||||
@@ -20,13 +16,13 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent, shallowRef } from 'vue';
|
||||
import TooltipWrapper from '@/presentation/components/Shared/TooltipWrapper.vue';
|
||||
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
|
||||
import { injectKey } from '@/presentation/injectionSymbols';
|
||||
import FlatButton from '@/presentation/components/Shared/FlatButton.vue';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
TooltipWrapper,
|
||||
AppIcon,
|
||||
FlatButton,
|
||||
},
|
||||
setup() {
|
||||
const { copyText } = injectKey((keys) => keys.useClipboard);
|
||||
@@ -73,12 +69,6 @@ export default defineComponent({
|
||||
.copy-action-container {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
.copy-button {
|
||||
@include clickable;
|
||||
@include hover-or-touch {
|
||||
color: $color-primary;
|
||||
}
|
||||
}
|
||||
code {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
@@ -5,9 +5,7 @@
|
||||
<div class="title">
|
||||
Tools
|
||||
</div>
|
||||
<button type="button" class="close-button" @click="close">
|
||||
<AppIcon icon="xmark" />
|
||||
</button>
|
||||
<FlatButton icon="xmark" class="close-button" @click="close" />
|
||||
</div>
|
||||
<hr />
|
||||
<div class="action-buttons">
|
||||
@@ -28,12 +26,12 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref } from 'vue';
|
||||
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';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
AppIcon,
|
||||
FlatButton,
|
||||
},
|
||||
setup() {
|
||||
const { log } = injectKey((keys) => keys.useLogger);
|
||||
@@ -118,19 +116,19 @@ interface DevAction {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
button {
|
||||
display: block;
|
||||
padding: 5px 10px;
|
||||
background-color: $color-primary;
|
||||
color: $color-on-primary;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
button {
|
||||
display: block;
|
||||
padding: 5px 10px;
|
||||
background-color: $color-primary;
|
||||
color: $color-on-primary;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
@include hover-or-touch {
|
||||
background-color: $color-secondary;
|
||||
color: $color-on-secondary;
|
||||
@include hover-or-touch {
|
||||
background-color: $color-secondary;
|
||||
color: $color-on-secondary;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,23 +4,23 @@
|
||||
Parent wrapper allows `MenuOptionList` to safely add content inside
|
||||
such as adding content in `::before` block without making it clickable.
|
||||
-->
|
||||
<span
|
||||
v-non-collapsing
|
||||
:class="{
|
||||
disabled: !enabled,
|
||||
enabled: enabled,
|
||||
}"
|
||||
<FlatButton
|
||||
:disabled="!enabled"
|
||||
:label="label"
|
||||
flat
|
||||
@click="onClicked()"
|
||||
>{{ label }}</span>
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { NonCollapsing } from '@/presentation/components/Scripts/View/Cards/NonCollapsingDirective';
|
||||
import FlatButton from '@/presentation/components/Shared/FlatButton.vue';
|
||||
|
||||
export default defineComponent({
|
||||
directives: { NonCollapsing },
|
||||
components: { FlatButton },
|
||||
props: {
|
||||
enabled: {
|
||||
type: Boolean,
|
||||
@@ -48,18 +48,3 @@ export default defineComponent({
|
||||
},
|
||||
});
|
||||
</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>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
</div>
|
||||
<div class="card__expander" @click.stop>
|
||||
<div class="card__expander__close-button">
|
||||
<AppIcon
|
||||
<FlatButton
|
||||
icon="xmark"
|
||||
@click="collapse()"
|
||||
/>
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
defineComponent, computed, shallowRef,
|
||||
} from 'vue';
|
||||
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
|
||||
import FlatButton from '@/presentation/components/Shared/FlatButton.vue';
|
||||
import { injectKey } from '@/presentation/injectionSymbols';
|
||||
import ScriptsTree from '@/presentation/components/Scripts/View/Tree/ScriptsTree.vue';
|
||||
import { sleep } from '@/infrastructure/Threading/AsyncSleep';
|
||||
@@ -61,6 +62,7 @@ export default defineComponent({
|
||||
ScriptsTree,
|
||||
AppIcon,
|
||||
CardSelectionIndicator,
|
||||
FlatButton,
|
||||
},
|
||||
props: {
|
||||
categoryId: {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
class="search__query__close-button"
|
||||
@click="clearSearchQuery()"
|
||||
>
|
||||
<AppIcon icon="xmark" />
|
||||
<FlatButton icon="xmark" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!searchHasMatches" class="search-no-matches">
|
||||
@@ -39,19 +39,19 @@
|
||||
import {
|
||||
defineComponent, PropType, ref, computed,
|
||||
} from 'vue';
|
||||
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
|
||||
import { injectKey } from '@/presentation/injectionSymbols';
|
||||
import ScriptsTree from '@/presentation/components/Scripts/View/Tree/ScriptsTree.vue';
|
||||
import CardList from '@/presentation/components/Scripts/View/Cards/CardList.vue';
|
||||
import { ViewType } from '@/presentation/components/Scripts/Menu/View/ViewType';
|
||||
import { IReadOnlyUserFilter } from '@/application/Context/State/Filter/IUserFilter';
|
||||
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
|
||||
import FlatButton from '@/presentation/components/Shared/FlatButton.vue';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
ScriptsTree,
|
||||
CardList,
|
||||
AppIcon,
|
||||
FlatButton,
|
||||
},
|
||||
props: {
|
||||
currentView: {
|
||||
@@ -149,14 +149,10 @@ $margin-inner: 4px;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-top: 1em;
|
||||
color: $color-primary;
|
||||
color: $color-primary-light;
|
||||
.search__query__close-button {
|
||||
@include clickable;
|
||||
font-size: 1.25em;
|
||||
margin-left: 0.25rem;
|
||||
@include hover-or-touch {
|
||||
color: $color-primary-dark;
|
||||
}
|
||||
}
|
||||
}
|
||||
.search-no-matches {
|
||||
|
||||
@@ -128,7 +128,7 @@ $base-spacing: $text-size;
|
||||
*/
|
||||
@include hover-or-touch {
|
||||
&::after{
|
||||
background-color: $globals-color-hover;
|
||||
background-color: $color-highlight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
<template>
|
||||
<a
|
||||
class="button"
|
||||
target="_blank"
|
||||
:class="{ 'button-on': isOn }"
|
||||
<div
|
||||
class="documentation-button"
|
||||
:class="{ expanded: isOn }"
|
||||
@click.stop
|
||||
@click="toggle()"
|
||||
>
|
||||
<AppIcon icon="circle-info" />
|
||||
</a>
|
||||
<FlatButton
|
||||
icon="circle-info"
|
||||
@click="toggle()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
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({
|
||||
components: {
|
||||
AppIcon,
|
||||
FlatButton,
|
||||
},
|
||||
emits: [
|
||||
'show',
|
||||
@@ -45,14 +46,15 @@ export default defineComponent({
|
||||
<style scoped lang="scss">
|
||||
@use "@/presentation/assets/styles/main" as *;
|
||||
|
||||
.button {
|
||||
@include clickable;
|
||||
.documentation-button {
|
||||
vertical-align: middle;
|
||||
color: $color-primary;
|
||||
@include hover-or-touch {
|
||||
color: $color-primary-darker;
|
||||
:deep() { // This override leads to inconsistent highlight color, it should be re-styled.
|
||||
@include hover-or-touch {
|
||||
color: $color-primary-darker;
|
||||
}
|
||||
}
|
||||
&-on {
|
||||
&.expanded {
|
||||
color: $color-primary-light;
|
||||
}
|
||||
}
|
||||
|
||||
73
src/presentation/components/Shared/FlatButton.vue
Normal file
73
src/presentation/components/Shared/FlatButton.vue
Normal 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>
|
||||
@@ -6,25 +6,24 @@
|
||||
<div class="dialog__content">
|
||||
<slot />
|
||||
</div>
|
||||
<div
|
||||
<FlatButton
|
||||
icon="xmark"
|
||||
class="dialog__close-button"
|
||||
@click="hide"
|
||||
>
|
||||
<AppIcon icon="xmark" />
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
</ModalContainer>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
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';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
ModalContainer,
|
||||
AppIcon,
|
||||
FlatButton,
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
@@ -72,16 +71,12 @@ export default defineComponent({
|
||||
margin: 5%;
|
||||
}
|
||||
|
||||
&__close-button {
|
||||
.dialog__close-button {
|
||||
color: $color-primary-dark;
|
||||
width: auto;
|
||||
font-size: 1.5em;
|
||||
margin-right: 0.25em;
|
||||
align-self: flex-start;
|
||||
@include clickable;
|
||||
@include hover-or-touch {
|
||||
color: $color-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -64,7 +64,6 @@ function hasDesktopVersion(os: OperatingSystem): boolean {
|
||||
<style scoped lang="scss">
|
||||
@use "@/presentation/assets/styles/main" as *;
|
||||
.url {
|
||||
@include clickable;
|
||||
&__active {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
@@ -32,8 +32,12 @@
|
||||
</a>
|
||||
</div>
|
||||
<div class="footer__section__item">
|
||||
<AppIcon class="icon" icon="user-secret" />
|
||||
<a @click="showPrivacyDialog()">Privacy</a>
|
||||
<FlatButton
|
||||
label="Privacy"
|
||||
icon="user-secret"
|
||||
flat
|
||||
@click="showPrivacyDialog()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -50,6 +54,7 @@ import {
|
||||
import ModalDialog from '@/presentation/components/Shared/Modal/ModalDialog.vue';
|
||||
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
|
||||
import { injectKey } from '@/presentation/injectionSymbols';
|
||||
import FlatButton from '@/presentation/components/Shared/FlatButton.vue';
|
||||
import DownloadUrlList from './DownloadUrlList.vue';
|
||||
import PrivacyPolicy from './PrivacyPolicy.vue';
|
||||
|
||||
@@ -59,6 +64,7 @@ export default defineComponent({
|
||||
PrivacyPolicy,
|
||||
DownloadUrlList,
|
||||
AppIcon,
|
||||
FlatButton,
|
||||
},
|
||||
setup() {
|
||||
const { info } = injectKey((keys) => keys.useApplication);
|
||||
@@ -99,7 +105,6 @@ export default defineComponent({
|
||||
|
||||
.icon {
|
||||
margin-right: 0.5em;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer {
|
||||
|
||||
@@ -16,7 +16,7 @@ describe('Modal interaction and layout stability', () => {
|
||||
});
|
||||
|
||||
cy
|
||||
.contains('a', 'Privacy')
|
||||
.contains('button', 'Privacy')
|
||||
.click();
|
||||
|
||||
cy
|
||||
|
||||
@@ -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;
|
||||
|
||||
167
tests/unit/presentation/components/Shared/FlatButton.spec.ts
Normal file
167
tests/unit/presentation/components/Shared/FlatButton.spec.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user