From 4024fbed08cac073f077a517f6c354d84d27e2e0 Mon Sep 17 00:00:00 2001 From: Artem Belik Date: Thu, 20 Aug 2026 13:13:30 +0300 Subject: [PATCH 1/7] feat(components): added CodeBlock component (#DS-5224) --- package.json | 1 + packages/components/package.json | 8 + packages/components/src/code-block.ts | 1 + .../src/components/CodeBlock/CodeBlock.mdx | 166 ++++ .../components/CodeBlock/CodeBlock.module.css | 528 +++++++++++ .../CodeBlock/CodeBlock.stories.tsx | 515 ++++++++++ .../components/CodeBlock/CodeBlock.test.tsx | 890 ++++++++++++++++++ .../src/components/CodeBlock/CodeBlock.tsx | 354 +++++++ .../components/CodeBlockActionBar.tsx | 167 ++++ .../CodeBlock/components/CodeBlockTabs.tsx | 89 ++ .../components/CodeBlock/components/index.ts | 2 + .../src/components/CodeBlock/context.tsx | 67 ++ .../src/components/CodeBlock/hooks/index.ts | 2 + .../CodeBlock/hooks/useHighlightedCode.ts | 237 +++++ .../CodeBlock/hooks/useOverflowShadow.ts | 15 + .../src/components/CodeBlock/index.ts | 7 + .../src/components/CodeBlock/intl.json | 24 + .../src/components/CodeBlock/types.ts | 150 +++ .../CodeBlock/utils/lineNumbers.test.ts | 76 ++ .../components/CodeBlock/utils/lineNumbers.ts | 107 +++ .../src/components/Tabs/Tabs.test.tsx | 31 + .../Tabs/components/TabPanel/TabPanel.tsx | 43 +- packages/components/vite.config.ts | 1 + pnpm-lock.yaml | 12 + tools/api-extractor/config.json | 1 + .../components/CodeBlock.api.md | 98 ++ 26 files changed, 3574 insertions(+), 18 deletions(-) create mode 100644 packages/components/src/code-block.ts create mode 100644 packages/components/src/components/CodeBlock/CodeBlock.mdx create mode 100644 packages/components/src/components/CodeBlock/CodeBlock.module.css create mode 100644 packages/components/src/components/CodeBlock/CodeBlock.stories.tsx create mode 100644 packages/components/src/components/CodeBlock/CodeBlock.test.tsx create mode 100644 packages/components/src/components/CodeBlock/CodeBlock.tsx create mode 100644 packages/components/src/components/CodeBlock/components/CodeBlockActionBar.tsx create mode 100644 packages/components/src/components/CodeBlock/components/CodeBlockTabs.tsx create mode 100644 packages/components/src/components/CodeBlock/components/index.ts create mode 100644 packages/components/src/components/CodeBlock/context.tsx create mode 100644 packages/components/src/components/CodeBlock/hooks/index.ts create mode 100644 packages/components/src/components/CodeBlock/hooks/useHighlightedCode.ts create mode 100644 packages/components/src/components/CodeBlock/hooks/useOverflowShadow.ts create mode 100644 packages/components/src/components/CodeBlock/index.ts create mode 100644 packages/components/src/components/CodeBlock/intl.json create mode 100644 packages/components/src/components/CodeBlock/types.ts create mode 100644 packages/components/src/components/CodeBlock/utils/lineNumbers.test.ts create mode 100644 packages/components/src/components/CodeBlock/utils/lineNumbers.ts create mode 100644 tools/public_api_guard/components/CodeBlock.api.md diff --git a/package.json b/package.json index 1e57c564f..7cafe5f73 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "eslint-plugin-react-hooks": "^7.1.1", "fs-extra": "^11.3.0", "globals": "^17.9.0", + "highlight.js": "^11.11.1", "husky": "^9.1.6", "jsdom": "^25.0.1", "lightningcss": "^1.29.3", diff --git a/packages/components/package.json b/packages/components/package.json index 9144992a5..ffd0f582f 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -20,6 +20,10 @@ "types": "./dist/markdown.d.ts", "default": "./dist/markdown.js" }, + "./code-block": { + "types": "./dist/code-block.d.ts", + "default": "./dist/code-block.js" + }, "./style.css": "./dist/style.css" }, "type": "module", @@ -46,6 +50,7 @@ "peerDependencies": { "@koobiq/design-tokens": "^3.17.2", "@koobiq/react-icons": "^12.1.1", + "highlight.js": "^11.0.0", "react": "18.x || 19.x", "react-dom": "18.x || 19.x", "react-markdown": "^10.1.0", @@ -57,6 +62,9 @@ }, "remark-gfm": { "optional": true + }, + "highlight.js": { + "optional": true } } } diff --git a/packages/components/src/code-block.ts b/packages/components/src/code-block.ts new file mode 100644 index 000000000..694fce4b3 --- /dev/null +++ b/packages/components/src/code-block.ts @@ -0,0 +1 @@ +export * from './components/CodeBlock'; diff --git a/packages/components/src/components/CodeBlock/CodeBlock.mdx b/packages/components/src/components/CodeBlock/CodeBlock.mdx new file mode 100644 index 000000000..20cdced24 --- /dev/null +++ b/packages/components/src/components/CodeBlock/CodeBlock.mdx @@ -0,0 +1,166 @@ +import { + Alert, + Meta, + Story, + Status, +} from '../../../../../.storybook/components'; + +import * as Stories from './CodeBlock.stories'; + + + +# CodeBlock + + + +`CodeBlock` is a component that displays reformatted text content with syntax highlighting. + + + For the component to work, the + [`highlight.js@^11`](https://github.com/highlightjs/highlight.js/tree/stable-11) peer dependency is required: + +```bash +npm install highlight.js@^11 +``` + + + +### Configuring highlight.js + +By default, `CodeBlock` lazily loads the full `highlight.js` bundle with all languages (~1 MB). To +reduce bundle size, wrap the app (or a part of it) with `CodeBlockHighlightConfigProvider` and specify +only the languages you need: + +```tsx + import('highlight.js/lib/core'), + languages: { + typescript: () => import('highlight.js/lib/languages/typescript'), + css: () => import('highlight.js/lib/languages/css'), + html: () => import('highlight.js/lib/languages/xml'), + }, + fallbackLanguage: 'plaintext', + }} +> + + +``` + +Languages are loaded lazily on first use. Note that `html` is an alias of `xml` in `highlight.js`, so +register `xml` to enable HTML syntax highlighting. Pass a stable (module-level or memoized) `config` +object — `CodeBlock` reuses one shared `highlight.js` instance for the app, keyed by that reference. + +See the full list of supported languages in the +[highlight.js documentation](https://highlightjs.readthedocs.io/en/stable/supported-languages.html). + +### Line numbers + +Numbering lines is useful for referencing a specific location in the document. Line numbers are +disabled by default and can be enabled using the `hasLineNumbers` prop. + + + +### Header with title + +In the header, use `renderTabLabel` to render a document name or the detected syntax. A shadow appears +under the header when the code content is scrolled. + + + +### Changing the block height + +The block can expand to fill the available screen area if reviewing the code is one of the main tasks +in the interface. When screen space is limited or the code only supplements the main content, use +`maxHeight` to constrain it. + +For compactness, use `viewAll` to show a portion of the document with the option to reveal everything. +The block increases in height so all the code is visible, avoiding double scrolling. The wrap setting +is preserved when collapsing and expanding the block. + + + +### Soft line wrap + +By default, lines don't wrap. Pre-configure the mode using `softWrap`, or let users control it with a +toggle button in the action panel by enabling `canToggleSoftWrap`. + +The icon in the button represents the future state after activation and changes after the wrap mode +is toggled. + + + +### Displaying multiple documents + +Multiple documents are placed in the block as tabs. The tab title is formed from the `filename` field. +Tabs can be hidden using `hideTabs`, and the active tab can be set using `activeFileIndex` or +`defaultActiveFileIndex`. + +Tabs are hidden automatically for a single document with an empty `filename`. + + + +The tab header is displayed without a shadow by default. If content extends beyond the available area, +a shadow appears under the header when the code content is scrolled. + + + +### Visual styling + +Two visual styles are available: an outlined block (default), or a filled block with a background that +contrasts with the page. The filled style prevents the block from getting lost on a screen with varied +content and is configured using `isFilled`. + + + +### Without borders + +When the code block should fill an entire container or screen, use `hideBorder` to remove its border. + + + +### Action panel + +The action panel is located in the upper-right corner of the block and remains fixed while scrolling. +The component configuration determines which actions are available. + +The panel is always visible when tabs are shown and on touch devices. When tabs are hidden, it appears +on hover or focus on other devices. `alwaysShowActionBar` keeps the panel visible regardless of tabs or +hover and is disabled by default. + +```tsx + +``` + +#### Changing wrap mode + +The user can toggle the wrap mode using a button. This option is disabled by default and can be enabled +using `canToggleSoftWrap`. + +```tsx + +``` + +#### Downloading the document + +The user can download the document using an action that is disabled by default and can be enabled with +`canDownload`. The file name and extension are taken from `filename`; when it is empty, +`fallbackFileName` is used instead (`code` by default). + +```tsx + +``` + +#### Copying the document text + +The user can copy the document text by default. Use `hideCopyButton` to hide this action. + +```tsx + +``` + +#### Opening the document in an external system + +The address is taken from the `link` field. The link opens in a new tab. + + diff --git a/packages/components/src/components/CodeBlock/CodeBlock.module.css b/packages/components/src/components/CodeBlock/CodeBlock.module.css new file mode 100644 index 000000000..f1e629c5f --- /dev/null +++ b/packages/components/src/components/CodeBlock/CodeBlock.module.css @@ -0,0 +1,528 @@ +.base { + display: flex; + flex-direction: column; + position: relative; + box-sizing: border-box; + border-style: solid; + border-width: var(--kbq-size-border-width); + border-radius: var(--kbq-size-m); + border-color: var(--kbq-code-block-outline-container-border-color); + background: var(--kbq-code-block-outline-container-background); + hyphens: none; + + &[data-border-hidden] { + border-color: transparent !important; + } + + &[data-filled] { + border-color: transparent !important; + background: var(--kbq-code-block-filled-container-background); + + .header { + background: var(--kbq-code-block-filled-header-background); + } + + &[data-hide-tabs] .actionbarButtonStack { + background: var(--kbq-code-block-filled-actionbar-background); + border-color: var( + --kbq-code-block-filled-actionbar-border-color, + var(--kbq-line-contrast-less) + ); + } + + .viewAllWrapper { + background: var( + --kbq-code-block-filled-collapse-button-expand-background + ); + } + + .viewAll[data-state='expanded'] { + background: var(--kbq-code-block-filled-collapse-expanded-background); + } + + .viewAll[data-state='collapsed'] { + background: var(--kbq-code-block-filled-collapse-collapsed-background); + } + } + + &:not([data-filled]) { + .header { + background: var(--kbq-code-block-outline-header-background); + } + + &[data-hide-tabs] .actionbarButtonStack { + background: var(--kbq-code-block-outline-actionbar-background); + border-color: var( + --kbq-code-block-outline-actionbar-border-color, + var(--kbq-line-contrast-less) + ); + } + + .viewAllWrapper { + background: var( + --kbq-code-block-outline-collapse-button-expand-background + ); + } + + .viewAll[data-state='expanded'] { + background: var(--kbq-code-block-outline-collapse-expanded-background); + } + + .viewAll[data-state='collapsed'] { + background: var(--kbq-code-block-outline-collapse-collapsed-background); + } + } + + &:not([data-hide-tabs]) { + .header { + border-radius: var(--kbq-size-m) var(--kbq-size-m) 0 0; + } + + .main { + border-radius: 0 0 var(--kbq-size-m) var(--kbq-size-m); + } + + > .actionbar { + position: absolute; + inset-block-start: var(--kbq-size-s); + inset-inline-end: var(--kbq-size-m); + } + } + + &[data-hide-tabs] { + .header { + padding: 0; + } + + .actionbar { + display: none; + position: absolute; + inset-block-start: 0; + inset-inline-end: 0; + margin-block: var(--kbq-size-s); + margin-inline: 0 var(--kbq-size-s); + z-index: var(--kbq-layer-absolute); + } + + .actionbarButtonStack { + border-width: var(--kbq-size-border-width); + border-style: solid; + border-radius: calc(var(--kbq-size-s) + 1px); + } + + .main { + position: relative; + + .code { + padding: 14px var(--kbq-size-xl); + } + } + + /* + * The actionbar is only revealed on hover/focus — always shown when there's no hover input (touch). + * Hovering the whole block, not just the (zero-height, once tabs are hidden) header — the header has + * no other content to give it a hoverable box. + */ + &:hover .actionbar, + &:focus-within .actionbar, + &[data-always-show-action-bar] .actionbar { + display: flex; + } + + @media (hover: none) { + .actionbar { + display: flex; + } + } + } +} + +.header { + display: flex; + align-items: center; + padding: var(--kbq-size-s) var(--kbq-size-m); + padding-inline-end: calc( + var(--kbq-size-m) + var(--code-block-actionbar-inline-size, 0px) + ); + transition: box-shadow var(--kbq-transition-default); +} + +.headerScrolled { + box-shadow: var(--kbq-code-block-outline-header-scroll-shadow); + + .base[data-filled] & { + box-shadow: var(--kbq-code-block-filled-header-scroll-shadow); + } +} + +.tabsContainer { + gap: 0; + flex-grow: 1; + min-block-size: 0; + inline-size: 100%; +} + +.main { + flex-grow: 1; + min-block-size: 0; + overflow: auto; + border-radius: var(--kbq-size-m); + + &:focus-visible { + outline: 2px solid var(--kbq-states-line-focus-theme); + outline-offset: -2px; + } +} + +.base:has(.viewAll):not([data-view-all]) .main { + overflow: hidden; +} + +.pre { + margin: 0; + flex-grow: 1; +} + +.code { + display: block; + tab-size: 4; + padding: var(--kbq-size-xxs) var(--kbq-size-xl) var(--kbq-size-l) + var(--kbq-size-xl); + color: var(--kbq-foreground-contrast); + + &:focus-visible { + outline: none; + } + + .base:has(.viewAll) & { + padding-block-end: var(--kbq-size-3xl) !important; + } + + .base[data-soft-wrap] & { + white-space: pre-wrap; + overflow-wrap: anywhere; + } + + .base:not([data-line-numbers]) & :global(.hljs-ln-numbers) { + display: none; + } + + & :global(.hljs-ln-line) { + padding: 0; + } + + & :global(.hljs-ln-numbers) { + padding-inline-end: var(--kbq-size-m); + vertical-align: baseline; + text-align: end; + color: var(--kbq-code-block-hljs-line-numbers-color); + user-select: none; + } + + & :global(.hljs-ln) { + border-collapse: collapse; + border-spacing: 0; + } + + & :global(.hljs-ln-n)::before { + white-space: nowrap; + content: attr(data-line-number); + } +} + +.viewAll { + display: flex; + justify-content: center; + inline-size: 100%; + position: absolute; + inset-block-end: calc(var(--kbq-size-border-width) * -1); + border-radius: var(--kbq-size-m); + + &[data-state='collapsed'] { + padding-block: var(--kbq-size-3xl) var(--kbq-size-l); + } + + &[data-state='expanded'] { + padding-block-start: 0; + margin-block-end: var(--kbq-size-l); + } +} + +.viewAllWrapper { + border-radius: var(--kbq-size-s); + opacity: 0.9; +} + +.actionbar { + display: flex; + align-items: center; + justify-content: end; + z-index: 1; +} + +.actionbarButtonStack { + display: flex; + gap: var(--kbq-size-3xs); +} + +/* stylelint-disable selector-class-pattern */ +.code :global(.hljs-addition) { + background-color: var(--kbq-code-block-hljs-addition-background); + color: var(--kbq-code-block-hljs-addition-color); +} + +.code :global(.hljs-attr) { + background-color: var(--kbq-code-block-hljs-attr-background); + color: var(--kbq-code-block-hljs-attr-color); +} + +.code :global(.hljs-attribute) { + background-color: var(--kbq-code-block-hljs-attribute-background); + color: var(--kbq-code-block-hljs-attribute-color); +} + +.code :global(.hljs-built_in) { + background-color: var(--kbq-code-block-hljs-built_in-background); + color: var(--kbq-code-block-hljs-built_in-color); +} + +.code :global(.hljs-bullet) { + background-color: var(--kbq-code-block-hljs-bullet-background); + color: var(--kbq-code-block-hljs-bullet-color); +} + +.code :global(.hljs-char.escape_) { + background-color: var(--kbq-code-block-hljs-char-escape-background); + color: var(--kbq-code-block-hljs-char-escape-color); +} + +.code :global(.hljs-class) { + background-color: var(--kbq-code-block-hljs-class-background); + color: var(--kbq-code-block-hljs-class-color); +} + +.code :global(.hljs-code) { + background-color: var(--kbq-code-block-hljs-code-background); + color: var(--kbq-code-block-hljs-code-color); +} + +.code :global(.hljs-comment) { + background-color: var(--kbq-code-block-hljs-comment-background); + color: var(--kbq-code-block-hljs-comment-color); +} + +.code :global(.hljs-deletion) { + background-color: var(--kbq-code-block-hljs-deletion-background); + color: var(--kbq-code-block-hljs-deletion-color); +} + +.code :global(.hljs-doctag) { + background-color: var(--kbq-code-block-hljs-doctag-background); + color: var(--kbq-code-block-hljs-doctag-color); +} + +.code :global(.hljs-emphasis) { + background-color: var(--kbq-code-block-hljs-emphasis-background); + color: var(--kbq-code-block-hljs-emphasis-color); + font-style: italic; +} + +.code :global(.hljs-formula) { + background-color: var(--kbq-code-block-hljs-formula-background); + color: var(--kbq-code-block-hljs-formula-color); +} + +.code :global(.hljs-function) { + background-color: var(--kbq-code-block-hljs-function-background); + color: var(--kbq-code-block-hljs-function-color); +} + +.code :global(.hljs-keyword) { + background-color: var(--kbq-code-block-hljs-keyword-background); + color: var(--kbq-code-block-hljs-keyword-color); +} + +.code :global(.hljs-link) { + background-color: var(--kbq-code-block-hljs-link-background); + color: var(--kbq-code-block-hljs-link-color); +} + +.code :global(.hljs-literal) { + background-color: var(--kbq-code-block-hljs-literal-background); + color: var(--kbq-code-block-hljs-literal-color); +} + +.code :global(.hljs-meta) { + background-color: var(--kbq-code-block-hljs-meta-background); + color: var(--kbq-code-block-hljs-meta-color); +} + +.code :global(.hljs-meta-keyword) { + background-color: var(--kbq-code-block-hljs-meta-keyword-background); + color: var(--kbq-code-block-hljs-meta-keyword-color); +} + +.code :global(.hljs-meta-string) { + background-color: var(--kbq-code-block-hljs-meta-string-background); + color: var(--kbq-code-block-hljs-meta-string-color); +} + +.code :global(.hljs-meta-prompt), +.code :global(.hljs-meta .hljs-prompt) { + background-color: var(--kbq-code-block-hljs-meta-prompt-background); + color: var(--kbq-code-block-hljs-meta-prompt-color); +} + +.code :global(.hljs-name) { + background-color: var(--kbq-code-block-hljs-name-background); + color: var(--kbq-code-block-hljs-name-color); +} + +.code :global(.hljs-number) { + background-color: var(--kbq-code-block-hljs-number-background); + color: var(--kbq-code-block-hljs-number-color); +} + +.code :global(.hljs-operator) { + background-color: var(--kbq-code-block-hljs-operator-background); + color: var(--kbq-code-block-hljs-operator-color); +} + +.code :global(.hljs-params) { + background-color: var(--kbq-code-block-hljs-params-background); + color: var(--kbq-code-block-hljs-params-color); +} + +.code :global(.hljs-property) { + background-color: var(--kbq-code-block-hljs-property-background); + color: var(--kbq-code-block-hljs-property-color); +} + +.code :global(.hljs-punctuation) { + background-color: var(--kbq-code-block-hljs-punctuation-background); + color: var(--kbq-code-block-hljs-punctuation-color); +} + +.code :global(.hljs-quote) { + background-color: var(--kbq-code-block-hljs-quote-background); + color: var(--kbq-code-block-hljs-quote-color); +} + +.code :global(.hljs-regexp) { + background-color: var(--kbq-code-block-hljs-regexp-background); + color: var(--kbq-code-block-hljs-regexp-color); +} + +.code :global(.hljs-section) { + background-color: var(--kbq-code-block-hljs-section-background); + color: var(--kbq-code-block-hljs-section-color); +} + +.code :global(.hljs-selector-attr) { + background-color: var(--kbq-code-block-hljs-selector-attr-background); + color: var(--kbq-code-block-hljs-selector-attr-color); +} + +.code :global(.hljs-selector-class) { + background-color: var(--kbq-code-block-hljs-selector-class-background); + color: var(--kbq-code-block-hljs-selector-class-color); +} + +.code :global(.hljs-selector-id) { + background-color: var(--kbq-code-block-hljs-selector-id-background); + color: var(--kbq-code-block-hljs-selector-id-color); +} + +.code :global(.hljs-selector-pseudo) { + background-color: var(--kbq-code-block-hljs-selector-pseudo-background); + color: var(--kbq-code-block-hljs-selector-pseudo-color); +} + +.code :global(.hljs-selector-tag) { + background-color: var(--kbq-code-block-hljs-selector-tag-background); + color: var(--kbq-code-block-hljs-selector-tag-color); +} + +.code :global(.hljs-string) { + background-color: var(--kbq-code-block-hljs-string-background); + color: var(--kbq-code-block-hljs-string-color); +} + +.code :global(.hljs-strong) { + background-color: var(--kbq-code-block-hljs-strong-background); + color: var(--kbq-code-block-hljs-strong-color); + font-weight: bold; +} + +.code :global(.hljs-subst) { + background-color: var(--kbq-code-block-hljs-subst-background); + color: var(--kbq-code-block-hljs-subst-color); +} + +.code :global(.hljs-symbol) { + background-color: var(--kbq-code-block-hljs-symbol-background); + color: var(--kbq-code-block-hljs-symbol-color); +} + +.code :global(.hljs-tag) { + background-color: var(--kbq-code-block-hljs-tag-background); + color: var(--kbq-code-block-hljs-tag-color); +} + +.code :global(.hljs-template-tag) { + background-color: var(--kbq-code-block-hljs-template-tag-background); + color: var(--kbq-code-block-hljs-template-tag-color); +} + +.code :global(.hljs-template-variable) { + background-color: var(--kbq-code-block-hljs-template-variable-background); + color: var(--kbq-code-block-hljs-template-variable-color); +} + +.code :global(.hljs-title) { + background-color: var(--kbq-code-block-hljs-title-background); + color: var(--kbq-code-block-hljs-title-color); +} + +.code :global(.hljs-title.class_) { + background-color: var(--kbq-code-block-hljs-title-class-background); + color: var(--kbq-code-block-hljs-title-class-color); + font-style: var(--kbq-code-block-font-hljs-title-class-font-style); + font-weight: var(--kbq-code-block-font-hljs-title-class-font-weight); +} + +.code :global(.hljs-title.class_.inherited__) { + background-color: var(--kbq-code-block-hljs-title-class-inherited-background); + color: var(--kbq-code-block-hljs-title-class-inherited-color); +} + +.code :global(.hljs-title.function_) { + background-color: var(--kbq-code-block-hljs-title-function-background); + color: var(--kbq-code-block-hljs-title-function-color); +} + +.code :global(.hljs-title.function_.invoke__) { + background-color: var(--kbq-code-block-hljs-title-function-invoke-background); + color: var(--kbq-code-block-hljs-title-function-invoke-color); +} + +.code :global(.hljs-type) { + background-color: var(--kbq-code-block-hljs-type-background); + color: var(--kbq-code-block-hljs-type-color); +} + +.code :global(.hljs-variable) { + background-color: var(--kbq-code-block-hljs-variable-background); + color: var(--kbq-code-block-hljs-variable-color); +} + +.code :global(.hljs-variable.constant_) { + background-color: var(--kbq-code-block-hljs-variable-constant-background); + color: var(--kbq-code-block-hljs-variable-constant-color); +} + +.code :global(.hljs-variable.language_) { + background-color: var(--kbq-code-block-hljs-variable-language-background); + color: var(--kbq-code-block-hljs-variable-language-color); +} +/* stylelint-enable selector-class-pattern */ diff --git a/packages/components/src/components/CodeBlock/CodeBlock.stories.tsx b/packages/components/src/components/CodeBlock/CodeBlock.stories.tsx new file mode 100644 index 000000000..74cd0e307 --- /dev/null +++ b/packages/components/src/components/CodeBlock/CodeBlock.stories.tsx @@ -0,0 +1,515 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { IconDiamond16 } from '@koobiq/react-icons'; +import type { Meta, StoryObj } from '@storybook/react'; + +import { Button } from '../Button'; +import { SidePanel } from '../SidePanel'; +import { Toggle } from '../Toggle'; +import { Typography } from '../Typography'; + +import { + CodeBlock, + CodeBlockHighlightConfigProvider, + type CodeBlockFile, + type CodeBlockProps, + type CodeBlockRef, +} from './index.js'; + +const meta = { + title: 'Components/CodeBlock', + component: CodeBlock, + parameters: { + layout: 'padded', + }, + tags: ['status:new', 'date:2026-08-05'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Overview: Story = { + render: function Render(args) { + const highlightConfig = useMemo( + () => ({ + core: () => import('highlight.js/lib/core'), + languages: { + javascript: () => import('highlight.js/lib/languages/javascript'), + }, + }), + [] + ); + + const [hasLineNumbers, setHasLineNumbers] = useState(true); + + const files: CodeBlockFile[] = [ + { + content: `function getVulnerabilities() {\n\treturn ['BruteForce', 'Complex Attack', 'DDoS', 'HIPS alert', 'IDS/IPS Alert', 'Zero-Day Exploit', 'XSS', 'Malware', 'Ransomware', 'Phishing'];\n};`, + language: 'javascript', + }, + ]; + + return ( + + + Line numbers + + + + ); + }, +}; + +export const HeaderPinned: Story = { + render: function Render(args) { + const highlightConfig = useMemo( + () => ({ + core: () => import('highlight.js/lib/core'), + languages: { + json: () => import('highlight.js/lib/languages/json'), + }, + }), + [] + ); + + const files: CodeBlockFile[] = [ + { + language: 'json', + content: `{ + "data": [{ + "id": "1", + "attributes": { + "title": "JSON:API paints my hyper-super-duper mighty bikeshed!", + "body": "The shortest article. Ever." + }, + "relationships": { + "author": { + "data": {"id": "42", "type": "people"} + } + } + }], + "included": [ + { + "type": "people", + "id": "42", + "attributes": { + "name": "John" + } + } + ] +}`, + }, + ]; + + return ( + + 'data'} + style={{ blockSize: 350, marginBlockEnd: 'var(--kbq-size-xs)' }} + /> + ( + <> + + + {file.language || fallbackFileName} + + + )} + style={{ blockSize: 350 }} + /> + + ); + }, +}; + +export const WithMaxHeight: Story = { + render: function Render(args) { + const highlightConfig = useMemo( + () => ({ + core: () => import('highlight.js/lib/core'), + languages: { + xml: () => import('highlight.js/lib/languages/xml'), + }, + }), + [] + ); + + const [viewAll, setViewAll] = useState(false); + const [isFilled, setIsFilled] = useState(false); + + const files: CodeBlockFile[] = [ + { + content: `\n\n\t\n\t\tCross-site scripting\n\t\tXSS\n\t\tCross-site scripting (XSS) is a type of security vulnerability that can be found in some web applications. XSS attacks enable attackers to inject client-side scripts into web pages viewed by other users. A cross-site scripting vulnerability may be used by attackers to bypass access controls such as the same-origin policy. During the second half of 2007, XSSed documented 11,253 site-specific cross-site vulnerabilities, compared to 2,134 "traditional" vulnerabilities documented by Symantec. XSS effects vary in range from petty nuisance to significant security risk, depending on the sensitivity of the data handled by the vulnerable site and the nature of any security mitigation implemented by the site's owner network.\n\t\n\t\n\t\tDenial-of-service attack\n\t\tDoS\n\t\tIn computing, a denial-of-service attack (DoS attack) is a cyber-attack in which the perpetrator seeks to make a machine or network resource unavailable to its intended users by temporarily or indefinitely disrupting services of a host connected to a network. Denial of service is typically accomplished by flooding the targeted machine or resource with superfluous requests in an attempt to overload systems and prevent some or all legitimate requests from being fulfilled. The range of attacks varies widely, spanning from inundating a server with millions of requests to slow its performance, overwhelming a server with a substantial amount of invalid data, to submitting requests with an illegitimate IP address.\n\t\n`, + language: 'xml', + }, + ]; + + return ( + + + Show all + + + Filled + + + + ); + }, +}; + +export const WithSoftWrap: Story = { + render: function Render(args) { + const highlightConfig = useMemo( + () => ({ + core: () => import('highlight.js/lib/core'), + languages: { + json: () => import('highlight.js/lib/languages/json'), + }, + }), + [] + ); + + const [softWrap, setSoftWrap] = useState(false); + + const files: CodeBlockFile[] = [ + { + content: `[\n\t{\n\t\t"name": "Cross-site scripting",\n\t\t"abbreviation": "XSS",\n\t\t"description": "Cross-site scripting (XSS) is a type of security vulnerability that can be found in some web applications. XSS attacks enable attackers to inject client-side scripts into web pages viewed by other users. A cross-site scripting vulnerability may be used by attackers to bypass access controls such as the same-origin policy. During the second half of 2007, XSSed documented 11,253 site-specific cross-site vulnerabilities, compared to 2,134 "traditional" vulnerabilities documented by Symantec. XSS effects vary in range from petty nuisance to significant security risk, depending on the sensitivity of the data handled by the vulnerable site and the nature of any security mitigation implemented by the site's owner network."\n\t}\n]`, + language: 'json', + }, + ]; + + return ( + + + Word wrap + + + + ); + }, +}; + +export const WithTabs: Story = { + render: function Render(args) { + const highlightConfig = useMemo( + () => ({ + core: () => import('highlight.js/lib/core'), + languages: { + css: () => import('highlight.js/lib/languages/css'), + typescript: () => import('highlight.js/lib/languages/typescript'), + // The XML grammar provides the `html` alias. + xml: () => import('highlight.js/lib/languages/xml'), + }, + }), + [] + ); + + const [hideTabs, setHideTabs] = useState(false); + const [isFilled, setIsFilled] = useState(false); + const [hasLineNumbers, setHasLineNumbers] = useState(true); + + const files: CodeBlockFile[] = [ + { + language: 'html', + filename: 'index.html', + content: `\n\n\t\n\t\t\n\t\t\n\t\tSecurity dashboard\n\t\t\n\t\n\t\n\t\t
\n\t\t\n\t\n`, + }, + { + language: 'typescript', + filename: 'main.ts', + content: `type Vulnerability = {\n\tname: string;\n\tseverity: 'critical' | 'high' | 'medium';\n};\n\nconst vulnerabilities: Vulnerability[] = [\n\t{ name: 'Cross-site scripting', severity: 'high' },\n\t{ name: 'SQL injection', severity: 'critical' },\n\t{ name: 'Open redirect', severity: 'medium' }\n];\n\nconst app = document.querySelector('#app');\n\nif (!app) throw new Error('Application root was not found');\n\napp.innerHTML = \`\n\t

Vulnerabilities

\n\t
    \n\t\t\${vulnerabilities\n\t\t\t.map(({ name, severity }) => \`
  • \${name}
  • \`)\n\t\t\t.join('')}\n\t
\n\`;`, + }, + { + language: 'css', + filename: 'main.css', + content: `:root {\n\tfont-family: Inter, sans-serif;\n\tcolor: #1f2937;\n\tbackground: #f8fafc;\n}\n\nbody {\n\tmargin: 0;\n\tpadding: 24px;\n}\n\nmain {\n\tmax-width: 640px;\n\tmargin: 0 auto;\n}\n\nul {\n\tpadding: 0;\n\tlist-style: none;\n}\n\nli {\n\tpadding: 12px;\n\tborder-bottom: 1px solid #e2e8f0;\n}`, + }, + ]; + + return ( + + + Hide tabs + + + Filled + + + Line numbers + + + + ); + }, +}; + +export const WithTabsAndShadow: Story = { + render: function Render(args) { + const highlightConfig = useMemo( + () => ({ + core: () => import('highlight.js/lib/core'), + languages: { + css: () => import('highlight.js/lib/languages/css'), + typescript: () => import('highlight.js/lib/languages/typescript'), + // The XML grammar provides the `html` alias. + xml: () => import('highlight.js/lib/languages/xml'), + }, + }), + [] + ); + + const codeBlockRef = useRef(null); + + const files: CodeBlockFile[] = [ + { + language: 'html', + filename: 'index.html', + content: `\n\n\t\n\t\t\n\t\t\n\t\tSecurity dashboard\n\t\t\n\t\n\t\n\t\t
\n\t\t\n\t\n`, + }, + { + language: 'typescript', + filename: 'main.ts', + content: `type Vulnerability = {\n\tname: string;\n\tseverity: 'critical' | 'high' | 'medium';\n};\n\nconst vulnerabilities: Vulnerability[] = [\n\t{ name: 'Cross-site scripting', severity: 'high' },\n\t{ name: 'SQL injection', severity: 'critical' },\n\t{ name: 'Open redirect', severity: 'medium' }\n];\n\nconst app = document.querySelector('#app');\n\nif (!app) throw new Error('Application root was not found');\n\napp.innerHTML = \`\n\t

Vulnerabilities

\n\t
    \n\t\t\${vulnerabilities\n\t\t\t.map(({ name, severity }) => \`
  • \${name}
  • \`)\n\t\t\t.join('')}\n\t
\n\`;`, + }, + { + language: 'css', + filename: 'main.css', + content: `:root {\n\tfont-family: Inter, sans-serif;\n\tcolor: #1f2937;\n\tbackground: #f8fafc;\n}\n\nbody {\n\tmargin: 0;\n\tpadding: 24px;\n}\n\nmain {\n\tmax-width: 640px;\n\tmargin: 0 auto;\n}\n\nul {\n\tpadding: 0;\n\tlist-style: none;\n}\n\nli {\n\tpadding: 12px;\n\tborder-bottom: 1px solid #e2e8f0;\n}`, + }, + ]; + + useEffect(() => { + codeBlockRef.current?.scrollTo({ bottom: 0, behavior: 'instant' }); + }, []); + + return ( + + + + ); + }, +}; + +export const WithFilled: Story = { + render: function Render(args) { + const highlightConfig = useMemo( + () => ({ + core: () => import('highlight.js/lib/core'), + languages: { + bash: () => import('highlight.js/lib/languages/bash'), + }, + }), + [] + ); + + const [isFilled, setIsFilled] = useState(true); + const [alwaysShowActionBar, setAlwaysShowActionBar] = useState(false); + + const files: CodeBlockFile[] = [ + { + language: 'bash', + content: 'npm install @koobiq/components', + }, + ]; + + return ( + + + Filled + + + Always show actionbar + + + + ); + }, +}; + +export const WithNoBorder: Story = { + render: function Render(args) { + const highlightConfig = useMemo( + () => ({ + core: () => import('highlight.js/lib/core'), + languages: { + xml: () => import('highlight.js/lib/languages/xml'), + }, + }), + [] + ); + + const xss = `\t\n\t\tCross-site scripting\n\t\tXSS\n\t\tCross-site scripting (XSS) is a type of security vulnerability that can be found in some web applications. XSS attacks enable attackers to inject client-side scripts into web pages viewed by other users. A cross-site scripting vulnerability may be used by attackers to bypass access controls such as the same-origin policy. During the second half of 2007, XSSed documented 11,253 site-specific cross-site vulnerabilities, compared to 2,134 "traditional" vulnerabilities documented by Symantec. XSS effects vary in range from petty nuisance to significant security risk, depending on the sensitivity of the data handled by the vulnerable site and the nature of any security mitigation implemented by the site's owner network.\n\t`; + const dos = `\t\n\t\tDenial-of-service attack\n\t\tDoS\n\t\tIn computing, a denial-of-service attack (DoS attack) is a cyber-attack in which the perpetrator seeks to make a machine or network resource unavailable to its intended users by temporarily or indefinitely disrupting services of a host connected to a network. Denial of service is typically accomplished by flooding the targeted machine or resource with superfluous requests in an attempt to overload systems and prevent some or all legitimate requests from being fulfilled. The range of attacks varies widely, spanning from inundating a server with millions of requests to slow its performance, overwhelming a server with a substantial amount of invalid data, to submitting requests with an illegitimate IP address.\n\t`; + + const files: CodeBlockFile[] = [ + { + content: `\n\n${[ + xss, + dos, + ...Array.from({ length: 10 }, () => xss), + ].join('\n')}\n`, + language: 'xml', + link: 'https://github.com/koobiq/angular-components', + }, + ]; + + return ( + + } + > + {() => ( + + + + )} + + + ); + }, +}; + +export const WithLink: Story = { + render: function Render(args) { + const highlightConfig = useMemo( + () => ({ + core: () => import('highlight.js/lib/core'), + languages: { + xml: () => import('highlight.js/lib/languages/xml'), + }, + }), + [] + ); + + const files: CodeBlockFile[] = [ + { + link: 'https://en.wikipedia.org/wiki/Cross-site_scripting', + filename: 'vulnerabilities.xml', + content: `\n\n\t\n\t\tCross-site scripting\n\t\tXSS\n\t\tCross-site scripting (XSS) is a type of security vulnerability that can be found in some web applications. XSS attacks enable attackers to inject client-side scripts into web pages viewed by other users. A cross-site scripting vulnerability may be used by attackers to bypass access controls such as the same-origin policy. During the second half of 2007, XSSed documented 11,253 site-specific cross-site vulnerabilities, compared to 2,134 "traditional" vulnerabilities documented by Symantec. XSS effects vary in range from petty nuisance to significant security risk, depending on the sensitivity of the data handled by the vulnerable site and the nature of any security mitigation implemented by the site's owner network.\n\t\n`, + language: 'xml', + }, + ]; + + return ( + + + + ); + }, +}; diff --git a/packages/components/src/components/CodeBlock/CodeBlock.test.tsx b/packages/components/src/components/CodeBlock/CodeBlock.test.tsx new file mode 100644 index 000000000..90db4cfda --- /dev/null +++ b/packages/components/src/components/CodeBlock/CodeBlock.test.tsx @@ -0,0 +1,890 @@ +import { createRef } from 'react'; + +import { once } from '@koobiq/logger'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import type { HLJSApi } from 'highlight.js'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { CodeBlock } from './CodeBlock'; +import { CodeBlockHighlightConfigProvider } from './context'; +import type { CodeBlockHighlightConfig } from './context'; +import type { CodeBlockFile, CodeBlockRef } from './types'; + +const createResizeEntry = (height: number): ResizeObserverEntry => + ({ + contentRect: { + x: 0, + y: 0, + top: 0, + left: 0, + right: 300, + bottom: height, + width: 300, + height, + }, + borderBoxSize: [{ inlineSize: 300, blockSize: height }], + }) as unknown as ResizeObserverEntry; + +afterEach(() => { + once.clear(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('CodeBlock', () => { + const user = userEvent.setup({ delay: 0 }); + + const jsFile: CodeBlockFile[] = [ + { content: 'const answer = 42;', language: 'javascript' }, + ]; + + const multiFile: CodeBlockFile[] = [ + { content: '
', language: 'xml', filename: 'index.html' }, + { + content: 'const answer = 42;', + language: 'javascript', + filename: 'main.ts', + }, + ]; + + const getCode = () => document.querySelector('code[data-language]'); + + // jsdom doesn't implement scrolling. + Object.defineProperty(HTMLElement.prototype, 'scrollTo', { + value() {}, + writable: true, + }); + + it('highlights the active file content', async () => { + render(); + + await waitFor(() => + expect(getCode()).toHaveAttribute('data-language', 'javascript') + ); + + expect(getCode()?.textContent).toBe('const answer = 42;'); + expect(document.querySelector('.hljs-keyword')).toHaveTextContent('const'); + }); + + it('hides tabs automatically for a single file without a filename', async () => { + const onHideTabsChange = vi.fn(); + + render( + + ); + + await waitFor(() => expect(getCode()).not.toBeNull()); + + expect(screen.queryAllByRole('tab')).toHaveLength(0); + expect(screen.getByTestId('root')).toHaveAttribute('data-hide-tabs'); + expect(onHideTabsChange).toHaveBeenCalledWith(true); + }); + + it('shows tabs again when files no longer require automatic hiding', async () => { + const onHideTabsChange = vi.fn(); + + const { rerender } = render( + + ); + + await waitFor(() => + expect(onHideTabsChange).toHaveBeenLastCalledWith(true) + ); + + rerender( + + ); + + await waitFor(() => expect(screen.getAllByRole('tab')).toHaveLength(2)); + + expect(screen.getByTestId('root')).not.toHaveAttribute('data-hide-tabs'); + expect(onHideTabsChange).toHaveBeenLastCalledWith(false); + expect(onHideTabsChange).toHaveBeenCalledTimes(2); + }); + + it('preserves defaultHideTabs when the file list changes', async () => { + const onHideTabsChange = vi.fn(); + + const { rerender } = render( + + ); + + await waitFor(() => expect(getCode()).not.toBeNull()); + + rerender( + + ); + + expect(screen.queryAllByRole('tab')).toHaveLength(0); + expect(screen.getByTestId('root')).toHaveAttribute('data-hide-tabs'); + expect(onHideTabsChange).not.toHaveBeenCalled(); + }); + + it('shows one tab per file and switches the active file on click', async () => { + const onActiveFileIndexChange = vi.fn(); + + render( + + ); + + await waitFor(() => + expect(getCode()).toHaveAttribute('data-language', 'xml') + ); + + const tabs = screen.getAllByRole('tab'); + expect(tabs).toHaveLength(2); + expect(tabs[0]).toHaveTextContent('index.html'); + expect(tabs[1]).toHaveTextContent('main.ts'); + + const panel = screen.getByRole('tabpanel'); + + expect(tabs[0]).toHaveAttribute('aria-controls', panel.id); + expect(panel).toHaveAttribute('aria-labelledby', tabs[0]!.id); + + await user.click(tabs[1]!); + + expect(onActiveFileIndexChange).toHaveBeenCalledWith(1); + + await waitFor(() => + expect(getCode()).toHaveAttribute('data-language', 'javascript') + ); + + expect(screen.getByRole('tabpanel')).toHaveAttribute( + 'aria-labelledby', + tabs[1]!.id + ); + }); + + it('renders a custom tab label via renderTabLabel', async () => { + render( + + `Custom: ${file.filename ?? fallback}` + } + /> + ); + + expect(await screen.findByText('Custom: index.html')).toBeInTheDocument(); + }); + + it('renders a single custom tab label as a standard selected tab', async () => { + render( + 'JavaScript source'} + /> + ); + + const tab = await screen.findByRole('tab', { name: 'JavaScript source' }); + + expect(tab).toHaveAttribute('data-selected', 'true'); + }); + + it('uses fallbackFileName for an unnamed visible tab', async () => { + render( + + ); + + expect( + await screen.findByRole('tab', { name: 'snippet.js' }) + ).toBeInTheDocument(); + }); + + it('keeps a controlled activeFileIndex until the prop changes', async () => { + const onActiveFileIndexChange = vi.fn(); + + const { rerender } = render( + + ); + + await waitFor(() => + expect(getCode()).toHaveAttribute('data-language', 'xml') + ); + + await user.click(screen.getByRole('tab', { name: 'main.ts' })); + + expect(onActiveFileIndexChange).toHaveBeenCalledWith(1); + expect(getCode()).toHaveAttribute('data-language', 'xml'); + + rerender( + + ); + + await waitFor(() => + expect(getCode()).toHaveAttribute('data-language', 'javascript') + ); + }); + + it('applies visual, default state and DOM passthrough props', async () => { + render( + + ); + + await waitFor(() => + expect(getCode()).toHaveAttribute('data-language', 'javascript') + ); + + const root = screen.getByTestId('root'); + + expect(root).toHaveClass('custom-class'); + expect(root).toHaveStyle({ inlineSize: '320px' }); + expect(root).toHaveAttribute('data-context', 'example'); + expect(root).toHaveAttribute('data-filled'); + expect(root).toHaveAttribute('data-border-hidden'); + expect(root).toHaveAttribute('data-line-numbers'); + expect(root).toHaveAttribute('data-hide-tabs'); + expect(root).toHaveAttribute('data-soft-wrap'); + expect(root).toHaveAttribute('data-view-all'); + + expect( + Array.from(document.querySelectorAll('.hljs-ln-numbers')).map((cell) => + cell.getAttribute('data-line-number') + ) + ).toEqual(['10', '11']); + }); + + describe('highlight configuration', () => { + it('loads and reuses only the languages provided by the nearest provider', async () => { + const loadCore = vi.fn(() => import('highlight.js/lib/core')); + + const loadJavaScript = vi.fn( + () => import('highlight.js/lib/languages/javascript') + ); + + const config: CodeBlockHighlightConfig = { + core: loadCore, + languages: { javascript: loadJavaScript }, + }; + + render( + + + + + ); + + await waitFor(() => + expect( + document.querySelectorAll('code[data-language="javascript"]') + ).toHaveLength(2) + ); + + expect(loadCore).toHaveBeenCalledTimes(1); + expect(loadJavaScript).toHaveBeenCalledTimes(1); + + expect(document.querySelector('.hljs-keyword')).toHaveTextContent( + 'const' + ); + }); + + it('escapes source text when neither the requested nor fallback language is registered', async () => { + const hljs = { + getLanguage: vi.fn(() => undefined), + highlight: vi.fn(), + registerLanguage: vi.fn(), + } as unknown as HLJSApi; + + const config: CodeBlockHighlightConfig = { + core: vi.fn().mockResolvedValue({ default: hljs }), + fallbackLanguage: 'text', + }; + + const content = ''; + + render( + + + + ); + + await waitFor(() => + expect(getCode()).toHaveAttribute('data-language', 'text') + ); + + expect(getCode()).toHaveTextContent(content); + expect(getCode()?.innerHTML).toContain('<script>'); + expect(getCode()?.querySelector('script')).toBeNull(); + expect(hljs.highlight).not.toHaveBeenCalled(); + }); + + it('keeps the raw source visible when highlighting fails', async () => { + const error = new Error('load failed'); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const config: CodeBlockHighlightConfig = { + core: vi.fn().mockRejectedValue(error), + }; + + render( + + + + ); + + await waitFor(() => + expect(warnSpy).toHaveBeenCalledWith( + '[koobiq] [CodeBlock] Failed to highlight the file.', + error + ) + ); + + const code = document.querySelector('code.hljs'); + + expect(code).not.toHaveAttribute('data-language'); + expect(code).toHaveTextContent('const answer = 42;'); + }); + }); + + describe('action bar', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('copies the active file content', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + + vi.stubGlobal('navigator', { + ...navigator, + clipboard: { writeText }, + } as unknown as Navigator); + + render(); + + await waitFor(() => expect(getCode()).not.toBeNull()); + + await user.click(screen.getByRole('button', { name: 'Copy' })); + + expect(writeText).toHaveBeenCalledWith('const answer = 42;'); + + vi.unstubAllGlobals(); + }); + + it('shows copied feedback until the copy tooltip closes', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + + vi.stubGlobal('navigator', { + ...navigator, + clipboard: { writeText }, + } as unknown as Navigator); + + render(); + + const copyButton = await screen.findByRole('button', { name: 'Copy' }); + + await user.hover(copyButton); + expect(await screen.findByText('Copy')).toBeVisible(); + + await user.click(copyButton); + expect(await screen.findByText('✓ Copied')).toBeVisible(); + + await user.unhover(copyButton); + + await waitFor(() => + expect(screen.queryByText('✓ Copied')).not.toBeInTheDocument() + ); + + await user.hover(copyButton); + expect(await screen.findByText('Copy')).toBeVisible(); + + vi.unstubAllGlobals(); + }); + + it('toggles an uncontrolled defaultSoftWrap value and reports the change', async () => { + const onSoftWrapChange = vi.fn(); + + render( + + ); + + await waitFor(() => expect(getCode()).not.toBeNull()); + + const root = screen.getByTestId('root'); + expect(root).toHaveAttribute('data-soft-wrap'); + + await user.click( + screen.getByRole('button', { name: 'Disable word wrap' }) + ); + + expect(onSoftWrapChange).toHaveBeenCalledWith(false); + expect(root).not.toHaveAttribute('data-soft-wrap'); + + expect( + screen.getByRole('button', { name: 'Enable word wrap' }) + ).toBeInTheDocument(); + }); + + it('reports a controlled softWrap change without mutating the rendered state', async () => { + const onSoftWrapChange = vi.fn(); + + render( + + ); + + await user.click( + await screen.findByRole('button', { name: 'Disable word wrap' }) + ); + + expect(onSoftWrapChange).toHaveBeenCalledWith(false); + expect(screen.getByTestId('root')).toHaveAttribute('data-soft-wrap'); + }); + + it('hides the copy button when requested', async () => { + render(); + + await waitFor(() => expect(getCode()).not.toBeNull()); + + expect( + screen.queryByRole('button', { name: 'Copy' }) + ).not.toBeInTheDocument(); + }); + + it('downloads the active file as a blob', async () => { + const createObjectURL = vi.fn().mockReturnValue('blob:mock-url'); + const revokeObjectURL = vi.fn(); + + vi.stubGlobal('URL', { + ...URL, + createObjectURL, + revokeObjectURL, + }); + + const clickSpy = vi + .spyOn(HTMLAnchorElement.prototype, 'click') + .mockImplementation(() => {}); + + const { rerender } = render( + + ); + + await waitFor(() => expect(getCode()).not.toBeNull()); + + await user.click(screen.getByRole('button', { name: 'Download' })); + + expect(createObjectURL).toHaveBeenCalled(); + expect(clickSpy).toHaveBeenCalled(); + + expect(clickSpy.mock.instances[0]).toMatchObject({ + download: 'snippet.js', + href: 'blob:mock-url', + }); + + expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock-url'); + + rerender( + + ); + + await user.click(screen.getByRole('button', { name: 'Download' })); + + expect(clickSpy.mock.instances[1]).toMatchObject({ + download: 'main.js', + }); + }); + + it('opens the file link in a new tab', async () => { + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + + render( + + ); + + await waitFor(() => expect(getCode()).not.toBeNull()); + + await user.click( + screen.getByRole('button', { name: 'Open in the external system' }) + ); + + expect(openSpy).toHaveBeenCalledWith( + 'https://example.com', + '_blank', + 'noopener,noreferrer' + ); + + openSpy.mockRestore(); + }); + }); + + it('limits overflowing content and toggles viewAll', async () => { + let resize: ResizeObserverCallback; + + class ResizeObserverMock { + callback: ResizeObserverCallback; + + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + } + + observe = vi.fn((target: Element) => { + if (target.tagName === 'PRE') resize = this.callback; + }); + unobserve = vi.fn(); + disconnect = vi.fn(); + } + + vi.stubGlobal('ResizeObserver', ResizeObserverMock); + + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(0); + + return 1; + }); + + vi.stubGlobal('cancelAnimationFrame', vi.fn()); + + const onViewAllChange = vi.fn(); + + const scrollToSpy = vi + .spyOn(HTMLElement.prototype, 'scrollTo') + .mockImplementation(() => {}); + + render( + + ); + + await waitFor(() => expect(getCode()).not.toBeNull()); + + act(() => resize!([createResizeEntry(180)], {} as ResizeObserver)); + + const main = screen.getByRole('region', { name: 'code' }); + + expect(main).toHaveStyle({ maxHeight: '100px' }); + + await user.click(screen.getByRole('button', { name: 'Show all' })); + + expect(onViewAllChange).toHaveBeenLastCalledWith(true); + expect(screen.getByTestId('root')).toHaveAttribute('data-view-all'); + expect(main.style.maxHeight).toBe(''); + + await user.click(screen.getByRole('button', { name: 'Show less' })); + + expect(onViewAllChange).toHaveBeenLastCalledWith(false); + expect(screen.getByTestId('root')).not.toHaveAttribute('data-view-all'); + expect(main).toHaveStyle({ maxHeight: '100px' }); + + expect(scrollToSpy).toHaveBeenCalledWith({ + top: 0, + behavior: 'instant', + }); + }); + + it('reflects controlled viewAll prop updates', async () => { + const { rerender } = render( + + ); + + await waitFor(() => expect(getCode()).not.toBeNull()); + + expect(screen.getByTestId('root')).toHaveAttribute('data-view-all'); + + rerender(); + + expect(screen.getByTestId('root')).not.toHaveAttribute('data-view-all'); + }); + + it('forwards an imperative handle exposing the root element and scrollTo', async () => { + const ref = createRef(); + + const scrollToSpy = vi + .spyOn(HTMLElement.prototype, 'scrollTo') + .mockImplementation(() => {}); + + render(); + + await waitFor(() => expect(getCode()).not.toBeNull()); + + expect(ref.current?.element).toBe(screen.getByTestId('root')); + + ref.current?.scrollTo({ top: 0 }); + expect(scrollToSpy).toHaveBeenCalledWith({ top: 0 }); + + scrollToSpy.mockRestore(); + }); + + it('queues imperative scrolling until highlighting finishes', async () => { + let resolveCore!: (module: { default: HLJSApi }) => void; + + const corePromise = new Promise<{ default: HLJSApi }>((resolve) => { + resolveCore = resolve; + }); + + const hljs = { + getLanguage: vi.fn(() => ({})), + registerLanguage: vi.fn(), + highlight: vi.fn(() => ({ + value: 'const answer = 42;', + language: 'javascript', + relevance: 1, + illegal: false, + })), + } as unknown as HLJSApi; + + const config: CodeBlockHighlightConfig = { + core: vi.fn(() => corePromise), + }; + + const ref = createRef(); + + const scrollToSpy = vi + .spyOn(HTMLElement.prototype, 'scrollTo') + .mockImplementation(() => {}); + + render( + + + + ); + + ref.current?.scrollTo({ top: 24, behavior: 'instant' }); + expect(scrollToSpy).not.toHaveBeenCalled(); + + resolveCore({ default: hljs }); + + await waitFor(() => + expect(scrollToSpy).toHaveBeenCalledWith({ + top: 24, + left: undefined, + behavior: 'instant', + }) + ); + }); + + it('supports scrolling from the bottom edge', async () => { + const ref = createRef(); + + const scrollToSpy = vi + .spyOn(HTMLElement.prototype, 'scrollTo') + .mockImplementation(() => {}); + + render(); + + await waitFor(() => + expect(getCode()).toHaveAttribute('data-language', 'javascript') + ); + + const panel = screen.getByRole('region', { name: 'code' }); + + Object.defineProperties(panel, { + scrollHeight: { configurable: true, value: 600 }, + clientHeight: { configurable: true, value: 200 }, + }); + + ref.current?.scrollTo({ bottom: 16, behavior: 'instant' }); + + expect(scrollToSpy).toHaveBeenCalledWith({ + top: 384, + left: undefined, + behavior: 'instant', + }); + + scrollToSpy.mockRestore(); + }); + + it('resolves horizontal edge offsets for LTR and RTL content', async () => { + const ref = createRef(); + + const scrollToSpy = vi + .spyOn(HTMLElement.prototype, 'scrollTo') + .mockImplementation(() => {}); + + render(); + + await waitFor(() => expect(getCode()).not.toBeNull()); + + const panel = screen.getByRole('region', { name: 'code' }); + + Object.defineProperties(panel, { + scrollWidth: { configurable: true, value: 600 }, + clientWidth: { configurable: true, value: 200 }, + }); + + ref.current?.scrollTo({ left: 32 }); + + expect(scrollToSpy).toHaveBeenLastCalledWith({ + top: undefined, + left: 32, + behavior: undefined, + }); + + ref.current?.scrollTo({ right: 20 }); + + expect(scrollToSpy).toHaveBeenLastCalledWith({ + top: undefined, + left: 380, + behavior: undefined, + }); + + ref.current?.scrollTo({ end: 16 }); + + expect(scrollToSpy).toHaveBeenLastCalledWith({ + top: undefined, + left: 384, + behavior: undefined, + }); + + panel.style.direction = 'rtl'; + ref.current?.scrollTo({ start: 12 }); + + expect(scrollToSpy).toHaveBeenLastCalledWith({ + top: undefined, + left: -12, + behavior: undefined, + }); + + ref.current?.scrollTo({ end: 16 }); + + expect(scrollToSpy).toHaveBeenLastCalledWith({ + top: undefined, + left: -384, + behavior: undefined, + }); + }); + + it('tracks vertical scrolling to display the header shadow', async () => { + render(); + + await waitFor(() => expect(getCode()).not.toBeNull()); + + const panel = screen.getByRole('region', { name: 'code' }); + const header = screen.getByTestId('code-block-actionbar').parentElement!; + const initialClassName = header.className; + + Object.defineProperty(panel, 'scrollTop', { + configurable: true, + value: 10, + writable: true, + }); + + fireEvent.scroll(panel); + expect(header.className).not.toBe(initialClassName); + + panel.scrollTop = 0; + fireEvent.scroll(panel); + expect(header.className).toBe(initialClassName); + }); + + it('clamps an out-of-range active file index', async () => { + const onActiveFileIndexChange = vi.fn(); + + render( + + ); + + await waitFor(() => + expect(getCode()).toHaveAttribute('data-language', 'javascript') + ); + + expect(onActiveFileIndexChange).toHaveBeenCalledWith(1); + }); + + it('keeps the action bar visible when tabs are hidden if requested', async () => { + render(); + + await waitFor(() => expect(getCode()).not.toBeNull()); + + expect(screen.getByTestId('root')).toHaveAttribute( + 'data-always-show-action-bar' + ); + }); + + it('warns when files is empty', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const { unmount } = render(); + + expect(warnSpy).toHaveBeenCalledWith( + '[koobiq] CodeBlock: "files" should contain at least one file.' + ); + + unmount(); + }); +}); diff --git a/packages/components/src/components/CodeBlock/CodeBlock.tsx b/packages/components/src/components/CodeBlock/CodeBlock.tsx new file mode 100644 index 000000000..8630154aa --- /dev/null +++ b/packages/components/src/components/CodeBlock/CodeBlock.tsx @@ -0,0 +1,354 @@ +'use client'; + +import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'; +import type { Ref } from 'react'; + +import { once } from '@koobiq/logger'; +import { + clsx, + useControlledState, + useLocalizedStringFormatter, + useResizeObserver, +} from '@koobiq/react-core'; +import { IconChevronDown16, IconChevronUp16 } from '@koobiq/react-icons'; + +import { utilClasses } from '../../styles/utility'; +import { Button } from '../Button'; + +import s from './CodeBlock.module.css'; +import { CodeBlockActionBar, CodeBlockTabs } from './components'; +import { useHighlightedCode, useOverflowShadow } from './hooks'; +import intlMessages from './intl.json'; +import type { + CodeBlockFile, + CodeBlockProps, + CodeBlockRef, + CodeBlockScrollToOptions, +} from './types'; + +const EMPTY_FILE: CodeBlockFile = { content: '' }; + +function scrollElementTo( + element: HTMLElement, + options: CodeBlockScrollToOptions +): void { + const { behavior, top, bottom, left, right, start, end } = options; + const maxTop = element.scrollHeight - element.clientHeight; + const maxLeft = element.scrollWidth - element.clientWidth; + const isRtl = getComputedStyle(element).direction === 'rtl'; + + const resolvedTop = top ?? (bottom == null ? undefined : maxTop - bottom); + let resolvedLeft = left; + + if (resolvedLeft == null && right != null) { + resolvedLeft = isRtl ? -right : maxLeft - right; + } + + if (resolvedLeft == null && start != null) { + resolvedLeft = isRtl ? -start : start; + } + + if (resolvedLeft == null && end != null) { + resolvedLeft = isRtl ? end - maxLeft : maxLeft - end; + } + + element.scrollTo({ top: resolvedTop, left: resolvedLeft, behavior }); +} + +function CodeBlockRender(props: CodeBlockProps, ref: Ref) { + const { + files, + hasLineNumbers = false, + isFilled = false, + hideBorder = false, + canToggleSoftWrap = false, + canDownload = false, + hideCopyButton = false, + alwaysShowActionBar = false, + softWrap: softWrapProp, + defaultSoftWrap, + onSoftWrapChange, + viewAll: viewAllProp, + defaultViewAll, + onViewAllChange, + maxHeight, + hideTabs: hideTabsProp, + defaultHideTabs, + onHideTabsChange, + activeFileIndex: activeFileIndexProp, + defaultActiveFileIndex, + onActiveFileIndexChange, + renderTabLabel, + fallbackFileName = 'code', + startFrom = 1, + className, + style, + 'data-testid': dataTestId, + ...other + } = props; + + if (process.env.NODE_ENV !== 'production' && files.length === 0) { + once.warn('CodeBlock: "files" should contain at least one file.'); + } + + const t = useLocalizedStringFormatter(intlMessages); + + const rootRef = useRef(null); + const mainRef = useRef(null); + const pendingScrollRef = useRef(null); + const tabsHiddenAutomaticallyRef = useRef(false); + + const [softWrap, setSoftWrap] = useControlledState( + softWrapProp, + defaultSoftWrap ?? false, + onSoftWrapChange + ); + + const [viewAll, setViewAll] = useControlledState( + viewAllProp, + defaultViewAll ?? false, + onViewAllChange + ); + + const [hideTabs, setHideTabs] = useControlledState( + hideTabsProp, + defaultHideTabs ?? false, + onHideTabsChange + ); + + const [activeFileIndexState, setActiveFileIndex] = useControlledState( + activeFileIndexProp, + defaultActiveFileIndex ?? 0, + onActiveFileIndexChange + ); + + // Clamp against out-of-range indexes, e.g. after `files` shrinks. + const normalizedActiveFileIndex = Number.isFinite(activeFileIndexState) + ? Math.trunc(activeFileIndexState) + : 0; + + const activeFileIndex = Math.max( + 0, + Math.min(normalizedActiveFileIndex, files.length - 1) + ); + + const activeFile = files[activeFileIndex] ?? EMPTY_FILE; + + useEffect(() => { + if (activeFileIndexState !== activeFileIndex) { + setActiveFileIndex(activeFileIndex); + } + }, [activeFileIndex, activeFileIndexState, setActiveFileIndex]); + + // A single file without a name has nothing to switch between or label — hide the tabs, same as Angular. + const isSingleUnnamedFile = files.length === 1 && !activeFile.filename; + + // A controlled `false` deliberately overrides the automatic single-file behavior so a custom + // header can remain visible. + const shouldAutoHideTabs = hideTabsProp === undefined && isSingleUnnamedFile; + + const isTabsHidden = hideTabs || shouldAutoHideTabs; + + useEffect(() => { + if (hideTabsProp !== undefined) { + tabsHiddenAutomaticallyRef.current = false; + + return; + } + + if (shouldAutoHideTabs && !hideTabs) { + tabsHiddenAutomaticallyRef.current = true; + setHideTabs(true); + } else if (!shouldAutoHideTabs && tabsHiddenAutomaticallyRef.current) { + tabsHiddenAutomaticallyRef.current = false; + setHideTabs(false); + } + }, [hideTabs, hideTabsProp, setHideTabs, shouldAutoHideTabs]); + + const { html, language, pending, failed } = useHighlightedCode(activeFile, { + hasLineNumbers, + startFrom, + }); + + const [preRef, preRect] = useResizeObserver(); + const hasMaxHeight = maxHeight != null && maxHeight > 0; + const calculatedMaxHeight = hasMaxHeight && !viewAll ? maxHeight : undefined; + + const contentExceedsMaxHeight = hasMaxHeight && preRect.height > maxHeight; + + const mainElement = mainRef.current; + + const canFocusContent = Boolean( + !calculatedMaxHeight && + mainElement && + (mainElement.scrollHeight > mainElement.clientHeight || + mainElement.scrollWidth > mainElement.clientWidth) + ); + + const { isScrolled, onScroll } = useOverflowShadow(); + + const scrollTo = (options: CodeBlockScrollToOptions) => { + if (pending) { + pendingScrollRef.current = options; + + return; + } + + if (mainRef.current) scrollElementTo(mainRef.current, options); + }; + + useEffect(() => { + if (pending || !pendingScrollRef.current || !mainRef.current) return; + + scrollElementTo(mainRef.current, pendingScrollRef.current); + pendingScrollRef.current = null; + }, [pending]); + + useImperativeHandle(ref, () => ({ + element: rootRef.current, + scrollTo, + })); + + const scrollToTop = () => + mainRef.current && + scrollElementTo(mainRef.current, { top: 0, behavior: 'instant' }); + + const onTabChange = (index: number) => { + if (index === activeFileIndex) return; + + setActiveFileIndex(index); + scrollToTop(); + }; + + const toggleSoftWrap = () => setSoftWrap(!softWrap); + + const toggleViewAll = () => { + const nextViewAll = !viewAll; + + setViewAll(nextViewAll); + + if (!nextViewAll) scrollToTop(); + }; + + const actionCount = + Number(canToggleSoftWrap) + + Number(canDownload) + + Number(!hideCopyButton) + + Number(Boolean(activeFile.link)); + + const actionBar = ( + + ); + + const panelContent = ( + <> +
+        
+          {pending || failed ? activeFile.content : undefined}
+        
+      
+ + {contentExceedsMaxHeight && ( +
+
+ +
+
+ )} + + ); + + return ( +
+ {isTabsHidden ? ( + <> +
+ {actionBar} +
+
+ {panelContent} +
+ + ) : ( + <> + + {actionBar} + + )} +
+ ); +} + +export const CodeBlock = forwardRef(CodeBlockRender); + +CodeBlock.displayName = 'CodeBlock'; diff --git a/packages/components/src/components/CodeBlock/components/CodeBlockActionBar.tsx b/packages/components/src/components/CodeBlock/components/CodeBlockActionBar.tsx new file mode 100644 index 000000000..cf4d4189c --- /dev/null +++ b/packages/components/src/components/CodeBlock/components/CodeBlockActionBar.tsx @@ -0,0 +1,167 @@ +'use client'; + +import { useRef, useState } from 'react'; + +import { useCopyToClipboard } from '@koobiq/react-core'; +import { + IconArrowDownToLine16, + IconArrowUpRightFromSquare16, + IconFileMultipleO16, + IconTextOverflow16, + IconTextWrap16, +} from '@koobiq/react-icons'; + +import { Button } from '../../Button'; +import { Tooltip } from '../../Tooltip'; +import s from '../CodeBlock.module.css'; +import type { CodeBlockFile } from '../types'; + +export type CodeBlockActionBarProps = { + file: CodeBlockFile; + fallbackFileName: string; + canToggleSoftWrap: boolean; + canDownload: boolean; + canCopy: boolean; + softWrap: boolean; + onSoftWrapToggle: () => void; + copyTooltip: string; + copiedTooltip: string; + downloadTooltip: string; + softWrapOnTooltip: string; + softWrapOffTooltip: string; + openExternalSystemTooltip: string; +}; + +function download(file: CodeBlockFile, fallbackFileName: string): void { + const blob = new Blob([file.content], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + + link.href = url; + link.download = file.filename || fallbackFileName; + link.click(); + + URL.revokeObjectURL(url); +} + +export function CodeBlockActionBar(props: CodeBlockActionBarProps) { + const { + file, + fallbackFileName, + canToggleSoftWrap, + canDownload, + canCopy, + softWrap, + onSoftWrapToggle, + copyTooltip, + copiedTooltip, + downloadTooltip, + softWrapOnTooltip, + softWrapOffTooltip, + openExternalSystemTooltip, + } = props; + + const [, copy] = useCopyToClipboard(); + const [copiedContent, setCopiedContent] = useState(null); + const copyRequestRef = useRef(0); + const isCopied = copiedContent === file.content; + + const copyCode = (): void => { + copyRequestRef.current += 1; + + const copyRequest = copyRequestRef.current; + + void copy(file.content).then((isSuccessful) => { + if (copyRequest === copyRequestRef.current) { + setCopiedContent(isSuccessful ? file.content : null); + } + }); + }; + + const resetCopyTooltip = (isOpen: boolean): void => { + if (!isOpen) { + copyRequestRef.current += 1; + setCopiedContent(null); + } + }; + + return ( +
+
+ {canToggleSoftWrap && ( + ( +
+
+ ); +} diff --git a/packages/components/src/components/CodeBlock/components/CodeBlockTabs.tsx b/packages/components/src/components/CodeBlock/components/CodeBlockTabs.tsx new file mode 100644 index 000000000..5cd6d013b --- /dev/null +++ b/packages/components/src/components/CodeBlock/components/CodeBlockTabs.tsx @@ -0,0 +1,89 @@ +'use client'; + +import type { CSSProperties, ReactNode, Ref, UIEventHandler } from 'react'; + +import { clsx } from '@koobiq/react-core'; + +import { Tab, Tabs } from '../../Tabs'; +import s from '../CodeBlock.module.css'; +import type { CodeBlockFile } from '../types'; + +export type CodeBlockTabsProps = { + files: CodeBlockFile[]; + activeFileIndex: number; + onActiveFileIndexChange: (index: number) => void; + fallbackFileName: string; + renderTabLabel?: (file: CodeBlockFile, fallbackFileName: string) => ReactNode; + panelRef: Ref; + panelContent: ReactNode; + panelMaxHeight?: number; + onPanelScroll: UIEventHandler; + isScrolled: boolean; + actionCount: number; + 'aria-label': string; +}; + +export function CodeBlockTabs(props: CodeBlockTabsProps) { + const { + files, + activeFileIndex, + onActiveFileIndexChange, + fallbackFileName, + renderTabLabel, + panelRef, + panelContent, + panelMaxHeight, + onPanelScroll, + isScrolled, + actionCount, + 'aria-label': ariaLabel, + } = props; + + const actionBarInlineSize = + actionCount > 0 + ? `calc(${actionCount} * var(--kbq-size-3xl) + ${Math.max( + actionCount - 1, + 0 + )} * var(--kbq-size-3xs))` + : '0px'; + + const headerStyle = { + '--code-block-actionbar-inline-size': actionBarInlineSize, + } as CSSProperties; + + return ( + { + if (files.length > 1) onActiveFileIndexChange(Number(key)); + }} + slotProps={{ + tabs: { + className: clsx(s.header, isScrolled && s.headerScrolled), + style: headerStyle, + }, + tabPanel: { + ref: panelRef, + className: s.main, + style: { maxHeight: panelMaxHeight }, + onScroll: onPanelScroll, + }, + }} + > + {files.map((file, index) => ( + + {panelContent} + + ))} + + ); +} diff --git a/packages/components/src/components/CodeBlock/components/index.ts b/packages/components/src/components/CodeBlock/components/index.ts new file mode 100644 index 000000000..0c0b50903 --- /dev/null +++ b/packages/components/src/components/CodeBlock/components/index.ts @@ -0,0 +1,2 @@ +export * from './CodeBlockActionBar'; +export * from './CodeBlockTabs'; diff --git a/packages/components/src/components/CodeBlock/context.tsx b/packages/components/src/components/CodeBlock/context.tsx new file mode 100644 index 000000000..90e16eba6 --- /dev/null +++ b/packages/components/src/components/CodeBlock/context.tsx @@ -0,0 +1,67 @@ +'use client'; + +import { createContext, useContext } from 'react'; +import type { ReactNode } from 'react'; + +import type { HLJSApi, LanguageFn } from 'highlight.js'; + +/** `highlight.js` loading configuration for `CodeBlock`. */ +export type CodeBlockHighlightConfig = Partial<{ + /** Lazy loader for the highlight.js core (no bundled languages). When omitted, the full bundle is loaded. */ + core: () => Promise<{ default: HLJSApi }>; + /** Map of language name to a lazy loader for that language's `LanguageFn`. */ + languages: Record Promise<{ default: LanguageFn }>>; + /** Language used when a file language is missing or unsupported. */ + fallbackLanguage: string; +}>; + +const CodeBlockHighlightConfigContext = + createContext(null); + +// A stable reference so `useCodeBlockHighlightConfig` doesn't hand back a new object identity on every +// call when no provider is present — `useHighlightedCode` keys its load cache off that identity. +const EMPTY_CONFIG: CodeBlockHighlightConfig = {}; + +export type CodeBlockHighlightConfigProviderProps = { + /** The `highlight.js` loading configuration applied to every `CodeBlock` inside. */ + config: CodeBlockHighlightConfig; + children?: ReactNode; +}; + +/** + * Configures how `CodeBlock` loads `highlight.js`. + * + * By default, `CodeBlock` lazily loads the full `highlight.js` bundle with all languages (~1 MB). + * To reduce bundle size, wrap the app (or a part of it) with this provider and specify only the languages you need. + * @example + * ```tsx + * import('highlight.js/lib/core'), + * languages: { + * typescript: () => import('highlight.js/lib/languages/typescript'), + * css: () => import('highlight.js/lib/languages/css'), + * html: () => import('highlight.js/lib/languages/xml') + * }, + * fallbackLanguage: 'plaintext' + * }} + * > + * + * + * ``` + */ +export function CodeBlockHighlightConfigProvider( + props: CodeBlockHighlightConfigProviderProps +) { + const { config, children } = props; + + return ( + + {children} + + ); +} + +export function useCodeBlockHighlightConfig(): CodeBlockHighlightConfig { + return useContext(CodeBlockHighlightConfigContext) ?? EMPTY_CONFIG; +} diff --git a/packages/components/src/components/CodeBlock/hooks/index.ts b/packages/components/src/components/CodeBlock/hooks/index.ts new file mode 100644 index 000000000..3b42780c7 --- /dev/null +++ b/packages/components/src/components/CodeBlock/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './useHighlightedCode'; +export * from './useOverflowShadow'; diff --git a/packages/components/src/components/CodeBlock/hooks/useHighlightedCode.ts b/packages/components/src/components/CodeBlock/hooks/useHighlightedCode.ts new file mode 100644 index 000000000..21f8fb8d3 --- /dev/null +++ b/packages/components/src/components/CodeBlock/hooks/useHighlightedCode.ts @@ -0,0 +1,237 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import { once } from '@koobiq/logger'; +import type { HLJSApi } from 'highlight.js'; + +import { useCodeBlockHighlightConfig } from '../context'; +import type { CodeBlockHighlightConfig } from '../context'; +import type { CodeBlockFile } from '../types'; +import { addLineNumbers } from '../utils/lineNumbers'; + +const FALLBACK_LANGUAGE = 'plaintext'; + +const hljsPromises = new WeakMap>(); + +async function loadHljs(config: CodeBlockHighlightConfig): Promise { + const loadCore = config.core ?? (() => import('highlight.js')); + const { default: instance } = await loadCore(); + + if (config.languages) { + await Promise.all( + Object.entries(config.languages).map(async ([name, loadLanguage]) => { + const { default: language } = await loadLanguage(); + + instance.registerLanguage(name, language); + }) + ); + } + + return instance; +} + +/** + * Resolves the shared `highlight.js` instance for the app. + * + * Cached at module scope so every `CodeBlock` on the page reuses the same load, keyed by the + * `CodeBlockHighlightConfig` reference — pass a stable (e.g. module-level or memoized) `config` to + * `CodeBlockHighlightConfigProvider` to avoid redundant reloads. + */ +function getHljsInstance(config: CodeBlockHighlightConfig): Promise { + const cachedPromise = hljsPromises.get(config); + + if (cachedPromise) return cachedPromise; + + const promise = loadHljs(config).catch((error: unknown) => { + // A transient chunk/network failure should be retryable by a later mount. + hljsPromises.delete(config); + + throw error; + }); + + hljsPromises.set(config, promise); + + return promise; +} + +export type UseHighlightedCodeOptions = { + /** Whether to wrap the output in a line-numbered table. */ + hasLineNumbers?: boolean; + /** The starting line number. */ + startFrom?: number; +}; + +export type UseHighlightedCodeResult = { + /** Highlighted HTML, ready for `dangerouslySetInnerHTML`. Empty while `pending`. */ + html: string; + /** Whether `highlight.js` is still loading. */ + pending: boolean; + /** Whether loading or highlighting failed. */ + failed: boolean; + /** The language `highlight.js` actually used (may differ from `file.language` if unsupported). */ + language: string; +}; + +type HighlightedCodeState = UseHighlightedCodeResult & { + config: CodeBlockHighlightConfig; + content: string; + fileLanguage: string | undefined; + hasLineNumbers: boolean; + startFrom: number; +}; + +function escapeHTML(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +/** + * Loads `highlight.js` (honoring `CodeBlockHighlightConfigProvider`) and highlights `file`. + * + * `highlight.js` escapes the raw source text before wrapping it in `` tokens, so the returned + * `html` is safe to render with `dangerouslySetInnerHTML` — the same trust model other React syntax + * highlighters rely on. + */ +export function useHighlightedCode( + file: CodeBlockFile, + options: UseHighlightedCodeOptions = {} +): UseHighlightedCodeResult { + const { hasLineNumbers = false, startFrom = 1 } = options; + const config = useCodeBlockHighlightConfig(); + const fallbackLanguage = config.fallbackLanguage ?? FALLBACK_LANGUAGE; + + const [result, setResult] = useState({ + html: '', + pending: true, + failed: false, + language: file.language ?? fallbackLanguage, + config, + content: file.content, + fileLanguage: file.language, + hasLineNumbers, + startFrom, + }); + + const isCurrentResult = + result.config === config && + result.content === file.content && + result.fileLanguage === file.language && + result.hasLineNumbers === hasLineNumbers && + result.startFrom === startFrom; + + useEffect(() => { + let cancelled = false; + + getHljsInstance(config) + .then((hljs) => { + if (cancelled) return; + + let { language } = file; + + if (!language || !hljs.getLanguage(language)) { + if (process.env.NODE_ENV !== 'production') { + once.warn( + `[CodeBlock] Unknown file language: "${language}". Fall back to "${fallbackLanguage}".`, + file + ); + } + + language = fallbackLanguage; + } + + let value: string; + let resolvedLanguage = language; + + if (hljs.getLanguage(language)) { + const highlighted = hljs.highlight(file.content, { language }); + + value = highlighted.value; + resolvedLanguage = highlighted.language ?? language; + + if (process.env.NODE_ENV !== 'production' && highlighted.illegal) { + once.warn( + '[CodeBlock] File content contains illegal characters.', + file + ); + } + + if ( + process.env.NODE_ENV !== 'production' && + highlighted.relevance === 0 + ) { + once.warn( + '[CodeBlock] File content does not match the specified programming language.', + file + ); + } + } else { + value = escapeHTML(file.content); + } + + const html = hasLineNumbers + ? addLineNumbers(value, { startFrom }) + : value; + + setResult({ + html, + pending: false, + failed: false, + language: resolvedLanguage, + config, + content: file.content, + fileLanguage: file.language, + hasLineNumbers, + startFrom, + }); + }) + .catch((error: unknown) => { + if (cancelled) return; + + if (process.env.NODE_ENV !== 'production') { + once.warn('[CodeBlock] Failed to highlight the file.', error); + } + + setResult({ + html: '', + pending: false, + failed: true, + language: file.language ?? fallbackLanguage, + config, + content: file.content, + fileLanguage: file.language, + hasLineNumbers, + startFrom, + }); + }); + + return () => { + cancelled = true; + }; + // `file.content`/`file.language` (not `file`) so a fresh `file` object with the same values + // (e.g. an inline object literal from the caller) doesn't re-trigger highlighting. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + config, + fallbackLanguage, + file.content, + file.language, + hasLineNumbers, + startFrom, + ]); + + if (!isCurrentResult) { + return { + html: '', + pending: true, + failed: false, + language: file.language ?? fallbackLanguage, + }; + } + + return result; +} diff --git a/packages/components/src/components/CodeBlock/hooks/useOverflowShadow.ts b/packages/components/src/components/CodeBlock/hooks/useOverflowShadow.ts new file mode 100644 index 000000000..c992bd764 --- /dev/null +++ b/packages/components/src/components/CodeBlock/hooks/useOverflowShadow.ts @@ -0,0 +1,15 @@ +'use client'; + +import { useCallback, useState } from 'react'; +import type { UIEvent } from 'react'; + +/** Tracks whether a scrollable element is scrolled away from its top, to drive a header shadow. */ +export function useOverflowShadow() { + const [isScrolled, setIsScrolled] = useState(false); + + const onScroll = useCallback((event: UIEvent) => { + setIsScrolled(event.currentTarget.scrollTop > 0); + }, []); + + return { isScrolled, onScroll }; +} diff --git a/packages/components/src/components/CodeBlock/index.ts b/packages/components/src/components/CodeBlock/index.ts new file mode 100644 index 000000000..57a654824 --- /dev/null +++ b/packages/components/src/components/CodeBlock/index.ts @@ -0,0 +1,7 @@ +export * from './CodeBlock'; +export { + CodeBlockHighlightConfigProvider, + type CodeBlockHighlightConfig, + type CodeBlockHighlightConfigProviderProps, +} from './context'; +export * from './types'; diff --git a/packages/components/src/components/CodeBlock/intl.json b/packages/components/src/components/CodeBlock/intl.json new file mode 100644 index 000000000..3324891f1 --- /dev/null +++ b/packages/components/src/components/CodeBlock/intl.json @@ -0,0 +1,24 @@ +{ + "ru-RU": { + "softWrapOnTooltip": "Включить перенос строк", + "softWrapOffTooltip": "Выключить перенос строк", + "downloadTooltip": "Скачать", + "copyTooltip": "Копировать", + "copiedTooltip": "✓ Скопировано", + "viewAllText": "Показать всё", + "viewLessText": "Свернуть", + "filesLabel": "Файлы", + "openExternalSystemTooltip": "Открыть во внешней системе" + }, + "en-US": { + "softWrapOnTooltip": "Enable word wrap", + "softWrapOffTooltip": "Disable word wrap", + "downloadTooltip": "Download", + "copyTooltip": "Copy", + "copiedTooltip": "✓ Copied", + "viewAllText": "Show all", + "viewLessText": "Show less", + "filesLabel": "Files", + "openExternalSystemTooltip": "Open in the external system" + } +} diff --git a/packages/components/src/components/CodeBlock/types.ts b/packages/components/src/components/CodeBlock/types.ts new file mode 100644 index 000000000..d5974e4e5 --- /dev/null +++ b/packages/components/src/components/CodeBlock/types.ts @@ -0,0 +1,150 @@ +import type { CSSProperties, ReactNode, Ref } from 'react'; + +import type { DataAttributeProps } from '@koobiq/react-core'; + +/** A single file displayed inside a `CodeBlock`. */ +export type CodeBlockFile = { + /** Code content. */ + content: string; + /** + * File name, displayed in the tab header and used when downloading. + * If not provided, `fallbackFileName` is used instead. + */ + filename?: string; + /** + * File language, required for correct syntax highlighting. + * If not provided or unsupported, falls back to `plaintext`. + * + * List of supported languages: {@link https://highlightjs.readthedocs.io/en/stable/supported-languages.html} + */ + language?: string; + /** + * Link to the file, opened in a new tab. + * Adds the "open in external system" action. + */ + link?: string; +}; + +/** Options to scroll the code content to a given position. */ +export type CodeBlockScrollToOptions = ScrollOptions & { + /** Offset from the top edge. */ + top?: number; + /** Offset from the bottom edge. */ + bottom?: number; + /** Offset from the left edge. */ + left?: number; + /** Offset from the right edge. */ + right?: number; + /** Offset from the inline-start edge. */ + start?: number; + /** Offset from the inline-end edge. */ + end?: number; +}; + +/** Imperative handle exposed on the `CodeBlock` ref. */ +export type CodeBlockRef = { + /** The root DOM element. */ + element: HTMLDivElement | null; + /** Scrolls the code content to the specified position. */ + scrollTo: (options: CodeBlockScrollToOptions) => void; +}; + +export type CodeBlockProps = { + /** Files to display. */ + files: CodeBlockFile[]; + /** + * Whether to display line numbers. + * @default false + */ + hasLineNumbers?: boolean; + /** + * Whether the code block should be filled instead of outlined. + * @default false + */ + isFilled?: boolean; + /** + * Whether to hide the border. + * @default false + */ + hideBorder?: boolean; + /** + * Adds a soft-wrap toggle button to the action bar. + * @default false + */ + canToggleSoftWrap?: boolean; + /** + * Adds a download-file button to the action bar. + * @default false + */ + canDownload?: boolean; + /** + * Whether to hide the copy-to-clipboard button. + * @default false + */ + hideCopyButton?: boolean; + /** + * Whether the action bar should remain visible when tabs are hidden. + * @default false + */ + alwaysShowActionBar?: boolean; + /** + * Whether sequences of whitespace are preserved instead of wrapping. + * @default false + */ + softWrap?: boolean; + /** The uncontrolled default value for `softWrap`. */ + defaultSoftWrap?: boolean; + /** Handler called when the soft-wrap mode changes. */ + onSoftWrapChange?: (softWrap: boolean) => void; + /** + * Whether the full content is shown regardless of `maxHeight`. + * @default false + */ + viewAll?: boolean; + /** The uncontrolled default value for `viewAll`. */ + defaultViewAll?: boolean; + /** Handler called when `viewAll` changes. */ + onViewAllChange?: (viewAll: boolean) => void; + /** + * Maximum height (in pixels) of the code block content, in which case the rest is hidden. + * Can be toggled open with `viewAll`. + */ + maxHeight?: number; + /** + * Whether to hide the header tabs. Tabs are hidden automatically for a single file without a + * `filename`; control this prop with `false` to keep them visible. + * Makes the action bar floating and shown only on hover. + * @default false + */ + hideTabs?: boolean; + /** The uncontrolled default value for `hideTabs`. */ + defaultHideTabs?: boolean; + /** Handler called when `hideTabs` changes. */ + onHideTabsChange?: (hideTabs: boolean) => void; + /** + * The index of the active file. + * @default 0 + */ + activeFileIndex?: number; + /** The uncontrolled default value for `activeFileIndex`. */ + defaultActiveFileIndex?: number; + /** Handler called when the active file index changes. */ + onActiveFileIndexChange?: (activeFileIndex: number) => void; + /** Renders custom tab label content instead of the plain file name. */ + renderTabLabel?: (file: CodeBlockFile, fallbackFileName: string) => ReactNode; + /** + * Fallback file name used when a file has no `filename`, both for the tab label and for downloads. + * @default 'code' + */ + fallbackFileName?: string; + /** The starting line number. */ + startFrom?: number; + /** Additional CSS-classes. */ + className?: string; + /** Inline styles. */ + style?: CSSProperties; + /** Ref to the root element, also exposing the `scrollTo` method. */ + ref?: Ref; + /** Unique identifier for testing purposes. */ + 'data-testid'?: string | number; +} & DataAttributeProps; diff --git a/packages/components/src/components/CodeBlock/utils/lineNumbers.test.ts b/packages/components/src/components/CodeBlock/utils/lineNumbers.test.ts new file mode 100644 index 000000000..1b35e0f6f --- /dev/null +++ b/packages/components/src/components/CodeBlock/utils/lineNumbers.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; + +import { addLineNumbers } from './lineNumbers'; + +function parse(html: string): HTMLElement { + const container = document.createElement('div'); + + container.innerHTML = html; + + return container; +} + +describe('addLineNumbers', () => { + it('adds a numbered row for each line and honors startFrom', () => { + const result = parse( + addLineNumbers('const first = 1;\n\nconst third = 3;', { startFrom: 7 }) + ); + + const numberCells = result.querySelectorAll('.hljs-ln-numbers'); + const codeCells = result.querySelectorAll('.hljs-ln-code'); + + expect(numberCells).toHaveLength(3); + + expect( + Array.from(numberCells).map((cell) => + cell.getAttribute('data-line-number') + ) + ).toEqual(['7', '8', '9']); + + expect(Array.from(codeCells).map((cell) => cell.textContent)).toEqual([ + 'const first = 1;', + ' ', + 'const third = 3;', + ]); + }); + + it('leaves single-line markup unchanged unless singleLine is enabled', () => { + const html = 'const value = 1;'; + + expect(addLineNumbers(html)).toBe(html); + + expect(parse(addLineNumbers(html, { singleLine: true }))).toHaveTextContent( + 'const value = 1;' + ); + + expect( + parse(addLineNumbers(html, { singleLine: true })).querySelectorAll('tr') + ).toHaveLength(1); + }); + + it('duplicates multiline highlight spans into the corresponding rows', () => { + const result = parse( + addLineNumbers('first\nsecond') + ); + + const codeCells = result.querySelectorAll('.hljs-ln-code'); + + expect(codeCells).toHaveLength(2); + + expect(Array.from(codeCells).map((cell) => cell.textContent)).toEqual([ + 'first', + 'second', + ]); + + expect(codeCells[0]?.querySelector('.hljs-string')).not.toBeNull(); + expect(codeCells[1]?.querySelector('.hljs-string')).not.toBeNull(); + }); + + it('handles empty content and ignores a trailing blank line', () => { + expect(addLineNumbers('')).toBe(''); + + const result = parse(addLineNumbers('first\nsecond\n')); + + expect(result.querySelectorAll('tr')).toHaveLength(2); + }); +}); diff --git a/packages/components/src/components/CodeBlock/utils/lineNumbers.ts b/packages/components/src/components/CodeBlock/utils/lineNumbers.ts new file mode 100644 index 000000000..7854910d9 --- /dev/null +++ b/packages/components/src/components/CodeBlock/utils/lineNumbers.ts @@ -0,0 +1,107 @@ +const TABLE_NAME = 'hljs-ln'; +const LINE_NAME = 'hljs-ln-line'; +const CODE_BLOCK_NAME = 'hljs-ln-code'; +const NUMBERS_BLOCK_NAME = 'hljs-ln-numbers'; +const NUMBER_LINE_NAME = 'hljs-ln-n'; +const DATA_ATTR_NAME = 'data-line-number'; +const BREAK_LINE_REGEXP = /\r\n|\r|\n/g; + +export type AddLineNumbersOptions = { + /** The starting line number. */ + startFrom?: number; + /** Whether to display line numbers for single line code. */ + singleLine?: boolean; +}; + +function getLines(text: string): string[] { + if (text.length === 0) return []; + + return text.split(BREAK_LINE_REGEXP); +} + +function getLinesCount(text: string): number { + return (text.trim().match(BREAK_LINE_REGEXP) || []).length; +} + +/** Splits a multiline `hljs-*` span into one span per line, so each line can be wrapped in its own table row. */ +function duplicateMultilineNode(element: Element): void { + const { className } = element; + + if (!/hljs-/.test(className)) return; + + const lines = getLines(element.innerHTML); + + element.innerHTML = lines + .map( + (line) => + `${line.length > 0 ? line : ' '}` + ) + .join('\n') + .trim(); +} + +/** Recursively fixes multi-line token spans produced by `highlight.js`. */ +function duplicateMultilineNodes(element: Element): void { + element.childNodes.forEach((node) => { + if (getLinesCount(node.textContent ?? '') === 0) return; + + if (node.childNodes.length > 0) { + duplicateMultilineNodes(node as Element); + } else if (node.parentElement) { + duplicateMultilineNode(node.parentElement); + } + }); +} + +function addLineNumbersBlockFor( + inputHtml: string, + options: Required +): string { + const lines = getLines(inputHtml); + + // If the last line contains only a line break, remove it. + if (lines[lines.length - 1]?.trim() === '') { + lines.pop(); + } + + if (lines.length <= 1 && !options.singleLine) return inputHtml; + + const rows = lines + .map((line, index) => { + const lineNumber = index + options.startFrom; + const codeLine = line.length > 0 ? line : ' '; + + return ( + `` + + `` + + `
` + + `` + + `${codeLine}` + + `` + ); + }) + .join(''); + + return `${rows}
`; +} + +/** + * Wraps highlighted `highlight.js` HTML output into a line-numbered ``. + * + * Ported from {@link https://github.com/wcoder/highlightjs-line-numbers.js} (v2.9.0), trimmed of the + * legacy Microsoft Edge copy/paste workaround — not needed for this package's browserslist targets. + */ +export function addLineNumbers( + html: string, + options: AddLineNumbersOptions = {} +): string { + const { startFrom = 1, singleLine = false } = options; + + const container = document.createElement('code'); + + container.innerHTML = html; + + duplicateMultilineNodes(container); + + return addLineNumbersBlockFor(container.innerHTML, { startFrom, singleLine }); +} diff --git a/packages/components/src/components/Tabs/Tabs.test.tsx b/packages/components/src/components/Tabs/Tabs.test.tsx index 0d443bfed..264d1db02 100644 --- a/packages/components/src/components/Tabs/Tabs.test.tsx +++ b/packages/components/src/components/Tabs/Tabs.test.tsx @@ -1,3 +1,5 @@ +import { createRef } from 'react'; + import { act, fireEvent, render, screen, within } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; @@ -94,6 +96,35 @@ describe('Tabs', () => { expect(screen.getByText('Overview content')).toBeInTheDocument(); }); + it('should forward tabPanel slot DOM props and ref', () => { + const panelRef = createRef(); + const onScroll = vi.fn(); + + render( + + + Overview content + + + ); + + const panel = screen.getByRole('tabpanel'); + + expect(panelRef.current).toBe(panel); + + fireEvent.scroll(panel); + + expect(onScroll).toHaveBeenCalledOnce(); + }); + it('should render startAddon and endAddon', () => { render( diff --git a/packages/components/src/components/Tabs/components/TabPanel/TabPanel.tsx b/packages/components/src/components/Tabs/components/TabPanel/TabPanel.tsx index 92143a12f..c15cb8ee5 100644 --- a/packages/components/src/components/Tabs/components/TabPanel/TabPanel.tsx +++ b/packages/components/src/components/Tabs/components/TabPanel/TabPanel.tsx @@ -1,8 +1,14 @@ 'use client'; -import { type CSSProperties, useRef } from 'react'; - -import { clsx } from '@koobiq/react-core'; +import { + type ComponentPropsWithRef, + forwardRef, + type ReactElement, + type Ref, + useRef, +} from 'react'; + +import { clsx, mergeProps, mergeRefs } from '@koobiq/react-core'; import type { AriaTabPanelProps, TabListState } from '@koobiq/react-primitives'; import { useTabPanel } from '@koobiq/react-primitives'; @@ -12,30 +18,31 @@ import s from './TabPanel.module.css'; const textNormal = utilClasses.typography['text-normal']; -export type TabPanelProps = AriaTabPanelProps & { - state: TabListState; - className?: string; - style?: CSSProperties; -}; +export type TabPanelProps = AriaTabPanelProps & + Omit, 'children'> & { + state: TabListState; + }; + +type TabPanelComponent = (props: TabPanelProps) => ReactElement | null; -export function TabPanel({ - state, - style, - className, - ...props -}: TabPanelProps) { - const ref = useRef(null); +function TabPanelRender( + { state, style, className, ...props }: Omit, 'ref'>, + ref: Ref +) { + const innerRef = useRef(null); - const { tabPanelProps } = useTabPanel(props, state, ref); + const { tabPanelProps } = useTabPanel(props, state, innerRef); return (
{state.selectedItem?.props.children}
); } + +export const TabPanel = forwardRef(TabPanelRender) as TabPanelComponent; diff --git a/packages/components/vite.config.ts b/packages/components/vite.config.ts index f7a70e27c..edb8d1ea2 100644 --- a/packages/components/vite.config.ts +++ b/packages/components/vite.config.ts @@ -25,6 +25,7 @@ export default defineConfig({ entry: { index: path.resolve(__dirname, 'src/index.ts'), markdown: path.resolve(__dirname, 'src/markdown.ts'), + 'code-block': path.resolve(__dirname, 'src/code-block.ts'), }, fileName: (_, entryName: string) => `${entryName}.js`, formats: ['es'], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 83c740818..39536a490 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -169,6 +169,9 @@ importers: globals: specifier: ^17.9.0 version: 17.9.0 + highlight.js: + specifier: ^11.11.1 + version: 11.12.0 husky: specifier: ^9.1.6 version: 9.1.7 @@ -280,6 +283,9 @@ importers: '@types/react-transition-group': specifier: ^4.4.12 version: 4.4.12(@types/react@19.2.7) + highlight.js: + specifier: ^11.0.0 + version: 11.12.0 react: specifier: ^19.0.0 version: 19.2.3 @@ -4656,6 +4662,10 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + highlight.js@11.12.0: + resolution: {integrity: sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==} + engines: {node: '>=12.0.0'} + hookified@1.15.1: resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} @@ -12104,6 +12114,8 @@ snapshots: dependencies: hermes-estree: 0.25.1 + highlight.js@11.12.0: {} + hookified@1.15.1: {} hosted-git-info@2.8.9: {} diff --git a/tools/api-extractor/config.json b/tools/api-extractor/config.json index 035a2e3b4..3376176da 100644 --- a/tools/api-extractor/config.json +++ b/tools/api-extractor/config.json @@ -15,6 +15,7 @@ "CheckboxGroup", "ClampedText", "ClampedList", + "CodeBlock", "Container", "ContentPanel", "DateInput", diff --git a/tools/public_api_guard/components/CodeBlock.api.md b/tools/public_api_guard/components/CodeBlock.api.md new file mode 100644 index 000000000..fba05e83e --- /dev/null +++ b/tools/public_api_guard/components/CodeBlock.api.md @@ -0,0 +1,98 @@ +## API Report File for "koobiq-react" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { CSSProperties } from 'react'; +import type { DataAttributeProps } from '@koobiq/react-core'; +import { ForwardRefExoticComponent } from 'react'; +import type { HLJSApi } from 'highlight.js'; +import { JSX } from 'react/jsx-runtime'; +import type { LanguageFn } from 'highlight.js'; +import type { ReactNode } from 'react'; +import type { Ref } from 'react'; +import { RefAttributes } from 'react'; + +// @public (undocumented) +export const CodeBlock: ForwardRefExoticComponent & RefAttributes>; + +// @public +export type CodeBlockFile = { + content: string; + filename?: string; + language?: string; + link?: string; +}; + +// @public +export type CodeBlockHighlightConfig = Partial<{ + core: () => Promise<{ + default: HLJSApi; + }>; + languages: Record Promise<{ + default: LanguageFn; + }>>; + fallbackLanguage: string; +}>; + +// @public +export function CodeBlockHighlightConfigProvider(props: CodeBlockHighlightConfigProviderProps): JSX.Element; + +// @public (undocumented) +export type CodeBlockHighlightConfigProviderProps = { + config: CodeBlockHighlightConfig; + children?: ReactNode; +}; + +// @public (undocumented) +export type CodeBlockProps = { + files: CodeBlockFile[]; + hasLineNumbers?: boolean; + isFilled?: boolean; + hideBorder?: boolean; + canToggleSoftWrap?: boolean; + canDownload?: boolean; + hideCopyButton?: boolean; + alwaysShowActionBar?: boolean; + softWrap?: boolean; + defaultSoftWrap?: boolean; + onSoftWrapChange?: (softWrap: boolean) => void; + viewAll?: boolean; + defaultViewAll?: boolean; + onViewAllChange?: (viewAll: boolean) => void; + maxHeight?: number; + hideTabs?: boolean; + defaultHideTabs?: boolean; + onHideTabsChange?: (hideTabs: boolean) => void; + activeFileIndex?: number; + defaultActiveFileIndex?: number; + onActiveFileIndexChange?: (activeFileIndex: number) => void; + renderTabLabel?: (file: CodeBlockFile, fallbackFileName: string) => ReactNode; + fallbackFileName?: string; + startFrom?: number; + className?: string; + style?: CSSProperties; + ref?: Ref; + 'data-testid'?: string | number; +} & DataAttributeProps; + +// @public +export type CodeBlockRef = { + element: HTMLDivElement | null; + scrollTo: (options: CodeBlockScrollToOptions) => void; +}; + +// @public +export type CodeBlockScrollToOptions = ScrollOptions & { + top?: number; + bottom?: number; + left?: number; + right?: number; + start?: number; + end?: number; +}; + +// (No @packageDocumentation comment for this package) + +``` From 7b56c158eb229c46bc42bd9fb19f1743bf3f79e0 Mon Sep 17 00:00:00 2001 From: Artem Belik Date: Thu, 20 Aug 2026 15:26:02 +0300 Subject: [PATCH 2/7] refactor: review --- .../src/components/CodeBlock/CodeBlock.mdx | 32 +++--- .../components/CodeBlock/CodeBlock.module.css | 2 +- .../components/CodeBlock/CodeBlock.test.tsx | 101 ++++++++++++++++-- .../src/components/CodeBlock/CodeBlock.tsx | 18 +++- .../components/CodeBlockActionBar.tsx | 4 +- .../CodeBlock/components/CodeBlockTabs.tsx | 19 +++- .../src/components/CodeBlock/context.tsx | 22 ++-- .../CodeBlock/hooks/useHighlightedCode.ts | 4 +- .../CodeBlock/utils/lineNumbers.test.ts | 21 ++++ .../components/CodeBlock/utils/lineNumbers.ts | 2 +- 10 files changed, 183 insertions(+), 42 deletions(-) diff --git a/packages/components/src/components/CodeBlock/CodeBlock.mdx b/packages/components/src/components/CodeBlock/CodeBlock.mdx index 20cdced24..84b82fe81 100644 --- a/packages/components/src/components/CodeBlock/CodeBlock.mdx +++ b/packages/components/src/components/CodeBlock/CodeBlock.mdx @@ -16,7 +16,7 @@ import * as Stories from './CodeBlock.stories'; `CodeBlock` is a component that displays reformatted text content with syntax highlighting. - For the component to work, the + For syntax highlighting, the [`highlight.js@^11`](https://github.com/highlightjs/highlight.js/tree/stable-11) peer dependency is required: ```bash @@ -32,19 +32,23 @@ reduce bundle size, wrap the app (or a part of it) with `CodeBlockHighlightConfi only the languages you need: ```tsx - import('highlight.js/lib/core'), - languages: { - typescript: () => import('highlight.js/lib/languages/typescript'), - css: () => import('highlight.js/lib/languages/css'), - html: () => import('highlight.js/lib/languages/xml'), - }, - fallbackLanguage: 'plaintext', - }} -> - - +const highlightConfig = { + core: () => import('highlight.js/lib/core'), + languages: { + typescript: () => import('highlight.js/lib/languages/typescript'), + css: () => import('highlight.js/lib/languages/css'), + xml: () => import('highlight.js/lib/languages/xml'), + }, + fallbackLanguage: 'plaintext', +}; + +function App() { + return ( + + + + ); +} ``` Languages are loaded lazily on first use. Note that `html` is an alias of `xml` in `highlight.js`, so diff --git a/packages/components/src/components/CodeBlock/CodeBlock.module.css b/packages/components/src/components/CodeBlock/CodeBlock.module.css index f1e629c5f..5d66a3299 100644 --- a/packages/components/src/components/CodeBlock/CodeBlock.module.css +++ b/packages/components/src/components/CodeBlock/CodeBlock.module.css @@ -257,7 +257,7 @@ display: flex; align-items: center; justify-content: end; - z-index: 1; + z-index: var(--kbq-layer-absolute); } .actionbarButtonStack { diff --git a/packages/components/src/components/CodeBlock/CodeBlock.test.tsx b/packages/components/src/components/CodeBlock/CodeBlock.test.tsx index 90db4cfda..fd53b68ac 100644 --- a/packages/components/src/components/CodeBlock/CodeBlock.test.tsx +++ b/packages/components/src/components/CodeBlock/CodeBlock.test.tsx @@ -343,6 +343,8 @@ describe('CodeBlock', () => { }); it('escapes source text when neither the requested nor fallback language is registered', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const hljs = { getLanguage: vi.fn(() => undefined), highlight: vi.fn(), @@ -355,10 +357,11 @@ describe('CodeBlock', () => { }; const content = ''; + const file = { content, language: 'unknown' }; render( - + ); @@ -370,6 +373,49 @@ describe('CodeBlock', () => { expect(getCode()?.innerHTML).toContain('<script>'); expect(getCode()?.querySelector('script')).toBeNull(); expect(hljs.highlight).not.toHaveBeenCalled(); + + expect(warnSpy).toHaveBeenCalledWith( + '[koobiq] [CodeBlock] Unsupported file language: "unknown". Fall back to "text".', + file + ); + }); + + it('reports a missing language without printing undefined', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const hljs = { + getLanguage: vi.fn((language: string) => + language === 'plaintext' ? {} : undefined + ), + highlight: vi.fn(() => ({ + value: 'plain text', + language: 'plaintext', + illegal: false, + relevance: 1, + })), + registerLanguage: vi.fn(), + } as unknown as HLJSApi; + + const config: CodeBlockHighlightConfig = { + core: vi.fn().mockResolvedValue({ default: hljs }), + }; + + const file = { content: 'plain text' }; + + render( + + + + ); + + await waitFor(() => + expect(getCode()).toHaveAttribute('data-language', 'plaintext') + ); + + expect(warnSpy).toHaveBeenCalledWith( + '[koobiq] [CodeBlock] Missing file language. Fall back to "plaintext".', + file + ); }); it('keeps the raw source visible when highlighting fails', async () => { @@ -525,9 +571,14 @@ describe('CodeBlock', () => { revokeObjectURL, }); + const clickedLinks: HTMLAnchorElement[] = []; + const clickSpy = vi .spyOn(HTMLAnchorElement.prototype, 'click') - .mockImplementation(() => {}); + .mockImplementation(function (this: HTMLAnchorElement) { + clickedLinks.push(this); + expect(this.isConnected).toBe(true); + }); const { rerender } = render( @@ -545,7 +596,11 @@ describe('CodeBlock', () => { href: 'blob:mock-url', }); - expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock-url'); + expect(clickedLinks[0]?.isConnected).toBe(false); + + await waitFor(() => + expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock-url') + ); rerender( { expect(clickSpy.mock.instances[1]).toMatchObject({ download: 'main.js', }); + + await waitFor(() => expect(revokeObjectURL).toHaveBeenCalledTimes(2)); }); it('opens the file link in a new tab', async () => { @@ -831,8 +888,9 @@ describe('CodeBlock', () => { await waitFor(() => expect(getCode()).not.toBeNull()); const panel = screen.getByRole('region', { name: 'code' }); - const header = screen.getByTestId('code-block-actionbar').parentElement!; - const initialClassName = header.className; + const header = screen.getByTestId('code-block-header'); + + expect(header).not.toHaveAttribute('data-scrolled'); Object.defineProperty(panel, 'scrollTop', { configurable: true, @@ -841,11 +899,11 @@ describe('CodeBlock', () => { }); fireEvent.scroll(panel); - expect(header.className).not.toBe(initialClassName); + expect(header).toHaveAttribute('data-scrolled'); panel.scrollTop = 0; fireEvent.scroll(panel); - expect(header.className).toBe(initialClassName); + expect(header).not.toHaveAttribute('data-scrolled'); }); it('clamps an out-of-range active file index', async () => { @@ -866,6 +924,35 @@ describe('CodeBlock', () => { expect(onActiveFileIndexChange).toHaveBeenCalledWith(1); }); + it('does not emit a derived change for a controlled out-of-range active file index', async () => { + const firstOnChange = vi.fn(); + const secondOnChange = vi.fn(); + + const { rerender } = render( + + ); + + await waitFor(() => + expect(getCode()).toHaveAttribute('data-language', 'javascript') + ); + + expect(firstOnChange).not.toHaveBeenCalled(); + + rerender( + + ); + + expect(secondOnChange).not.toHaveBeenCalled(); + }); + it('keeps the action bar visible when tabs are hidden if requested', async () => { render(); diff --git a/packages/components/src/components/CodeBlock/CodeBlock.tsx b/packages/components/src/components/CodeBlock/CodeBlock.tsx index 8630154aa..669291fda 100644 --- a/packages/components/src/components/CodeBlock/CodeBlock.tsx +++ b/packages/components/src/components/CodeBlock/CodeBlock.tsx @@ -135,10 +135,18 @@ function CodeBlockRender(props: CodeBlockProps, ref: Ref) { const activeFile = files[activeFileIndex] ?? EMPTY_FILE; useEffect(() => { - if (activeFileIndexState !== activeFileIndex) { + if ( + activeFileIndexProp === undefined && + activeFileIndexState !== activeFileIndex + ) { setActiveFileIndex(activeFileIndex); } - }, [activeFileIndex, activeFileIndexState, setActiveFileIndex]); + }, [ + activeFileIndex, + activeFileIndexProp, + activeFileIndexState, + setActiveFileIndex, + ]); // A single file without a name has nothing to switch between or label — hide the tabs, same as Angular. const isSingleUnnamedFile = files.length === 1 && !activeFile.filename; @@ -309,7 +317,11 @@ function CodeBlockRender(props: CodeBlockProps, ref: Ref) { > {isTabsHidden ? ( <> -
+
{actionBar}
URL.revokeObjectURL(url), 0); } export function CodeBlockActionBar(props: CodeBlockActionBarProps) { diff --git a/packages/components/src/components/CodeBlock/components/CodeBlockTabs.tsx b/packages/components/src/components/CodeBlock/components/CodeBlockTabs.tsx index 5cd6d013b..817111da1 100644 --- a/packages/components/src/components/CodeBlock/components/CodeBlockTabs.tsx +++ b/packages/components/src/components/CodeBlock/components/CodeBlockTabs.tsx @@ -1,8 +1,15 @@ 'use client'; -import type { CSSProperties, ReactNode, Ref, UIEventHandler } from 'react'; +import type { + ComponentProps, + CSSProperties, + ReactNode, + Ref, + UIEventHandler, +} from 'react'; import { clsx } from '@koobiq/react-core'; +import type { DataAttributeProps } from '@koobiq/react-core'; import { Tab, Tabs } from '../../Tabs'; import s from '../CodeBlock.module.css'; @@ -51,6 +58,13 @@ export function CodeBlockTabs(props: CodeBlockTabsProps) { '--code-block-actionbar-inline-size': actionBarInlineSize, } as CSSProperties; + const headerProps = { + className: clsx(s.header, isScrolled && s.headerScrolled), + style: headerStyle, + 'data-testid': 'code-block-header', + 'data-scrolled': isScrolled || undefined, + } satisfies ComponentProps<'div'> & DataAttributeProps; + return ( import('highlight.js/lib/core'), - * languages: { - * typescript: () => import('highlight.js/lib/languages/typescript'), - * css: () => import('highlight.js/lib/languages/css'), - * html: () => import('highlight.js/lib/languages/xml') - * }, - * fallbackLanguage: 'plaintext' - * }} - * > + * const highlightConfig = { + * core: () => import('highlight.js/lib/core'), + * languages: { + * typescript: () => import('highlight.js/lib/languages/typescript'), + * css: () => import('highlight.js/lib/languages/css'), + * xml: () => import('highlight.js/lib/languages/xml') + * }, + * fallbackLanguage: 'plaintext' + * }; + * + * * * * ``` diff --git a/packages/components/src/components/CodeBlock/hooks/useHighlightedCode.ts b/packages/components/src/components/CodeBlock/hooks/useHighlightedCode.ts index 21f8fb8d3..17aa5cac6 100644 --- a/packages/components/src/components/CodeBlock/hooks/useHighlightedCode.ts +++ b/packages/components/src/components/CodeBlock/hooks/useHighlightedCode.ts @@ -136,7 +136,9 @@ export function useHighlightedCode( if (!language || !hljs.getLanguage(language)) { if (process.env.NODE_ENV !== 'production') { once.warn( - `[CodeBlock] Unknown file language: "${language}". Fall back to "${fallbackLanguage}".`, + language + ? `[CodeBlock] Unsupported file language: "${language}". Fall back to "${fallbackLanguage}".` + : `[CodeBlock] Missing file language. Fall back to "${fallbackLanguage}".`, file ); } diff --git a/packages/components/src/components/CodeBlock/utils/lineNumbers.test.ts b/packages/components/src/components/CodeBlock/utils/lineNumbers.test.ts index 1b35e0f6f..a009bfb74 100644 --- a/packages/components/src/components/CodeBlock/utils/lineNumbers.test.ts +++ b/packages/components/src/components/CodeBlock/utils/lineNumbers.test.ts @@ -66,6 +66,27 @@ describe('addLineNumbers', () => { expect(codeCells[1]?.querySelector('.hljs-string')).not.toBeNull(); }); + it('duplicates a highlight span that ends with a line break', () => { + const result = parse( + addLineNumbers('first\nsecond') + ); + + const codeCells = result.querySelectorAll('.hljs-ln-code'); + + expect(codeCells).toHaveLength(2); + + expect(Array.from(codeCells).map((cell) => cell.textContent)).toEqual([ + 'first', + ' second', + ]); + + expect(codeCells[0]?.querySelector('.hljs-string')).toHaveTextContent( + 'first' + ); + + expect(codeCells[1]?.querySelector('.hljs-string')?.textContent).toBe(' '); + }); + it('handles empty content and ignores a trailing blank line', () => { expect(addLineNumbers('')).toBe(''); diff --git a/packages/components/src/components/CodeBlock/utils/lineNumbers.ts b/packages/components/src/components/CodeBlock/utils/lineNumbers.ts index 7854910d9..f5d61de1a 100644 --- a/packages/components/src/components/CodeBlock/utils/lineNumbers.ts +++ b/packages/components/src/components/CodeBlock/utils/lineNumbers.ts @@ -20,7 +20,7 @@ function getLines(text: string): string[] { } function getLinesCount(text: string): number { - return (text.trim().match(BREAK_LINE_REGEXP) || []).length; + return (text.match(BREAK_LINE_REGEXP) || []).length; } /** Splits a multiline `hljs-*` span into one span per line, so each line can be wrapped in its own table row. */ From a6d7773f7ebfbfca7688fc1a6a0e66e82c2eb70d Mon Sep 17 00:00:00 2001 From: Artem Belik Date: Fri, 21 Aug 2026 14:33:29 +0300 Subject: [PATCH 3/7] refactor: tooltip --- .../components/CodeBlock/CodeBlock.module.css | 9 +++-- .../components/CodeBlock/CodeBlock.test.tsx | 19 +++++++++++ .../src/components/CodeBlock/CodeBlock.tsx | 33 ++++++++++++++++++- 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/packages/components/src/components/CodeBlock/CodeBlock.module.css b/packages/components/src/components/CodeBlock/CodeBlock.module.css index 5d66a3299..3c3dabefa 100644 --- a/packages/components/src/components/CodeBlock/CodeBlock.module.css +++ b/packages/components/src/components/CodeBlock/CodeBlock.module.css @@ -95,7 +95,8 @@ } .actionbar { - display: none; + visibility: hidden; + pointer-events: none; position: absolute; inset-block-start: 0; inset-inline-end: 0; @@ -126,12 +127,14 @@ &:hover .actionbar, &:focus-within .actionbar, &[data-always-show-action-bar] .actionbar { - display: flex; + visibility: visible; + pointer-events: auto; } @media (hover: none) { .actionbar { - display: flex; + visibility: visible; + pointer-events: auto; } } } diff --git a/packages/components/src/components/CodeBlock/CodeBlock.test.tsx b/packages/components/src/components/CodeBlock/CodeBlock.test.tsx index fd53b68ac..4e16b9220 100644 --- a/packages/components/src/components/CodeBlock/CodeBlock.test.tsx +++ b/packages/components/src/components/CodeBlock/CodeBlock.test.tsx @@ -500,6 +500,25 @@ describe('CodeBlock', () => { vi.unstubAllGlobals(); }); + it('closes an open tooltip when pointer enters its portal', async () => { + render(); + + const copyButton = await screen.findByRole('button', { name: 'Copy' }); + + await user.hover(copyButton); + + await waitFor(() => + expect(screen.getByRole('tooltip')).toHaveAttribute( + 'data-transition', + 'entered' + ) + ); + + await user.hover(screen.getByRole('tooltip')); + + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + it('toggles an uncontrolled defaultSoftWrap value and reports the change', async () => { const onSoftWrapChange = vi.fn(); diff --git a/packages/components/src/components/CodeBlock/CodeBlock.tsx b/packages/components/src/components/CodeBlock/CodeBlock.tsx index 669291fda..535cac66f 100644 --- a/packages/components/src/components/CodeBlock/CodeBlock.tsx +++ b/packages/components/src/components/CodeBlock/CodeBlock.tsx @@ -1,6 +1,12 @@ 'use client'; -import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'; +import { + forwardRef, + useEffect, + useImperativeHandle, + useRef, + useState, +} from 'react'; import type { Ref } from 'react'; import { once } from '@koobiq/logger'; @@ -97,6 +103,7 @@ function CodeBlockRender(props: CodeBlockProps, ref: Ref) { const mainRef = useRef(null); const pendingScrollRef = useRef(null); const tabsHiddenAutomaticallyRef = useRef(false); + const [actionBarSession, setActionBarSession] = useState(0); const [softWrap, setSoftWrap] = useControlledState( softWrapProp, @@ -157,6 +164,29 @@ function CodeBlockRender(props: CodeBlockProps, ref: Ref) { const isTabsHidden = hideTabs || shouldAutoHideTabs; + useEffect(() => { + const root = rootRef.current; + + if (!root) return; + + const onMouseLeave = () => { + const actionBarRemainsVisible = + !isTabsHidden || + alwaysShowActionBar || + root.contains(document.activeElement); + + if (!actionBarRemainsVisible) { + // React treats a portal as part of the component tree, so its synthetic mouseleave does not fire + // when the pointer enters a tooltip. A native listener follows the DOM tree and closes it correctly. + setActionBarSession((session) => session + 1); + } + }; + + root.addEventListener('mouseleave', onMouseLeave); + + return () => root.removeEventListener('mouseleave', onMouseLeave); + }, [alwaysShowActionBar, isTabsHidden]); + useEffect(() => { if (hideTabsProp !== undefined) { tabsHiddenAutomaticallyRef.current = false; @@ -246,6 +276,7 @@ function CodeBlockRender(props: CodeBlockProps, ref: Ref) { const actionBar = ( Date: Fri, 21 Aug 2026 15:55:21 +0300 Subject: [PATCH 4/7] refactor: review --- .../src/components/CodeBlock/CodeBlock.mdx | 6 +-- .../components/CodeBlock/CodeBlock.module.css | 7 +-- .../CodeBlock/CodeBlock.stories.tsx | 40 ++++++++------- .../components/CodeBlock/CodeBlock.test.tsx | 50 +++++++++++++------ .../src/components/CodeBlock/CodeBlock.tsx | 38 +++++--------- .../components/CodeBlockActionBar.tsx | 12 ++++- .../src/components/CodeBlock/context.tsx | 4 +- .../CodeBlock/hooks/useHighlightedCode.ts | 4 +- .../src/components/CodeBlock/index.ts | 1 - .../components/CodeBlock.api.md | 7 ++- 10 files changed, 94 insertions(+), 75 deletions(-) diff --git a/packages/components/src/components/CodeBlock/CodeBlock.mdx b/packages/components/src/components/CodeBlock/CodeBlock.mdx index 84b82fe81..67ae7c67c 100644 --- a/packages/components/src/components/CodeBlock/CodeBlock.mdx +++ b/packages/components/src/components/CodeBlock/CodeBlock.mdx @@ -28,7 +28,7 @@ npm install highlight.js@^11 ### Configuring highlight.js By default, `CodeBlock` lazily loads the full `highlight.js` bundle with all languages (~1 MB). To -reduce bundle size, wrap the app (or a part of it) with `CodeBlockHighlightConfigProvider` and specify +reduce bundle size, wrap the app (or a part of it) with `CodeBlock.HighlightConfigProvider` and specify only the languages you need: ```tsx @@ -44,9 +44,9 @@ const highlightConfig = { function App() { return ( - + - + ); } ``` diff --git a/packages/components/src/components/CodeBlock/CodeBlock.module.css b/packages/components/src/components/CodeBlock/CodeBlock.module.css index 3c3dabefa..8b5a98ba8 100644 --- a/packages/components/src/components/CodeBlock/CodeBlock.module.css +++ b/packages/components/src/components/CodeBlock/CodeBlock.module.css @@ -95,7 +95,7 @@ } .actionbar { - visibility: hidden; + opacity: 0; pointer-events: none; position: absolute; inset-block-start: 0; @@ -126,14 +126,15 @@ */ &:hover .actionbar, &:focus-within .actionbar, + &[data-action-bar-tooltip-open] .actionbar, &[data-always-show-action-bar] .actionbar { - visibility: visible; + opacity: 1; pointer-events: auto; } @media (hover: none) { .actionbar { - visibility: visible; + opacity: 1; pointer-events: auto; } } diff --git a/packages/components/src/components/CodeBlock/CodeBlock.stories.tsx b/packages/components/src/components/CodeBlock/CodeBlock.stories.tsx index 74cd0e307..100838097 100644 --- a/packages/components/src/components/CodeBlock/CodeBlock.stories.tsx +++ b/packages/components/src/components/CodeBlock/CodeBlock.stories.tsx @@ -10,7 +10,6 @@ import { Typography } from '../Typography'; import { CodeBlock, - CodeBlockHighlightConfigProvider, type CodeBlockFile, type CodeBlockProps, type CodeBlockRef, @@ -19,6 +18,9 @@ import { const meta = { title: 'Components/CodeBlock', component: CodeBlock, + subcomponents: { + HighlightConfigProvider: CodeBlock.HighlightConfigProvider, + }, parameters: { layout: 'padded', }, @@ -51,7 +53,7 @@ export const Overview: Story = { ]; return ( - + - + ); }, }; @@ -112,7 +114,7 @@ export const HeaderPinned: Story = { ]; return ( - + - + ); }, }; @@ -173,7 +175,7 @@ export const WithMaxHeight: Story = { ]; return ( - + - + ); }, }; @@ -224,7 +226,7 @@ export const WithSoftWrap: Story = { ]; return ( - + - + ); }, }; @@ -283,7 +285,7 @@ export const WithTabs: Story = { ]; return ( - + - + ); }, }; @@ -359,7 +361,7 @@ export const WithTabsAndShadow: Story = { }, []); return ( - + - + ); }, }; @@ -398,7 +400,7 @@ export const WithFilled: Story = { ]; return ( - + - + ); }, }; @@ -454,7 +456,7 @@ export const WithNoBorder: Story = { ]; return ( - + } @@ -480,7 +482,7 @@ export const WithNoBorder: Story = { )} - + ); }, }; @@ -507,9 +509,9 @@ export const WithLink: Story = { ]; return ( - + - + ); }, }; diff --git a/packages/components/src/components/CodeBlock/CodeBlock.test.tsx b/packages/components/src/components/CodeBlock/CodeBlock.test.tsx index 4e16b9220..499de5b85 100644 --- a/packages/components/src/components/CodeBlock/CodeBlock.test.tsx +++ b/packages/components/src/components/CodeBlock/CodeBlock.test.tsx @@ -13,7 +13,6 @@ import type { HLJSApi } from 'highlight.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { CodeBlock } from './CodeBlock'; -import { CodeBlockHighlightConfigProvider } from './context'; import type { CodeBlockHighlightConfig } from './context'; import type { CodeBlockFile, CodeBlockRef } from './types'; @@ -91,6 +90,16 @@ describe('CodeBlock', () => { expect(onHideTabsChange).toHaveBeenCalledWith(true); }); + it('keeps action bar controls in sequential keyboard navigation when tabs are hidden', async () => { + render(); + + const copyButton = await screen.findByRole('button', { name: 'Copy' }); + + await user.tab(); + + expect(copyButton).toHaveFocus(); + }); + it('shows tabs again when files no longer require automatic hiding', async () => { const onHideTabsChange = vi.fn(); @@ -322,10 +331,10 @@ describe('CodeBlock', () => { }; render( - + - + ); await waitFor(() => @@ -360,9 +369,9 @@ describe('CodeBlock', () => { const file = { content, language: 'unknown' }; render( - + - + ); await waitFor(() => @@ -403,9 +412,9 @@ describe('CodeBlock', () => { const file = { content: 'plain text' }; render( - + - + ); await waitFor(() => @@ -427,9 +436,9 @@ describe('CodeBlock', () => { }; render( - + - + ); await waitFor(() => @@ -500,9 +509,10 @@ describe('CodeBlock', () => { vi.unstubAllGlobals(); }); - it('closes an open tooltip when pointer enters its portal', async () => { + it('keeps the action bar visible while pointer is over its tooltip', async () => { render(); + const root = screen.getByTestId('root'); const copyButton = await screen.findByRole('button', { name: 'Copy' }); await user.hover(copyButton); @@ -514,9 +524,21 @@ describe('CodeBlock', () => { ) ); - await user.hover(screen.getByRole('tooltip')); + expect(root).toHaveAttribute('data-action-bar-tooltip-open'); + + const tooltip = screen.getByRole('tooltip'); + + await user.hover(tooltip); - expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + expect(tooltip).toBeVisible(); + expect(root).toHaveAttribute('data-action-bar-tooltip-open'); + + await user.unhover(tooltip); + + await waitFor(() => { + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + expect(root).not.toHaveAttribute('data-action-bar-tooltip-open'); + }); }); it('toggles an uncontrolled defaultSoftWrap value and reports the change', async () => { @@ -791,9 +813,9 @@ describe('CodeBlock', () => { .mockImplementation(() => {}); render( - + - + ); ref.current?.scrollTo({ top: 24, behavior: 'instant' }); diff --git a/packages/components/src/components/CodeBlock/CodeBlock.tsx b/packages/components/src/components/CodeBlock/CodeBlock.tsx index 535cac66f..baaeb2f7c 100644 --- a/packages/components/src/components/CodeBlock/CodeBlock.tsx +++ b/packages/components/src/components/CodeBlock/CodeBlock.tsx @@ -23,6 +23,7 @@ import { Button } from '../Button'; import s from './CodeBlock.module.css'; import { CodeBlockActionBar, CodeBlockTabs } from './components'; +import { CodeBlockHighlightConfigProvider } from './context'; import { useHighlightedCode, useOverflowShadow } from './hooks'; import intlMessages from './intl.json'; import type { @@ -103,7 +104,7 @@ function CodeBlockRender(props: CodeBlockProps, ref: Ref) { const mainRef = useRef(null); const pendingScrollRef = useRef(null); const tabsHiddenAutomaticallyRef = useRef(false); - const [actionBarSession, setActionBarSession] = useState(0); + const [isActionBarTooltipOpen, setIsActionBarTooltipOpen] = useState(false); const [softWrap, setSoftWrap] = useControlledState( softWrapProp, @@ -164,29 +165,6 @@ function CodeBlockRender(props: CodeBlockProps, ref: Ref) { const isTabsHidden = hideTabs || shouldAutoHideTabs; - useEffect(() => { - const root = rootRef.current; - - if (!root) return; - - const onMouseLeave = () => { - const actionBarRemainsVisible = - !isTabsHidden || - alwaysShowActionBar || - root.contains(document.activeElement); - - if (!actionBarRemainsVisible) { - // React treats a portal as part of the component tree, so its synthetic mouseleave does not fire - // when the pointer enters a tooltip. A native listener follows the DOM tree and closes it correctly. - setActionBarSession((session) => session + 1); - } - }; - - root.addEventListener('mouseleave', onMouseLeave); - - return () => root.removeEventListener('mouseleave', onMouseLeave); - }, [alwaysShowActionBar, isTabsHidden]); - useEffect(() => { if (hideTabsProp !== undefined) { tabsHiddenAutomaticallyRef.current = false; @@ -276,7 +254,6 @@ function CodeBlockRender(props: CodeBlockProps, ref: Ref) { const actionBar = ( ) { softWrapOnTooltip={t.format('softWrapOnTooltip')} softWrapOffTooltip={t.format('softWrapOffTooltip')} openExternalSystemTooltip={t.format('openExternalSystemTooltip')} + onTooltipOpenChange={setIsActionBarTooltipOpen} /> ); @@ -342,6 +320,7 @@ function CodeBlockRender(props: CodeBlockProps, ref: Ref) { data-line-numbers={hasLineNumbers || undefined} data-hide-tabs={isTabsHidden || undefined} data-always-show-action-bar={alwaysShowActionBar || undefined} + data-action-bar-tooltip-open={isActionBarTooltipOpen || undefined} data-soft-wrap={softWrap || undefined} data-view-all={viewAll || undefined} style={style} @@ -392,6 +371,13 @@ function CodeBlockRender(props: CodeBlockProps, ref: Ref) { ); } -export const CodeBlock = forwardRef(CodeBlockRender); +const CodeBlockComponent = forwardRef(CodeBlockRender); + +type CompoundedComponent = typeof CodeBlockComponent & { + HighlightConfigProvider: typeof CodeBlockHighlightConfigProvider; +}; + +export const CodeBlock = CodeBlockComponent as CompoundedComponent; CodeBlock.displayName = 'CodeBlock'; +CodeBlock.HighlightConfigProvider = CodeBlockHighlightConfigProvider; diff --git a/packages/components/src/components/CodeBlock/components/CodeBlockActionBar.tsx b/packages/components/src/components/CodeBlock/components/CodeBlockActionBar.tsx index f2113c344..c114e8d13 100644 --- a/packages/components/src/components/CodeBlock/components/CodeBlockActionBar.tsx +++ b/packages/components/src/components/CodeBlock/components/CodeBlockActionBar.tsx @@ -30,6 +30,7 @@ export type CodeBlockActionBarProps = { softWrapOnTooltip: string; softWrapOffTooltip: string; openExternalSystemTooltip: string; + onTooltipOpenChange: (isOpen: boolean) => void; }; function download(file: CodeBlockFile, fallbackFileName: string): void { @@ -61,6 +62,7 @@ export function CodeBlockActionBar(props: CodeBlockActionBarProps) { softWrapOnTooltip, softWrapOffTooltip, openExternalSystemTooltip, + onTooltipOpenChange, } = props; const [, copy] = useCopyToClipboard(); @@ -87,11 +89,17 @@ export function CodeBlockActionBar(props: CodeBlockActionBarProps) { } }; + const onCopyTooltipOpenChange = (isOpen: boolean): void => { + resetCopyTooltip(isOpen); + onTooltipOpenChange(isOpen); + }; + return (
{canToggleSoftWrap && ( ( } @@ -472,8 +419,6 @@ export const WithNoBorder: Story = { > )} - + ); }, }; @@ -509,9 +454,9 @@ export const WithLink: Story = { ]; return ( - + - + ); }, }; diff --git a/packages/components/src/components/CodeBlock/CodeBlock.test.tsx b/packages/components/src/components/CodeBlock/CodeBlock.test.tsx index 499de5b85..8b82df1b6 100644 --- a/packages/components/src/components/CodeBlock/CodeBlock.test.tsx +++ b/packages/components/src/components/CodeBlock/CodeBlock.test.tsx @@ -13,6 +13,7 @@ import type { HLJSApi } from 'highlight.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { CodeBlock } from './CodeBlock'; +import { CodeBlockProvider } from './context'; import type { CodeBlockHighlightConfig } from './context'; import type { CodeBlockFile, CodeBlockRef } from './types'; @@ -317,6 +318,51 @@ describe('CodeBlock', () => { ).toEqual(['10', '11']); }); + it('forwards slotProps to the header and the content region', async () => { + const { rerender } = render( + + ); + + await waitFor(() => + expect(getCode()).toHaveAttribute('data-language', 'xml') + ); + + expect(screen.getByTestId('code-block-header')).toHaveClass( + 'custom-header' + ); + + const panel = screen.getByRole('tabpanel'); + + expect(panel).toHaveClass('custom-content'); + expect(panel).toHaveStyle({ minBlockSize: '40px' }); + + // The same slots are applied to the plain header and region rendered without tabs. + rerender( + + ); + + expect(screen.getByTestId('code-block-header')).toHaveClass( + 'custom-header' + ); + + const region = screen.getByRole('region'); + + expect(region).toHaveClass('custom-content'); + expect(region).toHaveStyle({ minBlockSize: '40px' }); + }); + describe('highlight configuration', () => { it('loads and reuses only the languages provided by the nearest provider', async () => { const loadCore = vi.fn(() => import('highlight.js/lib/core')); @@ -331,10 +377,10 @@ describe('CodeBlock', () => { }; render( - + - + ); await waitFor(() => @@ -369,9 +415,9 @@ describe('CodeBlock', () => { const file = { content, language: 'unknown' }; render( - + - + ); await waitFor(() => @@ -412,9 +458,9 @@ describe('CodeBlock', () => { const file = { content: 'plain text' }; render( - + - + ); await waitFor(() => @@ -436,9 +482,9 @@ describe('CodeBlock', () => { }; render( - + - + ); await waitFor(() => @@ -813,9 +859,9 @@ describe('CodeBlock', () => { .mockImplementation(() => {}); render( - + - + ); ref.current?.scrollTo({ top: 24, behavior: 'instant' }); diff --git a/packages/components/src/components/CodeBlock/CodeBlock.tsx b/packages/components/src/components/CodeBlock/CodeBlock.tsx index baaeb2f7c..d4f724365 100644 --- a/packages/components/src/components/CodeBlock/CodeBlock.tsx +++ b/packages/components/src/components/CodeBlock/CodeBlock.tsx @@ -16,14 +16,15 @@ import { useLocalizedStringFormatter, useResizeObserver, } from '@koobiq/react-core'; -import { IconChevronDown16, IconChevronUp16 } from '@koobiq/react-icons'; - -import { utilClasses } from '../../styles/utility'; -import { Button } from '../Button'; import s from './CodeBlock.module.css'; -import { CodeBlockActionBar, CodeBlockTabs } from './components'; -import { CodeBlockHighlightConfigProvider } from './context'; +import { + CodeBlockActionBar, + CodeBlockCode, + CodeBlockContent, + CodeBlockHeader, + CodeBlockTabs, +} from './components'; import { useHighlightedCode, useOverflowShadow } from './hooks'; import intlMessages from './intl.json'; import type { @@ -88,6 +89,7 @@ function CodeBlockRender(props: CodeBlockProps, ref: Ref) { renderTabLabel, fallbackFileName = 'code', startFrom = 1, + slotProps, className, style, 'data-testid': dataTestId, @@ -246,12 +248,6 @@ function CodeBlockRender(props: CodeBlockProps, ref: Ref) { if (!nextViewAll) scrollToTop(); }; - const actionCount = - Number(canToggleSoftWrap) + - Number(canDownload) + - Number(!hideCopyButton) + - Number(Boolean(activeFile.link)); - const actionBar = ( ) { /> ); - const panelContent = ( - <> -
-        
-          {pending || failed ? activeFile.content : undefined}
-        
-      
- - {contentExceedsMaxHeight && ( -
-
- -
-
- )} - + const code = ( + ); return ( @@ -327,26 +300,22 @@ function CodeBlockRender(props: CodeBlockProps, ref: Ref) { > {isTabsHidden ? ( <> -
{actionBar} -
-
+ - {panelContent} -
+ {code} + ) : ( <> @@ -357,12 +326,12 @@ function CodeBlockRender(props: CodeBlockProps, ref: Ref) { fallbackFileName={fallbackFileName} renderTabLabel={renderTabLabel} panelRef={mainRef} - panelContent={panelContent} + panelContent={code} panelMaxHeight={calculatedMaxHeight} onPanelScroll={onScroll} isScrolled={isScrolled} - actionCount={actionCount} aria-label={t.format('filesLabel')} + slotProps={slotProps} /> {actionBar} @@ -371,13 +340,11 @@ function CodeBlockRender(props: CodeBlockProps, ref: Ref) { ); } -const CodeBlockComponent = forwardRef(CodeBlockRender); - -type CompoundedComponent = typeof CodeBlockComponent & { - HighlightConfigProvider: typeof CodeBlockHighlightConfigProvider; -}; - -export const CodeBlock = CodeBlockComponent as CompoundedComponent; +/** + * CodeBlock displays reformatted text content with syntax highlighting. + * + * Wrap it with `CodeBlockProvider` to control how `highlight.js` is loaded. + */ +export const CodeBlock = forwardRef(CodeBlockRender); CodeBlock.displayName = 'CodeBlock'; -CodeBlock.HighlightConfigProvider = CodeBlockHighlightConfigProvider; diff --git a/packages/components/src/components/CodeBlock/components/CodeBlockCode.tsx b/packages/components/src/components/CodeBlock/components/CodeBlockCode.tsx new file mode 100644 index 000000000..cffcf01bc --- /dev/null +++ b/packages/components/src/components/CodeBlock/components/CodeBlockCode.tsx @@ -0,0 +1,79 @@ +'use client'; + +import type { Ref } from 'react'; + +import { clsx } from '@koobiq/react-core'; +import { IconChevronDown16, IconChevronUp16 } from '@koobiq/react-icons'; + +import { utilClasses } from '../../../styles/utility'; +import { Button } from '../../Button'; +import s from '../CodeBlock.module.css'; + +export type CodeBlockCodeProps = { + preRef: Ref; + /** Highlighted markup, rendered when `isHighlighted` is set. */ + html: string; + /** The language `highlight.js` used for `html`. */ + language: string; + /** Raw source, rendered as plain text until the content is highlighted. */ + source: string; + /** Whether `html` is ready to be rendered instead of `source`. */ + isHighlighted: boolean; + /** Whether the content is clipped by `maxHeight` and can be expanded. */ + canViewAll: boolean; + viewAll: boolean; + onViewAllToggle: () => void; + viewAllText: string; + viewLessText: string; +}; + +export function CodeBlockCode(props: CodeBlockCodeProps) { + const { + preRef, + html, + language, + source, + isHighlighted, + canViewAll, + viewAll, + onViewAllToggle, + viewAllText, + viewLessText, + } = props; + + return ( + <> +
+        
+          {isHighlighted ? undefined : source}
+        
+      
+ + {canViewAll && ( +
+
+ +
+
+ )} + + ); +} diff --git a/packages/components/src/components/CodeBlock/components/CodeBlockContent.tsx b/packages/components/src/components/CodeBlock/components/CodeBlockContent.tsx new file mode 100644 index 000000000..9aabd3d38 --- /dev/null +++ b/packages/components/src/components/CodeBlock/components/CodeBlockContent.tsx @@ -0,0 +1,62 @@ +'use client'; + +import type { + ComponentPropsWithRef, + ReactNode, + Ref, + UIEventHandler, +} from 'react'; + +import { mergeProps, mergeRefs } from '@koobiq/react-core'; + +import s from '../CodeBlock.module.css'; + +export type CodeBlockContentProps = { + contentRef: Ref; + /** Accessible name of the region, taken from the file name. */ + 'aria-label': string; + /** Whether the content overflows and therefore has to be reachable by keyboard. */ + isFocusable: boolean; + maxHeight?: number; + onScroll: UIEventHandler; + children?: ReactNode; + slotProps?: Omit, 'children'>; +}; + +/** + * The scrollable region with the code of a `CodeBlock` without tabs. With tabs, that region is the + * tab panel rendered by `CodeBlockTabs`. + */ +export function CodeBlockContent(props: CodeBlockContentProps) { + const { + contentRef, + 'aria-label': ariaLabel, + isFocusable, + maxHeight, + onScroll, + children, + slotProps, + } = props; + + const { ref: slotRef, style: slotStyle, ...restSlotProps } = slotProps ?? {}; + + return ( +
+ {children} +
+ ); +} diff --git a/packages/components/src/components/CodeBlock/components/CodeBlockHeader.tsx b/packages/components/src/components/CodeBlock/components/CodeBlockHeader.tsx new file mode 100644 index 000000000..f9f9aedce --- /dev/null +++ b/packages/components/src/components/CodeBlock/components/CodeBlockHeader.tsx @@ -0,0 +1,47 @@ +'use client'; + +import type { ComponentPropsWithRef, ReactNode } from 'react'; + +import { mergeProps } from '@koobiq/react-core'; +import type { DataAttributeProps } from '@koobiq/react-core'; + +import s from '../CodeBlock.module.css'; + +export type CodeBlockHeaderSlotProps = Omit< + ComponentPropsWithRef<'div'>, + 'children' +>; + +export type CodeBlockHeaderProps = { + /** Whether the code content is scrolled away from its top, which casts a shadow under the header. */ + isScrolled: boolean; + children?: ReactNode; + slotProps?: CodeBlockHeaderSlotProps; +}; + +/** + * Props of the header band, shared by both headers: the plain one below and the one `Tabs` renders + * for `CodeBlockTabs`. + */ +export function getCodeBlockHeaderProps( + isScrolled: boolean, + slotProps?: CodeBlockHeaderSlotProps +) { + return mergeProps( + { + className: s.header, + 'data-testid': 'code-block-header', + 'data-scrolled': isScrolled || undefined, + } satisfies ComponentPropsWithRef<'div'> & DataAttributeProps, + slotProps + ); +} + +/** The header band of a `CodeBlock` without tabs — with them it comes from `CodeBlockTabs`. */ +export function CodeBlockHeader(props: CodeBlockHeaderProps) { + const { isScrolled, children, slotProps } = props; + + return ( +
{children}
+ ); +} diff --git a/packages/components/src/components/CodeBlock/components/CodeBlockTabs.tsx b/packages/components/src/components/CodeBlock/components/CodeBlockTabs.tsx index 817111da1..ca64f00e4 100644 --- a/packages/components/src/components/CodeBlock/components/CodeBlockTabs.tsx +++ b/packages/components/src/components/CodeBlock/components/CodeBlockTabs.tsx @@ -1,19 +1,14 @@ 'use client'; -import type { - ComponentProps, - CSSProperties, - ReactNode, - Ref, - UIEventHandler, -} from 'react'; +import type { ReactNode, Ref, UIEventHandler } from 'react'; -import { clsx } from '@koobiq/react-core'; -import type { DataAttributeProps } from '@koobiq/react-core'; +import { mergeProps, mergeRefs } from '@koobiq/react-core'; import { Tab, Tabs } from '../../Tabs'; import s from '../CodeBlock.module.css'; -import type { CodeBlockFile } from '../types'; +import type { CodeBlockFile, CodeBlockProps } from '../types'; + +import { getCodeBlockHeaderProps } from './CodeBlockHeader'; export type CodeBlockTabsProps = { files: CodeBlockFile[]; @@ -26,10 +21,14 @@ export type CodeBlockTabsProps = { panelMaxHeight?: number; onPanelScroll: UIEventHandler; isScrolled: boolean; - actionCount: number; 'aria-label': string; + slotProps?: CodeBlockProps['slotProps']; }; +/** + * The header of a `CodeBlock` with tabs, along with the tab panel holding the code: `Tabs` renders + * both of them, and the code block lays them out next to the action bar. + */ export function CodeBlockTabs(props: CodeBlockTabsProps) { const { files, @@ -42,28 +41,15 @@ export function CodeBlockTabs(props: CodeBlockTabsProps) { panelMaxHeight, onPanelScroll, isScrolled, - actionCount, 'aria-label': ariaLabel, + slotProps, } = props; - const actionBarInlineSize = - actionCount > 0 - ? `calc(${actionCount} * var(--kbq-size-3xl) + ${Math.max( - actionCount - 1, - 0 - )} * var(--kbq-size-3xs))` - : '0px'; - - const headerStyle = { - '--code-block-actionbar-inline-size': actionBarInlineSize, - } as CSSProperties; - - const headerProps = { - className: clsx(s.header, isScrolled && s.headerScrolled), - style: headerStyle, - 'data-testid': 'code-block-header', - 'data-scrolled': isScrolled || undefined, - } satisfies ComponentProps<'div'> & DataAttributeProps; + const { + ref: contentRef, + style: contentStyle, + ...contentProps + } = slotProps?.content ?? {}; return ( 1) onActiveFileIndexChange(Number(key)); }} slotProps={{ - tabs: { - ...headerProps, - }, + tabs: getCodeBlockHeaderProps(isScrolled, slotProps?.header), tabPanel: { - ref: panelRef, - className: s.main, - style: { maxHeight: panelMaxHeight }, - onScroll: onPanelScroll, + ...mergeProps( + { className: s.main, onScroll: onPanelScroll }, + contentProps + ), + ref: mergeRefs(panelRef, contentRef), + style: { maxHeight: panelMaxHeight, ...contentStyle }, }, }} > diff --git a/packages/components/src/components/CodeBlock/components/index.ts b/packages/components/src/components/CodeBlock/components/index.ts index 0c0b50903..1b6d56ce5 100644 --- a/packages/components/src/components/CodeBlock/components/index.ts +++ b/packages/components/src/components/CodeBlock/components/index.ts @@ -1,2 +1,5 @@ export * from './CodeBlockActionBar'; +export * from './CodeBlockCode'; +export * from './CodeBlockContent'; +export * from './CodeBlockHeader'; export * from './CodeBlockTabs'; diff --git a/packages/components/src/components/CodeBlock/context.tsx b/packages/components/src/components/CodeBlock/context.tsx index 376565f2c..55a32de93 100644 --- a/packages/components/src/components/CodeBlock/context.tsx +++ b/packages/components/src/components/CodeBlock/context.tsx @@ -22,14 +22,14 @@ const CodeBlockHighlightConfigContext = // call when no provider is present — `useHighlightedCode` keys its load cache off that identity. const EMPTY_CONFIG: CodeBlockHighlightConfig = {}; -export type CodeBlockHighlightConfigProviderProps = { +export type CodeBlockProviderProps = { /** The `highlight.js` loading configuration applied to every `CodeBlock` inside. */ - config: CodeBlockHighlightConfig; + highlightConfig: CodeBlockHighlightConfig; children?: ReactNode; }; /** - * Configures how `CodeBlock` loads `highlight.js`. + * Configures every `CodeBlock` inside it. * * By default, `CodeBlock` lazily loads the full `highlight.js` bundle with all languages (~1 MB). * To reduce bundle size, wrap the app (or a part of it) with this provider and specify only the languages you need. @@ -45,18 +45,16 @@ export type CodeBlockHighlightConfigProviderProps = { * fallbackLanguage: 'plaintext' * }; * - * + * * - * + * * ``` */ -export function CodeBlockHighlightConfigProvider( - props: CodeBlockHighlightConfigProviderProps -) { - const { config, children } = props; +export function CodeBlockProvider(props: CodeBlockProviderProps) { + const { highlightConfig, children } = props; return ( - + {children} ); diff --git a/packages/components/src/components/CodeBlock/hooks/useHighlightedCode.ts b/packages/components/src/components/CodeBlock/hooks/useHighlightedCode.ts index d9a296d43..7e0e28969 100644 --- a/packages/components/src/components/CodeBlock/hooks/useHighlightedCode.ts +++ b/packages/components/src/components/CodeBlock/hooks/useHighlightedCode.ts @@ -35,8 +35,8 @@ async function loadHljs(config: CodeBlockHighlightConfig): Promise { * Resolves the shared `highlight.js` instance for the app. * * Cached at module scope so every `CodeBlock` on the page reuses the same load, keyed by the - * `CodeBlockHighlightConfig` reference — pass a stable (e.g. module-level or memoized) `config` to - * `CodeBlock.HighlightConfigProvider` to avoid redundant reloads. + * `CodeBlockHighlightConfig` reference — pass a stable (e.g. module-level or memoized) `highlightConfig` to + * `CodeBlockProvider` to avoid redundant reloads. */ function getHljsInstance(config: CodeBlockHighlightConfig): Promise { const cachedPromise = hljsPromises.get(config); @@ -91,7 +91,7 @@ function escapeHTML(value: string): string { } /** - * Loads `highlight.js` (honoring `CodeBlock.HighlightConfigProvider`) and highlights `file`. + * Loads `highlight.js` (honoring `CodeBlockProvider`) and highlights `file`. * * `highlight.js` escapes the raw source text before wrapping it in `` tokens, so the returned * `html` is safe to render with `dangerouslySetInnerHTML` — the same trust model other React syntax diff --git a/packages/components/src/components/CodeBlock/index.ts b/packages/components/src/components/CodeBlock/index.ts index 232b9c2e0..66e4c91c5 100644 --- a/packages/components/src/components/CodeBlock/index.ts +++ b/packages/components/src/components/CodeBlock/index.ts @@ -1,6 +1,7 @@ export * from './CodeBlock'; export { + CodeBlockProvider, type CodeBlockHighlightConfig, - type CodeBlockHighlightConfigProviderProps, + type CodeBlockProviderProps, } from './context'; export * from './types'; diff --git a/packages/components/src/components/CodeBlock/types.ts b/packages/components/src/components/CodeBlock/types.ts index d5974e4e5..245e585f3 100644 --- a/packages/components/src/components/CodeBlock/types.ts +++ b/packages/components/src/components/CodeBlock/types.ts @@ -1,4 +1,9 @@ -import type { CSSProperties, ReactNode, Ref } from 'react'; +import type { + ComponentPropsWithRef, + CSSProperties, + ReactNode, + Ref, +} from 'react'; import type { DataAttributeProps } from '@koobiq/react-core'; @@ -111,10 +116,11 @@ export type CodeBlockProps = { */ maxHeight?: number; /** - * Whether to hide the header tabs. Tabs are hidden automatically for a single file without a - * `filename`; control this prop with `false` to keep them visible. - * Makes the action bar floating and shown only on hover. - * @default false + * Whether to hide the header tabs, which also makes the action bar floating and shown on hover only. + * + * When the prop is omitted, the component decides on its own: the header is hidden for a single file + * without a `filename` and shown otherwise. Passing `false` takes that decision over and keeps the + * header visible even for such a file. */ hideTabs?: boolean; /** The uncontrolled default value for `hideTabs`. */ @@ -139,6 +145,13 @@ export type CodeBlockProps = { fallbackFileName?: string; /** The starting line number. */ startFrom?: number; + /** The props used for each slot inside. */ + slotProps?: { + /** Props of the header holding the tabs. */ + header?: Omit, 'children'>; + /** Props of the scrollable region holding the code. */ + content?: Omit, 'children'>; + }; /** Additional CSS-classes. */ className?: string; /** Inline styles. */ diff --git a/tools/public_api_guard/components/CodeBlock.api.md b/tools/public_api_guard/components/CodeBlock.api.md index 7e8d2c61a..12fdeefbf 100644 --- a/tools/public_api_guard/components/CodeBlock.api.md +++ b/tools/public_api_guard/components/CodeBlock.api.md @@ -4,6 +4,7 @@ ```ts +import type { ComponentPropsWithRef } from 'react'; import type { CSSProperties } from 'react'; import type { DataAttributeProps } from '@koobiq/react-core'; import { ForwardRefExoticComponent } from 'react'; @@ -14,10 +15,8 @@ import type { ReactNode } from 'react'; import type { Ref } from 'react'; import { RefAttributes } from 'react'; -// Warning: (ae-forgotten-export) The symbol "CompoundedComponent" needs to be exported by the entry point index.d.ts -// -// @public (undocumented) -export const CodeBlock: CompoundedComponent; +// @public +export const CodeBlock: ForwardRefExoticComponent & RefAttributes>; // @public export type CodeBlockFile = { @@ -38,12 +37,6 @@ export type CodeBlockHighlightConfig = Partial<{ fallbackLanguage: string; }>; -// @public (undocumented) -export type CodeBlockHighlightConfigProviderProps = { - config: CodeBlockHighlightConfig; - children?: ReactNode; -}; - // @public (undocumented) export type CodeBlockProps = { files: CodeBlockFile[]; @@ -70,12 +63,25 @@ export type CodeBlockProps = { renderTabLabel?: (file: CodeBlockFile, fallbackFileName: string) => ReactNode; fallbackFileName?: string; startFrom?: number; + slotProps?: { + header?: Omit, 'children'>; + content?: Omit, 'children'>; + }; className?: string; style?: CSSProperties; ref?: Ref; 'data-testid'?: string | number; } & DataAttributeProps; +// @public +export function CodeBlockProvider(props: CodeBlockProviderProps): JSX.Element; + +// @public (undocumented) +export type CodeBlockProviderProps = { + highlightConfig: CodeBlockHighlightConfig; + children?: ReactNode; +}; + // @public export type CodeBlockRef = { element: HTMLDivElement | null; From 4a69e72d1748ce8726910b03cc414e31bc361de3 Mon Sep 17 00:00:00 2001 From: Artem Belik Date: Wed, 2 Sep 2026 16:02:46 +0300 Subject: [PATCH 6/7] refactor: review --- .../components/CodeBlock/CodeBlock.test.tsx | 23 +++++++++++ .../src/components/CodeBlock/CodeBlock.tsx | 40 +++++++++++++------ .../components/CodeBlockActionBar.tsx | 2 +- .../CodeBlock/components/CodeBlockTabs.tsx | 3 +- .../CodeBlock/hooks/useHighlightedCode.ts | 21 +++++----- .../CodeBlock/hooks/useOverflowShadow.ts | 6 ++- .../src/components/CodeBlock/types.ts | 5 ++- .../components/CodeBlock/utils/lineNumbers.ts | 4 +- 8 files changed, 76 insertions(+), 28 deletions(-) diff --git a/packages/components/src/components/CodeBlock/CodeBlock.test.tsx b/packages/components/src/components/CodeBlock/CodeBlock.test.tsx index 8b82df1b6..c379e8fd0 100644 --- a/packages/components/src/components/CodeBlock/CodeBlock.test.tsx +++ b/packages/components/src/components/CodeBlock/CodeBlock.test.tsx @@ -993,6 +993,29 @@ describe('CodeBlock', () => { expect(header).not.toHaveAttribute('data-scrolled'); }); + it('drops the header shadow when the active file changes', async () => { + render(); + + await waitFor(() => expect(getCode()).not.toBeNull()); + + const header = screen.getByTestId('code-block-header'); + const panel = screen.getByRole('tabpanel'); + + Object.defineProperty(panel, 'scrollTop', { + configurable: true, + value: 10, + writable: true, + }); + + fireEvent.scroll(panel); + expect(header).toHaveAttribute('data-scrolled'); + + // The panel is re-created on tab change, so it never reports a scroll back to the top itself. + await user.click(screen.getByRole('tab', { name: 'main.ts' })); + + expect(header).not.toHaveAttribute('data-scrolled'); + }); + it('clamps an out-of-range active file index', async () => { const onActiveFileIndexChange = vi.fn(); diff --git a/packages/components/src/components/CodeBlock/CodeBlock.tsx b/packages/components/src/components/CodeBlock/CodeBlock.tsx index d4f724365..2bf92dd78 100644 --- a/packages/components/src/components/CodeBlock/CodeBlock.tsx +++ b/packages/components/src/components/CodeBlock/CodeBlock.tsx @@ -158,8 +158,9 @@ function CodeBlockRender(props: CodeBlockProps, ref: Ref) { setActiveFileIndex, ]); - // A single file without a name has nothing to switch between or label — hide the tabs, same as Angular. - const isSingleUnnamedFile = files.length === 1 && !activeFile.filename; + // A single file without a name has nothing to switch between or label — hide the tabs, same as + // Angular. An empty list has nothing to label either, and tabs would render an empty tab list. + const isSingleUnnamedFile = files.length <= 1 && !activeFile.filename; // A controlled `false` deliberately overrides the automatic single-file behavior so a custom // header can remain visible. @@ -194,16 +195,23 @@ function CodeBlockRender(props: CodeBlockProps, ref: Ref) { const contentExceedsMaxHeight = hasMaxHeight && preRect.height > maxHeight; - const mainElement = mainRef.current; + const [isContentOverflowing, setIsContentOverflowing] = useState(false); - const canFocusContent = Boolean( - !calculatedMaxHeight && - mainElement && - (mainElement.scrollHeight > mainElement.clientHeight || - mainElement.scrollWidth > mainElement.clientWidth) - ); + // Measured after the commit: reading `mainRef` while rendering reports the DOM of the previous + // one, which is null on mount and stale right after the active file changes. + useEffect(() => { + const element = mainRef.current; + + setIsContentOverflowing( + element != null && + (element.scrollHeight > element.clientHeight || + element.scrollWidth > element.clientWidth) + ); + }, [activeFileIndex, calculatedMaxHeight, html, pending, preRect, softWrap]); + + const canFocusContent = !calculatedMaxHeight && isContentOverflowing; - const { isScrolled, onScroll } = useOverflowShadow(); + const { isScrolled, onScroll, resetShadow } = useOverflowShadow(); const scrollTo = (options: CodeBlockScrollToOptions) => { if (pending) { @@ -227,9 +235,15 @@ function CodeBlockRender(props: CodeBlockProps, ref: Ref) { scrollTo, })); - const scrollToTop = () => - mainRef.current && - scrollElementTo(mainRef.current, { top: 0, behavior: 'instant' }); + const scrollToTop = () => { + // With tabs the panel this scrolls is about to be replaced, so the shadow it left behind has to + // be dropped by hand — the new, unscrolled panel never fires a scroll event of its own. + resetShadow(); + + if (mainRef.current) { + scrollElementTo(mainRef.current, { top: 0, behavior: 'instant' }); + } + }; const onTabChange = (index: number) => { if (index === activeFileIndex) return; diff --git a/packages/components/src/components/CodeBlock/components/CodeBlockActionBar.tsx b/packages/components/src/components/CodeBlock/components/CodeBlockActionBar.tsx index c114e8d13..8914a3ff6 100644 --- a/packages/components/src/components/CodeBlock/components/CodeBlockActionBar.tsx +++ b/packages/components/src/components/CodeBlock/components/CodeBlockActionBar.tsx @@ -95,7 +95,7 @@ export function CodeBlockActionBar(props: CodeBlockActionBarProps) { }; return ( -
+
{canToggleSoftWrap && ( - {panelContent} + {/* `Tabs` renders the children of the selected tab only, so the others need no copy. */} + {index === activeFileIndex ? panelContent : null} ))} diff --git a/packages/components/src/components/CodeBlock/hooks/useHighlightedCode.ts b/packages/components/src/components/CodeBlock/hooks/useHighlightedCode.ts index 7e0e28969..3f4d5ba45 100644 --- a/packages/components/src/components/CodeBlock/hooks/useHighlightedCode.ts +++ b/packages/components/src/components/CodeBlock/hooks/useHighlightedCode.ts @@ -127,6 +127,15 @@ export function useHighlightedCode( useEffect(() => { let cancelled = false; + // The inputs `isCurrentResult` compares the stored result against. + const inputs = { + config, + content: file.content, + fileLanguage: file.language, + hasLineNumbers, + startFrom, + }; + getHljsInstance(config) .then((hljs) => { if (cancelled) return; @@ -180,15 +189,11 @@ export function useHighlightedCode( : value; setResult({ + ...inputs, html, pending: false, failed: false, language: resolvedLanguage, - config, - content: file.content, - fileLanguage: file.language, - hasLineNumbers, - startFrom, }); }) .catch((error: unknown) => { @@ -199,15 +204,11 @@ export function useHighlightedCode( } setResult({ + ...inputs, html: '', pending: false, failed: true, language: file.language ?? fallbackLanguage, - config, - content: file.content, - fileLanguage: file.language, - hasLineNumbers, - startFrom, }); }); diff --git a/packages/components/src/components/CodeBlock/hooks/useOverflowShadow.ts b/packages/components/src/components/CodeBlock/hooks/useOverflowShadow.ts index c992bd764..8787833ab 100644 --- a/packages/components/src/components/CodeBlock/hooks/useOverflowShadow.ts +++ b/packages/components/src/components/CodeBlock/hooks/useOverflowShadow.ts @@ -11,5 +11,9 @@ export function useOverflowShadow() { setIsScrolled(event.currentTarget.scrollTop > 0); }, []); - return { isScrolled, onScroll }; + // A remounted scroll container (`Tabs` re-creates the panel on tab change) starts at the top + // without firing a scroll event, so the shadow has to be dropped explicitly. + const resetShadow = useCallback(() => setIsScrolled(false), []); + + return { isScrolled, onScroll, resetShadow }; } diff --git a/packages/components/src/components/CodeBlock/types.ts b/packages/components/src/components/CodeBlock/types.ts index 245e585f3..00cb9e610 100644 --- a/packages/components/src/components/CodeBlock/types.ts +++ b/packages/components/src/components/CodeBlock/types.ts @@ -143,7 +143,10 @@ export type CodeBlockProps = { * @default 'code' */ fallbackFileName?: string; - /** The starting line number. */ + /** + * The starting line number. + * @default 1 + */ startFrom?: number; /** The props used for each slot inside. */ slotProps?: { diff --git a/packages/components/src/components/CodeBlock/utils/lineNumbers.ts b/packages/components/src/components/CodeBlock/utils/lineNumbers.ts index f5d61de1a..fb13de6c5 100644 --- a/packages/components/src/components/CodeBlock/utils/lineNumbers.ts +++ b/packages/components/src/components/CodeBlock/utils/lineNumbers.ts @@ -42,7 +42,9 @@ function duplicateMultilineNode(element: Element): void { /** Recursively fixes multi-line token spans produced by `highlight.js`. */ function duplicateMultilineNodes(element: Element): void { - element.childNodes.forEach((node) => { + // A snapshot: `duplicateMultilineNode` rewrites the parent's `innerHTML`, which would swap the + // entries of the live `childNodes` list mid-iteration. + Array.from(element.childNodes).forEach((node) => { if (getLinesCount(node.textContent ?? '') === 0) return; if (node.childNodes.length > 0) { From cac6fd75e1f9883916da4e4473df6bca48ec06d3 Mon Sep 17 00:00:00 2001 From: Artem Belik Date: Wed, 2 Sep 2026 17:03:44 +0300 Subject: [PATCH 7/7] fix(CodeBlock): split nested multiline tokens for line numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `duplicateMultilineNodes` now walks depth first and splits the element itself afterwards: a token whose line breaks live inside a nested token stayed unsplit, so `addLineNumbersBlockFor` tore its tags across two rows and the second row lost the outer highlight class. Also splits the highlighted and the raw `` into separate branches — `dangerouslySetInnerHTML` and children may not sit on one element. Co-Authored-By: Claude Opus 5 --- .../CodeBlock/components/CodeBlockCode.tsx | 29 +++++++++++-------- .../CodeBlock/utils/lineNumbers.test.ts | 24 +++++++++++++++ .../components/CodeBlock/utils/lineNumbers.ts | 20 ++++++------- 3 files changed, 50 insertions(+), 23 deletions(-) diff --git a/packages/components/src/components/CodeBlock/components/CodeBlockCode.tsx b/packages/components/src/components/CodeBlock/components/CodeBlockCode.tsx index cffcf01bc..d466b825a 100644 --- a/packages/components/src/components/CodeBlock/components/CodeBlockCode.tsx +++ b/packages/components/src/components/CodeBlock/components/CodeBlockCode.tsx @@ -41,21 +41,26 @@ export function CodeBlockCode(props: CodeBlockCodeProps) { viewLessText, } = props; + const codeClassName = clsx( + 'hljs', + s.code, + utilClasses.typography['mono-codeblock'] + ); + return ( <>
-        
-          {isHighlighted ? undefined : source}
-        
+        {/* Separate branches: `dangerouslySetInnerHTML` and children may not sit on one element. */}
+        {isHighlighted ? (
+          
+        ) : (
+          {source}
+        )}
       
{canViewAll && ( diff --git a/packages/components/src/components/CodeBlock/utils/lineNumbers.test.ts b/packages/components/src/components/CodeBlock/utils/lineNumbers.test.ts index a009bfb74..e5f200fb9 100644 --- a/packages/components/src/components/CodeBlock/utils/lineNumbers.test.ts +++ b/packages/components/src/components/CodeBlock/utils/lineNumbers.test.ts @@ -87,6 +87,30 @@ describe('addLineNumbers', () => { expect(codeCells[1]?.querySelector('.hljs-string')?.textContent).toBe(' '); }); + it('keeps the outer token on every row when the line break sits in a nested one', () => { + const result = parse( + addLineNumbers( + 'first\nsecond' + ) + ); + + const codeCells = result.querySelectorAll('.hljs-ln-code'); + + expect(codeCells).toHaveLength(2); + + expect(Array.from(codeCells).map((cell) => cell.textContent)).toEqual([ + 'first', + 'second', + ]); + + // Splitting the nested token alone would leave the outer tags torn across the two rows. + expect( + Array.from(codeCells).map((cell) => + Boolean(cell.querySelector('.hljs-string')) + ) + ).toEqual([true, true]); + }); + it('handles empty content and ignores a trailing blank line', () => { expect(addLineNumbers('')).toBe(''); diff --git a/packages/components/src/components/CodeBlock/utils/lineNumbers.ts b/packages/components/src/components/CodeBlock/utils/lineNumbers.ts index fb13de6c5..92f94bde8 100644 --- a/packages/components/src/components/CodeBlock/utils/lineNumbers.ts +++ b/packages/components/src/components/CodeBlock/utils/lineNumbers.ts @@ -42,17 +42,15 @@ function duplicateMultilineNode(element: Element): void { /** Recursively fixes multi-line token spans produced by `highlight.js`. */ function duplicateMultilineNodes(element: Element): void { - // A snapshot: `duplicateMultilineNode` rewrites the parent's `innerHTML`, which would swap the - // entries of the live `childNodes` list mid-iteration. - Array.from(element.childNodes).forEach((node) => { - if (getLinesCount(node.textContent ?? '') === 0) return; - - if (node.childNodes.length > 0) { - duplicateMultilineNodes(node as Element); - } else if (node.parentElement) { - duplicateMultilineNode(node.parentElement); - } - }); + // Depth first, and over a snapshot: `duplicateMultilineNode` rewrites the element's `innerHTML`, + // which would swap the entries of the live child list mid-iteration. A nested token has to be + // split before its parent — otherwise the parent keeps a single pair of tags around a line break + // and `addLineNumbersBlockFor` tears them apart across two rows. + Array.from(element.children).forEach(duplicateMultilineNodes); + + if (getLinesCount(element.textContent ?? '') > 0) { + duplicateMultilineNode(element); + } } function addLineNumbersBlockFor(