diff --git a/packages/ui/src/common/Button.tsx b/packages/ui/src/common/Button.tsx
new file mode 100644
index 00000000..6eddd363
--- /dev/null
+++ b/packages/ui/src/common/Button.tsx
@@ -0,0 +1,143 @@
+/*
+ *
+ * Copyright (c) 2025 The Ontario Institute for Cancer Research. All rights reserved
+ *
+ * This program and the accompanying materials are made available under the terms of
+ * the GNU Affero General Public License v3.0. You should have received a copy of the
+ * GNU Affero General Public License along with this program.
+ * If not, see .
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
+ * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
+ * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT
+ * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+ * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
+ * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS;
+ * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
+ * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+ * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ *
+ */
+
+/** @jsxImportSource @emotion/react */
+// This is a slightly refactored version of the stage button
+import React, { ReactNode } from 'react';
+import { css } from '@emotion/react';
+
+import { Theme } from '../theme';
+import { useThemeContext } from '../theme/ThemeContext';
+
+type ButtonProps = {
+ children?: ReactNode;
+ disabled?: boolean;
+ onClick?: (
+ e: React.SyntheticEvent,
+ ) => any | ((e: React.SyntheticEvent) => Promise);
+ isAsync?: boolean;
+ className?: string;
+ isLoading?: boolean;
+ leftIcon?: ReactNode;
+ width?: string;
+};
+
+const getButtonContainerStyles = (theme: any, width?: string) => css`
+ display: flex;
+ flex-wrap: nowrap;
+ align-items: center;
+ justify-content: center;
+ gap: 11px;
+ width: ${width || 'auto'};
+ min-width: fit-content;
+ padding: 8px 16px;
+ background-color: #f7f7f7;
+ color: ${theme.colors.black};
+ border: 1px solid #beb2b294;
+ border-radius: 9px;
+ height: 42px;
+ box-sizing: border-box;
+ cursor: pointer;
+ position: relative;
+ transition: all 0.2s ease;
+
+ &:hover {
+ background-color: ${theme.colors.grey_1};
+ }
+
+ &:disabled {
+ cursor: not-allowed;
+ opacity: 0.7;
+ }
+`;
+
+const getContentStyles = (theme: Theme, shouldShowLoading: boolean) => css`
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ ${theme.typography.button};
+ color: inherit;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ visibility: ${shouldShowLoading ? 'hidden' : 'visible'};
+`;
+
+const getSpinnerStyles = (shouldShowLoading: boolean) => css`
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ visibility: ${shouldShowLoading ? 'visible' : 'hidden'};
+`;
+
+const getIconStyles = () => css`
+ display: flex;
+ align-items: center;
+`;
+
+/**
+ * This is the generic button component used throughout stage, however it has
+ * the styling that is specific to the current theme that is being used.
+ */
+const Button = React.forwardRef(
+ (
+ {
+ children,
+ onClick = () => {},
+ disabled = false,
+ isAsync = false,
+ className,
+ isLoading: controlledLoading,
+ leftIcon,
+ width,
+ }: ButtonProps,
+ ref,
+ ) => {
+ const [internalLoading, setInternalLoading] = React.useState(false);
+
+ const shouldShowLoading = !!controlledLoading || (internalLoading && isAsync);
+ const handleClick = async (event: React.SyntheticEvent) => {
+ setInternalLoading(true);
+ await onClick(event);
+ setInternalLoading(false);
+ };
+ const theme = useThemeContext();
+ const { Spinner } = theme.icons;
+ return (
+
+ );
+ },
+);
+
+export default Button;
diff --git a/packages/ui/src/theme/icons/Collapse.tsx b/packages/ui/src/theme/icons/Collapse.tsx
new file mode 100644
index 00000000..f6d6eca6
--- /dev/null
+++ b/packages/ui/src/theme/icons/Collapse.tsx
@@ -0,0 +1,49 @@
+/*
+ *
+ * Copyright (c) 2025 The Ontario Institute for Cancer Research. All rights reserved
+ *
+ * This program and the accompanying materials are made available under the terms of
+ * the GNU Affero General Public License v3.0. You should have received a copy of the
+ * GNU Affero General Public License along with this program.
+ * If not, see .
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
+ * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
+ * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT
+ * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+ * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
+ * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS;
+ * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
+ * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+ * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ *
+ */
+
+/** @jsxImportSource @emotion/react */
+
+import IconProps from './IconProps';
+
+const Collapse = ({ fill, width, height, style }: IconProps) => {
+ return (
+
+ );
+};
+
+export default Collapse;
diff --git a/packages/ui/src/theme/icons/Eye.tsx b/packages/ui/src/theme/icons/Eye.tsx
new file mode 100644
index 00000000..20797742
--- /dev/null
+++ b/packages/ui/src/theme/icons/Eye.tsx
@@ -0,0 +1,46 @@
+/*
+ *
+ * Copyright (c) 2025 The Ontario Institute for Cancer Research. All rights reserved
+ *
+ * This program and the accompanying materials are made available under the terms of
+ * the GNU Affero General Public License v3.0. You should have received a copy of the
+ * GNU Affero General Public License along with this program.
+ * If not, see .
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
+ * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
+ * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT
+ * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+ * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
+ * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS;
+ * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
+ * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+ * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ *
+ */
+
+/** @jsxImportSource @emotion/react */
+
+import IconProps from './IconProps';
+
+const Eye = ({ fill, width, height, style }: IconProps) => {
+ return (
+
+ );
+};
+
+export default Eye;
diff --git a/packages/ui/src/theme/icons/Spinner.tsx b/packages/ui/src/theme/icons/Spinner.tsx
new file mode 100644
index 00000000..6c0de99a
--- /dev/null
+++ b/packages/ui/src/theme/icons/Spinner.tsx
@@ -0,0 +1,54 @@
+/*
+ *
+ * Copyright (c) 2025 The Ontario Institute for Cancer Research. All rights reserved
+ *
+ * This program and the accompanying materials are made available under the terms of
+ * the GNU Affero General Public License v3.0. You should have received a copy of the
+ * GNU Affero General Public License along with this program.
+ * If not, see .
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
+ * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
+ * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT
+ * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+ * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
+ * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS;
+ * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
+ * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+ * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ *
+ */
+
+/** @jsxImportSource @emotion/react */
+
+import { css, keyframes } from '@emotion/react';
+import IconProps from './IconProps';
+
+// Animation
+const spin = keyframes`
+ 100% {
+ transform: rotate(360deg);
+ }
+`;
+
+const Spinner = ({ fill, height, width }: IconProps) => {
+ return (
+
+ );
+};
+
+export default Spinner;
diff --git a/packages/ui/src/theme/styles/icons.ts b/packages/ui/src/theme/styles/icons.ts
index d09423d3..2b107574 100644
--- a/packages/ui/src/theme/styles/icons.ts
+++ b/packages/ui/src/theme/styles/icons.ts
@@ -1,4 +1,10 @@
import ChevronDown from '../icons/ChevronDown';
+import Spinner from '../icons/Spinner';
+import Collapse from '../icons/Collapse';
+import Eye from '../icons/Eye';
export default {
ChevronDown,
+ Spinner,
+ Collapse,
+ Eye,
};
diff --git a/packages/ui/src/theme/styles/typography.ts b/packages/ui/src/theme/styles/typography.ts
index e02454de..e4df0422 100644
--- a/packages/ui/src/theme/styles/typography.ts
+++ b/packages/ui/src/theme/styles/typography.ts
@@ -35,11 +35,11 @@ const regular = css`
const button = css`
${baseFont}
- font-size: 16px;
- font-weight: bold;
+ font-size: 20px;
+ font-weight: 700;
font-style: normal;
font-stretch: normal;
- line-height: 18px;
+ line-height: 100%;
letter-spacing: normal;
`;
diff --git a/packages/ui/src/viewer-table/InteractionPanel/CollapseAllButton.tsx b/packages/ui/src/viewer-table/InteractionPanel/CollapseAllButton.tsx
new file mode 100644
index 00000000..604b9fba
--- /dev/null
+++ b/packages/ui/src/viewer-table/InteractionPanel/CollapseAllButton.tsx
@@ -0,0 +1,20 @@
+import { useEffect } from 'react';
+import Button from '../../common/Button';
+import { useThemeContext } from '../../theme/ThemeContext';
+
+export interface CollapseAllButtonProps {
+ onClick: () => void;
+}
+
+const CollapseAllButton = ({ onClick }: CollapseAllButtonProps) => {
+ const theme = useThemeContext();
+ const { Collapse } = theme.icons;
+
+ return (
+ } onClick={onClick}>
+ Collapse All
+
+ );
+};
+
+export default CollapseAllButton;
diff --git a/packages/ui/src/viewer-table/InteractionPanel/ExpandAllButton.tsx b/packages/ui/src/viewer-table/InteractionPanel/ExpandAllButton.tsx
new file mode 100644
index 00000000..42a81ec3
--- /dev/null
+++ b/packages/ui/src/viewer-table/InteractionPanel/ExpandAllButton.tsx
@@ -0,0 +1,20 @@
+import { useEffect } from 'react';
+import Button from '../../common/Button';
+import { useThemeContext } from '../../theme/ThemeContext';
+
+export interface ExpandAllButtonProps {
+ onClick: () => void;
+}
+
+const ExpandAllButton = ({ onClick }: ExpandAllButtonProps) => {
+ const theme = useThemeContext();
+ const { Eye } = theme.icons;
+
+ return (
+ } onClick={onClick}>
+ Expand All
+
+ );
+};
+
+export default ExpandAllButton;
diff --git a/packages/ui/stories/common/Button.stories.tsx b/packages/ui/stories/common/Button.stories.tsx
new file mode 100644
index 00000000..65578fdb
--- /dev/null
+++ b/packages/ui/stories/common/Button.stories.tsx
@@ -0,0 +1,28 @@
+/** @jsxImportSource @emotion/react */
+
+import type { Meta, StoryObj } from '@storybook/react';
+import themeDecorator from '../themeDecorator';
+import Button from '../../src/common/Button';
+
+const meta = {
+ component: Button,
+ title: 'Common/Button',
+ tags: ['autodocs'],
+ decorators: [themeDecorator()],
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: { children: 'Click Me', onClick: () => alert('I have been clicked'), className: 'my-button', leftIcon: '👍' },
+};
+export const Disabled: Story = {
+ args: { children: 'Disabled', disabled: true },
+};
+export const Loading: Story = {
+ args: { isLoading: true, children: 'Loading...' },
+};
+export const Empty: Story = {
+ args: {},
+};
diff --git a/packages/ui/stories/viewer-table/interaction-panel/CollapseAllButton.stories.tsx b/packages/ui/stories/viewer-table/interaction-panel/CollapseAllButton.stories.tsx
new file mode 100644
index 00000000..5e744b48
--- /dev/null
+++ b/packages/ui/stories/viewer-table/interaction-panel/CollapseAllButton.stories.tsx
@@ -0,0 +1,19 @@
+/** @jsxImportSource @emotion/react */
+
+import type { Meta, StoryObj } from '@storybook/react';
+import themeDecorator from '../../themeDecorator';
+import CollapseAllButton from '../../../src/viewer-table/InteractionPanel/CollapseAllButton';
+
+const meta = {
+ component: CollapseAllButton,
+ title: 'Viewer - Table/Interaction - Panel/CollapseAllButton',
+ tags: ['autodocs'],
+ decorators: [themeDecorator()],
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: { setIsCollapsed: (isCollapsed: boolean) => alert('all collapsable components are collapsed') },
+};
diff --git a/packages/ui/stories/viewer-table/interaction-panel/ExpandAllButton.stories.tsx b/packages/ui/stories/viewer-table/interaction-panel/ExpandAllButton.stories.tsx
new file mode 100644
index 00000000..fff42a43
--- /dev/null
+++ b/packages/ui/stories/viewer-table/interaction-panel/ExpandAllButton.stories.tsx
@@ -0,0 +1,18 @@
+/** @jsxImportSource @emotion/react */
+
+import type { Meta, StoryObj } from '@storybook/react';
+import themeDecorator from '../../themeDecorator';
+import ExpandAllButton from '../../../src/viewer-table/InteractionPanel/ExpandAllButton';
+const meta = {
+ component: ExpandAllButton,
+ title: 'Viewer - Table/Interaction - Panel/ExpandAllButton',
+ tags: ['autodocs'],
+ decorators: [themeDecorator()],
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: { setIsCollapsed: (isCollapsed: boolean) => alert('all collapsable components are expanded') },
+};