npm package: @linzjs/windows
Description: [![semantic-release: react](https://img.shields.io/badge/semantic--release-react-e10079?logo=semantic-release)](https://github.com/semantic-release/semantic-release)
Latest version: 9.5.6

# @linzjs/windows

[![semantic-release: react](https://img.shields.io/badge/semantic--release-react-e10079?logo=semantic-release)](https://github.com/semantic-release/semantic-release)

**Note: Make sure to install oxc plugin in Intellij/VSCode for development.**

> Reusable promise based windowing component for LINZ / Toitū te whenua.

React state based modals/windows are painful because they require:
- shared states for open/closed.
- callbacks/states for return values.
- inline modal/window includes, which prevent you from closing the invoking component before the modal/window has completed.

This module gives you promise based modals/windows which don't require all the state
based boilerplate / inline-components.

So you can simply do this in your react-app:
```
const result = await showModal(TestModal, { props... })

<button onClick={() => openPanel({ componentFn: () => <Panel.../>, onClose=onClose)}>
    Open panel
</button>
```

## Features
- Async HTML dialog based Modals.
- Draggable and resizeable, pop-in/out Panels/Windows.

## Install
```
npm install @linzjs/windows
```
or with Yarn
```
yarn add @linzjs/windows
```

## Debug log
To enable debug logs set:
```javascript
localStorage.setItem("@linzjs/windows.debugEnabled", "true");
```

## Demo

```bash
npm run storybook
```

---

## Components

### Modal (LuiModalAsync)

Promise-based async modals using the HTML `<dialog>` element. Show a modal, await the result, and continue.

![Generic modal example](https://github.com/matsondawson/windows-doc-images/raw/HEAD/docimages/generic-modal.png)

**Setup** — wrap your app with the context provider once at the root:

```tsx
import { LuiModalAsyncContextProvider } from '@linzjs/windows';

export const App = () => (
  <LuiModalAsyncContextProvider>
    <div>...the rest of your app...</div>
  </LuiModalAsyncContextProvider>
);
```

**Define a modal component** — extend your props with `LuiModalAsyncCallback<RESULT_TYPE>` to declare the return type and receive `resolve`:

```tsx
import React, { ReactElement } from 'react';
import {
  LuiModalAsync,
  LuiModalAsyncButtonContinue,
  LuiModalAsyncButtonDismiss,
  LuiModalAsyncButtonGroup,
  LuiModalAsyncCallback,
  LuiModalAsyncContent,
  LuiModalAsyncHeader,
  LuiModalAsyncMain,
} from '@linzjs/windows';

// The modal returns a number (or undefined if dismissed/escaped)
interface TestModalProps extends LuiModalAsyncCallback<number> {
  text: string;
}

const TestModal = ({ text, resolve }: TestModalProps): ReactElement => {
  const doSomething = () => {
    resolve(10); // close modal and return 10
  };

  return (
    <LuiModalAsync closeOnOverlayClick={false}>
      <LuiModalAsyncMain>
        <LuiModalAsyncHeader title={'Generic modal'} onHelpClick={() => alert('help link')} />
        <LuiModalAsyncContent>{text}</LuiModalAsyncContent>
      </LuiModalAsyncMain>
      <LuiModalAsyncButtonGroup>
        <LuiModalAsyncButtonDismiss autofocus={true}>Dismiss</LuiModalAsyncButtonDismiss>
        <LuiModalAsyncButtonContinue level={'tertiary'} onClick={doSomething}>
          Continue onClick
        </LuiModalAsyncButtonContinue>
        <LuiModalAsyncButtonContinue value={10}>Continue resolve value</LuiModalAsyncButtonContinue>
      </LuiModalAsyncButtonGroup>
    </LuiModalAsync>
  );
};
```

**Invoke the modal** — use `useShowAsyncModal`, await the result:

```tsx
import { useShowAsyncModal } from '@linzjs/windows';

export const TestModalUsage = () => {
  // modalOwnerRef is only required if you have popout windows
  const { showModal, modalOwnerRef } = useShowAsyncModal();

  const showModalHandler = async () => {
    const result = await showModal(
      TestModal,
      { text: "I'm generic modal content" },
      { showOnAllWindows: true },
    );

    if (!result) return alert('Modal closed');
    alert(`Modal result is: ${String(result)}`);
  };

  // Add modalOwnerRef so that modals work in popout windows
  return (
    <div ref={modalOwnerRef}>
      <button onClick={() => void showModalHandler()}>Click to show the modal</button>
    </div>
  );
};
```

---

### Prefab Modal

Pre-built modals for common use cases: `outline`, `info`, `warning`, `error`, `success`, `progress`, and `blocked`.

| Outline | Info | Warning | Error |
|---------|------|---------|-------|
| ![Outline modal](https://github.com/matsondawson/windows-doc-images/raw/HEAD/docimages/prefab-modal-outline.png) | ![Info modal](https://github.com/matsondawson/windows-doc-images/raw/HEAD/docimages/prefab-modal-info.png) | ![Warning modal](https://github.com/matsondawson/windows-doc-images/raw/HEAD/docimages/prefab-modal-warning.png) | ![Error modal](https://github.com/matsondawson/windows-doc-images/raw/HEAD/docimages/prefab-modal-error.png) |

| Success | Progress | Blocked | Custom buttons |
|---------|----------|---------|----------------|
| ![Success modal](https://github.com/matsondawson/windows-doc-images/raw/HEAD/docimages/prefab-modal-success.png) | ![Progress modal](https://github.com/matsondawson/windows-doc-images/raw/HEAD/docimages/prefab-modal-progress.png) | ![Blocked modal](https://github.com/matsondawson/windows-doc-images/raw/HEAD/docimages/prefab-modal-blocked.png) | ![Custom buttons modal](https://github.com/matsondawson/windows-doc-images/raw/HEAD/docimages/prefab-modal-custom.png) |

**Setup** — same `LuiModalAsyncContextProvider` as above.

**Invoke** — use `useLuiModalPrefab`:

```tsx
import { useLuiModalPrefab, LuiModalDontShowSessionRemove } from '@linzjs/windows';

export const PrefabModalUsage = () => {
  const { modalOwnerRef, showPrefabModal } = useLuiModalPrefab();

  return (
    <div ref={modalOwnerRef} style={{ display: 'flex', gap: 10 }}>
      {/* Info */}
      <button
        onClick={() =>
          void showPrefabModal({
            level: 'info',
            title: 'You are a fantastic person',
            children: 'Keep it up!',
          })
        }
      >
        Info
      </button>

      {/* Warning with custom buttons */}
      <button
        onClick={() =>
          void showPrefabModal<'delete'>({
            level: 'warning',
            title: 'You are about to make changes',
            children: 'Are you sure that you want to make these changes?',
            helpLink: 'https://www.example.com/help',
            buttons: [
              { title: 'Cancel', icon: 'ic_navigate_before', value: undefined },
              { title: 'Delete the world!', icon: 'ic_delete_solid', value: 'delete', level: 'error' },
            ],
          }).then((result) => {
            alert('Warning result: ' + result);
          })
        }
      >
        Warning
      </button>

      {/* Warning with "don't show again" */}
      <button
        onClick={() =>
          void showPrefabModal({
            level: 'warning',
            title: 'Warning',
            children: 'This message can be suppressed.',
            dontShowAgainSessionKey: 'userId_surveyId_modalId',
            buttons: [{ title: 'Dismiss', default: true }],
          })
        }
      >
        Warning + Don't show again
      </button>

      {/* Error */}
      <button
        onClick={() =>
          void showPrefabModal({
            level: 'error',
            title: 'Something is not right',
            children: 'Maybe stop doing that',
            onHelpClick: () => {},
          })
        }
      >
        Error
      </button>

      {/* Success */}
      <button
        onClick={() =>
          void showPrefabModal({
            level: 'success',
            title: 'You are a success',
            children: 'Keep succeeding!',
            onHelpClick: () => {},
          })
        }
      >
        Success
      </button>

      {/* Progress — polledCloseCondition closes the modal automatically */}
      <button
        onClick={() => {
          let done = false;
          setTimeout(() => (done = true), 5000);
          void showPrefabModal({
            level: 'progress',
            title: 'Signing in progress',
            children: 'This modal will close in 5 seconds unless cancelled.',
            buttons: [{ title: 'Cancel', value: true }],
            polledCloseCondition: () => done,
          }).then((result) => {
            alert(result === true ? 'Cancelled' : 'Timed-out');
          });
        }}
      >
        Progress
      </button>

      {/* Blocked */}
      <button
        onClick={() =>
          void showPrefabModal({
            level: 'blocked',
            title: 'Plan Generation blocked',
            children: "This CSD is being used by 'Joe Bloggs'. Please close Plan Generation.",
            buttons: [{ title: 'Return to Survey Capture' }],
          })
        }
      >
        Blocked
      </button>
    </div>
  );
};
```

To clear a "don't show again" session key programmatically:
```tsx
import { LuiModalDontShowSessionRemove } from '@linzjs/windows';

LuiModalDontShowSessionRemove('userId_surveyId_modalId');
```

---

### Upload Modal

A prefab modal for file upload interactions.

![Upload modal](https://github.com/matsondawson/windows-doc-images/raw/HEAD/docimages/file-upload.png)

**Setup** — same `LuiModalAsyncContextProvider` as above.

**Invoke** — use `useLuiModalUpload`:

```tsx
import { useLuiModalUpload } from '@linzjs/windows';

export const ModalUploadUsage = () => {
  const { modalOwnerRef, showUploadModal } = useLuiModalUpload();

  return (
    <div ref={modalOwnerRef}>
      <button
        onClick={() =>
          void showUploadModal({
            title: 'Add georeferenced image',
            content: 'Once your image has been uploaded, place markers to align the image to the spatial view.',
            width: 480,
            fileDescription: 'image',
            fileFormatText: 'Format: jpg, jpeg, png',
            acceptedExtensions: ['jpg', 'jpeg', 'png'],
            customFileErrorMessage: 'Incorrect file format!',
          }).then((file) => {
            console.log(file);
          })
        }
      >
        Upload Image...
      </button>
    </div>
  );
};
```

---

### Panel

Draggable, resizeable, pop-in/pop-out panel windows. Panels are opened imperatively via `OpenPanelButton` or `openPanel`.

![Panel](https://github.com/matsondawson/windows-doc-images/raw/HEAD/docimages/show-panel.png)

**Setup** — wrap your app with the context provider once at the root:

```tsx
import { PanelsContextProvider } from '@linzjs/windows';

export const App = () => (
  <PanelsContextProvider baseZIndex={500}>
    <div>...the rest of your app...</div>
  </PanelsContextProvider>
);
```

**Define a panel component:**

```tsx
import { Panel, PanelContent, PanelHeader } from '@linzjs/windows';

export const ShowPanelComponent = ({ data }: { data: number }) => (
  <Panel title={`Panel demo ${data}`} size={{ width: 640, height: 'auto' }}>
    <PanelHeader
      helpUrl={'#help'}
      icon={'ic_add_adopt'}
      onHelpClick={() => alert('Help!')}
    />
    <PanelContent>
      {/* panel body content */}
    </PanelContent>
  </Panel>
);

// Panel as modal (disables popout, uses fixed height)
export const ShowPanelModalComponent = ({ data }: { data: number }) => (
  <Panel title={`Panel demo ${data}`} size={{ width: 640, height: 400 }} modal={true}>
    <PanelHeader icon={'ic_add_adopt'} disablePopout={true} />
    <PanelContent>
      {/* panel body content */}
    </PanelContent>
  </Panel>
);
```

**Open panels** — use `OpenPanelButton` or the `openPanel` function:

```tsx
import { OpenPanelButton } from '@linzjs/windows';

export const TestShowPanel = () => (
  <div style={{ display: 'flex', gap: 8 }}>
    {/* uniqueId prevents duplicate panels */}
    <OpenPanelButton
      buttonText={'Panel 1'}
      testId={'panel1'}
      componentFn={() => <ShowPanelComponent data={1} />}
      uniqueId="panel1"
    />
    <OpenPanelButton
      buttonText={'Panel 2'}
      testId={'panel2'}
      componentFn={() => <ShowPanelComponent data={2} />}
      uniqueId="panel2"
    />
    <OpenPanelButton
      buttonText={'Panel as a Modal'}
      componentFn={() => <ShowPanelModalComponent data={3} />}
    />
    {/* Panel with dynamic bounds — recalculated on resize */}
    <OpenPanelButton
      buttonText={'Dynamic bounds panel'}
      componentFn={() => (
        <ShowPanelModalComponent
          data={3}
          resizeable={false}
          dynamicBounds={() => ({
            x: window.innerWidth / 3,
            y: window.innerHeight / 4,
            height: 'auto',
            width: window.innerWidth * 0.4,
          })}
        />
      )}
    />
  </div>
);
```

**Panel props:**

| Prop | Type | Description |
|------|------|-------------|
| `title` | `string` | Panel title bar text |
| `size` | `{ width: number, height: number \| 'auto' }` | Initial panel size |
| `modal` | `boolean` | Disable dragging and popout, center on screen |
| `resizeable` | `boolean` | Allow user resizing (default `true`) |
| `maxHeight` | `number` | Maximum height in px |
| `maxWidth` | `number` | Maximum width in px |
| `minHeight` | `number` | Minimum height in px |
| `minWidth` | `number` | Minimum width in px |
| `dynamicBounds` | `() => { x, y, width, height }` | Callback to recalculate position/size dynamically |

**Saving panel state** — pass `panelStateOptions` to `PanelsContextProvider` or `OpenPanelButton`:

```tsx
<OpenPanelButton
  buttonText={'Panel'}
  componentFn={() => <ShowPanelComponent data={1} />}
  panelStateOptions={{
    saveStateIn: 'localStorage',
    saveStateKey: 'userId',
  }}
/>
```

---

### Panel with Docking

Panels can dock into a designated `PanelDock` area. Add `dockTo` to `PanelHeader` and place a `PanelDock` in your layout.

```tsx
import { OpenPanelButton, Panel, PanelContent, PanelDock, PanelHeader } from '@linzjs/windows';
import { useState } from 'react';

// Panel that can dock to the left side
export const DockablePanel = ({ data }: { data: number }) => (
  <Panel title={`Panel demo ${data}`} size={{ width: 640, height: 400 }}>
    <PanelHeader dockTo={'leftSide'} />
    <PanelContent>
      {/* panel body content */}
    </PanelContent>
  </Panel>
);

export const TestShowPanel = () => {
  const [visible, setVisible] = useState(true);
  return (
    <>
      <div style={{ display: 'flex', gap: 8 }}>
        <OpenPanelButton buttonText={'panel'} componentFn={() => <DockablePanel data={1} />} />
        <button onClick={() => setVisible(!visible)}>Toggle dock visible</button>
      </div>
      {/* The panel docks into this area when the user clicks the dock button */}
      {visible && <PanelDock id={'leftSide'}>The Panel will dock in here</PanelDock>}
    </>
  );
};
```

---

### Tabbed Panel

Panels support tabs via the `@linzjs/lui` `LuiTabs` components. Wrap the `Panel` in `LuiTabs` and use `LuiTabsGroup`/`LuiTabsPanelSwitch` in the header.

![Tabbed panel example screenshot](https://github.com/matsondawson/windows-doc-images/raw/HEAD/docimages/panel-tabbed.png)

```tsx
import { LuiTabs, LuiTabsGroup, LuiTabsPanel, LuiTabsPanelSwitch } from '@linzjs/lui';
import { OpenPanelButton, Panel, PanelContent, PanelHeader } from '@linzjs/windows';

export const TabbedPanelComponent = ({ data }: { data: number }) => (
  <LuiTabs defaultPanel="africa">
    <Panel title={`Panel demo ${data}`} size={{ width: 640, height: 400 }}>
      <PanelHeader
        icon={'ic_send'}
        onHelpClick={() => alert('Help!!!')}
        extraLeft={
          <LuiTabsGroup ariaLabel="Animals">
            <LuiTabsPanelSwitch targetPanel="africa">Africa</LuiTabsPanelSwitch>
            <LuiTabsPanelSwitch targetPanel="asia">Asia</LuiTabsPanelSwitch>
          </LuiTabsGroup>
        }
      />
      <PanelContent>
        <LuiTabsPanel panel="africa">
          <h2>African Countries</h2>
        </LuiTabsPanel>
        <LuiTabsPanel panel="asia">
          <h2>Asian Countries</h2>
        </LuiTabsPanel>
      </PanelContent>
    </Panel>
  </LuiTabs>
);

export const TestShowTabbedPanel = () => (
  <>
    <OpenPanelButton buttonText={'TestPanel 1'} componentFn={() => <TabbedPanelComponent data={1} />} />
    <OpenPanelButton buttonText={'TestPanel 2'} componentFn={() => <TabbedPanelComponent data={2} />} />
  </>
);
```

---

### Panel with Global Modal

When a modal is shown from inside a panel that has been popped out into a separate window, use `showOnAllWindows: true` to ensure the modal appears in the correct window.

```tsx
import { useLuiModalPrefab, OpenPanelButton, Panel, PanelContent, PanelHeader } from '@linzjs/windows';

// showOnAllWindows ensures the modal appears even in popped-out windows
const PanelContents = () => {
  const { showPrefabModal } = useLuiModalPrefab();

  return (
    <div>
      <button
        onClick={() =>
          void showPrefabModal({
            showOnAllWindows: true,
            level: 'info',
            title: 'You are a fantastic person',
            children: 'Keep it up!',
          })
        }
      >
        Show modal
      </button>
    </div>
  );
};

export const TestShowPanelWithGlobalModal = () => (
  <>
    <OpenPanelButton buttonText={'TestPanel 1'} componentFn={() => (
      <Panel title={'Panel demo 1'} size={{ width: 640, height: 400 }}>
        <PanelHeader icon={'ic_send'} onHelpClick={() => alert('Help!!!')} />
        <PanelContent><PanelContents /></PanelContent>
      </Panel>
    )} />
  </>
);
```

---

### Ribbon

A toolbar ribbon with buttons, sliders, menus, and separators. Supports horizontal and vertical orientations.

![ribbon-slider.png](https://github.com/matsondawson/windows-doc-images/raw/HEAD/docimages/ribbon-slider.png)![img.png]()
![ribbon-panel.png](https://github.com/matsondawson/windows-doc-images/raw/HEAD/docimages/ribbon-panel.png)
![ribbon-menu.png](https://github.com/matsondawson/windows-doc-images/raw/HEAD/docimages/ribbon-menu.png)
**Setup** — requires both `LuiModalAsyncContextProvider` and `PanelsContextProvider`:

```tsx
import {
    RibbonButton,
    RibbonButtonLink,
    RibbonButtonOpenPanel,
    RibbonButtonSlider,
    RibbonContainer,
    RibbonSeparator,
    RibbonMenu,
    RibbonMenuOption,
    RibbonMenuSeparator,
    Panel,
    PanelContent,
    PanelHeader,
} from '@linzjs/windows';
import { useState } from 'react';

export const TestRibbonPanel = () => {
    const [selectedItem, setSelectedItem] = useState('ic_add_rectangle');
    const [loading, setLoading] = useState(false);
    const [processing, setProcessing] = useState(false);

    return (
        <>
            <button onClick={() => setLoading(!loading)}>Toggle loading</button>
            <button onClick={() => setProcessing(!processing)}>Toggle processing</button>

            {/* Horizontal ribbon */}
            <RibbonContainer style={{ position: 'absolute', left: 220, top: 120 }}>

                {/* Button that opens a panel — disabled state */}
                <RibbonButtonOpenPanel
                    disabled
                    title={'Marks'}
                    icon={'ic_marks'}
                    componentFn={() => <Panel title="Marks" size={{ width: 640, height: 400 }}><PanelHeader /><PanelContent /></Panel>}
                    loading={loading}
                />

                <RibbonSeparator />

                {/* Button that opens a panel — with processing indicator */}
                <RibbonButtonOpenPanel
                    title={'Vectors'}
                    icon={'ic_line_irregular'}
                    componentFn={() => <Panel title="Vectors" size={{ width: 640, height: 400 }}><PanelHeader /><PanelContent /></Panel>}
                    loading={loading}
                    processing={processing}
                    processingMessage={'Validating...'}
                />

                {/* Slider button — opens a sub-panel of options below */}
                <RibbonButtonSlider title={selectedItem} icon={selectedItem} alignment={'down'} loading={loading}>
                    <RibbonContainer orientation={'vertical'}>
                        <RibbonButton
                            title={'Rectangle'}
                            icon={'ic_add_rectangle'}
                            selected={selectedItem === 'ic_add_rectangle'}
                            onClick={() => setSelectedItem('ic_add_rectangle')}
                        />
                        <RibbonButton
                            title={'Other'}
                            icon={'ic_polygon_selection'}
                            selected={selectedItem === 'ic_polygon_selection'}
                            onClick={() => setSelectedItem('ic_polygon_selection')}
                        />
                    </RibbonContainer>
                </RibbonButtonSlider>

                {/* Slider button — dropdown menu with autoClose */}
                <RibbonButtonSlider title={'Other'} icon={'ic_more_vert'} alignment={'down'} autoClose={true} loading={loading}>
                    <RibbonMenu>
                        <RibbonMenuOption icon={'ic_define_nonprimary_diagram_circle'}>Circle</RibbonMenuOption>
                        <RibbonMenuOption icon={'ic_define_nonprimary_diagram_rectangle'} disabled={true}>Rectangle</RibbonMenuOption>
                        <RibbonMenuSeparator />
                        <RibbonMenuOption>Item 3</RibbonMenuOption>
                        <RibbonMenuSeparator />
                        <RibbonMenuOption icon={'ic_clear'}>Cancel</RibbonMenuOption>
                    </RibbonMenu>
                </RibbonButtonSlider>
            </RibbonContainer>

            {/* Vertical ribbon */}
            <RibbonContainer orientation={'vertical'} style={{ position: 'absolute', left: 220, top: 180 }}>
                <RibbonButtonOpenPanel
                    title={'Vectors'}
                    icon={'ic_timeline'}
                    componentFn={() => <Panel title="Vectors" size={{ width: 640, height: 400 }}><PanelHeader /><PanelContent /></Panel>}
                    loading={loading}
                />
                {/* Slider opens to the right */}
                <RibbonButtonSlider title={selectedItem} icon={selectedItem} alignment={'right'} loading={loading}>
                    <RibbonContainer>
                        <RibbonButton
                            title={'Rectangle'}
                            icon={'ic_add_rectangle'}
                            selected={selectedItem === 'ic_add_rectangle'}
                            onClick={() => setSelectedItem('ic_add_rectangle')}
                        />
                    </RibbonContainer>
                </RibbonButtonSlider>
                {/* External link button */}
                <RibbonButtonLink href={'https://example.com/'} icon={'ic_link'} loading={loading} />
            </RibbonContainer>
        </>
    );
};
```

**`RibbonButtonSlider` alignment options:** `'down'` | `'up'` | `'left'` | `'right'` | `'left-up'` | `'right-up'` | `'right-center'`