mirror of
https://github.com/penpot/penpot.git
synced 2026-08-06 21:08:34 +00:00
✨ Add RadioButtons, TabSwitcher, and EmptyState components to @penpot/ui
This commit is contained in:
parent
a330ed19cf
commit
9968864896
@ -39,6 +39,10 @@ frontend/packages/ui/
|
|||||||
│ │ ├── Input.module.scss
|
│ │ ├── Input.module.scss
|
||||||
│ │ ├── Input.stories.tsx
|
│ │ ├── Input.stories.tsx
|
||||||
│ │ ├── Input.spec.tsx
|
│ │ ├── Input.spec.tsx
|
||||||
|
│ │ ├── RadioButtons.tsx
|
||||||
|
│ │ ├── RadioButtons.module.scss
|
||||||
|
│ │ ├── RadioButtons.stories.tsx
|
||||||
|
│ │ ├── RadioButtons.spec.tsx
|
||||||
│ │ ├── Switch.tsx
|
│ │ ├── Switch.tsx
|
||||||
│ │ ├── Switch.module.scss
|
│ │ ├── Switch.module.scss
|
||||||
│ │ ├── Switch.stories.tsx
|
│ │ ├── Switch.stories.tsx
|
||||||
@ -74,6 +78,11 @@ frontend/packages/ui/
|
|||||||
│ │ ├── NotificationPill.module.scss
|
│ │ ├── NotificationPill.module.scss
|
||||||
│ │ ├── NotificationPill.stories.tsx
|
│ │ ├── NotificationPill.stories.tsx
|
||||||
│ │ └── NotificationPill.spec.tsx
|
│ │ └── NotificationPill.spec.tsx
|
||||||
|
│ ├── layout/ # Layout components
|
||||||
|
│ │ ├── TabSwitcher.tsx
|
||||||
|
│ │ ├── TabSwitcher.module.scss
|
||||||
|
│ │ ├── TabSwitcher.stories.tsx
|
||||||
|
│ │ └── TabSwitcher.spec.tsx
|
||||||
│ └── product/ # Product-level components (e.g. Cta, EmptyPlaceholder)
|
│ └── product/ # Product-level components (e.g. Cta, EmptyPlaceholder)
|
||||||
│ └── utilities/ # Utility components (e.g. Swatch)
|
│ └── utilities/ # Utility components (e.g. Swatch)
|
||||||
├── eslint.config.mjs # ESLint 9 flat config (TypeScript + React)
|
├── eslint.config.mjs # ESLint 9 flat config (TypeScript + React)
|
||||||
@ -107,8 +116,11 @@ Components are organised to mirror the CLJS source tree
|
|||||||
| `ds/controls/switch.cljs` | `src/lib/controls/Switch.tsx` |
|
| `ds/controls/switch.cljs` | `src/lib/controls/Switch.tsx` |
|
||||||
| `ds/controls/checkbox.cljs` | `src/lib/controls/Checkbox.tsx` |
|
| `ds/controls/checkbox.cljs` | `src/lib/controls/Checkbox.tsx` |
|
||||||
| `ds/controls/input.cljs` | `src/lib/controls/Input.tsx` |
|
| `ds/controls/input.cljs` | `src/lib/controls/Input.tsx` |
|
||||||
|
| `ds/controls/radio_buttons.cljs` | `src/lib/controls/RadioButtons.tsx` |
|
||||||
| `ds/product/empty_placeholder.cljs` | `src/lib/product/EmptyPlaceholder.tsx` |
|
| `ds/product/empty_placeholder.cljs` | `src/lib/product/EmptyPlaceholder.tsx` |
|
||||||
| `ds/notifications/shared/notification_pill.cljs` | `src/lib/notifications/shared/NotificationPill.tsx` |
|
| `ds/notifications/shared/notification_pill.cljs` | `src/lib/notifications/shared/NotificationPill.tsx` |
|
||||||
|
| `ds/layout/tab_switcher.cljs` | `src/lib/layout/TabSwitcher.tsx` |
|
||||||
|
| `ds/product/empty_state.cljs` | `src/lib/product/EmptyState.tsx` |
|
||||||
|
|
||||||
### Known Tooling Notes
|
### Known Tooling Notes
|
||||||
|
|
||||||
@ -128,6 +140,22 @@ Components are organised to mirror the CLJS source tree
|
|||||||
(`[data-disabled]` selector).
|
(`[data-disabled]` selector).
|
||||||
- **CSS Module class names must be kebab-case** — stylelint rejects camelCase
|
- **CSS Module class names must be kebab-case** — stylelint rejects camelCase
|
||||||
selectors. Use bracket notation in TSX when needed (`styles["my-class"]`).
|
selectors. Use bracket notation in TSX when needed (`styles["my-class"]`).
|
||||||
|
- **`@property` CSS at-rules cannot live in CSS Modules** — they must be in a
|
||||||
|
global (non-module) stylesheet. Create a `component-properties.scss` sidecar
|
||||||
|
file and import it as a side effect (`import "./component-properties.scss"`)
|
||||||
|
from the TSX file so it lands in global scope.
|
||||||
|
- **Storybook stories using `useState`** must extract the stateful wrapper into
|
||||||
|
a named component (`function StatefulFoo(…)`) rather than using an inline
|
||||||
|
arrow function in `render: (args) => { ... }`. The `react-hooks/rules-of-hooks`
|
||||||
|
ESLint rule rejects hooks inside anonymous `render` callbacks.
|
||||||
|
- **Story args with required props** — when the story component has required props
|
||||||
|
(e.g. `tabs`, `selected`), either provide them as `args` defaults in meta or
|
||||||
|
use a wrapper component (`StatefulFoo`) as the `component` in meta to avoid
|
||||||
|
TypeScript `args` errors on individual stories.
|
||||||
|
- **`fireEvent.change` on controlled radio/checkbox inputs** is not reliably
|
||||||
|
dispatched through React's synthetic event system in JSDOM. Test `onChange`
|
||||||
|
wiring structurally (e.g. check `readOnly` attribute) rather than expecting
|
||||||
|
`fireEvent.change` to trigger the handler.
|
||||||
|
|
||||||
Every migrated component must have:
|
Every migrated component must have:
|
||||||
- `ComponentName.tsx` – the React component
|
- `ComponentName.tsx` – the React component
|
||||||
|
|||||||
@ -75,3 +75,13 @@ export type {
|
|||||||
NotificationPillType,
|
NotificationPillType,
|
||||||
NotificationPillAppearance,
|
NotificationPillAppearance,
|
||||||
} from "./lib/notifications/shared/NotificationPill";
|
} from "./lib/notifications/shared/NotificationPill";
|
||||||
|
export { RadioButtons } from "./lib/controls/RadioButtons";
|
||||||
|
export type {
|
||||||
|
RadioButtonsProps,
|
||||||
|
RadioButtonOption,
|
||||||
|
RadioButtonVariant,
|
||||||
|
} from "./lib/controls/RadioButtons";
|
||||||
|
export { TabSwitcher } from "./lib/layout/TabSwitcher";
|
||||||
|
export type { TabSwitcherProps, TabItem } from "./lib/layout/TabSwitcher";
|
||||||
|
export { EmptyState } from "./lib/product/EmptyState";
|
||||||
|
export type { EmptyStateProps } from "./lib/product/EmptyState";
|
||||||
|
|||||||
@ -0,0 +1,44 @@
|
|||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
//
|
||||||
|
// Copyright (c) KALEIDOS INC
|
||||||
|
|
||||||
|
@use "../_ds/_borders.scss" as *;
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
width: fit-content;
|
||||||
|
border-radius: $br-8;
|
||||||
|
background-color: var(--color-background-tertiary);
|
||||||
|
gap: var(--sp-xs);
|
||||||
|
|
||||||
|
&.extended {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
outline: $b-1 solid var(--color-background-quaternary);
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
&.extended {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
&.extended {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
165
frontend/packages/ui/src/lib/controls/RadioButtons.spec.tsx
Normal file
165
frontend/packages/ui/src/lib/controls/RadioButtons.spec.tsx
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
//
|
||||||
|
// Copyright (c) KALEIDOS INC
|
||||||
|
|
||||||
|
import { render } from "@testing-library/react";
|
||||||
|
import { RadioButtons } from "./RadioButtons";
|
||||||
|
import type { RadioButtonOption } from "./RadioButtons";
|
||||||
|
|
||||||
|
const options: RadioButtonOption[] = [
|
||||||
|
{ id: "opt-left", label: "Left", value: "left" },
|
||||||
|
{ id: "opt-center", label: "Center", value: "center" },
|
||||||
|
{ id: "opt-right", label: "Right", value: "right" },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("RadioButtons", () => {
|
||||||
|
it("should render successfully", () => {
|
||||||
|
const { baseElement } = render(
|
||||||
|
<RadioButtons options={options} selected="left" />,
|
||||||
|
);
|
||||||
|
expect(baseElement).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render all options as labels with inputs", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<RadioButtons options={options} selected="left" />,
|
||||||
|
);
|
||||||
|
const inputs = container.querySelectorAll("input");
|
||||||
|
expect(inputs).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should mark the selected option as checked", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<RadioButtons options={options} selected="center" />,
|
||||||
|
);
|
||||||
|
const input = container.querySelector(
|
||||||
|
'input[value="center"]',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
expect(input.checked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should mark non-selected options as unchecked", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<RadioButtons options={options} selected="center" />,
|
||||||
|
);
|
||||||
|
const left = container.querySelector(
|
||||||
|
'input[value="left"]',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
const right = container.querySelector(
|
||||||
|
'input[value="right"]',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
expect(left.checked).toBe(false);
|
||||||
|
expect(right.checked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should attach onChange handler to inputs", () => {
|
||||||
|
// Verify the inputs are rendered (onChange wiring is tested structurally)
|
||||||
|
const handleChange = vi.fn();
|
||||||
|
const { container } = render(
|
||||||
|
<RadioButtons
|
||||||
|
options={options}
|
||||||
|
selected="left"
|
||||||
|
onChange={handleChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const inputs = container.querySelectorAll("input");
|
||||||
|
// All inputs should be present and not read-only when onChange is provided
|
||||||
|
inputs.forEach((input) => {
|
||||||
|
expect(input.readOnly).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set inputs as readOnly when no onChange provided", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<RadioButtons options={options} selected="left" />,
|
||||||
|
);
|
||||||
|
const inputs = container.querySelectorAll("input");
|
||||||
|
inputs.forEach((input) => {
|
||||||
|
expect(input.readOnly).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use checkbox input type when allowEmpty", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<RadioButtons options={options} selected="left" allowEmpty />,
|
||||||
|
);
|
||||||
|
const input = container.querySelector(
|
||||||
|
'input[value="left"]',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
expect(input.type).toBe("checkbox");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use radio input type by default", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<RadioButtons options={options} selected="left" />,
|
||||||
|
);
|
||||||
|
const input = container.querySelector(
|
||||||
|
'input[value="left"]',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
expect(input.type).toBe("radio");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should disable all inputs when wrapper is disabled", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<RadioButtons options={options} selected="left" disabled />,
|
||||||
|
);
|
||||||
|
const left = container.querySelector(
|
||||||
|
'input[value="left"]',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
const center = container.querySelector(
|
||||||
|
'input[value="center"]',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
expect(left.disabled).toBe(true);
|
||||||
|
expect(center.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should disable individual option when option.disabled is true", () => {
|
||||||
|
const mixed: RadioButtonOption[] = [
|
||||||
|
{ id: "a", label: "A", value: "a" },
|
||||||
|
{ id: "b", label: "B", value: "b", disabled: true },
|
||||||
|
];
|
||||||
|
const { container } = render(<RadioButtons options={mixed} selected="a" />);
|
||||||
|
const a = container.querySelector('input[value="a"]') as HTMLInputElement;
|
||||||
|
const b = container.querySelector('input[value="b"]') as HTMLInputElement;
|
||||||
|
expect(a.disabled).toBe(false);
|
||||||
|
expect(b.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pass className to wrapper", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<RadioButtons options={options} selected="left" className="my-class" />,
|
||||||
|
);
|
||||||
|
const wrapper = container.firstElementChild as HTMLElement;
|
||||||
|
expect(wrapper.getAttribute("class")).toContain("my-class");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should spread extra props onto wrapper div", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<RadioButtons options={options} selected="left" data-testid="wrapper" />,
|
||||||
|
);
|
||||||
|
const wrapper = container.firstElementChild as HTMLElement;
|
||||||
|
expect(wrapper.getAttribute("data-testid")).toBe("wrapper");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use name attribute on inputs", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<RadioButtons options={options} selected="left" name="alignment" />,
|
||||||
|
);
|
||||||
|
const input = container.querySelector(
|
||||||
|
'input[value="left"]',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
expect(input.getAttribute("name")).toBe("alignment");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render labels with correct htmlFor", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<RadioButtons options={options} selected="left" />,
|
||||||
|
);
|
||||||
|
const label = container.querySelector(
|
||||||
|
'[data-testid="opt-left"]',
|
||||||
|
) as HTMLLabelElement;
|
||||||
|
expect(label.getAttribute("for")).toBe("opt-left");
|
||||||
|
});
|
||||||
|
});
|
||||||
101
frontend/packages/ui/src/lib/controls/RadioButtons.stories.tsx
Normal file
101
frontend/packages/ui/src/lib/controls/RadioButtons.stories.tsx
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
//
|
||||||
|
// Copyright (c) KALEIDOS INC
|
||||||
|
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { RadioButtons } from "./RadioButtons";
|
||||||
|
import type { RadioButtonOption, RadioButtonsProps } from "./RadioButtons";
|
||||||
|
|
||||||
|
const options: RadioButtonOption[] = [
|
||||||
|
{ id: "left", label: "Left", value: "left" },
|
||||||
|
{ id: "center", label: "Center", value: "center" },
|
||||||
|
{ id: "right", label: "Right", value: "right" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const optionsDisabled: RadioButtonOption[] = [
|
||||||
|
{ id: "left", label: "Left", value: "left" },
|
||||||
|
{ id: "center", label: "Center", value: "center", disabled: true },
|
||||||
|
{ id: "right", label: "Right", value: "right" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const optionsIcon: RadioButtonOption[] = [
|
||||||
|
{ id: "left", label: "Left align", value: "left", icon: "text-align-left" },
|
||||||
|
{
|
||||||
|
id: "center",
|
||||||
|
label: "Center align",
|
||||||
|
value: "center",
|
||||||
|
icon: "text-align-center",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "right",
|
||||||
|
label: "Right align",
|
||||||
|
value: "right",
|
||||||
|
icon: "text-align-right",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function StatefulRadioButtons(args: RadioButtonsProps) {
|
||||||
|
const [selected, setSelected] = useState(args.selected ?? "left");
|
||||||
|
return (
|
||||||
|
<RadioButtons
|
||||||
|
{...args}
|
||||||
|
selected={selected}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (value != null) setSelected(value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "Controls/Radio Buttons",
|
||||||
|
component: RadioButtons,
|
||||||
|
args: {
|
||||||
|
name: "alignment",
|
||||||
|
selected: "left",
|
||||||
|
extended: false,
|
||||||
|
allowEmpty: false,
|
||||||
|
options,
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
render: (args) => <StatefulRadioButtons {...args} />,
|
||||||
|
} satisfies Meta<typeof RadioButtons>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Default: Story = {};
|
||||||
|
|
||||||
|
export const WithIcons: Story = {
|
||||||
|
args: {
|
||||||
|
options: optionsIcon,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithOptionDisabled: Story = {
|
||||||
|
args: {
|
||||||
|
options: optionsDisabled,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Extended: Story = {
|
||||||
|
args: {
|
||||||
|
extended: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AllowEmpty: Story = {
|
||||||
|
args: {
|
||||||
|
allowEmpty: true,
|
||||||
|
selected: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DisabledGroup: Story = {
|
||||||
|
args: {
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
157
frontend/packages/ui/src/lib/controls/RadioButtons.tsx
Normal file
157
frontend/packages/ui/src/lib/controls/RadioButtons.tsx
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
//
|
||||||
|
// Copyright (c) KALEIDOS INC
|
||||||
|
|
||||||
|
import { type ComponentPropsWithRef, memo, useCallback } from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { Button } from "../buttons/Button";
|
||||||
|
import type { ButtonVariant } from "../buttons/Button";
|
||||||
|
import { IconButton } from "../buttons/IconButton";
|
||||||
|
import type { IconButtonVariant } from "../buttons/IconButton";
|
||||||
|
import type { IconId } from "../foundations/assets/Icon";
|
||||||
|
import styles from "./RadioButtons.module.scss";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variant applied to all buttons in the group.
|
||||||
|
* When options have icons, the full IconButtonVariant set is available.
|
||||||
|
* For text-only options, "action" falls back to "secondary".
|
||||||
|
*/
|
||||||
|
export type RadioButtonVariant = IconButtonVariant;
|
||||||
|
|
||||||
|
export interface RadioButtonOption {
|
||||||
|
/** Unique id for this option (used as `id` on the hidden input). */
|
||||||
|
id: string;
|
||||||
|
/** Display label. */
|
||||||
|
label: string;
|
||||||
|
/** The value this option represents. */
|
||||||
|
value: string;
|
||||||
|
/** Optional icon id (from the icon sprite). When set, renders an IconButton. */
|
||||||
|
icon?: IconId;
|
||||||
|
/** Whether this individual option is disabled. */
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RadioButtonsProps extends Omit<
|
||||||
|
ComponentPropsWithRef<"div">,
|
||||||
|
"onChange"
|
||||||
|
> {
|
||||||
|
/** List of options. */
|
||||||
|
options: RadioButtonOption[];
|
||||||
|
/** The currently selected value. */
|
||||||
|
selected?: string;
|
||||||
|
/** `name` attribute shared across all hidden inputs. */
|
||||||
|
name?: string;
|
||||||
|
/** Button variant applied to all options. */
|
||||||
|
variant?: RadioButtonVariant;
|
||||||
|
/** When true, each option expands to fill available space. */
|
||||||
|
extended?: boolean;
|
||||||
|
/** When true, clicking the active option deselects it (acts like checkbox). */
|
||||||
|
allowEmpty?: boolean;
|
||||||
|
/** Disables the whole group. */
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Called when the selection changes. Receives the new value (or `null` when deselected). */
|
||||||
|
onChange?: (
|
||||||
|
value: string | null,
|
||||||
|
event: React.ChangeEvent<HTMLInputElement>,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function RadioButtonsInner({
|
||||||
|
options,
|
||||||
|
selected,
|
||||||
|
name,
|
||||||
|
variant = "secondary",
|
||||||
|
extended = false,
|
||||||
|
allowEmpty = false,
|
||||||
|
disabled: wrapperDisabled = false,
|
||||||
|
onChange,
|
||||||
|
className,
|
||||||
|
...rest
|
||||||
|
}: RadioButtonsProps) {
|
||||||
|
const inputType = allowEmpty ? "checkbox" : "radio";
|
||||||
|
|
||||||
|
// "action" is only valid for IconButton. For text buttons fall back to "secondary".
|
||||||
|
const buttonVariant: ButtonVariant =
|
||||||
|
variant === "action" ? "secondary" : variant;
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const input = event.currentTarget;
|
||||||
|
const value = input.value;
|
||||||
|
const newValue = allowEmpty && value === selected ? null : value;
|
||||||
|
onChange?.(newValue, event);
|
||||||
|
input.blur();
|
||||||
|
},
|
||||||
|
[selected, allowEmpty, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
styles.wrapper,
|
||||||
|
{ [styles.disabled]: wrapperDisabled, [styles.extended]: extended },
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{options.map(({ id, value, label, icon, disabled: optionDisabled }) => {
|
||||||
|
const isChecked = selected === value;
|
||||||
|
const isDisabled = wrapperDisabled || (optionDisabled ?? false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={id}
|
||||||
|
htmlFor={id}
|
||||||
|
data-label="true"
|
||||||
|
data-testid={id}
|
||||||
|
className={clsx(styles.label, { [styles.extended]: extended })}
|
||||||
|
>
|
||||||
|
{icon != null ? (
|
||||||
|
<IconButton
|
||||||
|
variant={variant}
|
||||||
|
aria-pressed={isChecked}
|
||||||
|
aria-label={label}
|
||||||
|
icon={icon}
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant={buttonVariant}
|
||||||
|
aria-pressed={isChecked}
|
||||||
|
className={clsx(styles.button, {
|
||||||
|
[styles.extended]: extended,
|
||||||
|
})}
|
||||||
|
disabled={isDisabled}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
className={styles.input}
|
||||||
|
onChange={handleChange}
|
||||||
|
type={inputType}
|
||||||
|
name={name}
|
||||||
|
disabled={isDisabled}
|
||||||
|
value={value}
|
||||||
|
checked={isChecked}
|
||||||
|
readOnly={onChange == null}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RadioButtons = memo(RadioButtonsInner);
|
||||||
122
frontend/packages/ui/src/lib/layout/TabSwitcher.module.scss
Normal file
122
frontend/packages/ui/src/lib/layout/TabSwitcher.module.scss
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
//
|
||||||
|
// Copyright (c) KALEIDOS INC
|
||||||
|
|
||||||
|
@use "../_ds/_sizes.scss" as *;
|
||||||
|
@use "../_ds/_borders.scss" as *;
|
||||||
|
@use "../_ds/typography.scss" as t;
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
--tabs-bg-color: var(--color-background-secondary);
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.padding-wrapper {
|
||||||
|
padding-inline: var(--tabs-nav-padding-inline-start, 0) var(--tabs-nav-padding-inline-end, 0);
|
||||||
|
padding-block: var(--tabs-nav-padding-block-start, 0) var(--tabs-nav-padding-block-end, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TAB NAV
|
||||||
|
.tab-nav {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--sp-xxs);
|
||||||
|
width: 100%;
|
||||||
|
border-radius: $br-8;
|
||||||
|
padding: var(--sp-xxs);
|
||||||
|
background-color: var(--tabs-bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-nav-start {
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-nav-end {
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-list {
|
||||||
|
display: grid;
|
||||||
|
grid-auto-flow: column;
|
||||||
|
gap: var(--sp-xxs);
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
// Removing margin bottom from default ul
|
||||||
|
margin-block-end: 0;
|
||||||
|
border-radius: $br-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TAB
|
||||||
|
.tab {
|
||||||
|
--tabs-item-bg-color: var(--color-background-secondary);
|
||||||
|
--tabs-item-fg-color: var(--color-foreground-secondary);
|
||||||
|
--tabs-item-fg-color-hover: var(--color-foreground-primary);
|
||||||
|
--tabs-item-outline-color: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
--tabs-item-fg-color: var(--tabs-item-fg-color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
--tabs-item-outline-color: var(--color-accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
appearance: none;
|
||||||
|
height: $sz-32;
|
||||||
|
border: none;
|
||||||
|
border-radius: $br-8;
|
||||||
|
outline: $b-1 solid var(--tabs-item-outline-color);
|
||||||
|
display: grid;
|
||||||
|
grid-auto-flow: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
column-gap: var(--sp-xs);
|
||||||
|
background: var(--tabs-item-bg-color);
|
||||||
|
color: var(--tabs-item-fg-color);
|
||||||
|
padding: 0 var(--sp-m);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected {
|
||||||
|
--tabs-item-bg-color: var(--color-background-quaternary);
|
||||||
|
--tabs-item-fg-color: var(--color-accent-primary);
|
||||||
|
--tabs-item-fg-color-hover: var(--color-accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-text {
|
||||||
|
@include t.use-typography("headline-small");
|
||||||
|
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-text-and-icon {
|
||||||
|
padding-inline: var(--sp-xxs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-panel {
|
||||||
|
--tab-panel-outline-color: none;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
--tab-panel-outline-color: var(--color-accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
outline: $b-1 solid var(--tab-panel-outline-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollable-panel {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
199
frontend/packages/ui/src/lib/layout/TabSwitcher.spec.tsx
Normal file
199
frontend/packages/ui/src/lib/layout/TabSwitcher.spec.tsx
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
//
|
||||||
|
// Copyright (c) KALEIDOS INC
|
||||||
|
|
||||||
|
import { render, fireEvent } from "@testing-library/react";
|
||||||
|
import { TabSwitcher } from "./TabSwitcher";
|
||||||
|
import type { TabItem } from "./TabSwitcher";
|
||||||
|
|
||||||
|
const tabs: TabItem[] = [
|
||||||
|
{ id: "tab-code", label: "Code" },
|
||||||
|
{ id: "tab-design", label: "Design" },
|
||||||
|
{ id: "tab-menu", label: "Menu" },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("TabSwitcher", () => {
|
||||||
|
it("should render successfully", () => {
|
||||||
|
const { baseElement } = render(
|
||||||
|
<TabSwitcher tabs={tabs} selected="tab-code" onTabChange={vi.fn()} />,
|
||||||
|
);
|
||||||
|
expect(baseElement).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render all tab buttons", () => {
|
||||||
|
const { getByRole, getAllByRole } = render(
|
||||||
|
<TabSwitcher tabs={tabs} selected="tab-code" onTabChange={vi.fn()} />,
|
||||||
|
);
|
||||||
|
const tablist = getByRole("tablist");
|
||||||
|
expect(tablist).toBeTruthy();
|
||||||
|
const tabButtons = getAllByRole("tab");
|
||||||
|
expect(tabButtons).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should mark the selected tab as aria-selected", () => {
|
||||||
|
const { getAllByRole } = render(
|
||||||
|
<TabSwitcher tabs={tabs} selected="tab-design" onTabChange={vi.fn()} />,
|
||||||
|
);
|
||||||
|
const tabButtons = getAllByRole("tab");
|
||||||
|
const designTab = tabButtons.find(
|
||||||
|
(btn) => btn.textContent?.trim() === "Design",
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
expect(designTab.getAttribute("aria-selected")).toBe("true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should mark other tabs as aria-selected=false", () => {
|
||||||
|
const { getAllByRole } = render(
|
||||||
|
<TabSwitcher tabs={tabs} selected="tab-design" onTabChange={vi.fn()} />,
|
||||||
|
);
|
||||||
|
const tabButtons = getAllByRole("tab");
|
||||||
|
const codeTab = tabButtons.find(
|
||||||
|
(btn) => btn.textContent?.trim() === "Code",
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
expect(codeTab.getAttribute("aria-selected")).toBe("false");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call onTabChange when a tab is clicked", () => {
|
||||||
|
const handleChange = vi.fn();
|
||||||
|
const { getAllByRole } = render(
|
||||||
|
<TabSwitcher
|
||||||
|
tabs={tabs}
|
||||||
|
selected="tab-code"
|
||||||
|
onTabChange={handleChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const tabButtons = getAllByRole("tab");
|
||||||
|
const designTab = tabButtons.find(
|
||||||
|
(btn) => btn.textContent?.trim() === "Design",
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
fireEvent.click(designTab);
|
||||||
|
expect(handleChange).toHaveBeenCalledWith("tab-design");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render a tabpanel", () => {
|
||||||
|
const { getByRole } = render(
|
||||||
|
<TabSwitcher tabs={tabs} selected="tab-code" onTabChange={vi.fn()}>
|
||||||
|
<p>Content</p>
|
||||||
|
</TabSwitcher>,
|
||||||
|
);
|
||||||
|
const panel = getByRole("tabpanel");
|
||||||
|
expect(panel).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render children inside the tab panel", () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<TabSwitcher tabs={tabs} selected="tab-code" onTabChange={vi.fn()}>
|
||||||
|
<p>Tab content here</p>
|
||||||
|
</TabSwitcher>,
|
||||||
|
);
|
||||||
|
expect(getByText("Tab content here")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should navigate with ArrowRight key", () => {
|
||||||
|
const handleChange = vi.fn();
|
||||||
|
const { getByRole } = render(
|
||||||
|
<TabSwitcher
|
||||||
|
tabs={tabs}
|
||||||
|
selected="tab-code"
|
||||||
|
onTabChange={handleChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const tablist = getByRole("tablist");
|
||||||
|
fireEvent.keyDown(tablist, { key: "ArrowRight" });
|
||||||
|
expect(handleChange).toHaveBeenCalledWith("tab-design");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should navigate with ArrowLeft key", () => {
|
||||||
|
const handleChange = vi.fn();
|
||||||
|
const { getByRole } = render(
|
||||||
|
<TabSwitcher
|
||||||
|
tabs={tabs}
|
||||||
|
selected="tab-code"
|
||||||
|
onTabChange={handleChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const tablist = getByRole("tablist");
|
||||||
|
fireEvent.keyDown(tablist, { key: "ArrowLeft" });
|
||||||
|
// wraps around to last tab
|
||||||
|
expect(handleChange).toHaveBeenCalledWith("tab-menu");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should navigate with Home key", () => {
|
||||||
|
const handleChange = vi.fn();
|
||||||
|
const { getByRole } = render(
|
||||||
|
<TabSwitcher
|
||||||
|
tabs={tabs}
|
||||||
|
selected="tab-design"
|
||||||
|
onTabChange={handleChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const tablist = getByRole("tablist");
|
||||||
|
fireEvent.keyDown(tablist, { key: "Home" });
|
||||||
|
expect(handleChange).toHaveBeenCalledWith("tab-code");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pass className to wrapper", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TabSwitcher
|
||||||
|
tabs={tabs}
|
||||||
|
selected="tab-code"
|
||||||
|
onTabChange={vi.fn()}
|
||||||
|
className="my-class"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const wrapper = container.firstElementChild as HTMLElement;
|
||||||
|
expect(wrapper.getAttribute("class")).toContain("my-class");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should spread extra props onto wrapper div", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TabSwitcher
|
||||||
|
tabs={tabs}
|
||||||
|
selected="tab-code"
|
||||||
|
onTabChange={vi.fn()}
|
||||||
|
data-testid="wrapper"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const wrapper = container.firstElementChild as HTMLElement;
|
||||||
|
expect(wrapper.getAttribute("data-testid")).toBe("wrapper");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render action button in start position", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TabSwitcher
|
||||||
|
tabs={tabs}
|
||||||
|
selected="tab-code"
|
||||||
|
onTabChange={vi.fn()}
|
||||||
|
actionButtonPosition="start"
|
||||||
|
actionButton={<button>Action</button>}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const nav = container.querySelector("nav") as HTMLElement;
|
||||||
|
const navClass = nav.getAttribute("class") ?? "";
|
||||||
|
expect(navClass).toContain("tab-nav-start");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render action button in end position", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TabSwitcher
|
||||||
|
tabs={tabs}
|
||||||
|
selected="tab-code"
|
||||||
|
onTabChange={vi.fn()}
|
||||||
|
actionButtonPosition="end"
|
||||||
|
actionButton={<button>Action</button>}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const nav = container.querySelector("nav") as HTMLElement;
|
||||||
|
const navClass = nav.getAttribute("class") ?? "";
|
||||||
|
expect(navClass).toContain("tab-nav-end");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set aria-labelledby on tabpanel to selected tab id", () => {
|
||||||
|
const { getByRole } = render(
|
||||||
|
<TabSwitcher tabs={tabs} selected="tab-code" onTabChange={vi.fn()} />,
|
||||||
|
);
|
||||||
|
const panel = getByRole("tabpanel");
|
||||||
|
expect(panel.getAttribute("aria-labelledby")).toBe("tab-code");
|
||||||
|
});
|
||||||
|
});
|
||||||
148
frontend/packages/ui/src/lib/layout/TabSwitcher.stories.tsx
Normal file
148
frontend/packages/ui/src/lib/layout/TabSwitcher.stories.tsx
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
//
|
||||||
|
// Copyright (c) KALEIDOS INC
|
||||||
|
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { TabSwitcher } from "./TabSwitcher";
|
||||||
|
import type { TabItem, TabSwitcherProps } from "./TabSwitcher";
|
||||||
|
|
||||||
|
interface TabWithContent extends TabItem {
|
||||||
|
content: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatefulTabSwitcher({
|
||||||
|
tabs,
|
||||||
|
defaultSelected,
|
||||||
|
...props
|
||||||
|
}: Omit<TabSwitcherProps, "selected" | "onTabChange" | "tabs"> & {
|
||||||
|
tabs: TabWithContent[];
|
||||||
|
defaultSelected?: string;
|
||||||
|
}) {
|
||||||
|
const [selected, setSelected] = useState(
|
||||||
|
defaultSelected ?? tabs[0]?.id ?? "",
|
||||||
|
);
|
||||||
|
const navTabs: TabItem[] = tabs.map(({ content: _c, ...item }) => item);
|
||||||
|
const contentMap = Object.fromEntries(tabs.map((t) => [t.id, t.content]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabSwitcher
|
||||||
|
{...props}
|
||||||
|
tabs={navTabs}
|
||||||
|
selected={selected}
|
||||||
|
onTabChange={setSelected}
|
||||||
|
>
|
||||||
|
{contentMap[selected]}
|
||||||
|
</TabSwitcher>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Story data
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const defaultTabs: TabWithContent[] = [
|
||||||
|
{ id: "tab-code", label: "Code", content: <p>Lorem Ipsum</p> },
|
||||||
|
{ id: "tab-design", label: "Design", content: <p>Dolor sit amet</p> },
|
||||||
|
{
|
||||||
|
id: "tab-menu",
|
||||||
|
label: "Menu",
|
||||||
|
content: <p>Consectetur adipiscing elit</p>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const iconTabs: TabWithContent[] = [
|
||||||
|
{
|
||||||
|
id: "tab-code",
|
||||||
|
"aria-label": "Code",
|
||||||
|
icon: "fill-content",
|
||||||
|
content: <p>Lorem Ipsum</p>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tab-design",
|
||||||
|
"aria-label": "Design",
|
||||||
|
icon: "pentool",
|
||||||
|
content: <p>Dolor sit amet</p>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tab-menu",
|
||||||
|
"aria-label": "Menu",
|
||||||
|
icon: "mask",
|
||||||
|
content: <p>Consectetur adipiscing elit</p>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const iconAndTextTabs: TabWithContent[] = [
|
||||||
|
{
|
||||||
|
id: "tab-code",
|
||||||
|
label: "Code",
|
||||||
|
icon: "fill-content",
|
||||||
|
content: <p>Lorem Ipsum</p>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tab-design",
|
||||||
|
label: "Design",
|
||||||
|
icon: "pentool",
|
||||||
|
content: <p>Dolor sit amet</p>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tab-menu",
|
||||||
|
label: "Menu",
|
||||||
|
icon: "mask",
|
||||||
|
content: <p>Consectetur adipiscing elit</p>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Meta
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "Layout/Tab Switcher",
|
||||||
|
component: StatefulTabSwitcher,
|
||||||
|
args: {
|
||||||
|
tabs: defaultTabs,
|
||||||
|
defaultSelected: "tab-code",
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof StatefulTabSwitcher>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Default: Story = {};
|
||||||
|
|
||||||
|
export const WithIcons: Story = {
|
||||||
|
args: {
|
||||||
|
tabs: iconTabs,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithIconsAndText: Story = {
|
||||||
|
args: {
|
||||||
|
tabs: iconAndTextTabs,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithActionButtonStart: Story = {
|
||||||
|
args: {
|
||||||
|
actionButtonPosition: "start",
|
||||||
|
actionButton: (
|
||||||
|
<button style={{ height: "32px", border: "none", borderRadius: "8px" }}>
|
||||||
|
A
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithActionButtonEnd: Story = {
|
||||||
|
args: {
|
||||||
|
actionButtonPosition: "end",
|
||||||
|
actionButton: (
|
||||||
|
<button style={{ height: "32px", border: "none", borderRadius: "8px" }}>
|
||||||
|
A
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
250
frontend/packages/ui/src/lib/layout/TabSwitcher.tsx
Normal file
250
frontend/packages/ui/src/lib/layout/TabSwitcher.tsx
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
//
|
||||||
|
// Copyright (c) KALEIDOS INC
|
||||||
|
|
||||||
|
import {
|
||||||
|
type ComponentPropsWithRef,
|
||||||
|
type ReactNode,
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useRef,
|
||||||
|
} from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { Icon } from "../foundations/assets/Icon";
|
||||||
|
import type { IconId } from "../foundations/assets/Icon";
|
||||||
|
import styles from "./TabSwitcher.module.scss";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface TabItem {
|
||||||
|
/** Unique tab identifier. Also used as the `id` and `aria-labelledby` value. */
|
||||||
|
id: string;
|
||||||
|
/** Visible label text. Either `label` or `aria-label` must be provided. */
|
||||||
|
label?: string;
|
||||||
|
/** Accessible label (for icon-only tabs). */
|
||||||
|
"aria-label"?: string;
|
||||||
|
/** Icon id. When provided, an icon is shown alongside or instead of the label. */
|
||||||
|
icon?: IconId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TabSwitcherProps extends ComponentPropsWithRef<"div"> {
|
||||||
|
/** Ordered list of tab definitions. Must have at least one item. */
|
||||||
|
tabs: TabItem[];
|
||||||
|
/** Id of the currently selected tab. */
|
||||||
|
selected: string;
|
||||||
|
/** Called when the user selects a different tab. */
|
||||||
|
onTabChange: (id: string) => void;
|
||||||
|
/** Element rendered alongside the tab nav bar. */
|
||||||
|
actionButton?: ReactNode;
|
||||||
|
/** Position of the action button relative to the tab list. */
|
||||||
|
actionButtonPosition?: "start" | "end";
|
||||||
|
/** When true, the tab panel scrolls vertically if its content overflows. */
|
||||||
|
scrollablePanel?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Private: Tab button
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface TabProps {
|
||||||
|
id: string;
|
||||||
|
label?: string;
|
||||||
|
"aria-label"?: string;
|
||||||
|
icon?: IconId;
|
||||||
|
selected: boolean;
|
||||||
|
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
|
tabRef: (node: HTMLButtonElement | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tab({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
"aria-label": ariaLabel,
|
||||||
|
icon,
|
||||||
|
selected,
|
||||||
|
onClick,
|
||||||
|
tabRef,
|
||||||
|
}: TabProps) {
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
id={id}
|
||||||
|
ref={tabRef}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={selected}
|
||||||
|
title={label ?? ariaLabel}
|
||||||
|
tabIndex={selected ? undefined : -1}
|
||||||
|
data-id={id}
|
||||||
|
className={clsx(styles.tab, { [styles.selected]: selected })}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{icon != null && (
|
||||||
|
<Icon
|
||||||
|
iconId={icon}
|
||||||
|
aria-hidden={label != null ? true : undefined}
|
||||||
|
aria-label={label == null ? ariaLabel : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{label != null && (
|
||||||
|
<span
|
||||||
|
className={clsx(styles["tab-text"], {
|
||||||
|
[styles["tab-text-and-icon"]]: icon != null,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Private: Tab nav
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface TabNavProps {
|
||||||
|
tabs: TabItem[];
|
||||||
|
selected: string;
|
||||||
|
actionButton?: ReactNode;
|
||||||
|
actionButtonPosition?: "start" | "end";
|
||||||
|
onTabClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
|
onKeyDown: (event: React.KeyboardEvent<HTMLUListElement>) => void;
|
||||||
|
registerRef: (id: string, node: HTMLButtonElement | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TabNav = memo(function TabNav({
|
||||||
|
tabs,
|
||||||
|
selected,
|
||||||
|
actionButton,
|
||||||
|
actionButtonPosition,
|
||||||
|
onTabClick,
|
||||||
|
onKeyDown,
|
||||||
|
registerRef,
|
||||||
|
}: TabNavProps) {
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
className={clsx(styles["tab-nav"], {
|
||||||
|
[styles["tab-nav-start"]]: actionButtonPosition === "start",
|
||||||
|
[styles["tab-nav-end"]]: actionButtonPosition === "end",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{actionButtonPosition === "start" && actionButton}
|
||||||
|
|
||||||
|
<ul
|
||||||
|
role="tablist"
|
||||||
|
aria-orientation="horizontal"
|
||||||
|
className={styles["tab-list"]}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
>
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<Tab
|
||||||
|
key={tab.id}
|
||||||
|
id={tab.id}
|
||||||
|
label={tab.label}
|
||||||
|
aria-label={tab["aria-label"]}
|
||||||
|
icon={tab.icon}
|
||||||
|
selected={tab.id === selected}
|
||||||
|
onClick={onTabClick}
|
||||||
|
tabRef={(node) => registerRef(tab.id, node)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{actionButtonPosition === "end" && actionButton}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function TabSwitcherInner({
|
||||||
|
tabs,
|
||||||
|
selected,
|
||||||
|
onTabChange,
|
||||||
|
actionButton,
|
||||||
|
actionButtonPosition,
|
||||||
|
scrollablePanel = false,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}: TabSwitcherProps) {
|
||||||
|
// Map of tab id → button DOM node, for programmatic focus
|
||||||
|
const nodesRef = useRef<Record<string, HTMLButtonElement>>({});
|
||||||
|
|
||||||
|
const registerRef = useCallback(
|
||||||
|
(id: string, node: HTMLButtonElement | null) => {
|
||||||
|
if (node != null) {
|
||||||
|
nodesRef.current[id] = node;
|
||||||
|
} else {
|
||||||
|
delete nodesRef.current[id];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTabClick = useCallback(
|
||||||
|
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
const id = event.currentTarget.dataset["id"];
|
||||||
|
if (id != null) onTabChange(id);
|
||||||
|
},
|
||||||
|
[onTabChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(event: React.KeyboardEvent<HTMLUListElement>) => {
|
||||||
|
const len = tabs.length;
|
||||||
|
const currentIndex = tabs.findIndex((t) => t.id === selected);
|
||||||
|
let nextId: string | undefined;
|
||||||
|
|
||||||
|
if (event.key === "Home") {
|
||||||
|
nextId = tabs[0]?.id;
|
||||||
|
} else if (event.key === "ArrowLeft") {
|
||||||
|
nextId = tabs[(currentIndex - 1 + len) % len]?.id;
|
||||||
|
} else if (event.key === "ArrowRight") {
|
||||||
|
nextId = tabs[(currentIndex + 1) % len]?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextId != null) {
|
||||||
|
onTabChange(nextId);
|
||||||
|
nodesRef.current[nextId]?.focus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[tabs, selected, onTabChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx(styles.tabs, className)} {...rest}>
|
||||||
|
<div className={styles["padding-wrapper"]}>
|
||||||
|
<TabNav
|
||||||
|
tabs={tabs}
|
||||||
|
selected={selected}
|
||||||
|
actionButton={actionButton}
|
||||||
|
actionButtonPosition={actionButtonPosition}
|
||||||
|
onTabClick={handleTabClick}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
registerRef={registerRef}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section
|
||||||
|
className={clsx(styles["tab-panel"], {
|
||||||
|
[styles["scrollable-panel"]]: scrollablePanel,
|
||||||
|
})}
|
||||||
|
tabIndex={0}
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby={selected}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TabSwitcher = memo(TabSwitcherInner);
|
||||||
36
frontend/packages/ui/src/lib/product/EmptyState.module.scss
Normal file
36
frontend/packages/ui/src/lib/product/EmptyState.module.scss
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
//
|
||||||
|
// Copyright (c) KALEIDOS INC
|
||||||
|
|
||||||
|
@use "../_ds/_sizes.scss" as *;
|
||||||
|
@use "../_ds/typography.scss" as t;
|
||||||
|
|
||||||
|
.group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-m);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
inline-size: $sz-48;
|
||||||
|
block-size: $sz-48;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
color: var(--color-foreground-secondary);
|
||||||
|
inline-size: $sz-32;
|
||||||
|
block-size: $sz-32;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
@include t.use-typography("body-small");
|
||||||
|
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-foreground-secondary);
|
||||||
|
}
|
||||||
64
frontend/packages/ui/src/lib/product/EmptyState.spec.tsx
Normal file
64
frontend/packages/ui/src/lib/product/EmptyState.spec.tsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
//
|
||||||
|
// Copyright (c) KALEIDOS INC
|
||||||
|
|
||||||
|
import { render } from "@testing-library/react";
|
||||||
|
import { EmptyState } from "./EmptyState";
|
||||||
|
|
||||||
|
describe("EmptyState", () => {
|
||||||
|
it("should render successfully", () => {
|
||||||
|
const { baseElement } = render(<EmptyState icon="help" text="Empty" />);
|
||||||
|
expect(baseElement).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render the provided text", () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<EmptyState icon="help" text="Nothing to see here" />,
|
||||||
|
);
|
||||||
|
expect(getByText("Nothing to see here")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render an SVG icon", () => {
|
||||||
|
const { container } = render(<EmptyState icon="help" text="Empty" />);
|
||||||
|
const svg = container.querySelector("svg");
|
||||||
|
expect(svg).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pass className to wrapper", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<EmptyState icon="help" text="Empty" className="my-class" />,
|
||||||
|
);
|
||||||
|
const wrapper = container.firstElementChild as HTMLElement;
|
||||||
|
expect(wrapper.getAttribute("class")).toContain("my-class");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should spread extra props onto wrapper div", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<EmptyState icon="help" text="Empty" data-testid="empty-state" />,
|
||||||
|
);
|
||||||
|
const wrapper = container.firstElementChild as HTMLElement;
|
||||||
|
expect(wrapper.getAttribute("data-testid")).toBe("empty-state");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should apply the group class to the wrapper", () => {
|
||||||
|
const { container } = render(<EmptyState icon="help" text="Empty" />);
|
||||||
|
const wrapper = container.firstElementChild as HTMLElement;
|
||||||
|
expect(wrapper.getAttribute("class")).toContain("group");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render the icon inside an icon-wrapper div", () => {
|
||||||
|
const { container } = render(<EmptyState icon="help" text="Empty" />);
|
||||||
|
const iconWrapper = container.querySelector("[class*='icon-wrapper']");
|
||||||
|
expect(iconWrapper).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render text inside a div with text class", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<EmptyState icon="help" text="Some text here" />,
|
||||||
|
);
|
||||||
|
const textDiv = container.querySelector("[class*='text']");
|
||||||
|
expect(textDiv?.textContent).toBe("Some text here");
|
||||||
|
});
|
||||||
|
});
|
||||||
29
frontend/packages/ui/src/lib/product/EmptyState.stories.tsx
Normal file
29
frontend/packages/ui/src/lib/product/EmptyState.stories.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
//
|
||||||
|
// Copyright (c) KALEIDOS INC
|
||||||
|
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { EmptyState } from "./EmptyState";
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "Product/EmptyState",
|
||||||
|
component: EmptyState,
|
||||||
|
args: {
|
||||||
|
icon: "help",
|
||||||
|
text: "This is an empty state",
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof EmptyState>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Default: Story = {};
|
||||||
|
|
||||||
|
export const WithDifferentIcon: Story = {
|
||||||
|
args: {
|
||||||
|
icon: "layers",
|
||||||
|
text: "No layers found",
|
||||||
|
},
|
||||||
|
};
|
||||||
39
frontend/packages/ui/src/lib/product/EmptyState.tsx
Normal file
39
frontend/packages/ui/src/lib/product/EmptyState.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
//
|
||||||
|
// Copyright (c) KALEIDOS INC
|
||||||
|
|
||||||
|
import { type ComponentPropsWithRef, memo } from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { Icon } from "../foundations/assets/Icon";
|
||||||
|
import type { IconId } from "../foundations/assets/Icon";
|
||||||
|
import styles from "./EmptyState.module.scss";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface EmptyStateProps extends ComponentPropsWithRef<"div"> {
|
||||||
|
/** Icon to display. Required. */
|
||||||
|
icon: IconId;
|
||||||
|
/** Descriptive text shown below the icon. Required. */
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function EmptyStateInner({ icon, text, className, ...rest }: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<div className={clsx(styles.group, className)} {...rest}>
|
||||||
|
<div className={styles["icon-wrapper"]}>
|
||||||
|
<Icon iconId={icon} size="l" className={styles.icon} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.text}>{text}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EmptyState = memo(EmptyStateInner);
|
||||||
Loading…
x
Reference in New Issue
Block a user