ui(components): New Dialog and Modal components for ui-next (#4772)
Co-authored-by: sedghi <ar.sedghi@gmail.com>
This commit is contained in:
1 parent
62b4fa3e89
commit
a3b51aa8db
90 files changed
+3459
-1357
No files matched your search
@@ -6,7 +6,13 @@ window.config = {
|
||||
// whiteLabeling: {},
|
||||
extensions: [],
|
||||
modes: [],
|
||||
customizationService: {},
|
||||
customizationService: {
|
||||
'viewportActionMenu.windowLevelActionMenu': {
|
||||
$merge: {
|
||||
location: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
showStudyList: true,
|
||||
// some windows systems have issues with more than 3 web workers
|
||||
maxNumberOfWebWorkers: 3,
|
||||
@@ -17,6 +23,7 @@ window.config = {
|
||||
experimentalStudyBrowserSort: false,
|
||||
strictZSpacingForVolumeViewport: true,
|
||||
groupEnabledModesFirst: true,
|
||||
allowMultiSelectExport: true,
|
||||
maxNumRequests: {
|
||||
interaction: 100,
|
||||
thumbnail: 75,
|
||||
|
||||
@@ -15,9 +15,6 @@ import {
|
||||
SystemContextProvider,
|
||||
} from '@ohif/core';
|
||||
import {
|
||||
DialogProvider,
|
||||
Modal,
|
||||
ModalProvider,
|
||||
ThemeWrapper,
|
||||
ViewportDialogProvider,
|
||||
CineProvider,
|
||||
@@ -27,8 +24,12 @@ import {
|
||||
ThemeWrapper as ThemeWrapperNext,
|
||||
NotificationProvider,
|
||||
ViewportGridProvider,
|
||||
DialogProvider,
|
||||
TooltipProvider,
|
||||
ToolboxProvider,
|
||||
Modal as ModalNext,
|
||||
ManagedDialog,
|
||||
ModalProvider,
|
||||
} from '@ohif/ui-next';
|
||||
// Viewer Project
|
||||
// TODO: Should this influence study list?
|
||||
@@ -122,8 +123,8 @@ function App({
|
||||
[CineProvider, { service: cineService }],
|
||||
[NotificationProvider, { service: uiNotificationService }],
|
||||
[TooltipProvider],
|
||||
[DialogProvider, { service: uiDialogService }],
|
||||
[ModalProvider, { service: uiModalService, modal: Modal }],
|
||||
[DialogProvider, { service: uiDialogService, dialog: ManagedDialog }],
|
||||
[ModalProvider, { service: uiModalService, modal: ModalNext }],
|
||||
[ShepherdJourneyProvider],
|
||||
];
|
||||
|
||||
|
||||
@@ -199,7 +199,6 @@ function DataSourceWrapper(props: withAppTypes) {
|
||||
|
||||
servicesManager.services.uiModalService.show({
|
||||
title: 'Data Source Connection Error',
|
||||
containerDimensions: 'w-1/2',
|
||||
content: () => {
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import filtersMeta from './filtersMeta.js';
|
||||
import { useAppConfig } from '@state';
|
||||
import { useDebounce, useSearchParams } from '@hooks';
|
||||
import { utils, hotkeys } from '@ohif/core';
|
||||
import { utils } from '@ohif/core';
|
||||
import publicUrl from '../../utils/publicUrl';
|
||||
|
||||
import {
|
||||
@@ -19,9 +19,6 @@ import {
|
||||
StudyListTable,
|
||||
StudyListPagination,
|
||||
StudyListFilter,
|
||||
useModal,
|
||||
AboutModal,
|
||||
UserPreferences,
|
||||
useSessionStorage,
|
||||
InvestigationalUseDialog,
|
||||
Button,
|
||||
@@ -35,21 +32,19 @@ import {
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
Clipboard,
|
||||
useModal,
|
||||
Onboarding,
|
||||
ScrollArea,
|
||||
} from '@ohif/ui-next';
|
||||
|
||||
import { Types } from '@ohif/ui';
|
||||
|
||||
import i18n from '@ohif/i18n';
|
||||
import { preserveQueryParameters, preserveQueryStrings } from '../../utils/preserveQueryParameters';
|
||||
|
||||
const PatientInfoVisibility = Types.PatientInfoVisibility;
|
||||
|
||||
const { sortBySeriesDate } = utils;
|
||||
|
||||
const { availableLanguages, defaultLanguage, currentLanguage } = i18n;
|
||||
|
||||
const seriesInStudiesMap = new Map();
|
||||
|
||||
/**
|
||||
@@ -102,6 +97,7 @@ function WorkList({
|
||||
const sortModifier = sortDirection === 'descending' ? 1 : -1;
|
||||
const defaultSortValues =
|
||||
shouldUseDefaultSort && canSort ? { sortBy: 'studyDate', sortDirection: 'ascending' } : {};
|
||||
const { customizationService } = servicesManager.services;
|
||||
|
||||
const sortedStudies = useMemo(() => {
|
||||
if (!canSort) {
|
||||
@@ -470,8 +466,9 @@ function WorkList({
|
||||
});
|
||||
|
||||
const hasStudies = numOfStudies > 0;
|
||||
const versionNumber = process.env.VERSION_NUMBER;
|
||||
const commitHash = process.env.COMMIT_HASH;
|
||||
|
||||
const AboutModal = customizationService.getCustomization('ohif.aboutModal');
|
||||
const UserPreferencesModal = customizationService.getCustomization('ohif.userPreferencesModal');
|
||||
|
||||
const menuOptions = [
|
||||
{
|
||||
@@ -479,10 +476,9 @@ function WorkList({
|
||||
icon: 'info',
|
||||
onClick: () =>
|
||||
show({
|
||||
content: AboutModal,
|
||||
content: AboutModal as React.ComponentType,
|
||||
title: t('AboutModal:About OHIF Viewer'),
|
||||
contentProps: { versionNumber, commitHash },
|
||||
containerDimensions: 'max-w-4xl max-h-4xl',
|
||||
containerClassName: 'max-w-md ',
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -491,24 +487,8 @@ function WorkList({
|
||||
onClick: () =>
|
||||
show({
|
||||
title: t('UserPreferencesModal:User preferences'),
|
||||
content: UserPreferences,
|
||||
contentProps: {
|
||||
hotkeyDefaults: hotkeysManager.getValidHotkeyDefinitions(hotkeyDefaults),
|
||||
hotkeyDefinitions,
|
||||
onCancel: hide,
|
||||
currentLanguage: currentLanguage(),
|
||||
availableLanguages,
|
||||
defaultLanguage,
|
||||
onSubmit: state => {
|
||||
if (state.language.value !== currentLanguage().value) {
|
||||
i18n.changeLanguage(state.language.value);
|
||||
}
|
||||
hotkeysManager.setHotkeys(state.hotkeyDefinitions);
|
||||
hide();
|
||||
},
|
||||
onReset: () => hotkeysManager.restoreDefaultBindings(),
|
||||
hotkeysModule: hotkeys,
|
||||
},
|
||||
content: UserPreferencesModal as React.ComponentType,
|
||||
containerClassName: 'flex max-w-4xl flex-col',
|
||||
}),
|
||||
},
|
||||
];
|
||||
@@ -523,7 +503,6 @@ function WorkList({
|
||||
});
|
||||
}
|
||||
|
||||
const { customizationService } = servicesManager.services;
|
||||
const LoadingIndicatorProgress = customizationService.getCustomization(
|
||||
'ui.loadingIndicatorProgress'
|
||||
);
|
||||
|
||||
@@ -70,7 +70,7 @@ function modeFactory({ modeConfiguration }) {
|
||||
uiModalService,
|
||||
} = servicesManager.services;
|
||||
|
||||
uiDialogService.dismissAll();
|
||||
uiDialogService.hideAll();
|
||||
uiModalService.hide();
|
||||
toolGroupService.destroy();
|
||||
syncGroupService.destroy();
|
||||
|
||||
@@ -135,7 +135,7 @@ const bindings = [
|
||||
},
|
||||
{
|
||||
commandName: 'cancelMeasurement',
|
||||
label: 'Cancel Cornerstone Measurement',
|
||||
label: 'Cancel Measurement',
|
||||
keys: ['esc'],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -382,6 +382,10 @@ export default class ExtensionManager extends PubSubService {
|
||||
return this.dataSourceMap[dataSourceName];
|
||||
};
|
||||
|
||||
getDataSourceInstance = dataSourceName => {
|
||||
return this.dataSourceMap[dataSourceName][0];
|
||||
};
|
||||
|
||||
getActiveDataSource = () => {
|
||||
return this.dataSourceMap[this.activeDataSource];
|
||||
};
|
||||
@@ -409,6 +413,29 @@ export default class ExtensionManager extends PubSubService {
|
||||
return this.getDataSourceDefinition(this.activeDataSource);
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets a formatted list of data sources suitable for UI display/selection.
|
||||
* Only returns data sources that support STOW or have a WADO root.
|
||||
* @returns Array of data source options with value, label, and placeholder
|
||||
*/
|
||||
getDataSourcesForUI = () => {
|
||||
// If multi-select export is not allowed, return empty list
|
||||
if (this._appConfig?.allowMultiSelectExport === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.keys(this.dataSourceMap)
|
||||
.filter(ds => {
|
||||
const configuration = this.dataSourceDefs[ds]?.configuration;
|
||||
return configuration?.supportsStow ?? configuration?.wadoRoot;
|
||||
})
|
||||
.map(ds => ({
|
||||
value: ds,
|
||||
label: ds,
|
||||
placeHolder: ds,
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {string} moduleType
|
||||
|
||||
@@ -9,7 +9,6 @@ export interface BaseCustomization extends Obj {
|
||||
description?: string;
|
||||
label?: string;
|
||||
commands?: Command[];
|
||||
content?: (...props: any) => React.JSX.Element;
|
||||
}
|
||||
|
||||
export interface LabelCustomization extends BaseCustomization {
|
||||
@@ -29,6 +28,7 @@ export interface ComponentCustomization extends BaseCustomization {
|
||||
}
|
||||
|
||||
export type Customization =
|
||||
| React.ComponentType
|
||||
| BaseCustomization
|
||||
| LabelCustomization
|
||||
| CommandCustomization
|
||||
|
||||
@@ -1,80 +1,95 @@
|
||||
import { PubSubService } from '../_shared/pubSubServiceInterface';
|
||||
import type { ManagedDialogProps } from 'platform/ui-next/src/contextProviders/ManagedDialog';
|
||||
type DialogOptions = ManagedDialogProps;
|
||||
|
||||
class UIDialogService extends PubSubService {
|
||||
public static readonly EVENTS = {};
|
||||
const name = 'uiDialogService';
|
||||
|
||||
public static REGISTRATION = {
|
||||
name: 'uiDialogService',
|
||||
const serviceImplementation = {
|
||||
_show: (options: DialogOptions) => {
|
||||
console.warn('show() NOT IMPLEMENTED');
|
||||
return '';
|
||||
},
|
||||
_hide: (id: string) => console.warn('hide() NOT IMPLEMENTED'),
|
||||
_hideAll: () => console.warn('hideAll() NOT IMPLEMENTED'),
|
||||
_isEmpty: () => {
|
||||
console.warn('isEmpty() NOT IMPLEMENTED');
|
||||
return true;
|
||||
},
|
||||
_customComponent: null,
|
||||
};
|
||||
|
||||
class UIDialogService {
|
||||
static REGISTRATION = {
|
||||
name,
|
||||
altName: 'UIDialogService',
|
||||
create: ({ configuration = {} }) => {
|
||||
create: (): UIDialogService => {
|
||||
return new UIDialogService();
|
||||
},
|
||||
};
|
||||
|
||||
serviceImplementation = {
|
||||
_dismiss: () => console.warn('dismiss() NOT IMPLEMENTED'),
|
||||
_dismissAll: () => console.warn('dismissAll() NOT IMPLEMENTED'),
|
||||
_create: () => console.warn('create() NOT IMPLEMENTED'),
|
||||
};
|
||||
readonly name = name;
|
||||
|
||||
constructor() {
|
||||
super(UIDialogService.EVENTS);
|
||||
this.serviceImplementation = {
|
||||
...this.serviceImplementation,
|
||||
};
|
||||
/**
|
||||
* Show a new UI dialog
|
||||
*
|
||||
* @param {DialogOptions} options - The dialog options
|
||||
* @returns {string} The dialog id
|
||||
*/
|
||||
show(options: DialogOptions): string {
|
||||
return serviceImplementation._show(options);
|
||||
}
|
||||
|
||||
public create({
|
||||
id,
|
||||
content,
|
||||
contentProps,
|
||||
onStart,
|
||||
onDrag,
|
||||
onStop,
|
||||
centralize = false,
|
||||
preservePosition = true,
|
||||
isDraggable = true,
|
||||
showOverlay = false,
|
||||
defaultPosition,
|
||||
onClickOutside,
|
||||
}) {
|
||||
return this.serviceImplementation._create({
|
||||
id,
|
||||
content,
|
||||
contentProps,
|
||||
onStart,
|
||||
onDrag,
|
||||
onStop,
|
||||
centralize,
|
||||
preservePosition,
|
||||
isDraggable,
|
||||
showOverlay,
|
||||
defaultPosition,
|
||||
onClickOutside,
|
||||
});
|
||||
/**
|
||||
* Hide a specific dialog by id
|
||||
*
|
||||
* @param {string} id - The dialog id to hide
|
||||
*/
|
||||
hide(id: string): void {
|
||||
return serviceImplementation._hide(id);
|
||||
}
|
||||
|
||||
public dismiss({ id }) {
|
||||
return this.serviceImplementation._dismiss({ id });
|
||||
/**
|
||||
* Hide all currently shown dialogs
|
||||
*/
|
||||
hideAll(): void {
|
||||
return serviceImplementation._hideAll();
|
||||
}
|
||||
|
||||
public dismissAll() {
|
||||
return this.serviceImplementation._dismissAll();
|
||||
/**
|
||||
* Check if there are any dialogs currently shown
|
||||
*
|
||||
* @returns {boolean} True if no dialogs are shown
|
||||
*/
|
||||
isEmpty(): boolean {
|
||||
return serviceImplementation._isEmpty();
|
||||
}
|
||||
|
||||
public setServiceImplementation({
|
||||
dismiss: dismissImplementation,
|
||||
dismissAll: dismissAllImplementation,
|
||||
create: createImplementation,
|
||||
}) {
|
||||
if (dismissImplementation) {
|
||||
this.serviceImplementation._dismiss = dismissImplementation;
|
||||
/**
|
||||
* This provides flexibility in customizing the Modal's default component
|
||||
*
|
||||
* @returns {React.Component}
|
||||
*/
|
||||
getCustomComponent() {
|
||||
return serviceImplementation._customComponent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the service implementation
|
||||
*/
|
||||
setServiceImplementation({ show, hide, hideAll, isEmpty, customComponent }: any): void {
|
||||
if (show) {
|
||||
serviceImplementation._show = show;
|
||||
}
|
||||
if (dismissAllImplementation) {
|
||||
this.serviceImplementation._dismissAll = dismissAllImplementation;
|
||||
if (hide) {
|
||||
serviceImplementation._hide = hide;
|
||||
}
|
||||
if (createImplementation) {
|
||||
this.serviceImplementation._create = createImplementation;
|
||||
if (hideAll) {
|
||||
serviceImplementation._hideAll = hideAll;
|
||||
}
|
||||
if (isEmpty) {
|
||||
serviceImplementation._isEmpty = isEmpty;
|
||||
}
|
||||
if (customComponent) {
|
||||
serviceImplementation._customComponent = customComponent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,3 @@
|
||||
/**
|
||||
* UI Modal
|
||||
*
|
||||
* @typedef {Object} ModalProps
|
||||
* @property {ReactElement|HTMLElement} [content=null] Modal content.
|
||||
* @property {Object} [contentProps=null] Modal content props.
|
||||
* @property {boolean} [shouldCloseOnEsc=false] Modal is dismissible via the esc key.
|
||||
* @property {boolean} [isOpen=true] Make the Modal visible or hidden.
|
||||
* @property {boolean} [closeButton=true] Should the modal body render the close button.
|
||||
* @property {string} [title=null] Should the modal render the title independently of the body content.
|
||||
* @property {string} [customClassName=null] The custom class to style the modal.
|
||||
*/
|
||||
|
||||
const name = 'uiModalService';
|
||||
|
||||
const serviceImplementation = {
|
||||
@@ -38,30 +25,20 @@ class UIModalService {
|
||||
show({
|
||||
content = null,
|
||||
contentProps = null,
|
||||
shouldCloseOnEsc = true,
|
||||
isOpen = true,
|
||||
closeButton = true,
|
||||
title = null,
|
||||
customClassName = null,
|
||||
movable = false,
|
||||
containerDimensions = null,
|
||||
contentDimensions = null,
|
||||
className = null,
|
||||
shouldCloseOnEsc = true,
|
||||
shouldCloseOnOverlayClick = true,
|
||||
shouldCloseImmediately = false,
|
||||
containerClassName = null,
|
||||
}) {
|
||||
return serviceImplementation._show({
|
||||
content,
|
||||
contentProps,
|
||||
shouldCloseOnEsc,
|
||||
isOpen,
|
||||
closeButton,
|
||||
title,
|
||||
customClassName,
|
||||
movable,
|
||||
containerDimensions,
|
||||
contentDimensions,
|
||||
className,
|
||||
shouldCloseOnOverlayClick,
|
||||
shouldCloseImmediately,
|
||||
containerClassName,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 169 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 332 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 298 KiB |
@@ -0,0 +1,314 @@
|
||||
---
|
||||
title: uiDialogService
|
||||
---
|
||||
|
||||
|
||||
## DialogService
|
||||
|
||||
This guide details the migration steps for the `uiDialogService` API changes, based on the provided diff. The most significant change is a shift from methods like `.create()` and `.dismiss()` to `.show()` and `.hide()`, alongside structural changes in how dialogs are defined and rendered. The changes aim for a more streamlined and flexible dialog management, leveraging React context for state management.
|
||||
|
||||
**Key Changes:**
|
||||
|
||||
* **`uiDialogService.create()` and `uiDialogService.dismiss()` are deprecated.** They have been replaced with `uiDialogService.show()` and `uiDialogService.hide()`. This change
|
||||
makes it consistent with the `uiModalService` and `uiNotificationService` APIs.
|
||||
* **`content` property now expects a React Component Type**, not an instance. Props for the content component are passed via `contentProps`.
|
||||
* **The `dialogId` is now consistently passed as `id` within the options** to `uiDialogService.show()`.
|
||||
|
||||
|
||||
### Props Kept same as before
|
||||
|
||||
| Prop | Description |
|
||||
|------|-------------|
|
||||
| `id` | Still required, but we don't return it from the `show` method anymore. |
|
||||
| `content` | This is now expected to be a *React component type* (a function or class that returns JSX) |
|
||||
| `contentProps` | This continues to be the way to pass data *to* your custom dialog component. However, several specific props that *used* to be passed here (like `onClose`, `actions`) are no longer valid. |
|
||||
| `isDraggable` | Controls whether the dialog can be moved by dragging. |
|
||||
| `defaultPosition` | Allows you to specify an initial `{ x, y }` position for the dialog. |
|
||||
| `title` | The title text to display in the dialog header. |
|
||||
| `showOverlay` | default true - if the dialog is draggable the overlay is not shown by default |
|
||||
|
||||
|
||||
### Removed Props:
|
||||
|
||||
| Prop | Description |
|
||||
|------|-------------|
|
||||
| `centralize` | Dialogs are now centered by default via CSS if you don't want center you pass defaultPosition |
|
||||
| `preservePosition` | Work in progress and will be available in future |
|
||||
| `contentDimensions` | Removed - should be specified directly in dialogs |
|
||||
| `onStart` | Removed |
|
||||
| `onDrag` | Removed |
|
||||
| `onStop` | Removed |
|
||||
| `onClickOutside` | Removed - if you want to close the dialog on click outside, you can use the `shouldCloseOnOverlayClick` prop |
|
||||
|
||||
|
||||
### Renamed Props:
|
||||
|
||||
| Prop | Description |
|
||||
|------|-------------|
|
||||
| `containerDimensions` | renamed to `containerClassName` |
|
||||
|
||||
|
||||
|
||||
|
||||
### New Props:
|
||||
|
||||
| Prop | Description |
|
||||
|------|-------------|
|
||||
| `unstyled` | A boolean prop to render the dialog without the default styling. It is used for context menu dialogs |
|
||||
| `shouldCloseOnEsc` | Default off for dialogs - Controls whether pressing the Escape key will close the dialog. |
|
||||
| `shouldCloseOnOverlayClick` | Default off for dialogs - Controls whether clicking the overlay background will close the dialog. |
|
||||
|
||||
|
||||
**Migration Steps:**
|
||||
|
||||
1. **Replace `.create()` with `.show()`:**
|
||||
|
||||
```diff
|
||||
- const dialogId = uiDialogService.create({
|
||||
- id: 'my-dialog',
|
||||
- content: MyDialogComponent,
|
||||
- contentProps: { prop1: 'value1' },
|
||||
- // ... other options ...
|
||||
- });
|
||||
|
||||
+ uiDialogService.show({
|
||||
+ id: 'my-dialog',
|
||||
+ content: MyDialogComponent,
|
||||
+ contentProps: { prop1: 'value1' },
|
||||
+ // ... other options ...
|
||||
+ });
|
||||
```
|
||||
|
||||
2. Rename `containerDimensions` to `containerClassName`
|
||||
|
||||
```diff
|
||||
- containerDimensions: 'w-[70%] max-w-[900px]',
|
||||
+ containerClassName: 'w-[70%] max-w-[900px]',
|
||||
```
|
||||
|
||||
3. **Replace `.dismiss({ id: dialogId })` with `.hide(dialogId)`:**
|
||||
|
||||
```diff
|
||||
- uiDialogService.dismiss({ id: dialogId });
|
||||
|
||||
+ uiDialogService.hide(dialogId);
|
||||
```
|
||||
|
||||
4. **Replace `dismissAll` with `hideAll`**
|
||||
```diff
|
||||
- uiDialogService.dismissAll();
|
||||
+ uiDialogService.hideAll();
|
||||
```
|
||||
5. **Update Dialog Content:**
|
||||
|
||||
* Ensure your dialog content is defined as a React component (functional or class-based).
|
||||
* Pass props to the component via `contentProps`.
|
||||
* You don't need to pass in `onClose` or `hide` as they are now handled passed in automatically.
|
||||
|
||||
```javascript
|
||||
// Example: MyDialogComponent.tsx
|
||||
function MyDialogComponent({ prop1, hide }) {
|
||||
return (
|
||||
<div>
|
||||
<p>Value of prop1: {prop1}</p>
|
||||
<button onClick={hide}>Close</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
**Example: Updating a Simple Alert Dialog**
|
||||
|
||||
```javascript
|
||||
// Before (using deprecated API)
|
||||
let dialogId;
|
||||
const showAlert = (message) => {
|
||||
dialogId = uiDialogService.create({
|
||||
centralize: true,
|
||||
isDraggable: false,
|
||||
content: Dialog,
|
||||
contentProps: {
|
||||
title: 'Alert',
|
||||
body: () => <p>{message}</p>,
|
||||
onClose: () => uiDialogService.dismiss({ id: dialogId }),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// After (using new API)
|
||||
function AlertDialog({ message, hide }) {
|
||||
return (
|
||||
<div>
|
||||
<p>{message}</p>
|
||||
<button onClick={hide}>OK</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const showAlert = (message) => {
|
||||
uiDialogService.show({
|
||||
id: 'alert-dialog',
|
||||
title: 'Alert',
|
||||
content: AlertDialog,
|
||||
contentProps: { message },
|
||||
});
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Components
|
||||
|
||||
### CreateReportDialogPrompt
|
||||
|
||||
**Key Changes:**
|
||||
|
||||
* **Function Signature Update:** The function now accepts an object with `servicesManager`, `extensionManager`, `title`(optional).
|
||||
* **Return Value Structure:** The function now returns an object containing `value` (the report name), `dataSourceName` (the selected data source, if applicable), and `action` (indicating the user's choice).
|
||||
|
||||
**Migration Steps:**
|
||||
|
||||
1. **Update Function Call:**
|
||||
|
||||
Previously, the function was called with separate arguments. You should now pass an object:
|
||||
|
||||
```diff
|
||||
- const promptResult = await createReportDialogPrompt(uiDialogService, {
|
||||
- extensionManager,
|
||||
- });
|
||||
|
||||
+ const promptResult = await createReportDialogPrompt({
|
||||
+ servicesManager,
|
||||
+ extensionManager,
|
||||
+ title: 'Store Segmentation', // Optional title
|
||||
+ });
|
||||
|
||||
```
|
||||
|
||||
### promptSaveReport
|
||||
|
||||
Not changed, just a javascript to typescript migration.
|
||||
|
||||
### callLabelAutocompleteDialog
|
||||
|
||||
`callLabelAutocompleteDialog` is deprecated and has been replaced by `callInputDialogAutoComplete`. This new function simplifies the asynchronous handling of user input by using `uiDialogService.show()` and returning a promise.
|
||||
|
||||
|
||||
|
||||
### showLabelAnnotationPopup
|
||||
|
||||
`showLabelAnnotationPopup` has been replaced with `callInputDialogAutoComplete`. This update also uses `uiDialogService.show()` and promises, and it removes the callback function.
|
||||
|
||||
- The function now expects an object with `measurement`, `uiDialogService`, `labelConfig`, and `renderContent`.
|
||||
|
||||
```diff
|
||||
- const value = await showLabelAnnotationPopup(
|
||||
- measurement,
|
||||
- servicesManager.services.uiDialogService,
|
||||
- labelConfig,
|
||||
- renderContent
|
||||
- );
|
||||
+ const value = await callInputDialogAutoComplete({
|
||||
+ measurement,
|
||||
+ uiDialogService,
|
||||
+ labelConfig,
|
||||
+ renderContent,
|
||||
+ });
|
||||
```
|
||||
|
||||
### callInputDialog
|
||||
|
||||
- expects an objects now and returns the value of the input which you can then use for actions
|
||||
|
||||
|
||||
```diff
|
||||
|
||||
- callInputDialog(
|
||||
- uiDialogService,
|
||||
- {
|
||||
- text: '',
|
||||
- label: `${length}`,
|
||||
- },
|
||||
- (value, id) => {
|
||||
- if (id === 'save') {
|
||||
- adjustCalibration(Number.parseFloat(value));
|
||||
- resolve(true);
|
||||
- } else {
|
||||
- reject('cancel');
|
||||
- }
|
||||
- },
|
||||
- false,
|
||||
- {
|
||||
- dialogTitle: 'Calibration',
|
||||
- inputLabel: 'Actual Physical distance (mm)',
|
||||
- validateFunc: val => {
|
||||
- const v = Number.parseFloat(val);
|
||||
- return !isNaN(v) && v !== 0.0;
|
||||
- },
|
||||
- }
|
||||
- );
|
||||
+ callInputDialog({
|
||||
+ uiDialogService,
|
||||
+ title: 'Calibration',
|
||||
+ placeholder: 'Actual Physical distance (mm)',
|
||||
+ defaultValue: `${length}`,
|
||||
+ }).then(newValue => {
|
||||
+ adjustCalibration(Number.parseFloat(newValue));
|
||||
+ resolve(true);
|
||||
+ });
|
||||
```
|
||||
|
||||
or another one
|
||||
|
||||
```diff
|
||||
- callInputDialog(
|
||||
- uiDialogService,
|
||||
- { text: '', label: 'Enter description' },
|
||||
- (value, action) => {
|
||||
- if (action === 'save') {
|
||||
- saveFunction(value);
|
||||
- }
|
||||
- }
|
||||
- );
|
||||
+ callInputDialog({
|
||||
+ uiDialogService,
|
||||
+ title: 'Enter description of the Series',
|
||||
+ defaultValue: '',
|
||||
+ }).then(value => {
|
||||
+ saveFunction(value);
|
||||
+ });
|
||||
```
|
||||
|
||||
|
||||
### colorPickerDialog
|
||||
|
||||
Instead of calling `colorPickerDialog(uiDialogService, rgbaColor, callback)`, use `uiDialogService.show()` with `ColorPickerDialog` as the content.
|
||||
|
||||
|
||||
```diff
|
||||
- colorPickerDialog(uiDialogService, rgbaColor, (newRgbaColor, actionId) => {
|
||||
- if (actionId === 'cancel') {
|
||||
- return;
|
||||
- }
|
||||
- const color = [newRgbaColor.r, newRgbaColor.g, newRgbaColor.b, newRgbaColor.a * 255.0];
|
||||
- segmentationService.setSegmentColor(viewportId, segmentationId, segmentIndex, color);
|
||||
- });
|
||||
|
||||
// after
|
||||
|
||||
+ uiDialogService.show({
|
||||
+ content: ColorPickerDialog,
|
||||
+ title: 'Segment Color',
|
||||
+ contentProps: {
|
||||
+ value: rgbaColor,
|
||||
+ onSave: newRgbaColor => {
|
||||
+ const color = [newRgbaColor.r, newRgbaColor.g, newRgbaColor.b, newRgbaColor.a * 255.0];
|
||||
+ segmentationService.setSegmentColor(viewportId, segmentationId, segmentIndex, color);
|
||||
+ },
|
||||
+ },
|
||||
+ });
|
||||
```
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
title: uiModalService
|
||||
---
|
||||
|
||||
|
||||
## ModalService
|
||||
|
||||
|
||||
### Props Kept same as before
|
||||
|
||||
| Prop | Description |
|
||||
|------|-------------|
|
||||
| `content` | This is now expected to be a *React component type* (a function or class that returns JSX) |
|
||||
| `contentProps` | This continues to be the way to pass data *to* your custom dialog component. However, several specific props that *used* to be passed here (like `onClose`, `actions`) are no longer valid. |
|
||||
| `title` | The title text to display in the dialog header. |
|
||||
| `shouldCloseOnEsc` | Allows closing the modal when the escape key is pressed. |
|
||||
| `shouldCloseOnOverlayClick` | Allows closing the modal when the overlay is clicked. |
|
||||
|
||||
### Renamed Props:
|
||||
|
||||
| Prop | Description |
|
||||
|------|-------------|
|
||||
| `containerDimensions` | renamed to `containerClassName` |
|
||||
|
||||
|
||||
|
||||
### Removed Props:
|
||||
|
||||
| Prop | Description |
|
||||
|------|-------------|
|
||||
| `movable` | It's removed because modals shouldn't be movable. If you need to move a dialog, use `uidDialogService` and `dialogs` instead. |
|
||||
| `isOpen` | always assumed `true` when `show` is called. |
|
||||
| `contentDimensions` | Removed, it is now component's responsibility to set the size for the content |
|
||||
| `customClassName` | renamed to `className` |
|
||||
| `closeButton` | The component now manages modal closing internally. If you need a close button, you can add one, perhaps by checking out the `FooterActions` component. |
|
||||
|
||||
|
||||
|
||||
|
||||
**Migration Steps:**
|
||||
|
||||
|
||||
### Rename of `containerDimensions` to `containerClassName` and removal of `contentDimensions`
|
||||
|
||||
|
||||
Before
|
||||
|
||||
```js
|
||||
uiModalService.show({
|
||||
title: 'Download High-Quality Image',
|
||||
content: CornerstoneViewportDownloadForm,
|
||||
contentProps: {
|
||||
activeViewportId,
|
||||
},
|
||||
containerDimensions: 'w-[70%] max-w-[900px]',
|
||||
contentDimensions: 'h-[493px] w-[460px] pl-[12px] pr-[12px]',
|
||||
});
|
||||
```
|
||||
|
||||
After: the component is responsible for setting the size
|
||||
|
||||
```js
|
||||
function CornerstoneViewportDownloadForm({ activeViewportId }) {
|
||||
return (
|
||||
<div className="h-[493px] w-[460px] pl-[12px] pr-[12px]">
|
||||
<h2 className="text-lg font-bold">Download Image</h2>
|
||||
<p>Viewport ID: {activeViewportId}</p>
|
||||
<button className="mt-4 bg-blue-500 text-white p-2 rounded">Download</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show the modal
|
||||
uiModalService.show({
|
||||
title: 'Download High-Quality Image',
|
||||
content: CornerstoneViewportDownloadForm,
|
||||
contentProps: { activeViewportId },
|
||||
containerClassName: 'w-[70%] max-w-[900px]',
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
### onClose
|
||||
Previously, you had to pass in the `onClose` as `hide` function automatically added to the component.
|
||||
|
||||
```diff
|
||||
- uiModalService.show({
|
||||
- title: 'Untrack Series',
|
||||
- content: UntrackSeriesModal,
|
||||
- contentProps: { onConfirm },
|
||||
- onClose: () => uiModalService.hide(),
|
||||
- });
|
||||
|
||||
+ uiModalService.show({
|
||||
+ title: 'Untrack Series',
|
||||
+ content: UntrackSeriesModal,
|
||||
+ contentProps: {
|
||||
+ onConfirm,
|
||||
+ hide, // passed in automatically in the background
|
||||
+ },
|
||||
+ });
|
||||
```
|
||||
+139
-23
@@ -16,6 +16,7 @@ import loadingIndicatorProgress from '../../../assets/img/loading-indicator-icon
|
||||
import loadingIndicatorPercent from '../../../assets/img/loading-indicator-percent.png';
|
||||
import viewportActionCorners from '../../../assets/img/viewport-action-corners.png';
|
||||
import contextMenu from '../../../assets/img/context-menu.jpg';
|
||||
import viewportDownloadWarning from '../../../assets/img/viewport-download-warning.png';
|
||||
import segmentationOverlay from '../../../assets/img/segmentation-overlay.png';
|
||||
|
||||
import segDisplayEditingTrue from '../../../assets/img/segDisplayEditingTrue.png';
|
||||
@@ -24,6 +25,8 @@ import thumbnailMenuItemsImage from '../../../assets/img/thumbnailMenuItemsImage
|
||||
import studyMenuItemsImage from '../../../assets/img/studyMenuItemsImage.png';
|
||||
import windowLevelActionMenu from '../../../assets/img/windowLevelActionMenu.png';
|
||||
import viewPortNotificationImage from '../../../assets/img/viewport-notification.png';
|
||||
import captureViewportModal from '../../../assets/img/captureViewportModal.png';
|
||||
import aboutModal from '../../../assets/img/aboutModal.png';
|
||||
|
||||
export const viewportOverlayCustomizations = [
|
||||
{
|
||||
@@ -269,24 +272,6 @@ window.config = {
|
||||
default: 'The CinePlayer component in the UI',
|
||||
configuration: null,
|
||||
},
|
||||
{
|
||||
id: 'cornerstone.windowLevelActionMenu',
|
||||
description: 'Window level action menu for the cornerstone viewport.',
|
||||
image: windowLevelActionMenu,
|
||||
default: null,
|
||||
configuration: `
|
||||
window.config = {
|
||||
// rest of window config
|
||||
customizationService: [
|
||||
{
|
||||
'cornerstone.windowLevelActionMenu': {
|
||||
$set: CustomizedComponent,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'cornerstone.windowLevelPresets',
|
||||
description: 'Window level presets for the cornerstone viewport.',
|
||||
@@ -785,10 +770,142 @@ window.config = {
|
||||
};
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'viewportDownload.warningMessage',
|
||||
description: 'Customizes the warning message for the viewport download form.',
|
||||
image: viewportDownloadWarning,
|
||||
default: {
|
||||
enabled: true,
|
||||
value: 'Not For Diagnostic Use',
|
||||
},
|
||||
configuration: `
|
||||
window.config = {
|
||||
// rest of window config
|
||||
customizationService: [
|
||||
{
|
||||
'viewportDownload.warningMessage': {
|
||||
$set: {
|
||||
enabled: true,
|
||||
value: 'Careful! This is not for diagnostic use.',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'ohif.captureViewportModal',
|
||||
description: 'The modal for capturing the viewport image.',
|
||||
image: captureViewportModal,
|
||||
default: 'Our own default component',
|
||||
configuration: `
|
||||
window.config = {
|
||||
// rest of window config
|
||||
|
||||
// You can use the component from ImageModal and FooterAction
|
||||
// to build your own custom component
|
||||
customizationService: [
|
||||
{
|
||||
'ohif.captureViewportModal': {
|
||||
$set: CustomizedComponent,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'ohif.aboutModal',
|
||||
description: 'The About modal',
|
||||
image: aboutModal,
|
||||
default: 'Our own default component',
|
||||
configuration: `
|
||||
window.config = {
|
||||
// rest of window config
|
||||
|
||||
// You can use the component from AboutModal
|
||||
// to build your own custom component
|
||||
customizationService: [
|
||||
{
|
||||
'ohif.aboutModal': {
|
||||
$set: CustomizedComponent,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'viewportDownload.warningMessage',
|
||||
description: 'Customizes the warning message for the viewport download form.',
|
||||
image: viewportDownloadWarning,
|
||||
default: {
|
||||
enabled: true,
|
||||
value: 'Not For Diagnostic Use',
|
||||
},
|
||||
configuration: `
|
||||
window.config = {
|
||||
// rest of window config
|
||||
customizationService: [
|
||||
{
|
||||
'viewportDownload.warningMessage': {
|
||||
$set: {
|
||||
enabled: true,
|
||||
value: 'Careful! This is not for diagnostic use.',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'ohif.captureViewportModal',
|
||||
description: 'The modal for capturing the viewport image.',
|
||||
image: captureViewportModal,
|
||||
default: 'Our own default component',
|
||||
configuration: `
|
||||
window.config = {
|
||||
// rest of window config
|
||||
|
||||
// You can use the component from ImageModal and FooterAction
|
||||
// to build your own custom component
|
||||
customizationService: [
|
||||
{
|
||||
'ohif.captureViewportModal': {
|
||||
$set: CustomizedComponent,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'ohif.aboutModal',
|
||||
description: 'The About modal',
|
||||
image: aboutModal,
|
||||
default: 'Our own default component',
|
||||
configuration: `
|
||||
window.config = {
|
||||
// rest of window config
|
||||
|
||||
// You can use the component from AboutModal
|
||||
// to build your own custom component
|
||||
customizationService: [
|
||||
{
|
||||
'ohif.aboutModal': {
|
||||
$set: CustomizedComponent,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'viewportActionMenu.windowLevelActionMenu',
|
||||
description:
|
||||
'Configures the display and location of the window level action menu in the viewport.',
|
||||
'Configures the display and location of the window level action menu in the viewport.',
|
||||
image: windowLevelActionMenu,
|
||||
default: null,
|
||||
configuration: `
|
||||
@@ -797,9 +914,8 @@ window.config = {
|
||||
customizationService: [
|
||||
{
|
||||
'viewportActionMenu.windowLevelActionMenu': {
|
||||
$set: {
|
||||
enabled: true,
|
||||
location: 1, // Set the location of the menu in the viewport.
|
||||
$merge: {
|
||||
location: 0, // Set the location of the menu in the viewport.
|
||||
// 0: topLeft
|
||||
// 1: topRight
|
||||
// 2: bottomLeft
|
||||
@@ -822,7 +938,7 @@ window.config = {
|
||||
customizationService: [
|
||||
{
|
||||
'viewportActionMenu.segmentationOverlay': {
|
||||
$set: {
|
||||
$merge: {
|
||||
enabled: true,
|
||||
location: 1, // Set the location of the overlay in the viewport.
|
||||
// 0: topLeft
|
||||
|
||||
@@ -136,6 +136,7 @@ module.exports = {
|
||||
prism: {
|
||||
theme: require('prism-react-renderer').themes.github,
|
||||
darkTheme: require('prism-react-renderer').themes.dracula,
|
||||
additionalLanguages: ['diff'],
|
||||
},
|
||||
algolia: {
|
||||
appId: 'EFLT6YIHHZ',
|
||||
|
||||
@@ -233,11 +233,6 @@ input[type='number'] {
|
||||
-moz-appearance: textfield; /* For Firefox */
|
||||
}
|
||||
|
||||
.navbar__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.navbar__item svg {
|
||||
margin-right: 5px;
|
||||
display: inline-block;
|
||||
@@ -642,6 +637,7 @@ html[data-theme='dark'] .markdown hr {
|
||||
/* Markdown code block styling */
|
||||
.markdown pre {
|
||||
font-size: 0.9rem;
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
.theme-code-block {
|
||||
@@ -725,3 +721,7 @@ a.dropdown__link[href='/3.9/migration-guide/3p8-to-3p9/']::after {
|
||||
font-size: 0.9em;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
li:first-child {
|
||||
margin-top: 0.5em; /* Adjust '1em' to your desired spacing */
|
||||
}
|
||||
@@ -98,8 +98,7 @@ function initI18n(
|
||||
},
|
||||
},
|
||||
react: {
|
||||
useSuspense: false, // TODO: Was seeing weird errors without this
|
||||
wait: true,
|
||||
useSuspense: true,
|
||||
bindI18n: 'languageChanged editorSaved',
|
||||
},
|
||||
});
|
||||
@@ -123,7 +122,7 @@ function initI18n(
|
||||
},
|
||||
detection,
|
||||
react: {
|
||||
wait: true,
|
||||
useSuspense: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -190,7 +190,8 @@ const DataRow: React.FC<DataRowProps> = ({
|
||||
return (
|
||||
<div
|
||||
ref={rowRef}
|
||||
className={`flex flex-col ${isVisible ? '' : 'opacity-60'}`}>
|
||||
className={`flex flex-col ${isVisible ? '' : 'opacity-60'}`}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center ${
|
||||
isSelected ? 'bg-popover' : 'bg-muted'
|
||||
@@ -291,7 +292,11 @@ const DataRow: React.FC<DataRowProps> = ({
|
||||
<Icons.More className="h-6 w-6" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
// this was causing issue for auto focus on input dialog
|
||||
onCloseAutoFocus={e => e.preventDefault()}
|
||||
>
|
||||
<>
|
||||
<DropdownMenuItem onClick={e => handleAction('Rename', e)}>
|
||||
<Icons.Rename className="text-foreground" />
|
||||
|
||||
@@ -3,8 +3,41 @@ import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { Cross2Icon } from '@radix-ui/react-icons';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { useDraggable } from './useDraggable';
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
interface DialogContextValue {
|
||||
isDraggable?: boolean;
|
||||
shouldCloseOnEsc?: boolean;
|
||||
shouldCloseOnOverlayClick?: boolean;
|
||||
showOverlay?: boolean;
|
||||
}
|
||||
|
||||
const DialogContext = React.createContext<DialogContextValue>({
|
||||
isDraggable: false,
|
||||
shouldCloseOnEsc: true,
|
||||
shouldCloseOnOverlayClick: true,
|
||||
});
|
||||
|
||||
interface DialogRootProps extends DialogPrimitive.DialogProps {
|
||||
isDraggable?: boolean;
|
||||
shouldCloseOnEsc?: boolean;
|
||||
shouldCloseOnOverlayClick?: boolean;
|
||||
showOverlay?: boolean;
|
||||
}
|
||||
|
||||
const Dialog = ({
|
||||
isDraggable,
|
||||
shouldCloseOnEsc = true,
|
||||
shouldCloseOnOverlayClick = true,
|
||||
showOverlay = true,
|
||||
...props
|
||||
}: DialogRootProps) => (
|
||||
<DialogContext.Provider
|
||||
value={{ isDraggable, shouldCloseOnEsc, shouldCloseOnOverlayClick, showOverlay }}
|
||||
>
|
||||
<DialogPrimitive.Root {...props} />
|
||||
</DialogContext.Provider>
|
||||
);
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
@@ -14,12 +47,14 @@ const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> & {
|
||||
className?: string;
|
||||
}
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-40 bg-black/60',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -27,36 +62,83 @@ const DialogOverlay = React.forwardRef<
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
interface DialogContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {}
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
DialogContentProps & {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { isDraggable, shouldCloseOnEsc, shouldCloseOnOverlayClick, showOverlay } =
|
||||
React.useContext(DialogContext);
|
||||
|
||||
const { handlePointerDown, setRefs, initialTransform } = useDraggable(
|
||||
{
|
||||
enabled: isDraggable,
|
||||
},
|
||||
ref
|
||||
);
|
||||
|
||||
// When not isDraggable, Tailwind centers the dialog.
|
||||
// When isDraggable, we remove the built‑in centering so our inline transform takes over.
|
||||
const contentClassName = cn(
|
||||
'max-w-md w-full bg-muted data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed left-[50%] top-[50%] z-50 grid gap-4 p-4 shadow-lg duration-200 sm:rounded-lg',
|
||||
!isDraggable ? 'translate-x-[-50%] translate-y-[-50%]' : '',
|
||||
className
|
||||
);
|
||||
|
||||
const style = isDraggable ? { ...props.style, transform: initialTransform } : props.style;
|
||||
|
||||
const content = (
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
ref={setRefs}
|
||||
className={contentClassName}
|
||||
{...props}
|
||||
style={style}
|
||||
onPointerDown={isDraggable ? handlePointerDown : props.onPointerDown}
|
||||
onEscapeKeyDown={event => {
|
||||
if (!shouldCloseOnEsc) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
onInteractOutside={event => {
|
||||
if (!shouldCloseOnOverlayClick) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<Cross2Icon className="text-primary h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
);
|
||||
|
||||
return (
|
||||
<DialogPortal>
|
||||
{showOverlay && !isDraggable && <DialogOverlay />}
|
||||
{content}
|
||||
</DialogPortal>
|
||||
);
|
||||
});
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg',
|
||||
'drag-handle relative flex select-none flex-col space-y-1.5 text-center sm:text-left',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<Cross2Icon className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
DialogHeader.displayName = 'DialogHeader';
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
@@ -69,11 +151,13 @@ DialogFooter.displayName = 'DialogFooter';
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> & {
|
||||
className?: string;
|
||||
}
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-xl font-semibold leading-none tracking-tight', className)}
|
||||
className={cn('text-primary-light text-xl font-normal leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -81,11 +165,13 @@ DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> & {
|
||||
className?: string;
|
||||
}
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-muted-foreground text-base', className)}
|
||||
className={cn('text-base', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import * as React from 'react';
|
||||
|
||||
interface Offset {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
startX: number;
|
||||
startY: number;
|
||||
initialOffset: Offset;
|
||||
}
|
||||
|
||||
interface UseDraggableProps {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface UseDraggableReturn {
|
||||
offset: React.RefObject<Offset>;
|
||||
internalRef: React.RefObject<HTMLDivElement>;
|
||||
handlePointerDown: (e: React.PointerEvent<HTMLDivElement>) => void;
|
||||
setRefs: (node: HTMLDivElement) => void;
|
||||
initialTransform: string | undefined;
|
||||
}
|
||||
|
||||
export function useDraggable(
|
||||
props: UseDraggableProps,
|
||||
ref?: React.ForwardedRef<HTMLDivElement>
|
||||
): UseDraggableReturn {
|
||||
const { enabled = false } = props;
|
||||
const offsetRef = React.useRef<Offset>({ x: 0, y: 0 });
|
||||
const internalRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const dragState = React.useRef<DragState | null>(null);
|
||||
|
||||
const handlePointerMove = React.useCallback((e: PointerEvent) => {
|
||||
if (!dragState.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = e.clientX - dragState.current.startX;
|
||||
const deltaY = e.clientY - dragState.current.startY;
|
||||
const newOffset = {
|
||||
x: dragState.current.initialOffset.x + deltaX,
|
||||
y: dragState.current.initialOffset.y + deltaY,
|
||||
};
|
||||
|
||||
offsetRef.current = newOffset;
|
||||
if (internalRef.current) {
|
||||
internalRef.current.style.transform = `translate(-50%, -50%) translate(${newOffset.x}px, ${newOffset.y}px)`;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePointerUp = React.useCallback(() => {
|
||||
if (internalRef.current) {
|
||||
internalRef.current.style.transition = '';
|
||||
}
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
dragState.current = null;
|
||||
}, [handlePointerMove]);
|
||||
|
||||
const handlePointerDown = React.useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('.drag-handle')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (internalRef.current) {
|
||||
internalRef.current.style.transition = 'none';
|
||||
}
|
||||
dragState.current = {
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
initialOffset: { ...offsetRef.current },
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerup', handlePointerUp);
|
||||
},
|
||||
[handlePointerMove, handlePointerUp]
|
||||
);
|
||||
|
||||
const setRefs = React.useCallback(
|
||||
(node: HTMLDivElement) => {
|
||||
internalRef.current = node;
|
||||
if (typeof ref === 'function') {
|
||||
ref(node);
|
||||
} else if (ref) {
|
||||
(ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||
}
|
||||
},
|
||||
[ref]
|
||||
);
|
||||
|
||||
const initialTransform = enabled
|
||||
? `translate(-50%, -50%) translate(${offsetRef.current.x}px, ${offsetRef.current.y}px)`
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
offset: offsetRef,
|
||||
internalRef,
|
||||
handlePointerDown,
|
||||
setRefs,
|
||||
initialTransform,
|
||||
};
|
||||
}
|
||||
@@ -62,6 +62,11 @@ const DropdownMenuContent = React.forwardRef<
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
onKeyDown={e => {
|
||||
// Todo: or maybe we just want to prevent arrows?
|
||||
e.stopPropagation();
|
||||
props.onKeyDown?.(e);
|
||||
}}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground border-input z-50 min-w-[8rem] overflow-hidden rounded border p-1 shadow-md',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import { Button } from '../Button/Button';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface FooterActionProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface ActionProps extends FooterActionProps {
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type FooterActionComponent = React.FC<FooterActionProps> & {
|
||||
Left: React.FC<FooterActionProps>;
|
||||
Right: React.FC<FooterActionProps>;
|
||||
Primary: React.FC<ActionProps>;
|
||||
Secondary: React.FC<ActionProps>;
|
||||
Auxiliary: React.FC<ActionProps>;
|
||||
};
|
||||
|
||||
export const FooterAction: FooterActionComponent = ({ children, className }: FooterActionProps) => {
|
||||
// Convert children to array for easier inspection
|
||||
const arrayChildren = React.Children.toArray(children);
|
||||
|
||||
// Check if we have a <FooterAction.Left> or <FooterAction.Right> among children
|
||||
const hasLeft = arrayChildren.some(
|
||||
(child: any) => child.type?.displayName === 'FooterAction.Left'
|
||||
);
|
||||
const hasRight = arrayChildren.some(
|
||||
(child: any) => child.type?.displayName === 'FooterAction.Right'
|
||||
);
|
||||
|
||||
// Decide on the justification class based on presence of Left/Right
|
||||
let justifyClass = 'justify-between'; // default
|
||||
if (hasLeft && !hasRight) {
|
||||
justifyClass = 'justify-start';
|
||||
} else if (!hasLeft && hasRight) {
|
||||
justifyClass = 'justify-end';
|
||||
}
|
||||
// If both or neither are present, keep justify-between (or adjust if you like)
|
||||
return (
|
||||
<div className={cn('flex w-full flex-shrink-0 items-center', justifyClass, className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FooterAction.displayName = 'FooterAction';
|
||||
|
||||
FooterAction.Left = ({ children }: FooterActionProps) => {
|
||||
return <div className="flex items-center">{children}</div>;
|
||||
};
|
||||
FooterAction.Left.displayName = 'FooterAction.Left';
|
||||
|
||||
FooterAction.Right = ({ children }: FooterActionProps) => {
|
||||
return <div className="flex items-center space-x-2">{children}</div>;
|
||||
};
|
||||
FooterAction.Right.displayName = 'FooterAction.Right';
|
||||
|
||||
// Primary action: Solid button (default)
|
||||
FooterAction.Primary = ({ children, onClick, className = 'min-w-[80px]' }: ActionProps) => {
|
||||
return (
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={onClick}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
FooterAction.Primary.displayName = 'FooterAction.Primary';
|
||||
|
||||
// Secondary action: Ghost button
|
||||
FooterAction.Secondary = ({ children, onClick, className = 'min-w-[80px]' }: ActionProps) => {
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onClick}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
FooterAction.Secondary.displayName = 'FooterAction.Secondary';
|
||||
|
||||
// Tertiary action: Ghost button with different styling
|
||||
FooterAction.Auxiliary = ({ children, onClick, className }: ActionProps) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onClick}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
FooterAction.Auxiliary.displayName = 'FooterAction.Auxiliary';
|
||||
@@ -0,0 +1,3 @@
|
||||
import { FooterAction } from './FooterAction';
|
||||
|
||||
export { FooterAction };
|
||||
@@ -46,6 +46,7 @@ import Show from './Sources/Show';
|
||||
import SidePanelCloseLeft from './Sources/SidePanelCloseLeft';
|
||||
import SidePanelCloseRight from './Sources/SidePanelCloseRight';
|
||||
import SortingAscending from './Sources/SortingAscending';
|
||||
import SocialGithub from './Sources/SocialGithub';
|
||||
import SortingDescending from './Sources/SortingDescending';
|
||||
import StatusError from './Sources/StatusError';
|
||||
import StatusSuccess from './Sources/StatusSuccess';
|
||||
@@ -471,6 +472,7 @@ export const Icons = {
|
||||
Show,
|
||||
SidePanelCloseLeft,
|
||||
SidePanelCloseRight,
|
||||
SocialGithub,
|
||||
SortingAscending,
|
||||
SortingDescending,
|
||||
Sorting,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import type { IconProps } from '../types';
|
||||
|
||||
export const SocialGithub = (props: IconProps) => (
|
||||
<svg
|
||||
width="24px"
|
||||
height="24px"
|
||||
viewBox="0 0 24 24"
|
||||
version="1.1"
|
||||
>
|
||||
<g
|
||||
id="SocialGithub"
|
||||
stroke="none"
|
||||
strokeWidth="1"
|
||||
fill="none"
|
||||
fillRule="evenodd"
|
||||
>
|
||||
<rect
|
||||
id="Rectangle"
|
||||
x="0"
|
||||
y="0"
|
||||
width="24"
|
||||
height="24"
|
||||
></rect>
|
||||
<g
|
||||
id="github-mark"
|
||||
transform="translate(1, 1)"
|
||||
fill="#FFFFFF"
|
||||
>
|
||||
<path
|
||||
d="M11.009102,0 C4.92135296,0 0,5.04164559 0,11.2788487 C0,16.2645778 3.15328046,20.4848935 7.52771627,21.9785956 C8.07463342,22.0908868 8.2749669,21.7359091 8.2749669,21.4373062 C8.2749669,21.1758281 8.25693914,20.279561 8.25693914,19.3457108 C5.19447352,20.018083 4.55674153,18.0011956 4.55674153,18.0011956 C4.0645837,16.6940344 3.33536083,16.3580774 3.33536083,16.3580774 C2.33301741,15.6671428 3.40837326,15.6671428 3.40837326,15.6671428 C4.52023532,15.7418508 5.10365868,16.824888 5.10365868,16.824888 C6.08774899,18.5427141 7.67351578,18.0573412 8.31147311,17.7585091 C8.4025133,17.0302205 8.69433765,16.5260559 9.00418977,16.2460154 C6.56165372,15.9845373 3.99179662,15.0135622 3.99179662,10.6811845 C3.99179662,9.44873132 4.42896978,8.44040221 5.12168644,7.65619715 C5.01239315,7.37615666 4.62952861,6.21818233 5.23120508,4.66833465 C5.23120508,4.66833465 6.16076142,4.36950256 8.25671379,5.8260798 C9.15406554,5.57919102 10.0794899,5.45359714 11.009102,5.4525397 C11.9386583,5.4525397 12.8862424,5.58339332 13.7612648,5.8260798 C15.8574425,4.36950256 16.7869988,4.66833465 16.7869988,4.66833465 C17.3886753,6.21818233 17.0055854,7.37615666 16.8962921,7.65619715 C17.6072619,8.44040221 18.0264073,9.44873132 18.0264073,10.6811845 C18.0264073,15.0135622 15.4565502,15.9657457 12.995761,16.2460154 C13.3968787,16.6007639 13.7430117,17.2729069 13.7430117,18.3373817 C13.7430117,19.8498753 13.7249839,21.0637661 13.7249839,21.437077 C13.7249839,21.7359091 13.9255427,22.0908868 14.4722345,21.9788248 C18.8466703,20.4846643 22,16.2645778 22,11.2788487 C22.0179786,5.04164559 17.0785978,0 11.009102,0 Z"
|
||||
id="Path"
|
||||
></path>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default SocialGithub;
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../Dialog';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
contentClassName?: string;
|
||||
shouldCloseOnEsc?: boolean;
|
||||
shouldCloseOnOverlayClick?: boolean;
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
const Modal: React.FC<ModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
shouldCloseOnEsc = true,
|
||||
shouldCloseOnOverlayClick = true,
|
||||
containerClassName,
|
||||
}) => {
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={open => !open && onClose()}
|
||||
shouldCloseOnEsc={shouldCloseOnEsc}
|
||||
shouldCloseOnOverlayClick={shouldCloseOnOverlayClick}
|
||||
>
|
||||
<DialogContent className={containerClassName}>
|
||||
{title && (
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
)}
|
||||
<div className={cn('mt-2')}>{children}</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
@@ -0,0 +1,258 @@
|
||||
import React, { createContext, useContext, useRef, useEffect } from 'react';
|
||||
import { useControllableState } from '@radix-ui/react-use-controllable-state';
|
||||
import { Label, Input as InputComponent, FooterAction } from '../../components';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface InputDialogContextValue {
|
||||
value: string;
|
||||
setValue: (value: string) => void;
|
||||
submitOnEnter?: boolean;
|
||||
}
|
||||
|
||||
const InputDialogContext = createContext<InputDialogContextValue | null>(null);
|
||||
|
||||
export type InputDialogRootProps = {
|
||||
/** The controlled value of the input */
|
||||
value?: string;
|
||||
/** The default value for uncontrolled usage */
|
||||
defaultValue?: string;
|
||||
/** Callback when input value changes */
|
||||
onChange?: (value: string) => void;
|
||||
/** Optional className for the root container */
|
||||
className?: string;
|
||||
/** Enable save on Enter key press */
|
||||
submitOnEnter?: boolean;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const InputDialogRoot = React.forwardRef<HTMLDivElement, InputDialogRootProps>(
|
||||
({ value, defaultValue = '', onChange, className, submitOnEnter, children }, ref) => {
|
||||
const [internalValue, setInternalValue] = useControllableState({
|
||||
prop: value,
|
||||
defaultProp: defaultValue,
|
||||
onChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<InputDialogContext.Provider
|
||||
value={{
|
||||
value: internalValue,
|
||||
setValue: setInternalValue,
|
||||
submitOnEnter,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col', className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</InputDialogContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
InputDialogRoot.displayName = 'InputDialog';
|
||||
|
||||
export interface InputDialogFieldProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/** Optional className for the field container */
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Field = React.forwardRef<HTMLDivElement, InputDialogFieldProps>(
|
||||
({ className, children, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('mb-4 flex flex-col space-y-2', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Field.displayName = 'InputDialog.Field';
|
||||
|
||||
export interface InputDialogInputProps
|
||||
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value'> {
|
||||
/** ID for the input field */
|
||||
id?: string;
|
||||
/** Optional className for the input container */
|
||||
className?: string;
|
||||
/** Save handler */
|
||||
onSave?: (value: string) => void;
|
||||
/** Placeholder text for the input field */
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const InputDialogInput = React.forwardRef<HTMLInputElement, InputDialogInputProps>(
|
||||
({ id = 'dialog-input', className, onSave, ...props }, ref) => {
|
||||
const context = useContext(InputDialogContext);
|
||||
if (!context) {
|
||||
throw new Error('InputDialog.Input must be used within an InputDialog');
|
||||
}
|
||||
|
||||
const { value, setValue } = context;
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Combine the forwarded ref with our local ref
|
||||
React.useImperativeHandle(ref, () => inputRef.current);
|
||||
|
||||
// Focus the input when it mounts
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (context.submitOnEnter && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const saveButton = document.querySelector(
|
||||
'[data-cy="input-dialog-save-button"]'
|
||||
) as HTMLButtonElement;
|
||||
if (saveButton) {
|
||||
saveButton.click();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('w-full', className)}>
|
||||
<InputComponent
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
InputDialogInput.displayName = 'InputDialog.Input';
|
||||
|
||||
export interface InputDialogLabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {
|
||||
/** Optional className for the label */
|
||||
className?: string;
|
||||
/** For attribute to match input ID */
|
||||
htmlFor?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const InputDialogLabel = React.forwardRef<HTMLLabelElement, InputDialogLabelProps>(
|
||||
({ className, htmlFor = 'dialog-input', children, ...props }, ref) => {
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(className)}
|
||||
htmlFor={htmlFor}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
InputDialogLabel.displayName = 'InputDialog.Label';
|
||||
|
||||
export interface InputDialogActionsProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/** Optional className for the actions container */
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Actions = React.forwardRef<HTMLDivElement, InputDialogActionsProps>(
|
||||
({ className, children, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<FooterAction className={cn(className)}>
|
||||
<FooterAction.Right>{children}</FooterAction.Right>
|
||||
</FooterAction>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Actions.displayName = 'InputDialog.Actions';
|
||||
|
||||
export interface InputDialogActionButtonProps {
|
||||
/** Optional className for the button */
|
||||
className?: string;
|
||||
/** Click handler that receives the current input value */
|
||||
onClick: (value: string) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ActionsSecondary = React.forwardRef<HTMLDivElement, InputDialogActionButtonProps>(
|
||||
({ className, onClick, children, ...props }, ref) => {
|
||||
const context = useContext(InputDialogContext);
|
||||
if (!context) {
|
||||
throw new Error('InputDialog.ActionsSecondary must be used within an InputDialog');
|
||||
}
|
||||
|
||||
const { value } = context;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<FooterAction.Secondary
|
||||
onClick={() => onClick(value)}
|
||||
className={cn(className)}
|
||||
>
|
||||
{children}
|
||||
</FooterAction.Secondary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ActionsSecondary.displayName = 'InputDialog.ActionsSecondary';
|
||||
|
||||
const ActionsPrimary = React.forwardRef<HTMLDivElement, InputDialogActionButtonProps>(
|
||||
({ className, onClick, children, ...props }, ref) => {
|
||||
const context = useContext(InputDialogContext);
|
||||
if (!context) {
|
||||
throw new Error('InputDialog.ActionsPrimary must be used within an InputDialog');
|
||||
}
|
||||
|
||||
const { value } = context;
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
{...props}
|
||||
data-cy="input-dialog-save-button"
|
||||
onClick={() => onClick(value)}
|
||||
>
|
||||
<FooterAction.Primary
|
||||
onClick={() => onClick(value)}
|
||||
className={cn(className)}
|
||||
>
|
||||
{children}
|
||||
</FooterAction.Primary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ActionsPrimary.displayName = 'InputDialog.ActionsPrimary';
|
||||
|
||||
export const InputDialog = Object.assign(InputDialogRoot, {
|
||||
Label: InputDialogLabel,
|
||||
Input: InputDialogInput,
|
||||
Field,
|
||||
Actions,
|
||||
ActionsSecondary,
|
||||
ActionsPrimary,
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import * as React from 'react';
|
||||
import { Input } from '../Input';
|
||||
import { ScrollArea } from '../ScrollArea';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface PresetDialogProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PresetDialog({ children, className }: PresetDialogProps) {
|
||||
return <div className={cn('flex h-[500px] max-w-lg flex-col', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subcomponent: PresetBody
|
||||
* A dark "box" container that wraps the filter and grid area.
|
||||
* Adjust bg color, padding, border, etc. to match your design.
|
||||
*/
|
||||
interface PresetBodyProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
function PresetBody({ children, className }: PresetBodyProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// Adjust these classes for your desired look
|
||||
'flex min-h-0 flex-1 flex-col rounded-md border border-white/10 bg-black p-2',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subcomponent: PresetFilter
|
||||
*/
|
||||
interface PresetFilterProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
function PresetFilter({ children, className }: PresetFilterProps) {
|
||||
return (
|
||||
<div className={cn('mb-2 flex w-full flex-shrink-0 items-center space-x-2', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subcomponent: PresetSearch
|
||||
*/
|
||||
interface PresetSearchProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
className?: string;
|
||||
}
|
||||
function PresetSearch({ className, ...props }: PresetSearchProps) {
|
||||
return (
|
||||
<Input
|
||||
className={cn('w-full', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subcomponent: PresetGrid
|
||||
*/
|
||||
interface PresetGridProps {
|
||||
children: React.ReactNode;
|
||||
maxHeight?: string;
|
||||
className?: string;
|
||||
}
|
||||
function PresetGrid({ children, maxHeight = 'flex-1', className }: PresetGridProps) {
|
||||
return (
|
||||
<ScrollArea className={cn('min-h-0 flex-1', className)}>
|
||||
<div className="grid grid-cols-4 gap-2 pr-2">{children}</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subcomponent: PresetOption
|
||||
*/
|
||||
interface PresetOptionProps {
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
function PresetOption({ label = 'Label', className }: PresetOptionProps) {
|
||||
return (
|
||||
<div className={cn('flex flex-col items-start space-y-1', className)}>
|
||||
{/* Default dark placeholder box (swap in an <img> if you like) */}
|
||||
<div className="bg-popover h-16 w-24 rounded" />
|
||||
<div className="text-muted-foreground text-left text-base">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Attach subcomponents to PresetDialog as static properties */
|
||||
PresetDialog.PresetBody = PresetBody;
|
||||
PresetDialog.PresetFilter = PresetFilter;
|
||||
PresetDialog.PresetSearch = PresetSearch;
|
||||
PresetDialog.PresetGrid = PresetGrid;
|
||||
PresetDialog.PresetOption = PresetOption;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { InputDialog } from './InputDialog';
|
||||
export { PresetDialog } from './PresetDialog';
|
||||
@@ -0,0 +1,114 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Button } from '../Button';
|
||||
import { Icons } from '../Icons';
|
||||
|
||||
interface AboutModalProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AboutModal({ children, className }: AboutModalProps) {
|
||||
return <div className={cn('space-y-1 text-center', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
/** Sub component: Product Name */
|
||||
interface ProductNameProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
function ProductName({ children, className }: ProductNameProps) {
|
||||
return (
|
||||
<div className={cn('text-foreground pt-3 text-2xl font-medium leading-none', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Sub-component: Product Version */
|
||||
interface ProductVersionProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
function ProductVersion({ children, className }: ProductVersionProps) {
|
||||
return (
|
||||
<div className={cn('text-muted-foreground text-2xl font-light leading-none', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Sub-component: Product Beta */
|
||||
interface ProductBetaProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
function ProductBeta({ children, className }: ProductBetaProps) {
|
||||
return (
|
||||
<div className={cn('text-muted-foreground text-xl font-light', className)}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Sub-component: Body (wraps all detail items) */
|
||||
interface BodyProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
function Body({ children, className }: BodyProps) {
|
||||
return (
|
||||
<div className={cn('my-3 flex flex-col items-center space-y-0', className)}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Sub-component: Detail Item */
|
||||
interface DetailItemProps {
|
||||
label: string;
|
||||
value: string;
|
||||
className?: string;
|
||||
}
|
||||
function DetailItem({ label, value, className }: DetailItemProps) {
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center', className)}>
|
||||
<div className="text-muted-foreground pt-2 text-sm font-semibold tracking-wide">{label}</div>
|
||||
<div className="text-muted-foreground text-sm">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Sub-component: Social Item */
|
||||
interface SocialItemProps {
|
||||
icon: string;
|
||||
url: string;
|
||||
text: string;
|
||||
className?: string;
|
||||
}
|
||||
function SocialItem({ icon, url, text, className }: SocialItemProps) {
|
||||
return (
|
||||
<div className={cn('text-foreground flex items-center', className)}>
|
||||
<div className="inline-block">
|
||||
<Icons.ByName name={icon} />
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
variant="link"
|
||||
className="py-6 text-lg"
|
||||
>
|
||||
<a
|
||||
href={`https://github.com/${url}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Attach sub-components to AboutModal as static properties */
|
||||
AboutModal.ProductName = ProductName;
|
||||
AboutModal.ProductVersion = ProductVersion;
|
||||
AboutModal.ProductBeta = ProductBeta;
|
||||
AboutModal.Body = Body;
|
||||
AboutModal.DetailItem = DetailItem;
|
||||
AboutModal.SocialItem = SocialItem;
|
||||
@@ -0,0 +1,231 @@
|
||||
import * as React from 'react';
|
||||
import { Input } from '../Input/Input';
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '../Select/Select';
|
||||
import { Switch } from '../Switch/Switch';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface ImageModalProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main ImageModal container. By default, we do not force any
|
||||
* layout here. We'll use a "Body" subcomponent for the main area
|
||||
* that sets up a flex row with a 70/30 split.
|
||||
*/
|
||||
export function ImageModal({ children, className }: ImageModalProps) {
|
||||
return <div className={cn(className)}>{children}</div>;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Body subcomponent */
|
||||
|
||||
interface ImageBodyProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
function ImageBody({ children, className }: ImageBodyProps) {
|
||||
return <div className={cn('flex flex-col sm:flex-row', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* ImageVisual subcomponent */
|
||||
|
||||
interface ImageVisualProps {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
function ImageVisual({ children, className }: ImageVisualProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 items-center justify-center rounded-2xl bg-black/80 p-4 sm:flex-[7]',
|
||||
'flex', // ensure the container is a flex box
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="h-[512px] w-[512px] overflow-auto">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* ImageOptions subcomponent */
|
||||
|
||||
interface ImageOptionsProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
function ImageOptions({ children, className }: ImageOptionsProps) {
|
||||
return <div className={cn('flex-1 space-y-5 p-4 sm:flex-[3]', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Filename subcomponent */
|
||||
|
||||
interface FilenameProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'children'> {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
/** Handler is optional. If not provided, default to a no‐op. */
|
||||
onChange?: React.ChangeEventHandler<HTMLInputElement>;
|
||||
}
|
||||
|
||||
function Filename({ children, className, value, onChange, ...props }: FilenameProps) {
|
||||
return (
|
||||
<div className={cn('text-foreground space-y-1', className)}>
|
||||
<label className="block text-base">{children}</label>
|
||||
<Input
|
||||
{...props}
|
||||
className={cn('w-full', className)}
|
||||
value={value}
|
||||
onChange={onChange ?? (() => {})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Filetype subcomponent */
|
||||
|
||||
interface FiletypeProps {
|
||||
selected: string;
|
||||
/** Handler is optional. If not provided, we do nothing. */
|
||||
onSelect?: (val: string) => void;
|
||||
className?: string;
|
||||
/** Array of file type options */
|
||||
options?: Array<{ value: string; label: string }>;
|
||||
}
|
||||
|
||||
function Filetype({ selected, onSelect, className, options = [] }: FiletypeProps) {
|
||||
const defaultOptions = [
|
||||
{ value: 'jpg', label: 'JPG' },
|
||||
{ value: 'png', label: 'PNG' },
|
||||
];
|
||||
|
||||
const fileTypeOptions = options.length ? options : defaultOptions;
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={selected}
|
||||
onValueChange={val => onSelect?.(val)}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label="File type"
|
||||
className={cn('w-[5.5rem] sm:w-24', className)}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fileTypeOptions.map(option => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* ImageSize subcomponent */
|
||||
|
||||
interface ImageSizeProps {
|
||||
children: React.ReactNode;
|
||||
width: string;
|
||||
height: string;
|
||||
/** Handlers optional. If not provided, default no‐op. */
|
||||
onWidthChange?: React.ChangeEventHandler<HTMLInputElement>;
|
||||
onHeightChange?: React.ChangeEventHandler<HTMLInputElement>;
|
||||
className?: string;
|
||||
maxWidth?: string;
|
||||
maxHeight?: string;
|
||||
}
|
||||
|
||||
function ImageSize({
|
||||
children,
|
||||
width,
|
||||
height,
|
||||
onWidthChange,
|
||||
onHeightChange,
|
||||
className,
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
}: ImageSizeProps) {
|
||||
return (
|
||||
<div className={cn('text-foreground space-y-1', className)}>
|
||||
<label className="block text-base">{children}</label>
|
||||
|
||||
{/* Flex container for width/height inputs */}
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* Width group */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-foreground text-base">W</span>
|
||||
<Input
|
||||
value={width}
|
||||
onChange={onWidthChange ?? (() => {})}
|
||||
placeholder="Width"
|
||||
className="w-20"
|
||||
max={maxWidth}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Height group */}
|
||||
<div className="text-foreground flex items-center space-x-2 text-base">
|
||||
<span className="text-foreground text-base">H</span>
|
||||
<Input
|
||||
value={height}
|
||||
onChange={onHeightChange ?? (() => {})}
|
||||
placeholder="Height"
|
||||
className="w-20"
|
||||
max={maxHeight}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* SwitchOption subcomponent */
|
||||
|
||||
interface SwitchOptionProps {
|
||||
children: React.ReactNode;
|
||||
checked?: boolean;
|
||||
defaultChecked?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function SwitchOption({
|
||||
children,
|
||||
checked,
|
||||
defaultChecked,
|
||||
onCheckedChange,
|
||||
className,
|
||||
}: SwitchOptionProps) {
|
||||
return (
|
||||
<div className={cn('text-foreground flex items-center space-x-2', className)}>
|
||||
<Switch
|
||||
checked={checked}
|
||||
defaultChecked={defaultChecked}
|
||||
onCheckedChange={val => onCheckedChange?.(val)}
|
||||
/>
|
||||
<span className="text-base">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Attach subcomponents onto the main ImageModal function. */
|
||||
|
||||
ImageModal.Body = ImageBody;
|
||||
ImageModal.ImageVisual = ImageVisual;
|
||||
ImageModal.ImageOptions = ImageOptions;
|
||||
ImageModal.Filename = Filename;
|
||||
ImageModal.Filetype = Filetype;
|
||||
ImageModal.ImageSize = ImageSize;
|
||||
ImageModal.SwitchOption = SwitchOption;
|
||||
@@ -0,0 +1,120 @@
|
||||
import * as React from 'react';
|
||||
import { Label } from '../Label';
|
||||
import { Input } from '../Input';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface UserPreferencesModalProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function UserPreferencesModal({ children, className }: UserPreferencesModalProps) {
|
||||
return (
|
||||
<div className={cn('flex max-h-[80vh] w-full max-w-4xl flex-col overflow-hidden', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Body
|
||||
* Automatically wraps content in a scrollable area.
|
||||
*/
|
||||
interface BodyProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
function Body({ children, className }: BodyProps) {
|
||||
return (
|
||||
<div className={cn('flex-1 overflow-y-auto', className)}>
|
||||
<div className={cn('mt-1 mb-4 flex flex-col space-y-4', className)}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Subheading
|
||||
* Section labels
|
||||
*/
|
||||
interface SubHeadingProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
function SubHeading({ children, className }: SubHeadingProps) {
|
||||
return <span className={cn('text-muted-foreground text-lg', className)}>{children}</span>;
|
||||
}
|
||||
|
||||
/** Responsive 3-column grid for hotkeys, etc. */
|
||||
interface HotkeysGridProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
function HotkeysGrid({ children, className }: HotkeysGridProps) {
|
||||
return (
|
||||
<div className={cn('grid grid-cols-1 gap-3 gap-x-16 md:grid-cols-2 lg:grid-cols-3', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A single hotkey row: label + input */
|
||||
interface HotkeyProps {
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
hotkeys?: {
|
||||
record: (callback: (sequence: string[]) => void) => void;
|
||||
pause: () => void;
|
||||
unpause: () => void;
|
||||
startRecording: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
function Hotkey({ label, placeholder, className, value, onChange, hotkeys }: HotkeyProps) {
|
||||
const [isRecording, setIsRecording] = React.useState(false);
|
||||
|
||||
const onInputKeyDown = (event: React.KeyboardEvent) => {
|
||||
event.preventDefault();
|
||||
hotkeys?.record((sequence: string[]) => {
|
||||
const keys = sequence.join('+');
|
||||
hotkeys?.unpause();
|
||||
setIsRecording(false);
|
||||
onChange?.(keys);
|
||||
});
|
||||
};
|
||||
|
||||
const onFocus = () => {
|
||||
setIsRecording(true);
|
||||
hotkeys?.pause();
|
||||
hotkeys?.startRecording();
|
||||
};
|
||||
|
||||
const onBlur = () => {
|
||||
setIsRecording(false);
|
||||
hotkeys?.unpause();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center justify-between space-x-2', className)}>
|
||||
<Label className="whitespace-nowrap">{label}</Label>
|
||||
<Input
|
||||
className={cn(
|
||||
'w-16 text-center transition-colors',
|
||||
isRecording && 'bg-accent text-accent-foreground caret-accent-foreground'
|
||||
)}
|
||||
placeholder={isRecording ? 'Press keys...' : placeholder}
|
||||
value={value}
|
||||
onKeyDown={onInputKeyDown}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
readOnly={!isRecording}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Attach subcomponents as static properties for a nicer API */
|
||||
UserPreferencesModal.Body = Body;
|
||||
UserPreferencesModal.HotkeysGrid = HotkeysGrid;
|
||||
UserPreferencesModal.Hotkey = Hotkey;
|
||||
UserPreferencesModal.SubHeading = SubHeading;
|
||||
@@ -0,0 +1,3 @@
|
||||
export { UserPreferencesModal } from './UserPreferencesModal';
|
||||
export { ImageModal } from './ImageModal';
|
||||
export { AboutModal } from './AboutModal';
|
||||
@@ -54,6 +54,10 @@ import { DisplaySetMessageListTooltip } from './DisplaySetMessageListTooltip';
|
||||
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from './Tooltip';
|
||||
import { ToolboxUI, Toolbox } from './OHIFToolbox';
|
||||
import Numeric from './Numeric';
|
||||
import { InputDialog, PresetDialog } from './OHIFDialogs';
|
||||
import { AboutModal, ImageModal, UserPreferencesModal } from './OHIFModals';
|
||||
import Modal from './Modal/Modal';
|
||||
import { FooterAction } from './FooterAction';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -225,4 +229,11 @@ export {
|
||||
ToolButtonListDropDown,
|
||||
ToolButtonListItem,
|
||||
ToolButtonListDivider,
|
||||
InputDialog,
|
||||
PresetDialog,
|
||||
Modal,
|
||||
AboutModal,
|
||||
ImageModal,
|
||||
UserPreferencesModal,
|
||||
FooterAction,
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { useState, createContext, useContext, useCallback, useEffect, useMemo } from 'react';
|
||||
import ManagedDialog, { ManagedDialogProps } from './ManagedDialog';
|
||||
|
||||
interface DialogContextType {
|
||||
show: (options: ManagedDialogProps) => string;
|
||||
hide: (id: string) => void;
|
||||
hideAll: () => void;
|
||||
isEmpty: () => boolean;
|
||||
}
|
||||
|
||||
interface DialogService {
|
||||
setServiceImplementation: (implementation: DialogContextType) => void;
|
||||
getCustomComponent?: () => React.ComponentType<ManagedDialogProps> | null;
|
||||
}
|
||||
|
||||
interface DialogProviderProps {
|
||||
children: React.ReactNode;
|
||||
dialog?: React.ComponentType<ManagedDialogProps>;
|
||||
service?: DialogService | null;
|
||||
}
|
||||
|
||||
const DialogContext = createContext<DialogContextType | null>(null);
|
||||
|
||||
export const useDialog = () => {
|
||||
const context = useContext(DialogContext);
|
||||
if (!context) {
|
||||
throw new Error('useDialog must be used within a DialogProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
const DialogProvider: React.FC<DialogProviderProps> = ({
|
||||
children,
|
||||
dialog: DialogComponent = ManagedDialog,
|
||||
service = null,
|
||||
}) => {
|
||||
const [dialogs, setDialogs] = useState<(ManagedDialogProps & { id: string })[]>([]);
|
||||
|
||||
const show = useCallback((options: ManagedDialogProps) => {
|
||||
const id = options.id;
|
||||
setDialogs(prev => [...prev, { ...options, id }]);
|
||||
return id;
|
||||
}, []);
|
||||
|
||||
const hide = useCallback((id: string) => {
|
||||
setDialogs(prev => prev.filter(dialog => dialog.id !== id));
|
||||
}, []);
|
||||
|
||||
const hideAll = useCallback(() => {
|
||||
setDialogs([]);
|
||||
}, []);
|
||||
|
||||
const isEmpty = useCallback(() => dialogs.length === 0, [dialogs]);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
show,
|
||||
hide,
|
||||
hideAll,
|
||||
isEmpty,
|
||||
}),
|
||||
[show, hide, hideAll, isEmpty]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (service) {
|
||||
service.setServiceImplementation(contextValue);
|
||||
}
|
||||
}, [service, contextValue]);
|
||||
|
||||
const CustomDialog = service?.getCustomComponent?.();
|
||||
const RenderedDialog = CustomDialog || DialogComponent;
|
||||
|
||||
return (
|
||||
<DialogContext.Provider value={contextValue}>
|
||||
{dialogs.map(dialog => (
|
||||
<RenderedDialog
|
||||
key={dialog.id}
|
||||
onClose={hide}
|
||||
isOpen={true}
|
||||
{...dialog}
|
||||
/>
|
||||
))}
|
||||
{children}
|
||||
</DialogContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export { DialogProvider };
|
||||
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../components/Dialog/Dialog';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
export interface ManagedDialogProps {
|
||||
id: string;
|
||||
isOpen?: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
content: React.ComponentType<{ hide?: () => void }>;
|
||||
contentProps?: Record<string, unknown>;
|
||||
isDraggable?: boolean;
|
||||
shouldCloseOnEsc?: boolean;
|
||||
shouldCloseOnOverlayClick?: boolean;
|
||||
defaultPosition?: { x: number; y: number };
|
||||
onClose?: (id: string) => void;
|
||||
unstyled?: boolean;
|
||||
showOverlay?: boolean;
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
const ManagedDialog: React.FC<ManagedDialogProps> = ({
|
||||
id,
|
||||
isOpen,
|
||||
title,
|
||||
content: DialogContentComponent,
|
||||
contentProps,
|
||||
isDraggable,
|
||||
shouldCloseOnEsc = false,
|
||||
shouldCloseOnOverlayClick = false,
|
||||
showOverlay = true,
|
||||
defaultPosition,
|
||||
onClose,
|
||||
unstyled,
|
||||
containerClassName,
|
||||
}) => {
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
modal={false} // keep modal behavior off for independent windows
|
||||
onOpenChange={open => {
|
||||
if (!open) {
|
||||
onClose(id);
|
||||
}
|
||||
}}
|
||||
isDraggable={isDraggable}
|
||||
shouldCloseOnEsc={shouldCloseOnEsc}
|
||||
shouldCloseOnOverlayClick={shouldCloseOnOverlayClick}
|
||||
showOverlay={showOverlay}
|
||||
>
|
||||
<DialogContent
|
||||
className={cn(unstyled ? 'p-0' : '', containerClassName)}
|
||||
style={{
|
||||
...(defaultPosition
|
||||
? {
|
||||
position: 'fixed',
|
||||
left: `${defaultPosition.x}px`,
|
||||
top: `${defaultPosition.y}px`,
|
||||
transform: 'translate(0, 0)',
|
||||
margin: 0,
|
||||
animation: 'none',
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{!unstyled && <DialogHeader>{title && <DialogTitle>{title}</DialogTitle>}</DialogHeader>}
|
||||
<DialogContentComponent
|
||||
{...contentProps}
|
||||
hide={() => onClose(id)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManagedDialog;
|
||||
@@ -0,0 +1,102 @@
|
||||
import React, { useState, createContext, useContext, useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ModalOptions {
|
||||
title?: string;
|
||||
shouldCloseOnEsc?: boolean;
|
||||
content?: React.ComponentType;
|
||||
contentProps?: Record<string, unknown>;
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
interface ModalContextType {
|
||||
show: (options: Partial<ModalOptions>) => void;
|
||||
hide: () => void;
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
interface ModalComponentProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ModalContext = createContext<ModalContextType | null>(null);
|
||||
|
||||
export const useModal = () => {
|
||||
const ctx = useContext(ModalContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useModal must be used within a ModalProvider');
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
const DEFAULT_OPTIONS: ModalOptions = {
|
||||
title: '',
|
||||
shouldCloseOnEsc: true,
|
||||
};
|
||||
|
||||
interface ModalService {
|
||||
setServiceImplementation: (implementation: ModalContextType) => void;
|
||||
getCustomComponent: () => React.ComponentType<ModalComponentProps> | null;
|
||||
}
|
||||
|
||||
interface ModalProviderProps {
|
||||
children: React.ReactNode;
|
||||
modal: React.ComponentType<ModalComponentProps>;
|
||||
service?: ModalService | null;
|
||||
}
|
||||
|
||||
const ModalProvider: React.FC<ModalProviderProps> = ({
|
||||
children,
|
||||
modal: ModalComponent,
|
||||
service = null,
|
||||
}) => {
|
||||
const { t } = useTranslation('Modals');
|
||||
const [options, setOptions] = useState<ModalOptions>(DEFAULT_OPTIONS);
|
||||
|
||||
const ModalContent = options.content;
|
||||
|
||||
const show = useCallback((props: Partial<ModalOptions>) => {
|
||||
setOptions(prev => ({ ...prev, ...props }));
|
||||
}, []);
|
||||
|
||||
const hide = useCallback(() => {
|
||||
setOptions(DEFAULT_OPTIONS);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (service) {
|
||||
service.setServiceImplementation({ show, hide });
|
||||
}
|
||||
}, [hide, service, show]);
|
||||
|
||||
const { title } = options;
|
||||
|
||||
const CustomModal = service?.getCustomComponent();
|
||||
const RenderedModal = CustomModal || ModalComponent;
|
||||
|
||||
return (
|
||||
<ModalContext.Provider value={{ show, hide }}>
|
||||
{ModalContent && (
|
||||
<RenderedModal
|
||||
isOpen={true}
|
||||
onClose={hide}
|
||||
title={t(title)}
|
||||
{...options}
|
||||
>
|
||||
<ModalContent
|
||||
{...options.contentProps}
|
||||
show={show}
|
||||
hide={hide}
|
||||
/>
|
||||
</RenderedModal>
|
||||
)}
|
||||
{children}
|
||||
</ModalContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export { ModalProvider };
|
||||
export const ModalConsumer = ModalContext.Consumer;
|
||||
@@ -1,7 +1,13 @@
|
||||
import NotificationProvider, { useNotification } from './NotificationProvider';
|
||||
import { ViewportGridContext, ViewportGridProvider, useViewportGrid } from './ViewportGridProvider';
|
||||
import { ToolboxProvider, useToolbox } from './ToolboxContext';
|
||||
import { ModalProvider, useModal } from './ModalProvider';
|
||||
import { DialogProvider, useDialog } from './DialogProvider';
|
||||
import ManagedDialog from './ManagedDialog';
|
||||
|
||||
export { useNotification, NotificationProvider };
|
||||
export { ViewportGridContext, ViewportGridProvider, useViewportGrid };
|
||||
export { ToolboxProvider, useToolbox };
|
||||
export { ModalProvider, useModal };
|
||||
export { DialogProvider, useDialog };
|
||||
export { ManagedDialog };
|
||||
@@ -100,6 +100,13 @@ import {
|
||||
ToolButtonListDivider,
|
||||
Toolbox,
|
||||
Numeric,
|
||||
InputDialog,
|
||||
PresetDialog,
|
||||
Modal,
|
||||
AboutModal,
|
||||
ImageModal,
|
||||
UserPreferencesModal,
|
||||
FooterAction,
|
||||
} from './components';
|
||||
import { DataRow } from './components/DataRow';
|
||||
|
||||
@@ -108,6 +115,11 @@ import {
|
||||
NotificationProvider,
|
||||
useToolbox,
|
||||
ToolboxProvider,
|
||||
useModal,
|
||||
ModalProvider,
|
||||
DialogProvider,
|
||||
useDialog,
|
||||
ManagedDialog,
|
||||
} from './contextProviders';
|
||||
import { ViewportGridContext, ViewportGridProvider, useViewportGrid } from './contextProviders';
|
||||
import * as utils from './utils';
|
||||
@@ -225,4 +237,16 @@ export {
|
||||
useToolbox,
|
||||
utils,
|
||||
Numeric,
|
||||
AboutModal,
|
||||
ImageModal,
|
||||
UserPreferencesModal,
|
||||
InputDialog,
|
||||
PresetDialog,
|
||||
Modal,
|
||||
useModal,
|
||||
ModalProvider,
|
||||
FooterAction,
|
||||
DialogProvider,
|
||||
useDialog,
|
||||
ManagedDialog,
|
||||
};
|
||||
@@ -37,6 +37,7 @@ class LabellingFlow extends Component<PropType> {
|
||||
this.state = {
|
||||
location,
|
||||
label,
|
||||
hide: props.hide,
|
||||
componentClassName: className,
|
||||
confirmationState: false,
|
||||
displayComponent: true,
|
||||
@@ -56,33 +57,19 @@ class LabellingFlow extends Component<PropType> {
|
||||
displayComponent={this.state.displayComponent}
|
||||
onTransitionExit={this.props.labellingDoneCallback}
|
||||
>
|
||||
<>
|
||||
<div
|
||||
className={className}
|
||||
ref={this.mainElement}
|
||||
>
|
||||
{this.labellingStateFragment()}
|
||||
</div>
|
||||
</>
|
||||
<div
|
||||
className={className}
|
||||
ref={this.mainElement}
|
||||
>
|
||||
{this.labellingStateFragment()}
|
||||
</div>
|
||||
</LabellingTransition>
|
||||
);
|
||||
}
|
||||
|
||||
closePopup = () => {
|
||||
this.setState({
|
||||
displayComponent: false,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
this.setState({
|
||||
displayComponent: false,
|
||||
});
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
selectTreeSelectCalback = (event, itemSelected) => {
|
||||
const label = itemSelected.value;
|
||||
this.closePopup();
|
||||
this.props.hide();
|
||||
return this.props.labellingDoneCallback(label);
|
||||
};
|
||||
|
||||
@@ -92,7 +79,7 @@ class LabellingFlow extends Component<PropType> {
|
||||
items={this.currentItems}
|
||||
columns={1}
|
||||
onSelected={this.selectTreeSelectCalback}
|
||||
closePopup={this.closePopup}
|
||||
closePopup={this.props.hide}
|
||||
selectTreeFirstTitle="Annotation"
|
||||
exclusive={this.props.exclusive}
|
||||
label={this.state.label}
|
||||
|
||||
@@ -22,7 +22,7 @@ const Modal = ({
|
||||
onClose,
|
||||
children,
|
||||
shouldCloseOnOverlayClick = true,
|
||||
movable = false,
|
||||
isDraggable = false,
|
||||
containerDimensions = null,
|
||||
contentDimensions = null,
|
||||
}) => {
|
||||
@@ -75,7 +75,7 @@ const Modal = ({
|
||||
: 'relative max-h-full w-11/12 text-white outline-none lg:w-10/12 xl:w-9/12'
|
||||
}
|
||||
overlayClassName={
|
||||
movable
|
||||
isDraggable
|
||||
? 'fixed top-0 left-0 right-0 bottom-0 z-50 flex items-center justify-center py-16 pointer-events-none'
|
||||
: 'fixed top-0 left-0 right-0 bottom-0 z-50 bg-overlay flex items-center justify-center py-16'
|
||||
}
|
||||
@@ -85,7 +85,7 @@ const Modal = ({
|
||||
title={title}
|
||||
shouldCloseOnOverlayClick={shouldCloseOnOverlayClick}
|
||||
>
|
||||
{movable ? (
|
||||
{isDraggable ? (
|
||||
<Draggable
|
||||
handle=".drag-handle"
|
||||
defaultClassName="bg-primary-dark pointer-events-auto"
|
||||
@@ -107,7 +107,7 @@ Modal.propTypes = {
|
||||
onClose: PropTypes.func,
|
||||
children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node]).isRequired,
|
||||
shouldCloseOnOverlayClick: PropTypes.bool,
|
||||
movable: PropTypes.bool,
|
||||
isDraggable: PropTypes.bool,
|
||||
containerDimensions: PropTypes.string,
|
||||
contentDimensions: PropTypes.string,
|
||||
};
|
||||
|
||||
@@ -168,20 +168,9 @@ export class SelectTree extends Component<PropType> {
|
||||
|
||||
headerItem = () => {
|
||||
const inputLeftPadding = this.props.items.length > 0 ? 'pl-8' : 'pl-4';
|
||||
const title = this.props.selectTreeFirstTitle;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-between border-b-2 border-solid border-black p-4">
|
||||
<div className="text-primary-active m-0 mb-5 p-2 leading-tight">
|
||||
<span className="text-primary-light align-sub text-xl">{title}</span>
|
||||
<div className="float-right">
|
||||
<Icons.Close
|
||||
className="cursor-pointer"
|
||||
onClick={() => this.props.closePopup()}
|
||||
fill="#a3a3a3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between border-b-2 border-solid border-black p-0.5">
|
||||
{this.props.searchEnabled && (
|
||||
<div className="flex w-full flex-col">
|
||||
{this.props.items.length > 0 && (
|
||||
|
||||
@@ -31,7 +31,7 @@ const ModalProvider = ({ children, modal: Modal, service = null }) => {
|
||||
closeButton: true,
|
||||
title: null,
|
||||
customClassName: '',
|
||||
movable: false,
|
||||
isDraggable: false,
|
||||
containerDimensions: null,
|
||||
contentDimensions: null,
|
||||
};
|
||||
@@ -78,7 +78,7 @@ const ModalProvider = ({ children, modal: Modal, service = null }) => {
|
||||
shouldCloseOnEsc,
|
||||
closeButton,
|
||||
shouldCloseOnOverlayClick,
|
||||
movable,
|
||||
isDraggable,
|
||||
containerDimensions,
|
||||
contentDimensions,
|
||||
} = options;
|
||||
@@ -96,7 +96,7 @@ const ModalProvider = ({ children, modal: Modal, service = null }) => {
|
||||
closeButton={closeButton}
|
||||
onClose={hide}
|
||||
shouldCloseOnOverlayClick={shouldCloseOnOverlayClick}
|
||||
movable={movable}
|
||||
isDraggable={isDraggable}
|
||||
containerDimensions={containerDimensions}
|
||||
contentDimensions={contentDimensions}
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user