diff --git a/agents/Rules.md b/agents/Rules.md index d4ff0642..88c9a907 100644 --- a/agents/Rules.md +++ b/agents/Rules.md @@ -46,7 +46,7 @@ if empty - nothing to change - ~~Callouts~~ - ~~update to `variant="note"` instead of 7 different components. add `variant="custom"`, set it by default~~ - ~~Cards~~ -- Code groups +- ~~Code groups~~ - ~~Color~~ - nits https://mintlify.slack.com/archives/C09QQDHD7PG/p1764635576242229?thread_ts=1763963569.715779&cid=C09QQDHD7PG - ~~Columns~~ diff --git a/packages/components/src/components/code-block/base-code-block.tsx b/packages/components/src/components/code-block/base-code-block.tsx index 6aa50dbf..7d05d717 100644 --- a/packages/components/src/components/code-block/base-code-block.tsx +++ b/packages/components/src/components/code-block/base-code-block.tsx @@ -17,7 +17,6 @@ import { CodeFooter } from "./code-footer"; interface BaseCodeBlockProps extends CodeBlockProps { isParentCodeGroup?: boolean; shouldHighlight?: boolean; - // pass isLivePreview to forceExtract to force code string theme changes forceExtract?: boolean; } diff --git a/packages/components/src/components/code-block/code-block.tsx b/packages/components/src/components/code-block/code-block.tsx index 00b87e21..0806c93c 100644 --- a/packages/components/src/components/code-block/code-block.tsx +++ b/packages/components/src/components/code-block/code-block.tsx @@ -2,13 +2,15 @@ import type { ReactNode, RefObject } from "react"; import { Classes } from "@/lib/local/selectors"; import { cn } from "@/utils/cn"; -import type { CopyToClipboardResult } from "@/utils/copy-to-clipboard"; import { getNodeText } from "@/utils/get-node-text"; -import type { CodeStyling } from "@/validation"; +import type { CodeBlockTheme, CodeStyling } from "@/validation"; import { BaseCodeBlock } from "./base-code-block"; import { CodeHeader } from "./code-header"; -import { CopyToClipboardButton } from "./copy-button"; +import { + CopyToClipboardButton, + type CopyToClipboardButtonProps, +} from "./copy-button"; type CodeBlockProps = { language?: string; @@ -62,7 +64,7 @@ type CodeBlockProps = { /** * Prop to set the code block theme (code block UI theme) */ - codeBlockTheme?: "dark" | "system"; + codeBlockTheme?: CodeBlockTheme; /** * Prop to set the code block theme object (syntax highlighting theme) */ @@ -75,17 +77,13 @@ type CodeBlockProps = { * Pass in CodeSnippetFeedbackButton component */ feedbackButton?: ReactNode; - /** - * The callback function when a user clicks on the copied to clipboard button - */ - onCopied?: (result: CopyToClipboardResult, textToCopy?: string) => void; children?: ReactNode; + copyButtonProps?: CopyToClipboardButtonProps; }; const CodeBlock = function CodeBlock(params: CodeBlockProps) { const { filename, - onCopied, children, className, icon, @@ -97,6 +95,7 @@ const CodeBlock = function CodeBlock(params: CodeBlockProps) { codeBlockThemeObject, askAiButton, feedbackButton, + copyButtonProps, } = params; const codeString = getNodeText(children); @@ -126,7 +125,11 @@ const CodeBlock = function CodeBlock(params: CodeBlockProps) { icon={icon} > {feedbackButton && feedbackButton} - + {askAiButton && askAiButton} ) : ( @@ -135,7 +138,11 @@ const CodeBlock = function CodeBlock(params: CodeBlockProps) { data-floating-buttons > {feedbackButton && feedbackButton} - + {askAiButton && askAiButton} )} diff --git a/packages/components/src/components/code-block/code-header.tsx b/packages/components/src/components/code-block/code-header.tsx index cc3e6941..5f5b7180 100644 --- a/packages/components/src/components/code-block/code-header.tsx +++ b/packages/components/src/components/code-block/code-header.tsx @@ -3,11 +3,12 @@ import type { ReactNode } from "react"; import { Icon as ComponentIcon } from "@/components/icon"; import { Classes } from "@/lib/local/selectors"; import { cn } from "@/utils/cn"; +import type { CodeBlockTheme } from "@/validation"; type CodeHeaderProps = { filename?: string; icon?: string; - codeBlockTheme?: "dark" | "system"; + codeBlockTheme?: CodeBlockTheme; children?: ReactNode; }; diff --git a/packages/components/src/components/code-block/copy-button.tsx b/packages/components/src/components/code-block/copy-button.tsx index 9f71605e..710a0d0b 100644 --- a/packages/components/src/components/code-block/copy-button.tsx +++ b/packages/components/src/components/code-block/copy-button.tsx @@ -5,6 +5,7 @@ import { type CopyToClipboardResult, copyToClipboard, } from "@/utils/copy-to-clipboard"; +import type { CodeBlockTheme } from "@/validation"; const DEFAULT_COPY_BUTTON_ARIA_LABEL = "Copy the contents from the code block"; const DEFAULT_TOOLTIP_COPY_TEXT = "Copy"; @@ -15,7 +16,7 @@ type CopyToClipboardButtonProps = { onCopied?: (result: CopyToClipboardResult, textToCopy?: string) => void; className?: string; showTooltip?: boolean; - codeBlockTheme?: "system" | "dark"; + codeBlockTheme?: CodeBlockTheme; copyButtonAriaLabel?: string; tooltipCopyText?: string; tooltipCopiedText?: string; diff --git a/packages/components/src/components/code-block/story-utils.ts b/packages/components/src/components/code-block/story-utils.ts index 597356eb..95b103af 100644 --- a/packages/components/src/components/code-block/story-utils.ts +++ b/packages/components/src/components/code-block/story-utils.ts @@ -132,6 +132,38 @@ function MyComponent() { ); }`; +const getString = (tabIndex = 1, language?: string) => { + const lineInfo = tabIndex ? `Here are ${tabIndex} line(s)` : ""; + if (language === "python") { + return new Array(tabIndex) + .fill(`print("Hello, World!${lineInfo ? ` ${lineInfo}` : ""}")`) + .join("\n"); + } + + if (language === "java") { + return new Array(tabIndex) + .fill( + `System.out.println("Hello, World!${lineInfo ? ` ${lineInfo}` : ""}");` + ) + .join("\n"); + } + + return new Array(tabIndex) + .fill(`console.log('Hello, World!${lineInfo ? ` ${lineInfo}` : ""}');`) + .join("\n"); +}; + +const shortMultiLineCode = `function greet(name) { + console.log("Hello, " + name + "!"); + return true; +}`; + +const shortMultiLineCodeTS = + // biome-ignore lint/suspicious/noTemplateCurlyInString: just example code for stories + "function greet(name: string): boolean {\n console.log(`Hello, ${name}!`);\n return true;\n}"; + +const shortCode = `def greet(name):\n print(f"Hello, {name}!")\n return True`; + export { mediumCode, longExpandableCode, @@ -140,4 +172,8 @@ export { diffCodeWithRemove, simpleDiffCode, complexDiffCode, + getString, + shortMultiLineCode, + shortCode, + shortMultiLineCodeTS, }; diff --git a/packages/components/src/components/code-group/code-group.stories.tsx b/packages/components/src/components/code-group/code-group.stories.tsx index e47dc4eb..a046999a 100644 --- a/packages/components/src/components/code-group/code-group.stories.tsx +++ b/packages/components/src/components/code-group/code-group.stories.tsx @@ -1,18 +1,955 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { CodeBlock } from "@/components/code-block"; +import { + getString, + longExpandableCode, + longSingleLineCode, + mediumCode, + shortCode, + shortMultiLineCode, + shortMultiLineCodeTS, +} from "@/components/code-block/story-utils"; import { CodeGroup } from "./code-group"; const meta: Meta = { title: "Components/CodeGroup", component: CodeGroup, - parameters: { - layout: "centered", - }, - tags: ["autodocs"], }; export default meta; type Story = StoryObj; export const Default: Story = { - args: {}, + render: (_, { globals }) => ( + + + {getString(1)} + + + {`print("Hello, World!")`} + + + {`System.out.println("Hello, World!");`} + + + ), +}; + +export const WithSmallText: Story = { + render: (_, { globals }) => ( + + + {getString()} + + + {`print("Hello, World!")`} + + + ), +}; + +export const SingleTab: Story = { + render: (_, { globals }) => ( + + + {getString()} + + + ), +}; + +export const WithDarkCode: Story = { + render: () => ( + + + {shortMultiLineCode} + + + {shortCode} + + + {mediumCode} + + + ), +}; + +export const WithShikiTheme: Story = { + render: () => ( + + + {shortMultiLineCode} + + + {shortCode} + + + {mediumCode} + + + ), +}; + +export const WithLightDarkShikiThemes: Story = { + render: (_, { globals }) => ( + + + {shortMultiLineCode} + + + {shortCode} + + + {mediumCode} + + + ), +}; + +export const WithFilenames: Story = { + render: () => ( + + + {shortMultiLineCode} + + + {shortCode} + + + {mediumCode} + + + ), +}; + +export const WithIcons: Story = { + render: () => ( + + + {shortMultiLineCode} + + + {shortCode} + + + {mediumCode} + + + {shortMultiLineCodeTS} + + + ), +}; + +export const WithSpacesInFilenames: Story = { + render: () => ( + + + {shortMultiLineCode} + + + {shortCode} + + + {mediumCode} + + + ), +}; + +export const WithLineNumbers: Story = { + render: () => ( + + + {shortMultiLineCode} + + + {shortCode} + + + {mediumCode} + + + ), +}; + +export const LineNumbersDarkCode: Story = { + render: () => ( + + + {shortMultiLineCode} + + + {shortCode} + + + ), +}; + +export const WithWrap: Story = { + render: () => ( + + + {longSingleLineCode} + + + {`# ${longSingleLineCode}`} + + + ), +}; + +export const WrapWithLineNumbers: Story = { + render: () => ( + + + {longSingleLineCode} + + + {`# ${longSingleLineCode}`} + + + ), +}; + +export const WithHighlight: Story = { + render: () => ( + + + {mediumCode} + + + {shortMultiLineCode} + + + ), +}; + +export const HighlightWithLineNumbers: Story = { + render: () => ( + + + {mediumCode} + + + {shortMultiLineCode} + + + ), +}; + +export const HighlightRange: Story = { + render: () => ( + + + {mediumCode} + + + {shortMultiLineCode} + + + ), +}; + +export const HighlightMixed: Story = { + render: () => ( + + + {mediumCode} + + + {shortMultiLineCode} + + + ), +}; + +export const WrapWithHighlight: Story = { + render: () => ( + + + {longSingleLineCode} + + + {`# ${longSingleLineCode}`} + + + ), +}; + +export const WithFocus: Story = { + render: () => ( + + + {mediumCode} + + + {shortMultiLineCode} + + + ), +}; + +export const FocusWithLineNumbers: Story = { + render: () => ( + + + {mediumCode} + + + {shortMultiLineCode} + + + ), +}; + +export const FocusRange: Story = { + render: () => ( + + + {mediumCode} + + + {shortMultiLineCode} + + + ), +}; + +export const FocusMixed: Story = { + render: () => ( + + + {mediumCode} + + + {shortMultiLineCode} + + + ), +}; + +export const WrapWithFocus: Story = { + render: () => ( + + + {longSingleLineCode} + {"\n"} + {shortMultiLineCode} + + + {`# ${longSingleLineCode}`} + {"\n"} + {shortMultiLineCode} + + + ), +}; + +export const HighlightAndFocus: Story = { + render: () => ( + + + {mediumCode} + + + {shortMultiLineCode} + + + ), +}; + +export const HighlightFocusLineNumbers: Story = { + render: () => ( + + + {mediumCode} + + + {shortMultiLineCode} + + + ), +}; + +export const HighlightFocusOverlap: Story = { + render: () => ( + + + {mediumCode} + + + {shortMultiLineCode} + + + ), +}; + +export const Expandable: Story = { + render: () => ( + + + {longExpandableCode} + + + {getString(15)} + + + ), +}; + +export const ExpandableWithLineNumbers: Story = { + render: () => ( + + + {longExpandableCode} + + + {getString(15)} + + + ), +}; + +export const ExpandableWithHighlight: Story = { + render: () => ( + + + {longExpandableCode} + + + {getString(15)} + + + ), +}; + +export const ExpandableWithFocus: Story = { + render: () => ( + + + {longExpandableCode} + + + {getString(15)} + + + ), +}; + +export const ExpandableAllFeatures: Story = { + render: () => ( + + + {longExpandableCode} + + + {getString(15)} + + + {mediumCode} + + + ), +}; + +export const EmptyCode: Story = { + render: () => ( + + + {""} + + + {""} + + + ), +}; + +export const VeryLongSingleLine: Story = { + render: () => ( + + + { + 'const veryLongVariableNameThatExceedsNormalLineLengthAndShouldTestHorizontalScrollingBehaviorInCodeBlocksWhenWrapIsDisabled = "This is a very long string that should also test the horizontal scrolling behavior of code blocks when wrap is disabled and the content exceeds the container width";' + } + + + { + 'very_long_variable_name_that_exceeds_normal_line_length_and_should_test_horizontal_scrolling_behavior_in_code_blocks_when_wrap_is_disabled = "This is a very long string that should also test the horizontal scrolling behavior of code blocks when wrap is disabled and the content exceeds the container width"' + } + + + ), +}; + +export const VeryLongSingleLineWithWrap: Story = { + render: () => ( + + + { + 'const veryLongVariableNameThatExceedsNormalLineLengthAndShouldTestHorizontalScrollingBehaviorInCodeBlocksWhenWrapIsDisabled = "This is a very long string that should also test the horizontal scrolling behavior of code blocks when wrap is disabled and the content exceeds the container width";' + } + + + { + 'very_long_variable_name_that_exceeds_normal_line_length_and_should_test_horizontal_scrolling_behavior_in_code_blocks_when_wrap_is_disabled = "This is a very long string that should also test the horizontal scrolling behavior of code blocks when wrap is disabled and the content exceeds the container width"' + } + + + ), +}; + +export const SpecialCharacters: Story = { + render: () => ( + + + {`// Special characters test +const symbols = "!@#$%^&*()_+-=[]{}|;':",./<>?"; +const unicode = "Hello 世界 🌍 café naïve résumé"; +const regex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$/;`} + + + {`# Special characters test +symbols = "!@#$%^&*()_+-=[]{}|;':\\",./<>?" +unicode = "Hello 世界 🌍 café naïve résumé" +import re +regex = re.compile(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$')`} + + + ), +}; + +export const ComplexHighlightPattern: Story = { + render: () => ( + + + {longExpandableCode} + + + {getString(15)} + + + ), +}; + +export const ComplexFocusPattern: Story = { + render: () => ( + + + {longExpandableCode} + + + {getString(15)} + + + ), +}; + +export const WithOverflowsInGrid: Story = { + render: () => ( +
+ + + {getString(3)} + + + {getString(3, "python")} + + + {getString(3, "java")} + + + + + {getString(3)} + + + {getString(3, "python")} + + + {getString(3, "java")} + + +
+ ), +}; + +export const DropdownEnabled: Story = { + render: () => ( + + + {getString(3)} + + + {getString(3, "python")} + + + {getString(3, "java")} + + + ), }; diff --git a/packages/components/src/components/code-group/code-group.tsx b/packages/components/src/components/code-group/code-group.tsx index 91868158..09e396c5 100644 --- a/packages/components/src/components/code-group/code-group.tsx +++ b/packages/components/src/components/code-group/code-group.tsx @@ -1,3 +1,307 @@ -export const CodeGroup = () => { - return
CodeGroup
; +import { Tabs } from "@base-ui/react/tabs"; +import React, { + forwardRef, + type ReactElement, + type ReactNode, + type RefObject, + useCallback, + useRef, + useState, +} from "react"; + +import { BaseCodeBlock } from "@/components/code-block/base-code-block"; +import type { CodeBlockProps } from "@/components/code-block/code-block"; +import { + CopyToClipboardButton, + type CopyToClipboardButtonProps, +} from "@/components/code-block/copy-button"; +import { Icon as ComponentIcon } from "@/components/icon"; +import { Classes } from "@/lib/local/selectors"; +import { cn } from "@/utils/cn"; +import { getNodeText } from "@/utils/get-node-text"; +import type { CodeBlockTheme, CodeStyling } from "@/validation"; + +import { LanguageDropdown } from "./language-dropdown"; + +type CodeGroupProps = { + dropdown?: boolean; + isSmallText?: boolean; + children?: ReactElement[] | ReactElement; + noMargins?: boolean; + feedbackModalOpen?: boolean; + anchorRef?: RefObject; + codeBlockTheme?: CodeBlockTheme; + codeBlockThemeObject?: CodeStyling; + initialSelectedTab?: number; + onSelectedTabChange?: (index: number) => void; + askAiButton?: ReactNode; + feedbackButton?: ReactNode; + className?: string; + copyButtonProps?: CopyToClipboardButtonProps; }; + +type CodeBlockChild = Exclude< + React.ReactElement, + boolean | null | undefined +>; + +const CodeGroup = ({ + children, + isSmallText, + className, + noMargins, + dropdown, + feedbackModalOpen, + anchorRef, + codeBlockTheme = "system", + codeBlockThemeObject, + initialSelectedTab = 0, + onSelectedTabChange, + askAiButton, + feedbackButton, + copyButtonProps, +}: CodeGroupProps) => { + const childArr = React.Children.toArray(children) as CodeBlockChild[]; + const safeInitialIndex = Math.min( + Math.max(0, initialSelectedTab), + Math.max(0, childArr.length - 1) + ); + const [selectedTab, setSelectedTab] = useState(safeInitialIndex); + const triggerRefs = useRef>(new Map()); + + const handleValueChange = useCallback( + (value: string) => { + const index = Number(value); + const wasFocusOnTab = + typeof document !== "undefined" && + document.activeElement?.getAttribute("role") === "tab"; + + // Important to clear hash to avoid collisions with tab groups + if (typeof window !== "undefined" && window.location.hash) { + window.history.replaceState( + null, + "", + window.location.pathname + window.location.search + ); + } + + setSelectedTab(index); + onSelectedTabChange?.(index); + + if (wasFocusOnTab && typeof requestAnimationFrame !== "undefined") { + requestAnimationFrame(() => { + const trigger = triggerRefs.current.get(index); + if (trigger) { + trigger.focus(); + } + }); + } + }, + [onSelectedTabChange] + ); + + if (!children) { + return null; + } + + if (childArr.length === 0) { + console.warn( + "CodeGroup has no children, expected at least one CodeBlock child." + ); + return null; + } + + const selectedIndex = Number(selectedTab); + + return ( + handleValueChange(String(value))} + ref={anchorRef as React.Ref} + value={selectedTab} + > +
+ {dropdown ? ( +
+ {childArr[selectedIndex]?.props.icon && + typeof childArr[selectedIndex]?.props.icon === "string" && ( + + )} + + {childArr[selectedIndex]?.props.filename} + +
+ ) : ( + + {childArr.map((child, index) => ( + { + if (el) { + triggerRefs.current.set(index, el); + } else { + triggerRefs.current.delete(index); + } + }} + tabsLength={childArr.length} + value={index} + > + {child.props.icon && typeof child.props.icon === "string" && ( + + )} + {child.props.filename} + + ))} + + )} +
+ {dropdown && ( + child.props.language || "")} + selectedLanguage={childArr[selectedIndex]?.props.language || ""} + setSelectedLanguage={(_language: string, index: number) => { + handleValueChange(String(index)); + }} + /> + )} + {feedbackButton && feedbackButton} + + {askAiButton && askAiButton} +
+
+
+ {childArr.map((child, index) => { + return ( + + + + ); + })} +
+
+ ); +}; + +type TabItemProps = { + children: ReactNode; + value: number; + isSelected: boolean; + tabsLength: number; + codeBlockTheme: CodeBlockTheme; +}; + +const TabItem = forwardRef, TabItemProps>( + function TabItem( + { children, value, isSelected, tabsLength, codeBlockTheme }, + ref + ) { + return ( + +
1 && + codeBlockTheme === "system" && + "group-hover:bg-gray-200/50 group-hover:text-primary dark:group-hover:bg-gray-700/70 dark:group-hover:text-primary-light", + tabsLength > 1 && + codeBlockTheme === "dark" && + "group-hover:bg-gray-700/70 group-hover:text-primary-light" + )} + > + {children} +
+ + {isSelected && ( +
+ )} + + ); + } +); + +export { CodeGroup, type CodeGroupProps }; diff --git a/packages/components/src/components/code-group/code-select-dropdown.tsx b/packages/components/src/components/code-group/code-select-dropdown.tsx new file mode 100644 index 00000000..01c21940 --- /dev/null +++ b/packages/components/src/components/code-group/code-select-dropdown.tsx @@ -0,0 +1,83 @@ +import { ChevronDownIcon } from "lucide-react"; + +import { cn } from "@/utils/cn"; +import type { CodeBlockTheme } from "@/validation"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "./dropdown"; + +type CodeSelectDropdownProps = { + selectedOption?: string; + setSelectedOption: (option: string) => void; + options: string[]; + codeBlockTheme?: CodeBlockTheme; +}; + +const CodeSelectDropdown = ({ + selectedOption, + setSelectedOption, + options, + codeBlockTheme = "system", +}: CodeSelectDropdownProps) => { + const hasOptions = options.length > 1; + + return ( + + +
+

{selectedOption}

+ {hasOptions && } +
+
+ + {options.map((option, i) => ( + setSelectedOption(option)} + > + {option} + + ))} + +
+ ); +}; + +export { CodeSelectDropdown }; diff --git a/packages/components/src/components/code-group/code-snippet.tsx b/packages/components/src/components/code-group/code-snippet.tsx new file mode 100644 index 00000000..942d3eba --- /dev/null +++ b/packages/components/src/components/code-group/code-snippet.tsx @@ -0,0 +1,47 @@ +import { memo, type ReactNode } from "react"; + +import { useGetShikiHighlightedHtml } from "@/hooks/use-get-shiki-highlighted-html"; +import { SHIKI_CLASSNAME } from "@/utils/shiki/constants"; +import type { CodeStyling } from "@/validation"; + +type CodeSnippetProps = { + language?: string; + /** + * If `children` is a `string`, `Shiki` will be emitted + */ + children?: ReactNode; + codeBlockThemeObject?: CodeStyling; +}; + +const CodeSnippet = memo( + ({ language, children, codeBlockThemeObject }: CodeSnippetProps) => { + const codeString = typeof children === "string" ? children : ""; + language = language ?? "text"; + const html = useGetShikiHighlightedHtml({ + codeString, + codeBlockTheme: codeBlockThemeObject, + language, + opts: { structure: "inline" }, + }); + + if (typeof children === "string" && language && html) { + return ( +
+          
+        
+ ); + } + + return ( +
+        {children}
+      
+ ); + } +); + +export { CodeSnippet, type CodeSnippetProps }; diff --git a/packages/components/src/components/code-group/dropdown.tsx b/packages/components/src/components/code-group/dropdown.tsx new file mode 100644 index 00000000..94f1f2e1 --- /dev/null +++ b/packages/components/src/components/code-group/dropdown.tsx @@ -0,0 +1,115 @@ +import { Menu } from "@base-ui/react/menu"; +import { CheckIcon } from "lucide-react"; +import type { ComponentPropsWithoutRef, ReactNode } from "react"; +import { cn } from "@/utils/cn"; + +type DropdownMenuProps = { + children: ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; +}; + +const DropdownMenu = ({ children, open, onOpenChange }: DropdownMenuProps) => ( + + {children} + +); +DropdownMenu.displayName = "DropdownMenu"; + +type DropdownMenuTriggerProps = ComponentPropsWithoutRef; + +const DropdownMenuTrigger = ({ + className, + children, + ...props +}: DropdownMenuTriggerProps) => ( + span]:line-clamp-1", + "flex items-center gap-1 py-0.5 text-gray-950/50 text-sm group-hover:text-gray-950/70 dark:text-white/50 dark:group-hover:text-white/70", + className + )} + {...props} + > + {children} + +); +DropdownMenuTrigger.displayName = "DropdownMenuTrigger"; + +type DropdownMenuContentProps = ComponentPropsWithoutRef & { + sideOffset?: number; + align?: "start" | "center" | "end"; +}; + +const DropdownMenuContent = ({ + className, + sideOffset = 4, + align = "start", + ...props +}: DropdownMenuContentProps) => ( + + + + + +); +DropdownMenuContent.displayName = "DropdownMenuContent"; + +type DropdownMenuItemProps = ComponentPropsWithoutRef & { + isSelected?: boolean; + onSelect?: () => void; +}; + +const DropdownMenuItem = ({ + className, + isSelected, + children, + onSelect, + onClick, + ...props +}: DropdownMenuItemProps) => ( + { + onClick?.(e); + onSelect?.(); + }} + {...props} + > + {children} + + +); +DropdownMenuItem.displayName = "DropdownMenuItem"; + +export { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +}; diff --git a/packages/components/src/components/code-group/index.ts b/packages/components/src/components/code-group/index.ts index d1007a47..d10a6dfb 100644 --- a/packages/components/src/components/code-group/index.ts +++ b/packages/components/src/components/code-group/index.ts @@ -1,2 +1,2 @@ // biome-ignore lint/performance/noBarrelFile: TODO -export { CodeGroup } from "./code-group"; +export { CodeGroup, type CodeGroupProps } from "./code-group"; diff --git a/packages/components/src/components/code-group/language-dropdown.tsx b/packages/components/src/components/code-group/language-dropdown.tsx new file mode 100644 index 00000000..e786031b --- /dev/null +++ b/packages/components/src/components/code-group/language-dropdown.tsx @@ -0,0 +1,111 @@ +import { ChevronsUpDownIcon } from "lucide-react"; +import { useState } from "react"; + +import { cn } from "@/utils/cn"; +import { getDisplayName } from "@/utils/shiki/snippet-presets"; +import type { CodeBlockTheme } from "@/validation"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "./dropdown"; +import { LanguageIcon } from "./language-icon"; + +type LanguageDropdownProps = { + selectedLanguage: string; + setSelectedLanguage: (language: string, index: number) => void; + languages: string[]; + codeBlockTheme?: CodeBlockTheme; +}; + +const LanguageDropdown = ({ + selectedLanguage, + setSelectedLanguage, + languages, + codeBlockTheme = "system", +}: LanguageDropdownProps) => { + const hasOptions = languages.length > 1; + const [isOpen, setIsOpen] = useState(false); + + return ( + + +
+ +

+ {getDisplayName(selectedLanguage)} +

+ {hasOptions && } +
+
+ + {languages.map((language, i) => { + const isSelected = language === selectedLanguage; + return ( + setSelectedLanguage(language, i)} + > + +
+ {getDisplayName(language)} +
+
+ ); + })} +
+
+ ); +}; + +export { LanguageDropdown }; diff --git a/packages/components/src/components/code-group/language-icon.tsx b/packages/components/src/components/code-group/language-icon.tsx new file mode 100644 index 00000000..e7906786 --- /dev/null +++ b/packages/components/src/components/code-group/language-icon.tsx @@ -0,0 +1,33 @@ +import { cn } from "@/utils/cn"; +import { getLanguageIconUrl } from "@/utils/icon-utils"; + +type LanguageIconProps = { + language: string; + className?: string; +}; + +const LanguageIcon = ({ language, className }: LanguageIconProps) => { + const url = getLanguageIconUrl(language); + + if (!url) { + return null; + } + + return ( + + ); +}; + +export { LanguageIcon }; diff --git a/packages/components/src/icons/index.tsx b/packages/components/src/icons/index.tsx index 21beef74..1fdeadbb 100644 --- a/packages/components/src/icons/index.tsx +++ b/packages/components/src/icons/index.tsx @@ -1,6 +1,7 @@ import type { SVGProps } from "react"; import { cn } from "@/utils/cn"; +import type { CodeBlockTheme } from "@/validation"; const InfoIcon = ({ "aria-label": ariaLabel = "Info", @@ -272,7 +273,7 @@ const LinkIcon = (props: SVGProps) => { const ActiveCopyButtonIcon = ({ codeBlockTheme = "system", }: { - codeBlockTheme?: "system" | "dark"; + codeBlockTheme?: CodeBlockTheme; }) => { return ( { return ( { diff --git a/packages/components/src/validation/index.ts b/packages/components/src/validation/index.ts index ccb3c6a6..34b9b64a 100644 --- a/packages/components/src/validation/index.ts +++ b/packages/components/src/validation/index.ts @@ -1,6 +1,7 @@ +type CodeBlockTheme = "dark" | "system"; + type CodeStyling = - | "dark" - | "system" + | CodeBlockTheme | { languages?: | { @@ -199,4 +200,4 @@ type CodeStyling = } | undefined; -export type { CodeStyling }; +export type { CodeStyling, CodeBlockTheme }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4103a697..334c4179 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1520,6 +1520,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} @@ -1877,6 +1881,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -2063,6 +2070,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + get-tsconfig@4.13.0: resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} @@ -2621,6 +2632,35 @@ packages: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true react-use-rect@2.0.6: resolution: {integrity: sha512-as/VEIPY9SrS4E7T/rXQ59aqQ+XKvl/5E6fI5ODmrQv6WJup2CFNA9ja3BMq13ge1dhgBFOHicZC1ZKXLUQtTA==} peerDependencies: @@ -2956,6 +2996,26 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + use-sync-external-store@1.6.0: resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: @@ -4454,6 +4514,10 @@ snapshots: argparse@2.0.1: {} + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + aria-query@5.3.0: dependencies: dequal: 2.0.3 @@ -4809,6 +4873,8 @@ snapshots: detect-libc@2.1.2: {} + detect-node-es@1.1.0: {} + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -5046,6 +5112,8 @@ snapshots: gensync@1.0.0-beta.2: {} + get-nonce@1.0.1: {} + get-tsconfig@4.13.0: dependencies: resolve-pkg-maps: 1.0.0 @@ -5574,6 +5642,32 @@ snapshots: react-refresh@0.17.0: {} + react-remove-scroll-bar@2.3.8(@types/react@18.3.27)(react@18.3.1): + dependencies: + react: 18.3.1 + react-style-singleton: 2.2.3(@types/react@18.3.27)(react@18.3.1) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.27 + + react-remove-scroll@2.7.2(@types/react@18.3.27)(react@18.3.1): + dependencies: + react: 18.3.1 + react-remove-scroll-bar: 2.3.8(@types/react@18.3.27)(react@18.3.1) + react-style-singleton: 2.2.3(@types/react@18.3.27)(react@18.3.1) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@18.3.27)(react@18.3.1) + use-sidecar: 1.1.3(@types/react@18.3.27)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + + react-style-singleton@2.2.3(@types/react@18.3.27)(react@18.3.1): + dependencies: + get-nonce: 1.0.1 + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.27 react-use-rect@2.0.6(react@18.3.1): dependencies: react: 18.3.1 @@ -5911,6 +6005,21 @@ snapshots: dependencies: punycode: 2.3.1 + use-callback-ref@1.3.3(@types/react@18.3.27)(react@18.3.1): + dependencies: + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.27 + + use-sidecar@1.1.3(@types/react@18.3.27)(react@18.3.1): + dependencies: + detect-node-es: 1.1.0 + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.27 + use-sync-external-store@1.6.0(react@18.3.1): dependencies: react: 18.3.1