mirror of
https://github.com/penpot/penpot.git
synced 2026-04-25 19:28:12 +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.stories.tsx
|
||||
│ │ ├── Input.spec.tsx
|
||||
│ │ ├── RadioButtons.tsx
|
||||
│ │ ├── RadioButtons.module.scss
|
||||
│ │ ├── RadioButtons.stories.tsx
|
||||
│ │ ├── RadioButtons.spec.tsx
|
||||
│ │ ├── Switch.tsx
|
||||
│ │ ├── Switch.module.scss
|
||||
│ │ ├── Switch.stories.tsx
|
||||
@ -74,6 +78,11 @@ frontend/packages/ui/
|
||||
│ │ ├── NotificationPill.module.scss
|
||||
│ │ ├── NotificationPill.stories.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)
|
||||
│ └── utilities/ # Utility components (e.g. Swatch)
|
||||
├── 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/checkbox.cljs` | `src/lib/controls/Checkbox.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/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
|
||||
|
||||
@ -128,6 +140,22 @@ Components are organised to mirror the CLJS source tree
|
||||
(`[data-disabled]` selector).
|
||||
- **CSS Module class names must be kebab-case** — stylelint rejects camelCase
|
||||
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:
|
||||
- `ComponentName.tsx` – the React component
|
||||
|
||||
@ -75,3 +75,13 @@ export type {
|
||||
NotificationPillType,
|
||||
NotificationPillAppearance,
|
||||
} 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