Skip to content

Commit 7801b3b

Browse files
committed
feat: new event style in calendar grid
Signed-off-by: Grigory Vodyanov <scratchx@gmx.com>
1 parent e05a172 commit 7801b3b

File tree

5 files changed

+245
-102
lines changed

5 files changed

+245
-102
lines changed

css/fullcalendar.scss

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@
1414
--fc-daygrid-event-dot-width: 10px !important;
1515

1616
--fc-event-bg-color: var(--color-primary-element);
17-
--fc-event-border-color: var(--color-primary-element-text);
18-
--fc-event-text-color: var(--color-primary-element-text);
17+
--fc-event-border-color: var(--color-primary-element);
18+
--fc-event-text-color: var(--color-main-text);
1919
--fc-event-selected-overlay-color: var(--color-box-shadow);
2020

2121
--fc-event-resizer-thickness: 8px;
@@ -131,7 +131,15 @@
131131
// ### FullCalendar Event adjustments
132132
.fc-event {
133133
padding-inline-start: 3px;
134-
border-width: 2px;
134+
135+
// Only show the left border, thick and full-opacity (dot events and list events are excluded)
136+
&:not(.fc-daygrid-dot-event):not(.fc-list-event) {
137+
border-block-start-width: 0 !important;
138+
border-block-end-width: 0 !important;
139+
border-inline-end-width: 0 !important;
140+
border-inline-start-width: var(--default-grid-baseline) !important;
141+
border-inline-start-style: solid !important;
142+
}
135143

136144
&.fc-event-nc-task-completed,
137145
&.fc-event-nc-cancelled {
@@ -141,6 +149,20 @@
141149
}
142150
}
143151

152+
153+
// Participation-state events with no fill keep the thick left stripe plus a 1px frame.
154+
&.fc-event-nc-all-declined,
155+
&.fc-event-nc-needs-action,
156+
&.fc-event-nc-declined {
157+
&:not(.fc-daygrid-dot-event):not(.fc-list-event) {
158+
background-color: transparent !important;
159+
border-block-start-width: 1px !important;
160+
border-block-end-width: 1px !important;
161+
border-inline-end-width: 1px !important;
162+
border-inline-start-width: var(--default-grid-baseline) !important;
163+
}
164+
}
165+
144166
.fc-event-title {
145167
text-overflow: ellipsis;
146168
font-weight: 700;
@@ -305,3 +327,34 @@
305327
margin-inline-end: 2vw;
306328
}
307329
}
330+
331+
// High-contrast mode: events use plain page background so the coloured left
332+
// border is the sole colour indicator (no transparency, no stripe patterns).
333+
//
334+
// Two triggers:
335+
// 1. OS/browser prefers-contrast: more media feature.
336+
// 2. Nextcloud in-app Accessibility theme, which writes
337+
// data-themes="light-highcontrast" (or dark-highcontrast) on <body>
338+
// without necessarily setting the OS media feature.
339+
@mixin _fc-high-contrast-events {
340+
.fc-event:not(.fc-daygrid-dot-event):not(.fc-list-event) {
341+
background-color: var(--fc-page-bg-color) !important;
342+
background-image: none !important;
343+
344+
.fc-event-title,
345+
.fc-event-time {
346+
color: var(--color-main-text) !important;
347+
}
348+
}
349+
}
350+
351+
@media (prefers-contrast: more) {
352+
@include _fc-high-contrast-events;
353+
}
354+
355+
[data-theme-light-highcontrast],
356+
[data-theme-dark-highcontrast],
357+
[data-themes*="highcontrast"] {
358+
@include _fc-high-contrast-events;
359+
}
360+

src/fullcalendar/eventSources/eventSource.js

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,6 @@ import useFetchedTimeRangesStore from '../../store/fetchedTimeRanges.js'
55
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
66
* SPDX-License-Identifier: AGPL-3.0-or-later
77
*/
8-
import {
9-
generateTextColorForHex,
10-
} from '../../utils/color.js'
118
import { getUnixTimestampFromDate } from '../../utils/date.js'
129
import logger from '../../utils/logger.js'
1310
import { eventSourceFunction } from './eventSourceFunction.js'
@@ -27,7 +24,6 @@ export default function() {
2724
// coloring
2825
backgroundColor: calendar.color,
2926
borderColor: calendar.color,
30-
textColor: generateTextColorForHex(calendar.color),
3127
// html foo
3228
events: async ({ start, end, timeZone }, successCallback, failureCallback) => {
3329
let timezoneObject = getTimezoneManager().getTimezoneForId(timeZone)

src/fullcalendar/eventSources/eventSourceFunction.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import usePrincipalsStore from '../../store/principals.js'
77
import useTasksStore from '../../store/unscheduledTasks.js'
88
import { getAllObjectsInTimeRange } from '../../utils/calendarObject.js'
99
import {
10-
generateTextColorForHex,
1110
getHexForColorName,
1211
hexToRGB,
1312
isLight,
@@ -140,6 +139,10 @@ export function eventSourceFunction(calendarObjects, calendar, start, end, timez
140139
}
141140
}
142141

142+
const attendeeCount = object.hasProperty('ATTENDEE')
143+
? [...object.getPropertyIterator('ATTENDEE')].length
144+
: 0
145+
143146
const fcEvent = {
144147
id: [calendarObject.id, object.id].join('###'),
145148
title,
@@ -165,6 +168,7 @@ export function eventSourceFunction(calendarObjects, calendar, start, end, timez
165168
davUrl: calendarObject.dav.url,
166169
location: object.location,
167170
description: object.description,
171+
attendeeCount,
168172
},
169173
}
170174

@@ -173,7 +177,6 @@ export function eventSourceFunction(calendarObjects, calendar, start, end, timez
173177
if (customColor) {
174178
fcEvent.backgroundColor = customColor
175179
fcEvent.borderColor = customColor
176-
fcEvent.textColor = generateTextColorForHex(customColor)
177180
}
178181
}
179182
if (object.name === 'VTODO' && object.endDate === null && object.percent !== 100 && object.status !== 'COMPLETED') {

src/fullcalendar/rendering/eventDidMount.js

Lines changed: 155 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,27 @@ export default errorCatch(function({ event, el }) {
9393
}
9494
}
9595

96+
// Apply semi-transparent background for grid events (not dot or list events).
97+
// The full-opacity color remains on the left border (set by FullCalendar's inline border-color).
98+
// Past events get a stronger fill to keep them visually distinct.
99+
// Skipped in high-contrast mode — CSS will force the plain page background instead.
100+
if (
101+
!el.classList.contains('fc-list-event')
102+
&& !el.classList.contains('fc-daygrid-dot-event')
103+
&& !isHighContrast()
104+
) {
105+
const bgColor = el.style.backgroundColor
106+
if (bgColor) {
107+
const rgb = extractRGB(bgColor)
108+
if (rgb) {
109+
const now = new Date()
110+
const isPast = event.end ? event.end < now : (event.start ? event.start < now : false)
111+
const opacity = isPast ? 0.05 : 0.35
112+
el.style.backgroundColor = `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${opacity})`
113+
}
114+
}
115+
}
116+
96117
if (
97118
el.classList.contains('fc-event-nc-all-declined')
98119
|| el.classList.contains('fc-event-nc-needs-action')
@@ -108,7 +129,19 @@ export default errorCatch(function({ event, el }) {
108129
dotElement.style.minWidth = '10px'
109130
dotElement.style.minHeight = '10px'
110131
} else {
111-
el.style.background = 'var(--fc-page-bg-color)'
132+
el.style.background = 'transparent'
133+
134+
if (!isHighContrast()) {
135+
const now = new Date()
136+
const isPast = event.end ? event.end < now : (event.start ? event.start < now : false)
137+
const borderRgb = extractRGB(el.style.borderColor)
138+
if (isPast && borderRgb) {
139+
const fadedBorderColor = `rgba(${borderRgb.r}, ${borderRgb.g}, ${borderRgb.b}, 0.35)`
140+
el.style.borderTopColor = fadedBorderColor
141+
el.style.borderRightColor = fadedBorderColor
142+
el.style.borderBottomColor = fadedBorderColor
143+
}
144+
}
112145
}
113146

114147
if (titleElement) {
@@ -133,59 +166,149 @@ export default errorCatch(function({ event, el }) {
133166
}
134167
}
135168

136-
if (el.classList.contains('fc-event-nc-all-declined')) {
137-
const titleElement = el.querySelector('.fc-event-title')
169+
if (
170+
event.extendedProps.attendeeCount >= 1
171+
&& !el.classList.contains('fc-event-nc-task')
172+
) {
173+
prependTitleIcon(el, 'M40-160v-112q0-34 17.5-62.5T104-378q62-31 126-46.5T360-440q66 0 130 15.5T616-378q29 15 46.5 43.5T680-272v112H40Zm640 0v-112q0-51-26-95.5T586-441q51 6 98 20.5t84 35.5q36 20 57 44.5t21 52.5v112H680ZM360-480q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 66-47 113t-113 47Zm400-160q0 66-47 113t-113 47q-11 0-28-2.5t-28-5.5q27-32 41.5-71t14.5-81q0-42-14.5-81T544-792q14-5 28-6.5t28-1.5q66 0 113 47t47 113Z')
174+
}
138175

139-
if (titleElement) {
140-
const svgString = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="m40-120 440-760 440 760H40Zm440-120q17 0 28.5-11.5T520-280q0-17-11.5-28.5T480-320q-17 0-28.5 11.5T440-280q0 17 11.5 28.5T480-240Zm-40-120h80v-200h-80v200Z"/></svg>'
141-
titleElement.innerHTML = svgString + titleElement.innerHTML
142-
143-
const svgElement = titleElement.querySelector('svg')
144-
if (svgElement) {
145-
svgElement.style.fill = el.style.borderColor
146-
svgElement.style.width = '1em'
147-
svgElement.style.marginBottom = '0.2em'
148-
svgElement.style.verticalAlign = 'middle'
149-
}
150-
}
176+
if (el.classList.contains('fc-event-nc-all-declined')) {
177+
prependTitleIcon(el, 'm40-120 440-760 440 760H40Zm440-120q17 0 28.5-11.5T520-280q0-17-11.5-28.5T480-320q-17 0-28.5 11.5T440-280q0 17 11.5 28.5T480-240Zm-40-120h80v-200h-80v200Z')
151178
}
152179

153180
if (el.classList.contains('fc-event-nc-tentative')) {
154181
const dotElement = el.querySelector('.fc-daygrid-event-dot')
155182

156-
// Get background color, with fallback to border color if dotElement doesn't exist
157-
const bgColor = el.style.backgroundColor
158-
? el.style.backgroundColor
159-
: (dotElement ? dotElement.style.borderColor : el.style.borderColor)
160-
const bgStripeColor = darkenColor(bgColor)
161-
162-
let backgroundStyling = `repeating-linear-gradient(45deg, ${bgStripeColor}, ${bgStripeColor} 1px, ${bgColor} 1px, ${bgColor} 10px)`
163-
164183
if (dotElement) {
184+
// Dot events: keep the existing marker fill and overlay stripes only.
185+
const dotColor = dotElement.style.borderColor || el.style.borderColor
186+
const dotRgb = extractRGB(dotColor)
187+
const stripeColor = dotRgb ? `rgba(${dotRgb.r}, ${dotRgb.g}, ${dotRgb.b}, 0.25)` : dotColor
165188
dotElement.style.borderWidth = '2px'
166-
backgroundStyling = `repeating-linear-gradient(45deg, ${bgColor}, ${bgColor} 1px, var(--fc-page-bg-color) 1px, var(--fc-page-bg-color) 3.5px)`
167-
168-
dotElement.style.background = backgroundStyling
189+
if (!isHighContrast()) {
190+
dotElement.style.backgroundImage = `repeating-linear-gradient(45deg, ${stripeColor}, ${stripeColor} 1px, transparent 1px, transparent 3.5px)`
191+
}
169192
dotElement.style.minWidth = '10px'
170193
dotElement.style.minHeight = '10px'
171-
} else {
172-
el.style.background = backgroundStyling
194+
} else if (!isHighContrast()) {
195+
// Block/time events: keep the existing fill and overlay stripes only.
196+
const eventColor = el.style.borderColor
197+
const rgb = extractRGB(eventColor)
198+
if (rgb) {
199+
const stripeColor = `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.25)`
200+
el.style.backgroundImage = `repeating-linear-gradient(45deg, ${stripeColor}, ${stripeColor} 2px, transparent 2px, transparent 10px)`
201+
} else {
202+
// Fallback for when border color can't be parsed
203+
const baseColor = el.style.backgroundColor
204+
const stripeColor = darkenColor(baseColor)
205+
el.style.backgroundImage = `repeating-linear-gradient(45deg, ${stripeColor}, ${stripeColor} 2px, transparent 2px, transparent 10px)`
206+
}
173207
}
174208

175209
el.title = t('calendar', 'Your participation is tentative')
176210
}
177211
}, 'eventDidMount')
178212

179213
/**
180-
* Create a slightly darker color for background stripes
214+
* Prepend a Material Symbols SVG icon to an event's title element.
215+
* The icon is coloured with the event's border colour and sized to match the text.
216+
*
217+
* @param {HTMLElement} el The root element of the fullcalendar event
218+
* @param {string} svgPath The `d` attribute of the SVG path to render
219+
*/
220+
function prependTitleIcon(el, svgPath) {
221+
const titleElement = el.querySelector('.fc-event-title')
222+
if (!titleElement) {
223+
return
224+
}
225+
const svgString = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="${svgPath}"/></svg>`
226+
titleElement.innerHTML = svgString + titleElement.innerHTML
227+
228+
const svgElement = titleElement.querySelector('svg')
229+
if (svgElement) {
230+
svgElement.style.fill = el.style.borderColor
231+
svgElement.style.width = '1em'
232+
svgElement.style.marginBottom = '0.2em'
233+
svgElement.style.verticalAlign = 'middle'
234+
}
235+
}
236+
237+
/**
238+
* Extract RGB components from a CSS color string.
239+
* Supports rgb(), rgba(), #rrggbb, and #rgb formats.
240+
*
241+
* @param {string} color The color string to parse
242+
* @return {{r: number, g: number, b: number}|null}
243+
*/
244+
function extractRGB(color) {
245+
if (!color) {
246+
return null
247+
}
248+
249+
// rgb() or rgba() — browser-normalised inline style values
250+
const rgbMatch = color.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/)
251+
if (rgbMatch) {
252+
return { r: parseInt(rgbMatch[1]), g: parseInt(rgbMatch[2]), b: parseInt(rgbMatch[3]) }
253+
}
254+
255+
// #rrggbb
256+
const hexMatch = color.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i)
257+
if (hexMatch) {
258+
return {
259+
r: parseInt(hexMatch[1], 16),
260+
g: parseInt(hexMatch[2], 16),
261+
b: parseInt(hexMatch[3], 16),
262+
}
263+
}
264+
265+
// #rgb
266+
const shortHexMatch = color.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i)
267+
if (shortHexMatch) {
268+
return {
269+
r: parseInt(shortHexMatch[1] + shortHexMatch[1], 16),
270+
g: parseInt(shortHexMatch[2] + shortHexMatch[2], 16),
271+
b: parseInt(shortHexMatch[3] + shortHexMatch[3], 16),
272+
}
273+
}
274+
275+
return null
276+
}
277+
278+
/**
279+
* Create a slightly darker color for background stripes.
280+
* Handles rgb(), rgba(), and hex color formats.
181281
*
182282
* @param {string} color The color to darken
283+
* @return {string} The darkened color
183284
*/
184285
function darkenColor(color) {
185-
const rgb = color.match(/\d+/g)
186-
if (!rgb) {
286+
const match = color.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/)
287+
if (!match) {
187288
return color
188289
}
189-
const [r, g, b] = rgb.map((c) => Math.max(0, Math.min(255, c - (c * 0.3))))
290+
const [r, g, b] = [match[1], match[2], match[3]].map((c) => Math.max(0, Math.min(255, parseInt(c) - (parseInt(c) * 0.3))))
291+
if (match[4] !== undefined) {
292+
return `rgba(${r}, ${g}, ${b}, ${match[4]})`
293+
}
190294
return `rgb(${r}, ${g}, ${b})`
191295
}
296+
297+
/**
298+
* Returns true when the user has requested a high-contrast experience.
299+
*
300+
* Nextcloud lets users pick a high-contrast theme from their Accessibility
301+
* settings. That sets data-themes="light-highcontrast" (or dark-highcontrast)
302+
* on <body> — it does NOT necessarily trigger the OS-level
303+
* `prefers-contrast: more` media feature, so we check both.
304+
*
305+
* @return {boolean}
306+
*/
307+
function isHighContrast() {
308+
if (window.matchMedia('(prefers-contrast: more)').matches) {
309+
return true
310+
}
311+
const themes = document.body.getAttribute('data-themes') ?? ''
312+
return themes.includes('highcontrast')
313+
}
314+

0 commit comments

Comments
 (0)