diff --git a/i18n/en.pot b/i18n/en.pot index 7417e7a..3854657 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-03-01T17:24:29.464Z\n" -"PO-Revision-Date: 2026-03-01T17:24:29.464Z\n" +"POT-Creation-Date: 2026-09-18T10:43:59.989Z\n" +"PO-Revision-Date: 2026-09-18T10:43:59.989Z\n" msgid "Save your data" msgstr "Save your data" @@ -48,6 +48,9 @@ msgstr "Return to home page" msgid "Command palette" msgstr "Command palette" +msgid "Back" +msgstr "Back" + msgid "Browse apps" msgstr "Browse apps" @@ -60,9 +63,6 @@ msgstr "Browse shortcuts" msgid "Logout" msgstr "Logout" -msgid "Back" -msgstr "Back" - msgid "to navigate" msgstr "to navigate" @@ -117,6 +117,11 @@ msgstr "Close" msgid "Copy debug info" msgstr "Copy debug info" +msgid "{{label}}, {{count}} unread" +msgid_plural "{{label}}, {{count}} unread" +msgstr[0] "{{label}}, {{count}} unread" +msgstr[1] "{{label}}, {{count}} unread" + msgid "Interpretations" msgstr "Interpretations" @@ -176,6 +181,13 @@ msgstr "" "Your session ended after {{sessionTimeoutInMinutes}} minutes without " "network activity. Log in again to continue." +msgid "" +"No network connection? Dismiss to continue working in an offline-capable " +"app." +msgstr "" +"No network connection? Dismiss to continue working in an offline-capable " +"app." + msgid "Dismiss" msgstr "Dismiss" diff --git a/src/components/ConnectedHeaderbar.jsx b/src/components/ConnectedHeaderbar.jsx index 8dc01b3..3e0fe84 100644 --- a/src/components/ConnectedHeaderbar.jsx +++ b/src/components/ConnectedHeaderbar.jsx @@ -5,7 +5,10 @@ import { useParams } from 'react-router' import { useClientPWAUpdateState } from '../lib/clientPWAUpdateState.jsx' import { ConfirmUpdateModal } from './ConfirmUpdateModal.tsx' import getContrastingColor from './get-contrasting-color.js' -import { CustomColorProvider } from './header-bar/custom-color-context.jsx' +import { + CustomColorProvider, + DEFAULT_HEADER_BAR_BG_COLOR, +} from './header-bar/custom-color-context.jsx' import { HeaderBar } from './header-bar/index.js' const getAppDisplayName = (appName, modules) => { @@ -105,7 +108,8 @@ export function ConnectedHeaderBar({ appsInfoQuery }) { : clientPWAUpdateState const bgColor = - appsInfoQuery?.data?.systemSettings.keyCustomColor || '#165c92' + appsInfoQuery?.data?.systemSettings.keyCustomColor || + DEFAULT_HEADER_BAR_BG_COLOR const color = useMemo(() => getContrastingColor(bgColor), [bgColor]) diff --git a/src/components/header-bar/custom-color-context.jsx b/src/components/header-bar/custom-color-context.jsx index f76eba7..83c672d 100644 --- a/src/components/header-bar/custom-color-context.jsx +++ b/src/components/header-bar/custom-color-context.jsx @@ -1,6 +1,8 @@ import PropTypes from 'prop-types' import React, { createContext, useContext, useMemo } from 'react' +export const DEFAULT_HEADER_BAR_BG_COLOR = '#165c92' + const customColorContext = createContext({}) export const CustomColorProvider = ({ color, bgColor, children }) => { @@ -8,7 +10,7 @@ export const CustomColorProvider = ({ color, bgColor, children }) => { () => ({ color, bgColor, - hasCustomColor: bgColor !== '#165c92', + hasCustomColor: bgColor !== DEFAULT_HEADER_BAR_BG_COLOR, }), [color, bgColor] ) diff --git a/src/components/header-bar/notification-icon.jsx b/src/components/header-bar/notification-icon.jsx index 71b6245..1b38289 100755 --- a/src/components/header-bar/notification-icon.jsx +++ b/src/components/header-bar/notification-icon.jsx @@ -1,11 +1,16 @@ -import { colors, theme, spacers } from '@dhis2/ui-constants' +import { colors, elevations, spacers, theme } from '@dhis2/ui-constants' import { IconMessages24, IconMail24 } from '@dhis2/ui-icons' import PropTypes from 'prop-types' -import React from 'react' +import React, { useEffect, useMemo, useState } from 'react' import { Link } from 'react-router' import css from 'styled-jsx/css' import i18n from '../../locales/index.js' -import { useCustomColorContext } from './custom-color-context.jsx' +import { + DEFAULT_HEADER_BAR_BG_COLOR, + useCustomColorContext, +} from './custom-color-context.jsx' + +const BADGE_REVEAL_DURATION_MS = 3000 function icon(kind, color) { if (kind === 'message') { @@ -15,8 +20,10 @@ function icon(kind, color) { } } -const getStyles = (bgColor) => { - const hoverStyle = bgColor ? 'opacity: 0.6;' : `background: #104f7e;` +const getStyles = ({ bgColor, color }) => { + const hoverBackground = bgColor ? '' : 'background: #104f7e;' + const hoverIconStyle = bgColor ? 'opacity: 0.6;' : '' + const ringColor = bgColor || DEFAULT_HEADER_BAR_BG_COLOR return css.resolve` a { @@ -38,11 +45,67 @@ const getStyles = (bgColor) => { a:focus:not(:focus-visible) { outline: none; } - a:hover { - ${hoverStyle} - } + a:hover, a:active { - ${hoverStyle} + ${hoverBackground} + } + a:hover :global(svg), + a:active :global(svg) { + ${hoverIconStyle} + } + + span { + box-sizing: border-box; + display: flex; + justify-content: center; + align-items: center; + z-index: 1; + position: absolute; + /* Anchored on a fixed centre point over the icon's top corner */ + top: 14px; + inset-inline-end: ${spacers.dp12}; + transform: translate(50%, -50%); + background-color: ${theme.secondary200}; + border: 1px solid ${color}; + font-size: 13px; + font-weight: 600; + line-height: 15px; + text-align: center; + cursor: inherit; + white-space: nowrap; + overflow: hidden; + + /* Dot state (default) */ + min-inline-size: ${spacers.dp8}; + max-inline-size: ${spacers.dp8}; + min-block-size: ${spacers.dp8}; + max-block-size: ${spacers.dp8}; + border-radius: ${spacers.dp4}; + padding: 0; + color: transparent; + box-shadow: 0 0 0 2px ${ringColor}; + + transition: all 150ms ease-in-out; + } + + /* Full counter badge */ + span.is-expanded, + a:hover span, + a:focus-visible span { + min-inline-size: 18px; + max-inline-size: 80px; + min-block-size: 18px; + max-block-size: 18px; + border-radius: ${spacers.dp12}; + padding: 0 ${spacers.dp4}; + color: ${theme.secondary800}; + box-shadow: ${elevations.e100}; + } + + @media (prefers-reduced-motion: reduce) { + span { + transition: none; + } } ` } @@ -55,13 +118,37 @@ export const NotificationIcon = ({ title, 'aria-label': ariaLabel, }) => { - const { - color = colors.white, - bgColor, - hasCustomColor, - } = useCustomColorContext() + const { color = colors.white, bgColor } = useCustomColorContext() + const [badgeExpanded, setBadgeExpanded] = useState(count > 0) + const label = i18n.t(ariaLabel) + const displayCount = count > 99 ? '99+' : count + const accessibleLabel = + count > 0 + ? i18n.t('{{label}}, {{count}} unread', { + label, + count: displayCount, + }) + : label + + // Briefly reveal the full counter whenever a count arrives or changes, then + // collapse it back to a dot + useEffect(() => { + if (count === 0) { + setBadgeExpanded(false) + return + } + setBadgeExpanded(true) + const timer = setTimeout( + () => setBadgeExpanded(false), + BADGE_REVEAL_DURATION_MS + ) + return () => clearTimeout(timer) + }, [count]) - const { className, styles } = getStyles(bgColor) + const { className, styles } = useMemo( + () => getStyles({ bgColor, color }), + [bgColor, color] + ) return ( {icon(kind, color)} {count > 0 && ( - {count} + + {displayCount} + )} {styles} - ) }