diff --git a/packages/ui/src/components/va-alert/useAlertStyles.ts b/packages/ui/src/components/va-alert/useAlertStyles.ts index fbf279e5ae..a7065b70a5 100644 --- a/packages/ui/src/components/va-alert/useAlertStyles.ts +++ b/packages/ui/src/components/va-alert/useAlertStyles.ts @@ -1,5 +1,5 @@ import { computed, toRef } from 'vue' -import { useColors, useTextColor } from '../../composables' +import { useColors, useCurrentElement, useElementBackground, useElementTextColor, useTextColor } from '../../composables' type AlertStyleProps = { modelValue: boolean, @@ -46,10 +46,12 @@ export const useAlertStyles = (props: AlertStyleProps) => { } }) + const currentColor = useElementTextColor(useElementBackground(useCurrentElement())) + const contentStyle = computed(() => { return { alignItems: props.center ? 'center' : '', - color: (props.border || props.outline) ? 'currentColor' : textColorComputed.value, + color: (props.border || props.outline) ? currentColor : textColorComputed.value, } }) diff --git a/packages/ui/src/components/va-date-picker/components/VaDatePickerHeader/VaDatePickerHeader.vue b/packages/ui/src/components/va-date-picker/components/VaDatePickerHeader/VaDatePickerHeader.vue index 9dd2289895..af225eea34 100644 --- a/packages/ui/src/components/va-date-picker/components/VaDatePickerHeader/VaDatePickerHeader.vue +++ b/packages/ui/src/components/va-date-picker/components/VaDatePickerHeader/VaDatePickerHeader.vue @@ -10,7 +10,7 @@ preset="plain" size="small" :color="color" - :textColor="'currentColor'" + :textColor="currentColor" :aria-label="tp($props.ariaPreviousPeriodLabel)" round @click="prev" @@ -24,7 +24,7 @@ preset="plain" size="small" :color="color" - :textColor="'currentColor'" + :textColor="currentColor" :aria-label="tp($props.ariaSwitchViewLabel)" @click="switchView" > @@ -44,7 +44,7 @@ preset="plain" size="small" :color="color" - :textColor="'currentColor'" + :textColor="currentColor" :aria-label="tp($props.ariaNextPeriodLabel)" @click="next" round @@ -61,7 +61,7 @@ import { useView } from '../../hooks/view' import { DatePickerView } from '../../types' import { VaButton } from '../../../va-button' -import { useTranslation } from '../../../../composables' +import { useCurrentElement, useElementTextColor, useElementBackground, useTranslation } from '../../../../composables' export default defineComponent({ name: 'VaDatePickerHeader', @@ -93,8 +93,11 @@ export default defineComponent({ syncView.value = view } + const currentColor = useElementTextColor(useElementBackground(useCurrentElement())) + return { ...useTranslation(), + currentColor, prev, next, changeView, diff --git a/packages/ui/src/composables/UseElementBackgroundDummy.vue b/packages/ui/src/composables/UseElementBackgroundDummy.vue new file mode 100644 index 0000000000..768e1474a5 --- /dev/null +++ b/packages/ui/src/composables/UseElementBackgroundDummy.vue @@ -0,0 +1,15 @@ + + + diff --git a/packages/ui/src/composables/index.ts b/packages/ui/src/composables/index.ts index 029a9caa78..07f4800765 100644 --- a/packages/ui/src/composables/index.ts +++ b/packages/ui/src/composables/index.ts @@ -72,3 +72,5 @@ export * from './useGlobalConfig' export * from './usePlacementAliases' export * from './useDropdownable' export * from './useTeleported' +export * from './useElementTextColor' +export * from './useElementBackground' diff --git a/packages/ui/src/composables/useElementBackground.stories.ts b/packages/ui/src/composables/useElementBackground.stories.ts new file mode 100644 index 0000000000..febc1d3a6f --- /dev/null +++ b/packages/ui/src/composables/useElementBackground.stories.ts @@ -0,0 +1,204 @@ +import { defineComponent, computed, ref } from 'vue' +import { useElementBackground } from './useElementBackground' +import { useCurrentElement } from './useCurrentElement' +import { VaButton } from '../components/va-button' +import UseElementBackgroundDummy from './UseElementBackgroundDummy.vue' +import { within } from '@storybook/testing-library' +import { sleep } from '../utils/sleep' +import { expect } from '@storybook/jest' + +export default { + title: 'composables/useElementBackground', +} + +export const Default = () => ({ + components: { VaButton, UseElementBackgroundDummy }, + data () { + return { + background: '#000', + color: null, + } + }, + template: ` + +

Color detected: {{color}}

+ black + white +`, +}) + +Default.play = async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + const color = canvas.getByTestId('color') as HTMLElement + const black = canvas.getByText('black') as HTMLElement + const white = canvas.getByText('white') as HTMLElement + + await sleep() + + await step('should match white', async () => { + white.click() + // Timing before update seems pretty random. + // That might be something we want to account for on usage. + // 20 ms was a sweet spot for me, 10 was too low. + await sleep(20) + expect(color.innerText).toBe('rgba255, 255, 255, 1') + }) + await step('should match black', async () => { + black.click() + await sleep(20) + expect(color.innerText).toBe('rgba0, 0, 0, 1') + }) +} + +export const WithTransition = () => ({ + data () { + return { + styles: { + background: '#000', + color: '#fff', + }, + } + }, + + setup () { + return { + color: useElementBackground(useCurrentElement()), + } + }, + + template: ` +
+ + + +
Current color: {{ color }}
+
+`, +}) + +export const WithOpacity = () => ({ + data () { + return { + styles: { + background: 'rgba(0, 0, 0, 0.5)', + color: '#fff', + }, + } + }, + + setup () { + return { + color: useElementBackground(useCurrentElement()), + } + }, + + template: ` +
+ + + +
Current color: {{ color }}
+
+`, +}) + +export const WithOpacityAndParentWithDynamicBg = () => ({ + data () { + return { + styles: { + background: 'red', + color: '#fff', + }, + } + }, + + components: { + Child: defineComponent({ + setup () { + return { + color: useElementBackground(useCurrentElement()), + } + }, + template: '
Current color: {{ color }}
', + }), + }, + + template: ` +
+ + + + +
+`, +}) + +export const MultipleParentWithDynamicBg = () => ({ + data () { + return { + styles: { + background: 'red', + color: '#fff', + }, + } + }, + + components: { + Child: defineComponent({ + setup () { + return { + color: useElementBackground(useCurrentElement()), + } + }, + template: '
Current color: {{ color }}
', + }), + }, + + template: ` +
+
+ + + + +
+ +
+
+
+`, +}) + +export const WithOpacityAndWillChange = () => ({ + data () { + return { + styles: { + background: 'red', + color: '#fff', + }, + } + }, + + components: { + Child: defineComponent({ + setup () { + return { + color: useElementBackground(useCurrentElement()), + } + }, + template: '
Current color: {{ color }}
', + }), + }, + + template: ` +
+ + + + +
+`, +}) diff --git a/packages/ui/src/composables/useElementBackground.ts b/packages/ui/src/composables/useElementBackground.ts new file mode 100644 index 0000000000..91285604f4 --- /dev/null +++ b/packages/ui/src/composables/useElementBackground.ts @@ -0,0 +1,149 @@ +import { Ref, ref, watchEffect } from 'vue' + +/** + * This module works with element background + * Finding element background is not so easy, because it may be transparent + * So we need to find all parent elements with background and apply them + * It is not performant, but it is the only way to do it + * So there is used ugly code which work fast enough + */ + +/** Value returned from window.getComputedStyle(el).backgroundColor */ +type RGBAColorString = `rgba(${number}, ${number}, ${number}, $${number})` +/** Parsed value [r, g, b, a] */ +type RGBAColorParsed = [number, number, number, number] + +const parseRgba = (rgba: RGBAColorString): RGBAColorParsed => { + let values: any[] + + if (rgba.startsWith('rgba')) { + values = rgba + .substring(5, rgba.length - 1) // Remove 'rgba(' and ')' + .split(',') + } else { + values = rgba + .substring(4, rgba.length - 1) // Remove 'rgb(' and ')' + .split(',') + } + + values[0] = Number(values[0]) + values[1] = Number(values[1]) + values[2] = Number(values[2]) + if (values[3] === undefined) { + values[3] = 1 + } else { + values[3] = Number(values[3]) + } + + return values as RGBAColorParsed +} + +const toHex = (color: RGBAColorParsed): string => { + return '#' + + (color[0] | 1 << 8).toString(16).slice(1) + + (color[1] | 1 << 8).toString(16).slice(1) + + (color[2] | 1 << 8).toString(16).slice(1) + + (color[3] * 255 | 1 << 8).toString(16).slice(1) +} + +const getParentsWithBackground = (el: HTMLElement): HTMLElement[] => { + const parents = [] + + let currentEl: HTMLElement | null = el + while (currentEl) { + if (!(currentEl instanceof HTMLElement) || !currentEl) { + return parents + } + + const { backgroundColor, willChange } = window.getComputedStyle(currentEl) + + const bgWillChange = willChange.includes('background') + + const parsedColor = parseRgba(backgroundColor as RGBAColorString) + + // In case color is not transparent, we can stop + if (parsedColor[3] === 1 && !bgWillChange) { + parents.push(currentEl) + return parents + } + + // If color is not fully transparent we need to add it to parents + if (parsedColor[3] !== 0 || bgWillChange) { + parents.push(currentEl) + } + + currentEl = currentEl.parentElement + } + + return parents +} + +// Add fake transition to element to make it trigger transitionend event, add as first class to not break other transitions +const WATCHER_CLASS = 'va-background-watcher' + +const watchElementBackground = (el: HTMLElement, cb: () => void) => { + el.className = WATCHER_CLASS + ' ' + el.className + + el.addEventListener('transitionend', (e) => { + if (e.target !== el) { return } + cb() + }) + + return () => { + el.className = el.className.replace(WATCHER_CLASS, '') + el.removeEventListener('transitionend', cb) + } +} + +const watchElementsBackground = (els: HTMLElement[], cb: () => void) => { + const unwatchers = els.map((el) => watchElementBackground(el, cb)) + + return () => { + unwatchers.forEach((unwatch) => unwatch()) + } +} + +/** It is not ideal. browser applies colors in a bit different way */ +const applyColors = (color1: RGBAColorParsed, color2: RGBAColorParsed): RGBAColorParsed => { + const weight = color2[3] + + if (weight === 1) { return color2 } + if (weight === 0) { return color1 } + + const c1 = Math.round((color1[0]) * (1 - weight) + (color2[0]) * weight) + const c2 = Math.round((color1[1]) * (1 - weight) + (color2[1]) * weight) + const c3 = Math.round((color1[2]) * (1 - weight) + (color2[2]) * weight) + + return [c1, c2, c3, 1] +} + +const getColorFromElements = (els: HTMLElement[]): RGBAColorParsed => { + let currentColor = [0, 0, 0, 0] as RGBAColorParsed + + for (let i = els.length - 1; i >= 0; i--) { + currentColor = applyColors(currentColor, parseRgba(window.getComputedStyle(els[i]).backgroundColor as RGBAColorString)) + } + + return currentColor +} + +export const useElementBackground = (el: Ref) => { + const color = ref('#000000') + let unWatchAll = () => void 0 as void + + watchEffect(() => { + unWatchAll() + + if (el.value) { + const parents = getParentsWithBackground(el.value) + + unWatchAll = watchElementsBackground(parents, () => { + color.value = toHex(getColorFromElements(parents)) + }) + + color.value = toHex(getColorFromElements(parents)) + } + }) + + return color +} diff --git a/packages/ui/src/styles/essential.scss b/packages/ui/src/styles/essential.scss index 9bbb8d7c79..9adaf37278 100644 --- a/packages/ui/src/styles/essential.scss +++ b/packages/ui/src/styles/essential.scss @@ -1,2 +1,6 @@ @import './global/theme.scss'; @import './global/css-variables.scss'; + +.va-background-watcher { + transition: 0.01s background-color linear; +} \ No newline at end of file diff --git a/packages/ui/src/styles/index.scss b/packages/ui/src/styles/index.scss index 9b73f0dfbc..db942f4d48 100644 --- a/packages/ui/src/styles/index.scss +++ b/packages/ui/src/styles/index.scss @@ -2,3 +2,4 @@ @import "global/index.scss"; // Mixins, functions and variables @import "resources/index.scss"; +@import "essential.scss"; \ No newline at end of file