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..7cee30a95 --- /dev/null +++ b/packages/components/src/components/CodeBlock/CodeBlock.mdx @@ -0,0 +1,188 @@ +import { + Alert, + Meta, + Story, + Props, + Status, +} from '../../../../../.storybook/components'; + +import * as Stories from './CodeBlock.stories'; + + + +# CodeBlock + + + +`CodeBlock` is a component that displays reformatted text content with syntax highlighting. + + + For syntax highlighting, the + [`highlight.js@^11`](https://github.com/highlightjs/highlight.js/tree/stable-11) peer dependency is required: + +```bash +npm install highlight.js@^11 +``` + + + +## Import + +```tsx +import { + CodeBlock, + CodeBlockProvider, +} from '@koobiq/react-components/code-block'; +``` + +## Usage + + + +## Props + + + +## 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 `CodeBlockProvider` and specify only the +languages you need: + +```tsx +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 +register `xml` to enable HTML syntax highlighting. Pass a stable (module-level or memoized) `highlightConfig` +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..a5ef18e4f --- /dev/null +++ b/packages/components/src/components/CodeBlock/CodeBlock.module.css @@ -0,0 +1,560 @@ +.base { + /* Vertically centers a single line of code against the floating action bar. */ + --code-block-floating-code-padding-block: calc( + var(--kbq-size-m) + var(--kbq-size-3xs) + ); + + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-rows: auto minmax(0, 1fr); + 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; + + /* + * The header band. The tab list and the action bar live in separate grid cells, so the background + * and the scroll shadow they share are painted by this row-spanning pseudo-element. + */ + &::before { + content: ''; + grid-area: 1 / 1 / 2 / -1; + border-start-start-radius: var(--kbq-size-m); + border-start-end-radius: var(--kbq-size-m); + transition: box-shadow var(--kbq-transition-default); + } + + /* + * `Tabs` renders the header row and the tab panel together. Unwrapping them into the grid lets the + * action bar sit next to the tab list instead of being positioned over it. + */ + > .tabsContainer { + display: contents; + } + + &[data-border-hidden] { + border-color: transparent; + } + + &[data-filled] { + border-color: transparent; + background: var(--kbq-code-block-filled-container-background); + + &::before { + background: var(--kbq-code-block-filled-header-background); + } + + &:has(.header[data-scrolled])::before { + box-shadow: var(--kbq-code-block-filled-header-scroll-shadow); + } + + &[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]) { + &::before { + background: var(--kbq-code-block-outline-header-background); + } + + &:has(.header[data-scrolled])::before { + box-shadow: var(--kbq-code-block-outline-header-scroll-shadow); + } + + &[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]) { + /* The tab list ends where the action bar begins, so the header keeps no padding of its own. */ + .header { + padding-inline-end: 0; + } + + .main { + border-end-start-radius: var(--kbq-size-m); + border-end-end-radius: var(--kbq-size-m); + border-start-start-radius: 0; + border-start-end-radius: 0; + } + + > .actionbar { + grid-area: 1 / 2 / 2 / 3; + align-self: center; + margin-inline-end: var(--kbq-size-m); + } + } + + &[data-hide-tabs] { + .header { + padding: 0; + } + + .actionbar { + opacity: 0; + pointer-events: 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 { + --code-block-code-padding-block-start: var( + --code-block-floating-code-padding-block + ); + --code-block-code-padding-block-end: var( + --code-block-floating-code-padding-block + ); + } + + /* + * 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-action-bar-tooltip-open] .actionbar, + &[data-always-show-action-bar] .actionbar { + opacity: 1; + pointer-events: auto; + } + + @media (hover: none) { + .actionbar { + opacity: 1; + pointer-events: auto; + } + } + } +} + +.header { + display: flex; + align-items: center; + grid-area: 1 / 1 / 2 / 2; + padding: var(--kbq-size-s) var(--kbq-size-m); +} + +.main { + grid-area: 2 / 1 / 3 / -1; + 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; +} + +.code { + --code-block-code-padding-block-start: var(--kbq-size-xxs); + --code-block-code-padding-block-end: var(--kbq-size-l); + + display: block; + tab-size: 4; + padding-block: var(--code-block-code-padding-block-start) + var(--code-block-code-padding-block-end); + padding-inline: var(--kbq-size-xl); + color: var(--kbq-foreground-contrast); + + &:focus-visible { + outline: none; + } + + .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); + } +} + +/* Leaves room for the expand button, which overlaps the end of the content. */ +.base:has(.viewAll) .code { + --code-block-code-padding-block-end: var(--kbq-size-3xl); +} + +.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: var(--kbq-layer-absolute); +} + +.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..0a2c4ca4f --- /dev/null +++ b/packages/components/src/components/CodeBlock/CodeBlock.stories.tsx @@ -0,0 +1,462 @@ +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 { spacing } from '../layout'; +import { SidePanel } from '../SidePanel'; +import { Toggle } from '../Toggle'; +import { Typography } from '../Typography'; + +import { + CodeBlock, + CodeBlockProvider, + type CodeBlockFile, + type CodeBlockProps, + type CodeBlockRef, +} from './index.js'; + +const meta = { + title: 'Components/CodeBlock', + component: CodeBlock, + subcomponents: { CodeBlockProvider }, + parameters: { + layout: 'padded', + }, + tags: ['status:new', 'date:2026-08-05'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Base: Story = { + render: function Render(args) { + const highlightConfig = useMemo( + () => ({ + core: () => import('highlight.js/lib/core'), + languages: { + typescript: () => import('highlight.js/lib/languages/typescript'), + }, + }), + [] + ); + + const files: CodeBlockFile[] = [ + { + language: 'typescript', + content: `type Vulnerability = {\n\tid: string;\n\tname: string;\n};\n\nconst vulnerabilities: Vulnerability[] = [\n\t{ id: '1', name: 'Zero-Day Exploit' },\n\t{ id: '2', name: 'Ransomware' }\n];`, + }, + ]; + + return ( + + + + ); + }, +}; + +export const LineNumbers: 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: `{\n\t"data": [{\n\t\t"id": "1",\n\t\t"attributes": {\n\t\t\t"name": "Cross-site scripting",\n\t\t\t"abbreviation": "XSS",\n\t\t\t"severity": "high"\n\t\t},\n\t\t"relationships": {\n\t\t\t"assignee": {\n\t\t\t\t"data": {"id": "42", "type": "people"}\n\t\t\t}\n\t\t}\n\t}],\n\t"included": [\n\t\t{\n\t\t\t"type": "people",\n\t\t\t"id": "42",\n\t\t\t"attributes": {\n\t\t\t\t"name": "John"\n\t\t\t}\n\t\t}\n\t]\n}`, + }, + ]; + + return ( + + 'data'} + className={spacing({ mbe: 'xs' })} + style={{ blockSize: 350 }} + /> + ( + <> + + + {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 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 + + + + ); + }, +}; + +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 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 + + + + ); + }, +}; + +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 files: CodeBlockFile[] = [ + { + language: 'bash', + content: 'npm audit --audit-level=high', + }, + ]; + + return ( + + + Filled + + + + ); + }, +}; + +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', + }, + ]; + + 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..c379e8fd0 --- /dev/null +++ b/packages/components/src/components/CodeBlock/CodeBlock.test.tsx @@ -0,0 +1,1087 @@ +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 { CodeBlockProvider } 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('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(); + + 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']); + }); + + 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')); + + 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 warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + 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 = ''; + const file = { content, language: 'unknown' }; + + 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(); + + 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 () => { + 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('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); + + await waitFor(() => + expect(screen.getByRole('tooltip')).toHaveAttribute( + 'data-transition', + 'entered' + ) + ); + + expect(root).toHaveAttribute('data-action-bar-tooltip-open'); + + const tooltip = screen.getByRole('tooltip'); + + await user.hover(tooltip); + + 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 () => { + 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 clickedLinks: HTMLAnchorElement[] = []; + + const clickSpy = vi + .spyOn(HTMLAnchorElement.prototype, 'click') + .mockImplementation(function (this: HTMLAnchorElement) { + clickedLinks.push(this); + expect(this.isConnected).toBe(true); + }); + + 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(clickedLinks[0]?.isConnected).toBe(false); + + await waitFor(() => + expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock-url') + ); + + rerender( + + ); + + await user.click(screen.getByRole('button', { name: 'Download' })); + + 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 () => { + 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-header'); + + expect(header).not.toHaveAttribute('data-scrolled'); + + Object.defineProperty(panel, 'scrollTop', { + configurable: true, + value: 10, + writable: true, + }); + + fireEvent.scroll(panel); + expect(header).toHaveAttribute('data-scrolled'); + + panel.scrollTop = 0; + fireEvent.scroll(panel); + 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(); + + render( + + ); + + await waitFor(() => + expect(getCode()).toHaveAttribute('data-language', 'javascript') + ); + + 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(); + + 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..2bf92dd78 --- /dev/null +++ b/packages/components/src/components/CodeBlock/CodeBlock.tsx @@ -0,0 +1,364 @@ +'use client'; + +import { + forwardRef, + useEffect, + useImperativeHandle, + useRef, + useState, +} from 'react'; +import type { Ref } from 'react'; + +import { once } from '@koobiq/logger'; +import { + clsx, + useControlledState, + useLocalizedStringFormatter, + useResizeObserver, +} from '@koobiq/react-core'; + +import s from './CodeBlock.module.css'; +import { + CodeBlockActionBar, + CodeBlockCode, + CodeBlockContent, + CodeBlockHeader, + 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, + slotProps, + 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 [isActionBarTooltipOpen, setIsActionBarTooltipOpen] = useState(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 ( + activeFileIndexProp === undefined && + activeFileIndexState !== activeFileIndex + ) { + setActiveFileIndex(activeFileIndex); + } + }, [ + activeFileIndex, + activeFileIndexProp, + activeFileIndexState, + setActiveFileIndex, + ]); + + // 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. + 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 [isContentOverflowing, setIsContentOverflowing] = useState(false); + + // 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, resetShadow } = 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 = () => { + // 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; + + setActiveFileIndex(index); + scrollToTop(); + }; + + const toggleSoftWrap = () => setSoftWrap(!softWrap); + + const toggleViewAll = () => { + const nextViewAll = !viewAll; + + setViewAll(nextViewAll); + + if (!nextViewAll) scrollToTop(); + }; + + const actionBar = ( + + ); + + const code = ( + + ); + + return ( +
+ {isTabsHidden ? ( + <> + + {actionBar} + + + {code} + + + ) : ( + <> + + {actionBar} + + )} +
+ ); +} + +/** + * 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'; 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..8914a3ff6 --- /dev/null +++ b/packages/components/src/components/CodeBlock/components/CodeBlockActionBar.tsx @@ -0,0 +1,179 @@ +'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; + onTooltipOpenChange: (isOpen: boolean) => void; +}; + +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; + document.body.append(link); + link.click(); + link.remove(); + + setTimeout(() => URL.revokeObjectURL(url), 0); +} + +export function CodeBlockActionBar(props: CodeBlockActionBarProps) { + const { + file, + fallbackFileName, + canToggleSoftWrap, + canDownload, + canCopy, + softWrap, + onSoftWrapToggle, + copyTooltip, + copiedTooltip, + downloadTooltip, + softWrapOnTooltip, + softWrapOffTooltip, + openExternalSystemTooltip, + onTooltipOpenChange, + } = 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); + } + }; + + const onCopyTooltipOpenChange = (isOpen: boolean): void => { + resetCopyTooltip(isOpen); + onTooltipOpenChange(isOpen); + }; + + return ( +
+
+ {canToggleSoftWrap && ( + ( +
+
+ ); +} 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..d466b825a --- /dev/null +++ b/packages/components/src/components/CodeBlock/components/CodeBlockCode.tsx @@ -0,0 +1,84 @@ +'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; + + const codeClassName = clsx( + 'hljs', + s.code, + utilClasses.typography['mono-codeblock'] + ); + + return ( + <> +
+        {/* Separate branches: `dangerouslySetInnerHTML` and children may not sit on one element. */}
+        {isHighlighted ? (
+          
+        ) : (
+          {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 new file mode 100644 index 000000000..4c6639499 --- /dev/null +++ b/packages/components/src/components/CodeBlock/components/CodeBlockTabs.tsx @@ -0,0 +1,89 @@ +'use client'; + +import type { ReactNode, Ref, UIEventHandler } from 'react'; + +import { mergeProps, mergeRefs } from '@koobiq/react-core'; + +import { Tab, Tabs } from '../../Tabs'; +import s from '../CodeBlock.module.css'; +import type { CodeBlockFile, CodeBlockProps } from '../types'; + +import { getCodeBlockHeaderProps } from './CodeBlockHeader'; + +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; + '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, + activeFileIndex, + onActiveFileIndexChange, + fallbackFileName, + renderTabLabel, + panelRef, + panelContent, + panelMaxHeight, + onPanelScroll, + isScrolled, + 'aria-label': ariaLabel, + slotProps, + } = props; + + const { + ref: contentRef, + style: contentStyle, + ...contentProps + } = slotProps?.content ?? {}; + + return ( + { + if (files.length > 1) onActiveFileIndexChange(Number(key)); + }} + slotProps={{ + tabs: getCodeBlockHeaderProps(isScrolled, slotProps?.header), + tabPanel: { + ...mergeProps( + { className: s.main, onScroll: onPanelScroll }, + contentProps + ), + ref: mergeRefs(panelRef, contentRef), + style: { maxHeight: panelMaxHeight, ...contentStyle }, + }, + }} + > + {files.map((file, index) => ( + + {/* `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/components/index.ts b/packages/components/src/components/CodeBlock/components/index.ts new file mode 100644 index 000000000..1b6d56ce5 --- /dev/null +++ b/packages/components/src/components/CodeBlock/components/index.ts @@ -0,0 +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 new file mode 100644 index 000000000..55a32de93 --- /dev/null +++ b/packages/components/src/components/CodeBlock/context.tsx @@ -0,0 +1,65 @@ +'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 CodeBlockProviderProps = { + /** The `highlight.js` loading configuration applied to every `CodeBlock` inside. */ + highlightConfig: CodeBlockHighlightConfig; + children?: ReactNode; +}; + +/** + * 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. + * @example + * ```tsx + * 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' + * }; + * + * + * + * + * ``` + */ +export function CodeBlockProvider(props: CodeBlockProviderProps) { + const { highlightConfig, 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..3f4d5ba45 --- /dev/null +++ b/packages/components/src/components/CodeBlock/hooks/useHighlightedCode.ts @@ -0,0 +1,240 @@ +'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) `highlightConfig` to + * `CodeBlockProvider` 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 `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 + * 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; + + // 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; + + let { language } = file; + + if (!language || !hljs.getLanguage(language)) { + if (process.env.NODE_ENV !== 'production') { + once.warn( + language + ? `[CodeBlock] Unsupported file language: "${language}". Fall back to "${fallbackLanguage}".` + : `[CodeBlock] Missing file 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({ + ...inputs, + html, + pending: false, + failed: false, + language: resolvedLanguage, + }); + }) + .catch((error: unknown) => { + if (cancelled) return; + + if (process.env.NODE_ENV !== 'production') { + once.warn('[CodeBlock] Failed to highlight the file.', error); + } + + setResult({ + ...inputs, + html: '', + pending: false, + failed: true, + language: file.language ?? fallbackLanguage, + }); + }); + + 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..8787833ab --- /dev/null +++ b/packages/components/src/components/CodeBlock/hooks/useOverflowShadow.ts @@ -0,0 +1,19 @@ +'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); + }, []); + + // 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/index.ts b/packages/components/src/components/CodeBlock/index.ts new file mode 100644 index 000000000..66e4c91c5 --- /dev/null +++ b/packages/components/src/components/CodeBlock/index.ts @@ -0,0 +1,7 @@ +export * from './CodeBlock'; +export { + CodeBlockProvider, + type CodeBlockHighlightConfig, + type CodeBlockProviderProps, +} 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..00cb9e610 --- /dev/null +++ b/packages/components/src/components/CodeBlock/types.ts @@ -0,0 +1,166 @@ +import type { + ComponentPropsWithRef, + 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, 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`. */ + 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. + * @default 1 + */ + 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. */ + 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..e5f200fb9 --- /dev/null +++ b/packages/components/src/components/CodeBlock/utils/lineNumbers.test.ts @@ -0,0 +1,121 @@ +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('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('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(''); + + 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..92f94bde8 --- /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.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 { + // 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( + 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..12fdeefbf --- /dev/null +++ b/tools/public_api_guard/components/CodeBlock.api.md @@ -0,0 +1,103 @@ +## 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 { ComponentPropsWithRef } from 'react'; +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 +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 (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; + 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; + 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) + +```