@@ -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 ( / ^ r g b a ? \( ( \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 - 9 a - f ] { 2 } ) ( [ 0 - 9 a - f ] { 2 } ) ( [ 0 - 9 a - 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 - 9 a - f ] ) ( [ 0 - 9 a - f ] ) ( [ 0 - 9 a - 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 */
184285function darkenColor ( color ) {
185- const rgb = color . match ( / \d + / g )
186- if ( ! rgb ) {
286+ const match = color . match ( / ^ r g b a ? \( ( \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