mirror of
https://github.com/nextcloud/server.git
synced 2026-05-28 04:32:30 -04:00
fix(workflowengine): use proper contrast colors for operations
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
parent
eec0f103ef
commit
00e1355855
5 changed files with 481 additions and 28 deletions
408
apps/workflowengine/src/components/Operation.spec.ts
Normal file
408
apps/workflowengine/src/components/Operation.spec.ts
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { cleanup, render } from '@testing-library/vue'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import Operation from './Operation.vue'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe('Operation.vue', () => {
|
||||
const mockOperation = {
|
||||
name: 'Test Operation',
|
||||
description: 'This is a test operation',
|
||||
iconClass: 'icon-test',
|
||||
icon: '',
|
||||
}
|
||||
|
||||
it('renders operation with required props', () => {
|
||||
const { getByRole } = render(Operation, {
|
||||
props: {
|
||||
operation: mockOperation,
|
||||
},
|
||||
})
|
||||
|
||||
expect(getByRole('heading', { level: 3 })).toBeTruthy()
|
||||
expect(getByRole('heading', { level: 3 }).textContent).toBe('Test Operation')
|
||||
})
|
||||
|
||||
it('displays operation name and description', () => {
|
||||
const { getByText } = render(Operation, {
|
||||
props: {
|
||||
operation: mockOperation,
|
||||
},
|
||||
})
|
||||
|
||||
expect(getByText('Test Operation')).toBeTruthy()
|
||||
expect(getByText('This is a test operation')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders icon with iconClass', () => {
|
||||
const { container } = render(Operation, {
|
||||
props: {
|
||||
operation: mockOperation,
|
||||
},
|
||||
})
|
||||
|
||||
const icon = container.querySelector('.icon')
|
||||
expect(icon).toBeTruthy()
|
||||
expect(icon?.classList.contains('icon-test')).toBe(true)
|
||||
})
|
||||
|
||||
it('renders icon with background image when no iconClass', () => {
|
||||
const { container } = render(Operation, {
|
||||
props: {
|
||||
operation: {
|
||||
name: 'Test Operation',
|
||||
description: 'Description',
|
||||
iconClass: '',
|
||||
icon: 'data:image/svg+xml;base64,test',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const icon = container.querySelector<HTMLElement>('.icon')
|
||||
expect(icon).toBeTruthy()
|
||||
expect(icon?.style.backgroundImage).toContain('data:image/svg+xml;base64,test')
|
||||
})
|
||||
|
||||
it('does not show button when colored is false', () => {
|
||||
const { queryByRole } = render(Operation, {
|
||||
props: {
|
||||
operation: mockOperation,
|
||||
colored: false,
|
||||
},
|
||||
})
|
||||
|
||||
expect(queryByRole('button')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('shows button with correct text when colored is true', () => {
|
||||
const { getByRole } = render(Operation, {
|
||||
props: {
|
||||
operation: mockOperation,
|
||||
colored: true,
|
||||
},
|
||||
})
|
||||
|
||||
const button = getByRole('button')
|
||||
expect(button).toBeTruthy()
|
||||
expect(button.textContent).toContain('Add new flow')
|
||||
})
|
||||
|
||||
it('applies colored class when colored prop is true', () => {
|
||||
const { container } = render(Operation, {
|
||||
props: {
|
||||
operation: mockOperation,
|
||||
colored: true,
|
||||
},
|
||||
})
|
||||
|
||||
const item = container.querySelector('.actions__item')
|
||||
expect(item?.classList.contains('colored')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not apply colored class when colored prop is false', () => {
|
||||
const { container } = render(Operation, {
|
||||
props: {
|
||||
operation: mockOperation,
|
||||
colored: false,
|
||||
},
|
||||
})
|
||||
|
||||
const item = container.querySelector('.actions__item')
|
||||
expect(item?.classList.contains('colored')).toBe(false)
|
||||
})
|
||||
|
||||
it('renders slot content', () => {
|
||||
const { getByText } = render(Operation, {
|
||||
props: {
|
||||
operation: mockOperation,
|
||||
},
|
||||
slots: {
|
||||
default: '<div>Slot content</div>',
|
||||
},
|
||||
})
|
||||
|
||||
expect(getByText('Slot content')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('applies background color when colored is true and color is provided', async () => {
|
||||
const { container } = render(Operation, {
|
||||
props: {
|
||||
operation: {
|
||||
...mockOperation,
|
||||
color: '#ff0000',
|
||||
},
|
||||
colored: true,
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(getComponentStyles(container).backgroundColor).toBe('#ff0000')
|
||||
})
|
||||
|
||||
it('updates styles when operation color changes', async () => {
|
||||
const { updateProps, container } = render(Operation, {
|
||||
props: {
|
||||
operation: mockOperation,
|
||||
colored: false,
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(getComponentStyles(container).backgroundColor).toBe('transparent')
|
||||
|
||||
await updateProps({
|
||||
operation: { ...mockOperation, color: '#00ff00' },
|
||||
colored: true,
|
||||
})
|
||||
|
||||
expect(getComponentStyles(container).backgroundColor).not.toBe('transparent')
|
||||
})
|
||||
|
||||
describe('backgroundColor watcher', () => {
|
||||
it('sets text color to var(--color-main-text) when background is transparent', async () => {
|
||||
const { container } = render(Operation, {
|
||||
props: {
|
||||
operation: mockOperation,
|
||||
colored: false,
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(getComponentStyles(container).color).toBe('var(--color-main-text)')
|
||||
})
|
||||
|
||||
it('sets text color to var(--color-primary-element-text) when background is var(--color-primary-element)', async () => {
|
||||
const { container } = render(Operation, {
|
||||
props: {
|
||||
operation: mockOperation,
|
||||
colored: true,
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(getComponentStyles(container).color).toBe('var(--color-primary-element-text)')
|
||||
})
|
||||
|
||||
it('sets text color to white (#ffffff) for dark background colors (high contrast)', async () => {
|
||||
const { container } = render(Operation, {
|
||||
props: {
|
||||
operation: {
|
||||
...mockOperation,
|
||||
color: '#000000',
|
||||
},
|
||||
colored: true,
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(getComponentStyles(container).color).toBe('#ffffff')
|
||||
})
|
||||
|
||||
it('sets text color to black (#000000) for light background colors (low contrast)', async () => {
|
||||
const { container } = render(Operation, {
|
||||
props: {
|
||||
operation: {
|
||||
...mockOperation,
|
||||
color: '#ffffff',
|
||||
},
|
||||
colored: true,
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(getComponentStyles(container).color).toBe('#000000')
|
||||
})
|
||||
|
||||
it('calculates color based on contrast for gray backgrounds', async () => {
|
||||
const { container } = render(Operation, {
|
||||
props: {
|
||||
operation: {
|
||||
...mockOperation,
|
||||
color: '#808080',
|
||||
},
|
||||
colored: true,
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
// Gray has contrast ratio < 4.5 with white, so should use black
|
||||
expect(getComponentStyles(container).color).toBe('#000000')
|
||||
})
|
||||
|
||||
it('applies invert filter to icon when text color is black', async () => {
|
||||
const { container } = render(Operation, {
|
||||
props: {
|
||||
operation: {
|
||||
...mockOperation,
|
||||
color: '#ffffff',
|
||||
},
|
||||
colored: true,
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(getComponentStyles(container).filter).toBe('invert(100%)')
|
||||
})
|
||||
|
||||
it('does not apply invert filter to icon when text color is not black', async () => {
|
||||
const { container } = render(Operation, {
|
||||
props: {
|
||||
operation: {
|
||||
...mockOperation,
|
||||
color: '#000000',
|
||||
},
|
||||
colored: true,
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(getComponentStyles(container).filter).toBe('none')
|
||||
})
|
||||
|
||||
it('does not apply invert filter when using CSS variable colors', async () => {
|
||||
const { container } = render(Operation, {
|
||||
props: {
|
||||
operation: mockOperation,
|
||||
colored: true,
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(getComponentStyles(container).filter).toBe('none')
|
||||
})
|
||||
|
||||
it('handles contrast calculation error gracefully', async () => {
|
||||
const { container } = render(Operation, {
|
||||
props: {
|
||||
operation: {
|
||||
...mockOperation,
|
||||
color: 'invalid-color',
|
||||
},
|
||||
colored: true,
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
// Should fallback to var(--color-main-text) on error
|
||||
expect(getComponentStyles(container).color).toBe('var(--color-main-text)')
|
||||
})
|
||||
|
||||
it('updates text color reactively when operation color changes', async () => {
|
||||
const { container, updateProps } = render(Operation, {
|
||||
props: {
|
||||
operation: {
|
||||
...mockOperation,
|
||||
color: '#ffffff',
|
||||
},
|
||||
colored: true,
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(getComponentStyles(container).color).toBe('#000000')
|
||||
|
||||
await updateProps({
|
||||
operation: {
|
||||
...mockOperation,
|
||||
color: '#000000',
|
||||
},
|
||||
colored: true,
|
||||
})
|
||||
|
||||
expect(getComponentStyles(container).color).toBe('#ffffff')
|
||||
})
|
||||
|
||||
it('transitions from colored to uncolored updates text color', async () => {
|
||||
const { container, updateProps } = render(Operation, {
|
||||
props: {
|
||||
operation: {
|
||||
...mockOperation,
|
||||
color: '#000000',
|
||||
},
|
||||
colored: true,
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(getComponentStyles(container).color).toBe('#ffffff')
|
||||
|
||||
await updateProps({
|
||||
operation: mockOperation,
|
||||
colored: false,
|
||||
})
|
||||
|
||||
expect(getComponentStyles(container).color).toBe('var(--color-main-text)')
|
||||
})
|
||||
|
||||
it('uses computed style for non-hex background colors', async () => {
|
||||
const { container } = render(Operation, {
|
||||
props: {
|
||||
operation: mockOperation,
|
||||
colored: true,
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
// When colored=true and no color property, uses var(--color-primary-element)
|
||||
// which is a CSS variable, so it uses the computed style path
|
||||
expect(getComponentStyles(container).color).toBe('var(--color-primary-element-text)')
|
||||
})
|
||||
|
||||
it('watcher runs with immediate:true on mount', async () => {
|
||||
const { container } = render(Operation, {
|
||||
props: {
|
||||
operation: {
|
||||
...mockOperation,
|
||||
color: '#000000',
|
||||
},
|
||||
colored: true,
|
||||
},
|
||||
})
|
||||
|
||||
// The watcher should have already run with immediate: true
|
||||
// so color should be calculated even before nextTick
|
||||
expect(getComponentStyles(container).color).toBeTruthy()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Get the computed styles of the component for testing purposes
|
||||
*
|
||||
* @param container - The container element
|
||||
*/
|
||||
function getComponentStyles(container: Element) {
|
||||
const element = container.querySelector<HTMLElement>('.actions__item')
|
||||
const styles = Object.values({ ...element!.style }) // variables are exposed as --HASH-VARNAME
|
||||
console.error(styles)
|
||||
const color = element?.style.getPropertyValue(styles.find((key) => (key as string).endsWith('-color'))!.toString())
|
||||
const backgroundColor = element?.style.getPropertyValue(styles.find((key) => (key as string).endsWith('-backgroundColor'))!.toString())
|
||||
const filter = element?.style.getPropertyValue(styles.find((key) => (key as string).endsWith('-iconFilter'))!.toString())
|
||||
return {
|
||||
color,
|
||||
backgroundColor,
|
||||
filter,
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,10 @@
|
|||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
<template>
|
||||
<div class="actions__item" :class="{ colored: colored }" :style="{ backgroundColor: colored ? operation.color : 'transparent' }">
|
||||
<div
|
||||
ref="operationElement"
|
||||
class="actions__item"
|
||||
:class="{ colored: colored }">
|
||||
<div class="icon" :class="operation.iconClass" :style="{ backgroundImage: operation.iconClass ? '' : `url(${operation.icon})` }" />
|
||||
<div class="actions__item__description">
|
||||
<h3>{{ operation.name }}</h3>
|
||||
|
|
@ -18,30 +21,69 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup lang="ts">
|
||||
/* eslint vue/multi-word-component-names: "warn" */
|
||||
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import Color from 'color'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
|
||||
export default {
|
||||
/* eslint vue/multi-word-component-names: "warn" */
|
||||
name: 'Operation',
|
||||
components: {
|
||||
NcButton,
|
||||
},
|
||||
const props = defineProps<{
|
||||
operation: Record<string, string>
|
||||
colored?: boolean
|
||||
}>()
|
||||
|
||||
props: {
|
||||
operation: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
const operationElement = ref<HTMLDivElement>()
|
||||
const color = ref('var(--color-main-text)')
|
||||
const backgroundColor = computed(() => props.colored ? (props.operation.color || 'var(--color-primary-element)') : 'transparent')
|
||||
|
||||
colored: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
watch(backgroundColor, async () => {
|
||||
if (backgroundColor.value === 'transparent') {
|
||||
color.value = 'var(--color-main-text)'
|
||||
return
|
||||
} else if (backgroundColor.value === 'var(--color-primary-element)') {
|
||||
color.value = 'var(--color-primary-element-text)'
|
||||
return
|
||||
}
|
||||
|
||||
let bgColor = backgroundColor.value
|
||||
if (!bgColor.startsWith('#')) {
|
||||
await nextTick()
|
||||
bgColor = window.getComputedStyle(operationElement.value!).backgroundColor
|
||||
}
|
||||
try {
|
||||
const contrast = Color(bgColor).contrast(Color('#ffffff'))
|
||||
color.value = contrast > 4.5 ? '#ffffff' : '#000000'
|
||||
} catch {
|
||||
color.value = 'var(--color-main-text)'
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
/**
|
||||
* Filter to apply to the icon to make it accessible on the given background color.
|
||||
*/
|
||||
const iconFilter = computed(() => {
|
||||
if (color.value === '#000000') {
|
||||
return 'invert(100%)'
|
||||
}
|
||||
return 'none'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "./../styles/operation.scss" as *;
|
||||
|
||||
.actions__item {
|
||||
color: v-bind('color');
|
||||
background-color: v-bind('backgroundColor');
|
||||
|
||||
h3 {
|
||||
color: v-bind('color');
|
||||
}
|
||||
|
||||
.icon {
|
||||
filter: v-bind('iconFilter');
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@
|
|||
</div>
|
||||
<div class="flow-icon icon-confirm" />
|
||||
<div class="action">
|
||||
<Operation :operation="operation" :colored="false">
|
||||
<Operation :operation="operation">
|
||||
<component
|
||||
:is="operation.element"
|
||||
v-if="operation.element"
|
||||
|
|
|
|||
|
|
@ -28,13 +28,14 @@
|
|||
v-for="operation in mainOperations"
|
||||
:key="operation.id"
|
||||
:operation="operation"
|
||||
colored
|
||||
@click.native="createNewRule(operation)" />
|
||||
<a
|
||||
v-if="showAppStoreHint"
|
||||
key="add"
|
||||
:href="appstoreUrl"
|
||||
class="actions__item colored more">
|
||||
<div class="icon icon-add" />
|
||||
<NcIconSvgWrapper class="actions__itemMore__icon" :path="mdiPlus" :size="50" />
|
||||
<div class="actions__item__description">
|
||||
<h3>{{ t('workflowengine', 'More flows') }}</h3>
|
||||
<small>{{ t('workflowengine', 'Browse the App Store') }}</small>
|
||||
|
|
@ -69,6 +70,7 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import { mdiPlus } from '@mdi/js'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { mapGetters, mapState } from 'vuex'
|
||||
|
|
@ -100,6 +102,10 @@ export default {
|
|||
Rule,
|
||||
},
|
||||
|
||||
setup() {
|
||||
return { mdiPlus }
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
showMoreOperations: false,
|
||||
|
|
@ -186,6 +192,10 @@ export default {
|
|||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.actions__itemMore__icon {
|
||||
margin-block: 10px;
|
||||
}
|
||||
|
||||
.slide-enter-active {
|
||||
-moz-transition-duration: 0.3s;
|
||||
-webkit-transition-duration: 0.3s;
|
||||
|
|
|
|||
|
|
@ -54,13 +54,6 @@ small {
|
|||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.colored:not(.more) {
|
||||
background-color: var(--color-primary-element);
|
||||
h3, small {
|
||||
color: var(--color-primary-element-text)
|
||||
}
|
||||
}
|
||||
|
||||
.actions__item:not(.colored) {
|
||||
flex-direction: row;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue