From 1cff5d2561ce3ba0ec137889c9c2073adf8d5505 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 07:58:47 +0000 Subject: [PATCH 1/2] 0.72.0 - Add Mark component for semantic inline text highlighting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `` component renders a semantic element with 8 color variants (default/primary/secondary/info/success/warning/destructive/accent), 4 style treatments (solid/soft/underline/outline), 3 sizes, optional rounded corners, and an `animate` reveal flag. Ships alongside a `HighlightedText` helper that auto-wraps every occurrence of a search token (string, array, or RegExp) within a longer string — useful for search-result highlighting, diff views, log scanners, and documentation. Includes a comprehensive Storybook story covering every style/variant combo, sizes, an interactive search-highlight playground, multi-token highlighting, RegExp matching, and a variant × style matrix. --- package.json | 2 +- src/components/ui/index.ts | 3 + src/components/ui/mark/Mark.stories.tsx | 389 ++++++++++++++++++++++ src/components/ui/mark/index.ts | 13 + src/components/ui/mark/mark-variants.ts | 26 ++ src/components/ui/mark/mark.tsx | 407 ++++++++++++++++++++++++ 6 files changed, 839 insertions(+), 1 deletion(-) create mode 100644 src/components/ui/mark/Mark.stories.tsx create mode 100644 src/components/ui/mark/index.ts create mode 100644 src/components/ui/mark/mark-variants.ts create mode 100644 src/components/ui/mark/mark.tsx diff --git a/package.json b/package.json index 071fe6f..b065c2c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@schemavaults/ui", - "version": "0.71.1", + "version": "0.72.0", "private": false, "license": "UNLICENSED", "description": "React.js UI components for SchemaVaults frontend applications", diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index 6910cd6..4a4627c 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -290,3 +290,6 @@ export type * from "./date-range-picker"; export * from "./time-picker"; export type * from "./time-picker"; + +export * from "./mark"; +export type * from "./mark"; diff --git a/src/components/ui/mark/Mark.stories.tsx b/src/components/ui/mark/Mark.stories.tsx new file mode 100644 index 0000000..0241768 --- /dev/null +++ b/src/components/ui/mark/Mark.stories.tsx @@ -0,0 +1,389 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useState, type ReactElement } from "react"; +import { Search } from "lucide-react"; + +import { Mark, HighlightedText } from "./mark"; +import { + markSizeIds, + markStyleIds, + markVariantIds, + type MarkVariant, +} from "./mark-variants"; + +const meta = { + title: "Components/Mark", + component: Mark, + parameters: { + layout: "centered", + docs: { + description: { + component: + "Semantic text highlighter rendered as a `` element. Use directly to emphasize spans of inline text, or via the companion `HighlightedText` component to auto-highlight every occurrence of a search term inside a string. Distinct from `Badge` and `Chip` — `Mark` is meant to live inline inside a sentence (search results, diffs, docs) rather than as a free-standing pill.", + }, + }, + }, + tags: ["autodocs"], + argTypes: { + variant: { + options: markVariantIds, + control: { type: "select" }, + }, + markStyle: { + options: markStyleIds, + control: { type: "radio" }, + }, + size: { + options: markSizeIds, + control: { type: "radio" }, + }, + rounded: { control: { type: "boolean" } }, + animate: { control: { type: "boolean" } }, + children: { control: { type: "text" } }, + }, + args: { + children: "highlighted", + variant: "default", + markStyle: "solid", + size: "default", + rounded: false, + animate: false, + }, + render: (args): ReactElement => ( +

+ The most important word in this sentence is , + and the rest is just context. +

+ ), +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const SolidVariants: Story = { + name: "Style: Solid — all color variants", + render: (): ReactElement => ( +
+ {markVariantIds.map((variant) => ( +

+ + {variant} + + The schema migration was{" "} + + executed successfully + {" "} + at 09:42 UTC. +

+ ))} +
+ ), +}; + +export const SoftVariants: Story = { + name: "Style: Soft — all color variants", + render: (): ReactElement => ( +
+ {markVariantIds.map((variant) => ( +

+ + {variant} + + The schema migration was{" "} + + executed successfully + {" "} + at 09:42 UTC. +

+ ))} +
+ ), +}; + +export const UnderlineVariants: Story = { + name: "Style: Underline — all color variants", + render: (): ReactElement => ( +
+ {markVariantIds.map((variant) => ( +

+ + {variant} + + The schema migration was{" "} + + executed successfully + {" "} + at 09:42 UTC. +

+ ))} +
+ ), +}; + +export const OutlineVariants: Story = { + name: "Style: Outline — all color variants", + render: (): ReactElement => ( +
+ {markVariantIds.map((variant) => ( +

+ + {variant} + + The schema migration was{" "} + + executed successfully + {" "} + at 09:42 UTC. +

+ ))} +
+ ), +}; + +export const AllSizes: Story = { + render: (): ReactElement => ( +
+ {markSizeIds.map((size) => ( +

+ + {size} + + The word {size} uses the {size} preset. +

+ ))} +
+ ), +}; + +export const Rounded: Story = { + render: (): ReactElement => ( +
+

+ Default corners:{" "} + + primary soft + +

+

+ Rounded corners:{" "} + + primary soft (rounded) + +

+
+ ), +}; + +function AnimatedRevealExample(): ReactElement { + const [tick, setTick] = useState(0); + return ( +
+

+ When the budget review wrapped, the team was{" "} + + relieved and elated + + . +

+ +

+ Re-mounts the paragraph so the CSS reveal restarts. The{" "} + animate prop is most useful when highlighting the first + time content streams in. +

+
+ ); +} + +export const AnimatedReveal: Story = { + name: "Animated highlight reveal", + render: (): ReactElement => , +}; + +export const InsideHeadings: Story = { + render: (): ReactElement => ( +
+

+ Ship safer with{" "} + + schema-aware + {" "} + migrations +

+

+ Catch{" "} + + breaking changes + {" "} + before they reach production +

+

+ Mark inherits font size from its parent, so the highlight scales + automatically across heading levels. +

+
+ ), +}; + +// ============================================================================= +// HighlightedText — auto-wrap matches inside a longer text +// ============================================================================= + +function SearchHighlightExample(): ReactElement { + const corpus = + "Schema migrations are the lifeblood of evolving systems. A schema change " + + "should be tracked, reviewed, and reversible. When teams skip schema review, " + + "production incidents follow."; + + const [query, setQuery] = useState("schema"); + const [caseSensitive, setCaseSensitive] = useState(false); + const [wholeWord, setWholeWord] = useState(false); + + return ( +
+
+ + setQuery(event.target.value)} + placeholder="Search…" + className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" + /> +
+
+ + +
+

+ +

+
+ ); +} + +export const SearchHighlight: StoryObj = { + name: "HighlightedText — search results", + render: (): ReactElement => , +}; + +export const MultipleTokens: StoryObj = { + name: "HighlightedText — multiple tokens, mixed variants", + render: (): ReactElement => { + const text = + "On Tuesday, the deploy succeeded but the canary failed health checks. " + + "The rollback completed in 42 seconds with no data loss."; + return ( +
+

+ +

+

+ +

+

+ Pass an array of tokens to highlight every one — the regex is + built and memoized internally. +

+
+ ); + }, +}; + +export const RegExpMatch: StoryObj = { + name: "HighlightedText — RegExp pattern", + render: (): ReactElement => ( +

+ +

+ ), +}; + +function VariantMatrixExample(): ReactElement { + const [activeVariant, setActiveVariant] = useState("primary"); + return ( +
+
+ {markVariantIds.map((variant) => ( + + ))} +
+

+ {markStyleIds.map((style, index) => ( + + {index > 0 ? " " : null} + + {style} + + {index < markStyleIds.length - 1 ? "," : "."} + + ))} +

+

+ Pick a color above to preview every markStyle with that + variant. +

+
+ ); +} + +export const InteractiveMatrix: StoryObj = { + name: "Interactive variant × style matrix", + render: (): ReactElement => , +}; diff --git a/src/components/ui/mark/index.ts b/src/components/ui/mark/index.ts new file mode 100644 index 0000000..cf692d1 --- /dev/null +++ b/src/components/ui/mark/index.ts @@ -0,0 +1,13 @@ +export { + Mark, + Mark as default, + HighlightedText, + markVariants, +} from "./mark"; +export type { MarkProps, HighlightedTextProps } from "./mark"; +export { + markVariantIds, + markStyleIds, + markSizeIds, +} from "./mark-variants"; +export type { MarkVariant, MarkStyle, MarkSize } from "./mark-variants"; diff --git a/src/components/ui/mark/mark-variants.ts b/src/components/ui/mark/mark-variants.ts new file mode 100644 index 0000000..c262170 --- /dev/null +++ b/src/components/ui/mark/mark-variants.ts @@ -0,0 +1,26 @@ +export const markVariantIds = [ + "default", + "primary", + "secondary", + "info", + "success", + "warning", + "destructive", + "accent", +] as const satisfies readonly string[]; +export type MarkVariant = (typeof markVariantIds)[number]; + +export const markStyleIds = [ + "solid", + "soft", + "underline", + "outline", +] as const satisfies readonly string[]; +export type MarkStyle = (typeof markStyleIds)[number]; + +export const markSizeIds = [ + "sm", + "default", + "lg", +] as const satisfies readonly string[]; +export type MarkSize = (typeof markSizeIds)[number]; diff --git a/src/components/ui/mark/mark.tsx b/src/components/ui/mark/mark.tsx new file mode 100644 index 0000000..3a3af90 --- /dev/null +++ b/src/components/ui/mark/mark.tsx @@ -0,0 +1,407 @@ +"use client"; + +import { cva, type VariantProps } from "class-variance-authority"; +import { + forwardRef, + useMemo, + type ComponentProps, + type ReactElement, + type ReactNode, + type Ref, +} from "react"; + +import { cn } from "@/lib/utils"; +import { + markSizeIds, + markStyleIds, + markVariantIds, + type MarkSize, + type MarkStyle, + type MarkVariant, +} from "./mark-variants"; + +const markVariants = cva( + "inline rounded-sm font-[inherit] decoration-skip-ink-auto transition-colors", + { + variants: { + variant: { + // The classic highlighter look — explicit yellow, intentionally not themed. + default: "", + primary: "", + secondary: "", + info: "", + success: "", + warning: "", + destructive: "", + accent: "", + } satisfies Record, + markStyle: { + solid: "", + soft: "", + underline: "bg-transparent border-b-2", + outline: "bg-transparent border", + } satisfies Record, + size: { + sm: "px-0.5 text-[0.95em]", + default: "px-1 text-[1em]", + lg: "px-1.5 py-0.5 text-[1.05em] font-medium", + } satisfies Record, + rounded: { + true: "rounded-md", + false: "rounded-sm", + }, + animate: { + // Uses tailwindcss-animate's `animate-in` enter utilities, which are + // registered globally via @schemavaults/theme. The mark fades and + // slides in from a slight left offset — a natural reveal that does + // not depend on custom keyframes. + true: "animate-in fade-in slide-in-from-left-1 duration-500", + false: "", + }, + }, + compoundVariants: [ + // === solid: explicit colors (highlighter feel) === + { + markStyle: "solid", + variant: "default", + class: "bg-yellow-200 text-yellow-950 dark:bg-yellow-400/80 dark:text-yellow-950", + }, + { + markStyle: "solid", + variant: "primary", + class: "bg-primary text-primary-foreground", + }, + { + markStyle: "solid", + variant: "secondary", + class: "bg-secondary text-secondary-foreground", + }, + { + markStyle: "solid", + variant: "info", + class: "bg-sky-300 text-sky-950 dark:bg-sky-400/80 dark:text-sky-950", + }, + { + markStyle: "solid", + variant: "success", + class: "bg-emerald-300 text-emerald-950 dark:bg-emerald-400/80 dark:text-emerald-950", + }, + { + markStyle: "solid", + variant: "warning", + class: "bg-warning text-warning-foreground", + }, + { + markStyle: "solid", + variant: "destructive", + class: "bg-destructive text-destructive-foreground", + }, + { + markStyle: "solid", + variant: "accent", + class: "bg-accent text-accent-foreground", + }, + + // === soft: subtle tinted background === + { + markStyle: "soft", + variant: "default", + class: "bg-yellow-200/50 text-foreground dark:bg-yellow-400/20 dark:text-yellow-100", + }, + { + markStyle: "soft", + variant: "primary", + class: "bg-primary/15 text-foreground dark:text-primary-foreground/90", + }, + { + markStyle: "soft", + variant: "secondary", + class: "bg-secondary/60 text-secondary-foreground", + }, + { + markStyle: "soft", + variant: "info", + class: "bg-sky-300/30 text-foreground dark:text-sky-100", + }, + { + markStyle: "soft", + variant: "success", + class: "bg-emerald-300/30 text-foreground dark:text-emerald-100", + }, + { + markStyle: "soft", + variant: "warning", + class: "bg-warning/25 text-foreground", + }, + { + markStyle: "soft", + variant: "destructive", + class: "bg-destructive/15 text-destructive dark:text-destructive-foreground/90", + }, + { + markStyle: "soft", + variant: "accent", + class: "bg-accent/60 text-accent-foreground", + }, + + // === underline: colored bottom border only === + { + markStyle: "underline", + variant: "default", + class: "border-yellow-400 text-foreground", + }, + { + markStyle: "underline", + variant: "primary", + class: "border-primary text-foreground", + }, + { + markStyle: "underline", + variant: "secondary", + class: "border-muted-foreground/60 text-foreground", + }, + { + markStyle: "underline", + variant: "info", + class: "border-sky-400 text-foreground", + }, + { + markStyle: "underline", + variant: "success", + class: "border-emerald-500 text-foreground", + }, + { + markStyle: "underline", + variant: "warning", + class: "border-warning text-foreground", + }, + { + markStyle: "underline", + variant: "destructive", + class: "border-destructive text-foreground", + }, + { + markStyle: "underline", + variant: "accent", + class: "border-accent-foreground/60 text-foreground", + }, + + // === outline: colored border only === + { + markStyle: "outline", + variant: "default", + class: "border-yellow-400 text-yellow-700 dark:text-yellow-300", + }, + { + markStyle: "outline", + variant: "primary", + class: "border-primary text-foreground", + }, + { + markStyle: "outline", + variant: "secondary", + class: "border-muted-foreground/40 text-muted-foreground", + }, + { + markStyle: "outline", + variant: "info", + class: "border-sky-400 text-sky-700 dark:text-sky-300", + }, + { + markStyle: "outline", + variant: "success", + class: "border-emerald-500 text-emerald-700 dark:text-emerald-300", + }, + { + markStyle: "outline", + variant: "warning", + class: "border-warning text-warning-foreground", + }, + { + markStyle: "outline", + variant: "destructive", + class: "border-destructive text-destructive", + }, + { + markStyle: "outline", + variant: "accent", + class: "border-accent text-accent-foreground", + }, + + ], + defaultVariants: { + variant: "default", + markStyle: "solid", + size: "default", + rounded: false, + animate: false, + }, + }, +); + +export interface MarkProps + extends Omit, "style">, + VariantProps { + /** + * Override the rendered tag. Defaults to the semantic `` element. + * Use `"span"` if you need decorative-only highlighting without the + * implicit ARIA `mark` semantics. + */ + as?: "mark" | "span"; + /** Pass-through `style` prop (preserved so consumers can layer custom CSS). */ + style?: ComponentProps<"mark">["style"]; +} + +function MarkImpl( + { + className, + variant, + markStyle, + size, + rounded, + animate, + as = "mark", + children, + ...props + }: MarkProps, + ref: Ref, +): ReactElement { + const Comp = as; + return ( + } + data-slot="mark" + data-variant={variant ?? "default"} + data-mark-style={markStyle ?? "solid"} + className={cn( + markVariants({ variant, markStyle, size, rounded, animate }), + className, + )} + {...props} + > + {children} + + ); +} + +export const Mark = forwardRef(MarkImpl); +Mark.displayName = "Mark"; + +// ============================================================================= +// HighlightedText — auto-wrap matches of `match` inside `text` with +// ============================================================================= + +export interface HighlightedTextProps + extends Omit, "children">, + Pick< + MarkProps, + "variant" | "markStyle" | "size" | "rounded" | "animate" + > { + /** The full text to render. */ + text: string; + /** A string token (or RegExp) to highlight inside `text`. */ + match: string | RegExp | readonly string[]; + /** When `match` is a string, control case sensitivity. Defaults to false. */ + caseSensitive?: boolean; + /** When true, only highlight whole-word matches. Defaults to false. */ + wholeWord?: boolean; + /** Optional class applied to each rendered . */ + markClassName?: string; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function buildHighlightRegExp( + match: HighlightedTextProps["match"], + caseSensitive: boolean, + wholeWord: boolean, +): RegExp | null { + if (match instanceof RegExp) { + // Ensure we have the global flag so we can iterate every match. + const flags = match.flags.includes("g") ? match.flags : `${match.flags}g`; + return new RegExp(match.source, flags); + } + const tokens = (Array.isArray(match) ? match : [match]) + .map((token) => String(token)) + .filter((token) => token.length > 0) + .map(escapeRegExp); + if (tokens.length === 0) return null; + const body = tokens.join("|"); + const pattern = wholeWord ? `\\b(?:${body})\\b` : `(?:${body})`; + return new RegExp(pattern, caseSensitive ? "g" : "gi"); +} + +export function HighlightedText({ + text, + match, + caseSensitive = false, + wholeWord = false, + variant, + markStyle, + size, + rounded, + animate, + markClassName, + className, + ...props +}: HighlightedTextProps): ReactElement { + const segments = useMemo(() => { + const regex = buildHighlightRegExp(match, caseSensitive, wholeWord); + if (regex === null) return [text]; + + const parts: ReactNode[] = []; + let lastIndex = 0; + let key = 0; + // Defensive guard — RegExp.exec can loop forever on zero-width matches. + for (const result of text.matchAll(regex)) { + if (result.index === undefined) continue; + if (result[0].length === 0) continue; + if (result.index > lastIndex) { + parts.push(text.slice(lastIndex, result.index)); + } + parts.push( + + {result[0]} + , + ); + lastIndex = result.index + result[0].length; + } + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)); + } + return parts; + }, [ + text, + match, + caseSensitive, + wholeWord, + variant, + markStyle, + size, + rounded, + animate, + markClassName, + ]); + + return ( + + {segments} + + ); +} +HighlightedText.displayName = "HighlightedText"; + +export { markVariants, markVariantIds, markStyleIds, markSizeIds }; +export type { MarkVariant, MarkStyle, MarkSize }; + +export default Mark; From 43ef8794e014b22af9d9fd455aa3788d16181358 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 15:09:46 +0000 Subject: [PATCH 2/2] 0.72.1 - Drop forwardRef from Mark in favor of React 19 ref-as-prop --- package.json | 2 +- src/components/ui/mark/mark.tsx | 35 +++++++++++++++------------------ 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/package.json b/package.json index b065c2c..24d82ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@schemavaults/ui", - "version": "0.72.0", + "version": "0.72.1", "private": false, "license": "UNLICENSED", "description": "React.js UI components for SchemaVaults frontend applications", diff --git a/src/components/ui/mark/mark.tsx b/src/components/ui/mark/mark.tsx index 3a3af90..16e6219 100644 --- a/src/components/ui/mark/mark.tsx +++ b/src/components/ui/mark/mark.tsx @@ -2,12 +2,11 @@ import { cva, type VariantProps } from "class-variance-authority"; import { - forwardRef, useMemo, type ComponentProps, + type Ref, type ReactElement, type ReactNode, - type Ref, } from "react"; import { cn } from "@/lib/utils"; @@ -240,7 +239,7 @@ const markVariants = cva( ); export interface MarkProps - extends Omit, "style">, + extends Omit, "style" | "ref">, VariantProps { /** * Override the rendered tag. Defaults to the semantic `` element. @@ -250,22 +249,22 @@ export interface MarkProps as?: "mark" | "span"; /** Pass-through `style` prop (preserved so consumers can layer custom CSS). */ style?: ComponentProps<"mark">["style"]; + /** React 19 ref-as-prop. */ + ref?: Ref; } -function MarkImpl( - { - className, - variant, - markStyle, - size, - rounded, - animate, - as = "mark", - children, - ...props - }: MarkProps, - ref: Ref, -): ReactElement { +export function Mark({ + className, + variant, + markStyle, + size, + rounded, + animate, + as = "mark", + children, + ref, + ...props +}: MarkProps): ReactElement { const Comp = as; return ( ); } - -export const Mark = forwardRef(MarkImpl); Mark.displayName = "Mark"; // =============================================================================