Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions apps/theming/tests/Themes/AccessibleThemeTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,16 @@ public static function dataAccessibilityPairs(): array {
],
$textContrast,
],
'primary-element on primary-element-light' => [
[
'--color-primary-element',
],
[
'--color-primary-element-light',
'--color-primary-element-light-hover',
],
$elementContrast,
],
'main-text' => [
['--color-main-text'],
[
Expand Down Expand Up @@ -231,4 +241,43 @@ public function testAccessibilityOfVariables(array $mainColors, array $backgroun
}
}
}

/**
* AppIcon.vue gradients are not plain variables the pairs above can see, so
* rebuild the lightest stop of each. Util::mix() weights its first colour at
* ($factor + 100) / 200, which is how the CSS percentages map onto $factor.
*/
public function testAppMenuIconGradientContrast(): void {
if (!isset($this->theme) || !isset($this->util)) {
$this->markTestSkipped('You need to setup $this->theme and $this->util in your setUp function');
}

$variables = $this->theme->getCSSVariables();
$resolve = function (string $name) use ($variables): string {
$matches = [];
if (preg_match('/^var\\(([^)]+)\\)$/', $variables[$name], $matches) === 1) {
return $variables[$matches[1]];
}
return $variables[$name];
};

$primaryElement = $resolve('--color-primary-element');
$circle = $resolve('--color-primary-element-light');
$mainBackground = $resolve('--color-main-background');

// color-mix(in srgb, var(--color-primary-element), 28% var(--color-primary-element-light))
$glyphTop = $this->util->mix($primaryElement, $circle, 44);
// color-mix(in srgb, var(--color-primary-element-light), 15% var(--color-main-background))
$circleTop = $this->util->mix($circle, $mainBackground, 70);

// The glyph is centred, so check it against both ends of the circle.
foreach (['circle top' => $circleTop, 'circle bottom' => $circle] as $label => $background) {
$contrast = $this->util->colorContrast($glyphTop, $background);
$this->assertGreaterThanOrEqual(
3.0,
$contrast,
"App menu glyph gradient top ($glyphTop) does not reach 3:1 on the $label ($background), got $contrast",
);
}
}
}
84 changes: 63 additions & 21 deletions core/src/components/AppIcon.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,31 +7,44 @@
<span
class="app-icon"
:class="{ 'app-icon--outlined': outlined }">
<img
<span
v-if="icon"
class="app-icon__img"
:src="icon"
alt=""
aria-hidden="true">
:style="iconStyle"
aria-hidden="true" />
<!-- @slot Overlay positioned over the circle, e.g. an unread badge. -->
<slot />
</span>
</template>

<script setup lang="ts">
withDefaults(defineProps<{
/** URL of the app icon (painted bright on the coloured circle). */
import { computed } from 'vue'

const props = withDefaults(defineProps<{
/** URL of the app icon, used as a CSS mask. */
icon: string
/** Render the circle as an outline only (no fill or gradient). */
outlined?: boolean
}>(), {
outlined: false,
})

// Escaped so a crafted path cannot break out of the url() token.
const iconStyle = computed(() => ({
'--app-icon-url': `url("${props.icon.replace(/["\\]/g, '\\$&')}")`,
}))
</script>

<style scoped lang="scss">
$bevel:
inset 0 -1px 0 0 color-mix(in srgb, var(--color-primary-element-light), 10% var(--color-primary-element)),
inset 0 -4px 6px -4px color-mix(in srgb, var(--color-primary-element-light), 16% var(--color-primary-element));

.app-icon {
--app-icon-circle-size: calc(var(--default-grid-baseline) * 10);
--app-icon-icon-size: 22px;
--app-icon-circle-size: calc(var(--default-grid-baseline) * 12);
// 28px on a 48px circle, so it follows when consumers resize the circle.
--app-icon-icon-size: calc(var(--app-icon-circle-size) * 7 / 12);
--app-icon-bevel: #{$bevel};
box-sizing: border-box;
position: relative;
display: flex;
Expand All @@ -40,24 +53,44 @@ withDefaults(defineProps<{
width: var(--app-icon-circle-size);
height: var(--app-icon-circle-size);
border-radius: 50%;
background-color: var(--color-primary-element);
transform: scale(var(--app-icon-scale, 1));
transition: transform var(--animation-quick) ease-out;
background-color: var(--color-primary-element-light);
background-image: linear-gradient(
to bottom,
rgba(255, 255, 255, 0.18) 0%,
rgba(255, 255, 255, 0) 45%,
rgba(0, 0, 0, 0.15) 100%
color-mix(in srgb, var(--color-primary-element-light), 15% var(--color-main-background)) 0%,
var(--color-primary-element-light) 100%
);
box-shadow:
inset 0 1px 0 0 rgba(255, 255, 255, 0.25),
inset 0 -1px 0 0 rgba(0, 0, 0, 0.2),
0 2px 4px rgba(0, 0, 0, 0.15);
box-shadow: var(--app-icon-bevel);

@media (prefers-color-scheme: dark) {
--app-icon-bevel: none;
}

@media (prefers-reduced-motion: reduce) {
transition: none;
}

&__img {
width: var(--app-icon-icon-size);
height: var(--app-icon-icon-size);
// App icons are bright; flip to dark when the circle background is bright (e.g. white in dark mode).
filter: var(--primary-invert-if-bright);
mask: var(--header-menu-icon-mask);
// Masked rather than shown: app icons ship a hardcoded fill, so
// currentColor never applies and a filter could only flip black and white.
background-color: var(--color-primary-element);
background-image: linear-gradient(
to bottom,
color-mix(in srgb, var(--color-primary-element), 28% var(--color-primary-element-light)) 0%,
var(--color-primary-element) 100%
);
mask: var(--app-icon-url) center / contain no-repeat;
}

// Masked backgrounds are not force-adjusted the way <img> is.
@media (forced-colors: active) {
&__img {
background-color: CanvasText;
background-image: none;
}
}

&--outlined {
Expand All @@ -67,8 +100,17 @@ withDefaults(defineProps<{
}

&--outlined &__img {
filter: var(--background-invert-if-dark);
mask: none;
background-color: var(--color-main-text);
background-image: none;
}
}

// An explicit theme choice must beat the media query above, which only sees the OS.
:global([data-themes*=dark] .app-icon) {
--app-icon-bevel: none;
}

:global([data-themes*=light] .app-icon) {
--app-icon-bevel: #{$bevel};
}
</style>
19 changes: 11 additions & 8 deletions core/src/components/AppItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -72,27 +72,30 @@ const unreadLabel = computed(() => {
flex-direction: column;
align-items: center;
gap: var(--default-grid-baseline);
// Inset so the hover/focus highlight floats around the circle and label
// rather than sitting flush against the icon at the top edge.
// Keeps the grown circle and the focus ring off the tile's edge.
padding-block: var(--default-grid-baseline);
border-radius: var(--border-radius-element);
text-decoration: none;
color: var(--color-main-text);
min-width: 0;

&:hover,
&:focus-visible {
background-color: var(--color-background-hover);
}

// Inset ring instead of outline + offset: the offset version visibly
// clips at the popover's rounded edge for items in the first/last row
// or column. The inset shadow stays inside the highlight rectangle.
// or column. The inset shadow stays inside the tile's own bounds.
&:focus-visible {
outline: none;
box-shadow: inset 0 0 0 2px var(--color-primary-element);
}

&:hover,
&:focus-visible {
--app-icon-scale: 1.08;
}

&:active {
--app-icon-scale: 0.96;
}

&__unread {
position: absolute;
top: 0;
Expand Down
3 changes: 2 additions & 1 deletion core/src/components/AppMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -492,7 +492,7 @@ export default defineComponent({

&__grid {
--app-item-col-width: 69px;
--app-item-row-height: 64px;
--app-item-row-height: 72px;
// border-box: the JS-set max-height (see recomputeGridMaxHeight)
// needs to include padding for the peek math to hold.
box-sizing: border-box;
Expand All @@ -502,6 +502,7 @@ export default defineComponent({
grid-auto-rows: minmax(var(--app-item-row-height), max-content);
// max-height set inline by recomputeGridMaxHeight(); CSS just owns the scroll.
overflow-y: auto;
overflow-x: hidden;

// Extra top padding on first-row tiles so the hover bg reads
// concentric with the popover's rounded top corner. !important
Expand Down
7 changes: 3 additions & 4 deletions core/src/tests/components/SearchResult.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import NcListItem from '@nextcloud/vue/components/NcListItem'
import AppIcon from '../../components/AppIcon.vue'
import SearchResult from '../../components/UnifiedSearch/SearchResult.vue'

function factory(propsData = {}, attrs = {}) {
Expand Down Expand Up @@ -47,12 +48,10 @@ describe('SearchResult icon', () => {
expect(img.attributes('src')).toBe('/apps/settings/img/password.svg')
})

it('renders an app icon (rounded, no thumbnail) as an image', () => {
it('hands an app icon (rounded, no thumbnail) to AppIcon', () => {
const wrapper = factory({ icon: '/apps/files/img/app.svg', rounded: true })

const img = wrapper.find('img')
expect(img.exists()).toBe(true)
expect(img.attributes('src')).toBe('/apps/files/img/app.svg')
expect(wrapper.findComponent(AppIcon).props('icon')).toBe('/apps/files/img/app.svg')
})

it('does not render a broken image for a legacy CSS-class icon', () => {
Expand Down
4 changes: 2 additions & 2 deletions dist/core-main.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/core-main.js.map

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions dist/core-unified-search.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/core-unified-search.js.map

Large diffs are not rendered by default.

Loading