diff --git a/.lastsync b/.lastsync
deleted file mode 100644
index 94f729137..000000000
--- a/.lastsync
+++ /dev/null
@@ -1 +0,0 @@
-9014da9bbe05bd45f38841c02e34dcb5a8e3e1d8
diff --git a/package.json b/package.json
index ad915344d..b13811f9e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@openstax/ui-components",
- "version": "1.23.1",
+ "version": "1.23.2",
"license": "MIT",
"sideEffects": [
"**/*.css"
diff --git a/src/components/Text.spec.tsx b/src/components/Text.spec.tsx
index c28cbb009..7189bfcff 100644
--- a/src/components/Text.spec.tsx
+++ b/src/components/Text.spec.tsx
@@ -1,5 +1,7 @@
-import { Paragraph, H2, H3 } from './Text';
+import { Paragraph, H2, H3, Heading } from './Text';
import renderer from 'react-test-renderer';
+import { render } from '@testing-library/react';
+import React from 'react';
describe('Text', () => {
it('matches body snapshot', () => {
@@ -24,3 +26,41 @@ describe('Text', () => {
});
});
+
+describe('Heading', () => {
+ it('defaults variant to match the semantic level', () => {
+ const tree = renderer.create(
+ This is a heading
+ ).toJSON();
+ expect(tree).toMatchObject({ type: 'h2', props: { className: 'text-h2' } });
+ });
+
+ it('renders the element for the given level', () => {
+ const tree = renderer.create(
+ This is a heading
+ ).toJSON();
+ expect((tree as unknown as { type: string }).type).toBe('h4');
+ });
+
+ it('decouples visual style from semantic level', () => {
+ // an
element styled like an h3
+ const tree = renderer.create(
+ This is a heading
+ ).toJSON();
+ expect(tree).toMatchObject({ type: 'h2', props: { className: 'text-h3' } });
+ });
+
+ it('merges a consumer className with the variant class', () => {
+ const tree = renderer.create(
+ This is a heading
+ ).toJSON();
+ expect((tree as unknown as { props: { className: string } }).props.className).toBe('text-h2 custom');
+ });
+
+ it('forwards a ref to the underlying element', () => {
+ const ref = React.createRef();
+ render(This is a heading);
+ expect(ref.current).not.toBeNull();
+ expect(ref.current?.tagName).toBe('H2');
+ });
+});
diff --git a/src/components/Text.stories.tsx b/src/components/Text.stories.tsx
index c83372b30..bab6aad54 100644
--- a/src/components/Text.stories.tsx
+++ b/src/components/Text.stories.tsx
@@ -1,4 +1,4 @@
-import { H2, H3, Paragraph } from "./Text";
+import { H2, H3, Heading as HeadingComponent, Paragraph } from "./Text";
export const PText = () => (
<>
@@ -12,3 +12,11 @@ export const Heading = () => (
H3 Heading text
>
);
+
+// Semantic level and visual style are independent: this renders an
+// element (correct document outline) styled like an h3.
+export const HeadingStyleOverride = () => (
+
+ h2 element with h3 style
+
+);
diff --git a/src/components/Text.tsx b/src/components/Text.tsx
index 08b815e8d..7381aa8d1 100644
--- a/src/components/Text.tsx
+++ b/src/components/Text.tsx
@@ -2,18 +2,46 @@ import React from 'react';
import classNames from 'classnames';
import './Text.css';
+export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
+
+/**
+ * Visual style applied to a heading, decoupled from its semantic level.
+ * Maps to the `text-*` classes in Text.css.
+ */
+export type HeadingVariant = 'h2' | 'h3';
+
+export interface HeadingProps extends React.ComponentPropsWithoutRef<'h2'> {
+ /** Semantic heading level — controls which `h1`–`h6` element is rendered. */
+ level: HeadingLevel;
+ /** Visual style to apply. Defaults to match `level` (e.g. `level={2}` → `h2`). */
+ variant?: HeadingVariant;
+}
+
+/**
+ * Renders a heading whose semantic level (the rendered `h1`–`h6` tag) is
+ * independent of its visual style (`variant`). This lets consumers preserve a
+ * correct document outline while reusing an existing heading style — e.g.
+ * `` is an `` styled like an h3.
+ */
+export const Heading = React.forwardRef(
+ ({ level, variant, className, ...props }, ref) => {
+ const Tag = `h${level}` as const;
+ // Default the visual style to match the semantic level.
+ const resolvedVariant = variant ?? (`h${level}` as HeadingVariant);
+ return ;
+ }
+);
+
+Heading.displayName = 'Heading';
+
export const H2 = React.forwardRef>(
- ({ className, ...props }, ref) => (
-
- )
+ (props, ref) =>
);
H2.displayName = 'H2';
export const H3 = React.forwardRef>(
- ({ className, ...props }, ref) => (
-
- )
+ (props, ref) =>
);
H3.displayName = 'H3';