Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
b333d8f
feat(ui): add h1–h6 base typography styles to global.css
MartinS-git Jun 8, 2026
6c58a32
feat(ui): add h1–h6 base typography styles to theme.css
MartinS-git Jun 8, 2026
26d2e58
refactor(ui): replace line-height values with Tailwind leading utilit…
MartinS-git Jun 8, 2026
11434dc
feat(ui): align FormattedText heading styles with typography scale
MartinS-git Jun 8, 2026
72c66a4
chore: add changeset for typography scale headings
MartinS-git Jun 8, 2026
1d6fc70
refactor(ui): move h1–h6 into @layer base and use var(--font-sans) in…
MartinS-git Jun 8, 2026
4a60bfe
fix(ui): remove font-bold and text-lg overrides from ContentHeading
MartinS-git Jun 10, 2026
90adb97
chore: update changeset with full list of typography changes
MartinS-git Jun 10, 2026
7aac887
refactor(ui): replace @apply leading utilities with plain CSS in glob…
MartinS-git Jun 10, 2026
314d330
chore(ui): remove Tailwind utility comments from h1–h6 line-height va…
MartinS-git Jun 10, 2026
07a62e7
fix(ui): replace h1 misuse in components with semantically correct el…
MartinS-git Jun 11, 2026
4c98064
fix(ui): update SignInForm test to reflect removed text-xl/font-bold …
MartinS-git Jun 11, 2026
3805d85
fix(ui): use h4 for ReactNode modal titles and add heading level asse…
MartinS-git Jun 11, 2026
713e76b
fix(ui): update Modal test to use heading level 4 instead of div for …
MartinS-git Jun 11, 2026
be77926
fix(ui): replace hardcoded font-weight with Tailwind utility and fix …
MartinS-git Jun 12, 2026
35bdb33
fix(ui): remove jn: prefix from @apply font-bold in theme.css for con…
MartinS-git Jun 12, 2026
4af9079
fix(ui): add typography styles to Modal ReactNode title div and corre…
MartinS-git Jun 12, 2026
f2aaa3f
fix(ui): align Modal ReactNode title typography with h4 base styles (…
MartinS-git Jun 12, 2026
c7eb1ab
fix(ui): add jn:font-sans to Modal ReactNode titlestyles for font-fam…
MartinS-git Jun 12, 2026
77631d9
fix(ui): apply titlestyles to both h4 and div Modal title paths for f…
MartinS-git Jun 12, 2026
05747d1
fix(ui): remove redundant span wrapper in PopupMenuSectionHeading to …
MartinS-git Jun 12, 2026
e304ede
refactor(ui): add .juno-h1–.juno-h6 classes for shared heading typogr…
edda Jun 15, 2026
b881b0c
fix(ui): replace fixed Modal header height with min-height and align …
MartinS-git Jun 15, 2026
c736f09
chore: update changeset with Modal header min-height fix
MartinS-git Jun 15, 2026
78927e2
fix(ui): stretch Modal close button to full header height for larger …
MartinS-git Jun 15, 2026
f4bae33
feat(ui): add h6 to FormattedText stories and tests
MartinS-git Jun 15, 2026
9d4ec9d
fix(ui): add heading level assertions to Form/FormSection tests and p…
MartinS-git Jun 15, 2026
8a1b6da
test(ui): add Modal test for empty string title not rendering heading
MartinS-git Jun 15, 2026
f2e2adc
fix(ui): call useId unconditionally in Modal to comply with Rules of …
MartinS-git Jun 15, 2026
8a80dfb
Merge branch 'main' into headline-definitions
MartinS-git Jun 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/typography-scale-headings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"@cloudoperators/juno-ui-components": minor
---

Add base typography styles for `h1`–`h6` aligned with the Juno design system scale.

Headings rendered inside Juno apps (and inside `FormattedText`) now use a consistent IBM Plex Sans Bold scale: `h1` 1.69rem, `h2` 1.56rem, `h3` 1.44rem, `h4` 1.28rem, `h5` 1.125rem, `h6` 1rem.

Matching `.juno-h1`–`.juno-h6` utility classes apply the same scale to non-heading elements (e.g. an element with `role="heading"`).

**Visual change**: `ContentHeading` now uses the h1 scale (1.69rem) instead of `text-lg` (1.125rem). If you relied on the previous size, override with your own classes.

**Accessibility fixes**: several components previously used `<h1>` for UI labels regardless of context. They now use semantically appropriate elements so screen reader heading navigation reflects real document structure:

- `Modal` title: `<h4>` (string title) or `<div role="heading" aria-level={4}>` (ReactNode title); modal header uses `min-height` instead of fixed height, aligns items to the top so long titles wrap without clipping, and the close button stretches to full header height for a larger click target
- `Form` title: `<h3>`
- `FormSection` title: `<h4>`
- `SignInForm` title: `<h2>`
- `Message` title: `<strong>` (no longer a heading)
- `IntroBox` title: `<p>` (no longer a heading)
- `PopupMenuSectionHeading` label: plain text inside `<header>` (no longer a heading)
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@
import React, { HTMLAttributes, ReactNode } from "react"

const baseHeadingStyles = `
jn:font-bold
jn:text-lg
jn:text-theme-high
jn:pb-2
`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ const formBaseStyles = `
`

const formTitleStyles = `
jn:text-2xl
jn:font-bold
jn:mb-4
`

Expand Down Expand Up @@ -45,7 +43,7 @@ export interface FormProps extends FormHTMLAttributes<HTMLFormElement> {
export const Form = ({ title = "", className = "", children, ...props }: FormProps): ReactNode => {
return (
<form className={`juno-form ${formBaseStyles} ${className}`} {...props}>
{title ? <h1 className={`juno-form-heading ${formTitleStyles}`}>{title}</h1> : null}
{title ? <h3 className={`juno-form-heading ${formTitleStyles}`}>{title}</h3> : null}
Comment thread
MartinS-git marked this conversation as resolved.
{children}
</form>
)
Expand Down
4 changes: 2 additions & 2 deletions packages/ui-components/src/components/Form/Form.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ describe("Form Component Tests", () => {
test("renders a title", () => {
render(<Form data-testid="my-form" title="My Form" />)
expect(screen.getByTestId("my-form")).toBeInTheDocument()
expect(screen.getByRole("heading")).toHaveClass("juno-form-heading")
expect(screen.getByRole("heading")).toHaveTextContent("My Form")
expect(screen.getByRole("heading", { level: 3 })).toHaveClass("juno-form-heading")
expect(screen.getByRole("heading", { level: 3 })).toHaveTextContent("My Form")
})

test("renders without any props", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@ const formSectionBaseStyles = `
`

const headingStyles = `
jn:text-lg
jn:font-bold
jn:mb-4
`

Expand Down Expand Up @@ -45,7 +43,7 @@ export interface FormSectionProps extends HTMLAttributes<HTMLElement> {
export const FormSection = ({ title = "", children, className = "", ...props }: FormSectionProps): ReactNode => {
return (
<section className={`juno-form-section ${formSectionBaseStyles} ${className}`} {...props}>
{title ? <h1 className={`juno-formsection-heading ${headingStyles}`}>{title}</h1> : null}
{title ? <h4 className={`juno-formsection-heading ${headingStyles}`}>{title}</h4> : null}
Comment thread
MartinS-git marked this conversation as resolved.
{children}
</section>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,18 @@ import React from "react"
import { render, screen } from "@testing-library/react"
import { describe, expect, test } from "vitest"

import { FormSection } from "./FormSection.component"
import { FormRow } from "../FormRow/FormRow.component"

describe("FormSection Component Tests", () => {
test("renders a title as h4", () => {
render(<FormSection title="My Section" />)
expect(screen.getByRole("heading", { level: 4 })).toBeInTheDocument()
expect(screen.getByRole("heading", { level: 4 })).toHaveClass("juno-formsection-heading")
expect(screen.getByRole("heading", { level: 4 })).toHaveTextContent("My Section")
})
})

describe("FormRow Component Tests", () => {
describe("Basic Rendering", () => {
test("renders a FormRow", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const Template = (args: FormattedTextProps) => (
<h3>Headline 3</h3>
<h4>Headline 4</h4>
<h5>Headline 5</h5>
<h6>Headline 6</h6>
<article>
<h2>h2: Lorem ipsum dolor sit amet</h2>
<h3>h3: Sunt in culpa qui officia</h3>
Expand Down Expand Up @@ -177,6 +178,7 @@ const Template = (args: FormattedTextProps) => (
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
</p>
<h6>h6: Duis aute irure dolor in reprehenderit</h6>
</article>
</FormattedText>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,14 @@ describe("FormattedText", () => {
expect(element).not.toBeNull() // Check if element exists
expect(element.getAttribute("data-lolol")).toBe("123-456") // Check attribute value
})

it("renders h6 headings inside FormattedText", () => {
render(
<FormattedText>
<h6>Headline 6</h6>
</FormattedText>
)
expect(screen.getByRole("heading", { level: 6 })).toBeInTheDocument()
expect(screen.getByRole("heading", { level: 6 })).toHaveTextContent("Headline 6")
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -36,42 +36,40 @@

h1 {
font-size: 1.69rem;
font-weight: 700;
line-height: 1.11; /* round(40 / 36) */
line-height: 1.375;
margin-top: 0;
margin-bottom: 2rem; /* rem(32) */
}

h2 {
font-size: 1.56rem;
font-weight: 700;
line-height: 160%;
line-height: 1.375;
margin-top: 3rem; /* rem(48) */
margin-bottom: 1.5rem; /* rem(24) */
}

h3 {
font-size: 1.44rem;
font-weight: 700;
line-height: 160%;
line-height: 1.375;
margin-top: 1rem;
margin-bottom: 0.75rem; /* rem(12) */
}

h4 {
font-size: 1.28rem;
font-style: normal;
font-weight: 700;
line-height: 160%;
line-height: 1.625;
margin-top: 1.5rem; /* rem(24) */
margin-bottom: 0.5rem; /* rem(8) */
}

h5 {
font-size: 1.03rem;
font-style: normal;
font-weight: 700;
line-height: 160%;
font-size: 1.125rem;
line-height: 1.625;
}

h6 {
font-size: 1rem;
line-height: 1.5;
}

img,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ export const IntroBox = ({
>
<div className={`${introboxBorder}`}></div>
<div className={`${introboxContent(variant, heroImage)}`}>
{title ? <h1 className={`${introboxHeading}`}>{title}</h1> : ""}
{title ? <p className={`${introboxHeading}`}>{title}</p> : ""}
{children ? children : <p>{text}</p>}
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ export const Message = ({
<div className={`juno-message-border ${messageBorderStyles} ${variantStyle}`}></div>
<Icon icon={iconToRender} size={21} className={`${getIconStyles(variant)} ${messageIconStyles}`} />
<div className={`juno-message-content ${messageContentStyles}`}>
{title && <h1 className={messageHeadingStyles}>{title}</h1>}
{title && <strong className={messageHeadingStyles}>{title}</strong>}
<div>{children || text}</div>
</div>
{dismissible && (
Expand Down
21 changes: 9 additions & 12 deletions packages/ui-components/src/components/Modal/Modal.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,16 +49,12 @@ const modalstyles = `

const headerstyles = `
jn:flex
jn:items-start
jn:py-2
jn:px-8
jn:border-b
jn:border-theme-background-lvl-4
jn:h-[2.8125rem]
`

const titlestyles = `
jn:text-xl
jn:font-bold
jn:min-h-[2.8125rem]
`

const contentstyles = `
Expand Down Expand Up @@ -115,7 +111,7 @@ export const Modal = ({
onCancel,
...props
}: ModalProps): ReactNode => {
const uniqueId = () => "juno-modal-" + useId()
const id = "juno-modal-" + useId()

const [isOpen, setIsOpen] = useState(open)
const [isCloseable, setIsCloseable] = useState(closeable)
Expand Down Expand Up @@ -169,21 +165,21 @@ export const Modal = ({

const modalTitle = title || heading
const hasTitle = Boolean(modalTitle)
const modalTitleId = hasTitle ? uniqueId() : undefined
const modalTitleId = hasTitle ? id : undefined

const renderModalTitle = () => {
if (modalTitle === null || modalTitle === undefined || modalTitle === false) {
if (modalTitle === null || modalTitle === undefined || modalTitle === false || modalTitle === "") {
return null
}
if (typeof modalTitle === "string") {
return (
<h1 className={`juno-modal-title ${titlestyles}`} id={modalTitleId}>
<h4 className="juno-modal-title" id={modalTitleId}>
{modalTitle}
</h1>
</h4>
Comment thread
MartinS-git marked this conversation as resolved.
)
}
return (
<div className={`juno-modal-title ${titlestyles}`} id={modalTitleId}>
<div className="juno-modal-title juno-h4" role="heading" aria-level={4} id={modalTitleId}>
{modalTitle}
Comment thread
MartinS-git marked this conversation as resolved.
</div>
)
Comment thread
MartinS-git marked this conversation as resolved.
Comment thread
MartinS-git marked this conversation as resolved.
Expand Down Expand Up @@ -221,6 +217,7 @@ export const Modal = ({
{isCloseable ? (
<Icon
icon="close"
className="jn:self-stretch jn:flex jn:items-start jn:pt-1"
onClick={handleCancelClick}
disabled={disableCancelButton || disableCloseButton}
/>
Expand Down
14 changes: 13 additions & 1 deletion packages/ui-components/src/components/Modal/Modal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ describe("Modal", () => {
)

const dialog = screen.getByRole("dialog")
const titleWrapper = screen.getByText("Node Title").closest("div")
const titleWrapper = screen.getByRole("heading", { level: 4 })

expect(dialog).toBeInTheDocument()
expect(titleWrapper).toBeInTheDocument()
Expand All @@ -72,6 +72,18 @@ describe("Modal", () => {
expect(screen.getByRole("dialog")).toHaveTextContent("My Modal Heading")
})

test("does not render a heading when title is an empty string", async () => {
await waitFor(() =>
render(
<PortalProvider>
<Modal title="" open />
</PortalProvider>
)
)
expect(screen.getByRole("dialog")).toBeInTheDocument()
expect(screen.queryByRole("heading")).not.toBeInTheDocument()
})

test("renders an aria-labelledby attribute referencing the title if passed", async () => {
await waitFor(() =>
render(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,7 @@ export const PopupMenuSectionHeading = ({
}: PopupMenuSectionHeadingProps): ReactNode => {
return (
<header className={`juno-popupmenu-section-title ${sectionTitleStyles} ${className}`} {...props}>
<h1>{label && label.length ? label : children}</h1>
{label && label.length ? label : children}
</header>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ import { Message } from "../Message"
import { Stack } from "../Stack"

const signInFormTitleStyles = `
jn:text-xl
jn:font-bold
jn:text-theme-highest
jn:mb-4
`
Expand Down Expand Up @@ -75,7 +73,7 @@ export const SignInForm = ({

return (
<form className={`juno-sign-in-form ${className}`} {...props}>
{title !== false && <h1 className={`juno-sign-in-form-heading ${signInFormTitleStyles}`}>{title}</h1>}
{title !== false && <h2 className={`juno-sign-in-form-heading ${signInFormTitleStyles}`}>{title}</h2>}

{errorMessage && <Message variant="error" text={errorMessage} className="jn:mb-4" />}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,15 @@ describe("SignInForm Component Tests", () => {
describe("Title Prop", () => {
test("renders default title 'Sign In' when no title prop is passed", () => {
render(<SignInForm data-testid="my-signin-form" />)
expect(screen.getByRole("heading")).toBeInTheDocument()
expect(screen.getByRole("heading")).toHaveClass("juno-sign-in-form-heading")
expect(screen.getByRole("heading")).toHaveTextContent("Sign In")
expect(screen.getByRole("heading", { level: 2 })).toBeInTheDocument()
expect(screen.getByRole("heading", { level: 2 })).toHaveClass("juno-sign-in-form-heading")
expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent("Sign In")
})

test("renders custom title when string is passed", () => {
render(<SignInForm data-testid="my-signin-form" title="Welcome Back" />)
expect(screen.getByRole("heading")).toBeInTheDocument()
expect(screen.getByRole("heading")).toHaveTextContent("Welcome Back")
expect(screen.getByRole("heading", { level: 2 })).toBeInTheDocument()
expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent("Welcome Back")
})

test("does not render title when title={false}", () => {
Expand All @@ -61,16 +61,14 @@ describe("SignInForm Component Tests", () => {

test("renders empty string title as empty heading", () => {
render(<SignInForm data-testid="my-signin-form" title="" />)
const heading = screen.queryByRole("heading")
const heading = screen.queryByRole("heading", { level: 2 })
expect(heading).toBeInTheDocument()
expect(heading).toHaveTextContent("")
})

test("applies correct styling to title", () => {
render(<SignInForm data-testid="my-signin-form" title="Test Title" />)
const heading = screen.getByRole("heading")
expect(heading).toHaveClass("jn:text-xl")
expect(heading).toHaveClass("jn:font-bold")
const heading = screen.getByRole("heading", { level: 2 })
expect(heading).toHaveClass("jn:text-theme-highest")
})
Comment thread
MartinS-git marked this conversation as resolved.
})
Expand Down
Loading
Loading