diff --git a/.changeset/typography-scale-headings.md b/.changeset/typography-scale-headings.md new file mode 100644 index 0000000000..c7bc79ead5 --- /dev/null +++ b/.changeset/typography-scale-headings.md @@ -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 `

` for UI labels regardless of context. They now use semantically appropriate elements so screen reader heading navigation reflects real document structure: + +- `Modal` title: `

` (string title) or `
` (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: `

` +- `FormSection` title: `

` +- `SignInForm` title: `

` +- `Message` title: `` (no longer a heading) +- `IntroBox` title: `

` (no longer a heading) +- `PopupMenuSectionHeading` label: plain text inside `

` (no longer a heading) diff --git a/packages/ui-components/src/components/ContentHeading/ContentHeading.component.tsx b/packages/ui-components/src/components/ContentHeading/ContentHeading.component.tsx index 87aaf3a080..cf22f9c7d1 100644 --- a/packages/ui-components/src/components/ContentHeading/ContentHeading.component.tsx +++ b/packages/ui-components/src/components/ContentHeading/ContentHeading.component.tsx @@ -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 ` diff --git a/packages/ui-components/src/components/Form/Form.component.tsx b/packages/ui-components/src/components/Form/Form.component.tsx index e6b610b800..b252ede711 100644 --- a/packages/ui-components/src/components/Form/Form.component.tsx +++ b/packages/ui-components/src/components/Form/Form.component.tsx @@ -10,8 +10,6 @@ const formBaseStyles = ` ` const formTitleStyles = ` - jn:text-2xl - jn:font-bold jn:mb-4 ` @@ -45,7 +43,7 @@ export interface FormProps extends FormHTMLAttributes { export const Form = ({ title = "", className = "", children, ...props }: FormProps): ReactNode => { return (
- {title ?

{title}

: null} + {title ?

{title}

: null} {children}
) diff --git a/packages/ui-components/src/components/Form/Form.test.tsx b/packages/ui-components/src/components/Form/Form.test.tsx index e0a0af3198..e9df8fdc41 100644 --- a/packages/ui-components/src/components/Form/Form.test.tsx +++ b/packages/ui-components/src/components/Form/Form.test.tsx @@ -19,8 +19,8 @@ describe("Form Component Tests", () => { test("renders a title", () => { render(
) 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", () => { diff --git a/packages/ui-components/src/components/FormSection/FormSection.component.tsx b/packages/ui-components/src/components/FormSection/FormSection.component.tsx index 38f160985e..df39d46e60 100644 --- a/packages/ui-components/src/components/FormSection/FormSection.component.tsx +++ b/packages/ui-components/src/components/FormSection/FormSection.component.tsx @@ -11,8 +11,6 @@ const formSectionBaseStyles = ` ` const headingStyles = ` - jn:text-lg - jn:font-bold jn:mb-4 ` @@ -45,7 +43,7 @@ export interface FormSectionProps extends HTMLAttributes { export const FormSection = ({ title = "", children, className = "", ...props }: FormSectionProps): ReactNode => { return (
- {title ?

{title}

: null} + {title ?

{title}

: null} {children}
) diff --git a/packages/ui-components/src/components/FormSection/FormSection.test.tsx b/packages/ui-components/src/components/FormSection/FormSection.test.tsx index f98e8e8e6c..da04437b3d 100644 --- a/packages/ui-components/src/components/FormSection/FormSection.test.tsx +++ b/packages/ui-components/src/components/FormSection/FormSection.test.tsx @@ -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() + 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", () => { diff --git a/packages/ui-components/src/components/FormattedText/FormattedText.stories.tsx b/packages/ui-components/src/components/FormattedText/FormattedText.stories.tsx index f7c824af66..286a61ba28 100644 --- a/packages/ui-components/src/components/FormattedText/FormattedText.stories.tsx +++ b/packages/ui-components/src/components/FormattedText/FormattedText.stories.tsx @@ -41,6 +41,7 @@ const Template = (args: FormattedTextProps) => (

Headline 3

Headline 4

Headline 5
+
Headline 6

h2: Lorem ipsum dolor sit amet

h3: Sunt in culpa qui officia

@@ -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.

+
h6: Duis aute irure dolor in reprehenderit
) diff --git a/packages/ui-components/src/components/FormattedText/FormattedText.test.tsx b/packages/ui-components/src/components/FormattedText/FormattedText.test.tsx index 4df41c9d4a..0734976cbe 100644 --- a/packages/ui-components/src/components/FormattedText/FormattedText.test.tsx +++ b/packages/ui-components/src/components/FormattedText/FormattedText.test.tsx @@ -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( + +
Headline 6
+
+ ) + expect(screen.getByRole("heading", { level: 6 })).toBeInTheDocument() + expect(screen.getByRole("heading", { level: 6 })).toHaveTextContent("Headline 6") + }) }) diff --git a/packages/ui-components/src/components/FormattedText/formatted-text.css b/packages/ui-components/src/components/FormattedText/formatted-text.css index 68d5bddfec..0ef051be29 100644 --- a/packages/ui-components/src/components/FormattedText/formatted-text.css +++ b/packages/ui-components/src/components/FormattedText/formatted-text.css @@ -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, diff --git a/packages/ui-components/src/components/IntroBox/IntroBox.component.tsx b/packages/ui-components/src/components/IntroBox/IntroBox.component.tsx index 0a719dddab..776519aa57 100644 --- a/packages/ui-components/src/components/IntroBox/IntroBox.component.tsx +++ b/packages/ui-components/src/components/IntroBox/IntroBox.component.tsx @@ -119,7 +119,7 @@ export const IntroBox = ({ >
- {title ?

{title}

: ""} + {title ?

{title}

: ""} {children ? children :

{text}

}

diff --git a/packages/ui-components/src/components/Message/Message.component.tsx b/packages/ui-components/src/components/Message/Message.component.tsx index 47294c3dbc..990406fcca 100644 --- a/packages/ui-components/src/components/Message/Message.component.tsx +++ b/packages/ui-components/src/components/Message/Message.component.tsx @@ -264,7 +264,7 @@ export const Message = ({
- {title &&

{title}

} + {title && {title}}
{children || text}
{dismissible && ( diff --git a/packages/ui-components/src/components/Modal/Modal.component.tsx b/packages/ui-components/src/components/Modal/Modal.component.tsx index 71839c3e5d..b5e977093f 100644 --- a/packages/ui-components/src/components/Modal/Modal.component.tsx +++ b/packages/ui-components/src/components/Modal/Modal.component.tsx @@ -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 = ` @@ -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) @@ -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 ( -

+

{modalTitle} -

+

) } return ( -
+
{modalTitle}
) @@ -221,6 +217,7 @@ export const Modal = ({ {isCloseable ? ( diff --git a/packages/ui-components/src/components/Modal/Modal.test.tsx b/packages/ui-components/src/components/Modal/Modal.test.tsx index 30034466f9..a15bd38f77 100644 --- a/packages/ui-components/src/components/Modal/Modal.test.tsx +++ b/packages/ui-components/src/components/Modal/Modal.test.tsx @@ -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() @@ -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( + + + + ) + ) + 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( diff --git a/packages/ui-components/src/components/PopupMenu/PopupMenu.component.tsx b/packages/ui-components/src/components/PopupMenu/PopupMenu.component.tsx index d1de26db66..f63b14daae 100644 --- a/packages/ui-components/src/components/PopupMenu/PopupMenu.component.tsx +++ b/packages/ui-components/src/components/PopupMenu/PopupMenu.component.tsx @@ -401,7 +401,7 @@ export const PopupMenuSectionHeading = ({ }: PopupMenuSectionHeadingProps): ReactNode => { return (
-

{label && label.length ? label : children}

+ {label && label.length ? label : children}
) } diff --git a/packages/ui-components/src/components/SignInForm/SignInForm.component.tsx b/packages/ui-components/src/components/SignInForm/SignInForm.component.tsx index 469ee20ce1..c813fae5d2 100644 --- a/packages/ui-components/src/components/SignInForm/SignInForm.component.tsx +++ b/packages/ui-components/src/components/SignInForm/SignInForm.component.tsx @@ -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 ` @@ -75,7 +73,7 @@ export const SignInForm = ({ return ( - {title !== false &&

{title}

} + {title !== false &&

{title}

} {errorMessage && } diff --git a/packages/ui-components/src/components/SignInForm/SignInForm.test.tsx b/packages/ui-components/src/components/SignInForm/SignInForm.test.tsx index d371b13c0f..31e4a26f75 100644 --- a/packages/ui-components/src/components/SignInForm/SignInForm.test.tsx +++ b/packages/ui-components/src/components/SignInForm/SignInForm.test.tsx @@ -43,15 +43,15 @@ describe("SignInForm Component Tests", () => { describe("Title Prop", () => { test("renders default title 'Sign In' when no title prop is passed", () => { render() - 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() - 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}", () => { @@ -61,16 +61,14 @@ describe("SignInForm Component Tests", () => { test("renders empty string title as empty heading", () => { render() - 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() - 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") }) }) diff --git a/packages/ui-components/src/global.css b/packages/ui-components/src/global.css index 0765870951..58eeee7286 100644 --- a/packages/ui-components/src/global.css +++ b/packages/ui-components/src/global.css @@ -1052,6 +1052,45 @@ @apply jn:text-theme-link; } + h1, .juno-h1, + h2, .juno-h2, + h3, .juno-h3, + h4, .juno-h4, + h5, .juno-h5, + h6, .juno-h6 { + @apply jn:font-sans jn:font-bold; + } + + h1, .juno-h1 { + font-size: 1.69rem; + line-height: 1.375; + } + + h2, .juno-h2 { + font-size: 1.56rem; + line-height: 1.375; + } + + h3, .juno-h3 { + font-size: 1.44rem; + line-height: 1.375; + } + + h4, .juno-h4 { + font-size: 1.28rem; + line-height: 1.625; + } + + h5, .juno-h5 { + font-size: 1.125rem; + line-height: 1.625; + } + + h6, .juno-h6 { + font-size: 1rem; + line-height: 1.5; + } + /* adds pointer cursor to buttons to restore tw3 behavior */ button:not(:disabled), [role="button"]:not(:disabled) { diff --git a/packages/ui-components/src/theme.css b/packages/ui-components/src/theme.css index f81b5234c0..9fa622bda1 100644 --- a/packages/ui-components/src/theme.css +++ b/packages/ui-components/src/theme.css @@ -1122,3 +1122,45 @@ --tw-content: ""; } } + +@layer base { + h1, .juno-h1, + h2, .juno-h2, + h3, .juno-h3, + h4, .juno-h4, + h5, .juno-h5, + h6, .juno-h6 { + font-family: var(--font-sans); + @apply font-bold; + } + + h1, .juno-h1 { + font-size: 1.69rem; + line-height: 1.375; + } + + h2, .juno-h2 { + font-size: 1.56rem; + line-height: 1.375; + } + + h3, .juno-h3 { + font-size: 1.44rem; + line-height: 1.375; + } + + h4, .juno-h4 { + font-size: 1.28rem; + line-height: 1.625; + } + + h5, .juno-h5 { + font-size: 1.125rem; + line-height: 1.625; + } + + h6, .juno-h6 { + font-size: 1rem; + line-height: 1.5; + } +}