feat(ui-next): Adds appearance dialog with theme presets and custom theme support (#6041)

This commit is contained in:
Dan Rukas authored and GitHub committed 2026-06-29 12:38:46 -04:00
1 parent 0fcebe05c6
commit 0dfd321326
22 files changed
+868 -3

No files matched your search

@@ -43,6 +43,10 @@ function ViewerHeader({ appConfig }: withAppTypes<{ appConfig: AppTypes.Config }
'ohif.aboutModal'
) as Types.MenuComponentCustomization;
const AppearanceModal = customizationService.getCustomization(
'ohif.appearanceModal'
) as Types.MenuComponentCustomization;
const UserPreferencesModal = customizationService.getCustomization(
'ohif.userPreferencesModal'
) as Types.MenuComponentCustomization;
@@ -71,6 +75,19 @@ function ViewerHeader({ appConfig }: withAppTypes<{ appConfig: AppTypes.Config }
},
];
if (AppearanceModal) {
menuOptions.splice(1, 0, {
title: AppearanceModal.menuTitle ?? t('Header:Appearance'),
icon: 'ColorChange',
onClick: () =>
show({
content: AppearanceModal,
title: AppearanceModal.title ?? t('AppearanceModal:Appearance'),
containerClassName: AppearanceModal.containerClassName ?? 'max-w-md',
}),
});
}
if (appConfig.oidc) {
menuOptions.push({
title: t('Header:Logout'),
@@ -0,0 +1,143 @@
import React from 'react';
import {
AppearanceModal,
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
Button,
useActiveTheme,
themePresets,
} from '@ohif/ui-next';
import { useTranslation } from 'react-i18next';
function AppearanceModalDefault() {
const { activeTheme, setActiveTheme, customCss, applyCustomTheme, clearCustomTheme } =
useActiveTheme();
const { t } = useTranslation('AppearanceModal');
const [draftCss, setDraftCss] = React.useState(() => customCss);
const [isCustomOpen, setIsCustomOpen] = React.useState(() => activeTheme === 'custom');
const [parseFailed, setParseFailed] = React.useState(false);
const handleToggleCustom = () => {
setIsCustomOpen(!isCustomOpen);
};
const handleSave = () => {
setParseFailed(!applyCustomTheme(draftCss));
};
const handleClear = () => {
clearCustomTheme();
setDraftCss('');
setParseFailed(false);
setIsCustomOpen(false);
};
const handlePresetChange = (value: string) => {
if (value === 'custom') {
// Selecting "Custom" restores the saved custom theme; the draft is only
// committed through the explicit Apply button.
setIsCustomOpen(true);
if (customCss) {
applyCustomTheme(customCss);
}
} else {
setIsCustomOpen(false);
setActiveTheme(value);
}
};
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setDraftCss(e.target.value);
setParseFailed(false);
};
return (
<AppearanceModal>
<AppearanceModal.Body>
<div className="grid grid-cols-[auto_1fr] items-center gap-x-6 gap-y-4">
<AppearanceModal.SectionLabel>{t('Theme')}</AppearanceModal.SectionLabel>
<div>
<Select
value={activeTheme}
onValueChange={handlePresetChange}
>
<SelectTrigger
className="w-[200px]"
aria-label={t('Theme')}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">{t('Default Theme')}</SelectItem>
{themePresets.map(preset => (
<SelectItem
key={preset.name}
value={preset.name}
>
{preset.label}
</SelectItem>
))}
{(customCss || draftCss) && <SelectItem value="custom">{t('Custom')}</SelectItem>}
</SelectContent>
</Select>
</div>
<div className="col-start-2">
<Button
variant="ghost"
size="sm"
onClick={handleToggleCustom}
>
{isCustomOpen ? t('Hide') : t('Custom Theme')}
</Button>
</div>
</div>
{isCustomOpen && (
<div className="mt-4 flex flex-col space-y-2">
<textarea
value={draftCss}
onChange={handleTextChange}
placeholder={t('Paste your custom theme color tokens here')}
aria-label={t('Paste your custom theme color tokens here')}
rows={8}
className="bg-muted text-foreground border-input placeholder:text-muted-foreground focus:ring-ring rounded-md border px-3 py-2 font-mono text-sm focus:outline-none focus:ring-1 focus:ring-inset"
/>
{parseFailed && (
<span
className="text-destructive text-sm"
role="alert"
>
{t('No valid theme tokens found')}
</span>
)}
<div className="flex space-x-2">
<Button
variant="default"
size="sm"
onClick={handleSave}
>
{t('Apply')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleClear}
>
{t('Clear')}
</Button>
</div>
</div>
)}
</AppearanceModal.Body>
</AppearanceModal>
);
}
export default {
'ohif.appearanceModal': AppearanceModalDefault,
};
@@ -18,6 +18,7 @@ import progressLoadingBarCustomization from './customizations/progressLoadingBar
import labellingFlowCustomization from './customizations/labellingFlowCustomization';
import viewportNotificationCustomization from './customizations/notificationCustomization';
import aboutModalCustomization from './customizations/aboutModalCustomization';
import appearanceModalCustomization from './customizations/appearanceModalCustomization';
import userPreferencesCustomization from './customizations/userPreferencesCustomization';
import reportDialogCustomization from './customizations/reportDialogCustomization';
import hotkeyBindingsCustomization from './customizations/hotkeyBindingsCustomization';
@@ -39,6 +40,10 @@ export default function getCustomizationModule({ servicesManager, extensionManag
name: 'helloPage',
value: helloPageCustomization,
},
{
name: 'theme',
value: appearanceModalCustomization,
},
{
name: 'datasources',
value: datasourcesCustomization,
+12
View File
@@ -1,5 +1,6 @@
import { DicomMetadataStore, classes } from '@ohif/core';
import { calculateSUVScalingFactors } from '@cornerstonejs/calculate-suv';
import { ActiveThemeProvider } from '@ohif/ui-next';
import getPTImageIdInstanceMetadata from './getPTImageIdInstanceMetadata';
import { registerHangingProtocolAttributes } from './hangingprotocols';
@@ -16,7 +17,18 @@ export default function init({
servicesManager,
commandsManager,
hotkeysManager,
serviceProvidersManager,
appConfig,
}: withAppTypes): void {
const hasThemeModule =
Array.isArray(appConfig.customizationService) &&
appConfig.customizationService.some(
ref => typeof ref === 'string' && ref.includes('customizationModule.theme')
);
if (hasThemeModule) {
serviceProvidersManager.registerProvider('activeTheme', ActiveThemeProvider);
}
const { toolbarService, cineService, viewportGridService } = servicesManager.services;
toolbarService.registerEventForToolbarUpdate(cineService, [