-
-
Notifications
You must be signed in to change notification settings - Fork 8
feat: updated the install button #103
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1f05d4d
6aa7c85
784c6c0
1984181
4462d01
1604351
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,361 @@ | ||
| --- | ||
| import { codeToTokens } from 'shiki'; | ||
|
|
||
| interface InstallOption { | ||
| label: string; | ||
| command: string; | ||
| } | ||
|
|
||
| interface Props { | ||
| options: InstallOption[]; | ||
| defaultIndex?: number; | ||
| } | ||
|
|
||
| const { options, defaultIndex = 0 } = Astro.props; | ||
|
|
||
| /** | ||
| * Highlights a shell command using Shiki and returns inline HTML. | ||
| */ | ||
| async function highlightCommand(cmd: string): Promise<string> { | ||
| const { tokens } = await codeToTokens(cmd, { | ||
| lang: 'bash', | ||
| theme: 'github-dark', | ||
| }); | ||
|
|
||
| // Convert tokens to inline HTML spans (single line command) | ||
| return tokens[0] | ||
| .map(token => { | ||
| const escaped = token.content | ||
| .replace(/&/g, '&') | ||
| .replace(/</g, '<') | ||
| .replace(/>/g, '>'); | ||
| return `<span style="color: ${token.color}">${escaped}</span>`; | ||
| }) | ||
| .join(''); | ||
| } | ||
|
|
||
| // Pre-highlight all options server-side for client-side switching | ||
| const highlightedOptions = await Promise.all( | ||
| options.map(async (opt) => ({ | ||
| label: opt.label, | ||
| command: opt.command, | ||
| highlighted: await highlightCommand(opt.command), | ||
| })) | ||
| ); | ||
|
|
||
| const defaultOption = highlightedOptions[defaultIndex]; | ||
| --- | ||
|
|
||
| <div class="install-selector"> | ||
| <div class="install-box"> | ||
| <button class="dropdown-trigger" aria-expanded="false" aria-haspopup="listbox"> | ||
| <span class="dropdown-label">{defaultOption.label}</span> | ||
| <svg class="chevron" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | ||
| <path d="m6 9 6 6 6-6"/> | ||
| </svg> | ||
| </button> | ||
|
|
||
| <div class="command-area" style={{marginTop: 0}}> | ||
| <code class="command-text" style={{backgroundColor: 'transparent'}} data-command={defaultOption.command} set:html={defaultOption.highlighted} /> | ||
| <button class="copy-btn" data-command={defaultOption.command} aria-label="Copy to clipboard"> | ||
| <svg class="copy-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | ||
| <rect width="14" height="14" x="8" y="8" rx="2" ry="2"/> | ||
| <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/> | ||
| </svg> | ||
| <svg class="check-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | ||
| <path d="M20 6 9 17l-5-5"/> | ||
| </svg> | ||
| </button> | ||
| </div> | ||
|
|
||
| <ul class="dropdown-menu" role="listbox" aria-label="Installation method"> | ||
| {highlightedOptions.map((opt, index) => ( | ||
| <li> | ||
| <button | ||
| class={`dropdown-option ${index === defaultIndex ? 'selected' : ''}`} | ||
| data-label={opt.label} | ||
| data-command={opt.command} | ||
| data-highlighted={opt.highlighted} | ||
| role="option" | ||
| aria-selected={index === defaultIndex ? 'true' : 'false'} | ||
| > | ||
| {opt.label} | ||
| </button> | ||
| </li> | ||
| ))} | ||
| </ul> | ||
| </div> | ||
| </div> | ||
|
|
||
| <style> | ||
| .install-selector { | ||
| position: relative; | ||
| width: fit-content; | ||
| } | ||
|
|
||
| /* Unified container with shared border */ | ||
| .install-box { | ||
| display: flex; | ||
| align-items: stretch; | ||
| position: relative; | ||
| background: rgba(255, 255, 255, 0.05); | ||
| border: 1px solid rgba(255, 255, 255, 0.1); | ||
| border-radius: 9999px; | ||
| } | ||
|
|
||
| .dropdown-trigger { | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 0.5rem; | ||
| background: rgba(255, 255, 255, 0.04); | ||
| border: none; | ||
| border-radius: 9999px 0 0 9999px; | ||
| padding: 0.6rem 1rem; | ||
| font-family: inherit; | ||
| font-size: 0.9rem; | ||
| font-weight: 500; | ||
| color: rgba(255, 255, 255, 0.7); | ||
| cursor: pointer; | ||
| transition: background-color 0.2s ease; | ||
| white-space: nowrap; | ||
| position: relative; | ||
| z-index: 1; | ||
| } | ||
|
|
||
| .dropdown-trigger:hover { | ||
| background: rgba(255, 255, 255, 0.08); | ||
| } | ||
|
|
||
| .chevron { | ||
| transition: transform 0.2s ease; | ||
| opacity: 0.6; | ||
| } | ||
|
|
||
| .install-box.open .chevron { | ||
| transform: rotate(180deg); | ||
| } | ||
|
|
||
| .command-area { | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 0.75rem; | ||
| background: transparent; | ||
| border: none; | ||
| padding: 0.6rem 1rem; | ||
| font-family: 'JetBrains Mono', monospace; | ||
| font-size: 0.875rem; | ||
| line-height: 1; | ||
| } | ||
|
|
||
| .command-text { | ||
| white-space: nowrap; | ||
| background: transparent; | ||
| min-width: 47ch; /* Fixed width for longest command (curl) */ | ||
| } | ||
|
|
||
| /* Remove any background from Shiki-generated spans */ | ||
| .command-text :global(span) { | ||
| background: transparent !important; | ||
| } | ||
|
|
||
| .copy-btn { | ||
| background: none; | ||
| border: none; | ||
| color: rgba(255, 255, 255, 0.4); | ||
| cursor: pointer; | ||
| padding: 0; | ||
| transition: color 0.2s ease; | ||
| flex-shrink: 0; | ||
| width: 16px; | ||
| height: 16px; | ||
| display: grid; | ||
| place-items: center; | ||
| } | ||
|
|
||
| .copy-btn:hover { | ||
| color: #fff; | ||
| } | ||
|
|
||
| .copy-btn .copy-icon, | ||
| .copy-btn .check-icon { | ||
| grid-area: 1 / 1; | ||
| pointer-events: none; | ||
| transition: opacity 0.15s ease; | ||
| } | ||
|
|
||
| .copy-btn .check-icon { | ||
| opacity: 0; | ||
| color: #22c55e; | ||
| transform: translateY(-6px); | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Check icon is vertically misaligned when shownLow Severity The |
||
|
|
||
| .copy-btn.copied .copy-icon { | ||
| opacity: 0; | ||
| } | ||
|
|
||
| .copy-btn.copied .check-icon { | ||
| opacity: 1; | ||
| } | ||
|
|
||
| .dropdown-menu { | ||
| position: absolute; | ||
| top: 90%; | ||
| left: 0; | ||
| background: #1a1a1f; | ||
| border: 1px solid rgba(255, 255, 255, 0.1); | ||
| border-radius: 0.75rem; | ||
| padding: 0.5rem !important; | ||
| list-style: none; | ||
| margin: 0; | ||
| box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5); | ||
| opacity: 0; | ||
| visibility: hidden; | ||
| transform: translateY(-8px); | ||
| transition: all 0.2s ease; | ||
| z-index: 100; | ||
| min-width: 120px; | ||
| } | ||
|
|
||
| .install-box.open .dropdown-menu { | ||
| opacity: 1; | ||
| visibility: visible; | ||
| transform: translateY(0); | ||
| } | ||
|
|
||
| .dropdown-menu li { | ||
| margin: 0; | ||
| padding: 0; | ||
| } | ||
|
|
||
| .dropdown-option { | ||
| width: 100%; | ||
| margin-bottom: 0 !important; | ||
| display: block; | ||
| padding: 0.5rem 0.75rem; | ||
| background: none; | ||
| border: none; | ||
| border-radius: 0.5rem; | ||
| font-family: inherit; | ||
| font-size: 0.875rem; | ||
| color: rgba(255, 255, 255, 0.8); | ||
| cursor: pointer; | ||
| text-align: left; | ||
| transition: background-color 0.15s ease; | ||
| } | ||
|
|
||
| .dropdown-option:hover { | ||
| background: rgba(255, 255, 255, 0.08); | ||
| } | ||
|
|
||
| .dropdown-option.selected { | ||
| background: rgba(255, 255, 255, 0.1); | ||
| font-weight: 500; | ||
| } | ||
| </style> | ||
|
|
||
| <script is:inline> | ||
| (function() { | ||
| function initInstallSelector() { | ||
| document.querySelectorAll('.install-selector').forEach(function(selector) { | ||
| const installBox = selector.querySelector('.install-box'); | ||
| const trigger = selector.querySelector('.dropdown-trigger'); | ||
| const label = selector.querySelector('.dropdown-label'); | ||
| const commandText = selector.querySelector('.command-text'); | ||
| const copyBtn = selector.querySelector('.copy-btn'); | ||
| const options = selector.querySelectorAll('.dropdown-option'); | ||
|
|
||
| if (!trigger || !installBox) return; | ||
|
|
||
| // Toggle dropdown | ||
| trigger.addEventListener('click', function(e) { | ||
| e.stopPropagation(); | ||
| const isOpen = installBox.classList.contains('open'); | ||
|
|
||
| // Close all other dropdowns | ||
| document.querySelectorAll('.install-box.open').forEach(function(box) { | ||
| if (box !== installBox) box.classList.remove('open'); | ||
| }); | ||
|
|
||
| installBox.classList.toggle('open', !isOpen); | ||
| trigger.setAttribute('aria-expanded', (!isOpen).toString()); | ||
| }); | ||
|
|
||
| // Handle option selection | ||
| options.forEach(function(option) { | ||
| option.addEventListener('click', function() { | ||
| const newLabel = option.getAttribute('data-label'); | ||
| const newCommand = option.getAttribute('data-command'); | ||
| const newHighlighted = option.getAttribute('data-highlighted'); | ||
|
|
||
| if (label && newLabel) label.textContent = newLabel; | ||
| if (commandText && newHighlighted) { | ||
| commandText.innerHTML = newHighlighted; | ||
| } | ||
| if (commandText && newCommand) { | ||
| commandText.setAttribute('data-command', newCommand); | ||
| } | ||
| if (copyBtn && newCommand) { | ||
| copyBtn.setAttribute('data-command', newCommand); | ||
| } | ||
|
|
||
| // Update selected state | ||
| options.forEach(function(opt) { | ||
| opt.classList.remove('selected'); | ||
| opt.setAttribute('aria-selected', 'false'); | ||
| }); | ||
| option.classList.add('selected'); | ||
| option.setAttribute('aria-selected', 'true'); | ||
|
|
||
| // Close dropdown | ||
| installBox.classList.remove('open'); | ||
| trigger.setAttribute('aria-expanded', 'false'); | ||
| }); | ||
| }); | ||
|
|
||
| // Copy functionality | ||
| if (copyBtn) { | ||
| copyBtn.addEventListener('click', async function() { | ||
| const command = copyBtn.getAttribute('data-command'); | ||
| if (command) { | ||
| await navigator.clipboard.writeText(command); | ||
| copyBtn.classList.add('copied'); | ||
| setTimeout(function() { copyBtn.classList.remove('copied'); }, 2000); | ||
| } | ||
| }); | ||
| } | ||
| }); | ||
|
|
||
| // Close dropdown when clicking outside | ||
| document.addEventListener('click', function(e) { | ||
| if (!e.target.closest('.install-selector')) { | ||
| document.querySelectorAll('.install-box.open').forEach(function(box) { | ||
| box.classList.remove('open'); | ||
| const trigger = box.querySelector('.dropdown-trigger'); | ||
| if (trigger) trigger.setAttribute('aria-expanded', 'false'); | ||
| }); | ||
| } | ||
| }); | ||
|
|
||
| // Close on escape | ||
| document.addEventListener('keydown', function(e) { | ||
| if (e.key === 'Escape') { | ||
| document.querySelectorAll('.install-box.open').forEach(function(box) { | ||
| box.classList.remove('open'); | ||
| const trigger = box.querySelector('.dropdown-trigger'); | ||
| if (trigger) trigger.setAttribute('aria-expanded', 'false'); | ||
| }); | ||
| } | ||
| }); | ||
|
betegon marked this conversation as resolved.
|
||
| } | ||
|
|
||
| // Run when DOM is ready | ||
| if (document.readyState === 'loading') { | ||
| document.addEventListener('DOMContentLoaded', initInstallSelector); | ||
| } else { | ||
| initInstallSelector(); | ||
| } | ||
|
|
||
| // Re-run after Astro page transitions | ||
| document.addEventListener('astro:after-swap', initInstallSelector); | ||
| })(); | ||
| </script> | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: The component accesses
highlightedOptions[defaultIndex]without bounds checking, which can cause a build-time crash if an out-of-boundsdefaultIndexprop is provided.Severity: MEDIUM
Suggested Fix
Add a bounds check before accessing
highlightedOptions[defaultIndex]. Validate thatdefaultIndexis a valid index for the array (e.g.,defaultIndex >= 0 && defaultIndex < highlightedOptions.length). If the index is invalid, either fall back to a safe default like0or throw a more informative error to guide the developer.Prompt for AI Agent
Did we get this right? 👍 / 👎 to inform future reviews.