diff --git a/frontend/packages/ui/AGENTS.md b/frontend/packages/ui/AGENTS.md index 10fd25bc09..7df818f9cb 100644 --- a/frontend/packages/ui/AGENTS.md +++ b/frontend/packages/ui/AGENTS.md @@ -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 diff --git a/frontend/packages/ui/src/index.ts b/frontend/packages/ui/src/index.ts index 889e861aca..cdf5214318 100644 --- a/frontend/packages/ui/src/index.ts +++ b/frontend/packages/ui/src/index.ts @@ -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"; diff --git a/frontend/packages/ui/src/lib/controls/RadioButtons.module.scss b/frontend/packages/ui/src/lib/controls/RadioButtons.module.scss new file mode 100644 index 0000000000..64d3fc3c7f --- /dev/null +++ b/frontend/packages/ui/src/lib/controls/RadioButtons.module.scss @@ -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; +} diff --git a/frontend/packages/ui/src/lib/controls/RadioButtons.spec.tsx b/frontend/packages/ui/src/lib/controls/RadioButtons.spec.tsx new file mode 100644 index 0000000000..69294b5e08 --- /dev/null +++ b/frontend/packages/ui/src/lib/controls/RadioButtons.spec.tsx @@ -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( + , + ); + expect(baseElement).toBeTruthy(); + }); + + it("should render all options as labels with inputs", () => { + const { container } = render( + , + ); + const inputs = container.querySelectorAll("input"); + expect(inputs).toHaveLength(3); + }); + + it("should mark the selected option as checked", () => { + const { container } = render( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + const inputs = container.querySelectorAll("input"); + inputs.forEach((input) => { + expect(input.readOnly).toBe(true); + }); + }); + + it("should use checkbox input type when allowEmpty", () => { + const { container } = render( + , + ); + 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( + , + ); + 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( + , + ); + 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(); + 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( + , + ); + const wrapper = container.firstElementChild as HTMLElement; + expect(wrapper.getAttribute("class")).toContain("my-class"); + }); + + it("should spread extra props onto wrapper div", () => { + const { container } = render( + , + ); + const wrapper = container.firstElementChild as HTMLElement; + expect(wrapper.getAttribute("data-testid")).toBe("wrapper"); + }); + + it("should use name attribute on inputs", () => { + const { container } = render( + , + ); + 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( + , + ); + const label = container.querySelector( + '[data-testid="opt-left"]', + ) as HTMLLabelElement; + expect(label.getAttribute("for")).toBe("opt-left"); + }); +}); diff --git a/frontend/packages/ui/src/lib/controls/RadioButtons.stories.tsx b/frontend/packages/ui/src/lib/controls/RadioButtons.stories.tsx new file mode 100644 index 0000000000..db46c4784b --- /dev/null +++ b/frontend/packages/ui/src/lib/controls/RadioButtons.stories.tsx @@ -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 ( + { + 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) => , +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +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, + }, +}; diff --git a/frontend/packages/ui/src/lib/controls/RadioButtons.tsx b/frontend/packages/ui/src/lib/controls/RadioButtons.tsx new file mode 100644 index 0000000000..e803f2a2ac --- /dev/null +++ b/frontend/packages/ui/src/lib/controls/RadioButtons.tsx @@ -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, + ) => 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) => { + const input = event.currentTarget; + const value = input.value; + const newValue = allowEmpty && value === selected ? null : value; + onChange?.(newValue, event); + input.blur(); + }, + [selected, allowEmpty, onChange], + ); + + return ( +
+ {options.map(({ id, value, label, icon, disabled: optionDisabled }) => { + const isChecked = selected === value; + const isDisabled = wrapperDisabled || (optionDisabled ?? false); + + return ( + + ); + })} +
+ ); +} + +export const RadioButtons = memo(RadioButtonsInner); diff --git a/frontend/packages/ui/src/lib/layout/TabSwitcher.module.scss b/frontend/packages/ui/src/lib/layout/TabSwitcher.module.scss new file mode 100644 index 0000000000..97eb289af6 --- /dev/null +++ b/frontend/packages/ui/src/lib/layout/TabSwitcher.module.scss @@ -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; +} diff --git a/frontend/packages/ui/src/lib/layout/TabSwitcher.spec.tsx b/frontend/packages/ui/src/lib/layout/TabSwitcher.spec.tsx new file mode 100644 index 0000000000..418675605e --- /dev/null +++ b/frontend/packages/ui/src/lib/layout/TabSwitcher.spec.tsx @@ -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( + , + ); + expect(baseElement).toBeTruthy(); + }); + + it("should render all tab buttons", () => { + const { getByRole, getAllByRole } = render( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + +

Content

+
, + ); + const panel = getByRole("tabpanel"); + expect(panel).toBeTruthy(); + }); + + it("should render children inside the tab panel", () => { + const { getByText } = render( + +

Tab content here

+
, + ); + expect(getByText("Tab content here")).toBeTruthy(); + }); + + it("should navigate with ArrowRight key", () => { + const handleChange = vi.fn(); + const { getByRole } = render( + , + ); + 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( + , + ); + 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( + , + ); + const tablist = getByRole("tablist"); + fireEvent.keyDown(tablist, { key: "Home" }); + expect(handleChange).toHaveBeenCalledWith("tab-code"); + }); + + it("should pass className to wrapper", () => { + const { container } = render( + , + ); + const wrapper = container.firstElementChild as HTMLElement; + expect(wrapper.getAttribute("class")).toContain("my-class"); + }); + + it("should spread extra props onto wrapper div", () => { + const { container } = render( + , + ); + const wrapper = container.firstElementChild as HTMLElement; + expect(wrapper.getAttribute("data-testid")).toBe("wrapper"); + }); + + it("should render action button in start position", () => { + const { container } = render( + Action} + />, + ); + 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( + Action} + />, + ); + 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( + , + ); + const panel = getByRole("tabpanel"); + expect(panel.getAttribute("aria-labelledby")).toBe("tab-code"); + }); +}); diff --git a/frontend/packages/ui/src/lib/layout/TabSwitcher.stories.tsx b/frontend/packages/ui/src/lib/layout/TabSwitcher.stories.tsx new file mode 100644 index 0000000000..8c1059b480 --- /dev/null +++ b/frontend/packages/ui/src/lib/layout/TabSwitcher.stories.tsx @@ -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 & { + 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 ( + + {contentMap[selected]} + + ); +} + +// --------------------------------------------------------------------------- +// Story data +// --------------------------------------------------------------------------- + +const defaultTabs: TabWithContent[] = [ + { id: "tab-code", label: "Code", content:

Lorem Ipsum

}, + { id: "tab-design", label: "Design", content:

Dolor sit amet

}, + { + id: "tab-menu", + label: "Menu", + content:

Consectetur adipiscing elit

, + }, +]; + +const iconTabs: TabWithContent[] = [ + { + id: "tab-code", + "aria-label": "Code", + icon: "fill-content", + content:

Lorem Ipsum

, + }, + { + id: "tab-design", + "aria-label": "Design", + icon: "pentool", + content:

Dolor sit amet

, + }, + { + id: "tab-menu", + "aria-label": "Menu", + icon: "mask", + content:

Consectetur adipiscing elit

, + }, +]; + +const iconAndTextTabs: TabWithContent[] = [ + { + id: "tab-code", + label: "Code", + icon: "fill-content", + content:

Lorem Ipsum

, + }, + { + id: "tab-design", + label: "Design", + icon: "pentool", + content:

Dolor sit amet

, + }, + { + id: "tab-menu", + label: "Menu", + icon: "mask", + content:

Consectetur adipiscing elit

, + }, +]; + +// --------------------------------------------------------------------------- +// Meta +// --------------------------------------------------------------------------- + +const meta = { + title: "Layout/Tab Switcher", + component: StatefulTabSwitcher, + args: { + tabs: defaultTabs, + defaultSelected: "tab-code", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +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: ( + + ), + }, +}; + +export const WithActionButtonEnd: Story = { + args: { + actionButtonPosition: "end", + actionButton: ( + + ), + }, +}; diff --git a/frontend/packages/ui/src/lib/layout/TabSwitcher.tsx b/frontend/packages/ui/src/lib/layout/TabSwitcher.tsx new file mode 100644 index 0000000000..4db9049c18 --- /dev/null +++ b/frontend/packages/ui/src/lib/layout/TabSwitcher.tsx @@ -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) => void; + tabRef: (node: HTMLButtonElement | null) => void; +} + +function Tab({ + id, + label, + "aria-label": ariaLabel, + icon, + selected, + onClick, + tabRef, +}: TabProps) { + return ( +
  • + +
  • + ); +} + +// --------------------------------------------------------------------------- +// Private: Tab nav +// --------------------------------------------------------------------------- + +interface TabNavProps { + tabs: TabItem[]; + selected: string; + actionButton?: ReactNode; + actionButtonPosition?: "start" | "end"; + onTabClick: (event: React.MouseEvent) => void; + onKeyDown: (event: React.KeyboardEvent) => void; + registerRef: (id: string, node: HTMLButtonElement | null) => void; +} + +const TabNav = memo(function TabNav({ + tabs, + selected, + actionButton, + actionButtonPosition, + onTabClick, + onKeyDown, + registerRef, +}: TabNavProps) { + return ( + + ); +}); + +// --------------------------------------------------------------------------- +// 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>({}); + + 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) => { + const id = event.currentTarget.dataset["id"]; + if (id != null) onTabChange(id); + }, + [onTabChange], + ); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + 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 ( +
    +
    + +
    + +
    + {children} +
    +
    + ); +} + +export const TabSwitcher = memo(TabSwitcherInner); diff --git a/frontend/packages/ui/src/lib/product/EmptyState.module.scss b/frontend/packages/ui/src/lib/product/EmptyState.module.scss new file mode 100644 index 0000000000..a0da881cfb --- /dev/null +++ b/frontend/packages/ui/src/lib/product/EmptyState.module.scss @@ -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); +} diff --git a/frontend/packages/ui/src/lib/product/EmptyState.spec.tsx b/frontend/packages/ui/src/lib/product/EmptyState.spec.tsx new file mode 100644 index 0000000000..56b793065b --- /dev/null +++ b/frontend/packages/ui/src/lib/product/EmptyState.spec.tsx @@ -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(); + expect(baseElement).toBeTruthy(); + }); + + it("should render the provided text", () => { + const { getByText } = render( + , + ); + expect(getByText("Nothing to see here")).toBeTruthy(); + }); + + it("should render an SVG icon", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + expect(svg).toBeTruthy(); + }); + + it("should pass className to wrapper", () => { + const { container } = render( + , + ); + const wrapper = container.firstElementChild as HTMLElement; + expect(wrapper.getAttribute("class")).toContain("my-class"); + }); + + it("should spread extra props onto wrapper div", () => { + const { container } = render( + , + ); + 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(); + 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(); + const iconWrapper = container.querySelector("[class*='icon-wrapper']"); + expect(iconWrapper).toBeTruthy(); + }); + + it("should render text inside a div with text class", () => { + const { container } = render( + , + ); + const textDiv = container.querySelector("[class*='text']"); + expect(textDiv?.textContent).toBe("Some text here"); + }); +}); diff --git a/frontend/packages/ui/src/lib/product/EmptyState.stories.tsx b/frontend/packages/ui/src/lib/product/EmptyState.stories.tsx new file mode 100644 index 0000000000..48bd7f75fb --- /dev/null +++ b/frontend/packages/ui/src/lib/product/EmptyState.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithDifferentIcon: Story = { + args: { + icon: "layers", + text: "No layers found", + }, +}; diff --git a/frontend/packages/ui/src/lib/product/EmptyState.tsx b/frontend/packages/ui/src/lib/product/EmptyState.tsx new file mode 100644 index 0000000000..36be0b36c7 --- /dev/null +++ b/frontend/packages/ui/src/lib/product/EmptyState.tsx @@ -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 ( +
    +
    + +
    +
    {text}
    +
    + ); +} + +export const EmptyState = memo(EmptyStateInner);