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}
-
)
}