diff --git a/audit/component_map/codeblock.yml b/audit/component_map/codeblock.yml index 9c83fd3e6..75c8c22b7 100644 --- a/audit/component_map/codeblock.yml +++ b/audit/component_map/codeblock.yml @@ -1,5 +1,5 @@ shadcn: code-block -shadcn_sha: 4aaea19d9cff3e0908e12a8c1d504b8c5ec34610 +shadcn_sha: 8a3536678a528cc012b9e62484290e79d0be410f kind: static docs_view: codeblock parts: @@ -14,18 +14,24 @@ parts: CodeblockTheme: null selectors: Codeblock: '[data-audit-fixture] [data-slot="code-block"]' + CodeblockHeader: '[data-audit-fixture] [data-slot="code-block-header"]' + CodeblockTitle: '[data-audit-fixture] [data-slot="code-block-title"]' CodeblockActions: '[data-audit-fixture] [data-slot="code-block-actions"]' CodeblockCopyButton: '[data-audit-fixture] [data-slot="code-block-copy-button"]' CodeblockContent: '[data-audit-fixture] [data-slot="code-block-content"]' + CodeblockLine: '[data-audit-fixture] [data-slot="code-block-line"]' + CodeblockFooter: '[data-audit-fixture] [data-slot="code-block-footer"]' props: # Codeblock(code, ...) is a shorthand that also takes CodeblockContent's props. Codeblock: {variant: variant, size: size} - CodeblockContent: {wrap: wrap, lineNumbers: line_numbers, startLine: start_line} - CodeblockCopyButton: {value: value, variant: variant, size: size} + CodeblockContent: {style: style, wrap: wrap, lineNumbers: line_numbers, startLine: start_line} + # onClick: React event prop; in RubyUI a data-action passes through attrs. + CodeblockCopyButton: {value: value, variant: variant, size: size, onClick: null} CodeblockLine: {variant: variant} rubyui_only: # syntax: Rouge highlighting (shadcn's base ships no highlighter). - # clipboard*: the shorthand's copy button on/off and its labels. + # clipboard*: the shorthand's copy button on/off and its labels (clipboard_error: deprecated, ignored). Codeblock: [syntax, clipboard, clipboard_success, clipboard_error, wrap, line_numbers, start_line] CodeblockContent: [syntax] + # error_label: deprecated, ignored (a failed copy shows nothing, as in shadcn). CodeblockCopyButton: [label, copied_label, error_label] diff --git a/audit/divergences/codeblock.yml b/audit/divergences/codeblock.yml new file mode 100644 index 000000000..8ebd089e1 --- /dev/null +++ b/audit/divergences/codeblock.yml @@ -0,0 +1,6 @@ +- component: codeblock + dimension: Props + rubyui: "no onClick on CodeblockCopyButton; a data-action passed through attrs runs alongside the copy" + shadcn: "onClick on CodeBlockCopyButton, called before the copy" + reason: "Server-rendered component: there is no React event prop. Extra behavior hooks in through Stimulus with data: {action: \"click->my-controller#method\"}, merged with the button's own copy action." + since: "2026-11-05" diff --git a/audit/fixtures/codeblock/content.yml b/audit/fixtures/codeblock/content.yml index 8d8ab47e0..253d560a6 100644 --- a/audit/fixtures/codeblock/content.yml +++ b/audit/fixtures/codeblock/content.yml @@ -1,11 +1,20 @@ -# One code block with a copy button, as shadcn's code-block example without -# header/footer. Plain text on both sides (shadcn's base has no highlighter; -# RubyUI uses Rouge's plaintext lexer) so syntax colors stay out of Look. -# The root carries data-audit-trigger (static kind: only the closed state is -# collected; the copy flow is driven by hand). +# Three code blocks, the same on both sides: +# 1. Floating copy button (CodeBlockActions as a direct child), no header/footer. +# 2. Header with a title and the copy button, then a footer. +# 3. Numbered lines with the default, added, removed and highlighted variants. +# Plain text everywhere (shadcn's base has no highlighter; RubyUI uses Rouge's +# plaintext lexer) so syntax colors stay out of Look. The first root carries +# data-audit-trigger (static kind: only the closed state is collected; hover +# and the copy flow are driven by hand). lines: - "$ pnpm add ai zod" - "dependencies:" - "+ ai 6.0.12" - "+ zod 4.1.12" - "Done in 2.8s" +title: "install.sh" +footer: "Runs in the project root." +diff: + - "const answer = 41" + - "const answer = 42" + - "console.log(answer)" diff --git a/audit/fixtures/codeblock/rubyui.rb b/audit/fixtures/codeblock/rubyui.rb index f8c937351..59194c29b 100644 --- a/audit/fixtures/codeblock/rubyui.rb +++ b/audit/fixtures/codeblock/rubyui.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true module ShadcnAuditFixtures - # RubyUI side of audit/fixtures/codeblock (strings in content.yml). + # RubyUI side of audit/fixtures/codeblock (strings in content.yml): a + # floating copy button, a header + footer, and numbered diff lines. class Codeblock < Phlex::HTML include RubyUI @@ -14,8 +15,24 @@ class Codeblock < Phlex::HTML CODE def view_template - div(class: "w-[32rem] p-6") do + div(class: "flex w-[32rem] flex-col gap-4 p-6") do Codeblock(CODE, syntax: :plaintext, data: {audit_trigger: true}) + Codeblock do + CodeblockHeader do + CodeblockTitle { "install.sh" } + CodeblockActions { CodeblockCopyButton(value: CODE) } + end + CodeblockContent(CODE, syntax: :plaintext) + CodeblockFooter { "Runs in the project root." } + end + Codeblock do + CodeblockContent(line_numbers: true) do + CodeblockLine(variant: :removed) { "const answer = 41" } + CodeblockLine(variant: :added) { "const answer = 42" } + CodeblockLine() + CodeblockLine(variant: :highlighted) { "console.log(answer)" } + end + end end end end diff --git a/audit/fixtures/codeblock/shadcn.tsx b/audit/fixtures/codeblock/shadcn.tsx index c5379eafb..717f26fa4 100644 --- a/audit/fixtures/codeblock/shadcn.tsx +++ b/audit/fixtures/codeblock/shadcn.tsx @@ -1,10 +1,14 @@ -// One code block with a copy button (CodeBlockActions as a direct child, so -// it floats top-right like RubyUI's), no header/footer. Plain text, no highlighter. +// Three code blocks (strings in content.yml): a floating copy button, a +// header + footer, and numbered diff lines. Plain text, no highlighter. import { CodeBlock, CodeBlockActions, CodeBlockContent, CodeBlockCopyButton, + CodeBlockFooter, + CodeBlockHeader, + CodeBlockLine, + CodeBlockTitle, } from "@/components/ui/code-block" const code = `$ pnpm add ai zod @@ -16,13 +20,31 @@ Done in 2.8s export default function CodeblockFixture() { return ( -
(so the keyboard can scroll it)\n # with a inside.\n #\n # Given code, it highlights it with Rouge (syntax: any Rouge lexer name;\n # plain text without it) and, with line_numbers: true, renders one\n # CodeblockLine per line, numbered from start_line. Without code, the block\n # gives its own content, usually CodeblockLines (e.g. a diff with\n # variant: :added / :removed). wrap: true wraps long lines instead of\n # scrolling sideways.\n class CodeblockContent < Base\n FORMATTER = ::Rouge::Formatters::HTML.new\n\n def initialize(code = nil, syntax: nil, wrap: false, line_numbers: false, start_line: 1, style: nil, **attrs)\n @code = code\n @style = style\n @syntax = syntax\n @wrap = wrap\n @line_numbers = line_numbers\n @start_line = start_line.to_i\n super(**attrs)\n end\n\n def view_template(&block)\n pre(**attrs) do\n code(\n data: {slot: \"code-block-code\"},\n class: \"grid min-h-lh min-w-max content-start in-data-numbered:grid-cols-[auto_1fr] in-data-wrap:min-w-0\"\n ) do\n if @code.nil?\n block&.call\n elsif @line_numbers\n lines.each { |line| render CodeblockLine.new { raw(safe(line)) } }\n else\n # One grid item: Rouge's spans would each take a row of the grid.\n span { raw(safe(FORMATTER.format(lexer.lex(@code)))) }\n end\n end\n end\n end\n\n private\n\n def lexer\n (@syntax && ::Rouge::Lexer.find(@syntax.to_s)) || ::Rouge::Lexers::PlainText\n end\n\n # Highlighted HTML per line, without the trailing newline, so each line's\n # spans close inside its CodeblockLine.\n def lines\n lines = [[]]\n lexer.lex(@code.chomp).each do |token, value|\n value.split(/(\\n)/).each do |part|\n if part == \"\\n\"\n lines << []\n elsif !part.empty?\n lines.last << [token, part]\n end\n end\n end\n lines.map { |tokens| FORMATTER.format(tokens) }\n end\n\n # One declaration list: Phlex's mix would join our string with the\n # caller's style without a separator. The caller's style comes last, so it\n # wins, as in shadcn ({counterReset, ...style}).\n def style\n own = \"counter-reset: line #{@start_line - 1};\"\n theirs = @style.is_a?(Hash) ? @style.map { |k, v| \"#{k.to_s.tr(\"_\", \"-\")}: #{v};\" }.join(\" \") : @style.to_s.strip\n theirs = \"#{theirs};\" unless theirs.empty? || theirs.end_with?(\";\")\n [own, theirs].reject(&:empty?).join(\" \")\n end\n\n def default_attrs\n data = {slot: \"code-block-content\"}\n data[:wrap] = true if @wrap\n data[:numbered] = true if @line_numbers\n {\n dir: \"ltr\",\n tabindex: 0,\n style: style,\n data: data,\n class: [\n \"min-h-0 min-w-0 overflow-auto px-4 py-4 text-start font-mono leading-relaxed outline-none\",\n \"data-wrap:wrap-break-word data-wrap:whitespace-pre-wrap\",\n \"group-has-data-[slot=code-block-header]/code-block:pt-2 group-has-data-[slot=code-block-footer]/code-block:pb-2\",\n \"group-data-[size=sm]/code-block:px-3 group-data-[size=sm]/code-block:py-3\",\n \"group-data-[size=sm]/code-block:group-has-data-[slot=code-block-header]/code-block:pt-1.5\"\n ]\n }\n end\n end\nend\n"
+ "content": "# frozen_string_literal: true\n\nrequire \"rouge\"\n\nmodule RubyUI\n # The scrollable code: a focusable (so the keyboard can scroll it)\n # with a inside.\n #\n # Given code, it highlights it with Rouge (syntax: any Rouge lexer name;\n # plain text without it) and, with line_numbers: true, renders one\n # CodeblockLine per line, numbered from start_line. Without code, the block\n # gives its own content, usually CodeblockLines (e.g. a diff with\n # variant: :added / :removed). wrap: true wraps long lines instead of\n # scrolling sideways.\n class CodeblockContent < Base\n FORMATTER = ::Rouge::Formatters::HTML.new\n\n def initialize(code = nil, syntax: nil, wrap: false, line_numbers: false, start_line: 1, style: nil, **attrs)\n @code = code\n @style = style\n @syntax = syntax\n @wrap = wrap\n @line_numbers = line_numbers\n @start_line = start_line.to_i\n super(**attrs)\n end\n\n def view_template(&block)\n pre(**attrs) do\n code(\n data: {slot: \"code-block-code\"},\n class: \"grid min-h-lh min-w-max content-start in-data-numbered:grid-cols-[auto_1fr] in-data-wrap:min-w-0\"\n ) do\n if @code.nil?\n block&.call\n elsif @line_numbers\n lines.each { |line| render CodeblockLine.new { raw(safe(line)) } }\n else\n # One grid item: Rouge's spans would each take a row of the grid.\n span { raw(safe(FORMATTER.format(lexer.lex(@code)))) }\n end\n end\n end\n end\n\n private\n\n def lexer\n (@syntax && ::Rouge::Lexer.find(@syntax.to_s)) || ::Rouge::Lexers::PlainText\n end\n\n # Highlighted HTML per line, without the trailing newline, so each line's\n # spans close inside its CodeblockLine.\n def lines\n lines = [[]]\n lexer.lex(@code.chomp).each do |token, value|\n value.split(/(\\n)/).each do |part|\n if part == \"\\n\"\n lines << []\n elsif !part.empty?\n lines.last << [token, part]\n end\n end\n end\n lines.map { |tokens| FORMATTER.format(tokens) }\n end\n\n # One declaration list: Phlex's mix would join our string with the\n # caller's style without a separator. The caller's style comes last, so it\n # wins, as in shadcn ({counterReset, ...style}).\n def style\n own = \"counter-reset: line #{@start_line - 1};\"\n theirs = @style.is_a?(Hash) ? @style.map { |k, v| \"#{k.to_s.tr(\"_\", \"-\")}: #{v};\" }.join(\" \") : @style.to_s.strip\n theirs = \"#{theirs};\" unless theirs.empty? || theirs.end_with?(\";\")\n [own, theirs].reject(&:empty?).join(\" \")\n end\n\n def default_attrs\n data = {slot: \"code-block-content\"}\n data[:wrap] = true if @wrap\n data[:numbered] = true if @line_numbers\n {\n dir: \"ltr\",\n tabindex: 0,\n style: style,\n data: data,\n class: [\n \"min-h-0 min-w-0 scroll-fade-x scrollbar-thin overflow-auto px-4 py-4 text-start font-mono leading-relaxed outline-none\",\n \"data-wrap:scroll-fade-y data-wrap:wrap-break-word data-wrap:whitespace-pre-wrap\",\n \"group-has-data-[slot=code-block-header]/code-block:pt-2 group-has-data-[slot=code-block-footer]/code-block:pb-2\",\n \"group-data-[size=sm]/code-block:px-3 group-data-[size=sm]/code-block:py-3\",\n \"group-data-[size=sm]/code-block:group-has-data-[slot=code-block-header]/code-block:pt-1.5\"\n ]\n }\n end\n end\nend\n"
},
{
"path": "codeblock_controller.js",
- "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"ruby-ui--codeblock\"\n// Copies a CodeblockCopyButton's data-value (or the block's code), then shows\n// the copied state for two seconds: data-copied, the copied label as\n// aria-label and in the live region next to the button.\nexport default class extends Controller {\n static RESET_AFTER = 2000;\n\n connect() {\n this.timers = new Map();\n // A cached page (Turbo snapshot) can come back mid-feedback: start clean.\n this.buttons().forEach((button) => this.reset(button));\n }\n\n disconnect() {\n this.timers.forEach((timer, button) => {\n clearTimeout(timer);\n this.reset(button);\n });\n this.timers.clear();\n }\n\n copy(event) {\n const button = event.currentTarget;\n const value = button.dataset.value ?? this.code();\n\n // No Clipboard API outside a secure context (plain http): report it as a failure.\n if (!navigator.clipboard?.writeText) {\n this.show(button, button.dataset.errorLabel, false);\n return;\n }\n\n navigator.clipboard\n .writeText(value)\n .then(() => this.show(button, button.dataset.copiedLabel, true))\n .catch(() => this.show(button, button.dataset.errorLabel, false));\n }\n\n code() {\n return this.element.querySelector(\"[data-slot=code-block-code]\")?.innerText ?? \"\";\n }\n\n show(button, label, copied) {\n clearTimeout(this.timers.get(button));\n if (copied) {\n button.dataset.copied = \"true\";\n } else {\n delete button.dataset.copied;\n }\n button.setAttribute(\"aria-label\", label);\n this.status(button, label);\n\n this.timers.set(button, setTimeout(() => {\n this.timers.delete(button);\n this.reset(button);\n }, this.constructor.RESET_AFTER));\n }\n\n reset(button) {\n delete button.dataset.copied;\n button.setAttribute(\"aria-label\", button.dataset.label);\n this.status(button, \"\");\n }\n\n buttons() {\n return this.element.querySelectorAll(\"[data-slot=code-block-copy-button]\");\n }\n\n status(button, text) {\n const status = button.nextElementSibling;\n if (status?.dataset.slot === \"code-block-copy-status\") status.textContent = text;\n }\n}\n"
+ "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"ruby-ui--codeblock\"\n// Copies a CodeblockCopyButton's data-value (or the block's code), then shows\n// the copied state for two seconds: data-copied, the copied label as\n// aria-label and in the live region next to the button. A failed copy shows\n// nothing, as in shadcn.\nexport default class extends Controller {\n static RESET_AFTER = 2000;\n\n connect() {\n this.timers = new Map();\n // A cached page (Turbo snapshot) can come back mid-feedback: start clean.\n this.buttons().forEach((button) => this.reset(button));\n }\n\n disconnect() {\n this.timers.forEach((timer, button) => {\n clearTimeout(timer);\n this.reset(button);\n });\n this.timers.clear();\n }\n\n copy(event) {\n const button = event.currentTarget;\n const value = button.dataset.value ?? this.code();\n\n // No Clipboard API outside a secure context (plain http).\n if (!navigator.clipboard?.writeText) return;\n\n navigator.clipboard\n .writeText(value)\n .then(() => this.show(button))\n .catch(() => {});\n }\n\n code() {\n return this.element.querySelector(\"[data-slot=code-block-code]\")?.innerText ?? \"\";\n }\n\n show(button) {\n const label = button.dataset.copiedLabel;\n clearTimeout(this.timers.get(button));\n button.dataset.copied = \"true\";\n button.setAttribute(\"aria-label\", label);\n this.status(button, label);\n\n this.timers.set(button, setTimeout(() => {\n this.timers.delete(button);\n this.reset(button);\n }, this.constructor.RESET_AFTER));\n }\n\n reset(button) {\n delete button.dataset.copied;\n button.setAttribute(\"aria-label\", button.dataset.label);\n this.status(button, \"\");\n }\n\n buttons() {\n return this.element.querySelectorAll(\"[data-slot=code-block-copy-button]\");\n }\n\n status(button, text) {\n const status = button.nextElementSibling;\n if (status?.dataset.slot === \"code-block-copy-status\") status.textContent = text;\n }\n}\n"
},
{
"path": "codeblock_copy_button.rb",
- "content": "# frozen_string_literal: true\n\nmodule RubyUI\n # Copies value: (or, without it, the block's code) to the clipboard. After\n # copying, the icon turns into a check and the label into copied_label for\n # two seconds; a polite live region announces it. Needs a Codeblock around\n # it (its Stimulus controller lives on the root).\n class CodeblockCopyButton < Base\n def initialize(value: nil, label: \"Copy\", copied_label: \"Copied\", error_label: \"Copy failed\",\n variant: :ghost, size: :xs, **attrs)\n @value = value\n @label = label\n @copied_label = copied_label\n @error_label = error_label\n @variant = variant\n @size = size\n super(**attrs)\n end\n\n def view_template\n button = Button.new(variant: @variant, size: @size, icon: true, **attrs)\n # mix joins strings, so the Button's own slot would read \"button code-block-copy-button\".\n button.attrs[:data][:slot] = \"code-block-copy-button\"\n render button do\n copy_icon\n check_icon\n end\n span(aria_live: \"polite\", class: \"sr-only\", data: {slot: \"code-block-copy-status\"})\n end\n\n private\n\n def default_attrs\n data = {\n slot: \"code-block-copy-button\",\n action: \"ruby-ui--codeblock#copy\",\n label: @label,\n copied_label: @copied_label,\n error_label: @error_label\n }\n data[:value] = @value unless @value.nil?\n {aria: {label: @label}, data: data}\n end\n\n # lucide copy and check, as in shadcn; the button's data-copied picks one.\n def copy_icon\n icon(class: \"group-data-copied/button:hidden\") do |s|\n s.rect(width: \"14\", height: \"14\", x: \"8\", y: \"8\", rx: \"2\", ry: \"2\")\n s.path(d: \"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\")\n end\n end\n\n def check_icon\n icon(class: \"hidden group-data-copied/button:block\") do |s|\n s.path(d: \"M20 6 9 17l-5-5\")\n end\n end\n\n def icon(**, &)\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n width: \"24\",\n height: \"24\",\n viewbox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n stroke_width: \"2\",\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n aria_hidden: \"true\",\n **, &\n )\n end\n end\nend\n"
+ "content": "# frozen_string_literal: true\n\nmodule RubyUI\n # Copies value: (or, without it, the block's code) to the clipboard. After\n # copying, the icon turns into a check and the label into copied_label for\n # two seconds; a polite live region announces it. A failed copy shows\n # nothing, as in shadcn. Needs a Codeblock around it (its Stimulus\n # controller lives on the root).\n class CodeblockCopyButton < Base\n # error_label: is deprecated and ignored: a failed copy shows nothing.\n def initialize(value: nil, label: \"Copy\", copied_label: \"Copied\", error_label: nil,\n variant: :ghost, size: :xs, **attrs)\n unless error_label.nil?\n Warning.warn(\"RubyUI::CodeblockCopyButton: `error_label:` is deprecated and ignored \" \\\n \"(a failed copy shows nothing, as in shadcn)\\n\", category: :deprecated)\n end\n @value = value\n @label = label\n @copied_label = copied_label\n @variant = variant\n @size = size\n super(**attrs)\n end\n\n def view_template\n button = Button.new(variant: @variant, size: @size, icon: true, **attrs)\n # mix joins strings, so the Button's own slot would read \"button code-block-copy-button\".\n button.attrs[:data][:slot] = \"code-block-copy-button\"\n render button do\n copy_icon\n check_icon\n end\n span(aria_live: \"polite\", class: \"sr-only\", data: {slot: \"code-block-copy-status\"})\n end\n\n private\n\n def default_attrs\n data = {\n slot: \"code-block-copy-button\",\n action: \"ruby-ui--codeblock#copy\",\n label: @label,\n copied_label: @copied_label\n }\n data[:value] = @value unless @value.nil?\n {aria: {label: @label}, data: data}\n end\n\n # lucide copy and check, as in shadcn; the button's data-copied picks one.\n def copy_icon\n icon(class: \"group-data-copied/button:hidden\") do |s|\n s.rect(width: \"14\", height: \"14\", x: \"8\", y: \"8\", rx: \"2\", ry: \"2\")\n s.path(d: \"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\")\n end\n end\n\n def check_icon\n icon(class: \"hidden group-data-copied/button:block\") do |s|\n s.path(d: \"M20 6 9 17l-5-5\")\n end\n end\n\n def icon(**, &)\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n width: \"24\",\n height: \"24\",\n viewbox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n stroke_width: \"2\",\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n aria_hidden: \"true\",\n **, &\n )\n end\n end\nend\n"
},
{
"path": "codeblock_footer.rb",