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
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,7 +116,6 @@ interface DevAction {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
button {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -133,5 +130,6 @@ interface DevAction {
|
|||||||
color: $color-on-secondary;
|
color: $color-on-secondary;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
:deep() { // This override leads to inconsistent highlight color, it should be re-styled.
|
||||||
@include hover-or-touch {
|
@include hover-or-touch {
|
||||||
color: $color-primary-darker;
|
color: $color-primary-darker;
|
||||||
}
|
}
|
||||||
&-on {
|
}
|
||||||
|
&.expanded {
|
||||||
color: $color-primary-light;
|
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">
|
<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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ describe('Modal interaction and layout stability', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
cy
|
cy
|
||||||
.contains('a', 'Privacy')
|
.contains('button', 'Privacy')
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
cy
|
cy
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
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